Ship your own variant¶
spaday’s component packages are two halves bolted together: a Python authoring surface (typed component classes, catalog schemas, actions, bindings) and a JS implementation (the bundle that registers the custom elements). The bolt is meant to come out. This guide covers the four things people want when they build on spaday rather than just using it:
Theme a package from Python, without writing a stylesheet.
Serve your own bundle behind a package’s Python surface.
Point a component at your own element with a different tag.
Share one engine between two libraries on a page.
Theme a package from Python¶
css() sets CSS custom properties — any of them, not a fixed list — so a design system’s own
tokens are authored from Python with no stylesheet at all:
from spaday.components.shell import App
App().css(spa_surface="#0C4253", wa_color_brand_fill_loud="#FC6B47")
# → --spa-surface: #0C4253; --wa-color-brand-fill-loud: #FC6B47
Kwargs are kebab-cased and ---prefixed (spa_surface_2 → --spa-surface-2). Custom properties
inherit through shadow boundaries, so setting them on the App root reaches every component in the
tree.
Three layers, in order¶
A color in any spaday component package resolves through three layers:
var(--spa-dagre-node-fill, /* 1. the package's own token */
var(--spa-surface-2, /* 2. the shell token it belongs to */
#fafafa)) /* 3. a literal, for a standalone page */
So there are two ways to theme, and you pick by how wide you want the change:
App().css(spa_surface_2="#1a2028") # every package's panels follow
Dagre(graph=g).css(spa_dagre_node_fill="#1a2028") # only the graph
spaday.theme.SHELL_TOKENS lists the shell palette (--spa-surface, --spa-surface-2,
--spa-border, --spa-text, --spa-muted, --spa-accent, --spa-info, --spa-success, --spa-warning,
--spa-danger, plus layout tokens). Each package publishes its own TOKENS mapping. A Token
contains the CSS property, description, and optional shell fallback:
from spaday_dagre import TOKENS
for kwarg, token in TOKENS.items():
fallback = token.fallback or "required"
print(f"{kwarg:<28} {token.property:<34} {fallback:<18} {token.description}")
Token retains the former two-item tuple interface, so existing code that unpacks
for kwarg, (prop, what) in TOKENS.items() continues to work.
Package tokens are named --spa-<package>-<thing>, where <package> is the name you pass to
packages=[...]. They are only ever read by the package’s stylesheet, never defined by it — that
is what lets a token set on an ancestor (an app-level theme) win over the package’s default.
Page mode¶
The dark palette is keyed off WebAwesome’s wa-dark class, so one binding re-themes everything:
App(...).bind_root_class("wa-dark", "dark")
wa-light on a subtree flips a nested island back. Both palettes are emitted at zero specificity,
so your own rules always win.
When the design system should drive¶
spaday-webawesome maps WebAwesome’s tokens onto the shell palette rather than the other way
round, so restyling WebAwesome carries the shell, the graphs, the tables and the trees with it:
App().css(wa_color_brand_fill_loud="#0C4253") # → --spa-accent, --spa-info, and everything downstream
A canvas component cannot read CSS, so spaday-lightweight-charts samples the resolved value of its
tokens and hands them to the chart. Theming it looks identical from Python.
Render the generic controls with your own design¶
The generic controls (spaday.ui) reach a design system through a Design: data mapping each control
kind to the element that renders it and to where its generic surface lands there. A design-system
package publishes one on its ComponentPackage; an in-house design system publishes its own the same
way, and needs no Python beyond it:
from spaday.ui import ControlSpec, Design, Open, Options, Part, Value
DESIGN = Design(
name="acme",
controls={
"button": ControlSpec(
tag="acme-button",
label=Part(kind="text"), # the label is the button's text
props={"intent": "tone", "size": "size", "disabled": "disabled"},
values={"intent": {"primary": "brand"}}, # the design's word for it
),
"input": ControlSpec(
tag="acme-field",
label=Part(kind="attr", name="label"),
help=Part(kind="slot", name="hint"), # an element in the `hint` slot
error=Part(kind="attr", name="error-text"),
invalid={"invalid": True}, # set while an error is shown
value=Value(prop="value", event="acme-change"), # written back on the design's event
),
"select": ControlSpec(tag="acme-select", options=Options(kind="prop", name="items")),
"dialog": ControlSpec(
tag="acme-dialog",
open=Open(prop="opened", event="acme-closed", methods=("show", "hide")),
),
},
)
package = ComponentPackage(name="acme", ..., design=DESIGN)
Part places a label, help text or error message as an attribute, a slotted element, the control’s
text, a child inside it, or a sibling in a Wrap around it (a bp-field, a fluent-field, a plain
<label>). A non-empty tuple of parts sends the same text to several destinations, such as a visible
error slot and the control’s validation property. invalid props follow both literal and bound errors,
returning to the value in fixed (or being removed) when a bound error clears. An invalid target
cannot also receive error content, mapped control state, another binding, or a per-design override.
Options renders Select and RadioGroup choices as child elements, optionally inside one list
wrapper, or as a list property. It maps value, label, and disabled for literal and bound option
lists. A child option’s label can also be one Part or a non-empty tuple of parts; combine a sibling
part with item_wrap when each radio needs its own visible <label>. label_attr can repeat that text
in an attribute such as aria-label when the custom element does not derive its accessible name from
the wrapper. fixed
sets props required on every child option. For child options, selected names the boolean property
that marks the current choice; use selection=True when a group value must drive that property on
its children. Property option lists can use defer=True when the element must be connected before
receiving them.
Value names the value property and its change event. Its number and json codecs convert in both
directions; encode="string" only converts values sent to the element, and state can name a
different readable property such as selectedItem.value. scale_by="max" and scale_to=100
translate a generic value range to a fixed component range; scale_default supplies the source
range when the generic prop is omitted. Value.defer delays writes until the next animation frame
for a setter that requires connected children.
children_slot routes authored content to one named slot. accepts declares literal generic prop
values a realization can preserve; another value or a binding on that prop uses the marked native
fallback. A control the design leaves out also uses that fallback.
Open names overlay state and can supply the method pair used to open and close it instead of
assigning the open prop. Method calls are applied once the element is connected; while it remains
connected, later updates stay synchronous. Attachment inside an existing shadow root is checked on a
shared backoff timer and can take up to one second after a long detached period. Open.state can name
a different readable property, including a dotted path such as dialog.open, when the custom element
wraps the stateful overlay.
python -m spaday.ui.conformance PORT --package acme serves the conformance page with your design, so
the same browser checks spaday runs against the baseline (js/tests/ui.spec.js) run against yours.
Serve your own bundle¶
A ComponentPackage is a frozen dataclass of (name, assets_dir, assets, components), and
bootstrap(packages=[...]) takes descriptors directly. Keep the generated component classes and
their schemas, and serve your own JS:
import dataclasses
from spaday.bootstrap import bootstrap
from spaday_webawesome import package as webawesome
mine = dataclasses.replace(
webawesome,
assets_dir=MY_DIST, # your built bundle
assets=(("css", "my.css"), ("js", "my-wa.js")),
)
bootstrap(packages=[mine], ...)
Your bundle must register the tags the schemas name — that is the contract, and breaking it fails
silently: an unregistered tag renders an inert element and nothing reports it. It may register them
late, after the page has mounted: props written to an element before its tag is defined are set
through its properties once it is. check_script builds
a browser-side check from the schemas your package already carries, so test it:
from spaday import check_script
problems = page.evaluate(check_script([mine])) # any browser driver
assert problems == []
It verifies that every declared tag is a defined custom element, and that every json-kind prop is
exposed as a DOM property — the runtime falls back to an attribute when an element has no property,
and an attribute can only carry a string, so an attribute-only json prop would stringify your
object to "[object Object]". String, number, boolean and enum props survive that fallback, so they
are not required to be properties. Neither are props named with a hyphen: no element has a property
by that name, so they are carried as attributes whichever bundle implements the element.
Two packages with the same name are rejected, which is the point: an app gets the peer’s bundle or
yours, never both. That also means substitution is the simplest fix for the collision in
Share one engine — with one copy on the page, there is
nothing to collide.
Point a component at your own element¶
If your element implements the same contract under a different tag, retag() gives you the Python
surface pointed at it:
from spaday_perspective import PerspectivePanel, package
MyGrid = PerspectivePanel.retag("my-data-grid")
mine = dataclasses.replace(package, assets_dir=MY_DIST, assets=(("js", "grid.js"),), components=(MyGrid,))
retag() carries the tag into the class’s schema too — ComponentPackage requires the two to
agree — and the new tag registers for dict-tree validation like any other schema-carrying class.
You may not need the Python surface at all¶
Some packages contribute no server code. spaday-perspective is only PerspectivePanel plus the
package descriptor: the websocket is perspective-python’s own Server() and handler, wired by your
application. If you have your own grid element and just want it fed by the same data, point it at
the same URL — neither side needs to know about the other.