Two modules, two ways of guessing what an editor meant

Why Drupal component props need to declare editorial intent explicitly — and what core's Icon API already gets right about it.

Two bug reports landed on two different modules of mine inside the same week, and it took me embarrassingly long to notice they were the same bug wearing different clothes.

The first was against canvas_icon_picker (Canvas Icon Picker): the picker is silently ignored on a prop that also declares an enum. The editor gets a plain control, types the icon ID by hand if they happen to know it, and doesn’t if they don’t. Nothing logged, nothing broken, just a component that quietly stopped being editable the way it was meant to be.

The second was against sdc_prop_inherit (SDC Property Inheritance): Twig template required. Inheritance was leaning on the presence of a template to work out something the prop schema should have been able to tell it directly.

Different modules, different subsystems, one shape underneath. In both cases a consumer of a component prop schema had to infer what the prop was for, and inferred wrong.

What a prop schema tells you

Here is a prop of the kind that produced the first report:

components/card/card.component.yml
props:
type: object
properties:
icon:
type: string
title: Icon
enum:
- arrow-right
- download
- external

That schema declares three things: the value is a string, it is one of three values, and a human should see the label “Icon”. It declares nothing at all about what the string means. It could be an icon identifier. It could be a design-token key. It could be a CSS modifier suffix, or a sprite name from a build step, or a string that is called icon because the designer called that slot an icon in Figma. title is prose aimed at a person. There is no machine-readable statement of editorial intent anywhere in the file.

Every downstream consumer needs exactly that statement. Drupal Canvas needs it to choose a control. The icon picker needs it to know whether it should own the field. Inheritance needs it to decide whether a value is meaningful once it crosses a component boundary — a heading level or a variant name usually is, a one-off label almost never is. None of them can get it, so all of them sniff.

Two heuristics, one collision

The icon picker’s original rule was shape-based: a string prop, matched on a few signals, gets the picker. Add enum and two claims collide. The enum says closed list of values, render a select. The picker says this is an icon, render a searchable grid. Both are reasonable readings of the same schema, both were derived by guessing, and neither module knew the other had an opinion. So whichever resolution ran last won, and nobody reported the disagreement — because from inside each module there was no disagreement, just a prop that looked like something.

My first patch made it worse. I widened the match: if every value in the enum resolves to a known icon ID in the registry, treat the prop as an icon prop. Ten minutes of work, correct on my test site, deleted the same afternoon. It makes correctness depend on the contents of whichever icon packs happen to be installed — ship a pack containing download and an unrelated enum of file-action strings turns into an icon field. A rule that is right most of the time and wrong for reasons that live in another module’s configuration is worse than a rule that is simply wrong, because you cannot debug it from the component that broke.

Inheritance hits the same wall from the other side. Walking props from parent to child, the question should this value flow down is unanswerable from type: string. So the module reaches for other signals — naming, structure, in this case the template — and every one of those is a proxy for a statement the component author was never given a way to make.

The counter-argument I take seriously

SDC props are JSON Schema, deliberately. That was a good decision, and the strongest objection to everything below is that adding Drupal-specific keys forks a standard contract for short-term convenience. Validators stop being authoritative, generic tooling starts needing a Drupal plugin, and an ecosystem where five modules each invent their own x- key is meaningfully worse than one where they all guess. JSON Schema also already has an extension point for annotating what a string means: format.

I don’t think format closes it. format is a scalar-level annotation with an open vocabulary and no registry behind it — knowing a prop is format: icon still leaves every consumer to decide independently where icons come from, how to validate one, and what control to render. And a lot of editorial intent isn’t scalar-shaped. A link is a URL, a label, and a target. A motion preset is a name plus a duration plus an easing curve. The intent belongs to the group, not to any one string in it.

The part of the objection I can’t dismiss is the taxonomy risk, and I’ll come back to it.

Intent as first-class prop metadata

The closest existing model in Drupal is core’s Icon API, and what makes it work is not the identifier format. It is the registry. Packs declare icons, definitions resolve to something concrete, and there is one authoritative place to ask what exists. Once that registry is there, “this prop holds an icon” becomes a checkable claim rather than a naming convention that modules pattern-match against in private.

Generalise that. A registry of prop intents, where each intent declares its value shape as ordinary JSON Schema — so nothing forks — the service that resolves allowed values, and a default editorial control. A component then says what it means:

components/card/card.component.yml
props:
type: object
properties:
icon:
type: string
title: Icon
intent: core:icon
enum:
- arrow-right
- download
- external

The enum goes back to meaning what it means everywhere else in JSON Schema — a constraint on allowed values — instead of moonlighting as a widget hint. Canvas asks the registry which control serves core:icon. The picker registers as that control rather than pattern-matching its way into the field. Inheritance asks the registry whether an intent is contextual or local, once, instead of every consumer maintaining its own heuristics for the same fifteen prop kinds.

This matters more the further you push toward generated components. A model producing component YAML from a design file can state intent, because intent is a fact about the design. It cannot reliably reproduce the undocumented naming conventions that each module currently sniffs for. Governed AI authoring on top of Canvas needs the schema to carry meaning explicitly, or the governance has nothing to check against.

What would change my mind

Two things, concretely.

Canvas already resolves prop schemas to a field type and a control in order to render its editing UI — TODO(fact): confirm the exact class and service names for that resolution layer before this is worth arguing publicly. If that layer becomes extensible and reusable by contrib outside Canvas rendering, then intent is a Canvas concern that happens to be usable everywhere, and a separate registry is duplicated machinery. I would drop the proposal and write plugins against that instead.

The other is the taxonomy risk. If a first attempt at an intent vocabulary lands and immediately produces two hundred entries and a governance argument about the difference between core:link and core:cta, then the cost of naming outweighs the cost of guessing, and the guessing at least fails locally. I would want a very small starting vocabulary and a high bar for additions, and I’d want to be talked out of any intent that only one module consumes.

What I still can’t work out is where intent lives when it spans several props. A link authored as three sibling props has an intent that belongs to none of them individually, and every way I’ve sketched of expressing that either forces an object wrapper that fights how people actually write SDC, or invents a grouping convention parallel to the schema — which is the naming-convention problem again with more ceremony.

So the next step is the narrow one. I’m going to put a single-entry local registry inside canvas_icon_picker, make the picker resolve from it rather than from prop shape, and then see whether sdc_prop_inherit can read the same declaration without either module taking a dependency on the other. If that works for one intent it is worth proposing for the general case; if it doesn’t, the whole argument collapses at the first example. If you’ve already solved group-level intent, or you think format plus a shared convention genuinely gets there, both issues are open.