Use case
mix_chart ships real stylers — LineChartStyler, BarChartStyler,
PieChartStyler, plus shared sub-stylers such as ChartAxisStyler and
ChartTooltipStyler — that mix_protocol cannot encode at all:
ERR unsupported_encode_value at "": Expected box, got LineChartStyler.
ERR unsupported_encode_value at "": Expected text, got LineChartStyler.
... (one per branch)
The style union is closed: the branch map is hardcoded in the private factory
MixProtocol._builtIn(),
there is no public constructor, and the codec-authoring toolkit under
src/schema/ is not exported. Any package that defines stylers outside
packages/mix hits the same wall.
Concrete downstream need: Mix Atlas component capture records
protocol-encoded style evidence for every Fortal component so styles are
diffable across versions. 34 of Fortal's 38 widget families have that
evidence today; the 3 chart families cannot, purely because the protocol
cannot represent their stylers.
A sidecar protocol next door is not a viable workaround because chart stylers
embed core stylers —
ChartAxisStyler.label is a TextStyler
— and every branch codec receives rootStyleSchema so nested styles recurse
through the full discriminated union. Extension branches must join the same
union to express TextStyler inside LineChartStyler.
Proposal
Let packages contribute styler codecs as composable vocabularies, in two
steps. Step 1 is a pure internal refactor with identical wire behavior that
makes step 2 almost free.
Step 1 — make the field model the single source of truth.
The per-branch layer is already a clean declarative model: SchemaObject +
SchemaField declare each field once, and encode, decode, and
field-inventory checking derive from it. But
SchemaField.ackSchema widens every field to a bare AckSchema<Object, Object>,
erasing what the field knows about itself, so the contract file re-injects
that knowledge through hand-maintained tables:
Let SchemaField carry those semantics (token-kind acceptance, list-entry
shape, shared-literal identity) and derive the tables from the branch
definitions. The tables get deleted, not relocated — a new branch or field
cannot forget or mis-transcribe what is computed.
Step 2 — the union becomes data; composition falls out.
Once every branch is a self-describing definition, the union is a list and
the built-ins are the core vocabulary rather than a special case:
final class MixProtocolVocabulary {
const MixProtocolVocabulary({
required this.name, // 'mix_chart'
required this.branches, // self-describing styler branch definitions
});
}
// The shared singleton is the core vocabulary alone — unchanged wire.
final mixProtocol = MixProtocol(vocabularies: [coreVocabulary]);
// A consumer that needs charts composes explicitly:
final protocol = MixProtocol(
vocabularies: [coreVocabulary, mixChartVocabulary],
);
"Vocabulary" is already the package's term for its discriminator sets
(wire_vocabulary.dart):
a contributed unit adds words while the grammar (envelope, v: 1, property
terms, token references) stays fixed. Because extension branches are ordinary
branch definitions, schema export, lenient repair, and inventory ratcheting
work for them with no extension-specific code paths. mixChartVocabulary
would live in mix_chart — the package that defines LineChartStyler
defines its wire form.
Example document, showing a nested core text styler inside an extension
branch (the recursion that forces one union):
{
"v": 1,
"type": "mix_chart.line_chart",
"stroke": { "width": 2, "color": { "$token": "chart.accent" } },
"axis": {
"label": { "type": "text", "style": { "fontSize": 11 } }
}
}
The exact API could differ. The important requirements are:
- Deterministic composition, no runtime registry. Composition is explicit
construction in code; registration order or import side effects must never
change what "v1" means. Same composition → same schema export → same
encoded documents.
- The core wire is untouched. The
mixProtocol singleton remains exactly
the built-in union; a pure-Mix v1 document never depends on what else is in
the dependency graph.
- Namespaced discriminators. Contributed wire types are prefixed with the
package name (mix_chart.line_chart); bare snake_case names stay reserved
for the core. Construction rejects collisions.
v: 1 envelope unchanged. Decoders without a vocabulary already behave
correctly today: strict mode fails with unknownType, lenient mode skips
the branch.
- Schema export declares composition (e.g.
x-mix-protocol-vocabularies: ["mix_chart"]) so schema consumers can tell
what a document needs without decoding it.
- Per-vocabulary inventory ratchet, reusing the existing manifest
mechanism against each vocabulary's own package surface.
- Wire equivalence is provable. The structural schema-export test,
format_v1_contract_test, and styler_round_trip_test already pin
behavior; step 1 should also add a byte-for-byte export golden so the
refactor is self-verifying in-repo.
Out of scope for a first pass: contributing modifiers, variants, or theme
token kinds (no demanding consumer yet), and any aliases or migration
readers.
Open questions:
- Discriminator separator:
mix_chart.line_chart (. is already in the
token-name charset) vs. / or :.
- Should
x-mix-protocol-vocabularies entries carry versions
(mix_chart@0.0.1-beta.1) or names only?
- Codec home: inside
mix_chart vs. a separate mix_chart_protocol
package (mix_chart avoids single-consumer indirection but takes on
ack/mix_protocol dependencies).
- How far can the schema-export post-processing shrink? Deriving its inputs
removes the hand-maintained tables; eliminating the remaining
shape-sniffing of Ack output may need metadata pass-through in ack.
I'm happy to implement both steps, starting with the step 1 refactor behind
the byte-for-byte golden.
Use case
mix_chartships real stylers —LineChartStyler,BarChartStyler,PieChartStyler, plus shared sub-stylers such asChartAxisStylerandChartTooltipStyler— thatmix_protocolcannot encode at all:The style union is closed: the branch map is hardcoded in the private factory
MixProtocol._builtIn(),there is no public constructor, and the codec-authoring toolkit under
src/schema/is not exported. Any package that defines stylers outsidepackages/mixhits the same wall.Concrete downstream need: Mix Atlas component capture records
protocol-encoded style evidence for every Fortal component so styles are
diffable across versions. 34 of Fortal's 38 widget families have that
evidence today; the 3 chart families cannot, purely because the protocol
cannot represent their stylers.
A sidecar protocol next door is not a viable workaround because chart stylers
embed core stylers —
ChartAxisStyler.labelis aTextStyler— and every branch codec receives
rootStyleSchemaso nested styles recursethrough the full discriminated union. Extension branches must join the same
union to express
TextStylerinsideLineChartStyler.Proposal
Let packages contribute styler codecs as composable vocabularies, in two
steps. Step 1 is a pure internal refactor with identical wire behavior that
makes step 2 almost free.
Step 1 — make the field model the single source of truth.
The per-branch layer is already a clean declarative model:
SchemaObject+SchemaFielddeclare each field once, and encode, decode, andfield-inventory checking derive from it. But
SchemaField.ackSchemawidens every field to a bareAckSchema<Object, Object>,erasing what the field knows about itself, so the contract file re-injects
that knowledge through hand-maintained tables:
_doubleTokenRootPropertiesByType— its
iconentry{size, weight, grade, opticalSize, fill, opacity}isexactly the set of fields
declared with double-token codecs in
icon_styler_codec.dart,transcribed by hand.
_lenientListEntryPathSuffixes— a manual list of which wire paths are lists, which the field codecs
(
Ack.list(...)) already know.$refrewrites in the schema-export post-processing —manual transcriptions of which fields share literal definitions.
Let
SchemaFieldcarry those semantics (token-kind acceptance, list-entryshape, shared-literal identity) and derive the tables from the branch
definitions. The tables get deleted, not relocated — a new branch or field
cannot forget or mis-transcribe what is computed.
Step 2 — the union becomes data; composition falls out.
Once every branch is a self-describing definition, the union is a list and
the built-ins are the core vocabulary rather than a special case:
"Vocabulary" is already the package's term for its discriminator sets
(
wire_vocabulary.dart):a contributed unit adds words while the grammar (envelope,
v: 1, propertyterms, token references) stays fixed. Because extension branches are ordinary
branch definitions, schema export, lenient repair, and inventory ratcheting
work for them with no extension-specific code paths.
mixChartVocabularywould live in
mix_chart— the package that definesLineChartStylerdefines its wire form.
Example document, showing a nested core
textstyler inside an extensionbranch (the recursion that forces one union):
{ "v": 1, "type": "mix_chart.line_chart", "stroke": { "width": 2, "color": { "$token": "chart.accent" } }, "axis": { "label": { "type": "text", "style": { "fontSize": 11 } } } }The exact API could differ. The important requirements are:
construction in code; registration order or import side effects must never
change what "v1" means. Same composition → same schema export → same
encoded documents.
mixProtocolsingleton remains exactlythe built-in union; a pure-Mix v1 document never depends on what else is in
the dependency graph.
package name (
mix_chart.line_chart); bare snake_case names stay reservedfor the core. Construction rejects collisions.
v: 1envelope unchanged. Decoders without a vocabulary already behavecorrectly today: strict mode fails with
unknownType, lenient mode skipsthe branch.
x-mix-protocol-vocabularies: ["mix_chart"]) so schema consumers can tellwhat a document needs without decoding it.
mechanism against each vocabulary's own package surface.
format_v1_contract_test, andstyler_round_trip_testalready pinbehavior; step 1 should also add a byte-for-byte export golden so the
refactor is self-verifying in-repo.
Out of scope for a first pass: contributing modifiers, variants, or theme
token kinds (no demanding consumer yet), and any aliases or migration
readers.
Open questions:
mix_chart.line_chart(.is already in thetoken-name charset) vs.
/or:.x-mix-protocol-vocabulariesentries carry versions(
mix_chart@0.0.1-beta.1) or names only?mix_chartvs. a separatemix_chart_protocolpackage (
mix_chartavoids single-consumer indirection but takes onack/mix_protocoldependencies).removes the hand-maintained tables; eliminating the remaining
shape-sniffing of Ack output may need metadata pass-through in
ack.I'm happy to implement both steps, starting with the step 1 refactor behind
the byte-for-byte golden.