Skip to content

feat(design)!: customizable theming via a BrandTheme façade#377

Merged
91jaeminjo merged 19 commits into
mainfrom
feat/customizable-design-system
Jun 25, 2026
Merged

feat(design)!: customizable theming via a BrandTheme façade#377
91jaeminjo merged 19 commits into
mainfrom
feat/customizable-design-system

Conversation

@91jaeminjo

@91jaeminjo 91jaeminjo commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator

Summary

Makes the Soliplex theme customizable by whitelabel forks behind a stable public contract, without exposing the internal token system.

A public, nested BrandTheme façade of plain Flutter types (no JSON) is the customization contract. A private lowering step — lowerBrandTheme(BrandTheme, Brightness) → ThemeData — maps it onto the internal soliplex_design tokens, which stay free to refactor behind it. Colors flip per-brightness; typography and shape are shared. Fonts resolve through a pluggable FontResolver (bundled default; google_fonts stays consumer-side, so soliplex_design takes no new dependency). App identity (AppIdentity) and ClassificationTheme are 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: the info/warning filled status pills (SoliplexBadge/SoliplexChip) now read the new infoContainer/warningContainer token pairs — a soft container surface with an AA-legible on-color, matching the existing danger/success pills — 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

BrandTheme.soliplex() / .fromSeed(seed) / .fromAccents(light:, dark:) / full ctor
BrandColorScheme   // 7 required roles + optional tertiary/status/on* (WCAG fallback)
BrandTypography + TypeScaleOverride   // body/display/code families + per-role deltas
BrandShape.rounded()/.square()/.custom()
FontResolver / BundledFontResolver / ResolvedFont
AppIdentity        // app name + logos (BrandLogo)
standard({ AppIdentity? identity, BrandTheme theme = const BrandTheme.soliplex(),
           ClassificationTheme?, FontResolver fontResolver, ... })

⚠️ Breaking change (whitelabel forks)

  • SoliplexBrandingAppIdentity (name + logos) + BrandTheme (visual theme); standard(branding:)standard(identity:, theme:).
  • SymbolicColors moved from an extension on ColorScheme to one on BuildContext (colorScheme.dangercontext.danger).
  • Migration: 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)

  1. Façade types + FontResolver seam
  2. Internal token prep — status slots, typography widening, radii path (defaults preserved)
  3. Lowering layer + WCAG contrast helper + debug assert
  4. Status-color bug fix — danger/success/warning/info route through tokens, not hardcoded Colors.*
  5. Corner radii routed through the active theme (context.radii)
  6. Identity/theme split + wiring (breaking)
  7. Docs + design_system/ reference-bundle sync + CHANGELOG

Verification

  • App test suite +1784 passing; soliplex_design package logic tests passing.
  • flutter analyze clean (app + package); markdownlint clean.
  • Default theme proven byte-identical (37-field SoliplexColors compare in both brightnesses + value asserts).
  • Component golden tests are CI-authoritative (Linux). On macOS they show ~1% font anti-aliasing diffs that are not regressions. The badge/chip baselines were regenerated on the Linux render to reflect the container-based info/warning pills; 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-tested lowerBrandTheme. The app was not booted manually.
  • Consumer forks need their migration landed in the same release (the release skill propagates).

🤖 Generated with Claude Code

91jaeminjo and others added 7 commits June 23, 2026 16:28
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>
91jaeminjo and others added 12 commits June 24, 2026 11:44
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>
@91jaeminjo 91jaeminjo merged commit f05371a into main Jun 25, 2026
6 checks passed
@91jaeminjo 91jaeminjo deleted the feat/customizable-design-system branch June 25, 2026 05:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants