Context
src/protvista-uniprot.ts still defines a hand-rolled registerWebComponents() method that calls loadComponent() once per built-in nightingale tag (ten entries: nightingale-navigation, nightingale-track-canvas, nightingale-colored-sequence, nightingale-sequence, nightingale-variation, nightingale-linegraph-track, nightingale-filter, nightingale-manager, nightingale-sequence-heatmap, plus protvista-uniprot-structure). The list is byte-identical to the pre-refactor element except for one entry (nightingale-interpro-track, removed in commit 5f8250b when features-interpro migrated to track-canvas).
The config-as-data refactor moved component names into the registry — every semantic kind resolves to a component: string — but the actual customElements.define() call still happens via the hand-rolled list. Consumer-defined semantic kinds whose components live outside this list cannot register their own tag through the registry; the embedder has to call loadComponent() themselves, defeating the point of registerSemanticKind().
Captured in docs/hardcoded-assumptions-audit.md as item B18.
Task
Drive component registration from the registry instead. After loadConfig() resolves the config and the registry knows every component name referenced by every active semantic kind, walk the resolved set and call loadComponent(name, ctor) for each — looking up the constructor via a (name → ctor) map that built-ins seed at boot and consumers extend via a new element.registerComponent(name, ctor) API.
Scope:
- Add a
components bucket to the registry (registerComponent, getComponent, hasComponent, listComponents), seeded with the ten built-in (name, ctor) pairs currently in registerWebComponents(). Mirror the collision behaviour of the existing buckets — re-registering a built-in throws RegistryCollisionError.
- Expose
registerComponent(name, ctor) on the element's runtime API alongside registerAdapter / registerSemanticKind / registerTheme.
- After the loader has produced a
NormalizedConfig, walk the resolved kinds + explicit component: overrides, collect the unique set of component names, look each up in the registry, and call loadComponent(name, ctor) for them. Skip any name already defined on customElements (the existing loadComponent helper already does this guard).
- Delete
registerWebComponents() from src/protvista-uniprot.ts. The init flow becomes: loadConfig → register the components the config actually uses → mount.
- Update
docs/architecture.md's "How a config becomes pixels" section: the registry now owns component resolution end to end, not just name-mapping.
Acceptance:
- A consumer-defined semantic kind whose
component: resolves to a name not in the built-in set works end-to-end without the consumer touching customElements.define themselves — they just call element.registerComponent('my-track', MyTrack) once and reference it via kind:.
- No regression on the existing default config — the same 10 nightingale tags are still defined after mount, but via the registry walk rather than the hand-rolled list.
src/protvista-uniprot.ts no longer imports any @nightingale-elements/* constructor directly; those imports move to a small src/built-in-components.ts module that the registry seeds from.
- Tests cover: (a) the built-in walk registers every component the default config references; (b) a consumer-registered component is picked up when its kind appears in the config; (c) a kind referencing an unknown component fails validation with a useful message before mount, not at
customElements time.
Notes:
- The
loadComponent helper in src/utils/ stays as-is — it's the right level of abstraction for the actual customElements.define call (with the already-defined guard). What changes is who calls it, not how.
- Keep the built-in seeding in a dedicated module (
src/built-in-components.ts alongside registerBuiltinAdapters) so consumers wanting a leaner build can tree-shake the constructors they don't use.
Context
src/protvista-uniprot.tsstill defines a hand-rolledregisterWebComponents()method that callsloadComponent()once per built-in nightingale tag (ten entries:nightingale-navigation,nightingale-track-canvas,nightingale-colored-sequence,nightingale-sequence,nightingale-variation,nightingale-linegraph-track,nightingale-filter,nightingale-manager,nightingale-sequence-heatmap, plusprotvista-uniprot-structure). The list is byte-identical to the pre-refactor element except for one entry (nightingale-interpro-track, removed in commit5f8250bwhenfeatures-interpromigrated totrack-canvas).The config-as-data refactor moved component names into the registry — every semantic kind resolves to a
component:string — but the actualcustomElements.define()call still happens via the hand-rolled list. Consumer-defined semantic kinds whose components live outside this list cannot register their own tag through the registry; the embedder has to callloadComponent()themselves, defeating the point ofregisterSemanticKind().Captured in
docs/hardcoded-assumptions-audit.mdas item B18.Task
Drive component registration from the registry instead. After
loadConfig()resolves the config and the registry knows every component name referenced by every active semantic kind, walk the resolved set and callloadComponent(name, ctor)for each — looking up the constructor via a(name → ctor)map that built-ins seed at boot and consumers extend via a newelement.registerComponent(name, ctor)API.Scope:
componentsbucket to the registry (registerComponent,getComponent,hasComponent,listComponents), seeded with the ten built-in(name, ctor)pairs currently inregisterWebComponents(). Mirror the collision behaviour of the existing buckets — re-registering a built-in throwsRegistryCollisionError.registerComponent(name, ctor)on the element's runtime API alongsideregisterAdapter/registerSemanticKind/registerTheme.NormalizedConfig, walk the resolved kinds + explicitcomponent:overrides, collect the unique set of component names, look each up in the registry, and callloadComponent(name, ctor)for them. Skip any name already defined oncustomElements(the existingloadComponenthelper already does this guard).registerWebComponents()fromsrc/protvista-uniprot.ts. The init flow becomes:loadConfig→ register the components the config actually uses → mount.docs/architecture.md's "How a config becomes pixels" section: the registry now owns component resolution end to end, not just name-mapping.Acceptance:
component:resolves to a name not in the built-in set works end-to-end without the consumer touchingcustomElements.definethemselves — they just callelement.registerComponent('my-track', MyTrack)once and reference it viakind:.src/protvista-uniprot.tsno longer imports any@nightingale-elements/*constructor directly; those imports move to a smallsrc/built-in-components.tsmodule that the registry seeds from.customElementstime.Notes:
loadComponenthelper insrc/utils/stays as-is — it's the right level of abstraction for the actualcustomElements.definecall (with the already-defined guard). What changes is who calls it, not how.src/built-in-components.tsalongsideregisterBuiltinAdapters) so consumers wanting a leaner build can tree-shake the constructors they don't use.