Component overrides
The component kit is an opinionated default, so it needs the same escape hatch every other default has. This is that hatch: four rungs, a contract, and a resolution order.
Status: spec only. The component kit does not exist, none of the four rungs is implemented, and MAGIK_COMPONENT_CONTRACT_VIOLATION is a reserved name. Reviewed 2026-08-26.
Why this page exists
Spec item 11 requires a swap point for every opinionated default, and names five infrastructure backends. The kit — button, form, field, data_table, modal, toast, card, list, grid, tabs, stat, chart, plus the layout primitives sidebar, topbar, nav_item, breadcrumbs, account_menu and dashboard_grid — is an opinionated default too, and a much more visible one: it is on every screen the user's customers see. A layout is built out of those primitives, so replacing one is this ladder and not a second system.
A framework whose modal cannot be replaced is the Meteor failure mode one layer up (01-thesis.md): pleasant until the day the design system says otherwise, and then a rewrite. So the rule from 04-swap-points.md applies unchanged — every opinionated default has a config-level or declaration-level swap, and a swap ships proven, not promised.
The override ladder
Four rungs. You never have to jump to the top. Most needs are answered on rung 1, and each rung costs strictly more than the one below it.
| Rung | You want | You write | You keep |
|---|---|---|---|
| 1 | a different look | design tokens / CSS variables — no Ruby | everything: behaviour, htmx wiring, accessibility, guardrails |
| 2 | a different layout, same behaviour | variant: on the call, or component :Modal, extends: Magik::Kit::Modal overriding only body |
the prop set, the events, the composition contract |
| 3 | your own modal, still called modal everywhere |
component :Modal in app/components/ — an app definition shadows the framework one by name |
the contract, verified at boot |
| 4 | not a component at all | a raw-HTML escape hatch at the call site | nothing but the surrounding page |
Rung 1 — tokens
theme do
token :radius_lg, "12px"
token :surface_raised, light: "#ffffff", dark: "#16161c"
token :overlay_scrim, "rgba(0,0,0,.55)"
end
No Ruby beyond the token declaration; the kit reads CSS variables. If the answer to "how do I change the modal" is a colour, a radius or a shadow, it stops here.
Rung 2 — extend
component :Modal, extends: Magik::Kit::Modal do
body do
scrim
panel class: "acme-panel" do
slot :header
slot :content
{ slot :actions }
end
end
end
The prop set, the open/close events and the htmx targets are inherited. Only markup changes, so the contract cannot drift — you did not redeclare it.
Rung 3 — replace
# app/components/modal.rb
component :Modal do
satisfies Magik::Kit::Modal # the contract declaration — checked at boot
prop :title, :string
prop :dismissible, :boolean, default: true
slot :content
slot :actions
body do
tag :dialog, id: dom_id, "hx-target": target, class: "acme-modal" do
# entirely your markup
end
end
end
Everything in the app that says modal now gets yours — including kit components that compose it.
Rung 4 — escape
screen :Orders do
body do
raw <<~HTML
<dialog id="bespoke" data-controller="acme-modal">…</dialog>
HTML
end
end
raw is deliberately unpleasant to reach for: it is unchecked markup, no contract, no token wiring, and magik check reports every raw site so the count is visible. It exists because a framework with no escape hatch is the thing this page is preventing.
Why rung 3 needs a contract
Kit components compose each other. data_table opens a row in a modal; form composes field; toast is raised by action. So data_table must be able to open your modal without knowing anything about it.
Each kit component therefore publishes a contract — props in, slots, htmx targets, events out, and its accessibility obligations — and a replacement declares satisfies Magik::Kit::Modal. Boot verifies it.
| Contract element | Meaning | Example (Modal) |
|---|---|---|
| Props in | names, types and required/optional status a caller may pass | title: string, dismissible: boolean |
| Slots | named regions a caller fills | :content, :actions, optional :header |
| htmx targets | the DOM ids/selectors the framework will aim a swap at | dom_id, #{dom_id}-content |
| Events out | what the component emits, which other components listen for | magik:modal:open, magik:modal:close |
| Accessibility | the roles, the labelled-by relationship, the focus management and the keyboard path the replacement must also provide | role="dialog", aria-labelledby the :header slot, focus trapped while open, Esc closes |
| Failure | Code | fix: |
|---|---|---|
| a required prop, slot, target or event is missing from the replacement | MAGIK_COMPONENT_CONTRACT_VIOLATION |
magik errors explain MAGIK_COMPONENT_CONTRACT_VIOLATION |
The error names exactly what is missing — "app/components/modal.rb satisfies Magik::Kit::Modal but declares no slot :actions; data_table fills it" — because a contract violation an agent cannot act on is not a guardrail (07-ai-first.md). The code is registered in the catalogue at 03-guardrails.md.
This is the UI form of "proven, not promised": a replacement is accepted because it satisfies the contract at boot, not because its author intended it to.
Resolution order
Most specific wins, first match, no merging:
| # | Source | Selected by |
|---|---|---|
| 1 | app/components/ |
file present, name matches |
| 2 | domains/<name>/components/ |
the resolving domain, for domain-scoped overrides (02-dsl-surface.md) |
| 3 | a kit gem | kit :acme_ui in App.define — the seam for a house component library shared across several apps |
| 4 | the Magik built-in | always present, always last |
A domain-scoped override applies inside that domain only; a domain does not silently change how another domain renders.
Shadowing is never invisible. magik check reports every override and what it shadows:
component :Modal app/components/modal.rb shadows Magik::Kit::Modal contract: ok
component :DataTable domains/billing/components/… shadows Magik::Kit::DataTable contract: ok scope: Billing
component :Chart kit acme_ui shadows Magik::Kit::Chart contract: ok
Silent magic is the failure being designed against; a shadow you cannot see is exactly that. Registry mechanics and why shadowing is a boundary concern: ../architecture/02-boundaries.md.
One boundary, stated explicitly
"No SPA framework" forbids React or Vue owning rendering. It does not forbid attaching JavaScript to your own component.
| Allowed | Not allowed |
|---|---|
a Stimulus controller, an Alpine directive, a web component, a vanilla <script> on your markup |
a client-side router, a client-owned application state model, hydration of a server-rendered tree by a component framework |
| a date picker, a chart library, a rich text editor mounted on a server-rendered element | a build step the framework has to know about, or a second rendering path for the same screen |
The server renders the markup; you may wire whatever you like to it (05-limits.md).
And whatever you replace still obeys the boot guardrails — a replacement component holds no in-process state across requests, and renders no timestamp without an explicit zone (03-guardrails.md). Accessibility is in the same category, which is why it is a contract element rather than advice: a rung-3 replacement that drops the focus trap fails at boot exactly as a missing slot does. The escape hatch is from Magik's aesthetics, never from its invariants.