Skip to content

Support package-contributed styler vocabularies in mix_protocol #1026

Description

@leoafarias

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:

  1. Discriminator separator: mix_chart.line_chart (. is already in the
    token-name charset) vs. / or :.
  2. Should x-mix-protocol-vocabularies entries carry versions
    (mix_chart@0.0.1-beta.1) or names only?
  3. 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).
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions