ADR-003: Reify the Flavor — a Declaration Object Between Composition and Boot
- Status: Proposed
- Date: 2026-07-16
- Authors: William Karol Di Cioccio
- Supersedes: —
- Amends: ADR-002 §2 (barrel-boundary change shipped in #426) and §3.9/§5.2 (link contrast policy)
- Superseded by: —
1. Context and Problem Statement
Issue #418 and its follow-up #421 pushed us to open the theming system to
forks that need full token control. PR #426 delivered the agreed mechanism —
buildStandardKit (the composition kit), the direct
buildSoliplexThemeData path re-exported through the frontend barrel, and a
boot-time guard for the SoliplexTheme extension. Each piece is sound. But
stacking them exposed that the layer they all lean on was never designed —
it accreted. This ADR records the current shape, names its problems, and
proposes the missing concept before #426's surface becomes API that forks
depend on.
1.1 The hierarchy as shipped (post-#426)
TOKENS SoliplexColors (41 slots) · SoliplexRadii · typography
│
THEME buildSoliplexThemeData(colors, brightness) ─► ThemeData
▲ + SoliplexTheme ext
│
BRAND BrandTheme ── lowerBrandTheme() ──┘ (curated path, ADR-002)
IDENTITY AppIdentity (appName, logos, glow)
FEATURES AppModule ── build() ─► ModuleRoutes (routes, overrides, redirect)
COMPOSITION buildStandardModules(identity, backend knobs) ← new in #426
─► StandardModules record:
(modules, refreshListenable, initialRoute,
inactivity, serverManager)
"FLAVOR" standard() — a bare Future<ShellConfig> function
BOOT ShellConfig.fromModules(…) — flattens routes/overrides/redirects,
guards the theme extension, owns dispose
│
SHELL runSoliplexShell(config) ─► ProviderScope + MaterialApp.router
The theme column is clean: two public entry points (curated BrandTheme,
direct SoliplexColors) converging on one funnel that attaches the extension
and runs the contrast check. The problems are all in the two rows between
COMPOSITION and BOOT.
1.2 The problems
-
"Flavor" is a calling convention, not a thing. A flavor is whatever a
Future<ShellConfig>function happens to do. The assembly ritual — pick an identity, lower the brand twice, threadidentity.appNameinto a string slot, forward four kit fields intoShellConfig.fromModules— is copy-pasted into every author's function.docs/authoring-a-flavor.mddocuments the ritual honestly, and that is the tell: five lines of pure plumbing that every fork must transcribe, each line failable. ForgetrefreshListenableand auth-driven redirects silently stop re-evaluating; forgetinitialRouteand the OAuth callback breaks. -
StandardModulesis the missing concept's torso, anonymized. A five-field typedef record with no behavior: "a flavor minus identity minus theme," as loose values.serverManager— an escape hatch for custom modules — sits at the same level as boot-critical fields. -
ShellConfigis two things wearing one name. Simultaneously the declaration of an app (name, themes, mode, modules in) and the lowered boot artifact (routes/overrides/redirects flattened, dispose closure out).fromModulesis not a constructor; it is a compiler. Because the declaration has no home of its own, every upstream layer pours parameters into it. -
Field duplication across layers, resolved by hand.
appNamelives onAppIdentityandShellConfig, threaded viaidentity.appNameat every call site.InactivityConfigis built insidebuildStandardKitfrom twoDurationknobs yet is also aShellConfigfield with its own default.themeModerides onShellConfignext to already-loweredThemeData. -
#426's theme guard landed at the wrong layer — because the right layer does not exist.
ShellConfig.fromModules(core, module composition) now importssoliplex_designto check for theSoliplexThemeextension. Failing at boot instead of in the first branded widget is correct; but "is this theme valid" is a theme-boundary rule that ended up in the module coordinator because that is the only chokepoint the current shape offers. -
Even the directory names disagree.
src/composition/andsrc/flavors/are two names for the same missing idea — composition is flavor-making — and theflavors.dartbarrel exports a function plus a kit, a grab-bag shape.
1.3 Amendment to ADR-002, recorded
ADR-002 §2 fixed the soliplex_frontend barrel as the privacy boundary
("re-exports only the façade types"), and §8 rejected "Expose
SoliplexColors directly to forks" outright. The #418/#421 discussion
deliberately revised that call — a fork whose brand needs slots the façade
does not carry has no other path, and the curated contract remains the
default — and #426 implemented it by re-exporting SoliplexColors,
buildSoliplexThemeData, and the default palettes from the barrel. This ADR
records that amendment so ADR-002 does not read as silently violated:
the barrel now carries two tiers — the curated façade (unchanged, still
recommended) and the full-control tier (explicitly opt-in, welded to token
names by choice). The §8 rejection stands for the default path only.
This PR makes a second, smaller amendment to ADR-002, recorded here for the
same reason: the link contrast check now runs on the resolved link for every
theme (both tiers), so it warns on a default link over a fork-overridden
background — reversing ADR-002 §3.9/§5.2's "checked only when the brand sets
link" policy. The rationale and the amended wording live at ADR-002 §3.9.
2. Decision
Introduce a Flavor declaration — the complete declaration of an app
variant: who it is (AppIdentity), how it looks (FlavorTheme), what it does
(List<AppModule>), and how it boots (route, refresh, inactivity) — with a
single build() that owns the lowering ritual once:
AppIdentity + FlavorTheme + modules + boot knobs
└───────────────── Flavor ─────────────────┘
│ build() ← owns: appName threading, brand lowering,
▼ kit-field forwarding
ShellConfig ← narrows toward a pure boot artifact
▼
runSoliplexShell
FlavorTheme is the theme half: one slot wrapping the two public theming
paths (FlavorTheme.brand(BrandTheme, …), lowered at build; or
FlavorTheme.themeData(light:, dark:) for full token control), and it owns
themeMode — brightness policy travels with the themes it selects between.
standard() keeps its exact signature and becomes a thin wrapper:
standardFlavor(...) builds the standard Flavor; standard() is
(await standardFlavor(…)).build(). A fork customizes through
standardFlavor's parameters, not by re-implementing assembly:
// Full color control + an extra module — no hand-carried kit fields.
final flavor = await standardFlavor(
identity: myIdentity,
theme: FlavorTheme.themeData(light: myLight, dark: myDark),
extraModules: (kit) => [MyModule(kit.serverManager)],
);
runSoliplexShell(flavor.build());
3. The Decisions, by Axis
3.1 Flavor is a single-use assembly declaration, built once
Flavors are configuration: cheap to construct, inert until build(). A builder
or subclass-per-flavor invites state and ordering concerns the domain does not
have. A flavor holds live, build-once module instances, so it is not a value to
copy and rebuild — it is a declaration you assemble once via build().
Customization is a parameter of standardFlavor (identity, theme,
extraModules), not a mutation of a built flavor. Deep forks that want a named
flavor still just write a function — one that returns a Flavor, so the
assembly ritual stays owned by build().
3.2 FlavorTheme holds the theme source, lowered at build time
Holding BrandTheme (not pre-lowered ThemeData) keeps the theme slot
synchronous to construct and const-able, defers the lowering cost until it
is needed exactly once, and gives build() a single place to guarantee both
brightnesses are lowered with the same resolver and classifications — a pair
of calls every flavor function currently duplicates. The themeData variant
exists because the full-control tier (§1.3) hands us ThemeData directly.
3.3 standardFlavor() and the extraModules seam
standardFlavor converts the composition kit into a Flavor, absorbing the
kit-field forwarding that docs/authoring-a-flavor.md currently asks authors
to transcribe. Custom modules usually need shared state (serverManager), so
the seam is a callback receiving the kit —
List<AppModule> Function(StandardKit kit) — rather than a plain list.
buildStandardKit stays public for compositions that diverge further.
3.4 standard() is compatibility-frozen
Signature unchanged; implementation delegates. Existing callers (including
main.dart) compile and behave identically. This is also the correctness
proof: the entire shipped app boots through the new path.
3.5 ShellConfig narrows but stays public, for now
With Flavor.build() as the blessed assembly point, ShellConfig should
tend toward an internal boot artifact. It stays public in this change —
runSoliplexShell takes it, deep embedders construct it, and #426's doc
teaches it — but new capability should land on Flavor, not on
ShellConfig.fromModules's parameter list.
3.6 The theme guard's eventual home
The SoliplexTheme-extension guard stays in ShellConfig.fromModules in
this change (moving it would alter #426's semantics for direct callers). Its
natural home is the theme boundary — FlavorTheme.themeData can validate at
construction, failing even earlier with the same message. Deferred until the
Flavor shape is accepted.
4. What Does Not Change
- The two theming tiers and their semantics (curated
BrandThemepath, directSoliplexColorspath), including #426's contrast check and guard. - The composition kit's role — it remains the module-graph layer;
Flavorsits on top of it, not instead of it. (This change renames #426'sStandardModules/buildStandardModulestoStandardKit/buildStandardKitand relocates it besidestandard.dart; §6.) runSoliplexShell(ShellConfig)and the shell widget tree.- The shipped Soliplex app's behavior, byte for byte.
5. Known Limitations and Open Questions
- Naming — resolved.
Flavorcollides with the informal use of "flavor" for the functions themselves, and the alternatives (FlavorSpec,AppVariant,SoliplexApp;FlavorTheme→ThemeSource) were weighed and declined. This change shipsFlavor/FlavorThemeas the names — they now appear in the CHANGELOG,CLAUDE.md, and the authoring guide as public API, so the question is settled by adoption, not left open for a rename after the names are in a release. The kit half is likewise settled: this change renames #426'sStandardModules/buildStandardModulesrecord and builder toStandardKit/buildStandardKit, aligning the type names with the "kit" vocabulary. appNameduplication survives (§1.2 #4):Flavor.build()threadsidentity.appNameintoShellConfig.appName, hiding the duplication rather than removing it. Removing it meansShellConfigtakingAppIdentity— a breaking change deferred with §3.5.- The kit's
serverManagerescape hatch is still a concrete type on a record; if custom modules need more shared state, the record grows. A futureFlavorContextinterface may age better; not needed yet. - The catalog rides the main barrel while there is one flavor. A second
flavor with a distinct module graph is the trigger to split — but into
per-flavor entrypoints (
lib/standard.dart,lib/<fork>.dart) layered over the framework barrel, so a fork imports only the flavor it extends. That is not a return to theflavors.dartgrab-bag §1.2 #6 rejected. Not needed yet.
6. Consequences
Positive
- Flavor authoring drops from a transcribed ritual to a composed value; the failure modes in §1.2 #1 become unrepresentable.
- The public API gains the concept users already talk about ("the standard flavor", "your fork's flavor") as a type the IDE can show them.
ShellConfigstops accreting parameters; the guard and lowering logic get a single owner;themeModerejoins the theme.- ADR-002's amended boundary is written down (§1.3) instead of implicit in a PR diff.
- The
src/composition//src/flavors/split (§1.2 #6) collapses:buildStandardKitmoves besidestandard.dartas the standard flavor's two files — the module-graph kit and its assembly, kept separate by the same altitude argument that rejects folding them (§8). The main barrel now carries the whole flavor-authoring surface (the other half of §1.2 #6), so one import authors a flavor, with no separateflavors.dartbarrel.
Negative / cost
- One more public type to document and keep stable.
- Two blessed ways to reach
ShellConfigduring the transition (directfromModules, andFlavor.build()); mitigated by §3.5's "new capability lands onFlavor" rule. docs/authoring-a-flavor.mdand the CHANGELOG are rewritten around the new API in this change and must be kept in sync with it.
7. Migration
None breaking. standard() callers are untouched. #426's
buildStandardKit consumers may adopt standardFlavor incrementally.
8. Alternatives Considered
| Alternative | Why rejected |
|---|---|
| Keep the function convention (status quo) | The assembly ritual stays copy-pasted and failable in every fork; the concept keeps living in a doc instead of the type system. |
Fatten ShellConfig (add identity, brand, kit fields) |
Deepens the declaration/artifact conflation (§1.2 #3); every new axis lands as another fromModules parameter. |
A builder (FlavorBuilder…build()) |
Mutable staging for what is plain configuration; standardFlavor's parameters express derivation ("standard, but…") more directly, and a flavor holds live modules it builds once anyway. |
| Sealed class hierarchy for the theme source | Two variants do not justify pattern-matching ergonomics over two named constructors; revisit if a third source appears. |
Fold the kit into Flavor (kill StandardKit) |
The kit is genuinely a lower layer — module graph wiring — and #426 just extracted it cleanly; re-fusing it would undo that separation. |