feat(design)!: customizable theming via a BrandTheme façade#377
Merged
Conversation
Introduce the public theme-customization contract in soliplex_design: BrandTheme with a constructor ladder (soliplex/fromSeed/fromAccents), BrandColorScheme (seven required roles plus optional status and on-color slots, with fromAccent derivation), BrandTypography + TypeScaleOverride, and BrandShape. Add the FontResolver injection seam with a dependency-free BundledFontResolver that defers to native asset fonts. The types are exported but not yet consumed by theme construction, so app behavior is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add danger/success/warning/info slots to SoliplexColors, pinned in both palettes to the Material status colors previously hardcoded in SymbolicColors. Widen soliplexTextTheme to accept body/display font families, a fallback chain, a FontResolver, and per-role TypeScaleOverride deltas; with no overrides it reproduces the current type scale exactly. Thread a radii parameter through the theme factories so corner radii flow from a single SoliplexRadii into every component shape. Defaults are unchanged, so built themes stay byte-identical; no consumer reads the new capability yet. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add lower(BrandTheme, Brightness) -> ThemeData: the buffer that maps the public façade onto the internal token system. Façade roles override a neutral per-brightness base while surfaces stay brand-independent; unspecified on-colors get a WCAG-readable foreground, unspecified status colors fall back to the base. Typography flows through the resolver, codeFamily becomes the monospace token, and shape becomes the radii. Add a WCAG contrast helper (contrastRatio / readableOn) and a debug assert flagging hand-built on-color pairs below 4.5:1. The default BrandTheme.soliplex() lowers to today's SoliplexColors byte-for-byte, so the WCAG path never runs for the shipped look. Give the SoliplexTheme extension a monospace font token and route context.monospace through it, falling back to the platform family when the extension is absent. Reuse the theme builder via an internal buildSoliplexThemeData that accepts a prebuilt TextTheme. lower() is internal and not yet wired into theme construction. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SymbolicColors hardcoded the Material status palette (Colors.blue/orange/red/green), ignoring the token system so a whitelabel theme could not recolor status. Move it from an extension on ColorScheme, which cannot reach the SoliplexColors tokens, to one on BuildContext: context.danger/success/warning/info now read the active SoliplexTheme, falling back to the default palette for the current brightness when unthemed. Drop the unused isDarkMode getter. Read the info/warning tokens directly in the badge and chip intents, and update the app status call sites accordingly. Defaults are the same values as before, so the shipped look is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
App widgets read the global soliplexRadii constant directly, so a brand BrandShape override would not reach ad-hoc container radii. Add a context.radii accessor that resolves the active SoliplexTheme radii and falls back to the default scale when unthemed, and move every BorderRadius.circular(soliplexRadii.x) call site (and the classification badge) onto it. Defaults are unchanged, so the shipped look is identical. Add a hygiene test that fails on raw hex color literals or hardcoded Material status colors in app code, keeping the whitelabel contract's "all color comes from tokens" premise enforced. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace SoliplexBranding with two orthogonal configs: AppIdentity (app
name + logos) and BrandTheme (color/type/shape). standard() now takes
identity + theme + fontResolver and builds its ThemeData via
lowerBrandTheme(theme, brightness) instead of deriving from a branding
accent. Identity and visual theme vary independently.
- Rename core/branding.dart to core/app_identity.dart; AppIdentity +
BrandLogo(identity:).
- standard(): {AppIdentity? identity, BrandTheme theme =
const BrandTheme.soliplex(), FontResolver fontResolver,
ClassificationTheme?}. identity defaults to AppIdentity.soliplex, so
the runnable app is unchanged.
- Export lowerBrandTheme; re-export the BrandTheme façade + AppIdentity +
FontResolver from the frontend barrel so forks depend only on it.
- Thread AppIdentity through the lobby module, screen, and sidebar.
Defaults lower byte-for-byte to today's themes, so the shipped Soliplex
look is unchanged.
BREAKING CHANGE: consumer forks migrate SoliplexBranding(accentLight,
accentDark, ...) to AppIdentity(...) + BrandTheme.fromAccents(...), and
SymbolicColors moves from ColorScheme to BuildContext (colorScheme.danger
becomes context.danger). Updated in the same release.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ndle Update the design-system docs for the BrandTheme contract: rewrite the CLAUDE.md theming section, fix the status-color and radius accessors (context.danger / context.radii) in both CLAUDE.md and the soliplex_design README, and add a "Customizing the theme" section with the constructor ladder and the customizable-vs-fixed table. Add the danger/success/warning/info status tokens to the design_system reference bundle (tokens.dart/css/jsx + README). Rewrite the adopt-design-system skill from SoliplexBranding to AppIdentity + BrandTheme. Add CHANGELOG entries for the feature and the breaking fork migration. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
WilliamKarolDiCioccio
approved these changes
Jun 24, 2026
Add seven optional roles to BrandColorScheme, lifting existing internal tokens to the public façade: error/onError (the destructive role, lowered to colorScheme.error), errorContainer/onErrorContainer and successContainer/onSuccessContainer (the soft status surfaces), and link. Field names follow Material ColorScheme convention; danger stays the inline status signal and is documented as distinct from error. lowerBrandTheme maps each onto its slot, falling back to the base palette when unset (so the default palette stays byte-for-byte) and deriving an unset on-color via readableOn. Extend the debug contrast assert to the error and error/success-container pairs, and check link against the background since it has no on-color. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
readableOn now derives from pure black rather than the softer #0A0A0A foreground tone, so an auto-filled on-color clears WCAG AA 4.5:1 for any surface (worst case ≈4.58:1; #0A0A0A bottomed out at ≈4.45:1 for mid-tone surfaces). The contrast assert now takes the brand scheme and only checks link against the background when the brand sets link — overriding only the background no longer trips the assert on the default link the fork never customised. Tighten the link doc to state it is asserted against the background only, and de-duplicate the signal-vs-role prose so the banner comments carry the danger-vs-error distinction. Add tests for explicit container on-colors, equality across the optional roles, link gating, and the worst-case readableOn contrast. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Brand-supplied on-colors are used verbatim; a pair below the WCAG AA 4.5:1 threshold is reported through soliplex_logging rather than tripping a debug-only assert, so the contract holds in release builds. Unset on-colors still derive a WCAG-readable foreground, and fromAccent derives onPrimary through readableOn so a mid-tone seed clears AA. The check also covers the foreground/background pair. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Update the README, CHANGELOG, and brand dartdoc so they describe the verbatim-plus-warning contrast behaviour instead of the removed assert, and document the link-on-non-background and silent-font-fallback caveats. Surface the AppIdentity logoGlow/logoDark exclusivity with an assert, and note the SoliplexTheme.lerp color snap is intentional. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CI renders the golden snapshots on ubuntu; on macOS they always show ~1% text-edge font-rendering diffs that are not regressions. Document this at each golden test file and in the design-system guide so they are not regenerated off Linux. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Warn when mutedForeground/muted text falls below a 3:1 contrast floor (de-emphasized text, so held below the AA 4.5 bar the other roles use). - Assert BrandShape.custom radii are non-negative. - Route the danger badge/chip through the brand errorContainer tokens so all non-neutral intents read from SoliplexColors, matching success. - Document that an unbundled font family silently falls back at render. - Add the dark status colors to the CSS @media (prefers-color-scheme) block so prefers-dark surfaces stop inheriting the light status palette. - Move the classifications comment in standard() onto its parameter; sync the README and CHANGELOG contrast wording with the muted floor. - Cover danger/success badge and chip routing; drop a determinism-only ResolvedFont hashCode test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Route onTertiary through _onColorFor like the other optional on-colors, so an unset tertiary/onTertiary pair keeps the base on-color instead of re-deriving one via readableOn. This restores the byte-identical fallback for an untouched tertiary role (the base dark on-tertiary #1F1F1F was being overridden with #000000) and lets the contrast warning surface a sub-AA base pair rather than masking it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Route SoliplexBadge/SoliplexChip warning and info intents through the brand's warningContainer/infoContainer token pairs, matching danger and success, so a fork can rebrand all four status pills. Add the container token slots and lower their on-colors with a WCAG-readable fallback. Name the brightness in sub-AA contrast warnings and document that they surface only through an attached log sink. Assert non-negative TypeScaleOverride fontSize/height, matching BrandShape. Cover the onSecondary re-derivation and sub-AA warning paths. Rename the design brief off the ADR-002 number so the index carries one ADR-002. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
readableOn now resolves a softest-first cascade — near-black #212427 / near-white #FAFAFA, falling through #0A0A0A to pure black/white only when a mid-tone surface would drop the near-tone below AA — so derived on-colors stay AA-legible while reading easier than pure black/white. Add BrandTint (TintSource none|surface|primary + strength), an opt-in BrandTheme axis that tints auto-derived on-colors toward the surface or brand-primary hue; the default is none, so the shipped Soliplex look is unchanged. Unify on-color derivation on readableOn (SoliplexColors.fromAccent included) and defer BrandColorScheme.fromAccent's onPrimary to the lowering layer so the tint applies to seed-built brands. Docs: ADR-002 records the cascade, BrandTint, and the design brief; update the design README and CHANGELOG. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The readableOn comment claimed a "worst case ≈4.58:1," which reads as the floor on returned contrast. That figure is the minimum of the pure-tone endpoints (at the black/white crossover) and only guarantees the cascade always finds an AA-clearing tone; the returned tone itself is guaranteed only ≥4.5:1 and can sit right at the floor when a near-tone clears it. Reword to separate the two, and fix a mid-word line wrap. Note in lowerBrandTheme that the warn-but-keep handling of a fork's sub-AA color pair has no throwing/strict mode by design. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When a FontResolver returns no family, _lowerMonospace falls back to the declared codeFamily. Lock that branch with a resolver returning a null fontFamily so a regression to a non-null assertion or empty default is caught. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ills The info/warning filled pills now read the infoContainer/warningContainer token pairs instead of tinting the signal color at 15% alpha, so their gallery goldens changed. Regenerate the four badge/chip baselines on the Linux render via the update-goldens workflow. Correct the CHANGELOG: the theme lowering is byte-for-byte and the app's rendered screens are unchanged, but the info/warning filled-pill defaults are new. No app screen uses those intents, so the restyle is gallery-only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Makes the Soliplex theme customizable by whitelabel forks behind a stable public contract, without exposing the internal token system.
A public, nested
BrandThemefaçade of plain Flutter types (no JSON) is the customization contract. A private lowering step —lowerBrandTheme(BrandTheme, Brightness) → ThemeData— maps it onto the internalsoliplex_designtokens, which stay free to refactor behind it. Colors flip per-brightness; typography and shape are shared. Fonts resolve through a pluggableFontResolver(bundled default;google_fontsstays consumer-side, sosoliplex_designtakes no new dependency). App identity (AppIdentity) andClassificationThemeare separate configs. Spacing and breakpoints stay fixed.Default
BrandTheme.soliplex()lowers byte-for-byte to today's palette, and the app's rendered screens are unchanged. One deliberate exception: theinfo/warningfilled status pills (SoliplexBadge/SoliplexChip) now read the newinfoContainer/warningContainertoken pairs — a soft container surface with an AA-legible on-color, matching the existingdanger/successpills — instead of tinting the signal color at 15% alpha. No app screen uses those intents, so the restyle is visible only in the component gallery.Public surface
SoliplexBranding→AppIdentity(name + logos) +BrandTheme(visual theme);standard(branding:)→standard(identity:, theme:).SymbolicColorsmoved from an extension onColorSchemeto one onBuildContext(colorScheme.danger→context.danger).SoliplexBranding(accentLight: x, accentDark: y, appName:, logoLight:)→AppIdentity(appName:, logoLight:)+theme: BrandTheme.fromAccents(light: x, dark: y). Forks are updated in the same release.How it's built (7 commits, each green)
FontResolverseamdanger/success/warning/inforoute through tokens, not hardcodedColors.*context.radii)design_system/reference-bundle sync + CHANGELOGVerification
soliplex_designpackage logic tests passing.flutter analyzeclean (app + package);markdownlintclean.SoliplexColorscompare in both brightnesses + value asserts).badge/chipbaselines were regenerated on the Linux render to reflect the container-basedinfo/warningpills; all other component goldens are unchanged.Notes / follow-ups
standard()has no direct test (heavy async deps: secure storage, auth flow); it delegates theme construction to the fully-testedlowerBrandTheme. The app was not booted manually.releaseskill propagates).🤖 Generated with Claude Code