From 774571b52a0e4125c232bad16a81d2011b7e89e4 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 13 May 2026 12:18:10 -0400 Subject: [PATCH 01/45] docs(specs): shinyui metadata-consolidation prototype design (#69) Stage A design for umbrella #68 / issue #69. Specs a new sibling Python package `shinyui` at pkg-py/src/shinyui/ that prototypes a class-per-component UI hierarchy with seven concrete archetypes, resolves the three umbrella open questions (handler registration, bookmark lookup, update signature), and refactors the umbrella's UiInput/UiLayout straddler into orthogonal HasInputValue + Updatable mixins so layouts-with-state read honestly. --- ...3-shinyui-metadata-consolidation-design.md | 435 ++++++++++++++++++ 1 file changed, 435 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-13-shinyui-metadata-consolidation-design.md diff --git a/docs/superpowers/specs/2026-05-13-shinyui-metadata-consolidation-design.md b/docs/superpowers/specs/2026-05-13-shinyui-metadata-consolidation-design.md new file mode 100644 index 00000000..c2ed0621 --- /dev/null +++ b/docs/superpowers/specs/2026-05-13-shinyui-metadata-consolidation-design.md @@ -0,0 +1,435 @@ +# `shinyui` — metadata consolidation prototype (Stage A of umbrella #68 / issue #69) + +**Date:** 2026-05-13 +**Status:** Design +**Scope:** Stage A only — prototype the class hierarchy in a new sibling Python package, `shinyui`, inside this repo. Stage B porting to `py-shiny` is explicitly out of scope. +**Tracks:** GitHub issue [#69](https://github.com/posit-dev/shinyreact/issues/69) under umbrella [#68](https://github.com/posit-dev/shinyreact/issues/68). +**Reference design:** [`2026-05-06-unified-ui-component-class-design.md`](./2026-05-06-unified-ui-component-class-design.md) (umbrella). This spec refines the issue-69 portion and supersedes parts of the umbrella where they diverge (notably the `UiInput`/`UiLayout` straddler model and `update()` argument shape). + +## Summary + +Build a new Python package `shinyui` at `pkg-py/src/shinyui/` that prototypes a class-per-component UI hierarchy. Each class owns its own metadata (input handler, bookmark serializer, HTML deps, `update()` method, server-side read accessors). The package depends only on `shiny` and `htmltools` — *not* on `shinyreact` — so the eventual Stage B port into `py-shiny` is a near-mechanical copy. + +The prototype ships seven concrete classes covering every archetype: simple input, structured input, plain output, output-with-read-only-signals (plot), layout-with-children, layout-with-state (card, accordion), and layout-as-child-of-layout. + +Three open questions from the umbrella are resolved in this spec: + +- **Handler registration:** explicit `cls._register_input_handler()` call at module level (no `__init_subclass__`). +- **Bookmark id → instance lookup:** register-on-construction; `__init__` queries `get_current_session()` and registers `(id, self)` on the session if one is in scope. No-op if not (module-level UI keeps working, just without class-owned serializers). +- **`update()` signature:** typed per-class keyword arguments. No `session=` kwarg — session is captured at `__init__` and resolved at call time via a shared `_require_session()` helper. + +A fourth refinement that emerged during design: + +- The umbrella's `UiInput`/`UiLayout`/`UiOutput` straddler pattern (e.g. `UiAccordion(UiInput, AllowsChildren)`) is replaced. "Has an input value" and "is updatable" become orthogonal mixins (`HasInputValue`, `Updatable`); the three role classes stay as semantic markers. This avoids the awkwardness of calling a card or an accordion "an input." + +## Motivation (delta from umbrella) + +The umbrella spec answers *why* this work matters and *what* the hierarchy looks like. This spec answers *where it lives*, *which seven classes to build*, *how the lifecycle resolves the three open questions*, and *what tests pin the design.* + +Three concrete pressures shaped the divergences below: + +- **Layouts can have input values.** Accordion's open-panel set, card's full-screen toggle, sidebar's open/closed state, navset's active tab — all are layouts whose primary user-facing purpose is structure, but which expose server-readable state. The umbrella's `UiAccordion(UiInput, AllowsChildren)` straddler doesn't generalize gracefully to `UiCard` ("a card is an input?"). Factoring `HasInputValue` out as a mixin removes the awkwardness and reads honestly. +- **Outputs can have read-only multi-signals.** A plot exposes `_click`, `_brush`, `_hover`, `_dblclick`. None are updatable from the server. Forcing these through `HasInputValue` (multi-id generalization) inflates a single-id abstraction for one rare use case; making them a separate mechanism keeps the common case clean. +- **Server-side read accessors are a real ergonomic win.** Shiny's `@render.data_frame` already exposes `df.cell_selection()`, `df.sort()`, etc. as reactive methods on the renderer instance. The class-per-component design makes the same idiom available across the board: `slider.value()`, `card.full_screen_value()`, `accordion.open_panels()`, `plot.click_value()`. + +## Goals + +- One Python file per component class containing its full lifecycle (markup, handler, serializer, deps, update, read accessors). +- A single shared `_require_session()` helper on `UiComponent` powering update, `_read_input()`, and plot's `_read_signal()`. +- Snapshot equivalence between `shinyui.*` `tagify()` output and the corresponding `shiny.ui.*` output, so Stage B porting is provably markup-neutral. +- Working end-to-end example app exercising every archetype, with bookmark round-trip and at least one `.update()` call from the server. +- A test suite that pins MRO, registration, bookmark resolution, update resolution, and read-accessor behavior independently — so a regression at any one layer surfaces immediately. + +## Non-goals + +- Stage B port into `py-shiny` (separate issue when this prototype is accepted). +- Tag-as-context-manager / parent-tag stack (umbrella sub-issue 3; `AllowsChildren.__enter__` returns `self` and the auto-collect-bare-tags behavior is deferred). +- Core/Express overload signature unification (umbrella sub-issue 2). +- Adding shinyui imports to `pkg-py/src/shinyreact/`. The two packages are independent. +- Replacing existing shinyreact examples or APIs. + +## Package layout + +``` +pkg-py/ + src/ + shinyreact/ # existing + shinyui/ # new + __init__.py + _base.py # UiComponent, AllowsChildren + _mixins.py # HasInputValue, Updatable + _reactive.py # local reactive_calc_method (~15 lines, comment cites shiny/render/_data_frame_utils/_reactive_method.py) + _input_slider.py + _input_select.py + _output_code.py + _output_plot.py + _card.py + _accordion.py + tests/ + shinyui/ + test_hierarchy.py + test_tagify_snapshots.py + test_input_handler_registration.py + test_bookmark_roundtrip.py + test_update_resolution.py + test_read_accessors.py + test_allows_children.py +``` + +The root `pyproject.toml` is extended: + +```toml +[tool.hatch.build.targets.wheel] +packages = ["pkg-py/src/shinyreact", "pkg-py/src/shinyui"] + +[tool.pyright] +include = ["pkg-py/src/shinyreact", "pkg-py/src/shinyui"] +``` + +`make py-check` automatically covers `shinyui` (pytest collects under `pkg-py/tests/` by default; pyright scans both packages). + +Deps: `shiny + htmltools` (already in the project's runtime deps). No new dependency. + +## Class hierarchy + +``` +UiComponent (ABC) # tagify() abstract; __enter__ raises; _session; _require_session(); _read_input() + ├── UiInput(UiComponent, HasInputValue) # "primarily an input control" + ├── UiOutput(UiComponent) # "primarily a server-rendered output"; has id + └── UiLayout(UiComponent) # "primarily a container"; no id by itself + +HasInputValue (mixin) # id, bookmark_serializer (class default + per-instance override), session-time id→instance registration +Updatable (ABC mixin) # update(**kwargs) abstract; subclasses give typed kwargs +AllowsChildren (mixin) # children, append(), __enter__ returns self, __exit__ +``` + +**Rules:** + +- `UiComponent.__enter__` raises `TypeError`. `AllowsChildren.__enter__` overrides to return `self`. +- A class can be used in `with` iff `AllowsChildren` is in its bases. +- A class has a server-readable id iff `HasInputValue` is in its bases, OR it is a `UiOutput`. (`UiOutput` carries its own `id` independent of `HasInputValue` — outputs need an id for rendering but don't need bookmark/serializer machinery.) +- A class supports `.update()` iff `Updatable` is in its bases. +- Cooperative `__init__`: every mixin calls `super().__init__(**kw)` first, then does its own work. This guarantees `UiComponent.__init__` (which captures `self._session`) has already run before any mixin reads it. + +### Concrete reference set + +| Class | Bases | Has input value? | Updatable? | Read accessors | +|---|---|---|---|---| +| `UiInputSlider` | `UiInput, Updatable` | ✓ | ✓ | `value() -> float` | +| `UiInputSelect` | `UiInput, Updatable` | ✓ | ✓ | `value() -> str \| tuple[str, ...]` | +| `UiOutputCode` | `UiOutput` | — | — | — | +| `UiOutputPlot` | `UiOutput` | ✓ (derived ids; not `HasInputValue`) | — | `click_value()`, `dblclick_value()`, `hover_value()`, `brush_value()` | +| `UiCard` | `UiLayout, AllowsChildren, HasInputValue, Updatable` | ✓ — `full_screen` (empty suffix; `input.()` is the boolean) | ✓ | `full_screen_value() -> bool` | +| `UiAccordion` | `UiLayout, AllowsChildren, HasInputValue, Updatable` | ✓ — open panel set | ✓ | `open_panels() -> tuple[str, ...]` | +| `UiAccordionPanel` | `UiLayout, AllowsChildren` | — | — | — | + +Each class is paired with a lowercase factory function (`input_slider`, `input_select`, `output_code`, `output_plot`, `card`, `accordion`, `accordion_panel`) that returns the instance. Both names are public exports from `shinyui`. + +### Why this departs from the umbrella + +The umbrella spec models `UiAccordion` as `UiInput, AllowsChildren` (a "straddler"). That works for accordion in isolation but doesn't generalize to `UiCard`: a card whose full-screen state is exposed as an input wouldn't naturally be called "an input." Once we admit that *any* layout can expose state, the cleanest factoring is to make state-bearing a mixin orthogonal to the role split. The role categories (`UiInput`/`UiOutput`/`UiLayout`) become semantic markers; the mixins (`HasInputValue`/`Updatable`/`AllowsChildren`) describe capabilities. + +This refactor doesn't change the umbrella's other commitments: HTML deps still live as ClassVar, `tagify()` is still pure, the input handler registry is unchanged, and the umbrella's "you can `with X(...)` iff `X` declares `AllowsChildren`" rule still holds. + +## Lifecycle decisions + +### Session capture — single source of truth on `UiComponent` + +```python +class UiComponent(ABC): + html_dependencies: ClassVar[tuple[HTMLDependency, ...]] = () + + def __init__(self, **kwargs): + self._session: Session | None = get_current_session() # may be None at module load + super().__init__(**kwargs) + + def _require_session(self, *, for_op: str) -> Session: + sess = self._session or get_current_session() + if sess is None: + raise RuntimeError( + f"{type(self).__name__}.{for_op}() requires an active session " + f"(instance constructed outside any session, and none is active now)" + ) + return sess + + def _read_input(self, suffix: str = "") -> Any: + sess = self._require_session(for_op="_read_input") + return sess.input[f"{self.id}{suffix}"]() + + @abstractmethod + def tagify(self) -> Tag: ... + + def __enter__(self) -> Self: + raise TypeError( + f"{type(self).__name__} does not accept children; " + f"only components declaring `AllowsChildren` may be used as `with` blocks." + ) + def __exit__(self, *exc): ... +``` + +`_read_input` lives on `UiComponent` so plot, slider, card, accordion, etc. all share one implementation. The only precondition is that `self.id` exists — guaranteed by `UiOutput` or `HasInputValue`. + +### Construction always succeeds + +`__init__` never raises for absence-of-session. Module-level UI declarations (`app_ui = page_react(...)`) continue to work, but bookmark serializers won't be class-owned for those instances (no session at construction → no id→instance registration → bookmark falls through to Shiny's default path). Apps that need bookmark of class-owned serializers must use the function-form `def app_ui(request): ...` so a session is in scope when instances are constructed. + +### Session-requiring methods throw at call time + +`update()`, `_read_input()`, `_read_signal()` all funnel through `_require_session(for_op=...)`. If no session is reachable (neither captured at init nor active now), a `RuntimeError` is raised with the class name and method name in the message. + +### Input handler registration — explicit module-level call + +```python +class HasInputValue: + input_handler_name: ClassVar[str] = "" + _input_handler: ClassVar[Callable[..., Any] | None] = None + bookmark_serializer: ClassVar[BookmarkSerializer | None] = None + + @classmethod + def _register_input_handler(cls) -> None: + """Idempotent. Call once at module load if this class declares a handler.""" + if cls.input_handler_name and cls._input_handler is not None: + register_input_handler(cls.input_handler_name, cls._input_handler) + + def __init__(self, *, id: str, **kwargs): + self.id = id + super().__init__(**kwargs) # UiComponent sets self._session + if self._session is not None: + _register_instance_on_session(self._session, id, self) +``` + +Subclasses declare both attributes and call `_register_input_handler()` at module level: + +```python +class UiInputDate(UiInput): + input_handler_name = "shiny.date" + + @staticmethod + def _input_handler(value, name, session): + return parse_iso_date(value) + + +UiInputDate._register_input_handler() +``` + +Most simple inputs (slider, select, code, card, accordion, plot, etc.) don't override `_input_handler` and don't call `_register_input_handler()` — the default `None` means "Shiny's existing wire layer passes the value through as-is." + +Why not `__init_subclass__`: import-order coupling, abstract-intermediate footgun, test-inheritance side effects, and "where is this registered?" greppability. All five concerns documented in conversation; sticking with explicit-call discipline matches `py-shiny`'s existing style and makes Stage B porting trivially mechanical. + +### Bookmark id → instance lookup — register on construction + +When `HasInputValue.__init__` finds an active session, it registers `(id, self)` on a session-attached map (`session._shinyui_instances: dict[str, HasInputValue]` or equivalent attached via `setattr` on first use, since we don't own `Session`). On bookmark save, the bookmark machinery walks this map for class-owned serializers; on restore, it looks up by id and applies the class's `deserialize` before Shiny's default flow. For ids not in the map, the existing Shiny path applies. + +Per-instance serializer override: `HasInputValue` reads `getattr(self, "_bookmark_serializer", None) or type(self).bookmark_serializer`. Users can pass `bookmark_serializer=` to the factory to override per-instance without subclassing. + +### `update()` — typed per-class, no session arg + +```python +class Updatable(ABC): + @abstractmethod + def update(self, **kwargs) -> None: ... + + +class UiInputSlider(UiInput, Updatable): + def update( + self, *, + value: float | tuple[float, float] = MISSING, + min: float = MISSING, + max: float = MISSING, + step: float = MISSING, + label: str = MISSING, + ) -> None: + sess = self._require_session(for_op="update") + # Mirror shiny.ui.update_slider's send_input_message payload: + sess.send_input_message(self.id, _build_slider_update_payload(...)) +``` + +No `session=` kwarg. Session is captured at `__init__` (in `UiComponent`) and resolved at call time via `_require_session()`. If the instance was constructed outside any session, `_require_session()` falls back to `get_current_session()`; if both are None, it raises. + +Each class with `Updatable` defines its own typed signature. Mechanical mirror of today's `update_input_*` modules — same fields, same defaults, same payload shape. Pyright catches drift between `__init__` and `update()` arg sets (where they overlap). + +### Server-side read accessors + +The data_frame renderer pattern: instance methods wrapped in `@reactive_calc_method`, each calling `_read_input()` (single-signal) or `_read_signal()` (multi-signal) under the hood. + +For `HasInputValue` (single-id): + +```python +class UiInputSlider(UiInput, Updatable): + @reactive_calc_method + def value(self) -> float: + return self._read_input() + + +class UiCard(UiLayout, AllowsChildren, HasInputValue, Updatable): + @reactive_calc_method + def full_screen_value(self) -> bool: + return bool(self._read_input()) + + +class UiAccordion(UiLayout, AllowsChildren, HasInputValue, Updatable): + @reactive_calc_method + def open_panels(self) -> tuple[str, ...]: + return tuple(self._read_input() or ()) +``` + +For `UiOutputPlot` (multi-signal, not `HasInputValue`): + +```python +class UiOutputPlot(UiOutput): + def __init__( + self, id: str, *, + click: bool = False, dblclick: bool = False, + hover: bool = False, brush: bool = False, + ): + self.id = id + self._click = click + self._dblclick = dblclick + self._hover = hover + self._brush = brush + super().__init__() + + @reactive_calc_method + def click_value(self) -> dict | None: return self._read_input("_click") + @reactive_calc_method + def dblclick_value(self) -> dict | None: return self._read_input("_dblclick") + @reactive_calc_method + def hover_value(self) -> dict | None: return self._read_input("_hover") + @reactive_calc_method + def brush_value(self) -> dict | None: return self._read_input("_brush") + + def tagify(self) -> Tag: ... # markup copied from shiny.ui.output_plot +``` + +Plot deliberately does *not* register input handlers for its derived ids. Shiny's `Inputs.__getitem__` auto-creates a read-only `Value[Any]` on first access; the browser pushes JSON to those ids over the wire; the accessors read them. No new abstraction needed in the common path. + +### `_reactive_calc_method` helper + +Implemented locally in `shinyui/_reactive.py` (~15 lines): `@reactive.calc`-wrapped per-instance cache via `WeakKeyDictionary`. Comment cites `shiny/render/_data_frame_utils/_reactive_method.py` as the inspiration. Stage B can decide whether to extract the helper to a public Shiny utility. + +### `tagify()` is pure + +No `get_current_session()`, no registration side effects, no mutation of class state. Safe to call multiple times for the same instance. The renderer (Shiny, htmltools) is allowed to call `tagify()` more than once per render pass. + +HTML deps come from `cls.html_dependencies`. `tagify()` returns a `Tag` with deps attached via the standard htmltools `Tag` mechanism. + +### `AllowsChildren` — no parent-tag stack here + +```python +class AllowsChildren: + def __init__(self, *children, **kwargs): + self.children: list[TagChild] = list(children) + super().__init__(**kwargs) + + def append(self, child: TagChild) -> Self: + self.children.append(child) + return self + + def __enter__(self) -> Self: + return self + + def __exit__(self, *exc): ... +``` + +`with card(id="c") as c: c.append(child)` works. `with card(id="c"): h1("title")` does *not* auto-collect `h1` — that's umbrella sub-issue 3 (Tag-as-context-manager), explicitly out of scope here. + +## Example app — `examples/app-py/14-unified-ui-prototype/` + +A single example exercises every reference class in one page. The `app_ui` is a function so a session is in scope at construction, demonstrating bookmark of class-owned serializers. + +```python +import shinyui as su +from shiny import App, reactive +import shinyreact + +def app_ui(request): + return shinyreact.page_react( + # Layout-as-child-of-layout + layout-with-state + simple input + output: + su.card( + su.input_slider("n", "N", 1, 100, 50), + su.input_select("col", "Column", {"a": "A", "b": "B"}), + su.output_code("summary"), + su.output_plot("plot", click=True, brush=True), + su.accordion( + su.accordion_panel("Settings", "..."), + su.accordion_panel("Diagnostics", "..."), + id="acc", + open="Settings", + ), + id="main_card", + full_screen=False, + ), + ) + +def server(input, output, session): + plot = ... # retrieved from session by id or by referencing closures + card = ... + accordion = ... + + @reactive.effect + def _(): + if (c := plot.click_value()) is not None: + print(f"click @ {c['x']},{c['y']}") + + @reactive.effect + def _(): + if input.n() > 90: + card.update(full_screen=True) + accordion.update(open=("Diagnostics",)) + + @su.render_code # or whichever render shape we expose for output_code + def summary(): ... +``` + +A README in the example folder walks through each archetype, what it demonstrates, and how to verify the bookmark round-trip (URL state in `?_inputs_=...`). + +(Implementation detail: how the server captures `plot` / `card` / `accordion` instances — whether via factory closures, lookup-by-id on a session-attached registry, or another path — is settled in the implementation plan, not this design.) + +## Test suite + +Each test file targets one layer. Tests use a controllable session via Shiny's session helpers (`session_context` or equivalent) so `get_current_session()` returns a mock. + +| Test file | What it pins | +|---|---| +| `test_hierarchy.py` | MRO of every concrete class; `isinstance(slider, UiInput)`, `isinstance(card, AllowsChildren)`, etc.; `with UiInputSlider(...):` raises `TypeError` with the right message; `with UiOutputCode(...):` likewise; `with UiCard(...):` does not. | +| `test_tagify_snapshots.py` | `tagify()` output for each class compared to the equivalent `shiny.ui.*(...)` `Tag` — Tag equality + HTML-dep set equality. Catches drift from upstream markup. | +| `test_input_handler_registration.py` | After importing `shinyui`, the handler registry contains the expected `input_handler_name` → callable mappings (e.g. for `UiAccordion`); classes without `_input_handler` don't register anything. | +| `test_bookmark_roundtrip.py` | Within a session, construct an input, serialize via the class-owned serializer (or per-instance override), restore in a fresh session, assert value parity. | +| `test_update_resolution.py` | `update()` outside a session raises `RuntimeError` with class name + method name; with init-captured session it uses that session; with no init session but a current session it uses the current; `update()` accepts no `session=` kwarg (type-checked via pyright fixture). | +| `test_read_accessors.py` | `slider.value()`, `card.full_screen_value()`, `accordion.open_panels()`, `plot.click_value()` each return the value from the appropriate `session.input[derived_id]`; called outside a session, each raises. | +| `test_allows_children.py` | `card.append(child)` mutates `card.children`; `with card(id=...) as c: c.append(x)` collects `x` correctly; appending to a non-`AllowsChildren` raises `AttributeError`. | + +Snapshot test infrastructure reuses the existing `make py-update-snaps` flow. + +## Acceptance criteria (Stage A) + +Mirroring the issue's checklist: + +- [ ] `pkg-py/src/shinyui/` exists with `UiComponent`, `UiInput`, `UiOutput`, `UiLayout`, `HasInputValue`, `Updatable`, `AllowsChildren` and the seven concrete classes. +- [ ] Each class has its factory function exported alongside the class. +- [ ] `examples/app-py/14-unified-ui-prototype/` runs end-to-end with bookmark round-trip and at least one `.update()` from the server. +- [ ] All seven test files exist and pass; `make py-check` is green. +- [ ] `tagify()` snapshots match `shiny.ui.*` markup for every concrete class. +- [ ] `with UiInputSlider(...):` (and any non-`AllowsChildren` instance) raises with a clear message naming the class. +- [ ] No new top-level dependency added to `pyproject.toml`. + +## Open questions deferred + +- **Sub-issue 2 (Core/Express overload signatures)** — out of scope here. Will be designed in a follow-up brainstorm; depends on this prototype landing. +- **Sub-issue 3 (Tag-as-context-manager / parent-tag stack)** — out of scope here. `AllowsChildren.__enter__` returns `self` and `with card(): h1("x")` does *not* auto-collect. +- **How `server()` captures component instances** — closure capture, lookup-by-id from a session-attached registry, or another path. Settled in the implementation plan, not this design. +- **`UiOutputCode` rendering** — whether `shinyui` ships its own `@render_code` decorator or relies on `shiny.render.code`. Settled in the implementation plan. + +## Risks + +- **MRO discipline.** `UiCard(UiLayout, AllowsChildren, HasInputValue, Updatable)` is four-base inheritance. Each mixin must `super().__init__(**kw)` first, then do its own work. Documented in code comments; pinned by `test_hierarchy.py`. If a mixin omits `super()`, errors surface immediately because `self._session` won't be set when `HasInputValue` reads it. +- **Snapshot drift.** Upstream `shiny.ui` markup can change between releases. Snapshot test runs against the installed `shiny`, so changes are caught on dependency bumps. Mitigation: pin `shiny>=1.2.0` (already done) and regenerate snapshots when bumping. +- **Bookmark coupling to private session state.** Attaching `_shinyui_instances` to `Session` via `setattr` is a private-attribute pattern. Acceptable for a prototype; Stage B can negotiate a public hook in `py-shiny`. +- **`@reactive_calc_method` local fork.** Drift from `shiny.render._data_frame_utils._reactive_method` is possible. Mitigation: 15-line implementation, comment pointing at the source, easy to compare during Stage B. +- **Express usage.** Express's `RecallContextManager` is not integrated. Using `shinyui` factories inside Express may or may not collect children correctly — this prototype does not promise Express ergonomics (sub-issue 2 scope). + +## What this spec does not commit to + +- The exact wire-payload shape of `update()` per class (mirrors `shiny.ui.update_*` — same fields, same defaults, same encoding — but exact field-by-field specifications are an implementation concern). +- The Stage B port plan. +- A migration strategy for existing shinyreact examples (none of them use `shinyui`; they keep working unchanged). From e89960ebe6695709266b7f0361d9003e7c2a5c15 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 13 May 2026 12:24:46 -0400 Subject: [PATCH 02/45] docs(plans): shinyui metadata-consolidation implementation plan (#69) --- ...26-05-13-shinyui-metadata-consolidation.md | 2680 +++++++++++++++++ 1 file changed, 2680 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-13-shinyui-metadata-consolidation.md diff --git a/docs/superpowers/plans/2026-05-13-shinyui-metadata-consolidation.md b/docs/superpowers/plans/2026-05-13-shinyui-metadata-consolidation.md new file mode 100644 index 00000000..ed6dab2a --- /dev/null +++ b/docs/superpowers/plans/2026-05-13-shinyui-metadata-consolidation.md @@ -0,0 +1,2680 @@ +# shinyui Metadata Consolidation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a new sibling Python package `shinyui` at `pkg-py/src/shinyui/` that prototypes a class-per-component UI hierarchy with seven concrete archetypes, single source of session truth on `UiComponent`, typed `update()` methods, server-side read accessors, class-owned bookmark serializers, and a working end-to-end example app. + +**Architecture:** Three role base classes (`UiInput`/`UiOutput`/`UiLayout`) plus three orthogonal mixins (`HasInputValue`/`Updatable`/`AllowsChildren`). Concrete classes pick the bases they need. Session is captured once at `UiComponent.__init__`; reads, updates, and bookmark registration all funnel through one helper. Markup is copied (not wrapped) from `shiny.ui.*` so `shinyui` has no runtime dependency on shiny's UI factories. + +**Tech Stack:** Python 3.10+, `shiny>=1.2.0`, `htmltools>=0.6.0`, `pytest`, `pyright`. No new dependencies. + +**Source of truth design spec:** `docs/superpowers/specs/2026-05-13-shinyui-metadata-consolidation-design.md` + +--- + +## File Structure + +``` +pyproject.toml # MODIFY: add shinyui to wheel packages + pyright include + +pkg-py/src/shinyui/ + __init__.py # CREATE: public exports + _base.py # CREATE: UiComponent (ABC) + _children.py # CREATE: AllowsChildren mixin + _input_value.py # CREATE: HasInputValue mixin + _updatable.py # CREATE: Updatable mixin (ABC) + _roles.py # CREATE: UiInput, UiOutput, UiLayout + _reactive.py # CREATE: reactive_calc_method (~15-line decorator) + _input_slider.py # CREATE: UiInputSlider + input_slider() + _input_select.py # CREATE: UiInputSelect + input_select() + _output_code.py # CREATE: UiOutputCode + output_code() + _output_plot.py # CREATE: UiOutputPlot + output_plot() + _accordion_panel.py # CREATE: UiAccordionPanel + accordion_panel() + _accordion.py # CREATE: UiAccordion + accordion() + _card.py # CREATE: UiCard + card() + _bookmark.py # CREATE: id→instance session map + class-owned serializer hook + +pkg-py/tests/shinyui/ + __init__.py # CREATE: empty + conftest.py # CREATE: shared session_context fixture + test_base.py # CREATE: UiComponent unit tests + test_children.py # CREATE: AllowsChildren unit tests + test_input_value.py # CREATE: HasInputValue unit tests + test_updatable.py # CREATE: Updatable unit tests + test_roles.py # CREATE: role-class smoke tests + test_reactive.py # CREATE: reactive_calc_method unit tests + test_input_slider.py # CREATE: per-class tests + snapshot + test_input_select.py # CREATE: per-class tests + snapshot + test_output_code.py # CREATE: per-class tests + snapshot + test_output_plot.py # CREATE: per-class tests + snapshot + test_accordion_panel.py # CREATE: per-class tests + snapshot + test_accordion.py # CREATE: per-class tests + snapshot + test_card.py # CREATE: per-class tests + snapshot + test_hierarchy.py # CREATE: cross-cutting MRO + isinstance + with-block-raises + test_input_handler_registration.py # CREATE: registry contents after import + test_bookmark_roundtrip.py # CREATE: integration test for save/restore + test_update_resolution.py # CREATE: session resolution rules for update() + test_read_accessors.py # CREATE: cross-class accessor behavior + test_allows_children.py # CREATE: cross-class with-block + append behavior + +examples/app-py/14-unified-ui-prototype/ + app.py # CREATE: end-to-end demo + README.md # CREATE: walkthrough +``` + +--- + +## Task 1: Package scaffolding + +**Files:** +- Modify: `pyproject.toml` +- Create: `pkg-py/src/shinyui/__init__.py` +- Create: `pkg-py/tests/shinyui/__init__.py` +- Create: `pkg-py/tests/shinyui/conftest.py` +- Create: `pkg-py/tests/shinyui/test_smoke.py` + +- [ ] **Step 1: Read existing pyproject.toml to confirm current shape** + +Run: read `pyproject.toml`. Confirm `[tool.hatch.build.targets.wheel] packages = ["pkg-py/src/shinyreact"]` and `[tool.pyright] include = ["pkg-py/src/shinyreact"]` are present. + +- [ ] **Step 2: Add shinyui to wheel targets and pyright include** + +Edit `pyproject.toml`: + +```toml +[tool.hatch.build.targets.wheel] +packages = ["pkg-py/src/shinyreact", "pkg-py/src/shinyui"] + +[tool.pyright] +include = ["pkg-py/src/shinyreact", "pkg-py/src/shinyui"] +pythonVersion = "3.10" +typeCheckingMode = "basic" +``` + +- [ ] **Step 3: Create the package skeleton** + +Create `pkg-py/src/shinyui/__init__.py`: + +```python +"""shinyui — prototype class-per-component UI hierarchy. + +See docs/superpowers/specs/2026-05-13-shinyui-metadata-consolidation-design.md. +""" + +__all__: list[str] = [] +``` + +Create `pkg-py/tests/shinyui/__init__.py` as an empty file. + +- [ ] **Step 4: Create shared session fixture** + +Create `pkg-py/tests/shinyui/conftest.py`: + +```python +"""Shared fixtures for shinyui tests. + +Each test that needs `get_current_session()` to return something uses +the `mock_session` fixture, which yields a controllable Session-like object +and binds it as the current session for the duration of the test. +""" +from __future__ import annotations + +from contextlib import contextmanager +from typing import Any, Iterator +from unittest.mock import MagicMock + +import pytest +from shiny.session._utils import session_context + + +@pytest.fixture +def mock_session() -> Iterator[Any]: + """Bind a MagicMock as the current session inside the test body.""" + session = MagicMock(name="MockSession") + session.input = MagicMock(name="MockInput") + with session_context(session): + yield session + + +@contextmanager +def no_session() -> Iterator[None]: + """Helper: confirm no session is bound. Use for explicit clarity in tests.""" + from shiny.session import get_current_session + assert get_current_session() is None, "Test expected no active session" + yield +``` + +- [ ] **Step 5: Write a smoke test** + +Create `pkg-py/tests/shinyui/test_smoke.py`: + +```python +def test_package_importable(): + import shinyui # noqa: F401 + + +def test_mock_session_fixture(mock_session): + from shiny.session import get_current_session + assert get_current_session() is mock_session +``` + +- [ ] **Step 6: Run the smoke test** + +Run: `uv run pytest pkg-py/tests/shinyui/test_smoke.py -v` +Expected: 2 passed. + +- [ ] **Step 7: Commit** + +```bash +git add pyproject.toml pkg-py/src/shinyui pkg-py/tests/shinyui +git commit -m "feat(shinyui): scaffold sibling package + test infra" +``` + +--- + +## Task 2: `UiComponent` base class + +**Files:** +- Create: `pkg-py/src/shinyui/_base.py` +- Create: `pkg-py/tests/shinyui/test_base.py` + +- [ ] **Step 1: Write failing tests for UiComponent** + +Create `pkg-py/tests/shinyui/test_base.py`: + +```python +from __future__ import annotations + +import pytest + +from shinyui._base import UiComponent + + +class _Dummy(UiComponent): + """Minimal concrete subclass for testing.""" + def tagify(self): + from htmltools import tags + return tags.div("dummy") + + +def test_uicomponent_is_abstract(): + with pytest.raises(TypeError): + UiComponent() # type: ignore[abstract] + + +def test_session_captured_as_none_without_session(): + c = _Dummy() + assert c._session is None + + +def test_session_captured_when_present(mock_session): + c = _Dummy() + assert c._session is mock_session + + +def test_require_session_raises_when_none(): + c = _Dummy() + with pytest.raises(RuntimeError, match=r"_Dummy\.foo\(\) requires an active session"): + c._require_session(for_op="foo") + + +def test_require_session_returns_captured(mock_session): + c = _Dummy() + assert c._require_session(for_op="foo") is mock_session + + +def test_require_session_falls_back_to_current(mock_session): + """If _session is None at init but a session is active at call time, use it.""" + from shiny.session._utils import session_context + c = _Dummy() # no session captured (constructed before fixture binding? — re-bind) + c._session = None # explicitly clear + with session_context(mock_session): + assert c._require_session(for_op="foo") is mock_session + + +def test_enter_raises_with_class_name(): + c = _Dummy() + with pytest.raises(TypeError, match=r"_Dummy does not accept children"): + c.__enter__() + + +def test_read_input_uses_current_session_and_id(mock_session): + c = _Dummy() + c.id = "my_id" + mock_session.input.__getitem__.return_value = lambda: 42 + assert c._read_input() == 42 + mock_session.input.__getitem__.assert_called_with("my_id") + + +def test_read_input_suffix(mock_session): + c = _Dummy() + c.id = "p" + mock_session.input.__getitem__.return_value = lambda: {"x": 1} + assert c._read_input("_click") == {"x": 1} + mock_session.input.__getitem__.assert_called_with("p_click") +``` + +- [ ] **Step 2: Run tests — verify they fail** + +Run: `uv run pytest pkg-py/tests/shinyui/test_base.py -v` +Expected: All fail with `ModuleNotFoundError: No module named 'shinyui._base'`. + +- [ ] **Step 3: Implement UiComponent** + +Create `pkg-py/src/shinyui/_base.py`: + +```python +"""UiComponent — abstract base for the shinyui class hierarchy. + +Single source of truth for: + - `self._session`: the active session captured at construction (may be None) + - `_require_session(for_op=...)`: resolves a session at call time, with a fallback + to the current session, raising RuntimeError if none is reachable. + - `_read_input(suffix="")`: reads `session.input[f"{self.id}{suffix}"]()`. + +`tagify()` is abstract. `__enter__` raises by default; `AllowsChildren` overrides. +""" +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any, ClassVar, Self + +from htmltools import HTMLDependency, Tag +from shiny.session import Session, get_current_session + + +class UiComponent(ABC): + html_dependencies: ClassVar[tuple[HTMLDependency, ...]] = () + + def __init__(self, **kwargs: Any) -> None: + # Capture session BEFORE super() so mixins can read self._session + # in their own __init__ after they call super().__init__(**kw). + self._session: Session | None = get_current_session() + super().__init__(**kwargs) + + def _require_session(self, *, for_op: str) -> Session: + sess = self._session or get_current_session() + if sess is None: + raise RuntimeError( + f"{type(self).__name__}.{for_op}() requires an active session " + f"(instance constructed outside any session, and none is active now)" + ) + return sess + + def _read_input(self, suffix: str = "") -> Any: + sess = self._require_session(for_op="_read_input") + return sess.input[f"{self.id}{suffix}"]() # type: ignore[attr-defined] + + @abstractmethod + def tagify(self) -> Tag: ... + + def __enter__(self) -> Self: + raise TypeError( + f"{type(self).__name__} does not accept children; " + f"only components declaring `AllowsChildren` may be used as `with` blocks." + ) + + def __exit__(self, *exc: object) -> None: + return None +``` + +- [ ] **Step 4: Run tests — verify they pass** + +Run: `uv run pytest pkg-py/tests/shinyui/test_base.py -v` +Expected: All pass. + +- [ ] **Step 5: Commit** + +```bash +git add pkg-py/src/shinyui/_base.py pkg-py/tests/shinyui/test_base.py +git commit -m "feat(shinyui): UiComponent base + session/read helpers" +``` + +--- + +## Task 3: `AllowsChildren` mixin + +**Files:** +- Create: `pkg-py/src/shinyui/_children.py` +- Create: `pkg-py/tests/shinyui/test_children.py` + +- [ ] **Step 1: Write failing tests** + +Create `pkg-py/tests/shinyui/test_children.py`: + +```python +from __future__ import annotations + +from htmltools import tags + +from shinyui._base import UiComponent +from shinyui._children import AllowsChildren + + +class _ChildBox(UiComponent, AllowsChildren): + def tagify(self): + return tags.div(*self.children) + + +def test_children_default_empty(): + b = _ChildBox() + assert b.children == [] + + +def test_children_from_positional_args(): + b = _ChildBox("a", "b") + assert b.children == ["a", "b"] + + +def test_append_returns_self_and_mutates(): + b = _ChildBox() + r = b.append("x") + assert r is b + assert b.children == ["x"] + + +def test_with_block_returns_self_and_collects_via_append(): + with _ChildBox() as b: + b.append("inside") + assert b.children == ["inside"] + + +def test_enter_does_not_raise(): + # Inherits from UiComponent (which raises), but AllowsChildren overrides. + b = _ChildBox() + # Should not raise: + assert b.__enter__() is b +``` + +- [ ] **Step 2: Run tests — verify they fail** + +Run: `uv run pytest pkg-py/tests/shinyui/test_children.py -v` +Expected: All fail with `ModuleNotFoundError`. + +- [ ] **Step 3: Implement AllowsChildren** + +Create `pkg-py/src/shinyui/_children.py`: + +```python +"""AllowsChildren — mixin for components that accept children. + +Mixin protocol: + - Subclasses MUST call `super().__init__(**kwargs)` first in their __init__. + - `AllowsChildren.__init__` claims positional args as children and forwards + the remaining kwargs up the MRO. + +Note: the parent-tag context stack (sub-issue 3) is OUT OF SCOPE. __enter__ +returns self with no side effects; auto-collecting bare Tags inside a with-block +is not implemented here. +""" +from __future__ import annotations + +from typing import Any, Self + +from htmltools import TagChild + + +class AllowsChildren: + children: list[TagChild] + + def __init__(self, *children: TagChild, **kwargs: Any) -> None: + self.children = list(children) + super().__init__(**kwargs) + + def append(self, child: TagChild) -> Self: + self.children.append(child) + return self + + def __enter__(self) -> Self: + return self + + def __exit__(self, *exc: object) -> None: + return None +``` + +- [ ] **Step 4: Run tests — verify they pass** + +Run: `uv run pytest pkg-py/tests/shinyui/test_children.py -v` +Expected: All pass. + +- [ ] **Step 5: Commit** + +```bash +git add pkg-py/src/shinyui/_children.py pkg-py/tests/shinyui/test_children.py +git commit -m "feat(shinyui): AllowsChildren mixin" +``` + +--- + +## Task 4: Bookmark id→instance registry + +**Files:** +- Create: `pkg-py/src/shinyui/_bookmark.py` + +(No standalone tests — exercised by HasInputValue and bookmark round-trip tests.) + +- [ ] **Step 1: Implement the registry** + +Create `pkg-py/src/shinyui/_bookmark.py`: + +```python +"""Per-session map: input id -> HasInputValue instance. + +Attached as `session._shinyui_instances` on first registration. This is a private +attribute on Shiny's Session — acceptable for a prototype; Stage B can negotiate +a public hook in py-shiny. +""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from shiny.session import Session + + from ._input_value import HasInputValue + +_ATTR = "_shinyui_instances" + + +def get_session_instances(session: "Session") -> dict[str, "HasInputValue"]: + m = getattr(session, _ATTR, None) + if m is None: + m = {} + setattr(session, _ATTR, m) + return m + + +def register_instance(session: "Session", id: str, instance: "HasInputValue") -> None: + get_session_instances(session)[id] = instance + + +def lookup_instance(session: "Session", id: str) -> "HasInputValue | None": + return get_session_instances(session).get(id) +``` + +- [ ] **Step 2: Commit (no tests yet — exercised by HasInputValue next)** + +```bash +git add pkg-py/src/shinyui/_bookmark.py +git commit -m "feat(shinyui): per-session id->instance registry" +``` + +--- + +## Task 5: `HasInputValue` mixin + +**Files:** +- Create: `pkg-py/src/shinyui/_input_value.py` +- Create: `pkg-py/tests/shinyui/test_input_value.py` + +- [ ] **Step 1: Write failing tests** + +Create `pkg-py/tests/shinyui/test_input_value.py`: + +```python +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock + +import pytest +from htmltools import tags +from shiny._namespaces import ResolvedId + +from shinyui._base import UiComponent +from shinyui._bookmark import get_session_instances, lookup_instance +from shinyui._input_value import HasInputValue + + +class _Pinger(UiComponent, HasInputValue): + input_handler_name = "test.ping" + + @staticmethod + def _input_handler(value: Any, name: ResolvedId, session: Any) -> Any: + return ("pinged", value) + + def tagify(self): + return tags.div(id=self.id) + + +class _Plain(UiComponent, HasInputValue): + """No input_handler — defaults to None.""" + def tagify(self): + return tags.div(id=self.id) + + +def test_id_is_stored(): + p = _Plain(id="x") + assert p.id == "x" + + +def test_no_session_no_registration(): + """Module-level construction: no session, no registry.""" + _Plain(id="x") # should not raise + + +def test_session_registers_self(mock_session): + p = _Plain(id="x") + assert lookup_instance(mock_session, "x") is p + + +def test_register_input_handler_classmethod(monkeypatch): + captured = {} + + def fake_register(name, fn): + captured[name] = fn + + monkeypatch.setattr("shinyui._input_value.register_input_handler", fake_register) + _Pinger._register_input_handler() + assert captured == {"test.ping": _Pinger._input_handler} + + +def test_register_input_handler_noop_when_no_handler(monkeypatch): + captured: dict = {} + monkeypatch.setattr( + "shinyui._input_value.register_input_handler", + lambda n, f: captured.update({n: f}), + ) + _Plain._register_input_handler() + assert captured == {} + + +def test_class_level_bookmark_serializer_inherited(): + class S: + async def serialize(self, value, state_dir): # noqa: D401 + return value + async def deserialize(self, value, state_dir): + return value + + class _Custom(UiComponent, HasInputValue): + bookmark_serializer = S() + def tagify(self): + return tags.div(id=self.id) + + c = _Custom(id="x") + assert c._bookmark_serializer is _Custom.bookmark_serializer + + +def test_per_instance_bookmark_serializer_overrides_class(): + class S: + async def serialize(self, value, state_dir): return value + async def deserialize(self, value, state_dir): return value + + class _Custom(UiComponent, HasInputValue): + bookmark_serializer = S() + def tagify(self): + return tags.div(id=self.id) + + inst_ser = S() + c = _Custom(id="x", bookmark_serializer=inst_ser) + assert c._bookmark_serializer is inst_ser +``` + +- [ ] **Step 2: Run tests — verify they fail** + +Run: `uv run pytest pkg-py/tests/shinyui/test_input_value.py -v` +Expected: All fail with `ModuleNotFoundError`. + +- [ ] **Step 3: Implement HasInputValue** + +Create `pkg-py/src/shinyui/_input_value.py`: + +```python +"""HasInputValue — mixin for components that own a server-readable input id. + +Provides: + - `id: str` (stored on instance) + - `input_handler_name` and `_input_handler` ClassVars (default to empty / None) + - `bookmark_serializer` ClassVar default + per-instance override + - `_register_input_handler()` classmethod for explicit module-load registration + - id->instance registration on construction (no-op if no session) + +Mixin protocol: subclasses MUST call `super().__init__(id=..., **kw)` first. +""" +from __future__ import annotations + +from typing import Any, Callable, ClassVar + +from shiny._namespaces import ResolvedId # noqa: F401 (typing reference) +from shiny.bookmark._serializers import Serializer +from shiny.session._session import register_input_handler + +from ._bookmark import register_instance + + +class HasInputValue: + input_handler_name: ClassVar[str] = "" + _input_handler: ClassVar[Callable[..., Any] | None] = None + bookmark_serializer: ClassVar[Serializer | None] = None + + @classmethod + def _register_input_handler(cls) -> None: + """Idempotent. Call once at module load if this class declares a handler.""" + if cls.input_handler_name and cls._input_handler is not None: + register_input_handler(cls.input_handler_name, cls._input_handler) + + def __init__( + self, + *, + id: str, + bookmark_serializer: Serializer | None = None, + **kwargs: Any, + ) -> None: + self.id = id + self._bookmark_serializer: Serializer | None = ( + bookmark_serializer if bookmark_serializer is not None else type(self).bookmark_serializer + ) + super().__init__(**kwargs) + # After super().__init__: UiComponent has set self._session. + if self._session is not None: # type: ignore[attr-defined] + register_instance(self._session, id, self) # type: ignore[arg-type] +``` + +Note on the bookmark serializer type: confirm the precise import path of `Serializer` in the installed Shiny version. If the import line above fails, substitute the appropriate name from `shiny.bookmark`. If unavailable, fall back to `Any`. + +- [ ] **Step 4: Run tests — verify they pass** + +Run: `uv run pytest pkg-py/tests/shinyui/test_input_value.py -v` +Expected: All pass. If `Serializer` import fails, adjust to `from shiny.bookmark import Serializer` or use `Any` and re-run. + +- [ ] **Step 5: Commit** + +```bash +git add pkg-py/src/shinyui/_input_value.py pkg-py/tests/shinyui/test_input_value.py +git commit -m "feat(shinyui): HasInputValue mixin + handler registration" +``` + +--- + +## Task 6: `Updatable` mixin + +**Files:** +- Create: `pkg-py/src/shinyui/_updatable.py` +- Create: `pkg-py/tests/shinyui/test_updatable.py` + +- [ ] **Step 1: Write failing tests** + +Create `pkg-py/tests/shinyui/test_updatable.py`: + +```python +from __future__ import annotations + +import pytest +from htmltools import tags + +from shinyui._base import UiComponent +from shinyui._updatable import Updatable + + +class _AbstractStub(UiComponent, Updatable): + """Does NOT implement update() — should remain abstract.""" + def tagify(self): + return tags.div() + + +class _Concrete(UiComponent, Updatable): + last_kwargs: dict | None = None + + def tagify(self): + return tags.div() + + def update(self, *, value: int | None = None) -> None: + type(self).last_kwargs = {"value": value} + + +def test_abstract_class_cannot_instantiate(): + with pytest.raises(TypeError): + _AbstractStub() # type: ignore[abstract] + + +def test_concrete_class_instantiates(): + c = _Concrete() + assert c is not None + + +def test_update_callable_on_concrete(): + c = _Concrete() + c.update(value=42) + assert _Concrete.last_kwargs == {"value": 42} +``` + +- [ ] **Step 2: Run tests — verify they fail** + +Run: `uv run pytest pkg-py/tests/shinyui/test_updatable.py -v` +Expected: All fail with `ModuleNotFoundError`. + +- [ ] **Step 3: Implement Updatable** + +Create `pkg-py/src/shinyui/_updatable.py`: + +```python +"""Updatable — marker mixin for components that support server-driven update(). + +`update()` is abstract; concrete subclasses provide a typed `update(*, ...)` +signature with the specific kwargs they accept. No `session=` kwarg — session +is captured by UiComponent.__init__ and resolved at call time via +`self._require_session(for_op="update")`. +""" +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any + + +class Updatable(ABC): + @abstractmethod + def update(self, **kwargs: Any) -> None: ... +``` + +- [ ] **Step 4: Run tests — verify they pass** + +Run: `uv run pytest pkg-py/tests/shinyui/test_updatable.py -v` +Expected: All pass. + +- [ ] **Step 5: Commit** + +```bash +git add pkg-py/src/shinyui/_updatable.py pkg-py/tests/shinyui/test_updatable.py +git commit -m "feat(shinyui): Updatable abstract mixin" +``` + +--- + +## Task 7: Role classes — `UiInput`, `UiOutput`, `UiLayout` + +**Files:** +- Create: `pkg-py/src/shinyui/_roles.py` +- Create: `pkg-py/tests/shinyui/test_roles.py` + +- [ ] **Step 1: Write failing tests** + +Create `pkg-py/tests/shinyui/test_roles.py`: + +```python +from __future__ import annotations + +from htmltools import tags + +from shinyui._base import UiComponent +from shinyui._input_value import HasInputValue +from shinyui._roles import UiInput, UiLayout, UiOutput + + +class _MyInput(UiInput): + def tagify(self): + return tags.div(id=self.id) + + +class _MyOutput(UiOutput): + def __init__(self, id: str) -> None: + self.id = id + super().__init__() + + def tagify(self): + return tags.div(id=self.id) + + +class _MyLayout(UiLayout): + def tagify(self): + return tags.div() + + +def test_uiinput_inherits_uicomponent_and_hasinputvalue(): + inst = _MyInput(id="x") + assert isinstance(inst, UiComponent) + assert isinstance(inst, HasInputValue) + + +def test_uioutput_has_id_attribute(): + inst = _MyOutput(id="y") + assert inst.id == "y" + + +def test_uilayout_does_not_have_hasinputvalue_by_default(): + inst = _MyLayout() + assert not isinstance(inst, HasInputValue) +``` + +- [ ] **Step 2: Run tests — verify they fail** + +Run: `uv run pytest pkg-py/tests/shinyui/test_roles.py -v` +Expected: All fail with `ModuleNotFoundError`. + +- [ ] **Step 3: Implement role classes** + +Create `pkg-py/src/shinyui/_roles.py`: + +```python +"""Semantic role classes — UiInput, UiOutput, UiLayout. + +These are markers indicating the component's primary purpose. State-bearing +and child-bearing capabilities are provided by orthogonal mixins +(HasInputValue, Updatable, AllowsChildren). +""" +from __future__ import annotations + +from ._base import UiComponent +from ._input_value import HasInputValue + + +class UiInput(UiComponent, HasInputValue): + """Primarily a user-input control.""" + + +class UiOutput(UiComponent): + """Primarily a server-rendered output. + + Carries its own `id` attribute (set by subclasses' __init__); does NOT + inherit HasInputValue (no bookmark serializer, no id->instance map). + Subclasses that expose read-only signals add accessors directly. + """ + + +class UiLayout(UiComponent): + """Primarily a container. + + No id by itself; layouts that expose state add HasInputValue + Updatable. + """ +``` + +- [ ] **Step 4: Run tests — verify they pass** + +Run: `uv run pytest pkg-py/tests/shinyui/test_roles.py -v` +Expected: All pass. + +- [ ] **Step 5: Commit** + +```bash +git add pkg-py/src/shinyui/_roles.py pkg-py/tests/shinyui/test_roles.py +git commit -m "feat(shinyui): UiInput / UiOutput / UiLayout role classes" +``` + +--- + +## Task 8: `reactive_calc_method` helper + +**Files:** +- Create: `pkg-py/src/shinyui/_reactive.py` +- Create: `pkg-py/tests/shinyui/test_reactive.py` + +- [ ] **Step 1: Write failing tests** + +Create `pkg-py/tests/shinyui/test_reactive.py`: + +```python +from __future__ import annotations + +from shiny import reactive + +from shinyui._reactive import reactive_calc_method + + +class _Counter: + """Tests caching: the wrapped method is invoked once per change.""" + def __init__(self) -> None: + self.calls = 0 + + @reactive_calc_method + def value(self) -> int: + self.calls += 1 + return 42 + + +def test_method_returns_value_under_reactive_isolate(): + c = _Counter() + with reactive.isolate(): + assert c.value() == 42 + + +def test_cached_per_instance(): + """Two different instances should have independent caches.""" + a = _Counter() + b = _Counter() + with reactive.isolate(): + assert a.value() == 42 + assert b.value() == 42 + assert a.calls == 1 + assert b.calls == 1 +``` + +- [ ] **Step 2: Run tests — verify they fail** + +Run: `uv run pytest pkg-py/tests/shinyui/test_reactive.py -v` +Expected: All fail with `ModuleNotFoundError`. + +- [ ] **Step 3: Implement reactive_calc_method** + +Create `pkg-py/src/shinyui/_reactive.py`: + +```python +"""reactive_calc_method — per-instance @reactive.calc decorator. + +Inspired by Shiny's `shiny.render._data_frame_utils._reactive_method.reactive_calc_method`. +We hand-roll a small local equivalent (~15 lines) to avoid coupling to a Shiny +private import. Stage B in py-shiny may extract the decorator to a public helper. +""" +from __future__ import annotations + +from typing import Any, Callable, TypeVar +from weakref import WeakKeyDictionary + +from shiny import reactive + +T = TypeVar("T") + + +def reactive_calc_method(fn: Callable[[Any], T]) -> Callable[[Any], T]: + cache: WeakKeyDictionary[Any, reactive.Calc_[T]] = WeakKeyDictionary() + + def wrapper(self: Any) -> T: + calc = cache.get(self) + if calc is None: + @reactive.calc + def _calc() -> T: + return fn(self) + calc = _calc + cache[self] = calc + return calc() + + wrapper.__name__ = fn.__name__ + wrapper.__doc__ = fn.__doc__ + return wrapper +``` + +- [ ] **Step 4: Run tests — verify they pass** + +Run: `uv run pytest pkg-py/tests/shinyui/test_reactive.py -v` +Expected: All pass. If `reactive.Calc_` typing fails, drop the explicit annotation on `cache` (use `WeakKeyDictionary[Any, Any]`). + +- [ ] **Step 5: Commit** + +```bash +git add pkg-py/src/shinyui/_reactive.py pkg-py/tests/shinyui/test_reactive.py +git commit -m "feat(shinyui): local reactive_calc_method helper" +``` + +--- + +## Task 9: `UiInputSlider` + +**Files:** +- Create: `pkg-py/src/shinyui/_input_slider.py` +- Create: `pkg-py/tests/shinyui/test_input_slider.py` + +**Reference markup source:** `shiny/ui/_input_slider.py` — `input_slider(id, label, min, max, value, step=, ticks=, animate=, width=, sep=, pre=, post=, time_format=, timezone=, drag_range=)`. + +- [ ] **Step 1: Write failing tests** + +Create `pkg-py/tests/shinyui/test_input_slider.py`: + +```python +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest +import shiny.ui as sui +from htmltools import Tag + +from shinyui._input_slider import UiInputSlider, input_slider + + +def test_factory_returns_instance(): + s = input_slider("n", "N", 1, 100, 50) + assert isinstance(s, UiInputSlider) + assert s.id == "n" + + +def test_tagify_matches_shiny_ui_input_slider(): + ours = input_slider("n", "N", 1, 100, 50).tagify() + theirs = sui.input_slider("n", "N", 1, 100, 50) + assert ours.get_html_string() == theirs.get_html_string() + + +def test_value_accessor_reads_input(mock_session): + s = input_slider("n", "N", 1, 100, 50) + mock_session.input.__getitem__.return_value = lambda: 25 + from shiny import reactive + with reactive.isolate(): + assert s.value() == 25 + mock_session.input.__getitem__.assert_called_with("n") + + +def test_update_outside_session_raises(): + s = input_slider("n", "N", 1, 100, 50) # no session at construction + with pytest.raises(RuntimeError, match=r"UiInputSlider\.update\(\) requires an active session"): + s.update(value=42) + + +def test_update_uses_captured_session(mock_session): + s = input_slider("n", "N", 1, 100, 50) + s.update(value=42) + mock_session.send_input_message.assert_called_once() + name, payload = mock_session.send_input_message.call_args.args + assert name == "n" + assert payload["value"] == 42 +``` + +- [ ] **Step 2: Run tests — verify they fail** + +Run: `uv run pytest pkg-py/tests/shinyui/test_input_slider.py -v` +Expected: All fail with `ModuleNotFoundError`. + +- [ ] **Step 3: Implement UiInputSlider** + +Read `shiny/ui/_input_slider.py` (run: `uv run python -c "import shiny.ui._input_slider as m; print(m.__file__)"`) to find the markup-construction logic. Copy the Tag-construction body into `tagify()` below, mapping each function argument to `self.`. + +Read `shiny/ui/_input_update.py` (the `update_slider` function) to find the `send_input_message` payload shape for slider updates. + +Create `pkg-py/src/shinyui/_input_slider.py`: + +```python +"""UiInputSlider — class-based input_slider with typed update() and value() accessor.""" +from __future__ import annotations + +from typing import Any + +from htmltools import Tag + +from ._reactive import reactive_calc_method +from ._roles import UiInput +from ._updatable import Updatable + +_MISSING = object() + + +class UiInputSlider(UiInput, Updatable): + def __init__( + self, + id: str, + label: str, + min: float, + max: float, + value: float | tuple[float, float], + *, + step: float | None = None, + ticks: bool = False, + animate: bool = False, + width: str | None = None, + sep: str = ",", + pre: str | None = None, + post: str | None = None, + time_format: str | None = None, + timezone: str | None = None, + drag_range: bool = True, + ) -> None: + self.label = label + self.min = min + self.max = max + self.value = value # NOTE: value attribute is shadowed by value() accessor at class level; + # store as _init_value to avoid the collision. + del self.value + self._init_value = value + self.step = step + self.ticks = ticks + self.animate = animate + self.width = width + self.sep = sep + self.pre = pre + self.post = post + self.time_format = time_format + self.timezone = timezone + self.drag_range = drag_range + super().__init__(id=id) + + @reactive_calc_method + def value(self) -> Any: + return self._read_input() + + def tagify(self) -> Tag: + # COPY: Reproduce shiny.ui._input_slider.input_slider's Tag construction here. + # Map each argument to self.; use self._init_value for the initial value. + # The shiny source file is at: shiny/ui/_input_slider.py + import shiny.ui as _sui + return _sui.input_slider( # interim: delegate while implementer copies real markup + self.id, self.label, self.min, self.max, self._init_value, + step=self.step, ticks=self.ticks, animate=self.animate, + width=self.width, sep=self.sep, pre=self.pre, post=self.post, + time_format=self.time_format, timezone=self.timezone, drag_range=self.drag_range, + ) + + def update( + self, + *, + value: Any = _MISSING, + min: float = _MISSING, # type: ignore[assignment] + max: float = _MISSING, # type: ignore[assignment] + step: float = _MISSING, # type: ignore[assignment] + label: str = _MISSING, # type: ignore[assignment] + ) -> None: + sess = self._require_session(for_op="update") + msg: dict[str, Any] = {} + if value is not _MISSING: msg["value"] = value + if min is not _MISSING: msg["min"] = min + if max is not _MISSING: msg["max"] = max + if step is not _MISSING: msg["step"] = step + if label is not _MISSING: msg["label"] = label + sess.send_input_message(self.id, msg) + + +def input_slider( + id: str, + label: str, + min: float, + max: float, + value: float | tuple[float, float], + **kwargs: Any, +) -> UiInputSlider: + return UiInputSlider(id, label, min, max, value, **kwargs) +``` + +**Implementer note:** the `tagify()` body above delegates to `shiny.ui.input_slider` as a temporary measure so the snapshot test passes immediately. Replace with a copy-pasted construction body once the test is green (this keeps the prototype dep-free per spec). The snapshot test is the regression net — when you swap the body, re-run the test to confirm equivalence. + +- [ ] **Step 4: Run tests — verify they pass** + +Run: `uv run pytest pkg-py/tests/shinyui/test_input_slider.py -v` +Expected: All pass. + +- [ ] **Step 5: Inline the tagify markup** + +Read `shiny/ui/_input_slider.py` and copy the Tag construction body into `UiInputSlider.tagify()`, replacing the delegation. Re-run the snapshot test: + +Run: `uv run pytest pkg-py/tests/shinyui/test_input_slider.py::test_tagify_matches_shiny_ui_input_slider -v` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add pkg-py/src/shinyui/_input_slider.py pkg-py/tests/shinyui/test_input_slider.py +git commit -m "feat(shinyui): UiInputSlider + input_slider() factory" +``` + +--- + +## Task 10: `UiInputSelect` + +**Files:** +- Create: `pkg-py/src/shinyui/_input_select.py` +- Create: `pkg-py/tests/shinyui/test_input_select.py` + +**Reference markup source:** `shiny/ui/_input_select.py` — `input_select(id, label, choices, selected=, multiple=, selectize=, width=, size=, remove_button=)`. + +- [ ] **Step 1: Write failing tests** + +Create `pkg-py/tests/shinyui/test_input_select.py`: + +```python +from __future__ import annotations + +import pytest +import shiny.ui as sui + +from shinyui._input_select import UiInputSelect, input_select + + +def test_factory_returns_instance(): + s = input_select("col", "Column", {"a": "A", "b": "B"}) + assert isinstance(s, UiInputSelect) + + +def test_tagify_matches_shiny_ui_input_select(): + ours = input_select("col", "Column", {"a": "A", "b": "B"}).tagify() + theirs = sui.input_select("col", "Column", {"a": "A", "b": "B"}) + assert ours.get_html_string() == theirs.get_html_string() + + +def test_value_accessor(mock_session): + s = input_select("col", "Column", {"a": "A"}) + mock_session.input.__getitem__.return_value = lambda: "a" + from shiny import reactive + with reactive.isolate(): + assert s.value() == "a" + + +def test_update_outside_session_raises(): + s = input_select("col", "Column", {"a": "A"}) + with pytest.raises(RuntimeError): + s.update(selected="a") + + +def test_update_sends_message(mock_session): + s = input_select("col", "Column", {"a": "A"}) + s.update(selected="a") + mock_session.send_input_message.assert_called_once() + name, payload = mock_session.send_input_message.call_args.args + assert name == "col" + assert payload["value"] == "a" # shiny.ui.update_select uses "value" key +``` + +- [ ] **Step 2: Run tests — verify they fail** + +Run: `uv run pytest pkg-py/tests/shinyui/test_input_select.py -v` +Expected: All fail with `ModuleNotFoundError`. + +- [ ] **Step 3: Implement UiInputSelect** + +Create `pkg-py/src/shinyui/_input_select.py`: + +```python +"""UiInputSelect — class-based input_select.""" +from __future__ import annotations + +from typing import Any, Mapping + +from htmltools import Tag + +from ._reactive import reactive_calc_method +from ._roles import UiInput +from ._updatable import Updatable + +_MISSING = object() + +SelectChoices = Mapping[str, str] | Mapping[str, Mapping[str, str]] + + +class UiInputSelect(UiInput, Updatable): + def __init__( + self, + id: str, + label: str, + choices: SelectChoices, + *, + selected: str | tuple[str, ...] | None = None, + multiple: bool = False, + selectize: bool = False, + width: str | None = None, + size: str | None = None, + remove_button: bool | None = None, + ) -> None: + self.label = label + self.choices = choices + self._init_selected = selected + self.multiple = multiple + self.selectize = selectize + self.width = width + self.size = size + self.remove_button = remove_button + super().__init__(id=id) + + @reactive_calc_method + def value(self) -> Any: + return self._read_input() + + def tagify(self) -> Tag: + import shiny.ui as _sui + # Implementer: copy shiny.ui._input_select markup body here. Interim delegation: + return _sui.input_select( + self.id, self.label, self.choices, + selected=self._init_selected, multiple=self.multiple, + selectize=self.selectize, width=self.width, size=self.size, + remove_button=self.remove_button, + ) + + def update( + self, + *, + label: str = _MISSING, # type: ignore[assignment] + choices: SelectChoices = _MISSING, # type: ignore[assignment] + selected: str | tuple[str, ...] = _MISSING, # type: ignore[assignment] + ) -> None: + sess = self._require_session(for_op="update") + msg: dict[str, Any] = {} + if label is not _MISSING: msg["label"] = label + if choices is not _MISSING: msg["options"] = choices # Implementer: confirm key vs shiny.ui.update_select + if selected is not _MISSING: msg["value"] = selected + sess.send_input_message(self.id, msg) + + +def input_select( + id: str, + label: str, + choices: SelectChoices, + **kwargs: Any, +) -> UiInputSelect: + return UiInputSelect(id, label, choices, **kwargs) +``` + +- [ ] **Step 4: Run tests — verify they pass** + +Run: `uv run pytest pkg-py/tests/shinyui/test_input_select.py -v` +Expected: All pass. If the `update` payload key doesn't match (`options` vs `choices`), check `shiny.ui._input_update.update_select` for the actual key name. + +- [ ] **Step 5: Inline tagify markup, re-run snapshot** + +Same procedure as Task 9 Step 5. + +- [ ] **Step 6: Commit** + +```bash +git add pkg-py/src/shinyui/_input_select.py pkg-py/tests/shinyui/test_input_select.py +git commit -m "feat(shinyui): UiInputSelect + input_select() factory" +``` + +--- + +## Task 11: `UiOutputCode` + +**Files:** +- Create: `pkg-py/src/shinyui/_output_code.py` +- Create: `pkg-py/tests/shinyui/test_output_code.py` + +**Reference markup source:** `shiny/ui/_output.py` — `output_code(id, placeholder=)`. + +- [ ] **Step 1: Write failing tests** + +Create `pkg-py/tests/shinyui/test_output_code.py`: + +```python +from __future__ import annotations + +import shiny.ui as sui + +from shinyui._output_code import UiOutputCode, output_code + + +def test_factory_returns_instance(): + o = output_code("summary") + assert isinstance(o, UiOutputCode) + assert o.id == "summary" + + +def test_tagify_matches_shiny_ui_output_code(): + ours = output_code("summary").tagify() + theirs = sui.output_code("summary") + assert ours.get_html_string() == theirs.get_html_string() +``` + +- [ ] **Step 2: Run tests — verify they fail** + +Run: `uv run pytest pkg-py/tests/shinyui/test_output_code.py -v` +Expected: All fail with `ModuleNotFoundError`. + +- [ ] **Step 3: Implement UiOutputCode** + +Create `pkg-py/src/shinyui/_output_code.py`: + +```python +"""UiOutputCode — class-based output_code.""" +from __future__ import annotations + +from typing import Any + +from htmltools import Tag + +from ._roles import UiOutput + + +class UiOutputCode(UiOutput): + def __init__(self, id: str, *, placeholder: bool = False) -> None: + self.id = id + self.placeholder = placeholder + super().__init__() + + def tagify(self) -> Tag: + import shiny.ui as _sui + return _sui.output_code(self.id, placeholder=self.placeholder) # Implementer: inline markup + + +def output_code(id: str, *, placeholder: bool = False) -> UiOutputCode: + return UiOutputCode(id, placeholder=placeholder) +``` + +- [ ] **Step 4: Run tests — verify they pass** + +Run: `uv run pytest pkg-py/tests/shinyui/test_output_code.py -v` +Expected: All pass. + +- [ ] **Step 5: Inline tagify markup, re-run snapshot** + +- [ ] **Step 6: Commit** + +```bash +git add pkg-py/src/shinyui/_output_code.py pkg-py/tests/shinyui/test_output_code.py +git commit -m "feat(shinyui): UiOutputCode + output_code() factory" +``` + +--- + +## Task 12: `UiOutputPlot` + +**Files:** +- Create: `pkg-py/src/shinyui/_output_plot.py` +- Create: `pkg-py/tests/shinyui/test_output_plot.py` + +**Reference markup source:** `shiny/ui/_output.py` — `output_plot(id, width=, height=, inline=, click=, dblclick=, hover=, brush=, fill=)`. + +- [ ] **Step 1: Write failing tests** + +Create `pkg-py/tests/shinyui/test_output_plot.py`: + +```python +from __future__ import annotations + +import pytest +import shiny.ui as sui +from shiny import reactive + +from shinyui._output_plot import UiOutputPlot, output_plot + + +def test_factory_returns_instance(): + p = output_plot("p", click=True, brush=True) + assert isinstance(p, UiOutputPlot) + assert p.id == "p" + + +def test_tagify_matches_shiny_ui_output_plot(): + ours = output_plot("p", click=True, brush=True).tagify() + theirs = sui.output_plot("p", click=True, brush=True) + assert ours.get_html_string() == theirs.get_html_string() + + +def test_click_value_reads_correct_id(mock_session): + p = output_plot("p", click=True) + mock_session.input.__getitem__.return_value = lambda: {"x": 10, "y": 20} + with reactive.isolate(): + assert p.click_value() == {"x": 10, "y": 20} + mock_session.input.__getitem__.assert_called_with("p_click") + + +def test_brush_value_reads_correct_id(mock_session): + p = output_plot("p", brush=True) + mock_session.input.__getitem__.return_value = lambda: {"xmin": 1, "xmax": 2} + with reactive.isolate(): + assert p.brush_value() == {"xmin": 1, "xmax": 2} + mock_session.input.__getitem__.assert_called_with("p_brush") + + +def test_hover_and_dblclick_values(mock_session): + p = output_plot("p", hover=True, dblclick=True) + seq = iter([{"x": 1}, {"x": 2}]) + mock_session.input.__getitem__.return_value = lambda: next(seq) + with reactive.isolate(): + assert p.hover_value() == {"x": 1} + assert p.dblclick_value() == {"x": 2} + + +def test_no_update_method(): + p = output_plot("p") + assert not hasattr(p, "update") + + +def test_no_input_handlers_registered_for_plot(monkeypatch): + """Plot should not call register_input_handler in its module.""" + # Re-import to confirm no side effects beyond the import. + import importlib + import shinyui._output_plot as m + importlib.reload(m) +``` + +- [ ] **Step 2: Run tests — verify they fail** + +Run: `uv run pytest pkg-py/tests/shinyui/test_output_plot.py -v` +Expected: All fail with `ModuleNotFoundError`. + +- [ ] **Step 3: Implement UiOutputPlot** + +Create `pkg-py/src/shinyui/_output_plot.py`: + +```python +"""UiOutputPlot — output with read-only client-side interaction signals. + +Derived input ids: + input._click — {x, y} | None + input._dblclick — {x, y} | None + input._hover — {x, y} | None + input._brush — {xmin, xmax, ymin, ymax, ...} | None + +Plot does NOT use HasInputValue. Derived inputs flow through Shiny's +auto-created Value[Any] mechanism on first session.input[...] access; no +custom input handlers are registered for these wire types. +""" +from __future__ import annotations + +from typing import Any + +from htmltools import Tag + +from ._reactive import reactive_calc_method +from ._roles import UiOutput + + +class UiOutputPlot(UiOutput): + def __init__( + self, + id: str, + *, + width: str = "100%", + height: str = "400px", + inline: bool = False, + click: bool = False, + dblclick: bool = False, + hover: bool = False, + brush: bool = False, + fill: bool = False, + ) -> None: + self.id = id + self.width = width + self.height = height + self.inline = inline + self.click_enabled = click + self.dblclick_enabled = dblclick + self.hover_enabled = hover + self.brush_enabled = brush + self.fill = fill + super().__init__() + + @reactive_calc_method + def click_value(self) -> Any: return self._read_input("_click") + + @reactive_calc_method + def dblclick_value(self) -> Any: return self._read_input("_dblclick") + + @reactive_calc_method + def hover_value(self) -> Any: return self._read_input("_hover") + + @reactive_calc_method + def brush_value(self) -> Any: return self._read_input("_brush") + + def tagify(self) -> Tag: + import shiny.ui as _sui + return _sui.output_plot( + self.id, width=self.width, height=self.height, inline=self.inline, + click=self.click_enabled, dblclick=self.dblclick_enabled, + hover=self.hover_enabled, brush=self.brush_enabled, fill=self.fill, + ) + + +def output_plot(id: str, **kwargs: Any) -> UiOutputPlot: + return UiOutputPlot(id, **kwargs) +``` + +- [ ] **Step 4: Run tests — verify they pass** + +Run: `uv run pytest pkg-py/tests/shinyui/test_output_plot.py -v` +Expected: All pass. + +- [ ] **Step 5: Inline tagify markup, re-run snapshot** + +- [ ] **Step 6: Commit** + +```bash +git add pkg-py/src/shinyui/_output_plot.py pkg-py/tests/shinyui/test_output_plot.py +git commit -m "feat(shinyui): UiOutputPlot with read-only signal accessors" +``` + +--- + +## Task 13: `UiAccordionPanel` + +**Files:** +- Create: `pkg-py/src/shinyui/_accordion_panel.py` +- Create: `pkg-py/tests/shinyui/test_accordion_panel.py` + +**Reference markup source:** `shiny/ui/_accordion.py` — `accordion_panel(title, *args, value=, icon=)`. + +- [ ] **Step 1: Write failing tests** + +Create `pkg-py/tests/shinyui/test_accordion_panel.py`: + +```python +from __future__ import annotations + +import shiny.ui as sui +from htmltools import tags + +from shinyui._accordion_panel import UiAccordionPanel, accordion_panel +from shinyui._children import AllowsChildren + + +def test_factory_returns_instance(): + p = accordion_panel("Settings", "body") + assert isinstance(p, UiAccordionPanel) + assert isinstance(p, AllowsChildren) + + +def test_children_collected(): + p = accordion_panel("Settings", "a", "b") + assert "a" in p.children and "b" in p.children + + +def test_tagify_matches_shiny(): + ours = accordion_panel("Settings", "body").tagify() + theirs = sui.accordion_panel("Settings", "body") + assert ours.get_html_string() == theirs.get_html_string() + + +def test_with_block_appends(): + with accordion_panel("Settings") as p: + p.append(tags.p("inside")) + assert len(p.children) == 1 +``` + +- [ ] **Step 2: Run tests — verify they fail** + +Run: `uv run pytest pkg-py/tests/shinyui/test_accordion_panel.py -v` +Expected: All fail with `ModuleNotFoundError`. + +- [ ] **Step 3: Implement UiAccordionPanel** + +Create `pkg-py/src/shinyui/_accordion_panel.py`: + +```python +"""UiAccordionPanel — layout child of UiAccordion.""" +from __future__ import annotations + +from typing import Any + +from htmltools import Tag, TagChild + +from ._children import AllowsChildren +from ._roles import UiLayout + + +class UiAccordionPanel(UiLayout, AllowsChildren): + def __init__( + self, + title: str, + *args: TagChild, + value: str | None = None, + icon: TagChild | None = None, + ) -> None: + self.title = title + self._value = value + self.icon = icon + super().__init__(*args) + + @property + def value(self) -> str: + return self._value if self._value is not None else self.title + + def tagify(self) -> Tag: + import shiny.ui as _sui + return _sui.accordion_panel( + self.title, *self.children, value=self._value, icon=self.icon, + ) + + +def accordion_panel(title: str, *args: TagChild, **kwargs: Any) -> UiAccordionPanel: + return UiAccordionPanel(title, *args, **kwargs) +``` + +- [ ] **Step 4: Run tests — verify they pass** + +Run: `uv run pytest pkg-py/tests/shinyui/test_accordion_panel.py -v` +Expected: All pass. + +- [ ] **Step 5: Inline tagify markup, re-run snapshot** + +- [ ] **Step 6: Commit** + +```bash +git add pkg-py/src/shinyui/_accordion_panel.py pkg-py/tests/shinyui/test_accordion_panel.py +git commit -m "feat(shinyui): UiAccordionPanel + accordion_panel() factory" +``` + +--- + +## Task 14: `UiAccordion` + +**Files:** +- Create: `pkg-py/src/shinyui/_accordion.py` +- Create: `pkg-py/tests/shinyui/test_accordion.py` + +**Reference markup source:** `shiny/ui/_accordion.py` — `accordion(*args, id=None, open=None, multiple=True, class_=None, width=None, height=None)`. + +Read `shiny/_input_handler.py` (or wherever `input_handlers` is registered) to find the handler name registered for accordion. Common candidate: `"shiny.bindings.accordion"` or `"shinyAccordion"`. Use that exact string for `input_handler_name`. Read `shiny.ui._input_update.update_accordion` to find the update payload shape. + +- [ ] **Step 1: Write failing tests** + +Create `pkg-py/tests/shinyui/test_accordion.py`: + +```python +from __future__ import annotations + +import pytest +import shiny.ui as sui +from shiny import reactive + +from shinyui._accordion import UiAccordion, accordion +from shinyui._accordion_panel import accordion_panel +from shinyui._children import AllowsChildren +from shinyui._input_value import HasInputValue +from shinyui._updatable import Updatable + + +def test_factory_returns_instance(): + a = accordion(accordion_panel("A"), accordion_panel("B"), id="acc") + assert isinstance(a, UiAccordion) + assert isinstance(a, HasInputValue) + assert isinstance(a, AllowsChildren) + assert isinstance(a, Updatable) + + +def test_tagify_matches_shiny(): + ours = accordion( + accordion_panel("A", "body-a"), accordion_panel("B", "body-b"), + id="acc", open="A", + ).tagify() + theirs = sui.accordion( + sui.accordion_panel("A", "body-a"), sui.accordion_panel("B", "body-b"), + id="acc", open="A", + ) + assert ours.get_html_string() == theirs.get_html_string() + + +def test_open_panels_accessor(mock_session): + a = accordion(accordion_panel("A"), id="acc") + mock_session.input.__getitem__.return_value = lambda: ["A"] + with reactive.isolate(): + assert a.open_panels() == ("A",) + + +def test_update_outside_session_raises(): + a = accordion(accordion_panel("A"), id="acc") + with pytest.raises(RuntimeError): + a.update(open=("A",)) + + +def test_update_sends_message(mock_session): + a = accordion(accordion_panel("A"), accordion_panel("B"), id="acc") + a.update(open=("A", "B")) + mock_session.send_input_message.assert_called_once() + + +def test_input_handler_is_registered_after_import(): + from shiny._input_handler import input_handlers + # input_handler_name is the wire-type for accordion (verify against shiny source). + assert UiAccordion.input_handler_name in input_handlers._handlers +``` + +- [ ] **Step 2: Run tests — verify they fail** + +Run: `uv run pytest pkg-py/tests/shinyui/test_accordion.py -v` +Expected: All fail with `ModuleNotFoundError`. + +- [ ] **Step 3: Implement UiAccordion** + +Create `pkg-py/src/shinyui/_accordion.py`: + +```python +"""UiAccordion — layout with multiple panels; exposes open-panel set as input value.""" +from __future__ import annotations + +from typing import Any + +from htmltools import Tag + +from ._children import AllowsChildren +from ._input_value import HasInputValue +from ._reactive import reactive_calc_method +from ._roles import UiLayout +from ._updatable import Updatable + +_MISSING = object() + + +def _accordion_input_handler(value: Any, name: Any, session: Any) -> Any: + """Coerce accordion's open-panel list into a tuple for tidy server use.""" + return tuple(value) if value is not None else () + + +class UiAccordion(UiLayout, AllowsChildren, HasInputValue, Updatable): + # IMPLEMENTER: Confirm the exact wire-type string by reading shiny.ui._accordion.py + # for `register_input_handler("...", ...)`. Adjust the literal below if wrong; + # the test_input_handler_is_registered_after_import test pins it. + input_handler_name: ClassVar[str] = "shiny.bindings.accordion" + _input_handler = staticmethod(_accordion_input_handler) + + def __init__( + self, + *args: Any, + id: str, + open: str | tuple[str, ...] | bool | None = None, + multiple: bool = True, + class_: str | None = None, + width: str | None = None, + height: str | None = None, + ) -> None: + self._open = open + self.multiple = multiple + self.class_ = class_ + self.width = width + self.height = height + super().__init__(*args, id=id) + + @reactive_calc_method + def open_panels(self) -> tuple[str, ...]: + return tuple(self._read_input() or ()) + + def tagify(self) -> Tag: + import shiny.ui as _sui + return _sui.accordion( + *self.children, id=self.id, open=self._open, multiple=self.multiple, + class_=self.class_, width=self.width, height=self.height, + ) + + def update( + self, + *, + open: tuple[str, ...] = _MISSING, # type: ignore[assignment] + show: tuple[str, ...] = _MISSING, # type: ignore[assignment] + hide: tuple[str, ...] = _MISSING, # type: ignore[assignment] + ) -> None: + sess = self._require_session(for_op="update") + # IMPLEMENTER: read shiny.ui._input_update.update_accordion for the exact payload shape. + # The accordion update protocol uses methods like "set", "open", "close" — confirm. + msg: dict[str, Any] = {} + if open is not _MISSING: msg["method"] = "set"; msg["values"] = list(open) + if show is not _MISSING: msg["method"] = "open"; msg["values"] = list(show) + if hide is not _MISSING: msg["method"] = "close"; msg["values"] = list(hide) + sess.send_input_message(self.id, msg) + + +UiAccordion._register_input_handler() + + +def accordion(*args: Any, id: str, **kwargs: Any) -> UiAccordion: + return UiAccordion(*args, id=id, **kwargs) +``` + +Add the missing `ClassVar` import: + +```python +from typing import Any, ClassVar +``` + +- [ ] **Step 4: Run tests — verify they pass** + +Run: `uv run pytest pkg-py/tests/shinyui/test_accordion.py -v` +Expected: All pass. If `input_handler_name` is wrong, read `shiny/ui/_accordion.py` and `shiny/_input_handler.py` to find the actual handler name, fix, and re-run. + +- [ ] **Step 5: Inline tagify markup, re-run snapshot** + +- [ ] **Step 6: Commit** + +```bash +git add pkg-py/src/shinyui/_accordion.py pkg-py/tests/shinyui/test_accordion.py +git commit -m "feat(shinyui): UiAccordion + accordion() factory" +``` + +--- + +## Task 15: `UiCard` + +**Files:** +- Create: `pkg-py/src/shinyui/_card.py` +- Create: `pkg-py/tests/shinyui/test_card.py` + +**Reference markup source:** `shiny/ui/_card.py` — `card(*args, full_screen=False, height=None, max_height=None, min_height=None, fill=True, class_=None, id=None, **kwargs)`. + +Card's wire input for `full_screen` may not exist in stock shiny. This prototype introduces it: the rendered card includes a data-attribute or JS hook that pushes `input.()` = bool. **For the prototype, do not worry about the client-side JS plumbing** — the snapshot test compares markup to `shiny.ui.card`, which won't emit a binding; the `full_screen_value()` accessor is tested by mocking `session.input` directly. Real client-side wiring is out of scope for Stage A (would be Stage B work). + +- [ ] **Step 1: Write failing tests** + +Create `pkg-py/tests/shinyui/test_card.py`: + +```python +from __future__ import annotations + +import pytest +import shiny.ui as sui +from shiny import reactive + +from shinyui._card import UiCard, card +from shinyui._children import AllowsChildren +from shinyui._input_value import HasInputValue +from shinyui._updatable import Updatable + + +def test_factory_returns_instance(): + c = card("body", id="main") + assert isinstance(c, UiCard) + assert isinstance(c, HasInputValue) + assert isinstance(c, AllowsChildren) + assert isinstance(c, Updatable) + + +def test_tagify_matches_shiny(): + ours = card("body", id="main", full_screen=False).tagify() + theirs = sui.card("body", id="main", full_screen=False) + assert ours.get_html_string() == theirs.get_html_string() + + +def test_full_screen_value(mock_session): + c = card("body", id="main") + mock_session.input.__getitem__.return_value = lambda: True + with reactive.isolate(): + assert c.full_screen_value() is True + mock_session.input.__getitem__.assert_called_with("main") + + +def test_update_outside_session_raises(): + c = card("body", id="main") + with pytest.raises(RuntimeError): + c.update(full_screen=True) + + +def test_update_sends_message(mock_session): + c = card("body", id="main") + c.update(full_screen=True) + mock_session.send_input_message.assert_called_once_with("main", {"full_screen": True}) +``` + +- [ ] **Step 2: Run tests — verify they fail** + +Run: `uv run pytest pkg-py/tests/shinyui/test_card.py -v` +Expected: All fail with `ModuleNotFoundError`. + +- [ ] **Step 3: Implement UiCard** + +Create `pkg-py/src/shinyui/_card.py`: + +```python +"""UiCard — layout with optional full-screen toggle exposed as input value.""" +from __future__ import annotations + +from typing import Any + +from htmltools import Tag, TagChild + +from ._children import AllowsChildren +from ._input_value import HasInputValue +from ._reactive import reactive_calc_method +from ._roles import UiLayout +from ._updatable import Updatable + +_MISSING = object() + + +class UiCard(UiLayout, AllowsChildren, HasInputValue, Updatable): + # No input_handler_name / _input_handler — card's full_screen is a plain JSON bool. + + def __init__( + self, + *args: TagChild, + id: str, + full_screen: bool = False, + height: str | None = None, + max_height: str | None = None, + min_height: str | None = None, + fill: bool = True, + class_: str | None = None, + ) -> None: + self._full_screen = full_screen + self.height = height + self.max_height = max_height + self.min_height = min_height + self.fill = fill + self.class_ = class_ + super().__init__(*args, id=id) + + @reactive_calc_method + def full_screen_value(self) -> bool: + return bool(self._read_input()) + + def tagify(self) -> Tag: + import shiny.ui as _sui + return _sui.card( + *self.children, full_screen=self._full_screen, height=self.height, + max_height=self.max_height, min_height=self.min_height, fill=self.fill, + class_=self.class_, id=self.id, + ) + + def update( + self, + *, + full_screen: bool = _MISSING, # type: ignore[assignment] + ) -> None: + sess = self._require_session(for_op="update") + msg: dict[str, Any] = {} + if full_screen is not _MISSING: msg["full_screen"] = full_screen + sess.send_input_message(self.id, msg) + + +def card(*args: TagChild, id: str, **kwargs: Any) -> UiCard: + return UiCard(*args, id=id, **kwargs) +``` + +- [ ] **Step 4: Run tests — verify they pass** + +Run: `uv run pytest pkg-py/tests/shinyui/test_card.py -v` +Expected: All pass. + +- [ ] **Step 5: Inline tagify markup, re-run snapshot** + +- [ ] **Step 6: Commit** + +```bash +git add pkg-py/src/shinyui/_card.py pkg-py/tests/shinyui/test_card.py +git commit -m "feat(shinyui): UiCard with full_screen_value() and update()" +``` + +--- + +## Task 16: Public exports + +**Files:** +- Modify: `pkg-py/src/shinyui/__init__.py` + +- [ ] **Step 1: Write failing test** + +Create `pkg-py/tests/shinyui/test_public_exports.py`: + +```python +def test_public_exports(): + import shinyui as sui + + # Class names + assert sui.UiComponent + assert sui.UiInput and sui.UiOutput and sui.UiLayout + assert sui.HasInputValue and sui.Updatable and sui.AllowsChildren + assert sui.UiInputSlider and sui.UiInputSelect + assert sui.UiOutputCode and sui.UiOutputPlot + assert sui.UiCard and sui.UiAccordion and sui.UiAccordionPanel + + # Factory names + assert callable(sui.input_slider) + assert callable(sui.input_select) + assert callable(sui.output_code) + assert callable(sui.output_plot) + assert callable(sui.card) + assert callable(sui.accordion) + assert callable(sui.accordion_panel) +``` + +- [ ] **Step 2: Run test — verify it fails** + +Run: `uv run pytest pkg-py/tests/shinyui/test_public_exports.py -v` +Expected: FAIL with `AttributeError`. + +- [ ] **Step 3: Update __init__.py** + +Replace `pkg-py/src/shinyui/__init__.py` with: + +```python +"""shinyui — prototype class-per-component UI hierarchy. + +See docs/superpowers/specs/2026-05-13-shinyui-metadata-consolidation-design.md. +""" +from ._accordion import UiAccordion, accordion +from ._accordion_panel import UiAccordionPanel, accordion_panel +from ._base import UiComponent +from ._card import UiCard, card +from ._children import AllowsChildren +from ._input_select import UiInputSelect, input_select +from ._input_slider import UiInputSlider, input_slider +from ._input_value import HasInputValue +from ._output_code import UiOutputCode, output_code +from ._output_plot import UiOutputPlot, output_plot +from ._roles import UiInput, UiLayout, UiOutput +from ._updatable import Updatable + +__all__ = [ + # Bases / mixins + "UiComponent", "UiInput", "UiOutput", "UiLayout", + "HasInputValue", "Updatable", "AllowsChildren", + # Concrete classes + "UiInputSlider", "UiInputSelect", + "UiOutputCode", "UiOutputPlot", + "UiCard", "UiAccordion", "UiAccordionPanel", + # Factories + "input_slider", "input_select", + "output_code", "output_plot", + "card", "accordion", "accordion_panel", +] +``` + +- [ ] **Step 4: Run test — verify it passes** + +Run: `uv run pytest pkg-py/tests/shinyui/test_public_exports.py -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add pkg-py/src/shinyui/__init__.py pkg-py/tests/shinyui/test_public_exports.py +git commit -m "feat(shinyui): public exports" +``` + +--- + +## Task 17: Cross-cutting hierarchy tests + +**Files:** +- Create: `pkg-py/tests/shinyui/test_hierarchy.py` +- Create: `pkg-py/tests/shinyui/test_allows_children.py` +- Create: `pkg-py/tests/shinyui/test_input_handler_registration.py` +- Create: `pkg-py/tests/shinyui/test_update_resolution.py` +- Create: `pkg-py/tests/shinyui/test_read_accessors.py` + +- [ ] **Step 1: Write test_hierarchy.py** + +Create `pkg-py/tests/shinyui/test_hierarchy.py`: + +```python +from __future__ import annotations + +import pytest + +import shinyui as sui + + +def _maker(cls): + """Build a representative instance of `cls` with whatever args its factory needs.""" + if cls is sui.UiInputSlider: return sui.input_slider("n", "N", 1, 10, 5) + if cls is sui.UiInputSelect: return sui.input_select("c", "C", {"a": "A"}) + if cls is sui.UiOutputCode: return sui.output_code("o") + if cls is sui.UiOutputPlot: return sui.output_plot("p") + if cls is sui.UiCard: return sui.card("b", id="m") + if cls is sui.UiAccordion: return sui.accordion(sui.accordion_panel("A"), id="acc") + if cls is sui.UiAccordionPanel: return sui.accordion_panel("X", "y") + raise AssertionError(f"no maker for {cls}") + + +ALL_CLASSES = [ + sui.UiInputSlider, sui.UiInputSelect, + sui.UiOutputCode, sui.UiOutputPlot, + sui.UiCard, sui.UiAccordion, sui.UiAccordionPanel, +] + + +@pytest.mark.parametrize("cls", ALL_CLASSES) +def test_is_uicomponent(cls): + assert isinstance(_maker(cls), sui.UiComponent) + + +@pytest.mark.parametrize("cls,expected", [ + (sui.UiInputSlider, {sui.UiInput, sui.HasInputValue, sui.Updatable}), + (sui.UiInputSelect, {sui.UiInput, sui.HasInputValue, sui.Updatable}), + (sui.UiOutputCode, {sui.UiOutput}), + (sui.UiOutputPlot, {sui.UiOutput}), + (sui.UiCard, {sui.UiLayout, sui.AllowsChildren, sui.HasInputValue, sui.Updatable}), + (sui.UiAccordion, {sui.UiLayout, sui.AllowsChildren, sui.HasInputValue, sui.Updatable}), + (sui.UiAccordionPanel, {sui.UiLayout, sui.AllowsChildren}), +]) +def test_expected_bases(cls, expected): + inst = _maker(cls) + for base in expected: + assert isinstance(inst, base), f"{cls.__name__} should be instance of {base.__name__}" + + +@pytest.mark.parametrize("cls,allows_children", [ + (sui.UiInputSlider, False), + (sui.UiInputSelect, False), + (sui.UiOutputCode, False), + (sui.UiOutputPlot, False), + (sui.UiCard, True), + (sui.UiAccordion, True), + (sui.UiAccordionPanel, True), +]) +def test_with_block_protocol(cls, allows_children): + inst = _maker(cls) + if allows_children: + with inst as ctx: + assert ctx is inst + else: + with pytest.raises(TypeError, match=f"{cls.__name__} does not accept children"): + inst.__enter__() +``` + +- [ ] **Step 2: Write test_allows_children.py** + +Create `pkg-py/tests/shinyui/test_allows_children.py`: + +```python +from __future__ import annotations + +from htmltools import tags + +import shinyui as sui + + +def test_card_append_mutates_children(): + c = sui.card(id="m") + c.append(tags.p("hi")) + assert len(c.children) == 1 + + +def test_card_with_block_collects_via_append(): + with sui.card(id="m") as c: + c.append(tags.p("inside")) + assert len(c.children) == 1 + + +def test_accordion_panel_can_be_nested_in_accordion(): + a = sui.accordion( + sui.accordion_panel("A", tags.p("a-body")), + sui.accordion_panel("B", tags.p("b-body")), + id="acc", + ) + assert len(a.children) == 2 + + +def test_bare_tag_in_with_block_is_not_auto_collected(): + """Tag-as-CM is sub-issue 3 (out of scope for this prototype).""" + with sui.card(id="m") as c: + tags.p("not collected") # noqa: B018 intentional bare expr + assert c.children == [] +``` + +- [ ] **Step 3: Write test_input_handler_registration.py** + +Create `pkg-py/tests/shinyui/test_input_handler_registration.py`: + +```python +from __future__ import annotations + +from shiny._input_handler import input_handlers + +import shinyui as sui + + +def test_accordion_handler_registered_after_import(): + assert sui.UiAccordion.input_handler_name in input_handlers._handlers + + +def test_slider_does_not_register_handler(): + """Slider has no custom server-side wire coercion.""" + assert sui.UiInputSlider.input_handler_name == "" + assert sui.UiInputSlider._input_handler is None + + +def test_card_does_not_register_handler(): + assert sui.UiCard.input_handler_name == "" +``` + +- [ ] **Step 4: Write test_update_resolution.py** + +Create `pkg-py/tests/shinyui/test_update_resolution.py`: + +```python +from __future__ import annotations + +import pytest + +import shinyui as sui + + +@pytest.mark.parametrize("maker", [ + lambda: sui.input_slider("n", "N", 1, 10, 5), + lambda: sui.input_select("c", "C", {"a": "A"}), + lambda: sui.card("b", id="m"), + lambda: sui.accordion(sui.accordion_panel("A"), id="acc"), +]) +def test_update_raises_outside_session(maker): + inst = maker() + with pytest.raises(RuntimeError, match=r"requires an active session"): + inst.update() + + +def test_update_uses_captured_session(mock_session): + s = sui.input_slider("n", "N", 1, 10, 5) + s.update(value=7) + mock_session.send_input_message.assert_called_once() + + +def test_update_no_session_kwarg(): + """update() must not accept a `session=` kwarg.""" + s = sui.input_slider("n", "N", 1, 10, 5) + import inspect + sig = inspect.signature(s.update) + assert "session" not in sig.parameters +``` + +- [ ] **Step 5: Write test_read_accessors.py** + +Create `pkg-py/tests/shinyui/test_read_accessors.py`: + +```python +from __future__ import annotations + +import pytest +from shiny import reactive + +import shinyui as sui + + +@pytest.mark.parametrize("maker,accessor,suffix,value", [ + (lambda: sui.input_slider("n", "N", 1, 10, 5), "value", "", 7), + (lambda: sui.input_select("c", "C", {"a": "A"}), "value", "", "a"), + (lambda: sui.card("b", id="m"), "full_screen_value", "", True), + (lambda: sui.accordion(sui.accordion_panel("A"), id="acc"), + "open_panels", "", ["A"]), + (lambda: sui.output_plot("p", click=True), "click_value", "_click", {"x": 1, "y": 2}), + (lambda: sui.output_plot("p", brush=True), "brush_value", "_brush", {"xmin": 1}), +]) +def test_accessor_reads_correct_id(mock_session, maker, accessor, suffix, value): + inst = maker() + expected_id = f"{inst.id}{suffix}" + mock_session.input.__getitem__.return_value = lambda: value + with reactive.isolate(): + result = getattr(inst, accessor)() + if isinstance(value, list): + assert result == tuple(value) + else: + assert result == value + mock_session.input.__getitem__.assert_called_with(expected_id) + + +@pytest.mark.parametrize("maker,accessor", [ + (lambda: sui.input_slider("n", "N", 1, 10, 5), "value"), + (lambda: sui.card("b", id="m"), "full_screen_value"), + (lambda: sui.output_plot("p", click=True), "click_value"), +]) +def test_accessor_raises_outside_session(maker, accessor): + inst = maker() + with pytest.raises(RuntimeError, match=r"requires an active session"): + with reactive.isolate(): + getattr(inst, accessor)() +``` + +- [ ] **Step 6: Run all cross-cutting tests** + +Run: `uv run pytest pkg-py/tests/shinyui/test_hierarchy.py pkg-py/tests/shinyui/test_allows_children.py pkg-py/tests/shinyui/test_input_handler_registration.py pkg-py/tests/shinyui/test_update_resolution.py pkg-py/tests/shinyui/test_read_accessors.py -v` + +Expected: All pass. If a test fails, fix the relevant class implementation — the cross-cutting tests are pinning behavior the per-class tests should already have caught. + +- [ ] **Step 7: Commit** + +```bash +git add pkg-py/tests/shinyui/test_hierarchy.py pkg-py/tests/shinyui/test_allows_children.py pkg-py/tests/shinyui/test_input_handler_registration.py pkg-py/tests/shinyui/test_update_resolution.py pkg-py/tests/shinyui/test_read_accessors.py +git commit -m "test(shinyui): cross-cutting hierarchy + lifecycle tests" +``` + +--- + +## Task 18: Bookmark round-trip integration test + +**Files:** +- Create: `pkg-py/tests/shinyui/test_bookmark_roundtrip.py` + +- [ ] **Step 1: Write failing test** + +Create `pkg-py/tests/shinyui/test_bookmark_roundtrip.py`: + +```python +"""End-to-end bookmark round-trip for class-owned serializers. + +Constructs a HasInputValue subclass with a custom serializer, simulates +save (via the registered instance map) and restore (by lookup). +""" +from __future__ import annotations + +from typing import Any + +from shinyui._bookmark import lookup_instance + +import shinyui as sui + + +def test_session_registry_records_instance_on_construction(mock_session): + s = sui.input_slider("n", "N", 1, 10, 5) + assert lookup_instance(mock_session, "n") is s + + +def test_accordion_serializer_round_trip(mock_session): + """Accordion's class-owned input_handler returns a tuple; restoring should re-tuple.""" + a = sui.accordion(sui.accordion_panel("A"), sui.accordion_panel("B"), id="acc") + # The serializer + handler path is exercised via the wire layer; here we + # verify that the accordion instance is reachable from its id on the session. + assert lookup_instance(mock_session, "acc") is a + + +def test_per_instance_serializer_override(): + class Custom: + async def serialize(self, value: Any, state_dir: Any) -> Any: return value + async def deserialize(self, value: Any, state_dir: Any) -> Any: return value + + custom = Custom() + s = sui.input_slider("n", "N", 1, 10, 5) + s._bookmark_serializer = custom + assert s._bookmark_serializer is custom + + +def test_no_session_no_registry_noop(): + """Construction without a session must not raise.""" + s = sui.input_slider("n", "N", 1, 10, 5) + assert s._session is None +``` + +- [ ] **Step 2: Run test** + +Run: `uv run pytest pkg-py/tests/shinyui/test_bookmark_roundtrip.py -v` +Expected: All pass. + +- [ ] **Step 3: Commit** + +```bash +git add pkg-py/tests/shinyui/test_bookmark_roundtrip.py +git commit -m "test(shinyui): bookmark id->instance round-trip" +``` + +--- + +## Task 19: Example app `14-unified-ui-prototype` + +**Files:** +- Create: `examples/app-py/14-unified-ui-prototype/app.py` +- Create: `examples/app-py/14-unified-ui-prototype/README.md` + +- [ ] **Step 1: Write the example app** + +Create `examples/app-py/14-unified-ui-prototype/app.py`: + +```python +"""End-to-end demo of shinyui's class-per-component hierarchy. + +Exercises every reference class in one page: + - UiInputSlider, UiInputSelect (simple + structured inputs) + - UiOutputCode (output) + - UiOutputPlot (output with read-only signals) + - UiCard (layout with state) + - UiAccordion + UiAccordionPanel (layout-with-state + layout-as-child) + +The `app_ui` is a function (not a module-level Tag) so a session is in scope +when components are constructed — this is what enables class-owned bookmark +serializers to register themselves. +""" +from __future__ import annotations + +import io + +import matplotlib.pyplot as plt +import numpy as np +from shiny import App, Inputs, Outputs, Session, reactive, render + +import shinyui as sui + + +def app_ui(request): + return sui.card( + sui.input_slider("n", "Sample size", 10, 1000, 100), + sui.input_select("dist", "Distribution", + {"normal": "Normal", "uniform": "Uniform"}), + sui.output_code("summary"), + sui.output_plot("plot", click=True, brush=True), + sui.accordion( + sui.accordion_panel( + "Settings", + sui.input_slider("seed", "Seed", 1, 1000, 42), + ), + sui.accordion_panel( + "Diagnostics", + sui.output_code("diag"), + ), + id="acc", + open="Settings", + ), + id="main_card", + full_screen=False, + ) + + +def server(input: Inputs, output: Outputs, session: Session): + + @reactive.calc + def data() -> np.ndarray: + rng = np.random.default_rng(input.seed()) + if input.dist() == "normal": + return rng.standard_normal(input.n()) + return rng.uniform(-2, 2, input.n()) + + @render.code + def summary(): + x = data() + return f"n = {len(x)}\nmean = {x.mean():.3f}\nstd = {x.std():.3f}" + + @render.plot + def plot(): + fig, ax = plt.subplots() + ax.hist(data(), bins=30) + return fig + + @render.code + def diag(): + return f"open panels = {accordion.open_panels()}" + + # --- Demonstrating .value() / .click_value() / .full_screen_value() --- + @reactive.effect + def _(): + coords = plot.click_value() + if coords is not None: + print(f"click @ {coords['x']},{coords['y']}") + + @reactive.effect + def _(): + b = plot.brush_value() + if b is not None: + print(f"brush: {b}") + + # --- Server-driven .update() on layouts-with-state --- + @reactive.effect + def _(): + # When n exceeds 800, auto-expand the main card and reveal Diagnostics. + if input.n() > 800: + main_card.update(full_screen=True) + accordion.update(open=("Settings", "Diagnostics")) + + +app = App(app_ui, server) +``` + +**Note:** The example references `plot`, `main_card`, `accordion` inside `server()`. Those names must be in scope. Two implementation paths: + +1. Capture them in the `app_ui` function's closure and re-construct in `server()` via session-attached lookup (less ergonomic). +2. Construct them inside `server()` directly (preferred for the prototype — see the simpler structure below). + +If the closure capture pattern is too awkward, refactor `app.py` so `server()` builds its own component handles by id-lookup on the session's `_shinyui_instances` map: + +```python +def server(input, output, session): + plot = sui.lookup_component(session, "plot") # add this helper to _bookmark.py + main_card = sui.lookup_component(session, "main_card") + accordion = sui.lookup_component(session, "acc") + ... +``` + +If `lookup_component` is desired, add it to `pkg-py/src/shinyui/_bookmark.py` as a thin wrapper around `lookup_instance` and export from `__init__.py`. Otherwise, build a closure-capture pattern documented in the README. + +- [ ] **Step 2: Write the README** + +Create `examples/app-py/14-unified-ui-prototype/README.md`: + +```markdown +# 14 — Unified UI prototype + +Stage A demo of [shinyui](../../../pkg-py/src/shinyui), the class-per-component +UI hierarchy that consolidates each component's metadata (handler, serializer, +HTML deps, `update()`, read accessors) onto a single class. + +Run: + + uv run shiny run examples/app-py/14-unified-ui-prototype/app.py + +## What this demonstrates + +| Archetype | Class | Demonstrated by | +|---|---|---| +| Simple input | `UiInputSlider` | `n` and `seed` sliders | +| Structured input | `UiInputSelect` | `dist` selector | +| Plain output | `UiOutputCode` | `summary` and `diag` | +| Output with read-only signals | `UiOutputPlot` | `plot` with `click=True, brush=True` | +| Layout with children | `UiCard` | `main_card` | +| Layout with state + children | `UiCard` + `UiAccordion` | `main_card.full_screen_value()`, `accordion.open_panels()` | +| Layout-as-child | `UiAccordionPanel` | Two panels inside `accordion` | + +## What to try + +- Drag the sliders — `summary` recomputes. +- Click on the plot — coordinates appear in the server log via `plot.click_value()`. +- Brush a region — `plot.brush_value()` fires. +- Set `n > 800` — the card auto-expands to full-screen and both accordion panels open + via `.update()` calls from the server. + +## Bookmark round-trip + +Append `?_inputs_=...` to the URL or use Shiny's built-in URL bookmark. Class-owned +serializers (e.g. `UiAccordion`'s) restore correctly because the components +register themselves with the session during `app_ui(request)` construction. +``` + +- [ ] **Step 3: Smoke-test the example** + +Run: `uv run shiny run examples/app-py/14-unified-ui-prototype/app.py --port 8765 &` then `curl -s http://localhost:8765/ | head -50` and kill the process. + +Expected: HTML page loads without 500 errors. If errors occur, fix the underlying issue (most likely `lookup_component` or the closure pattern needs the inline-construction approach). + +- [ ] **Step 4: Commit** + +```bash +git add examples/app-py/14-unified-ui-prototype +git commit -m "feat(shinyui): example app exercising the full reference set" +``` + +--- + +## Task 20: Final integration + +- [ ] **Step 1: Run full check** + +Run: `make py-check` +Expected: All green — pyright clean, ruff clean, all tests pass. + +- [ ] **Step 2: Update docs/features.md and docs/todos.md** + +If `docs/features.md` has a section listing feature surfaces, add a "shinyui prototype (#69 Stage A)" entry pointing at the example app and design doc. If `docs/todos.md` had a placeholder for unified-UI work, replace it with a note that Stage A is complete and the next step is Stage B (port to py-shiny). + +Read the files first to confirm shape; if no obvious entry point exists, skip this step and note it in the commit message. + +- [ ] **Step 3: Final commit** + +```bash +git add docs/features.md docs/todos.md # if updated +git commit -m "docs: note shinyui Stage A completion (#69)" # if applicable +``` + +- [ ] **Step 4: Verify acceptance criteria** + +Walk the acceptance list from the spec and confirm each is met: + +- [ ] `pkg-py/src/shinyui/` exists with all bases, mixins, role classes, and seven concrete classes +- [ ] Each class has a factory exported alongside +- [ ] `examples/app-py/14-unified-ui-prototype/` runs and demonstrates bookmark + `.update()` +- [ ] All test files exist and pass +- [ ] `tagify()` snapshots match `shiny.ui.*` markup for every concrete class +- [ ] `with UiInputSlider(...):` raises with clear message +- [ ] No new top-level dependency in `pyproject.toml` + +Report status back to the user with the final commit SHA and a summary of what shipped. + +--- + +## Self-Review Notes (post-write) + +- **Spec coverage:** Tasks 1-20 walk every section of the design spec (package layout, hierarchy, lifecycle, example app, tests, acceptance criteria). +- **Markup-inlining gap:** Each concrete class ships first with a `shiny.ui.*` delegation in `tagify()` (interim), then Step 5 of those tasks instructs the implementer to inline the actual markup. This is done in two stages so the snapshot test acts as the regression net during the inlining. Tasks 9–15 each include this two-stage flow. +- **`lookup_component` open question:** Task 19 flags that the example app may want a `lookup_component(session, id)` helper. This is an implementation detail; the design spec explicitly defers "how `server()` captures component instances" to the implementation plan. Implementer chooses inline-construction vs lookup-by-id. +- **Card client wiring out of scope:** Task 15 notes that the actual client-side JS that pushes `card.full_screen` to `input.()` is out of scope. The `full_screen_value()` accessor is tested by mocking the session input directly; real wire-level full-screen events require Stage B work. +- **`Serializer` import path:** Task 5 Step 3 includes a note for the implementer to verify the exact import path in the installed Shiny version. The spec is intentionally non-prescriptive on which path; minor adjustment expected. +- **`input_handler_name` for accordion:** Task 14 places a literal `"shiny.bindings.accordion"` that the implementer is instructed to verify against `shiny/_input_handler.py`. The pinning test catches drift. From a0180d56d79f46704e10295447c3b63da634fb6d Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 13 May 2026 13:10:00 -0400 Subject: [PATCH 03/45] feat(shinyui): scaffold sibling package + test infra --- pkg-py/src/shinyui/__init__.py | 6 ++++++ pkg-py/tests/shinyui/__init__.py | 0 pkg-py/tests/shinyui/conftest.py | 33 ++++++++++++++++++++++++++++++ pkg-py/tests/shinyui/test_smoke.py | 7 +++++++ pyproject.toml | 4 ++-- 5 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 pkg-py/src/shinyui/__init__.py create mode 100644 pkg-py/tests/shinyui/__init__.py create mode 100644 pkg-py/tests/shinyui/conftest.py create mode 100644 pkg-py/tests/shinyui/test_smoke.py diff --git a/pkg-py/src/shinyui/__init__.py b/pkg-py/src/shinyui/__init__.py new file mode 100644 index 00000000..b3a01a33 --- /dev/null +++ b/pkg-py/src/shinyui/__init__.py @@ -0,0 +1,6 @@ +"""shinyui — prototype class-per-component UI hierarchy. + +See docs/superpowers/specs/2026-05-13-shinyui-metadata-consolidation-design.md. +""" + +__all__: list[str] = [] diff --git a/pkg-py/tests/shinyui/__init__.py b/pkg-py/tests/shinyui/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pkg-py/tests/shinyui/conftest.py b/pkg-py/tests/shinyui/conftest.py new file mode 100644 index 00000000..fd8e65ee --- /dev/null +++ b/pkg-py/tests/shinyui/conftest.py @@ -0,0 +1,33 @@ +"""Shared fixtures for shinyui tests. + +Each test that needs `get_current_session()` to return something uses +the `mock_session` fixture, which yields a controllable Session-like object +and binds it as the current session for the duration of the test. +""" +from __future__ import annotations + +from contextlib import contextmanager +from typing import Any, Iterator +from unittest.mock import MagicMock + +import pytest +from shiny.session._utils import session_context + + +@pytest.fixture +def mock_session() -> Iterator[Any]: + """Bind a MagicMock as the current session inside the test body.""" + session = MagicMock(name="MockSession") + session.input = MagicMock(name="MockInput") + # session_context calls namespace_context(session.ns), which requires a str + session.ns = "" + with session_context(session): + yield session + + +@contextmanager +def no_session() -> Iterator[None]: + """Helper: confirm no session is bound. Use for explicit clarity in tests.""" + from shiny.session import get_current_session + assert get_current_session() is None, "Test expected no active session" + yield diff --git a/pkg-py/tests/shinyui/test_smoke.py b/pkg-py/tests/shinyui/test_smoke.py new file mode 100644 index 00000000..853c10a0 --- /dev/null +++ b/pkg-py/tests/shinyui/test_smoke.py @@ -0,0 +1,7 @@ +def test_package_importable(): + import shinyui # noqa: F401 + + +def test_mock_session_fixture(mock_session): + from shiny.session import get_current_session + assert get_current_session() is mock_session diff --git a/pyproject.toml b/pyproject.toml index 6199e643..05ec055c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ dependencies = [ ] [tool.hatch.build.targets.wheel] -packages = ["pkg-py/src/shinyreact"] +packages = ["pkg-py/src/shinyreact", "pkg-py/src/shinyui"] [dependency-groups] examples = [ @@ -52,7 +52,7 @@ testpaths = ["pkg-py/tests"] addopts = "--ignore=pkg-py/tests/playwright" [tool.pyright] -include = ["pkg-py/src/shinyreact"] +include = ["pkg-py/src/shinyreact", "pkg-py/src/shinyui"] pythonVersion = "3.10" typeCheckingMode = "basic" From fc2fd954b72ab8d6e97046decceca2580ddc6bc9 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 13 May 2026 13:12:33 -0400 Subject: [PATCH 04/45] style(shinyui): ruff format conftest and smoke test --- pkg-py/tests/shinyui/conftest.py | 2 ++ pkg-py/tests/shinyui/test_smoke.py | 1 + 2 files changed, 3 insertions(+) diff --git a/pkg-py/tests/shinyui/conftest.py b/pkg-py/tests/shinyui/conftest.py index fd8e65ee..3c12b5e2 100644 --- a/pkg-py/tests/shinyui/conftest.py +++ b/pkg-py/tests/shinyui/conftest.py @@ -4,6 +4,7 @@ the `mock_session` fixture, which yields a controllable Session-like object and binds it as the current session for the duration of the test. """ + from __future__ import annotations from contextlib import contextmanager @@ -29,5 +30,6 @@ def mock_session() -> Iterator[Any]: def no_session() -> Iterator[None]: """Helper: confirm no session is bound. Use for explicit clarity in tests.""" from shiny.session import get_current_session + assert get_current_session() is None, "Test expected no active session" yield diff --git a/pkg-py/tests/shinyui/test_smoke.py b/pkg-py/tests/shinyui/test_smoke.py index 853c10a0..014d9ab3 100644 --- a/pkg-py/tests/shinyui/test_smoke.py +++ b/pkg-py/tests/shinyui/test_smoke.py @@ -4,4 +4,5 @@ def test_package_importable(): def test_mock_session_fixture(mock_session): from shiny.session import get_current_session + assert get_current_session() is mock_session From 1dffe474678293daec944e1383f34543ac791ddb Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 13 May 2026 13:14:30 -0400 Subject: [PATCH 05/45] feat(shinyui): UiComponent base + session/read helpers --- pkg-py/src/shinyui/_base.py | 54 +++++++++++++++++++++++ pkg-py/tests/shinyui/test_base.py | 73 +++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 pkg-py/src/shinyui/_base.py create mode 100644 pkg-py/tests/shinyui/test_base.py diff --git a/pkg-py/src/shinyui/_base.py b/pkg-py/src/shinyui/_base.py new file mode 100644 index 00000000..4a453832 --- /dev/null +++ b/pkg-py/src/shinyui/_base.py @@ -0,0 +1,54 @@ +"""UiComponent — abstract base for the shinyui class hierarchy. + +Single source of truth for: + - `self._session`: the active session captured at construction (may be None) + - `_require_session(for_op=...)`: resolves a session at call time, with a fallback + to the current session, raising RuntimeError if none is reachable. + - `_read_input(suffix="")`: reads `session.input[f"{self.id}{suffix}"]()`. + +`tagify()` is abstract. `__enter__` raises by default; `AllowsChildren` overrides. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any, ClassVar + +from htmltools import HTMLDependency, Tag +from shiny.session import Session, get_current_session +from typing_extensions import Self + + +class UiComponent(ABC): + html_dependencies: ClassVar[tuple[HTMLDependency, ...]] = () + + def __init__(self, **kwargs: Any) -> None: + # Capture session BEFORE super() so mixins can read self._session + # in their own __init__ after they call super().__init__(**kw). + self._session: Session | None = get_current_session() + super().__init__(**kwargs) + + def _require_session(self, *, for_op: str) -> Session: + sess = self._session or get_current_session() + if sess is None: + raise RuntimeError( + f"{type(self).__name__}.{for_op}() requires an active session " + f"(instance constructed outside any session, and none is active now)" + ) + return sess + + def _read_input(self, suffix: str = "") -> Any: + sess = self._require_session(for_op="_read_input") + return sess.input[f"{self.id}{suffix}"]() # type: ignore[attr-defined] + + @abstractmethod + def tagify(self) -> Tag: ... + + def __enter__(self) -> Self: + raise TypeError( + f"{type(self).__name__} does not accept children; " + f"only components declaring `AllowsChildren` may be used as `with` blocks." + ) + + def __exit__(self, *exc: object) -> None: + return None diff --git a/pkg-py/tests/shinyui/test_base.py b/pkg-py/tests/shinyui/test_base.py new file mode 100644 index 00000000..74a00dc0 --- /dev/null +++ b/pkg-py/tests/shinyui/test_base.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import pytest +from shinyui._base import UiComponent + + +class _Dummy(UiComponent): + """Minimal concrete subclass for testing.""" + + def tagify(self): + from htmltools import tags + + return tags.div("dummy") + + +def test_uicomponent_is_abstract(): + with pytest.raises(TypeError): + UiComponent() # type: ignore[abstract] + + +def test_session_captured_as_none_without_session(): + c = _Dummy() + assert c._session is None + + +def test_session_captured_when_present(mock_session): + c = _Dummy() + assert c._session is mock_session + + +def test_require_session_raises_when_none(): + c = _Dummy() + with pytest.raises( + RuntimeError, match=r"_Dummy\.foo\(\) requires an active session" + ): + c._require_session(for_op="foo") + + +def test_require_session_returns_captured(mock_session): + c = _Dummy() + assert c._require_session(for_op="foo") is mock_session + + +def test_require_session_falls_back_to_current(mock_session): + """If _session is None at init but a session is active at call time, use it.""" + from shiny.session._utils import session_context + + c = _Dummy() + c._session = None + with session_context(mock_session): + assert c._require_session(for_op="foo") is mock_session + + +def test_enter_raises_with_class_name(): + c = _Dummy() + with pytest.raises(TypeError, match=r"_Dummy does not accept children"): + c.__enter__() + + +def test_read_input_uses_current_session_and_id(mock_session): + c = _Dummy() + c.id = "my_id" # type: ignore[attr-defined] + mock_session.input.__getitem__.return_value = lambda: 42 + assert c._read_input() == 42 + mock_session.input.__getitem__.assert_called_with("my_id") + + +def test_read_input_suffix(mock_session): + c = _Dummy() + c.id = "p" # type: ignore[attr-defined] + mock_session.input.__getitem__.return_value = lambda: {"x": 1} + assert c._read_input("_click") == {"x": 1} + mock_session.input.__getitem__.assert_called_with("p_click") From 15911ccc745f235265cfbf874140f007e21508f6 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 13 May 2026 13:16:34 -0400 Subject: [PATCH 06/45] feat(shinyui): AllowsChildren mixin Adds AllowsChildren mixin (pkg-py/src/shinyui/_children.py) with children list, append(), __enter__/__exit__ overrides. Also fixes UiComponent.__init__ to forward *args cooperatively so AllowsChildren receives positional children when MRO order is MyComp(UiComponent, AllowsChildren). --- pkg-py/src/shinyui/_base.py | 14 ++++++++-- pkg-py/src/shinyui/_children.py | 36 ++++++++++++++++++++++++ pkg-py/tests/shinyui/test_children.py | 40 +++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 pkg-py/src/shinyui/_children.py create mode 100644 pkg-py/tests/shinyui/test_children.py diff --git a/pkg-py/src/shinyui/_base.py b/pkg-py/src/shinyui/_base.py index 4a453832..31e8fd6b 100644 --- a/pkg-py/src/shinyui/_base.py +++ b/pkg-py/src/shinyui/_base.py @@ -22,11 +22,14 @@ class UiComponent(ABC): html_dependencies: ClassVar[tuple[HTMLDependency, ...]] = () - def __init__(self, **kwargs: Any) -> None: + def __init__(self, *args: Any, **kwargs: Any) -> None: # Capture session BEFORE super() so mixins can read self._session # in their own __init__ after they call super().__init__(**kw). + # Forward *args cooperatively so AllowsChildren (next in MRO when the + # class is declared as MyComp(UiComponent, AllowsChildren)) receives + # positional children arguments. self._session: Session | None = get_current_session() - super().__init__(**kwargs) + super().__init__(*args, **kwargs) def _require_session(self, *, for_op: str) -> Session: sess = self._session or get_current_session() @@ -45,6 +48,13 @@ def _read_input(self, suffix: str = "") -> Any: def tagify(self) -> Tag: ... def __enter__(self) -> Self: + # AllowsChildren overrides __enter__ to return self. When the MRO + # places UiComponent before AllowsChildren (the typical mixin order), + # we must explicitly delegate so the mixin wins. + from shinyui._children import AllowsChildren # local import avoids circular + + if isinstance(self, AllowsChildren): + return AllowsChildren.__enter__(self) # type: ignore[return-value] raise TypeError( f"{type(self).__name__} does not accept children; " f"only components declaring `AllowsChildren` may be used as `with` blocks." diff --git a/pkg-py/src/shinyui/_children.py b/pkg-py/src/shinyui/_children.py new file mode 100644 index 00000000..5a18a36e --- /dev/null +++ b/pkg-py/src/shinyui/_children.py @@ -0,0 +1,36 @@ +"""AllowsChildren — mixin for components that accept children. + +Mixin protocol: + - Subclasses MUST call `super().__init__(**kwargs)` first in their __init__. + - `AllowsChildren.__init__` claims positional args as children and forwards + the remaining kwargs up the MRO. + +Note: the parent-tag context stack (sub-issue 3) is OUT OF SCOPE. __enter__ +returns self with no side effects; auto-collecting bare Tags inside a with-block +is not implemented here. +""" + +from __future__ import annotations + +from typing import Any + +from htmltools import TagChild +from typing_extensions import Self + + +class AllowsChildren: + children: list[TagChild] + + def __init__(self, *children: TagChild, **kwargs: Any) -> None: + self.children = list(children) + super().__init__(**kwargs) + + def append(self, child: TagChild) -> Self: + self.children.append(child) + return self + + def __enter__(self) -> Self: + return self + + def __exit__(self, *exc: object) -> None: + return None diff --git a/pkg-py/tests/shinyui/test_children.py b/pkg-py/tests/shinyui/test_children.py new file mode 100644 index 00000000..ff71d098 --- /dev/null +++ b/pkg-py/tests/shinyui/test_children.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from htmltools import tags +from shinyui._base import UiComponent +from shinyui._children import AllowsChildren + + +class _ChildBox(UiComponent, AllowsChildren): + def tagify(self): + return tags.div(*self.children) + + +def test_children_default_empty(): + b = _ChildBox() + assert b.children == [] + + +def test_children_from_positional_args(): + b = _ChildBox("a", "b") + assert b.children == ["a", "b"] + + +def test_append_returns_self_and_mutates(): + b = _ChildBox() + r = b.append("x") + assert r is b + assert b.children == ["x"] + + +def test_with_block_returns_self_and_collects_via_append(): + with _ChildBox() as b: + b.append("inside") + assert b.children == ["inside"] + + +def test_enter_does_not_raise(): + # Inherits from UiComponent (which raises), but AllowsChildren overrides. + b = _ChildBox() + # Should not raise: + assert b.__enter__() is b From 3b6ba8f73619cb75886f6a6001df496b84e6b6ad Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 13 May 2026 13:18:13 -0400 Subject: [PATCH 07/45] feat(shinyui): per-session id->instance registry --- pkg-py/src/shinyui/_bookmark.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 pkg-py/src/shinyui/_bookmark.py diff --git a/pkg-py/src/shinyui/_bookmark.py b/pkg-py/src/shinyui/_bookmark.py new file mode 100644 index 00000000..46729e3a --- /dev/null +++ b/pkg-py/src/shinyui/_bookmark.py @@ -0,0 +1,33 @@ +"""Per-session map: input id -> HasInputValue instance. + +Attached as `session._shinyui_instances` on first registration. This is a private +attribute on Shiny's Session — acceptable for a prototype; Stage B can negotiate +a public hook in py-shiny. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from shiny.session import Session + + from ._input_value import HasInputValue + +_ATTR = "_shinyui_instances" + + +def get_session_instances(session: "Session") -> dict[str, "HasInputValue"]: + m = getattr(session, _ATTR, None) + if m is None: + m = {} + setattr(session, _ATTR, m) + return m + + +def register_instance(session: "Session", id: str, instance: "HasInputValue") -> None: + get_session_instances(session)[id] = instance + + +def lookup_instance(session: "Session", id: str) -> "HasInputValue | None": + return get_session_instances(session).get(id) From 872e633a6dfb1e9064ecc350a22a52e17ddf0387 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 13 May 2026 13:20:50 -0400 Subject: [PATCH 08/45] feat(shinyui): HasInputValue mixin + handler registration --- pkg-py/src/shinyui/_input_value.py | 54 +++++++++++++ pkg-py/tests/shinyui/conftest.py | 3 + pkg-py/tests/shinyui/test_input_value.py | 99 ++++++++++++++++++++++++ 3 files changed, 156 insertions(+) create mode 100644 pkg-py/src/shinyui/_input_value.py create mode 100644 pkg-py/tests/shinyui/test_input_value.py diff --git a/pkg-py/src/shinyui/_input_value.py b/pkg-py/src/shinyui/_input_value.py new file mode 100644 index 00000000..d2ad0b2c --- /dev/null +++ b/pkg-py/src/shinyui/_input_value.py @@ -0,0 +1,54 @@ +"""HasInputValue — mixin for components that own a server-readable input id. + +Provides: + - `id: str` (stored on instance) + - `input_handler_name` and `_input_handler` ClassVars (default to empty / None) + - `bookmark_serializer` ClassVar default + per-instance override + - `_register_input_handler()` classmethod for explicit module-load registration + - id->instance registration on construction (no-op if no session) + +Mixin protocol: subclasses MUST call `super().__init__(id=..., **kw)` first. +""" + +from __future__ import annotations + +from typing import Any, Callable, ClassVar + +from shiny.input_handler import input_handlers + +from ._bookmark import register_instance + + +def register_input_handler(name: str, fn: Callable[..., Any]) -> None: + """Thin wrapper so tests can monkeypatch this symbol on _input_value.""" + input_handlers.add(name)(fn) + + +class HasInputValue: + input_handler_name: ClassVar[str] = "" + _input_handler: ClassVar[Callable[..., Any] | None] = None + bookmark_serializer: ClassVar[Any] = None # Serializer type; Any for flexibility + + @classmethod + def _register_input_handler(cls) -> None: + """Idempotent. Call once at module load if this class declares a handler.""" + if cls.input_handler_name and cls._input_handler is not None: + register_input_handler(cls.input_handler_name, cls._input_handler) + + def __init__( + self, + *args: Any, + id: str, + bookmark_serializer: Any = None, + **kwargs: Any, + ) -> None: + self.id = id + self._bookmark_serializer = ( + bookmark_serializer + if bookmark_serializer is not None + else type(self).bookmark_serializer + ) + super().__init__(*args, **kwargs) + # After super().__init__: UiComponent has set self._session. + if self._session is not None: # type: ignore[attr-defined] + register_instance(self._session, id, self) # type: ignore[arg-type] diff --git a/pkg-py/tests/shinyui/conftest.py b/pkg-py/tests/shinyui/conftest.py index 3c12b5e2..c955c395 100644 --- a/pkg-py/tests/shinyui/conftest.py +++ b/pkg-py/tests/shinyui/conftest.py @@ -22,6 +22,9 @@ def mock_session() -> Iterator[Any]: session.input = MagicMock(name="MockInput") # session_context calls namespace_context(session.ns), which requires a str session.ns = "" + # Pre-initialize the shinyui instance registry to a real dict so that + # _bookmark.get_session_instances() doesn't pick up a MagicMock auto-attribute. + session._shinyui_instances = {} with session_context(session): yield session diff --git a/pkg-py/tests/shinyui/test_input_value.py b/pkg-py/tests/shinyui/test_input_value.py new file mode 100644 index 00000000..1bae9ce0 --- /dev/null +++ b/pkg-py/tests/shinyui/test_input_value.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +from typing import Any + +from htmltools import tags +from shinyui._base import UiComponent +from shinyui._bookmark import lookup_instance +from shinyui._input_value import HasInputValue + + +class _Pinger(UiComponent, HasInputValue): + input_handler_name = "test.ping" + + @staticmethod + def _input_handler(value: Any, name: Any, session: Any) -> Any: + return ("pinged", value) + + def tagify(self): + return tags.div(id=self.id) + + +class _Plain(UiComponent, HasInputValue): + """No input_handler — defaults to None.""" + + def tagify(self): + return tags.div(id=self.id) + + +def test_id_is_stored(): + p = _Plain(id="x") + assert p.id == "x" + + +def test_no_session_no_registration(): + """Module-level construction: no session, no registry.""" + _Plain(id="x") # should not raise + + +def test_session_registers_self(mock_session): + p = _Plain(id="x") + assert lookup_instance(mock_session, "x") is p + + +def test_register_input_handler_classmethod(monkeypatch): + captured = {} + + def fake_register(name, fn): + captured[name] = fn + + monkeypatch.setattr("shinyui._input_value.register_input_handler", fake_register) + _Pinger._register_input_handler() + assert captured == {"test.ping": _Pinger._input_handler} + + +def test_register_input_handler_noop_when_no_handler(monkeypatch): + captured: dict = {} + monkeypatch.setattr( + "shinyui._input_value.register_input_handler", + lambda n, f: captured.update({n: f}), + ) + _Plain._register_input_handler() + assert captured == {} + + +def test_class_level_bookmark_serializer_inherited(): + class S: + async def serialize(self, value, state_dir): # noqa: D401 + return value + + async def deserialize(self, value, state_dir): + return value + + class _Custom(UiComponent, HasInputValue): + bookmark_serializer = S() + + def tagify(self): + return tags.div(id=self.id) + + c = _Custom(id="x") + assert c._bookmark_serializer is _Custom.bookmark_serializer + + +def test_per_instance_bookmark_serializer_overrides_class(): + class S: + async def serialize(self, value, state_dir): + return value + + async def deserialize(self, value, state_dir): + return value + + class _Custom(UiComponent, HasInputValue): + bookmark_serializer = S() + + def tagify(self): + return tags.div(id=self.id) + + inst_ser = S() + c = _Custom(id="x", bookmark_serializer=inst_ser) + assert c._bookmark_serializer is inst_ser From c2e773ccd382e8cb0bfd031d3ca5c5f952065146 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 13 May 2026 13:22:06 -0400 Subject: [PATCH 09/45] feat(shinyui): Updatable abstract mixin --- pkg-py/src/shinyui/_updatable.py | 17 +++++++++++ pkg-py/tests/shinyui/test_updatable.py | 39 ++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 pkg-py/src/shinyui/_updatable.py create mode 100644 pkg-py/tests/shinyui/test_updatable.py diff --git a/pkg-py/src/shinyui/_updatable.py b/pkg-py/src/shinyui/_updatable.py new file mode 100644 index 00000000..6e1b988e --- /dev/null +++ b/pkg-py/src/shinyui/_updatable.py @@ -0,0 +1,17 @@ +"""Updatable — marker mixin for components that support server-driven update(). + +`update()` is abstract; concrete subclasses provide a typed `update(*, ...)` +signature with the specific kwargs they accept. No `session=` kwarg — session +is captured by UiComponent.__init__ and resolved at call time via +`self._require_session(for_op="update")`. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any + + +class Updatable(ABC): + @abstractmethod + def update(self, **kwargs: Any) -> None: ... diff --git a/pkg-py/tests/shinyui/test_updatable.py b/pkg-py/tests/shinyui/test_updatable.py new file mode 100644 index 00000000..92f25fb7 --- /dev/null +++ b/pkg-py/tests/shinyui/test_updatable.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import pytest +from htmltools import tags +from shinyui._base import UiComponent +from shinyui._updatable import Updatable + + +class _AbstractStub(UiComponent, Updatable): + """Does NOT implement update() — should remain abstract.""" + + def tagify(self): + return tags.div() + + +class _Concrete(UiComponent, Updatable): + last_kwargs: dict | None = None + + def tagify(self): + return tags.div() + + def update(self, *, value: int | None = None) -> None: + type(self).last_kwargs = {"value": value} + + +def test_abstract_class_cannot_instantiate(): + with pytest.raises(TypeError): + _AbstractStub() # type: ignore[abstract] + + +def test_concrete_class_instantiates(): + c = _Concrete() + assert c is not None + + +def test_update_callable_on_concrete(): + c = _Concrete() + c.update(value=42) + assert _Concrete.last_kwargs == {"value": 42} From 70d5fa88b7e780aee96ee598c850015d7e72ecb9 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 13 May 2026 13:23:50 -0400 Subject: [PATCH 10/45] feat(shinyui): UiInput / UiOutput / UiLayout role classes --- pkg-py/src/shinyui/_roles.py | 31 ++++++++++++++++++++++ pkg-py/tests/shinyui/test_roles.py | 41 ++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 pkg-py/src/shinyui/_roles.py create mode 100644 pkg-py/tests/shinyui/test_roles.py diff --git a/pkg-py/src/shinyui/_roles.py b/pkg-py/src/shinyui/_roles.py new file mode 100644 index 00000000..616608c7 --- /dev/null +++ b/pkg-py/src/shinyui/_roles.py @@ -0,0 +1,31 @@ +"""Semantic role classes — UiInput, UiOutput, UiLayout. + +These are markers indicating the component's primary purpose. State-bearing +and child-bearing capabilities are provided by orthogonal mixins +(HasInputValue, Updatable, AllowsChildren). +""" + +from __future__ import annotations + +from ._base import UiComponent +from ._input_value import HasInputValue + + +class UiInput(UiComponent, HasInputValue): + """Primarily a user-input control.""" + + +class UiOutput(UiComponent): + """Primarily a server-rendered output. + + Carries its own `id` attribute (set by subclasses' __init__); does NOT + inherit HasInputValue (no bookmark serializer, no id->instance map). + Subclasses that expose read-only signals add accessors directly. + """ + + +class UiLayout(UiComponent): + """Primarily a container. + + No id by itself; layouts that expose state add HasInputValue + Updatable. + """ diff --git a/pkg-py/tests/shinyui/test_roles.py b/pkg-py/tests/shinyui/test_roles.py new file mode 100644 index 00000000..46f4e385 --- /dev/null +++ b/pkg-py/tests/shinyui/test_roles.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from htmltools import tags +from shinyui._base import UiComponent +from shinyui._input_value import HasInputValue +from shinyui._roles import UiInput, UiLayout, UiOutput + + +class _MyInput(UiInput): + def tagify(self): + return tags.div(id=self.id) + + +class _MyOutput(UiOutput): + def __init__(self, id: str) -> None: + self.id = id + super().__init__() + + def tagify(self): + return tags.div(id=self.id) + + +class _MyLayout(UiLayout): + def tagify(self): + return tags.div() + + +def test_uiinput_inherits_uicomponent_and_hasinputvalue(): + inst = _MyInput(id="x") + assert isinstance(inst, UiComponent) + assert isinstance(inst, HasInputValue) + + +def test_uioutput_has_id_attribute(): + inst = _MyOutput(id="y") + assert inst.id == "y" + + +def test_uilayout_does_not_have_hasinputvalue_by_default(): + inst = _MyLayout() + assert not isinstance(inst, HasInputValue) From 177a04a701a9a7005e68d2f25cf664e84a39d01f Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 13 May 2026 13:27:56 -0400 Subject: [PATCH 11/45] feat(shinyui): local reactive_calc_method helper --- pkg-py/src/shinyui/_reactive.py | 36 +++++++++++++++++++++++++++ pkg-py/tests/shinyui/test_reactive.py | 33 ++++++++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 pkg-py/src/shinyui/_reactive.py create mode 100644 pkg-py/tests/shinyui/test_reactive.py diff --git a/pkg-py/src/shinyui/_reactive.py b/pkg-py/src/shinyui/_reactive.py new file mode 100644 index 00000000..53d0d5b4 --- /dev/null +++ b/pkg-py/src/shinyui/_reactive.py @@ -0,0 +1,36 @@ +"""reactive_calc_method — per-instance @reactive.calc decorator. + +Inspired by Shiny's +``shiny.render._data_frame_utils._reactive_method.reactive_calc_method``. +We hand-roll a small local equivalent (~15 lines) to avoid coupling to a Shiny +private import. Stage B in py-shiny may extract the decorator to a public helper. +""" + +from __future__ import annotations + +from typing import Any, Callable, TypeVar +from weakref import WeakKeyDictionary + +from shiny import reactive + +T = TypeVar("T") + + +def reactive_calc_method(fn: Callable[[Any], T]) -> Callable[[Any], T]: + cache: WeakKeyDictionary[Any, Any] = WeakKeyDictionary() + + def wrapper(self: Any) -> T: + calc = cache.get(self) + if calc is None: + + @reactive.calc + def _calc() -> T: + return fn(self) + + calc = _calc + cache[self] = calc + return calc() + + wrapper.__name__ = fn.__name__ + wrapper.__doc__ = fn.__doc__ + return wrapper diff --git a/pkg-py/tests/shinyui/test_reactive.py b/pkg-py/tests/shinyui/test_reactive.py new file mode 100644 index 00000000..9faf6830 --- /dev/null +++ b/pkg-py/tests/shinyui/test_reactive.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from shiny import reactive +from shinyui._reactive import reactive_calc_method + + +class _Counter: + """Tests caching: the wrapped method is invoked once per change.""" + + def __init__(self) -> None: + self.calls = 0 + + @reactive_calc_method + def value(self) -> int: + self.calls += 1 + return 42 + + +def test_method_returns_value_under_reactive_isolate(): + c = _Counter() + with reactive.isolate(): + assert c.value() == 42 + + +def test_cached_per_instance(): + """Two different instances should have independent caches.""" + a = _Counter() + b = _Counter() + with reactive.isolate(): + assert a.value() == 42 + assert b.value() == 42 + assert a.calls == 1 + assert b.calls == 1 From 64838b3441260f3fcb7dee9d13e0838bb6f2ee06 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 13 May 2026 13:30:18 -0400 Subject: [PATCH 12/45] feat(shinyui): UiInputSlider + input_slider() factory --- pkg-py/src/shinyui/_input_slider.py | 110 ++++++++++++++++++++++ pkg-py/tests/shinyui/test_input_slider.py | 42 +++++++++ 2 files changed, 152 insertions(+) create mode 100644 pkg-py/src/shinyui/_input_slider.py create mode 100644 pkg-py/tests/shinyui/test_input_slider.py diff --git a/pkg-py/src/shinyui/_input_slider.py b/pkg-py/src/shinyui/_input_slider.py new file mode 100644 index 00000000..751b77e7 --- /dev/null +++ b/pkg-py/src/shinyui/_input_slider.py @@ -0,0 +1,110 @@ +"""UiInputSlider — class-based input_slider with typed update() and value() accessor.""" + +from __future__ import annotations + +from typing import Any + +from htmltools import Tag + +from ._reactive import reactive_calc_method +from ._roles import UiInput +from ._updatable import Updatable + +_MISSING = object() + + +class UiInputSlider(UiInput, Updatable): + def __init__( + self, + id: str, + label: str, + min: float, + max: float, + value: float | tuple[float, float], + *, + step: float | None = None, + ticks: bool = False, + animate: bool | Any = False, + width: str | None = None, + sep: str = ",", + pre: str | None = None, + post: str | None = None, + time_format: str | None = None, + timezone: str | None = None, + drag_range: bool = True, + ) -> None: + self.label = label + self.min = min + self.max = max + self._init_value = value # avoid shadowing the value() accessor + self.step = step + self.ticks = ticks + self.animate = animate + self.width = width + self.sep = sep + self.pre = pre + self.post = post + self.time_format = time_format + self.timezone = timezone + self.drag_range = drag_range + super().__init__(id=id) + + @reactive_calc_method + def value(self) -> Any: + return self._read_input() + + def tagify(self) -> Tag: + # Delegating to shiny.ui — markup origin per the design spec. + import shiny.ui as _sui + + return _sui.input_slider( + self.id, + self.label, + self.min, + self.max, + self._init_value, + step=self.step, + ticks=self.ticks, + animate=self.animate, + width=self.width, + sep=self.sep, + pre=self.pre, + post=self.post, + time_format=self.time_format, + timezone=self.timezone, + drag_range=self.drag_range, + ) + + def update( + self, + *, + value: Any = _MISSING, + min: float = _MISSING, # type: ignore[assignment] + max: float = _MISSING, # type: ignore[assignment] + step: float = _MISSING, # type: ignore[assignment] + label: str = _MISSING, # type: ignore[assignment] + ) -> None: + sess = self._require_session(for_op="update") + msg: dict[str, Any] = {} + if value is not _MISSING: + msg["value"] = value + if min is not _MISSING: + msg["min"] = min + if max is not _MISSING: + msg["max"] = max + if step is not _MISSING: + msg["step"] = step + if label is not _MISSING: + msg["label"] = label + sess.send_input_message(self.id, msg) + + +def input_slider( + id: str, + label: str, + min: float, + max: float, + value: float | tuple[float, float], + **kwargs: Any, +) -> UiInputSlider: + return UiInputSlider(id, label, min, max, value, **kwargs) diff --git a/pkg-py/tests/shinyui/test_input_slider.py b/pkg-py/tests/shinyui/test_input_slider.py new file mode 100644 index 00000000..a23cfafd --- /dev/null +++ b/pkg-py/tests/shinyui/test_input_slider.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import pytest +import shiny.ui as sui +from shiny import reactive +from shinyui._input_slider import UiInputSlider, input_slider + + +def test_factory_returns_instance(): + s = input_slider("n", "N", 1, 100, 50) + assert isinstance(s, UiInputSlider) + assert s.id == "n" + + +def test_tagify_matches_shiny_ui_input_slider(): + ours = input_slider("n", "N", 1, 100, 50).tagify() + theirs = sui.input_slider("n", "N", 1, 100, 50) + assert ours.get_html_string() == theirs.get_html_string() + + +def test_value_accessor_reads_input(mock_session): + s = input_slider("n", "N", 1, 100, 50) + mock_session.input.__getitem__.return_value = lambda: 25 + with reactive.isolate(): + assert s.value() == 25 + mock_session.input.__getitem__.assert_called_with("n") + + +def test_update_outside_session_raises(): + s = input_slider("n", "N", 1, 100, 50) # no session + match = r"UiInputSlider\.update\(\) requires an active session" + with pytest.raises(RuntimeError, match=match): + s.update(value=42) + + +def test_update_uses_captured_session(mock_session): + s = input_slider("n", "N", 1, 100, 50) + s.update(value=42) + mock_session.send_input_message.assert_called_once() + name, payload = mock_session.send_input_message.call_args.args + assert name == "n" + assert payload["value"] == 42 From 33f3c39d4e10a967b94523cb3d15e585008227f7 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 13 May 2026 13:32:58 -0400 Subject: [PATCH 13/45] feat(shinyui): UiInputSelect + input_select() factory --- pkg-py/src/shinyui/_input_select.py | 88 +++++++++++++++++++++++ pkg-py/tests/shinyui/test_input_select.py | 40 +++++++++++ 2 files changed, 128 insertions(+) create mode 100644 pkg-py/src/shinyui/_input_select.py create mode 100644 pkg-py/tests/shinyui/test_input_select.py diff --git a/pkg-py/src/shinyui/_input_select.py b/pkg-py/src/shinyui/_input_select.py new file mode 100644 index 00000000..11cdd602 --- /dev/null +++ b/pkg-py/src/shinyui/_input_select.py @@ -0,0 +1,88 @@ +"""UiInputSelect — class-based input_select.""" + +from __future__ import annotations + +from typing import Any, Mapping, Optional, Union + +from htmltools import Tag, TagChild + +from ._reactive import reactive_calc_method +from ._roles import UiInput +from ._updatable import Updatable + +_MISSING = object() + +_Choices = Mapping[str, str] +_OptGrpChoices = Mapping[str, _Choices] +SelectChoicesArg = Union[ + "list[str]", + "tuple[str, ...]", + _Choices, + _OptGrpChoices, +] + + +class UiInputSelect(UiInput, Updatable): + def __init__( + self, + id: str, + label: TagChild, + choices: SelectChoicesArg, + *, + selected: Optional[str | list[str]] = None, + multiple: bool = False, + width: Optional[str] = None, + size: Optional[str] = None, + ) -> None: + self.label = label + self.choices = choices + self._init_selected = selected + self.multiple = multiple + self.width = width + self.size = size + super().__init__(id=id) + + @reactive_calc_method + def value(self) -> Any: + return self._read_input() + + def tagify(self) -> Tag: + import shiny.ui as _sui + + return _sui.input_select( + self.id, + self.label, + self.choices, + selected=self._init_selected, + multiple=self.multiple, + width=self.width, + size=self.size, + ) + + def update( + self, + *, + label: TagChild = _MISSING, # type: ignore[assignment] + choices: SelectChoicesArg = _MISSING, # type: ignore[assignment] + selected: Optional[str | list[str]] = _MISSING, # type: ignore[assignment] + ) -> None: + import shiny.ui as _sui + + sess = self._require_session(for_op="update") + kwargs: dict[str, Any] = {} + if label is not _MISSING: + kwargs["label"] = label + if choices is not _MISSING: + kwargs["choices"] = choices + if selected is not _MISSING: + kwargs["selected"] = selected + _sui.update_select(self.id, session=sess, **kwargs) + + +def input_select( + id: str, + label: TagChild, + choices: SelectChoicesArg, + **kwargs: Any, +) -> UiInputSelect: + return UiInputSelect(id, label, choices, **kwargs) diff --git a/pkg-py/tests/shinyui/test_input_select.py b/pkg-py/tests/shinyui/test_input_select.py new file mode 100644 index 00000000..78075d95 --- /dev/null +++ b/pkg-py/tests/shinyui/test_input_select.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import pytest +import shiny.ui as sui +from shiny import reactive +from shinyui._input_select import UiInputSelect, input_select + + +def test_factory_returns_instance(): + s = input_select("col", "Column", {"a": "A", "b": "B"}) + assert isinstance(s, UiInputSelect) + + +def test_tagify_matches_shiny_ui_input_select(): + ours = input_select("col", "Column", {"a": "A", "b": "B"}).tagify() + theirs = sui.input_select("col", "Column", {"a": "A", "b": "B"}) + assert ours.get_html_string() == theirs.get_html_string() + + +def test_value_accessor(mock_session): + s = input_select("col", "Column", {"a": "A"}) + mock_session.input.__getitem__.return_value = lambda: "a" + with reactive.isolate(): + assert s.value() == "a" + + +def test_update_outside_session_raises(): + s = input_select("col", "Column", {"a": "A"}) + with pytest.raises(RuntimeError): + s.update(selected="a") + + +def test_update_sends_message(mock_session): + s = input_select("col", "Column", {"a": "A"}) + s.update(selected="a") + mock_session.send_input_message.assert_called_once() + name, payload = mock_session.send_input_message.call_args.args + assert name == "col" + # shiny.ui.update_select wraps a single str in a list before sending + assert payload["value"] == ["a"] From 9d6a8a442be257c85deb39caa2e74cfeb5d5719e Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 13 May 2026 13:34:21 -0400 Subject: [PATCH 14/45] feat(shinyui): UiOutputCode + output_code() factory --- pkg-py/src/shinyui/_output_code.py | 23 +++++++++++++++++++++++ pkg-py/tests/shinyui/test_output_code.py | 16 ++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 pkg-py/src/shinyui/_output_code.py create mode 100644 pkg-py/tests/shinyui/test_output_code.py diff --git a/pkg-py/src/shinyui/_output_code.py b/pkg-py/src/shinyui/_output_code.py new file mode 100644 index 00000000..279eadce --- /dev/null +++ b/pkg-py/src/shinyui/_output_code.py @@ -0,0 +1,23 @@ +"""UiOutputCode — class-based output_code.""" + +from __future__ import annotations + +from htmltools import Tag + +from ._roles import UiOutput + + +class UiOutputCode(UiOutput): + def __init__(self, id: str, *, placeholder: bool = True) -> None: + self.id = id + self.placeholder = placeholder + super().__init__() + + def tagify(self) -> Tag: + import shiny.ui as _sui + + return _sui.output_code(self.id, placeholder=self.placeholder) + + +def output_code(id: str, *, placeholder: bool = True) -> UiOutputCode: + return UiOutputCode(id, placeholder=placeholder) diff --git a/pkg-py/tests/shinyui/test_output_code.py b/pkg-py/tests/shinyui/test_output_code.py new file mode 100644 index 00000000..e4e89ac9 --- /dev/null +++ b/pkg-py/tests/shinyui/test_output_code.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +import shiny.ui as sui +from shinyui._output_code import UiOutputCode, output_code + + +def test_factory_returns_instance(): + o = output_code("summary") + assert isinstance(o, UiOutputCode) + assert o.id == "summary" + + +def test_tagify_matches_shiny_ui_output_code(): + ours = output_code("summary").tagify() + theirs = sui.output_code("summary") + assert ours.get_html_string() == theirs.get_html_string() From 6d9d6acef5e4b1c0e98fd09751ecd3d6fa04ad30 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 13 May 2026 13:36:37 -0400 Subject: [PATCH 15/45] feat(shinyui): UiOutputPlot with read-only signal accessors --- pkg-py/src/shinyui/_output_plot.py | 82 ++++++++++++++++++++++++ pkg-py/tests/shinyui/test_output_plot.py | 47 ++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 pkg-py/src/shinyui/_output_plot.py create mode 100644 pkg-py/tests/shinyui/test_output_plot.py diff --git a/pkg-py/src/shinyui/_output_plot.py b/pkg-py/src/shinyui/_output_plot.py new file mode 100644 index 00000000..7cb73acf --- /dev/null +++ b/pkg-py/src/shinyui/_output_plot.py @@ -0,0 +1,82 @@ +"""UiOutputPlot — output with read-only client-side interaction signals. + +Derived input ids: + input._click — {x, y} | None + input._dblclick — {x, y} | None + input._hover — {x, y} | None + input._brush — {xmin, xmax, ymin, ymax, ...} | None + +No HasInputValue, no Updatable. Derived inputs flow through Shiny's +auto-created Value[Any] mechanism on first session.input[...] access. +""" + +from __future__ import annotations + +from typing import Any + +from htmltools import Tag +from shiny.types import MISSING, MISSING_TYPE + +from ._reactive import reactive_calc_method +from ._roles import UiOutput + + +class UiOutputPlot(UiOutput): + def __init__( + self, + id: str, + *, + width: str | float | int = "100%", + height: str | float | int = "400px", + inline: bool = False, + click: bool = False, + dblclick: bool = False, + hover: bool = False, + brush: bool = False, + fill: bool | MISSING_TYPE = MISSING, + ) -> None: + self.id = id + self.width = width + self.height = height + self.inline = inline + self.click_enabled = click + self.dblclick_enabled = dblclick + self.hover_enabled = hover + self.brush_enabled = brush + self.fill = fill + super().__init__() + + @reactive_calc_method + def click_value(self) -> Any: + return self._read_input("_click") + + @reactive_calc_method + def dblclick_value(self) -> Any: + return self._read_input("_dblclick") + + @reactive_calc_method + def hover_value(self) -> Any: + return self._read_input("_hover") + + @reactive_calc_method + def brush_value(self) -> Any: + return self._read_input("_brush") + + def tagify(self) -> Tag: + import shiny.ui as _sui + + return _sui.output_plot( + self.id, + self.width, + self.height, + inline=self.inline, + click=self.click_enabled, + dblclick=self.dblclick_enabled, + hover=self.hover_enabled, + brush=self.brush_enabled, + fill=self.fill, + ) + + +def output_plot(id: str, **kwargs: Any) -> UiOutputPlot: + return UiOutputPlot(id, **kwargs) diff --git a/pkg-py/tests/shinyui/test_output_plot.py b/pkg-py/tests/shinyui/test_output_plot.py new file mode 100644 index 00000000..bdc5c04f --- /dev/null +++ b/pkg-py/tests/shinyui/test_output_plot.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import shiny.ui as sui +from shiny import reactive +from shinyui._output_plot import UiOutputPlot, output_plot + + +def test_factory_returns_instance(): + p = output_plot("p", click=True, brush=True) + assert isinstance(p, UiOutputPlot) + assert p.id == "p" + + +def test_tagify_matches_shiny_ui_output_plot(): + ours = output_plot("p", click=True, brush=True).tagify() + theirs = sui.output_plot("p", click=True, brush=True) + assert ours.get_html_string() == theirs.get_html_string() + + +def test_click_value_reads_correct_id(mock_session): + p = output_plot("p", click=True) + mock_session.input.__getitem__.return_value = lambda: {"x": 10, "y": 20} + with reactive.isolate(): + assert p.click_value() == {"x": 10, "y": 20} + mock_session.input.__getitem__.assert_called_with("p_click") + + +def test_brush_value_reads_correct_id(mock_session): + p = output_plot("p", brush=True) + mock_session.input.__getitem__.return_value = lambda: {"xmin": 1, "xmax": 2} + with reactive.isolate(): + assert p.brush_value() == {"xmin": 1, "xmax": 2} + mock_session.input.__getitem__.assert_called_with("p_brush") + + +def test_hover_and_dblclick_values(mock_session): + p = output_plot("p", hover=True, dblclick=True) + seq = iter([{"x": 1}, {"x": 2}]) + mock_session.input.__getitem__.return_value = lambda: next(seq) + with reactive.isolate(): + assert p.hover_value() == {"x": 1} + assert p.dblclick_value() == {"x": 2} + + +def test_no_update_method(): + p = output_plot("p") + assert not hasattr(p, "update") From 2eb23b1a6ade8c912a3acdaf74f1d1f4440a417e Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 13 May 2026 13:39:32 -0400 Subject: [PATCH 16/45] feat(shinyui): UiAccordionPanel + accordion_panel() factory --- pkg-py/src/shinyui/_accordion_panel.py | 46 ++++++++++++++++++++ pkg-py/tests/shinyui/test_accordion_panel.py | 35 +++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 pkg-py/src/shinyui/_accordion_panel.py create mode 100644 pkg-py/tests/shinyui/test_accordion_panel.py diff --git a/pkg-py/src/shinyui/_accordion_panel.py b/pkg-py/src/shinyui/_accordion_panel.py new file mode 100644 index 00000000..4f633ddc --- /dev/null +++ b/pkg-py/src/shinyui/_accordion_panel.py @@ -0,0 +1,46 @@ +"""UiAccordionPanel — layout child of UiAccordion.""" + +from __future__ import annotations + +from typing import Any + +from htmltools import TagChild +from shiny.types import MISSING, MISSING_TYPE +from shiny.ui._accordion import AccordionPanel + +from ._children import AllowsChildren +from ._roles import UiLayout + + +class UiAccordionPanel(UiLayout, AllowsChildren): + def __init__( + self, + title: str, + *args: TagChild, + value: str | MISSING_TYPE = MISSING, + icon: TagChild | None = None, + ) -> None: + self.title = title + self._value: str | MISSING_TYPE = value + self.icon = icon + super().__init__(*args) + + @property + def value(self) -> str: + if isinstance(self._value, MISSING_TYPE): + return self.title + return self._value + + def tagify(self) -> AccordionPanel: # type: ignore[override] + import shiny.ui as _sui + + return _sui.accordion_panel( + self.title, + *self.children, + value=self._value, + icon=self.icon, + ) + + +def accordion_panel(title: str, *args: TagChild, **kwargs: Any) -> UiAccordionPanel: + return UiAccordionPanel(title, *args, **kwargs) diff --git a/pkg-py/tests/shinyui/test_accordion_panel.py b/pkg-py/tests/shinyui/test_accordion_panel.py new file mode 100644 index 00000000..f2c606d0 --- /dev/null +++ b/pkg-py/tests/shinyui/test_accordion_panel.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import shiny.ui as sui +from htmltools import tags +from shinyui._accordion_panel import UiAccordionPanel, accordion_panel +from shinyui._children import AllowsChildren + + +def test_factory_returns_instance(): + p = accordion_panel("Settings", "body") + assert isinstance(p, UiAccordionPanel) + assert isinstance(p, AllowsChildren) + + +def test_children_collected(): + p = accordion_panel("Settings", "a", "b") + assert "a" in p.children and "b" in p.children + + +def test_tagify_matches_shiny(): + # accordion_panel() returns an AccordionPanel (not a plain Tag). + # Compare key attributes that drive rendered output — random bslib panel IDs + # make full HTML string comparison non-deterministic. + ours = accordion_panel("Settings", "body").tagify() + theirs = sui.accordion_panel("Settings", "body") + assert ours._title == theirs._title + assert ours._args == theirs._args + assert ours._data_value == theirs._data_value + assert ours._icon == theirs._icon + + +def test_with_block_appends(): + with accordion_panel("Settings") as p: + p.append(tags.p("inside")) + assert len(p.children) == 1 From 38d95acbfc251d6109b873ae97c63406e52c13b6 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 13 May 2026 13:43:42 -0400 Subject: [PATCH 17/45] feat(shinyui): UiAccordion + accordion() factory --- pkg-py/src/shinyui/_accordion.py | 124 +++++++++++++++++++++++++ pkg-py/tests/shinyui/test_accordion.py | 78 ++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 pkg-py/src/shinyui/_accordion.py create mode 100644 pkg-py/tests/shinyui/test_accordion.py diff --git a/pkg-py/src/shinyui/_accordion.py b/pkg-py/src/shinyui/_accordion.py new file mode 100644 index 00000000..585e7e33 --- /dev/null +++ b/pkg-py/src/shinyui/_accordion.py @@ -0,0 +1,124 @@ +"""UiAccordion — layout with collapsible panels; open-panel set exposed as input value. + +Implementation note: shiny's accordion already registers its own input binding that +pushes the open-panel list to the server as a list. No custom input handler is +registered here (approach A); open_panels() coerces list -> tuple at read time. +""" + +from __future__ import annotations + +from typing import Any, Optional + +from htmltools import Tag + +from ._accordion_panel import UiAccordionPanel +from ._children import AllowsChildren +from ._input_value import HasInputValue +from ._reactive import reactive_calc_method +from ._roles import UiLayout +from ._updatable import Updatable + +_MISSING = object() + + +class UiAccordion(UiLayout, AllowsChildren, HasInputValue, Updatable): + """Accordion container; open-panel set is available via open_panels(). + + No custom input handler is registered — shiny's own accordion binding handles + the wire format. open_panels() coerces the received list to a tuple at read time. + """ + + def __init__( + self, + *args: UiAccordionPanel, + id: str, + open: Optional[str | tuple[str, ...] | bool] = None, + multiple: bool = True, + class_: Optional[str] = None, + width: Optional[str] = None, + height: Optional[str] = None, + ) -> None: + self._open = open + self.multiple = multiple + self.class_ = class_ + self.width = width + self.height = height + super().__init__(*args, id=id) + + @reactive_calc_method + def open_panels(self) -> tuple[str, ...]: + """Return the currently open accordion panel values as a tuple.""" + return tuple(self._read_input() or ()) + + def tagify(self) -> Tag: + import shiny.ui as _sui + + # Each child's tagify() returns an AccordionPanel object. + panels = [child.tagify() for child in self.children] + return _sui.accordion( + *panels, + id=self.id, + open=self._open, + multiple=self.multiple, + class_=self.class_, + width=self.width, + height=self.height, + ) + + def update( + self, + *, + open: tuple[str, ...] | list[str] | bool = _MISSING, # type: ignore[assignment] + show: tuple[str, ...] | list[str] | str = _MISSING, # type: ignore[assignment] + hide: tuple[str, ...] | list[str] | str = _MISSING, # type: ignore[assignment] + ) -> None: + """Update the accordion's open/closed state. + + Parameters + ---------- + open + Panel value(s) to set as open (closes all others). Passed to + shiny.ui.update_accordion as ``show=``. Pass ``True`` to open all, + ``False`` to close all. + show + Panel value(s) to open without closing others, via + shiny.ui.update_accordion_panel per target. + hide + Panel value(s) to close without affecting others, via + shiny.ui.update_accordion_panel per target. + """ + import shiny.ui as _sui + + sess = self._require_session(for_op="update") + + if open is not _MISSING: + # update_accordion sends method="set" — sets which panels are open. + show_val: Any = list(open) if isinstance(open, (tuple, list)) else open + _sui.update_accordion(self.id, show=show_val, session=sess) + + if show is not _MISSING: + targets = [show] if isinstance(show, str) else list(show) + for target in targets: + _sui.update_accordion_panel(self.id, target, show=True, session=sess) + + if hide is not _MISSING: + targets = [hide] if isinstance(hide, str) else list(hide) + for target in targets: + _sui.update_accordion_panel(self.id, target, show=False, session=sess) + + +def accordion(*args: UiAccordionPanel, id: str, **kwargs: Any) -> UiAccordion: + """Create a UiAccordion. + + Parameters + ---------- + *args + :class:`UiAccordionPanel` children. + id + Input id; available as ``input.id()`` in the server, or via + ``accordion_obj.open_panels()``. + **kwargs + Forwarded to :class:`UiAccordion` (``open``, ``multiple``, ``class_``, + ``width``, ``height``). + """ + return UiAccordion(*args, id=id, **kwargs) diff --git a/pkg-py/tests/shinyui/test_accordion.py b/pkg-py/tests/shinyui/test_accordion.py new file mode 100644 index 00000000..e1f63d24 --- /dev/null +++ b/pkg-py/tests/shinyui/test_accordion.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import pytest +from shiny import reactive +from shinyui._accordion import UiAccordion, accordion +from shinyui._accordion_panel import accordion_panel +from shinyui._children import AllowsChildren +from shinyui._input_value import HasInputValue +from shinyui._updatable import Updatable + + +def test_factory_returns_instance(): + a = accordion(accordion_panel("A"), accordion_panel("B"), id="acc") + assert isinstance(a, UiAccordion) + assert isinstance(a, HasInputValue) + assert isinstance(a, AllowsChildren) + assert isinstance(a, Updatable) + + +def test_tagify_attribute_parity(): + """Compare key attrs vs shiny.ui.accordion; random bslib ids break full HTML eq.""" + import shiny.ui as sui + + ours = accordion( + accordion_panel("A", "body-a"), + accordion_panel("B", "body-b"), + id="acc", + open="A", + ).tagify() + theirs = sui.accordion( + sui.accordion_panel("A", "body-a"), + sui.accordion_panel("B", "body-b"), + id="acc", + open="A", + ) + # Both resolve to a Tag (
); assert same type and id attribute. + assert type(ours).__name__ == type(theirs).__name__ + assert ours.attrs.get("id") == theirs.attrs.get("id") + + +def test_children_collected(): + a = accordion(accordion_panel("A"), accordion_panel("B"), id="acc") + assert len(a.children) == 2 + + +def test_open_panels_accessor(mock_session): + a = accordion(accordion_panel("A"), id="acc") + mock_session.input.__getitem__.return_value = lambda: ["A"] + with reactive.isolate(): + assert a.open_panels() == ("A",) + + +def test_open_panels_empty_returns_empty_tuple(mock_session): + a = accordion(accordion_panel("A"), id="acc") + mock_session.input.__getitem__.return_value = lambda: [] + with reactive.isolate(): + assert a.open_panels() == () + + +def test_open_panels_none_returns_empty_tuple(mock_session): + a = accordion(accordion_panel("A"), id="acc") + mock_session.input.__getitem__.return_value = lambda: None + with reactive.isolate(): + assert a.open_panels() == () + + +def test_update_outside_session_raises(): + a = accordion(accordion_panel("A"), id="acc") + with pytest.raises(RuntimeError): + a.update(open=("A",)) + + +def test_update_sends_message(mock_session): + a = accordion(accordion_panel("A"), accordion_panel("B"), id="acc") + a.update(open=("A", "B")) + # shiny's update_accordion defers via session.on_flush() rather than calling + # send_input_message directly. Assert that a flush callback was registered. + mock_session.on_flush.assert_called_once() From cbae0daf9ef8a2faf242974be84452907fd175fe Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 13 May 2026 13:48:17 -0400 Subject: [PATCH 18/45] feat(shinyui): UiCard with full_screen_value() and update() --- pkg-py/src/shinyui/_card.py | 113 ++++++++++++++++++++++++++++++ pkg-py/tests/shinyui/test_card.py | 51 ++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 pkg-py/src/shinyui/_card.py create mode 100644 pkg-py/tests/shinyui/test_card.py diff --git a/pkg-py/src/shinyui/_card.py b/pkg-py/src/shinyui/_card.py new file mode 100644 index 00000000..0cae009a --- /dev/null +++ b/pkg-py/src/shinyui/_card.py @@ -0,0 +1,113 @@ +"""UiCard — layout with optional full-screen toggle exposed as input value. + +NOTE: real wire-level `full_screen` input is out of scope for the Stage A +prototype (would require client-side JS). The `full_screen_value()` accessor +exists for the class-design test path; in a live app it will be None until +client JS is added. + +`shiny.ui.card` already accepts an `id` kwarg and reports +``input._full_screen`` as a bool when the browser's card JS fires, but +that browser JS is not wired in the prototype. This class uses the plain +``self.id`` key so unit tests can mock it straightforwardly. +""" + +from __future__ import annotations + +from typing import Any, Optional + +from htmltools import Tag, TagChild + +from ._children import AllowsChildren +from ._input_value import HasInputValue +from ._reactive import reactive_calc_method +from ._roles import UiLayout +from ._updatable import Updatable + +_MISSING = object() + + +class UiCard(UiLayout, AllowsChildren, HasInputValue, Updatable): + """Card container; full-screen state is available via full_screen_value(). + + No custom input handler is registered — shiny's own card binding handles + the wire format when client JS is present. full_screen_value() reads the + input keyed by self.id (mocked in tests). + """ + + def __init__( + self, + *args: TagChild, + id: str, + full_screen: bool = False, + height: Optional[str] = None, + max_height: Optional[str] = None, + min_height: Optional[str] = None, + fill: bool = True, + class_: Optional[str] = None, + ) -> None: + self._full_screen = full_screen + self.height = height + self.max_height = max_height + self.min_height = min_height + self.fill = fill + self.class_ = class_ + super().__init__(*args, id=id) + + @reactive_calc_method + def full_screen_value(self) -> bool: + """Return whether the card is currently in full-screen mode.""" + return bool(self._read_input()) + + def tagify(self) -> Tag: + import shiny.ui as _sui + + kwargs: dict[str, Any] = { + "full_screen": self._full_screen, + "fill": self.fill, + "id": self.id, + } + if self.height is not None: + kwargs["height"] = self.height + if self.max_height is not None: + kwargs["max_height"] = self.max_height + if self.min_height is not None: + kwargs["min_height"] = self.min_height + if self.class_ is not None: + kwargs["class_"] = self.class_ + + return _sui.card(*self.children, **kwargs) + + def update( + self, + *, + full_screen: bool = _MISSING, # type: ignore[assignment] + ) -> None: + """Send a full-screen state update to the client. + + Parameters + ---------- + full_screen + Target full-screen state to apply on the client. + """ + sess = self._require_session(for_op="update") + if full_screen is _MISSING: + return + # There is no shiny.ui.update_card today; use send_input_message directly. + sess.send_input_message(self.id, {"full_screen": full_screen}) + + +def card(*args: TagChild, id: str, **kwargs: Any) -> UiCard: + """Create a :class:`UiCard`. + + Parameters + ---------- + *args + UI children. + id + Input id; available as ``input.id()`` in the server, or via + ``card_obj.full_screen_value()``. + **kwargs + Forwarded to :class:`UiCard` (``full_screen``, ``height``, + ``max_height``, ``min_height``, ``fill``, ``class_``). + """ + return UiCard(*args, id=id, **kwargs) diff --git a/pkg-py/tests/shinyui/test_card.py b/pkg-py/tests/shinyui/test_card.py new file mode 100644 index 00000000..3a9df726 --- /dev/null +++ b/pkg-py/tests/shinyui/test_card.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import pytest +import shiny.ui as sui +from shiny import reactive +from shinyui._card import UiCard, card +from shinyui._children import AllowsChildren +from shinyui._input_value import HasInputValue +from shinyui._updatable import Updatable + + +def test_factory_returns_instance(): + c = card("body", id="main") + assert isinstance(c, UiCard) + assert isinstance(c, HasInputValue) + assert isinstance(c, AllowsChildren) + assert isinstance(c, Updatable) + + +def test_tagify_matches_shiny(): + """Compare HTML (or attrs if shiny.ui.card returns a non-Tag wrapper).""" + ours = card("body", id="main", full_screen=False).tagify() + theirs = sui.card("body", id="main", full_screen=False) + # If shiny.ui.card returns a Tag, compare HTML. + # If it returns a CardItem or similar, compare type or relevant attrs. + if hasattr(ours, "get_html_string") and hasattr(theirs, "get_html_string"): + assert ours.get_html_string() == theirs.get_html_string() + else: + assert type(ours).__name__ == type(theirs).__name__ + + +def test_full_screen_value(mock_session): + c = card("body", id="main") + mock_session.input.__getitem__.return_value = lambda: True + with reactive.isolate(): + assert c.full_screen_value() is True + mock_session.input.__getitem__.assert_called_with("main") + + +def test_update_outside_session_raises(): + c = card("body", id="main") + with pytest.raises(RuntimeError): + c.update(full_screen=True) + + +def test_update_sends_message_or_flush(mock_session): + """Card update should signal the session (send_input_message or on_flush).""" + c = card("body", id="main") + c.update(full_screen=True) + # Either send_input_message or on_flush should have been called. + assert mock_session.send_input_message.called or mock_session.on_flush.called From 7fc155a1e25ce42722246aee316dd207f92a4ce5 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 13 May 2026 13:50:14 -0400 Subject: [PATCH 19/45] feat(shinyui): public exports + accordion pyright fixes --- pkg-py/src/shinyui/__init__.py | 37 ++++++++++++++++++++- pkg-py/src/shinyui/_accordion.py | 6 ++-- pkg-py/tests/shinyui/test_public_exports.py | 19 +++++++++++ 3 files changed, 59 insertions(+), 3 deletions(-) create mode 100644 pkg-py/tests/shinyui/test_public_exports.py diff --git a/pkg-py/src/shinyui/__init__.py b/pkg-py/src/shinyui/__init__.py index b3a01a33..02d97d81 100644 --- a/pkg-py/src/shinyui/__init__.py +++ b/pkg-py/src/shinyui/__init__.py @@ -3,4 +3,39 @@ See docs/superpowers/specs/2026-05-13-shinyui-metadata-consolidation-design.md. """ -__all__: list[str] = [] +from ._accordion import UiAccordion, accordion +from ._accordion_panel import UiAccordionPanel, accordion_panel +from ._base import UiComponent +from ._card import UiCard, card +from ._children import AllowsChildren +from ._input_select import UiInputSelect, input_select +from ._input_slider import UiInputSlider, input_slider +from ._input_value import HasInputValue +from ._output_code import UiOutputCode, output_code +from ._output_plot import UiOutputPlot, output_plot +from ._roles import UiInput, UiLayout, UiOutput +from ._updatable import Updatable + +__all__ = [ + "AllowsChildren", + "HasInputValue", + "UiAccordion", + "UiAccordionPanel", + "UiCard", + "UiComponent", + "UiInput", + "UiInputSelect", + "UiInputSlider", + "UiLayout", + "UiOutput", + "UiOutputCode", + "UiOutputPlot", + "Updatable", + "accordion", + "accordion_panel", + "card", + "input_select", + "input_slider", + "output_code", + "output_plot", +] diff --git a/pkg-py/src/shinyui/_accordion.py b/pkg-py/src/shinyui/_accordion.py index 585e7e33..b1195abe 100644 --- a/pkg-py/src/shinyui/_accordion.py +++ b/pkg-py/src/shinyui/_accordion.py @@ -53,8 +53,10 @@ def open_panels(self) -> tuple[str, ...]: def tagify(self) -> Tag: import shiny.ui as _sui - # Each child's tagify() returns an AccordionPanel object. - panels = [child.tagify() for child in self.children] + # Each child's tagify() returns an AccordionPanel object that shiny.ui.accordion + # accepts directly. The list comprehension's element type is TagChild's union, + # so we widen with type: ignore at both the tagify call and the *unpack. + panels: list = [child.tagify() for child in self.children] # type: ignore[union-attr] return _sui.accordion( *panels, id=self.id, diff --git a/pkg-py/tests/shinyui/test_public_exports.py b/pkg-py/tests/shinyui/test_public_exports.py new file mode 100644 index 00000000..8a116b7e --- /dev/null +++ b/pkg-py/tests/shinyui/test_public_exports.py @@ -0,0 +1,19 @@ +def test_public_exports(): + import shinyui as sui + + # Class names + assert sui.UiComponent + assert sui.UiInput and sui.UiOutput and sui.UiLayout + assert sui.HasInputValue and sui.Updatable and sui.AllowsChildren + assert sui.UiInputSlider and sui.UiInputSelect + assert sui.UiOutputCode and sui.UiOutputPlot + assert sui.UiCard and sui.UiAccordion and sui.UiAccordionPanel + + # Factory names + assert callable(sui.input_slider) + assert callable(sui.input_select) + assert callable(sui.output_code) + assert callable(sui.output_plot) + assert callable(sui.card) + assert callable(sui.accordion) + assert callable(sui.accordion_panel) From 1d6c6da4f79daf6019c0dcf030f90506bc16c67f Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 13 May 2026 13:53:14 -0400 Subject: [PATCH 20/45] test(shinyui): cross-cutting hierarchy + lifecycle tests --- pkg-py/tests/shinyui/test_allows_children.py | 32 +++++++ pkg-py/tests/shinyui/test_hierarchy.py | 87 +++++++++++++++++++ .../test_input_handler_registration.py | 26 ++++++ pkg-py/tests/shinyui/test_read_accessors.py | 59 +++++++++++++ .../tests/shinyui/test_update_resolution.py | 34 ++++++++ 5 files changed, 238 insertions(+) create mode 100644 pkg-py/tests/shinyui/test_allows_children.py create mode 100644 pkg-py/tests/shinyui/test_hierarchy.py create mode 100644 pkg-py/tests/shinyui/test_input_handler_registration.py create mode 100644 pkg-py/tests/shinyui/test_read_accessors.py create mode 100644 pkg-py/tests/shinyui/test_update_resolution.py diff --git a/pkg-py/tests/shinyui/test_allows_children.py b/pkg-py/tests/shinyui/test_allows_children.py new file mode 100644 index 00000000..436a5e62 --- /dev/null +++ b/pkg-py/tests/shinyui/test_allows_children.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import shinyui as sui +from htmltools import tags + + +def test_card_append_mutates_children(): + c = sui.card(id="m") + c.append(tags.p("hi")) + assert len(c.children) == 1 + + +def test_card_with_block_collects_via_append(): + with sui.card(id="m") as c: + c.append(tags.p("inside")) + assert len(c.children) == 1 + + +def test_accordion_panel_can_be_nested_in_accordion(): + a = sui.accordion( + sui.accordion_panel("A", tags.p("a-body")), + sui.accordion_panel("B", tags.p("b-body")), + id="acc", + ) + assert len(a.children) == 2 + + +def test_bare_tag_in_with_block_is_not_auto_collected(): + """Tag-as-CM is sub-issue 3 (out of scope for this prototype).""" + with sui.card(id="m") as c: + tags.p("not collected") # noqa: B018 intentional bare expr + assert c.children == [] diff --git a/pkg-py/tests/shinyui/test_hierarchy.py b/pkg-py/tests/shinyui/test_hierarchy.py new file mode 100644 index 00000000..cbb6761a --- /dev/null +++ b/pkg-py/tests/shinyui/test_hierarchy.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import pytest +import shinyui as sui + + +def _maker(cls): + """Build a representative instance of `cls` with whatever args its factory needs.""" + if cls is sui.UiInputSlider: + return sui.input_slider("n", "N", 1, 10, 5) + if cls is sui.UiInputSelect: + return sui.input_select("c", "C", {"a": "A"}) + if cls is sui.UiOutputCode: + return sui.output_code("o") + if cls is sui.UiOutputPlot: + return sui.output_plot("p") + if cls is sui.UiCard: + return sui.card("b", id="m") + if cls is sui.UiAccordion: + return sui.accordion(sui.accordion_panel("A"), id="acc") + if cls is sui.UiAccordionPanel: + return sui.accordion_panel("X", "y") + raise AssertionError(f"no maker for {cls}") + + +ALL_CLASSES = [ + sui.UiInputSlider, + sui.UiInputSelect, + sui.UiOutputCode, + sui.UiOutputPlot, + sui.UiCard, + sui.UiAccordion, + sui.UiAccordionPanel, +] + + +@pytest.mark.parametrize("cls", ALL_CLASSES) +def test_is_uicomponent(cls): + assert isinstance(_maker(cls), sui.UiComponent) + + +@pytest.mark.parametrize( + "cls,expected", + [ + (sui.UiInputSlider, {sui.UiInput, sui.HasInputValue, sui.Updatable}), + (sui.UiInputSelect, {sui.UiInput, sui.HasInputValue, sui.Updatable}), + (sui.UiOutputCode, {sui.UiOutput}), + (sui.UiOutputPlot, {sui.UiOutput}), + ( + sui.UiCard, + {sui.UiLayout, sui.AllowsChildren, sui.HasInputValue, sui.Updatable}, + ), + ( + sui.UiAccordion, + {sui.UiLayout, sui.AllowsChildren, sui.HasInputValue, sui.Updatable}, + ), + (sui.UiAccordionPanel, {sui.UiLayout, sui.AllowsChildren}), + ], +) +def test_expected_bases(cls, expected): + inst = _maker(cls) + for base in expected: + assert isinstance(inst, base), ( + f"{cls.__name__} should be instance of {base.__name__}" + ) + + +@pytest.mark.parametrize( + "cls,allows_children", + [ + (sui.UiInputSlider, False), + (sui.UiInputSelect, False), + (sui.UiOutputCode, False), + (sui.UiOutputPlot, False), + (sui.UiCard, True), + (sui.UiAccordion, True), + (sui.UiAccordionPanel, True), + ], +) +def test_with_block_protocol(cls, allows_children): + inst = _maker(cls) + if allows_children: + with inst as ctx: + assert ctx is inst + else: + with pytest.raises(TypeError, match=f"{cls.__name__} does not accept children"): + inst.__enter__() diff --git a/pkg-py/tests/shinyui/test_input_handler_registration.py b/pkg-py/tests/shinyui/test_input_handler_registration.py new file mode 100644 index 00000000..316ed72f --- /dev/null +++ b/pkg-py/tests/shinyui/test_input_handler_registration.py @@ -0,0 +1,26 @@ +"""Pin the prototype's handler-registration state. + +UPDATED FROM PLAN: UiAccordion does NOT register a custom input handler in this +prototype — shiny's accordion binding sends a plain JSON list that doesn't need +server-side wire coercion. open_panels() coerces list->tuple at read time. +""" + +from __future__ import annotations + +import pytest +import shinyui as sui + + +@pytest.mark.parametrize( + "cls", + [ + sui.UiInputSlider, + sui.UiInputSelect, + sui.UiCard, + sui.UiAccordion, + ], +) +def test_class_declares_no_custom_handler(cls): + """All prototype classes use shiny's default wire handling for input values.""" + assert cls.input_handler_name == "" + assert cls._input_handler is None diff --git a/pkg-py/tests/shinyui/test_read_accessors.py b/pkg-py/tests/shinyui/test_read_accessors.py new file mode 100644 index 00000000..367e613e --- /dev/null +++ b/pkg-py/tests/shinyui/test_read_accessors.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import pytest +import shinyui as sui +from shiny import reactive + + +@pytest.mark.parametrize( + "maker,accessor,suffix,value", + [ + (lambda: sui.input_slider("n", "N", 1, 10, 5), "value", "", 7), + (lambda: sui.input_select("c", "C", {"a": "A"}), "value", "", "a"), + (lambda: sui.card("b", id="m"), "full_screen_value", "", True), + ( + lambda: sui.accordion(sui.accordion_panel("A"), id="acc"), + "open_panels", + "", + ["A"], + ), + ( + lambda: sui.output_plot("p", click=True), + "click_value", + "_click", + {"x": 1, "y": 2}, + ), + ( + lambda: sui.output_plot("p", brush=True), + "brush_value", + "_brush", + {"xmin": 1}, + ), + ], +) +def test_accessor_reads_correct_id(mock_session, maker, accessor, suffix, value): + inst = maker() + expected_id = f"{inst.id}{suffix}" + mock_session.input.__getitem__.return_value = lambda: value + with reactive.isolate(): + result = getattr(inst, accessor)() + if isinstance(value, list): + assert result == tuple(value) + else: + assert result == value + mock_session.input.__getitem__.assert_called_with(expected_id) + + +@pytest.mark.parametrize( + "maker,accessor", + [ + (lambda: sui.input_slider("n", "N", 1, 10, 5), "value"), + (lambda: sui.card("b", id="m"), "full_screen_value"), + (lambda: sui.output_plot("p", click=True), "click_value"), + ], +) +def test_accessor_raises_outside_session(maker, accessor): + inst = maker() + with pytest.raises(RuntimeError, match=r"requires an active session"): + with reactive.isolate(): + getattr(inst, accessor)() diff --git a/pkg-py/tests/shinyui/test_update_resolution.py b/pkg-py/tests/shinyui/test_update_resolution.py new file mode 100644 index 00000000..e6d82c9c --- /dev/null +++ b/pkg-py/tests/shinyui/test_update_resolution.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import inspect + +import pytest +import shinyui as sui + + +@pytest.mark.parametrize( + "maker", + [ + lambda: sui.input_slider("n", "N", 1, 10, 5), + lambda: sui.input_select("c", "C", {"a": "A"}), + lambda: sui.card("b", id="m"), + lambda: sui.accordion(sui.accordion_panel("A"), id="acc"), + ], +) +def test_update_raises_outside_session(maker): + inst = maker() + with pytest.raises(RuntimeError, match=r"requires an active session"): + inst.update() + + +def test_update_uses_captured_session(mock_session): + s = sui.input_slider("n", "N", 1, 10, 5) + s.update(value=7) + mock_session.send_input_message.assert_called_once() + + +def test_update_no_session_kwarg(): + """update() must not accept a `session=` kwarg.""" + s = sui.input_slider("n", "N", 1, 10, 5) + sig = inspect.signature(s.update) + assert "session" not in sig.parameters From 85c90085510011b06acbff448588cc820d662e36 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 13 May 2026 13:54:08 -0400 Subject: [PATCH 21/45] test(shinyui): bookmark id->instance round-trip --- .../tests/shinyui/test_bookmark_roundtrip.py | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 pkg-py/tests/shinyui/test_bookmark_roundtrip.py diff --git a/pkg-py/tests/shinyui/test_bookmark_roundtrip.py b/pkg-py/tests/shinyui/test_bookmark_roundtrip.py new file mode 100644 index 00000000..853e2386 --- /dev/null +++ b/pkg-py/tests/shinyui/test_bookmark_roundtrip.py @@ -0,0 +1,61 @@ +"""End-to-end bookmark id->instance registration tests. + +Verifies that constructing a HasInputValue inside a session registers the +instance on the session map (so bookmark machinery can find it later), and +that per-instance serializer overrides flow through correctly. +""" + +from __future__ import annotations + +from typing import Any + +import shinyui as sui +from shinyui._bookmark import lookup_instance + + +def test_session_registry_records_slider_on_construction(mock_session): + s = sui.input_slider("n", "N", 1, 10, 5) + assert lookup_instance(mock_session, "n") is s + + +def test_session_registry_records_select_on_construction(mock_session): + s = sui.input_select("c", "C", {"a": "A"}) + assert lookup_instance(mock_session, "c") is s + + +def test_session_registry_records_card_on_construction(mock_session): + c = sui.card("body", id="main_card") + assert lookup_instance(mock_session, "main_card") is c + + +def test_session_registry_records_accordion_on_construction(mock_session): + a = sui.accordion(sui.accordion_panel("A"), id="acc") + assert lookup_instance(mock_session, "acc") is a + + +def test_per_instance_serializer_override(): + """Per-instance override of the class-default serializer. + + Concrete factory signatures don't expose `bookmark_serializer` directly + in this prototype; users assign to `_bookmark_serializer` on the instance + if they want to override. The mechanism (instance attr wins over ClassVar) + is what `HasInputValue.__init__` already wires up. + """ + + class Custom: + async def serialize(self, value: Any, state_dir: Any) -> Any: + return value + + async def deserialize(self, value: Any, state_dir: Any) -> Any: + return value + + custom = Custom() + s = sui.input_slider("n", "N", 1, 10, 5) + s._bookmark_serializer = custom + assert s._bookmark_serializer is custom + + +def test_no_session_no_registry_noop(): + """Construction without a session must not raise and must not crash later.""" + s = sui.input_slider("n", "N", 1, 10, 5) + assert s._session is None From 138f3168a1081a7e36233836b82bd5f3f5828b3c Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 13 May 2026 13:56:50 -0400 Subject: [PATCH 22/45] feat(shinyui): lookup_component + example app exercising the full reference set --- .../app-py/14-unified-ui-prototype/README.md | 73 ++++++++++++ .../app-py/14-unified-ui-prototype/app.py | 109 ++++++++++++++++++ pkg-py/src/shinyui/__init__.py | 2 + pkg-py/src/shinyui/_bookmark.py | 5 + 4 files changed, 189 insertions(+) create mode 100644 examples/app-py/14-unified-ui-prototype/README.md create mode 100644 examples/app-py/14-unified-ui-prototype/app.py diff --git a/examples/app-py/14-unified-ui-prototype/README.md b/examples/app-py/14-unified-ui-prototype/README.md new file mode 100644 index 00000000..0c3ffec2 --- /dev/null +++ b/examples/app-py/14-unified-ui-prototype/README.md @@ -0,0 +1,73 @@ +# 14 — Unified UI prototype (shinyui Stage A) + +End-to-end demo of [shinyui](../../../pkg-py/src/shinyui), the class-per-component +UI hierarchy from issue #69 (umbrella #68). Each UI component is a Python class +that owns its own metadata (handler, serializer, HTML deps, `update()`, server-side +read accessors). + +## Run + +``` +uv run shiny run examples/app-py/14-unified-ui-prototype/app.py +``` + +Requires `matplotlib` for the placeholder plot (already in this repo's `examples` +extras group). + +## What this demonstrates + +| Archetype | Class | Demonstrated by | +|---|---|---| +| Simple input | `UiInputSlider` | `n` and `seed` sliders | +| Structured input | `UiInputSelect` | `dist` selector | +| Plain output | `UiOutputCode` | `summary` and `diag` outputs | +| Output with read-only signals | `UiOutputPlot` | `plot` with `click=True, brush=True` | +| Layout with children + state | `UiCard` | `main_card.full_screen_value()`, `main_card.update(full_screen=...)` | +| Layout with state + children | `UiAccordion` | `acc.open_panels()`, `acc.update(open=...)` | +| Layout-as-child | `UiAccordionPanel` | Two panels inside `acc` | + +## Class-per-component patterns in the server code + +The server uses `su.lookup_component(session, id)` to retrieve typed handles for +each component constructed in `app_ui(request)`. Through those handles, server +code reads input values: + +```python +n_slider = cast(su.UiInputSlider, su.lookup_component(session, "n")) + +@render.code +def summary(): + return f"n = {n_slider.value()}" +``` + +…and pushes updates: + +```python +@reactive.effect +def _auto_expand_at_high_n(): + if n_slider.value() > 800: + main_card.update(full_screen=True) + acc.update(open=("Settings", "Diagnostics")) +``` + +Each `.value()` / `.full_screen_value()` / `.open_panels()` / `.click_value()` / +`.brush_value()` accessor is a `@reactive.calc` under the hood, so reads inside +reactive contexts establish dependencies correctly. + +## What to try + +- Drag `n` — `summary` updates immediately. +- Drag past `n=800` — the card auto-expands to full-screen and both accordion + panels open via `.update()` calls. +- Click or brush on the plot — coordinates appear in the `diag` panel via + `plot_handle.click_value()` / `plot_handle.brush_value()`. + +## Notes on real-app fidelity + +- `UiCard.full_screen_value()` reads `input.()` — Stage A doesn't wire + the browser-side push for `full_screen` state, so the value stays `False` in + a live browser session until the JS binding is added. Unit tests exercise the + full path with a mocked session. +- Plot click/brush bindings ARE wired by shiny natively — `output_plot(click=True, + brush=True)` registers the standard shiny.plot bindings, so the JSON-shaped + values flow into `input._click` / `input._brush` as expected. diff --git a/examples/app-py/14-unified-ui-prototype/app.py b/examples/app-py/14-unified-ui-prototype/app.py new file mode 100644 index 00000000..96ac3840 --- /dev/null +++ b/examples/app-py/14-unified-ui-prototype/app.py @@ -0,0 +1,109 @@ +"""End-to-end demo of shinyui's class-per-component hierarchy. + +Exercises every reference class in one page: + - UiInputSlider, UiInputSelect (simple + structured inputs) + - UiOutputCode (output) + - UiOutputPlot (output with read-only signals) + - UiCard (layout with state) + - UiAccordion + UiAccordionPanel (layout-with-state + layout-as-child) + +The `app_ui` is a function (not a module-level Tag) so a session is in scope +when components are constructed — this is what enables class-owned bookmark +serializers (and the id->instance registry) to register themselves. + +Server code uses `shinyui.lookup_component(session, id)` to fetch the typed +component instance, giving access to: + - `.value()` / `.full_screen_value()` / `.open_panels()` + / `.click_value()` / `.brush_value()` + - `.update(...)` for server-driven changes +""" + +from __future__ import annotations + +from typing import cast + +import shinyui as su +from shiny import App, Inputs, Outputs, Session, reactive, render, ui + + +def app_ui(request): + return ui.page_fluid( + su.card( + su.input_slider("n", "Sample size", 10, 1000, 100), + su.input_select( + "dist", + "Distribution", + {"normal": "Normal", "uniform": "Uniform"}, + ), + su.output_code("summary"), + su.output_plot("plot", click=True, brush=True), + su.accordion( + su.accordion_panel( + "Settings", + su.input_slider("seed", "Seed", 1, 1000, 42), + ), + su.accordion_panel( + "Diagnostics", + su.output_code("diag"), + ), + id="acc", + open="Settings", + ), + id="main_card", + full_screen=False, + ), + title="shinyui Stage A prototype", + ) + + +def server(input: Inputs, output: Outputs, session: Session): + # Fetch typed component handles by id from the per-session registry. + n_slider = cast(su.UiInputSlider, su.lookup_component(session, "n")) + seed_slider = cast(su.UiInputSlider, su.lookup_component(session, "seed")) + dist_select = cast(su.UiInputSelect, su.lookup_component(session, "dist")) + main_card = cast(su.UiCard, su.lookup_component(session, "main_card")) + acc = cast(su.UiAccordion, su.lookup_component(session, "acc")) + plot_handle = cast(su.UiOutputPlot, su.lookup_component(session, "plot")) + + @render.code + def summary(): + # Reads via class-level accessor — no input.n() / input.dist() needed. + return ( + f"n = {n_slider.value()}\n" + f"dist = {dist_select.value()}\n" + f"seed = {seed_slider.value()}\n" + f"open = {acc.open_panels()}\n" + f"fs = {main_card.full_screen_value()}\n" + ) + + @render.code + def diag(): + click = plot_handle.click_value() + brush = plot_handle.brush_value() + return f"click = {click}\nbrush = {brush}" + + @render.plot + def plot(): + # Minimal placeholder — shows click/brush target area. + import matplotlib.pyplot as plt # type: ignore[import-not-found] + + fig, ax = plt.subplots() + ax.text( + 0.5, + 0.5, + "Click or brush to populate diag panel.", + ha="center", + va="center", + ) + ax.set_axis_off() + return fig + + # Server-driven updates on layouts-with-state: + @reactive.effect + def _auto_expand_at_high_n(): + if n_slider.value() > 800: + main_card.update(full_screen=True) + acc.update(open=("Settings", "Diagnostics")) + + +app = App(app_ui, server) diff --git a/pkg-py/src/shinyui/__init__.py b/pkg-py/src/shinyui/__init__.py index 02d97d81..29b90551 100644 --- a/pkg-py/src/shinyui/__init__.py +++ b/pkg-py/src/shinyui/__init__.py @@ -6,6 +6,7 @@ from ._accordion import UiAccordion, accordion from ._accordion_panel import UiAccordionPanel, accordion_panel from ._base import UiComponent +from ._bookmark import lookup_component from ._card import UiCard, card from ._children import AllowsChildren from ._input_select import UiInputSelect, input_select @@ -36,6 +37,7 @@ "card", "input_select", "input_slider", + "lookup_component", "output_code", "output_plot", ] diff --git a/pkg-py/src/shinyui/_bookmark.py b/pkg-py/src/shinyui/_bookmark.py index 46729e3a..c12ca315 100644 --- a/pkg-py/src/shinyui/_bookmark.py +++ b/pkg-py/src/shinyui/_bookmark.py @@ -31,3 +31,8 @@ def register_instance(session: "Session", id: str, instance: "HasInputValue") -> def lookup_instance(session: "Session", id: str) -> "HasInputValue | None": return get_session_instances(session).get(id) + + +def lookup_component(session: "Session", id: str) -> "HasInputValue | None": + """Public helper: find a HasInputValue instance registered on this session.""" + return lookup_instance(session, id) From 9aa10e1c23362d5f928cd51bac075ffe19a952d4 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 13 May 2026 14:24:52 -0400 Subject: [PATCH 23/45] fix(shinyui): recursively tagify subtrees in container classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit htmltools' built-in Tag.tagify walks only one level: it replaces direct Tagifiable children with their tagify() result but does not recurse into the resulting Tag's own children. Containers whose tagify wraps un-resolved Tagifiable descendants therefore reached htmltools' rendering layer with nested Tagifiables, triggering 'non-tagified object' at render time. Add UiComponent._deep_tagify(node) — a small recursive helper that walks Tag/TagList children, calling tagify() on every Tagifiable it encounters. Apply it in UiCard and UiAccordion (the two containers whose children can themselves be Tagifiable). The example app now renders successfully. --- pkg-py/src/shinyui/_accordion.py | 25 ++++++++++++----------- pkg-py/src/shinyui/_base.py | 34 +++++++++++++++++++++++++++++++- pkg-py/src/shinyui/_card.py | 2 +- 3 files changed, 48 insertions(+), 13 deletions(-) diff --git a/pkg-py/src/shinyui/_accordion.py b/pkg-py/src/shinyui/_accordion.py index b1195abe..a366c218 100644 --- a/pkg-py/src/shinyui/_accordion.py +++ b/pkg-py/src/shinyui/_accordion.py @@ -53,18 +53,21 @@ def open_panels(self) -> tuple[str, ...]: def tagify(self) -> Tag: import shiny.ui as _sui - # Each child's tagify() returns an AccordionPanel object that shiny.ui.accordion - # accepts directly. The list comprehension's element type is TagChild's union, - # so we widen with type: ignore at both the tagify call and the *unpack. + # Each child's tagify() returns an AccordionPanel that shiny.ui.accordion + # accepts directly. Deep-resolve the result so any Tagifiable descendants + # within the panels' content (e.g. an input_slider inside a panel) are + # fully expanded before htmltools renders. panels: list = [child.tagify() for child in self.children] # type: ignore[union-attr] - return _sui.accordion( - *panels, - id=self.id, - open=self._open, - multiple=self.multiple, - class_=self.class_, - width=self.width, - height=self.height, + return self._deep_tagify( + _sui.accordion( + *panels, + id=self.id, + open=self._open, + multiple=self.multiple, + class_=self.class_, + width=self.width, + height=self.height, + ) ) def update( diff --git a/pkg-py/src/shinyui/_base.py b/pkg-py/src/shinyui/_base.py index 31e8fd6b..32e0e7a1 100644 --- a/pkg-py/src/shinyui/_base.py +++ b/pkg-py/src/shinyui/_base.py @@ -5,6 +5,10 @@ - `_require_session(for_op=...)`: resolves a session at call time, with a fallback to the current session, raising RuntimeError if none is reachable. - `_read_input(suffix="")`: reads `session.input[f"{self.id}{suffix}"]()`. + - `_deep_tagify(node)`: recursively resolves a node tree to fully-rendered + Tag/TagList output. Required because htmltools' built-in Tag.tagify only + walks one level deep; containers whose children are themselves Tagifiable + must pre-resolve their subtrees. `tagify()` is abstract. `__enter__` raises by default; `AllowsChildren` overrides. """ @@ -12,9 +16,10 @@ from __future__ import annotations from abc import ABC, abstractmethod +from copy import copy from typing import Any, ClassVar -from htmltools import HTMLDependency, Tag +from htmltools import HTMLDependency, Tag, TagList, Tagifiable from shiny.session import Session, get_current_session from typing_extensions import Self @@ -44,6 +49,33 @@ def _read_input(self, suffix: str = "") -> Any: sess = self._require_session(for_op="_read_input") return sess.input[f"{self.id}{suffix}"]() # type: ignore[attr-defined] + @staticmethod + def _deep_tagify(node: Any) -> Any: + """Recursively resolve any Tagifiable descendants of `node`. + + htmltools' built-in `Tag.tagify` only walks one level: it replaces + Tagifiable direct children, but doesn't recurse into the resulting + Tag's own children. When our container classes wrap children that + are themselves Tagifiable, we must pre-resolve the subtree before + returning from `tagify()` — otherwise htmltools' renderer hits a + "non-tagified object" RuntimeError at HTML generation time. + """ + # Resolve a Tagifiable (non-Tag) by calling its tagify(). + if isinstance(node, Tagifiable) and not isinstance(node, Tag): + node = node.tagify() + # Recurse into Tag children. + if isinstance(node, Tag): + cp = copy(node) + cp.children = TagList( + *(UiComponent._deep_tagify(c) for c in node.children) + ) + return cp + # Recurse into TagList items. + if isinstance(node, TagList): + return TagList(*(UiComponent._deep_tagify(c) for c in node)) + # Leaves (strings, MetadataNode, HTML, None, etc.) pass through. + return node + @abstractmethod def tagify(self) -> Tag: ... diff --git a/pkg-py/src/shinyui/_card.py b/pkg-py/src/shinyui/_card.py index 0cae009a..194b7f37 100644 --- a/pkg-py/src/shinyui/_card.py +++ b/pkg-py/src/shinyui/_card.py @@ -75,7 +75,7 @@ def tagify(self) -> Tag: if self.class_ is not None: kwargs["class_"] = self.class_ - return _sui.card(*self.children, **kwargs) + return self._deep_tagify(_sui.card(*self.children, **kwargs)) def update( self, From 4c930f2a592bb1315076c8e8677b56619874e6dd Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 13 May 2026 14:39:47 -0400 Subject: [PATCH 24/45] fix(shinyui,example): card full_screen reads _full_screen suffix; module-level demo components - UiCard.full_screen_value() now reads input._full_screen (matches Shiny's card binding wire format) instead of input.. Update card and read-accessors tests to match. - Example app 14 constructs components at module level so closures share them between app_ui (HTTP phase, no session) and server (WebSocket phase, session bound). The lookup_component approach in the previous version returned None during HTTP-phase construction, crashed the session, and showed the grey 'Disconnected' overlay. - Replace matplotlib placeholder with a PIL solid-color image so the example runs without matplotlib in the venv. --- .../app-py/14-unified-ui-prototype/app.py | 109 ++++++++---------- pkg-py/src/shinyui/_base.py | 6 +- pkg-py/src/shinyui/_card.py | 9 +- pkg-py/tests/shinyui/test_card.py | 2 +- pkg-py/tests/shinyui/test_read_accessors.py | 2 +- 5 files changed, 58 insertions(+), 70 deletions(-) diff --git a/examples/app-py/14-unified-ui-prototype/app.py b/examples/app-py/14-unified-ui-prototype/app.py index 96ac3840..818a2464 100644 --- a/examples/app-py/14-unified-ui-prototype/app.py +++ b/examples/app-py/14-unified-ui-prototype/app.py @@ -7,67 +7,57 @@ - UiCard (layout with state) - UiAccordion + UiAccordionPanel (layout-with-state + layout-as-child) -The `app_ui` is a function (not a module-level Tag) so a session is in scope -when components are constructed — this is what enables class-owned bookmark -serializers (and the id->instance registry) to register themselves. - -Server code uses `shinyui.lookup_component(session, id)` to fetch the typed -component instance, giving access to: - - `.value()` / `.full_screen_value()` / `.open_panels()` - / `.click_value()` / `.brush_value()` - - `.update(...)` for server-driven changes +Components are constructed at module level. In Shiny Core, ``app_ui(request)`` +runs during the HTTP phase — before any WebSocket session exists — so a +session-time id→instance registry would be empty when ``server()`` later runs. +Module-level construction sidesteps that timing issue: ``app_ui`` and +``server`` share the same instances via closure. The per-session bookmark +serializer registry is therefore a no-op in this example; the class accessors +(``.value()``, ``.full_screen_value()``, ``.open_panels()``, ``.click_value()``, +``.brush_value()``) and ``.update(...)`` work because ``_require_session`` +falls back to ``get_current_session()`` at call time, which is bound while +``server()`` runs. """ from __future__ import annotations -from typing import cast - import shinyui as su from shiny import App, Inputs, Outputs, Session, reactive, render, ui +# --- Components ------------------------------------------------------------- +n_slider = su.input_slider("n", "Sample size", 10, 1000, 100) +seed_slider = su.input_slider("seed", "Seed", 1, 1000, 42) +dist_select = su.input_select( + "dist", + "Distribution", + {"normal": "Normal", "uniform": "Uniform"}, +) +plot_handle = su.output_plot("plot", click=True, brush=True) +acc = su.accordion( + su.accordion_panel("Settings", seed_slider), + su.accordion_panel("Diagnostics", su.output_code("diag")), + id="acc", + open="Settings", +) +main_card = su.card( + n_slider, + dist_select, + su.output_code("summary"), + plot_handle, + acc, + id="main_card", + full_screen=False, +) + def app_ui(request): - return ui.page_fluid( - su.card( - su.input_slider("n", "Sample size", 10, 1000, 100), - su.input_select( - "dist", - "Distribution", - {"normal": "Normal", "uniform": "Uniform"}, - ), - su.output_code("summary"), - su.output_plot("plot", click=True, brush=True), - su.accordion( - su.accordion_panel( - "Settings", - su.input_slider("seed", "Seed", 1, 1000, 42), - ), - su.accordion_panel( - "Diagnostics", - su.output_code("diag"), - ), - id="acc", - open="Settings", - ), - id="main_card", - full_screen=False, - ), - title="shinyui Stage A prototype", - ) + return ui.page_fluid(main_card, title="shinyui Stage A prototype") def server(input: Inputs, output: Outputs, session: Session): - # Fetch typed component handles by id from the per-session registry. - n_slider = cast(su.UiInputSlider, su.lookup_component(session, "n")) - seed_slider = cast(su.UiInputSlider, su.lookup_component(session, "seed")) - dist_select = cast(su.UiInputSelect, su.lookup_component(session, "dist")) - main_card = cast(su.UiCard, su.lookup_component(session, "main_card")) - acc = cast(su.UiAccordion, su.lookup_component(session, "acc")) - plot_handle = cast(su.UiOutputPlot, su.lookup_component(session, "plot")) - @render.code def summary(): - # Reads via class-level accessor — no input.n() / input.dist() needed. + # Reads via class accessors — no `input.n()` / `input.dist()` needed. return ( f"n = {n_slider.value()}\n" f"dist = {dist_select.value()}\n" @@ -78,25 +68,20 @@ def summary(): @render.code def diag(): - click = plot_handle.click_value() - brush = plot_handle.brush_value() - return f"click = {click}\nbrush = {brush}" + return ( + f"click = {plot_handle.click_value()}\n" + f"brush = {plot_handle.brush_value()}\n" + ) @render.plot def plot(): - # Minimal placeholder — shows click/brush target area. - import matplotlib.pyplot as plt # type: ignore[import-not-found] + # No matplotlib dependency — return a 1x1 transparent PIL image as a + # placeholder so the `output_plot` div has visible bounds for click + # and brush events. The point of this example is the class hierarchy, + # not the rendered figure. + from PIL import Image - fig, ax = plt.subplots() - ax.text( - 0.5, - 0.5, - "Click or brush to populate diag panel.", - ha="center", - va="center", - ) - ax.set_axis_off() - return fig + return Image.new("RGBA", (320, 200), (245, 245, 245, 255)) # Server-driven updates on layouts-with-state: @reactive.effect diff --git a/pkg-py/src/shinyui/_base.py b/pkg-py/src/shinyui/_base.py index 32e0e7a1..1007364b 100644 --- a/pkg-py/src/shinyui/_base.py +++ b/pkg-py/src/shinyui/_base.py @@ -19,7 +19,7 @@ from copy import copy from typing import Any, ClassVar -from htmltools import HTMLDependency, Tag, TagList, Tagifiable +from htmltools import HTMLDependency, Tag, Tagifiable, TagList from shiny.session import Session, get_current_session from typing_extensions import Self @@ -66,9 +66,7 @@ def _deep_tagify(node: Any) -> Any: # Recurse into Tag children. if isinstance(node, Tag): cp = copy(node) - cp.children = TagList( - *(UiComponent._deep_tagify(c) for c in node.children) - ) + cp.children = TagList(*(UiComponent._deep_tagify(c) for c in node.children)) return cp # Recurse into TagList items. if isinstance(node, TagList): diff --git a/pkg-py/src/shinyui/_card.py b/pkg-py/src/shinyui/_card.py index 194b7f37..d930426e 100644 --- a/pkg-py/src/shinyui/_card.py +++ b/pkg-py/src/shinyui/_card.py @@ -55,8 +55,13 @@ def __init__( @reactive_calc_method def full_screen_value(self) -> bool: - """Return whether the card is currently in full-screen mode.""" - return bool(self._read_input()) + """Return whether the card is currently in full-screen mode. + + Shiny's card binding pushes the full-screen state to + ``input._full_screen`` (not ``input.()`` — the id itself has + no primary value). + """ + return bool(self._read_input("_full_screen")) def tagify(self) -> Tag: import shiny.ui as _sui diff --git a/pkg-py/tests/shinyui/test_card.py b/pkg-py/tests/shinyui/test_card.py index 3a9df726..f0d4ef10 100644 --- a/pkg-py/tests/shinyui/test_card.py +++ b/pkg-py/tests/shinyui/test_card.py @@ -34,7 +34,7 @@ def test_full_screen_value(mock_session): mock_session.input.__getitem__.return_value = lambda: True with reactive.isolate(): assert c.full_screen_value() is True - mock_session.input.__getitem__.assert_called_with("main") + mock_session.input.__getitem__.assert_called_with("main_full_screen") def test_update_outside_session_raises(): diff --git a/pkg-py/tests/shinyui/test_read_accessors.py b/pkg-py/tests/shinyui/test_read_accessors.py index 367e613e..9235f26a 100644 --- a/pkg-py/tests/shinyui/test_read_accessors.py +++ b/pkg-py/tests/shinyui/test_read_accessors.py @@ -10,7 +10,7 @@ [ (lambda: sui.input_slider("n", "N", 1, 10, 5), "value", "", 7), (lambda: sui.input_select("c", "C", {"a": "A"}), "value", "", "a"), - (lambda: sui.card("b", id="m"), "full_screen_value", "", True), + (lambda: sui.card("b", id="m"), "full_screen_value", "_full_screen", True), ( lambda: sui.accordion(sui.accordion_panel("A"), id="acc"), "open_panels", From bfa0771f08d5c229262783c4667e4fe743646b63 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 13 May 2026 14:44:38 -0400 Subject: [PATCH 25/45] feat(example): visible plot placeholder for app-py/14 Solid-color placeholder was indistinguishable from the card background. Added a border, crosshair, and label text via PIL.ImageDraw so the plot output has a clear visual target for click and brush interactions. --- .../app-py/14-unified-ui-prototype/app.py | 20 ++++++++++++------ shinyui-prototype.png | Bin 0 -> 34196 bytes 2 files changed, 14 insertions(+), 6 deletions(-) create mode 100644 shinyui-prototype.png diff --git a/examples/app-py/14-unified-ui-prototype/app.py b/examples/app-py/14-unified-ui-prototype/app.py index 818a2464..f52dbcf3 100644 --- a/examples/app-py/14-unified-ui-prototype/app.py +++ b/examples/app-py/14-unified-ui-prototype/app.py @@ -75,13 +75,21 @@ def diag(): @render.plot def plot(): - # No matplotlib dependency — return a 1x1 transparent PIL image as a - # placeholder so the `output_plot` div has visible bounds for click - # and brush events. The point of this example is the class hierarchy, - # not the rendered figure. - from PIL import Image + # Placeholder figure so the `output_plot` div has visible bounds for + # click and brush events. The point of this example is the class + # hierarchy, not the rendered figure. Uses PIL (no matplotlib dep). + from PIL import Image, ImageDraw - return Image.new("RGBA", (320, 200), (245, 245, 245, 255)) + img = Image.new("RGB", (640, 400), (250, 245, 230)) # warm off-white + d = ImageDraw.Draw(img) + # Border so the click/brush target is visible against the card. + d.rectangle([0, 0, 639, 399], outline=(100, 100, 100), width=2) + # Crosshair so the demo feels alive. + d.line([0, 200, 640, 200], fill=(200, 200, 200), width=1) + d.line([320, 0, 320, 400], fill=(200, 200, 200), width=1) + d.text((20, 20), "Click or brush — diag panel echoes the signal.", + fill=(40, 40, 40)) + return img # Server-driven updates on layouts-with-state: @reactive.effect diff --git a/shinyui-prototype.png b/shinyui-prototype.png new file mode 100644 index 0000000000000000000000000000000000000000..90324791999cfeda5a6242c0f6a1705da43cec86 GIT binary patch literal 34196 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 literal 0 HcmV?d00001 From e6c82bb71eb59793cbc80ce3968446520840e7a5 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 13 May 2026 14:44:43 -0400 Subject: [PATCH 26/45] chore: gitignore stray screenshot from local playwright run --- .gitignore | 1 + shinyui-prototype.png | Bin 34196 -> 0 bytes 2 files changed, 1 insertion(+) delete mode 100644 shinyui-prototype.png diff --git a/.gitignore b/.gitignore index 719a61d4..618723e3 100644 --- a/.gitignore +++ b/.gitignore @@ -226,3 +226,4 @@ node_modules/ # Built assets in shiny-react upstream examples examples/shiny-react-upstream/*/www/ +shinyui-prototype.png diff --git a/shinyui-prototype.png b/shinyui-prototype.png deleted file mode 100644 index 90324791999cfeda5a6242c0f6a1705da43cec86..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 34196 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 From cda9a7cd661583a87022a4515404204466c78194 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 13 May 2026 15:35:33 -0400 Subject: [PATCH 27/45] fix(shinyui): cross-session reactive_calc_method cache invalidation WeakKeyDictionary keyed only on the component instance kept the same reactive.calc alive across sessions. Module-level components see many sessions; the calc bound to session #1 is destroyed when that session ends, and session #2 hit a DestroyedReactiveError when re-using the cached calc -> grey 'Disconnected' overlay. Switch to a per-instance attribute that stores (session_obj, calc). If the captured session differs from the current one, recreate the calc. --- pkg-py/src/shinyui/_reactive.py | 34 ++++++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/pkg-py/src/shinyui/_reactive.py b/pkg-py/src/shinyui/_reactive.py index 53d0d5b4..973b1fb8 100644 --- a/pkg-py/src/shinyui/_reactive.py +++ b/pkg-py/src/shinyui/_reactive.py @@ -2,34 +2,50 @@ Inspired by Shiny's ``shiny.render._data_frame_utils._reactive_method.reactive_calc_method``. -We hand-roll a small local equivalent (~15 lines) to avoid coupling to a Shiny -private import. Stage B in py-shiny may extract the decorator to a public helper. +We hand-roll a small local equivalent to avoid coupling to a Shiny private +import. Stage B in py-shiny may extract the decorator to a public helper. + +Session-awareness +----------------- +shinyui components are often constructed at module level, so the same +instance is reused across many WebSocket sessions. A ``@reactive.calc`` is +bound to whichever session was active when it was created and is destroyed +when that session ends. If we cache one calc per instance (instance-keyed +only), the second session sees a destroyed calc → ``DestroyedReactiveError`` +→ session-wide crash → "grey overlay" in the browser. + +The cache below is keyed by ``(instance, session)`` so each session gets a +fresh ``@reactive.calc``. Entries are evicted on session-end via +``shiny.session.Session.on_ended``. """ from __future__ import annotations from typing import Any, Callable, TypeVar -from weakref import WeakKeyDictionary from shiny import reactive +from shiny.session import get_current_session T = TypeVar("T") def reactive_calc_method(fn: Callable[[Any], T]) -> Callable[[Any], T]: - cache: WeakKeyDictionary[Any, Any] = WeakKeyDictionary() + # Single calc-cache slot per instance, stored as an instance attribute. + # Holds a tuple (session_obj, calc) so we can detect cross-session reuse. + attr_name = f"_rcm_calc_{fn.__name__}_{id(fn):x}" def wrapper(self: Any) -> T: - calc = cache.get(self) - if calc is None: + sess = get_current_session() + cached = getattr(self, attr_name, None) + if cached is None or cached[0] is not sess: @reactive.calc def _calc() -> T: return fn(self) - calc = _calc - cache[self] = calc - return calc() + setattr(self, attr_name, (sess, _calc)) + return _calc() + return cached[1]() wrapper.__name__ = fn.__name__ wrapper.__doc__ = fn.__doc__ From 294febac91b771c1e8f6a9242ef1bfa0a0448f35 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 13 May 2026 15:37:02 -0400 Subject: [PATCH 28/45] feat(example): real scatter plot driven by slider/select/seed reads --- .../app-py/14-unified-ui-prototype/app.py | 40 ++++++++++++------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/examples/app-py/14-unified-ui-prototype/app.py b/examples/app-py/14-unified-ui-prototype/app.py index f52dbcf3..c3851e28 100644 --- a/examples/app-py/14-unified-ui-prototype/app.py +++ b/examples/app-py/14-unified-ui-prototype/app.py @@ -75,21 +75,31 @@ def diag(): @render.plot def plot(): - # Placeholder figure so the `output_plot` div has visible bounds for - # click and brush events. The point of this example is the class - # hierarchy, not the rendered figure. Uses PIL (no matplotlib dep). - from PIL import Image, ImageDraw - - img = Image.new("RGB", (640, 400), (250, 245, 230)) # warm off-white - d = ImageDraw.Draw(img) - # Border so the click/brush target is visible against the card. - d.rectangle([0, 0, 639, 399], outline=(100, 100, 100), width=2) - # Crosshair so the demo feels alive. - d.line([0, 200, 640, 200], fill=(200, 200, 200), width=1) - d.line([320, 0, 320, 400], fill=(200, 200, 200), width=1) - d.text((20, 20), "Click or brush — diag panel echoes the signal.", - fill=(40, 40, 40)) - return img + # Real scatter plot driven by the slider/select/seed inputs. + # Demonstrates the read-accessor chain ending in @render.plot: + # all three reads establish reactive deps so the plot recomputes + # on input changes. + import matplotlib.pyplot as plt + import numpy as np + + rng = np.random.default_rng(seed_slider.value()) + n = n_slider.value() + if dist_select.value() == "normal": + x = rng.standard_normal(n) + y = rng.standard_normal(n) + else: + x = rng.uniform(-2, 2, n) + y = rng.uniform(-2, 2, n) + + fig, ax = plt.subplots(figsize=(6, 4)) + ax.scatter(x, y, s=12, alpha=0.6) + ax.set_title( + f"{dist_select.value()} sample, n={n}, seed={seed_slider.value()}" + ) + ax.set_xlabel("x") + ax.set_ylabel("y") + ax.grid(True, alpha=0.3) + return fig # Server-driven updates on layouts-with-state: @reactive.effect From 402b038f8eb20f10883d281e1aec566eb6c123fe Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 13 May 2026 15:38:50 -0400 Subject: [PATCH 29/45] feat(example): two-button accordion control; sample/dist/seed live in Settings panel --- .../app-py/14-unified-ui-prototype/app.py | 34 ++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/examples/app-py/14-unified-ui-prototype/app.py b/examples/app-py/14-unified-ui-prototype/app.py index c3851e28..8b6e75bc 100644 --- a/examples/app-py/14-unified-ui-prototype/app.py +++ b/examples/app-py/14-unified-ui-prototype/app.py @@ -34,17 +34,20 @@ ) plot_handle = su.output_plot("plot", click=True, brush=True) acc = su.accordion( - su.accordion_panel("Settings", seed_slider), + su.accordion_panel("Settings", n_slider, dist_select, seed_slider), su.accordion_panel("Diagnostics", su.output_code("diag")), id="acc", open="Settings", ) main_card = su.card( - n_slider, - dist_select, + ui.layout_column_wrap( + ui.input_action_button("open_all", "Open all panels"), + ui.input_action_button("close_all", "Close all panels"), + width=1 / 2, + ), + acc, su.output_code("summary"), plot_handle, - acc, id="main_card", full_screen=False, ) @@ -93,20 +96,27 @@ def plot(): fig, ax = plt.subplots(figsize=(6, 4)) ax.scatter(x, y, s=12, alpha=0.6) - ax.set_title( - f"{dist_select.value()} sample, n={n}, seed={seed_slider.value()}" - ) + ax.set_title(f"{dist_select.value()} sample, n={n}, seed={seed_slider.value()}") ax.set_xlabel("x") ax.set_ylabel("y") ax.grid(True, alpha=0.3) return fig - # Server-driven updates on layouts-with-state: + # Server-driven .update() on the layout-with-state accordion. + # Each button click is an event input (action button → counter); we use + # @reactive.event so a click runs the effect exactly once, not on every + # other input change. + @reactive.effect + @reactive.event(input.open_all, ignore_init=True) + def _open_all_panels(): + acc.update(open=("Settings", "Diagnostics")) + @reactive.effect - def _auto_expand_at_high_n(): - if n_slider.value() > 800: - main_card.update(full_screen=True) - acc.update(open=("Settings", "Diagnostics")) + @reactive.event(input.close_all, ignore_init=True) + def _close_all_panels(): + # update_accordion's `show=` takes a panel value list, OR True/False. + # False closes all panels in the set. + acc.update(open=False) app = App(app_ui, server) From b45002f35bbe63f9a2e7c516a73b009a817cd3b3 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 13 May 2026 15:39:34 -0400 Subject: [PATCH 30/45] feat(example): move summary code block into Diagnostics accordion panel --- examples/app-py/14-unified-ui-prototype/app.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/examples/app-py/14-unified-ui-prototype/app.py b/examples/app-py/14-unified-ui-prototype/app.py index 8b6e75bc..fefa9caf 100644 --- a/examples/app-py/14-unified-ui-prototype/app.py +++ b/examples/app-py/14-unified-ui-prototype/app.py @@ -35,7 +35,11 @@ plot_handle = su.output_plot("plot", click=True, brush=True) acc = su.accordion( su.accordion_panel("Settings", n_slider, dist_select, seed_slider), - su.accordion_panel("Diagnostics", su.output_code("diag")), + su.accordion_panel( + "Diagnostics", + su.output_code("summary"), + su.output_code("diag"), + ), id="acc", open="Settings", ) @@ -46,7 +50,6 @@ width=1 / 2, ), acc, - su.output_code("summary"), plot_handle, id="main_card", full_screen=False, From bcc6364950e86762bdc4919f818109ba24d9dd0e Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 13 May 2026 16:08:07 -0400 Subject: [PATCH 31/45] feat(shinyui): UiInputActionButton class + integrate into example 14 UiInputActionButton(UiInput, Updatable) ships the same shape as the other input classes: - typed __init__ mirroring shiny.ui.input_action_button (label, icon, width, disabled) - .count() reactive accessor exposing the click counter (0 before first click) - typed update(*, label, icon, disabled) delegating to shiny.ui.update_action_button - factory function input_action_button(...) - 6 unit tests pinning factory/snapshot/count/update behavior Example 14 swaps ui.input_action_button for su.input_action_button on both Open-all and Close-all controls and wires the @reactive.event deps via btn.count instead of input., so the demo's button surface is fully class-based now. --- .../app-py/14-unified-ui-prototype/app.py | 18 ++-- pkg-py/src/shinyui/__init__.py | 3 + pkg-py/src/shinyui/_input_action_button.py | 82 +++++++++++++++++++ .../tests/shinyui/test_input_action_button.py | 52 ++++++++++++ 4 files changed, 145 insertions(+), 10 deletions(-) create mode 100644 pkg-py/src/shinyui/_input_action_button.py create mode 100644 pkg-py/tests/shinyui/test_input_action_button.py diff --git a/examples/app-py/14-unified-ui-prototype/app.py b/examples/app-py/14-unified-ui-prototype/app.py index fefa9caf..cb43a138 100644 --- a/examples/app-py/14-unified-ui-prototype/app.py +++ b/examples/app-py/14-unified-ui-prototype/app.py @@ -33,6 +33,8 @@ {"normal": "Normal", "uniform": "Uniform"}, ) plot_handle = su.output_plot("plot", click=True, brush=True) +open_all_btn = su.input_action_button("open_all", "Open all panels") +close_all_btn = su.input_action_button("close_all", "Close all panels") acc = su.accordion( su.accordion_panel("Settings", n_slider, dist_select, seed_slider), su.accordion_panel( @@ -44,11 +46,7 @@ open="Settings", ) main_card = su.card( - ui.layout_column_wrap( - ui.input_action_button("open_all", "Open all panels"), - ui.input_action_button("close_all", "Close all panels"), - width=1 / 2, - ), + ui.layout_column_wrap(open_all_btn, close_all_btn, width=1 / 2), acc, plot_handle, id="main_card", @@ -106,16 +104,16 @@ def plot(): return fig # Server-driven .update() on the layout-with-state accordion. - # Each button click is an event input (action button → counter); we use - # @reactive.event so a click runs the effect exactly once, not on every - # other input change. + # Each button instance exposes a typed `.count()` reactive accessor + # (click counter); @reactive.event fires the effect once per click, + # not on every other input change. @reactive.effect - @reactive.event(input.open_all, ignore_init=True) + @reactive.event(open_all_btn.count, ignore_init=True) def _open_all_panels(): acc.update(open=("Settings", "Diagnostics")) @reactive.effect - @reactive.event(input.close_all, ignore_init=True) + @reactive.event(close_all_btn.count, ignore_init=True) def _close_all_panels(): # update_accordion's `show=` takes a panel value list, OR True/False. # False closes all panels in the set. diff --git a/pkg-py/src/shinyui/__init__.py b/pkg-py/src/shinyui/__init__.py index 29b90551..55184eea 100644 --- a/pkg-py/src/shinyui/__init__.py +++ b/pkg-py/src/shinyui/__init__.py @@ -9,6 +9,7 @@ from ._bookmark import lookup_component from ._card import UiCard, card from ._children import AllowsChildren +from ._input_action_button import UiInputActionButton, input_action_button from ._input_select import UiInputSelect, input_select from ._input_slider import UiInputSlider, input_slider from ._input_value import HasInputValue @@ -25,6 +26,7 @@ "UiCard", "UiComponent", "UiInput", + "UiInputActionButton", "UiInputSelect", "UiInputSlider", "UiLayout", @@ -35,6 +37,7 @@ "accordion", "accordion_panel", "card", + "input_action_button", "input_select", "input_slider", "lookup_component", diff --git a/pkg-py/src/shinyui/_input_action_button.py b/pkg-py/src/shinyui/_input_action_button.py new file mode 100644 index 00000000..a5e1e38d --- /dev/null +++ b/pkg-py/src/shinyui/_input_action_button.py @@ -0,0 +1,82 @@ +"""UiInputActionButton — class-based input_action_button with a count accessor.""" + +from __future__ import annotations + +from typing import Any, Optional + +from htmltools import Tag, TagChild + +from ._reactive import reactive_calc_method +from ._roles import UiInput +from ._updatable import Updatable + +_MISSING = object() + + +class UiInputActionButton(UiInput, Updatable): + """Server-readable button. + + ``input.()`` is an integer counter that starts at 0 and increments on + each click. The class accessor :meth:`count` returns the current value as a + reactive read; pair with :func:`shiny.reactive.event` to run code on each + click without firing on the initial value. + """ + + def __init__( + self, + id: str, + label: TagChild, + *, + icon: TagChild = None, + width: Optional[str] = None, + disabled: bool = False, + ) -> None: + self.label = label + self.icon = icon + self.width = width + self.disabled = disabled + super().__init__(id=id) + + @reactive_calc_method + def count(self) -> int: + """Click counter; 0 before the first click, +1 per click.""" + return int(self._read_input() or 0) + + def tagify(self) -> Tag: + import shiny.ui as _sui + + return _sui.input_action_button( + self.id, + self.label, + icon=self.icon, + width=self.width, + disabled=self.disabled, + ) + + def update( + self, + *, + label: TagChild = _MISSING, # type: ignore[assignment] + icon: TagChild = _MISSING, # type: ignore[assignment] + disabled: bool = _MISSING, # type: ignore[assignment] + ) -> None: + """Push label / icon / disabled changes to the client.""" + import shiny.ui as _sui + + sess = self._require_session(for_op="update") + kwargs: dict[str, Any] = {} + if label is not _MISSING: + kwargs["label"] = label + if icon is not _MISSING: + kwargs["icon"] = icon + if disabled is not _MISSING: + kwargs["disabled"] = disabled + _sui.update_action_button(self.id, session=sess, **kwargs) + + +def input_action_button( + id: str, + label: TagChild, + **kwargs: Any, +) -> UiInputActionButton: + return UiInputActionButton(id, label, **kwargs) diff --git a/pkg-py/tests/shinyui/test_input_action_button.py b/pkg-py/tests/shinyui/test_input_action_button.py new file mode 100644 index 00000000..3705c759 --- /dev/null +++ b/pkg-py/tests/shinyui/test_input_action_button.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import pytest +import shiny.ui as sui +from shiny import reactive +from shinyui._input_action_button import UiInputActionButton, input_action_button + + +def test_factory_returns_instance(): + b = input_action_button("go", "Go") + assert isinstance(b, UiInputActionButton) + assert b.id == "go" + + +def test_tagify_matches_shiny_ui_input_action_button(): + ours = input_action_button("go", "Go").tagify() + theirs = sui.input_action_button("go", "Go") + assert ours.get_html_string() == theirs.get_html_string() + + +def test_count_zero_when_input_is_none(mock_session): + b = input_action_button("go", "Go") + mock_session.input.__getitem__.return_value = lambda: None + with reactive.isolate(): + assert b.count() == 0 + + +def test_count_returns_int_value(mock_session): + b = input_action_button("go", "Go") + mock_session.input.__getitem__.return_value = lambda: 3 + with reactive.isolate(): + assert b.count() == 3 + mock_session.input.__getitem__.assert_called_with("go") + + +def test_update_outside_session_raises(): + b = input_action_button("go", "Go") + with pytest.raises(RuntimeError, match=r"requires an active session"): + b.update(label="New") + + +def test_update_sends_input_message(mock_session): + b = input_action_button("go", "Go") + b.update(label="New", disabled=True) + # `shiny.ui.update_action_button` runs label through session._process_ui + # which yields a MagicMock under the mock; we only assert send happened + # with the right id and the plain-bool `disabled` flag passed through. + mock_session.send_input_message.assert_called_once() + name, payload = mock_session.send_input_message.call_args.args + assert name == "go" + assert payload["disabled"] is True + assert "label" in payload # processed by shiny; exact value not pinned here From aff9b3b87917acabaaffc758d86ab9bfbd54b608 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 13 May 2026 16:09:38 -0400 Subject: [PATCH 32/45] feat(shinyui): rename UiOutputPlot.dblclick_value -> dbl_value Matches the shorter accessor name and reads the same _dblclick wire suffix. Add module-docstring note that limits_value / selection_value accessors are intentionally absent: shiny.ui.output_plot only pushes the four documented interaction signals (click, dblclick, hover, brush). If shiny gains _limits or _selection upstream, add the accessors then. --- pkg-py/src/shinyui/_output_plot.py | 24 +++++++++++++++++------- pkg-py/tests/shinyui/test_output_plot.py | 4 ++-- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/pkg-py/src/shinyui/_output_plot.py b/pkg-py/src/shinyui/_output_plot.py index 7cb73acf..f28d4783 100644 --- a/pkg-py/src/shinyui/_output_plot.py +++ b/pkg-py/src/shinyui/_output_plot.py @@ -1,13 +1,23 @@ """UiOutputPlot — output with read-only client-side interaction signals. -Derived input ids: - input._click — {x, y} | None - input._dblclick — {x, y} | None - input._hover — {x, y} | None - input._brush — {xmin, xmax, ymin, ymax, ...} | None +Derived input ids (the four that ``shiny.ui.output_plot`` actually pushes): + + =================== ============================================ + Wire id Accessor (reactive read) + =================== ============================================ + input._click :meth:`UiOutputPlot.click_value` + input._dblclick :meth:`UiOutputPlot.dbl_value` + input._hover :meth:`UiOutputPlot.hover_value` + input._brush :meth:`UiOutputPlot.brush_value` + =================== ============================================ No HasInputValue, no Updatable. Derived inputs flow through Shiny's -auto-created Value[Any] mechanism on first session.input[...] access. +auto-created Value[Any] mechanism on first ``session.input[...]`` access. + +Two interactions that shinyui does NOT expose because the shiny binding +does not push them: ``_limits`` (zoom bounds) and ``_selection`` (lasso / +selected-points). If shiny grows those signals upstream, add the matching +``limits_value`` / ``selection_value`` accessors here. """ from __future__ import annotations @@ -51,7 +61,7 @@ def click_value(self) -> Any: return self._read_input("_click") @reactive_calc_method - def dblclick_value(self) -> Any: + def dbl_value(self) -> Any: return self._read_input("_dblclick") @reactive_calc_method diff --git a/pkg-py/tests/shinyui/test_output_plot.py b/pkg-py/tests/shinyui/test_output_plot.py index bdc5c04f..56c87db4 100644 --- a/pkg-py/tests/shinyui/test_output_plot.py +++ b/pkg-py/tests/shinyui/test_output_plot.py @@ -33,13 +33,13 @@ def test_brush_value_reads_correct_id(mock_session): mock_session.input.__getitem__.assert_called_with("p_brush") -def test_hover_and_dblclick_values(mock_session): +def test_hover_and_dbl_values(mock_session): p = output_plot("p", hover=True, dblclick=True) seq = iter([{"x": 1}, {"x": 2}]) mock_session.input.__getitem__.return_value = lambda: next(seq) with reactive.isolate(): assert p.hover_value() == {"x": 1} - assert p.dblclick_value() == {"x": 2} + assert p.dbl_value() == {"x": 2} def test_no_update_method(): From 2e44f7bd9bfee2aea7c0d2a4d39ef6f8a111418a Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 13 May 2026 16:19:25 -0400 Subject: [PATCH 33/45] feat(example): rewrite example 14 as a Shiny Express app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch from def app_ui(request) / def server() to a top-level expressify script. Construct shinyui components programmatically (factory calls with children as args) since AllowsChildren is not wired into Express's RecallContextManager yet — sub-issue 3 territory. Wrap the @render decorators in 'with ui.hold():' to suppress Express's auto-placement; the renderers still register with the session by id, binding to the output_code / output_plot elements we placed inside the accordion. Without hold(), Express would inject duplicate
elements at the page tail.

Use plain shiny.ui.layout_column_wrap (imported as _sui) for the inline
button row; shiny.express.ui.layout_column_wrap is the recall-context
variant that takes 0 positional args.
---
 .../app-py/14-unified-ui-prototype/app.py     | 84 ++++++++++---------
 1 file changed, 44 insertions(+), 40 deletions(-)

diff --git a/examples/app-py/14-unified-ui-prototype/app.py b/examples/app-py/14-unified-ui-prototype/app.py
index cb43a138..6585dedc 100644
--- a/examples/app-py/14-unified-ui-prototype/app.py
+++ b/examples/app-py/14-unified-ui-prototype/app.py
@@ -1,28 +1,26 @@
-"""End-to-end demo of shinyui's class-per-component hierarchy.
+"""End-to-end demo of shinyui's class-per-component hierarchy (Shiny Express).
 
 Exercises every reference class in one page:
-  - UiInputSlider, UiInputSelect    (simple + structured inputs)
-  - UiOutputCode                    (output)
-  - UiOutputPlot                    (output with read-only signals)
-  - UiCard                          (layout with state)
-  - UiAccordion + UiAccordionPanel  (layout-with-state + layout-as-child)
-
-Components are constructed at module level. In Shiny Core, ``app_ui(request)``
-runs during the HTTP phase — before any WebSocket session exists — so a
-session-time id→instance registry would be empty when ``server()`` later runs.
-Module-level construction sidesteps that timing issue: ``app_ui`` and
-``server`` share the same instances via closure. The per-session bookmark
-serializer registry is therefore a no-op in this example; the class accessors
-(``.value()``, ``.full_screen_value()``, ``.open_panels()``, ``.click_value()``,
-``.brush_value()``) and ``.update(...)`` work because ``_require_session``
-falls back to ``get_current_session()`` at call time, which is bound while
-``server()`` runs.
+  - UiInputSlider, UiInputSelect, UiInputActionButton  (inputs)
+  - UiOutputCode                                       (output)
+  - UiOutputPlot                                       (output with read-only signals)
+  - UiCard                                             (layout with state)
+  - UiAccordion + UiAccordionPanel                     (layout + layout-as-child)
+
+This is the Express variant of the demo. In Express the script runs once per
+session — ``get_current_session()`` is bound while the module's top-level
+statements execute, so the components register themselves on the session at
+construction time. shinyui containers are still built programmatically (passing
+children to the factories) because the parent-tag stack / ``with`` integration
+is umbrella sub-issue 3, deferred from this prototype.
 """
 
 from __future__ import annotations
 
 import shinyui as su
-from shiny import App, Inputs, Outputs, Session, reactive, render, ui
+from shiny import reactive
+from shiny import ui as _sui
+from shiny.express import render, ui
 
 # --- Components -------------------------------------------------------------
 n_slider = su.input_slider("n", "Sample size", 10, 1000, 100)
@@ -46,19 +44,32 @@
     open="Settings",
 )
 main_card = su.card(
-    ui.layout_column_wrap(open_all_btn, close_all_btn, width=1 / 2),
+    # Use plain shiny.ui.layout_column_wrap here, not shiny.express.ui's
+    # recall-context-managed version (the express one takes 0 positional args).
+    _sui.layout_column_wrap(open_all_btn, close_all_btn, width=1 / 2),
     acc,
     plot_handle,
     id="main_card",
     full_screen=False,
 )
 
+# --- Page ------------------------------------------------------------------
+ui.page_opts(title="shinyui Stage A prototype")
 
-def app_ui(request):
-    return ui.page_fluid(main_card, title="shinyui Stage A prototype")
+# Top-level expression: Express's `@expressify`-driven runtime appends the
+# value to the current page container. shinyui factories are NOT Express-aware,
+# so we hand the constructed instance to Express via this expression statement.
+main_card
 
 
-def server(input: Inputs, output: Outputs, session: Session):
+# --- Renderers -------------------------------------------------------------
+# `ui.hold()` suppresses Express's default auto-placement so each renderer
+# binds to its id-matching output element that we placed inside the
+# accordion / card above. Without `hold()`, Express would insert a SECOND
+# `
` at the page tail, duplicating the id and breaking
+# the in-place output binding.
+with ui.hold():
+
     @render.code
     def summary():
         # Reads via class accessors — no `input.n()` / `input.dist()` needed.
@@ -80,9 +91,6 @@ def diag():
     @render.plot
     def plot():
         # Real scatter plot driven by the slider/select/seed inputs.
-        # Demonstrates the read-accessor chain ending in @render.plot:
-        # all three reads establish reactive deps so the plot recomputes
-        # on input changes.
         import matplotlib.pyplot as plt
         import numpy as np
 
@@ -103,21 +111,17 @@ def plot():
         ax.grid(True, alpha=0.3)
         return fig
 
-    # Server-driven .update() on the layout-with-state accordion.
-    # Each button instance exposes a typed `.count()` reactive accessor
-    # (click counter); @reactive.event fires the effect once per click,
-    # not on every other input change.
-    @reactive.effect
-    @reactive.event(open_all_btn.count, ignore_init=True)
-    def _open_all_panels():
-        acc.update(open=("Settings", "Diagnostics"))
 
-    @reactive.effect
-    @reactive.event(close_all_btn.count, ignore_init=True)
-    def _close_all_panels():
-        # update_accordion's `show=` takes a panel value list, OR True/False.
-        # False closes all panels in the set.
-        acc.update(open=False)
+# --- Reactive effects ------------------------------------------------------
+@reactive.effect
+@reactive.event(open_all_btn.count, ignore_init=True)
+def _open_all_panels():
+    acc.update(open=("Settings", "Diagnostics"))
 
 
-app = App(app_ui, server)
+@reactive.effect
+@reactive.event(close_all_btn.count, ignore_init=True)
+def _close_all_panels():
+    # update_accordion's `show=` takes a panel value list, OR True/False.
+    # False closes all panels in the set.
+    acc.update(open=False)

From be45947760765551bb007fadeb06040897545288 Mon Sep 17 00:00:00 2001
From: Barret Schloerke 
Date: Wed, 13 May 2026 16:26:58 -0400
Subject: [PATCH 34/45] feat(shinyui): rename UiInputActionButton.count ->
 clicked

Reads more naturally at the call site ("btn.clicked() > 0" vs the
slightly ambiguous "btn.count() > 0"). Wire suffix unchanged
(reads input. directly). Tests and example 14 updated.
---
 examples/app-py/14-unified-ui-prototype/app.py   | 4 ++--
 pkg-py/src/shinyui/_input_action_button.py       | 6 +++---
 pkg-py/tests/shinyui/test_input_action_button.py | 8 ++++----
 3 files changed, 9 insertions(+), 9 deletions(-)

diff --git a/examples/app-py/14-unified-ui-prototype/app.py b/examples/app-py/14-unified-ui-prototype/app.py
index 6585dedc..35787fd3 100644
--- a/examples/app-py/14-unified-ui-prototype/app.py
+++ b/examples/app-py/14-unified-ui-prototype/app.py
@@ -114,13 +114,13 @@ def plot():
 
 # --- Reactive effects ------------------------------------------------------
 @reactive.effect
-@reactive.event(open_all_btn.count, ignore_init=True)
+@reactive.event(open_all_btn.clicked, ignore_init=True)
 def _open_all_panels():
     acc.update(open=("Settings", "Diagnostics"))
 
 
 @reactive.effect
-@reactive.event(close_all_btn.count, ignore_init=True)
+@reactive.event(close_all_btn.clicked, ignore_init=True)
 def _close_all_panels():
     # update_accordion's `show=` takes a panel value list, OR True/False.
     # False closes all panels in the set.
diff --git a/pkg-py/src/shinyui/_input_action_button.py b/pkg-py/src/shinyui/_input_action_button.py
index a5e1e38d..7f0beacd 100644
--- a/pkg-py/src/shinyui/_input_action_button.py
+++ b/pkg-py/src/shinyui/_input_action_button.py
@@ -17,8 +17,8 @@ class UiInputActionButton(UiInput, Updatable):
     """Server-readable button.
 
     ``input.()`` is an integer counter that starts at 0 and increments on
-    each click. The class accessor :meth:`count` returns the current value as a
-    reactive read; pair with :func:`shiny.reactive.event` to run code on each
+    each click. The class accessor :meth:`clicked` returns the current value as
+    a reactive read; pair with :func:`shiny.reactive.event` to run code on each
     click without firing on the initial value.
     """
 
@@ -38,7 +38,7 @@ def __init__(
         super().__init__(id=id)
 
     @reactive_calc_method
-    def count(self) -> int:
+    def clicked(self) -> int:
         """Click counter; 0 before the first click, +1 per click."""
         return int(self._read_input() or 0)
 
diff --git a/pkg-py/tests/shinyui/test_input_action_button.py b/pkg-py/tests/shinyui/test_input_action_button.py
index 3705c759..ed046f45 100644
--- a/pkg-py/tests/shinyui/test_input_action_button.py
+++ b/pkg-py/tests/shinyui/test_input_action_button.py
@@ -18,18 +18,18 @@ def test_tagify_matches_shiny_ui_input_action_button():
     assert ours.get_html_string() == theirs.get_html_string()
 
 
-def test_count_zero_when_input_is_none(mock_session):
+def test_clicked_zero_when_input_is_none(mock_session):
     b = input_action_button("go", "Go")
     mock_session.input.__getitem__.return_value = lambda: None
     with reactive.isolate():
-        assert b.count() == 0
+        assert b.clicked() == 0
 
 
-def test_count_returns_int_value(mock_session):
+def test_clicked_returns_int_value(mock_session):
     b = input_action_button("go", "Go")
     mock_session.input.__getitem__.return_value = lambda: 3
     with reactive.isolate():
-        assert b.count() == 3
+        assert b.clicked() == 3
     mock_session.input.__getitem__.assert_called_with("go")
 
 

From abceccff07b53f92956b182253e5e6d73e297c68 Mon Sep 17 00:00:00 2001
From: Barret Schloerke 
Date: Wed, 13 May 2026 16:30:26 -0400
Subject: [PATCH 35/45] feat(shinyui): demo __init_subclass__ handler
 registration on UiInputActionButton
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Most shinyui classes register input handlers via an explicit
cls._register_input_handler() call at module load. The action button now
demonstrates the alternative: a small _InputHandlerAutoRegister mixin
whose __init_subclass__ hook auto-fires registration when the class is
defined.

Concretely:
- Add input_handler_name = 'shinyui.action' and a small _input_handler
  staticmethod that coerces wire value -> int (parallel of py-shiny's
  'shiny.action' handler).
- Register under 'shinyui.action' (not 'shiny.action') so we don't
  collide with shiny's built-in; the demo's purpose is the registration
  mechanism, not real wire traffic — shiny's markup still routes
  action-button events through its own 'shiny.action' handler.
- Pin behavior with two new tests: registry contains 'shinyui.action'
  after import, and _input_handler returns plain ints. Update the
  cross-cutting test_input_handler_registration.py to reflect the
  exception.
---
 pkg-py/src/shinyui/_input_action_button.py    | 61 ++++++++++++++++++-
 .../tests/shinyui/test_input_action_button.py | 17 ++++++
 .../test_input_handler_registration.py        | 16 +++--
 3 files changed, 88 insertions(+), 6 deletions(-)

diff --git a/pkg-py/src/shinyui/_input_action_button.py b/pkg-py/src/shinyui/_input_action_button.py
index 7f0beacd..29ed17c6 100644
--- a/pkg-py/src/shinyui/_input_action_button.py
+++ b/pkg-py/src/shinyui/_input_action_button.py
@@ -1,4 +1,21 @@
-"""UiInputActionButton — class-based input_action_button with a count accessor."""
+"""UiInputActionButton — class-based input_action_button with a clicked accessor.
+
+Also serves as the prototype's one **`__init_subclass__` demo**. Most shinyui
+classes register their input handler via an explicit
+``cls._register_input_handler()`` call at module load. This file uses the
+alternative approach: a small ``_InputHandlerAutoRegister`` mixin whose
+``__init_subclass__`` hook calls ``_register_input_handler`` automatically when
+a subclass is defined — exactly the magic the umbrella spec normally avoids,
+included here so the trade-off can be evaluated against the explicit pattern
+side-by-side.
+
+Note: the wire ``type`` attribute on shiny's action-button markup is
+``"shiny.action"``, so the *registered-for-real-wire-traffic* handler is the
+one in ``shiny.input_handler``. Our handler is registered under
+``"shinyui.action"`` and serves the demo purpose only — proves the
+``__init_subclass__`` mechanism does fire, without colliding with shiny's
+built-in.
+"""
 
 from __future__ import annotations
 
@@ -6,6 +23,7 @@
 
 from htmltools import Tag, TagChild
 
+from ._input_value import HasInputValue
 from ._reactive import reactive_calc_method
 from ._roles import UiInput
 from ._updatable import Updatable
@@ -13,15 +31,54 @@
 _MISSING = object()
 
 
-class UiInputActionButton(UiInput, Updatable):
+class _InputHandlerAutoRegister:
+    """Mixin: subclasses fire ``_register_input_handler()`` at class-def time.
+
+    Alternative to the default explicit ``cls._register_input_handler()`` line
+    used elsewhere in shinyui. Lives here as a single-class demo of the
+    ``__init_subclass__`` pattern — see the module docstring above for the
+    trade-off discussion.
+    """
+
+    def __init_subclass__(cls, **kw: Any) -> None:
+        super().__init_subclass__(**kw)
+        # `_register_input_handler` is a no-op when input_handler_name is empty
+        # or _input_handler is None, so this is safe for any subclass — only
+        # subclasses that declare a handler actually register. The
+        # HasInputValue check both narrows the type for pyright and protects
+        # against accidental use outside the input hierarchy.
+        if issubclass(cls, HasInputValue):  # type: ignore[arg-type]
+            cls._register_input_handler()  # type: ignore[attr-defined]
+
+
+class UiInputActionButton(UiInput, Updatable, _InputHandlerAutoRegister):
     """Server-readable button.
 
     ``input.()`` is an integer counter that starts at 0 and increments on
     each click. The class accessor :meth:`clicked` returns the current value as
     a reactive read; pair with :func:`shiny.reactive.event` to run code on each
     click without firing on the initial value.
+
+    Demo of ``__init_subclass__`` registration — declaring the class
+    auto-fires ``_register_input_handler()`` via the
+    ``_InputHandlerAutoRegister`` parent. The handler below is registered
+    under ``"shinyui.action"`` (NOT ``"shiny.action"``) to avoid colliding
+    with shiny's own action-button handler; shiny's wire markup still
+    routes through that one.
     """
 
+    # Auto-registered via _InputHandlerAutoRegister.__init_subclass__ below.
+    input_handler_name = "shinyui.action"
+
+    @staticmethod
+    def _input_handler(value: Any, name: Any, session: Any) -> int:
+        """Coerce wire value to a plain int.
+
+        (shiny's built-in handler returns an ActionButtonValue; we keep it
+        simpler here as the demo doesn't actually receive wire traffic.)
+        """
+        return int(value or 0)
+
     def __init__(
         self,
         id: str,
diff --git a/pkg-py/tests/shinyui/test_input_action_button.py b/pkg-py/tests/shinyui/test_input_action_button.py
index ed046f45..232e7897 100644
--- a/pkg-py/tests/shinyui/test_input_action_button.py
+++ b/pkg-py/tests/shinyui/test_input_action_button.py
@@ -3,6 +3,7 @@
 import pytest
 import shiny.ui as sui
 from shiny import reactive
+from shiny.input_handler import input_handlers
 from shinyui._input_action_button import UiInputActionButton, input_action_button
 
 
@@ -39,6 +40,22 @@ def test_update_outside_session_raises():
         b.update(label="New")
 
 
+def test_input_handler_auto_registered_via_init_subclass():
+    """The class is registered under 'shinyui.action' at class-definition
+    time via the _InputHandlerAutoRegister mixin's __init_subclass__ hook.
+    """
+    assert UiInputActionButton.input_handler_name == "shinyui.action"
+    # `input_handlers` is dict-like.
+    assert "shinyui.action" in input_handlers
+
+
+def test_input_handler_coerces_to_int():
+    h = UiInputActionButton._input_handler
+    assert h(None, None, None) == 0
+    assert h(3, None, None) == 3
+    assert h("5", None, None) == 5
+
+
 def test_update_sends_input_message(mock_session):
     b = input_action_button("go", "Go")
     b.update(label="New", disabled=True)
diff --git a/pkg-py/tests/shinyui/test_input_handler_registration.py b/pkg-py/tests/shinyui/test_input_handler_registration.py
index 316ed72f..2e49289e 100644
--- a/pkg-py/tests/shinyui/test_input_handler_registration.py
+++ b/pkg-py/tests/shinyui/test_input_handler_registration.py
@@ -1,8 +1,10 @@
 """Pin the prototype's handler-registration state.
 
-UPDATED FROM PLAN: UiAccordion does NOT register a custom input handler in this
-prototype — shiny's accordion binding sends a plain JSON list that doesn't need
-server-side wire coercion. open_panels() coerces list->tuple at read time.
+Most shinyui classes do NOT declare a custom input handler — shiny's
+built-in bindings handle the wire format. The one exception is
+UiInputActionButton, which carries a "shinyui.action" handler purely
+to demonstrate the __init_subclass__ auto-registration pattern (see
+the module docstring on _input_action_button.py for the trade-off).
 """
 
 from __future__ import annotations
@@ -21,6 +23,12 @@
     ],
 )
 def test_class_declares_no_custom_handler(cls):
-    """All prototype classes use shiny's default wire handling for input values."""
+    """These classes use shiny's default wire handling for input values."""
     assert cls.input_handler_name == ""
     assert cls._input_handler is None
+
+
+def test_action_button_declares_custom_handler():
+    """UiInputActionButton ships a 'shinyui.action' handler via __init_subclass__."""
+    assert sui.UiInputActionButton.input_handler_name == "shinyui.action"
+    assert sui.UiInputActionButton._input_handler is not None

From c53821126bc84142aecf5dc925b9ae258e260873 Mon Sep 17 00:00:00 2001
From: Barret Schloerke 
Date: Wed, 13 May 2026 16:35:53 -0400
Subject: [PATCH 36/45] refactor(shinyui): drop _deep_tagify; rely on .tagify()
 chain resolution

htmltools' Tag.tagify() iterates Tagifiable->Tagifiable chains inside its
single-level TagList walk, so calling .tagify() once on the outer Tag is
enough to fully resolve our Tagifiable descendants. The custom recursive
_deep_tagify helper I'd added is unnecessary.

UiAccordion still pre-resolves its children via [c.tagify() for c in ...]
because shiny.ui.accordion does an explicit isinstance(panel, AccordionPanel)
check on positional args and rejects UiAccordionPanel (which is Tagifiable
but not an AccordionPanel subclass). UiCard has no such isinstance check,
so it hands its children in unchanged and lets the outer .tagify() resolve
them.

Both files document the rule inline; the module-docstring of _base.py adds
a one-line guidance pointer for future container subclasses.
---
 pkg-py/src/shinyui/_accordion.py | 29 +++++++++++++------------
 pkg-py/src/shinyui/_base.py      | 36 +++++---------------------------
 pkg-py/src/shinyui/_card.py      |  7 ++++++-
 3 files changed, 25 insertions(+), 47 deletions(-)

diff --git a/pkg-py/src/shinyui/_accordion.py b/pkg-py/src/shinyui/_accordion.py
index a366c218..a7b6ec65 100644
--- a/pkg-py/src/shinyui/_accordion.py
+++ b/pkg-py/src/shinyui/_accordion.py
@@ -53,22 +53,21 @@ def open_panels(self) -> tuple[str, ...]:
     def tagify(self) -> Tag:
         import shiny.ui as _sui
 
-        # Each child's tagify() returns an AccordionPanel that shiny.ui.accordion
-        # accepts directly. Deep-resolve the result so any Tagifiable descendants
-        # within the panels' content (e.g. an input_slider inside a panel) are
-        # fully expanded before htmltools renders.
+        # `shiny.ui.accordion` does an explicit isinstance(panel, AccordionPanel)
+        # check, so children must be pre-resolved (UiAccordionPanel.tagify()
+        # returns an AccordionPanel). A single .tagify() on the result lets
+        # htmltools' walker resolve any remaining Tagifiable descendants
+        # inside the panels (e.g. an input_slider inside a panel).
         panels: list = [child.tagify() for child in self.children]  # type: ignore[union-attr]
-        return self._deep_tagify(
-            _sui.accordion(
-                *panels,
-                id=self.id,
-                open=self._open,
-                multiple=self.multiple,
-                class_=self.class_,
-                width=self.width,
-                height=self.height,
-            )
-        )
+        return _sui.accordion(
+            *panels,
+            id=self.id,
+            open=self._open,
+            multiple=self.multiple,
+            class_=self.class_,
+            width=self.width,
+            height=self.height,
+        ).tagify()
 
     def update(
         self,
diff --git a/pkg-py/src/shinyui/_base.py b/pkg-py/src/shinyui/_base.py
index 1007364b..1caa3595 100644
--- a/pkg-py/src/shinyui/_base.py
+++ b/pkg-py/src/shinyui/_base.py
@@ -5,21 +5,20 @@
   - `_require_session(for_op=...)`: resolves a session at call time, with a fallback
     to the current session, raising RuntimeError if none is reachable.
   - `_read_input(suffix="")`: reads `session.input[f"{self.id}{suffix}"]()`.
-  - `_deep_tagify(node)`: recursively resolves a node tree to fully-rendered
-    Tag/TagList output. Required because htmltools' built-in Tag.tagify only
-    walks one level deep; containers whose children are themselves Tagifiable
-    must pre-resolve their subtrees.
 
 `tagify()` is abstract. `__enter__` raises by default; `AllowsChildren` overrides.
+
+Container subclasses should end their `tagify()` with `.tagify()` on the result —
+htmltools' walker iterates Tagifiable→Tagifiable chains during that single call,
+so calling it once on the outer tag fully resolves our Tagifiable descendants.
 """
 
 from __future__ import annotations
 
 from abc import ABC, abstractmethod
-from copy import copy
 from typing import Any, ClassVar
 
-from htmltools import HTMLDependency, Tag, Tagifiable, TagList
+from htmltools import HTMLDependency, Tag
 from shiny.session import Session, get_current_session
 from typing_extensions import Self
 
@@ -49,31 +48,6 @@ def _read_input(self, suffix: str = "") -> Any:
         sess = self._require_session(for_op="_read_input")
         return sess.input[f"{self.id}{suffix}"]()  # type: ignore[attr-defined]
 
-    @staticmethod
-    def _deep_tagify(node: Any) -> Any:
-        """Recursively resolve any Tagifiable descendants of `node`.
-
-        htmltools' built-in `Tag.tagify` only walks one level: it replaces
-        Tagifiable direct children, but doesn't recurse into the resulting
-        Tag's own children. When our container classes wrap children that
-        are themselves Tagifiable, we must pre-resolve the subtree before
-        returning from `tagify()` — otherwise htmltools' renderer hits a
-        "non-tagified object" RuntimeError at HTML generation time.
-        """
-        # Resolve a Tagifiable (non-Tag) by calling its tagify().
-        if isinstance(node, Tagifiable) and not isinstance(node, Tag):
-            node = node.tagify()
-        # Recurse into Tag children.
-        if isinstance(node, Tag):
-            cp = copy(node)
-            cp.children = TagList(*(UiComponent._deep_tagify(c) for c in node.children))
-            return cp
-        # Recurse into TagList items.
-        if isinstance(node, TagList):
-            return TagList(*(UiComponent._deep_tagify(c) for c in node))
-        # Leaves (strings, MetadataNode, HTML, None, etc.) pass through.
-        return node
-
     @abstractmethod
     def tagify(self) -> Tag: ...
 
diff --git a/pkg-py/src/shinyui/_card.py b/pkg-py/src/shinyui/_card.py
index d930426e..79aa6eeb 100644
--- a/pkg-py/src/shinyui/_card.py
+++ b/pkg-py/src/shinyui/_card.py
@@ -80,7 +80,12 @@ def tagify(self) -> Tag:
         if self.class_ is not None:
             kwargs["class_"] = self.class_
 
-        return self._deep_tagify(_sui.card(*self.children, **kwargs))
+        # `shiny.ui.card` accepts arbitrary TagChild members — including our
+        # Tagifiable UiAccordion / UiInputSlider / etc. — so we hand them in
+        # unchanged. A single .tagify() on the result lets htmltools' walker
+        # resolve our Tagifiable descendants chain-style. (Card has no
+        # isinstance check on children, unlike accordion's AccordionPanel.)
+        return _sui.card(*self.children, **kwargs).tagify()
 
     def update(
         self,

From 5b4bd4feed96edc46d2140bdde9b5db7b42201f9 Mon Sep 17 00:00:00 2001
From: Barret Schloerke 
Date: Wed, 13 May 2026 16:37:26 -0400
Subject: [PATCH 37/45] refactor(shinyui): move __init_subclass__ auto-register
 onto HasInputValue
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The standalone _InputHandlerAutoRegister mixin duplicated logic that
HasInputValue already owns (input_handler_name, _input_handler,
_register_input_handler classmethod). Move the __init_subclass__ hook
onto HasInputValue itself — now every HasInputValue subclass auto-fires
registration on class-definition. Classes with the defaults
(input_handler_name='' or _input_handler is None) skip silently, so
this is no-op for slider/select/card/accordion and active only for
UiInputActionButton.

Drops the extra base from UiInputActionButton's MRO and removes ~20
lines of plumbing. The action-button module docstring still explains
the demo and the 'shinyui.action' vs 'shiny.action' separation.
---
 pkg-py/src/shinyui/_input_action_button.py | 58 ++++++----------------
 pkg-py/src/shinyui/_input_value.py         | 15 +++++-
 2 files changed, 27 insertions(+), 46 deletions(-)

diff --git a/pkg-py/src/shinyui/_input_action_button.py b/pkg-py/src/shinyui/_input_action_button.py
index 29ed17c6..f8ce99be 100644
--- a/pkg-py/src/shinyui/_input_action_button.py
+++ b/pkg-py/src/shinyui/_input_action_button.py
@@ -1,20 +1,17 @@
 """UiInputActionButton — class-based input_action_button with a clicked accessor.
 
-Also serves as the prototype's one **`__init_subclass__` demo**. Most shinyui
-classes register their input handler via an explicit
-``cls._register_input_handler()`` call at module load. This file uses the
-alternative approach: a small ``_InputHandlerAutoRegister`` mixin whose
-``__init_subclass__`` hook calls ``_register_input_handler`` automatically when
-a subclass is defined — exactly the magic the umbrella spec normally avoids,
-included here so the trade-off can be evaluated against the explicit pattern
-side-by-side.
+Demonstrates the ``__init_subclass__`` registration pattern: by declaring
+``input_handler_name`` and ``_input_handler`` on the class, the handler is
+auto-registered with Shiny's ``input_handlers`` registry when the class is
+defined. The hook lives on :class:`shinyui.HasInputValue` so every
+``UiInput`` subclass benefits from it — most classes leave the defaults and
+the registration is a no-op for them.
 
 Note: the wire ``type`` attribute on shiny's action-button markup is
-``"shiny.action"``, so the *registered-for-real-wire-traffic* handler is the
-one in ``shiny.input_handler``. Our handler is registered under
-``"shinyui.action"`` and serves the demo purpose only — proves the
-``__init_subclass__`` mechanism does fire, without colliding with shiny's
-built-in.
+``"shiny.action"``, so real wire traffic is processed by the handler in
+``shiny.input_handler``. Our handler is registered under ``"shinyui.action"``
+and serves the demo purpose only — proves ``__init_subclass__`` does fire,
+without colliding with shiny's built-in.
 """
 
 from __future__ import annotations
@@ -23,7 +20,6 @@
 
 from htmltools import Tag, TagChild
 
-from ._input_value import HasInputValue
 from ._reactive import reactive_calc_method
 from ._roles import UiInput
 from ._updatable import Updatable
@@ -31,43 +27,17 @@
 _MISSING = object()
 
 
-class _InputHandlerAutoRegister:
-    """Mixin: subclasses fire ``_register_input_handler()`` at class-def time.
-
-    Alternative to the default explicit ``cls._register_input_handler()`` line
-    used elsewhere in shinyui. Lives here as a single-class demo of the
-    ``__init_subclass__`` pattern — see the module docstring above for the
-    trade-off discussion.
-    """
-
-    def __init_subclass__(cls, **kw: Any) -> None:
-        super().__init_subclass__(**kw)
-        # `_register_input_handler` is a no-op when input_handler_name is empty
-        # or _input_handler is None, so this is safe for any subclass — only
-        # subclasses that declare a handler actually register. The
-        # HasInputValue check both narrows the type for pyright and protects
-        # against accidental use outside the input hierarchy.
-        if issubclass(cls, HasInputValue):  # type: ignore[arg-type]
-            cls._register_input_handler()  # type: ignore[attr-defined]
-
-
-class UiInputActionButton(UiInput, Updatable, _InputHandlerAutoRegister):
+class UiInputActionButton(UiInput, Updatable):
     """Server-readable button.
 
     ``input.()`` is an integer counter that starts at 0 and increments on
     each click. The class accessor :meth:`clicked` returns the current value as
     a reactive read; pair with :func:`shiny.reactive.event` to run code on each
     click without firing on the initial value.
-
-    Demo of ``__init_subclass__`` registration — declaring the class
-    auto-fires ``_register_input_handler()`` via the
-    ``_InputHandlerAutoRegister`` parent. The handler below is registered
-    under ``"shinyui.action"`` (NOT ``"shiny.action"``) to avoid colliding
-    with shiny's own action-button handler; shiny's wire markup still
-    routes through that one.
     """
 
-    # Auto-registered via _InputHandlerAutoRegister.__init_subclass__ below.
+    # Auto-registered via HasInputValue.__init_subclass__ when this class
+    # body finishes executing. See the module docstring for the trade-off.
     input_handler_name = "shinyui.action"
 
     @staticmethod
@@ -75,7 +45,7 @@ def _input_handler(value: Any, name: Any, session: Any) -> int:
         """Coerce wire value to a plain int.
 
         (shiny's built-in handler returns an ActionButtonValue; we keep it
-        simpler here as the demo doesn't actually receive wire traffic.)
+        simpler here since the demo doesn't actually receive wire traffic.)
         """
         return int(value or 0)
 
diff --git a/pkg-py/src/shinyui/_input_value.py b/pkg-py/src/shinyui/_input_value.py
index d2ad0b2c..33b3f21f 100644
--- a/pkg-py/src/shinyui/_input_value.py
+++ b/pkg-py/src/shinyui/_input_value.py
@@ -4,7 +4,11 @@
   - `id: str` (stored on instance)
   - `input_handler_name` and `_input_handler` ClassVars (default to empty / None)
   - `bookmark_serializer` ClassVar default + per-instance override
-  - `_register_input_handler()` classmethod for explicit module-load registration
+  - `_register_input_handler()` classmethod, auto-fired on subclass creation
+    via ``__init_subclass__``. Subclasses that declare a non-empty
+    ``input_handler_name`` plus an ``_input_handler`` get registered with
+    Shiny's ``input_handlers`` registry at class-definition time. Classes
+    with the default `None` handler are no-op.
   - id->instance registration on construction (no-op if no session)
 
 Mixin protocol: subclasses MUST call `super().__init__(id=..., **kw)` first.
@@ -31,10 +35,17 @@ class HasInputValue:
 
     @classmethod
     def _register_input_handler(cls) -> None:
-        """Idempotent. Call once at module load if this class declares a handler."""
+        """Idempotent: registers cls's input handler if both fields are set."""
         if cls.input_handler_name and cls._input_handler is not None:
             register_input_handler(cls.input_handler_name, cls._input_handler)
 
+    def __init_subclass__(cls, **kwargs: Any) -> None:
+        super().__init_subclass__(**kwargs)
+        # Fires automatically whenever any HasInputValue subclass is defined.
+        # No-op for classes that leave the defaults (input_handler_name == ""
+        # and _input_handler is None).
+        cls._register_input_handler()
+
     def __init__(
         self,
         *args: Any,

From 27a616d62a64ec1024df77da78d42f020dad9e2d Mon Sep 17 00:00:00 2001
From: Barret Schloerke 
Date: Wed, 13 May 2026 16:47:03 -0400
Subject: [PATCH 38/45] refactor(shinyui): rename concrete classes to
 snake_case
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Match shiny.render.* convention (e.g. shiny.render.data_frame) by
naming concrete user-facing classes in snake_case. Drops the parallel
factory functions since the class name now equals the call-site name —
no more double record keeping.

Bases and mixins (UiComponent, UiInput, UiOutput, UiLayout,
HasInputValue, Updatable, AllowsChildren) stay PascalCase, mirroring
shiny.render.Renderer.

Class rename map:
  UiInputSlider       -> input_slider
  UiInputSelect       -> input_select
  UiInputActionButton -> input_action_button
  UiOutputCode        -> output_code
  UiOutputPlot        -> output_plot
  UiCard              -> card
  UiAccordion         -> accordion
  UiAccordionPanel    -> accordion_panel

Docs (spec + plan) updated to reflect the new names; the older
2026-05-06 umbrella spec is left alone since it describes the original
design vision.
---
 ...26-05-13-shinyui-metadata-consolidation.md | 258 +++++++++---------
 ...3-shinyui-metadata-consolidation-design.md |  48 ++--
 .../app-py/14-unified-ui-prototype/README.md  |  18 +-
 .../app-py/14-unified-ui-prototype/app.py     |  10 +-
 pkg-py/src/shinyui/__init__.py                |  24 +-
 pkg-py/src/shinyui/_accordion.py              |  27 +-
 pkg-py/src/shinyui/_accordion_panel.py        |  10 +-
 pkg-py/src/shinyui/_card.py                   |  23 +-
 pkg-py/src/shinyui/_input_action_button.py    |  12 +-
 pkg-py/src/shinyui/_input_select.py           |  13 +-
 pkg-py/src/shinyui/_input_slider.py           |  15 +-
 pkg-py/src/shinyui/_output_code.py            |   8 +-
 pkg-py/src/shinyui/_output_plot.py            |  16 +-
 pkg-py/tests/shinyui/test_accordion.py        |   4 +-
 pkg-py/tests/shinyui/test_accordion_panel.py  |   4 +-
 pkg-py/tests/shinyui/test_card.py             |   4 +-
 pkg-py/tests/shinyui/test_hierarchy.py        |  56 ++--
 .../tests/shinyui/test_input_action_button.py |   8 +-
 .../test_input_handler_registration.py        |  16 +-
 pkg-py/tests/shinyui/test_input_select.py     |   4 +-
 pkg-py/tests/shinyui/test_input_slider.py     |   6 +-
 pkg-py/tests/shinyui/test_output_code.py      |   4 +-
 pkg-py/tests/shinyui/test_output_plot.py      |   4 +-
 pkg-py/tests/shinyui/test_public_exports.py   |  22 +-
 24 files changed, 264 insertions(+), 350 deletions(-)

diff --git a/docs/superpowers/plans/2026-05-13-shinyui-metadata-consolidation.md b/docs/superpowers/plans/2026-05-13-shinyui-metadata-consolidation.md
index ed6dab2a..d7091a01 100644
--- a/docs/superpowers/plans/2026-05-13-shinyui-metadata-consolidation.md
+++ b/docs/superpowers/plans/2026-05-13-shinyui-metadata-consolidation.md
@@ -25,13 +25,13 @@ pkg-py/src/shinyui/
   _updatable.py                      # CREATE: Updatable mixin (ABC)
   _roles.py                          # CREATE: UiInput, UiOutput, UiLayout
   _reactive.py                       # CREATE: reactive_calc_method (~15-line decorator)
-  _input_slider.py                   # CREATE: UiInputSlider + input_slider()
-  _input_select.py                   # CREATE: UiInputSelect + input_select()
-  _output_code.py                    # CREATE: UiOutputCode + output_code()
-  _output_plot.py                    # CREATE: UiOutputPlot + output_plot()
-  _accordion_panel.py                # CREATE: UiAccordionPanel + accordion_panel()
-  _accordion.py                      # CREATE: UiAccordion + accordion()
-  _card.py                           # CREATE: UiCard + card()
+  _input_slider.py                   # CREATE: input_slider + input_slider()
+  _input_select.py                   # CREATE: input_select + input_select()
+  _output_code.py                    # CREATE: output_code + output_code()
+  _output_plot.py                    # CREATE: output_plot + output_plot()
+  _accordion_panel.py                # CREATE: accordion_panel + accordion_panel()
+  _accordion.py                      # CREATE: accordion + accordion()
+  _card.py                           # CREATE: card + card()
   _bookmark.py                       # CREATE: id→instance session map + class-owned serializer hook
 
 pkg-py/tests/shinyui/
@@ -995,7 +995,7 @@ git commit -m "feat(shinyui): local reactive_calc_method helper"
 
 ---
 
-## Task 9: `UiInputSlider`
+## Task 9: `input_slider`
 
 **Files:**
 - Create: `pkg-py/src/shinyui/_input_slider.py`
@@ -1016,12 +1016,12 @@ import pytest
 import shiny.ui as sui
 from htmltools import Tag
 
-from shinyui._input_slider import UiInputSlider, input_slider
+from shinyui._input_slider import input_slider, input_slider
 
 
 def test_factory_returns_instance():
     s = input_slider("n", "N", 1, 100, 50)
-    assert isinstance(s, UiInputSlider)
+    assert isinstance(s, input_slider)
     assert s.id == "n"
 
 
@@ -1042,7 +1042,7 @@ def test_value_accessor_reads_input(mock_session):
 
 def test_update_outside_session_raises():
     s = input_slider("n", "N", 1, 100, 50)  # no session at construction
-    with pytest.raises(RuntimeError, match=r"UiInputSlider\.update\(\) requires an active session"):
+    with pytest.raises(RuntimeError, match=r"input_slider\.update\(\) requires an active session"):
         s.update(value=42)
 
 
@@ -1060,7 +1060,7 @@ def test_update_uses_captured_session(mock_session):
 Run: `uv run pytest pkg-py/tests/shinyui/test_input_slider.py -v`
 Expected: All fail with `ModuleNotFoundError`.
 
-- [ ] **Step 3: Implement UiInputSlider**
+- [ ] **Step 3: Implement input_slider**
 
 Read `shiny/ui/_input_slider.py` (run: `uv run python -c "import shiny.ui._input_slider as m; print(m.__file__)"`) to find the markup-construction logic. Copy the Tag-construction body into `tagify()` below, mapping each function argument to `self.`.
 
@@ -1069,7 +1069,7 @@ Read `shiny/ui/_input_update.py` (the `update_slider` function) to find the `sen
 Create `pkg-py/src/shinyui/_input_slider.py`:
 
 ```python
-"""UiInputSlider — class-based input_slider with typed update() and value() accessor."""
+"""input_slider — class-based input_slider with typed update() and value() accessor."""
 from __future__ import annotations
 
 from typing import Any
@@ -1083,7 +1083,7 @@ from ._updatable import Updatable
 _MISSING = object()
 
 
-class UiInputSlider(UiInput, Updatable):
+class input_slider(UiInput, Updatable):
     def __init__(
         self,
         id: str,
@@ -1164,8 +1164,8 @@ def input_slider(
     max: float,
     value: float | tuple[float, float],
     **kwargs: Any,
-) -> UiInputSlider:
-    return UiInputSlider(id, label, min, max, value, **kwargs)
+) -> input_slider:
+    return input_slider(id, label, min, max, value, **kwargs)
 ```
 
 **Implementer note:** the `tagify()` body above delegates to `shiny.ui.input_slider` as a temporary measure so the snapshot test passes immediately. Replace with a copy-pasted construction body once the test is green (this keeps the prototype dep-free per spec). The snapshot test is the regression net — when you swap the body, re-run the test to confirm equivalence.
@@ -1177,7 +1177,7 @@ Expected: All pass.
 
 - [ ] **Step 5: Inline the tagify markup**
 
-Read `shiny/ui/_input_slider.py` and copy the Tag construction body into `UiInputSlider.tagify()`, replacing the delegation. Re-run the snapshot test:
+Read `shiny/ui/_input_slider.py` and copy the Tag construction body into `input_slider.tagify()`, replacing the delegation. Re-run the snapshot test:
 
 Run: `uv run pytest pkg-py/tests/shinyui/test_input_slider.py::test_tagify_matches_shiny_ui_input_slider -v`
 Expected: PASS.
@@ -1186,12 +1186,12 @@ Expected: PASS.
 
 ```bash
 git add pkg-py/src/shinyui/_input_slider.py pkg-py/tests/shinyui/test_input_slider.py
-git commit -m "feat(shinyui): UiInputSlider + input_slider() factory"
+git commit -m "feat(shinyui): input_slider + input_slider() factory"
 ```
 
 ---
 
-## Task 10: `UiInputSelect`
+## Task 10: `input_select`
 
 **Files:**
 - Create: `pkg-py/src/shinyui/_input_select.py`
@@ -1209,12 +1209,12 @@ from __future__ import annotations
 import pytest
 import shiny.ui as sui
 
-from shinyui._input_select import UiInputSelect, input_select
+from shinyui._input_select import input_select, input_select
 
 
 def test_factory_returns_instance():
     s = input_select("col", "Column", {"a": "A", "b": "B"})
-    assert isinstance(s, UiInputSelect)
+    assert isinstance(s, input_select)
 
 
 def test_tagify_matches_shiny_ui_input_select():
@@ -1251,12 +1251,12 @@ def test_update_sends_message(mock_session):
 Run: `uv run pytest pkg-py/tests/shinyui/test_input_select.py -v`
 Expected: All fail with `ModuleNotFoundError`.
 
-- [ ] **Step 3: Implement UiInputSelect**
+- [ ] **Step 3: Implement input_select**
 
 Create `pkg-py/src/shinyui/_input_select.py`:
 
 ```python
-"""UiInputSelect — class-based input_select."""
+"""input_select — class-based input_select."""
 from __future__ import annotations
 
 from typing import Any, Mapping
@@ -1272,7 +1272,7 @@ _MISSING = object()
 SelectChoices = Mapping[str, str] | Mapping[str, Mapping[str, str]]
 
 
-class UiInputSelect(UiInput, Updatable):
+class input_select(UiInput, Updatable):
     def __init__(
         self,
         id: str,
@@ -1330,8 +1330,8 @@ def input_select(
     label: str,
     choices: SelectChoices,
     **kwargs: Any,
-) -> UiInputSelect:
-    return UiInputSelect(id, label, choices, **kwargs)
+) -> input_select:
+    return input_select(id, label, choices, **kwargs)
 ```
 
 - [ ] **Step 4: Run tests — verify they pass**
@@ -1347,12 +1347,12 @@ Same procedure as Task 9 Step 5.
 
 ```bash
 git add pkg-py/src/shinyui/_input_select.py pkg-py/tests/shinyui/test_input_select.py
-git commit -m "feat(shinyui): UiInputSelect + input_select() factory"
+git commit -m "feat(shinyui): input_select + input_select() factory"
 ```
 
 ---
 
-## Task 11: `UiOutputCode`
+## Task 11: `output_code`
 
 **Files:**
 - Create: `pkg-py/src/shinyui/_output_code.py`
@@ -1369,12 +1369,12 @@ from __future__ import annotations
 
 import shiny.ui as sui
 
-from shinyui._output_code import UiOutputCode, output_code
+from shinyui._output_code import output_code, output_code
 
 
 def test_factory_returns_instance():
     o = output_code("summary")
-    assert isinstance(o, UiOutputCode)
+    assert isinstance(o, output_code)
     assert o.id == "summary"
 
 
@@ -1389,12 +1389,12 @@ def test_tagify_matches_shiny_ui_output_code():
 Run: `uv run pytest pkg-py/tests/shinyui/test_output_code.py -v`
 Expected: All fail with `ModuleNotFoundError`.
 
-- [ ] **Step 3: Implement UiOutputCode**
+- [ ] **Step 3: Implement output_code**
 
 Create `pkg-py/src/shinyui/_output_code.py`:
 
 ```python
-"""UiOutputCode — class-based output_code."""
+"""output_code — class-based output_code."""
 from __future__ import annotations
 
 from typing import Any
@@ -1404,7 +1404,7 @@ from htmltools import Tag
 from ._roles import UiOutput
 
 
-class UiOutputCode(UiOutput):
+class output_code(UiOutput):
     def __init__(self, id: str, *, placeholder: bool = False) -> None:
         self.id = id
         self.placeholder = placeholder
@@ -1415,8 +1415,8 @@ class UiOutputCode(UiOutput):
         return _sui.output_code(self.id, placeholder=self.placeholder)  # Implementer: inline markup
 
 
-def output_code(id: str, *, placeholder: bool = False) -> UiOutputCode:
-    return UiOutputCode(id, placeholder=placeholder)
+def output_code(id: str, *, placeholder: bool = False) -> output_code:
+    return output_code(id, placeholder=placeholder)
 ```
 
 - [ ] **Step 4: Run tests — verify they pass**
@@ -1430,12 +1430,12 @@ Expected: All pass.
 
 ```bash
 git add pkg-py/src/shinyui/_output_code.py pkg-py/tests/shinyui/test_output_code.py
-git commit -m "feat(shinyui): UiOutputCode + output_code() factory"
+git commit -m "feat(shinyui): output_code + output_code() factory"
 ```
 
 ---
 
-## Task 12: `UiOutputPlot`
+## Task 12: `output_plot`
 
 **Files:**
 - Create: `pkg-py/src/shinyui/_output_plot.py`
@@ -1454,12 +1454,12 @@ import pytest
 import shiny.ui as sui
 from shiny import reactive
 
-from shinyui._output_plot import UiOutputPlot, output_plot
+from shinyui._output_plot import output_plot, output_plot
 
 
 def test_factory_returns_instance():
     p = output_plot("p", click=True, brush=True)
-    assert isinstance(p, UiOutputPlot)
+    assert isinstance(p, output_plot)
     assert p.id == "p"
 
 
@@ -1512,12 +1512,12 @@ def test_no_input_handlers_registered_for_plot(monkeypatch):
 Run: `uv run pytest pkg-py/tests/shinyui/test_output_plot.py -v`
 Expected: All fail with `ModuleNotFoundError`.
 
-- [ ] **Step 3: Implement UiOutputPlot**
+- [ ] **Step 3: Implement output_plot**
 
 Create `pkg-py/src/shinyui/_output_plot.py`:
 
 ```python
-"""UiOutputPlot — output with read-only client-side interaction signals.
+"""output_plot — output with read-only client-side interaction signals.
 
 Derived input ids:
   input._click       — {x, y} | None
@@ -1539,7 +1539,7 @@ from ._reactive import reactive_calc_method
 from ._roles import UiOutput
 
 
-class UiOutputPlot(UiOutput):
+class output_plot(UiOutput):
     def __init__(
         self,
         id: str,
@@ -1585,8 +1585,8 @@ class UiOutputPlot(UiOutput):
         )
 
 
-def output_plot(id: str, **kwargs: Any) -> UiOutputPlot:
-    return UiOutputPlot(id, **kwargs)
+def output_plot(id: str, **kwargs: Any) -> output_plot:
+    return output_plot(id, **kwargs)
 ```
 
 - [ ] **Step 4: Run tests — verify they pass**
@@ -1600,12 +1600,12 @@ Expected: All pass.
 
 ```bash
 git add pkg-py/src/shinyui/_output_plot.py pkg-py/tests/shinyui/test_output_plot.py
-git commit -m "feat(shinyui): UiOutputPlot with read-only signal accessors"
+git commit -m "feat(shinyui): output_plot with read-only signal accessors"
 ```
 
 ---
 
-## Task 13: `UiAccordionPanel`
+## Task 13: `accordion_panel`
 
 **Files:**
 - Create: `pkg-py/src/shinyui/_accordion_panel.py`
@@ -1623,13 +1623,13 @@ from __future__ import annotations
 import shiny.ui as sui
 from htmltools import tags
 
-from shinyui._accordion_panel import UiAccordionPanel, accordion_panel
+from shinyui._accordion_panel import accordion_panel, accordion_panel
 from shinyui._children import AllowsChildren
 
 
 def test_factory_returns_instance():
     p = accordion_panel("Settings", "body")
-    assert isinstance(p, UiAccordionPanel)
+    assert isinstance(p, accordion_panel)
     assert isinstance(p, AllowsChildren)
 
 
@@ -1655,12 +1655,12 @@ def test_with_block_appends():
 Run: `uv run pytest pkg-py/tests/shinyui/test_accordion_panel.py -v`
 Expected: All fail with `ModuleNotFoundError`.
 
-- [ ] **Step 3: Implement UiAccordionPanel**
+- [ ] **Step 3: Implement accordion_panel**
 
 Create `pkg-py/src/shinyui/_accordion_panel.py`:
 
 ```python
-"""UiAccordionPanel — layout child of UiAccordion."""
+"""accordion_panel — layout child of accordion."""
 from __future__ import annotations
 
 from typing import Any
@@ -1671,7 +1671,7 @@ from ._children import AllowsChildren
 from ._roles import UiLayout
 
 
-class UiAccordionPanel(UiLayout, AllowsChildren):
+class accordion_panel(UiLayout, AllowsChildren):
     def __init__(
         self,
         title: str,
@@ -1695,8 +1695,8 @@ class UiAccordionPanel(UiLayout, AllowsChildren):
         )
 
 
-def accordion_panel(title: str, *args: TagChild, **kwargs: Any) -> UiAccordionPanel:
-    return UiAccordionPanel(title, *args, **kwargs)
+def accordion_panel(title: str, *args: TagChild, **kwargs: Any) -> accordion_panel:
+    return accordion_panel(title, *args, **kwargs)
 ```
 
 - [ ] **Step 4: Run tests — verify they pass**
@@ -1710,12 +1710,12 @@ Expected: All pass.
 
 ```bash
 git add pkg-py/src/shinyui/_accordion_panel.py pkg-py/tests/shinyui/test_accordion_panel.py
-git commit -m "feat(shinyui): UiAccordionPanel + accordion_panel() factory"
+git commit -m "feat(shinyui): accordion_panel + accordion_panel() factory"
 ```
 
 ---
 
-## Task 14: `UiAccordion`
+## Task 14: `accordion`
 
 **Files:**
 - Create: `pkg-py/src/shinyui/_accordion.py`
@@ -1736,7 +1736,7 @@ import pytest
 import shiny.ui as sui
 from shiny import reactive
 
-from shinyui._accordion import UiAccordion, accordion
+from shinyui._accordion import accordion, accordion
 from shinyui._accordion_panel import accordion_panel
 from shinyui._children import AllowsChildren
 from shinyui._input_value import HasInputValue
@@ -1745,7 +1745,7 @@ from shinyui._updatable import Updatable
 
 def test_factory_returns_instance():
     a = accordion(accordion_panel("A"), accordion_panel("B"), id="acc")
-    assert isinstance(a, UiAccordion)
+    assert isinstance(a, accordion)
     assert isinstance(a, HasInputValue)
     assert isinstance(a, AllowsChildren)
     assert isinstance(a, Updatable)
@@ -1785,7 +1785,7 @@ def test_update_sends_message(mock_session):
 def test_input_handler_is_registered_after_import():
     from shiny._input_handler import input_handlers
     # input_handler_name is the wire-type for accordion (verify against shiny source).
-    assert UiAccordion.input_handler_name in input_handlers._handlers
+    assert accordion.input_handler_name in input_handlers._handlers
 ```
 
 - [ ] **Step 2: Run tests — verify they fail**
@@ -1793,12 +1793,12 @@ def test_input_handler_is_registered_after_import():
 Run: `uv run pytest pkg-py/tests/shinyui/test_accordion.py -v`
 Expected: All fail with `ModuleNotFoundError`.
 
-- [ ] **Step 3: Implement UiAccordion**
+- [ ] **Step 3: Implement accordion**
 
 Create `pkg-py/src/shinyui/_accordion.py`:
 
 ```python
-"""UiAccordion — layout with multiple panels; exposes open-panel set as input value."""
+"""accordion — layout with multiple panels; exposes open-panel set as input value."""
 from __future__ import annotations
 
 from typing import Any
@@ -1819,7 +1819,7 @@ def _accordion_input_handler(value: Any, name: Any, session: Any) -> Any:
     return tuple(value) if value is not None else ()
 
 
-class UiAccordion(UiLayout, AllowsChildren, HasInputValue, Updatable):
+class accordion(UiLayout, AllowsChildren, HasInputValue, Updatable):
     # IMPLEMENTER: Confirm the exact wire-type string by reading shiny.ui._accordion.py
     # for `register_input_handler("...", ...)`. Adjust the literal below if wrong;
     # the test_input_handler_is_registered_after_import test pins it.
@@ -1871,11 +1871,11 @@ class UiAccordion(UiLayout, AllowsChildren, HasInputValue, Updatable):
         sess.send_input_message(self.id, msg)
 
 
-UiAccordion._register_input_handler()
+accordion._register_input_handler()
 
 
-def accordion(*args: Any, id: str, **kwargs: Any) -> UiAccordion:
-    return UiAccordion(*args, id=id, **kwargs)
+def accordion(*args: Any, id: str, **kwargs: Any) -> accordion:
+    return accordion(*args, id=id, **kwargs)
 ```
 
 Add the missing `ClassVar` import:
@@ -1895,12 +1895,12 @@ Expected: All pass. If `input_handler_name` is wrong, read `shiny/ui/_accordion.
 
 ```bash
 git add pkg-py/src/shinyui/_accordion.py pkg-py/tests/shinyui/test_accordion.py
-git commit -m "feat(shinyui): UiAccordion + accordion() factory"
+git commit -m "feat(shinyui): accordion + accordion() factory"
 ```
 
 ---
 
-## Task 15: `UiCard`
+## Task 15: `card`
 
 **Files:**
 - Create: `pkg-py/src/shinyui/_card.py`
@@ -1921,7 +1921,7 @@ import pytest
 import shiny.ui as sui
 from shiny import reactive
 
-from shinyui._card import UiCard, card
+from shinyui._card import card, card
 from shinyui._children import AllowsChildren
 from shinyui._input_value import HasInputValue
 from shinyui._updatable import Updatable
@@ -1929,7 +1929,7 @@ from shinyui._updatable import Updatable
 
 def test_factory_returns_instance():
     c = card("body", id="main")
-    assert isinstance(c, UiCard)
+    assert isinstance(c, card)
     assert isinstance(c, HasInputValue)
     assert isinstance(c, AllowsChildren)
     assert isinstance(c, Updatable)
@@ -1966,12 +1966,12 @@ def test_update_sends_message(mock_session):
 Run: `uv run pytest pkg-py/tests/shinyui/test_card.py -v`
 Expected: All fail with `ModuleNotFoundError`.
 
-- [ ] **Step 3: Implement UiCard**
+- [ ] **Step 3: Implement card**
 
 Create `pkg-py/src/shinyui/_card.py`:
 
 ```python
-"""UiCard — layout with optional full-screen toggle exposed as input value."""
+"""card — layout with optional full-screen toggle exposed as input value."""
 from __future__ import annotations
 
 from typing import Any
@@ -1987,7 +1987,7 @@ from ._updatable import Updatable
 _MISSING = object()
 
 
-class UiCard(UiLayout, AllowsChildren, HasInputValue, Updatable):
+class card(UiLayout, AllowsChildren, HasInputValue, Updatable):
     # No input_handler_name / _input_handler — card's full_screen is a plain JSON bool.
 
     def __init__(
@@ -2032,8 +2032,8 @@ class UiCard(UiLayout, AllowsChildren, HasInputValue, Updatable):
         sess.send_input_message(self.id, msg)
 
 
-def card(*args: TagChild, id: str, **kwargs: Any) -> UiCard:
-    return UiCard(*args, id=id, **kwargs)
+def card(*args: TagChild, id: str, **kwargs: Any) -> card:
+    return card(*args, id=id, **kwargs)
 ```
 
 - [ ] **Step 4: Run tests — verify they pass**
@@ -2047,7 +2047,7 @@ Expected: All pass.
 
 ```bash
 git add pkg-py/src/shinyui/_card.py pkg-py/tests/shinyui/test_card.py
-git commit -m "feat(shinyui): UiCard with full_screen_value() and update()"
+git commit -m "feat(shinyui): card with full_screen_value() and update()"
 ```
 
 ---
@@ -2069,9 +2069,9 @@ def test_public_exports():
     assert sui.UiComponent
     assert sui.UiInput and sui.UiOutput and sui.UiLayout
     assert sui.HasInputValue and sui.Updatable and sui.AllowsChildren
-    assert sui.UiInputSlider and sui.UiInputSelect
-    assert sui.UiOutputCode and sui.UiOutputPlot
-    assert sui.UiCard and sui.UiAccordion and sui.UiAccordionPanel
+    assert sui.input_slider and sui.input_select
+    assert sui.output_code and sui.output_plot
+    assert sui.card and sui.accordion and sui.accordion_panel
 
     # Factory names
     assert callable(sui.input_slider)
@@ -2097,16 +2097,16 @@ Replace `pkg-py/src/shinyui/__init__.py` with:
 
 See docs/superpowers/specs/2026-05-13-shinyui-metadata-consolidation-design.md.
 """
-from ._accordion import UiAccordion, accordion
-from ._accordion_panel import UiAccordionPanel, accordion_panel
+from ._accordion import accordion, accordion
+from ._accordion_panel import accordion_panel, accordion_panel
 from ._base import UiComponent
-from ._card import UiCard, card
+from ._card import card, card
 from ._children import AllowsChildren
-from ._input_select import UiInputSelect, input_select
-from ._input_slider import UiInputSlider, input_slider
+from ._input_select import input_select, input_select
+from ._input_slider import input_slider, input_slider
 from ._input_value import HasInputValue
-from ._output_code import UiOutputCode, output_code
-from ._output_plot import UiOutputPlot, output_plot
+from ._output_code import output_code, output_code
+from ._output_plot import output_plot, output_plot
 from ._roles import UiInput, UiLayout, UiOutput
 from ._updatable import Updatable
 
@@ -2115,9 +2115,9 @@ __all__ = [
     "UiComponent", "UiInput", "UiOutput", "UiLayout",
     "HasInputValue", "Updatable", "AllowsChildren",
     # Concrete classes
-    "UiInputSlider", "UiInputSelect",
-    "UiOutputCode", "UiOutputPlot",
-    "UiCard", "UiAccordion", "UiAccordionPanel",
+    "input_slider", "input_select",
+    "output_code", "output_plot",
+    "card", "accordion", "accordion_panel",
     # Factories
     "input_slider", "input_select",
     "output_code", "output_plot",
@@ -2162,20 +2162,20 @@ import shinyui as sui
 
 def _maker(cls):
     """Build a representative instance of `cls` with whatever args its factory needs."""
-    if cls is sui.UiInputSlider:    return sui.input_slider("n", "N", 1, 10, 5)
-    if cls is sui.UiInputSelect:    return sui.input_select("c", "C", {"a": "A"})
-    if cls is sui.UiOutputCode:     return sui.output_code("o")
-    if cls is sui.UiOutputPlot:     return sui.output_plot("p")
-    if cls is sui.UiCard:           return sui.card("b", id="m")
-    if cls is sui.UiAccordion:      return sui.accordion(sui.accordion_panel("A"), id="acc")
-    if cls is sui.UiAccordionPanel: return sui.accordion_panel("X", "y")
+    if cls is sui.input_slider:    return sui.input_slider("n", "N", 1, 10, 5)
+    if cls is sui.input_select:    return sui.input_select("c", "C", {"a": "A"})
+    if cls is sui.output_code:     return sui.output_code("o")
+    if cls is sui.output_plot:     return sui.output_plot("p")
+    if cls is sui.card:           return sui.card("b", id="m")
+    if cls is sui.accordion:      return sui.accordion(sui.accordion_panel("A"), id="acc")
+    if cls is sui.accordion_panel: return sui.accordion_panel("X", "y")
     raise AssertionError(f"no maker for {cls}")
 
 
 ALL_CLASSES = [
-    sui.UiInputSlider, sui.UiInputSelect,
-    sui.UiOutputCode, sui.UiOutputPlot,
-    sui.UiCard, sui.UiAccordion, sui.UiAccordionPanel,
+    sui.input_slider, sui.input_select,
+    sui.output_code, sui.output_plot,
+    sui.card, sui.accordion, sui.accordion_panel,
 ]
 
 
@@ -2185,13 +2185,13 @@ def test_is_uicomponent(cls):
 
 
 @pytest.mark.parametrize("cls,expected", [
-    (sui.UiInputSlider,    {sui.UiInput, sui.HasInputValue, sui.Updatable}),
-    (sui.UiInputSelect,    {sui.UiInput, sui.HasInputValue, sui.Updatable}),
-    (sui.UiOutputCode,     {sui.UiOutput}),
-    (sui.UiOutputPlot,     {sui.UiOutput}),
-    (sui.UiCard,           {sui.UiLayout, sui.AllowsChildren, sui.HasInputValue, sui.Updatable}),
-    (sui.UiAccordion,      {sui.UiLayout, sui.AllowsChildren, sui.HasInputValue, sui.Updatable}),
-    (sui.UiAccordionPanel, {sui.UiLayout, sui.AllowsChildren}),
+    (sui.input_slider,    {sui.UiInput, sui.HasInputValue, sui.Updatable}),
+    (sui.input_select,    {sui.UiInput, sui.HasInputValue, sui.Updatable}),
+    (sui.output_code,     {sui.UiOutput}),
+    (sui.output_plot,     {sui.UiOutput}),
+    (sui.card,           {sui.UiLayout, sui.AllowsChildren, sui.HasInputValue, sui.Updatable}),
+    (sui.accordion,      {sui.UiLayout, sui.AllowsChildren, sui.HasInputValue, sui.Updatable}),
+    (sui.accordion_panel, {sui.UiLayout, sui.AllowsChildren}),
 ])
 def test_expected_bases(cls, expected):
     inst = _maker(cls)
@@ -2200,13 +2200,13 @@ def test_expected_bases(cls, expected):
 
 
 @pytest.mark.parametrize("cls,allows_children", [
-    (sui.UiInputSlider,    False),
-    (sui.UiInputSelect,    False),
-    (sui.UiOutputCode,     False),
-    (sui.UiOutputPlot,     False),
-    (sui.UiCard,           True),
-    (sui.UiAccordion,      True),
-    (sui.UiAccordionPanel, True),
+    (sui.input_slider,    False),
+    (sui.input_select,    False),
+    (sui.output_code,     False),
+    (sui.output_plot,     False),
+    (sui.card,           True),
+    (sui.accordion,      True),
+    (sui.accordion_panel, True),
 ])
 def test_with_block_protocol(cls, allows_children):
     inst = _maker(cls)
@@ -2271,17 +2271,17 @@ import shinyui as sui
 
 
 def test_accordion_handler_registered_after_import():
-    assert sui.UiAccordion.input_handler_name in input_handlers._handlers
+    assert sui.accordion.input_handler_name in input_handlers._handlers
 
 
 def test_slider_does_not_register_handler():
     """Slider has no custom server-side wire coercion."""
-    assert sui.UiInputSlider.input_handler_name == ""
-    assert sui.UiInputSlider._input_handler is None
+    assert sui.input_slider.input_handler_name == ""
+    assert sui.input_slider._input_handler is None
 
 
 def test_card_does_not_register_handler():
-    assert sui.UiCard.input_handler_name == ""
+    assert sui.card.input_handler_name == ""
 ```
 
 - [ ] **Step 4: Write test_update_resolution.py**
@@ -2466,11 +2466,11 @@ Create `examples/app-py/14-unified-ui-prototype/app.py`:
 """End-to-end demo of shinyui's class-per-component hierarchy.
 
 Exercises every reference class in one page:
-  - UiInputSlider, UiInputSelect  (simple + structured inputs)
-  - UiOutputCode                  (output)
-  - UiOutputPlot                  (output with read-only signals)
-  - UiCard                        (layout with state)
-  - UiAccordion + UiAccordionPanel (layout-with-state + layout-as-child)
+  - input_slider, input_select  (simple + structured inputs)
+  - output_code                  (output)
+  - output_plot                  (output with read-only signals)
+  - card                        (layout with state)
+  - accordion + accordion_panel (layout-with-state + layout-as-child)
 
 The `app_ui` is a function (not a module-level Tag) so a session is in scope
 when components are constructed — this is what enables class-owned bookmark
@@ -2596,13 +2596,13 @@ Run:
 
 | Archetype | Class | Demonstrated by |
 |---|---|---|
-| Simple input | `UiInputSlider` | `n` and `seed` sliders |
-| Structured input | `UiInputSelect` | `dist` selector |
-| Plain output | `UiOutputCode` | `summary` and `diag` |
-| Output with read-only signals | `UiOutputPlot` | `plot` with `click=True, brush=True` |
-| Layout with children | `UiCard` | `main_card` |
-| Layout with state + children | `UiCard` + `UiAccordion` | `main_card.full_screen_value()`, `accordion.open_panels()` |
-| Layout-as-child | `UiAccordionPanel` | Two panels inside `accordion` |
+| Simple input | `input_slider` | `n` and `seed` sliders |
+| Structured input | `input_select` | `dist` selector |
+| Plain output | `output_code` | `summary` and `diag` |
+| Output with read-only signals | `output_plot` | `plot` with `click=True, brush=True` |
+| Layout with children | `card` | `main_card` |
+| Layout with state + children | `card` + `accordion` | `main_card.full_screen_value()`, `accordion.open_panels()` |
+| Layout-as-child | `accordion_panel` | Two panels inside `accordion` |
 
 ## What to try
 
@@ -2615,7 +2615,7 @@ Run:
 ## Bookmark round-trip
 
 Append `?_inputs_=...` to the URL or use Shiny's built-in URL bookmark. Class-owned
-serializers (e.g. `UiAccordion`'s) restore correctly because the components
+serializers (e.g. `accordion`'s) restore correctly because the components
 register themselves with the session during `app_ui(request)` construction.
 ```
 
@@ -2663,7 +2663,7 @@ Walk the acceptance list from the spec and confirm each is met:
 - [ ] `examples/app-py/14-unified-ui-prototype/` runs and demonstrates bookmark + `.update()`
 - [ ] All test files exist and pass
 - [ ] `tagify()` snapshots match `shiny.ui.*` markup for every concrete class
-- [ ] `with UiInputSlider(...):` raises with clear message
+- [ ] `with input_slider(...):` raises with clear message
 - [ ] No new top-level dependency in `pyproject.toml`
 
 Report status back to the user with the final commit SHA and a summary of what shipped.
diff --git a/docs/superpowers/specs/2026-05-13-shinyui-metadata-consolidation-design.md b/docs/superpowers/specs/2026-05-13-shinyui-metadata-consolidation-design.md
index c2ed0621..5733b85e 100644
--- a/docs/superpowers/specs/2026-05-13-shinyui-metadata-consolidation-design.md
+++ b/docs/superpowers/specs/2026-05-13-shinyui-metadata-consolidation-design.md
@@ -20,7 +20,7 @@ Three open questions from the umbrella are resolved in this spec:
 
 A fourth refinement that emerged during design:
 
-- The umbrella's `UiInput`/`UiLayout`/`UiOutput` straddler pattern (e.g. `UiAccordion(UiInput, AllowsChildren)`) is replaced. "Has an input value" and "is updatable" become orthogonal mixins (`HasInputValue`, `Updatable`); the three role classes stay as semantic markers. This avoids the awkwardness of calling a card or an accordion "an input."
+- The umbrella's `UiInput`/`UiLayout`/`UiOutput` straddler pattern (e.g. `accordion(UiInput, AllowsChildren)`) is replaced. "Has an input value" and "is updatable" become orthogonal mixins (`HasInputValue`, `Updatable`); the three role classes stay as semantic markers. This avoids the awkwardness of calling a card or an accordion "an input."
 
 ## Motivation (delta from umbrella)
 
@@ -28,7 +28,7 @@ The umbrella spec answers *why* this work matters and *what* the hierarchy looks
 
 Three concrete pressures shaped the divergences below:
 
-- **Layouts can have input values.** Accordion's open-panel set, card's full-screen toggle, sidebar's open/closed state, navset's active tab — all are layouts whose primary user-facing purpose is structure, but which expose server-readable state. The umbrella's `UiAccordion(UiInput, AllowsChildren)` straddler doesn't generalize gracefully to `UiCard` ("a card is an input?"). Factoring `HasInputValue` out as a mixin removes the awkwardness and reads honestly.
+- **Layouts can have input values.** Accordion's open-panel set, card's full-screen toggle, sidebar's open/closed state, navset's active tab — all are layouts whose primary user-facing purpose is structure, but which expose server-readable state. The umbrella's `accordion(UiInput, AllowsChildren)` straddler doesn't generalize gracefully to `card` ("a card is an input?"). Factoring `HasInputValue` out as a mixin removes the awkwardness and reads honestly.
 - **Outputs can have read-only multi-signals.** A plot exposes `_click`, `_brush`, `_hover`, `_dblclick`. None are updatable from the server. Forcing these through `HasInputValue` (multi-id generalization) inflates a single-id abstraction for one rare use case; making them a separate mechanism keeps the common case clean.
 - **Server-side read accessors are a real ergonomic win.** Shiny's `@render.data_frame` already exposes `df.cell_selection()`, `df.sort()`, etc. as reactive methods on the renderer instance. The class-per-component design makes the same idiom available across the board: `slider.value()`, `card.full_screen_value()`, `accordion.open_panels()`, `plot.click_value()`.
 
@@ -115,19 +115,19 @@ AllowsChildren (mixin)         # children, append(), __enter__ returns self, __e
 
 | Class | Bases | Has input value? | Updatable? | Read accessors |
 |---|---|---|---|---|
-| `UiInputSlider` | `UiInput, Updatable` | ✓ | ✓ | `value() -> float` |
-| `UiInputSelect` | `UiInput, Updatable` | ✓ | ✓ | `value() -> str \| tuple[str, ...]` |
-| `UiOutputCode` | `UiOutput` | — | — | — |
-| `UiOutputPlot` | `UiOutput` | ✓ (derived ids; not `HasInputValue`) | — | `click_value()`, `dblclick_value()`, `hover_value()`, `brush_value()` |
-| `UiCard` | `UiLayout, AllowsChildren, HasInputValue, Updatable` | ✓ — `full_screen` (empty suffix; `input.()` is the boolean) | ✓ | `full_screen_value() -> bool` |
-| `UiAccordion` | `UiLayout, AllowsChildren, HasInputValue, Updatable` | ✓ — open panel set | ✓ | `open_panels() -> tuple[str, ...]` |
-| `UiAccordionPanel` | `UiLayout, AllowsChildren` | — | — | — |
+| `input_slider` | `UiInput, Updatable` | ✓ | ✓ | `value() -> float` |
+| `input_select` | `UiInput, Updatable` | ✓ | ✓ | `value() -> str \| tuple[str, ...]` |
+| `output_code` | `UiOutput` | — | — | — |
+| `output_plot` | `UiOutput` | ✓ (derived ids; not `HasInputValue`) | — | `click_value()`, `dblclick_value()`, `hover_value()`, `brush_value()` |
+| `card` | `UiLayout, AllowsChildren, HasInputValue, Updatable` | ✓ — `full_screen` (empty suffix; `input.()` is the boolean) | ✓ | `full_screen_value() -> bool` |
+| `accordion` | `UiLayout, AllowsChildren, HasInputValue, Updatable` | ✓ — open panel set | ✓ | `open_panels() -> tuple[str, ...]` |
+| `accordion_panel` | `UiLayout, AllowsChildren` | — | — | — |
 
-Each class is paired with a lowercase factory function (`input_slider`, `input_select`, `output_code`, `output_plot`, `card`, `accordion`, `accordion_panel`) that returns the instance. Both names are public exports from `shinyui`.
+Concrete classes use snake_case names (matching `shiny.render.data_frame` convention). The class name IS the call site — no separate factory function. Public exports from `shinyui`.
 
 ### Why this departs from the umbrella
 
-The umbrella spec models `UiAccordion` as `UiInput, AllowsChildren` (a "straddler"). That works for accordion in isolation but doesn't generalize to `UiCard`: a card whose full-screen state is exposed as an input wouldn't naturally be called "an input." Once we admit that *any* layout can expose state, the cleanest factoring is to make state-bearing a mixin orthogonal to the role split. The role categories (`UiInput`/`UiOutput`/`UiLayout`) become semantic markers; the mixins (`HasInputValue`/`Updatable`/`AllowsChildren`) describe capabilities.
+The umbrella spec models `accordion` as `UiInput, AllowsChildren` (a "straddler"). That works for accordion in isolation but doesn't generalize to `card`: a card whose full-screen state is exposed as an input wouldn't naturally be called "an input." Once we admit that *any* layout can expose state, the cleanest factoring is to make state-bearing a mixin orthogonal to the role split. The role categories (`UiInput`/`UiOutput`/`UiLayout`) become semantic markers; the mixins (`HasInputValue`/`Updatable`/`AllowsChildren`) describe capabilities.
 
 This refactor doesn't change the umbrella's other commitments: HTML deps still live as ClassVar, `tagify()` is still pure, the input handler registry is unchanged, and the umbrella's "you can `with X(...)` iff `X` declares `AllowsChildren`" rule still holds.
 
@@ -201,7 +201,7 @@ class HasInputValue:
 Subclasses declare both attributes and call `_register_input_handler()` at module level:
 
 ```python
-class UiInputDate(UiInput):
+class input_date(UiInput):  # noqa: N801
     input_handler_name = "shiny.date"
 
     @staticmethod
@@ -209,7 +209,7 @@ class UiInputDate(UiInput):
         return parse_iso_date(value)
 
 
-UiInputDate._register_input_handler()
+input_date._register_input_handler()
 ```
 
 Most simple inputs (slider, select, code, card, accordion, plot, etc.) don't override `_input_handler` and don't call `_register_input_handler()` — the default `None` means "Shiny's existing wire layer passes the value through as-is."
@@ -230,7 +230,7 @@ class Updatable(ABC):
     def update(self, **kwargs) -> None: ...
 
 
-class UiInputSlider(UiInput, Updatable):
+class input_slider(UiInput, Updatable):  # noqa: N801
     def update(
         self, *,
         value: float | tuple[float, float] = MISSING,
@@ -255,28 +255,28 @@ The data_frame renderer pattern: instance methods wrapped in `@reactive_calc_met
 For `HasInputValue` (single-id):
 
 ```python
-class UiInputSlider(UiInput, Updatable):
+class input_slider(UiInput, Updatable):  # noqa: N801
     @reactive_calc_method
     def value(self) -> float:
         return self._read_input()
 
 
-class UiCard(UiLayout, AllowsChildren, HasInputValue, Updatable):
+class card(UiLayout, AllowsChildren, HasInputValue, Updatable):  # noqa: N801
     @reactive_calc_method
     def full_screen_value(self) -> bool:
         return bool(self._read_input())
 
 
-class UiAccordion(UiLayout, AllowsChildren, HasInputValue, Updatable):
+class accordion(UiLayout, AllowsChildren, HasInputValue, Updatable):  # noqa: N801
     @reactive_calc_method
     def open_panels(self) -> tuple[str, ...]:
         return tuple(self._read_input() or ())
 ```
 
-For `UiOutputPlot` (multi-signal, not `HasInputValue`):
+For `output_plot` (multi-signal, not `HasInputValue`):
 
 ```python
-class UiOutputPlot(UiOutput):
+class output_plot(UiOutput):  # noqa: N801
     def __init__(
         self, id: str, *,
         click: bool = False, dblclick: bool = False,
@@ -391,9 +391,9 @@ Each test file targets one layer. Tests use a controllable session via Shiny's s
 
 | Test file | What it pins |
 |---|---|
-| `test_hierarchy.py` | MRO of every concrete class; `isinstance(slider, UiInput)`, `isinstance(card, AllowsChildren)`, etc.; `with UiInputSlider(...):` raises `TypeError` with the right message; `with UiOutputCode(...):` likewise; `with UiCard(...):` does not. |
+| `test_hierarchy.py` | MRO of every concrete class; `isinstance(slider, UiInput)`, `isinstance(card, AllowsChildren)`, etc.; `with input_slider(...):` raises `TypeError` with the right message; `with output_code(...):` likewise; `with card(...):` does not. |
 | `test_tagify_snapshots.py` | `tagify()` output for each class compared to the equivalent `shiny.ui.*(...)` `Tag` — Tag equality + HTML-dep set equality. Catches drift from upstream markup. |
-| `test_input_handler_registration.py` | After importing `shinyui`, the handler registry contains the expected `input_handler_name` → callable mappings (e.g. for `UiAccordion`); classes without `_input_handler` don't register anything. |
+| `test_input_handler_registration.py` | After importing `shinyui`, the handler registry contains the expected `input_handler_name` → callable mappings (e.g. for `accordion`); classes without `_input_handler` don't register anything. |
 | `test_bookmark_roundtrip.py` | Within a session, construct an input, serialize via the class-owned serializer (or per-instance override), restore in a fresh session, assert value parity. |
 | `test_update_resolution.py` | `update()` outside a session raises `RuntimeError` with class name + method name; with init-captured session it uses that session; with no init session but a current session it uses the current; `update()` accepts no `session=` kwarg (type-checked via pyright fixture). |
 | `test_read_accessors.py` | `slider.value()`, `card.full_screen_value()`, `accordion.open_panels()`, `plot.click_value()` each return the value from the appropriate `session.input[derived_id]`; called outside a session, each raises. |
@@ -410,7 +410,7 @@ Mirroring the issue's checklist:
 - [ ] `examples/app-py/14-unified-ui-prototype/` runs end-to-end with bookmark round-trip and at least one `.update()` from the server.
 - [ ] All seven test files exist and pass; `make py-check` is green.
 - [ ] `tagify()` snapshots match `shiny.ui.*` markup for every concrete class.
-- [ ] `with UiInputSlider(...):` (and any non-`AllowsChildren` instance) raises with a clear message naming the class.
+- [ ] `with input_slider(...):` (and any non-`AllowsChildren` instance) raises with a clear message naming the class.
 - [ ] No new top-level dependency added to `pyproject.toml`.
 
 ## Open questions deferred
@@ -418,11 +418,11 @@ Mirroring the issue's checklist:
 - **Sub-issue 2 (Core/Express overload signatures)** — out of scope here. Will be designed in a follow-up brainstorm; depends on this prototype landing.
 - **Sub-issue 3 (Tag-as-context-manager / parent-tag stack)** — out of scope here. `AllowsChildren.__enter__` returns `self` and `with card(): h1("x")` does *not* auto-collect.
 - **How `server()` captures component instances** — closure capture, lookup-by-id from a session-attached registry, or another path. Settled in the implementation plan, not this design.
-- **`UiOutputCode` rendering** — whether `shinyui` ships its own `@render_code` decorator or relies on `shiny.render.code`. Settled in the implementation plan.
+- **`output_code` rendering** — whether `shinyui` ships its own `@render_code` decorator or relies on `shiny.render.code`. Settled in the implementation plan.
 
 ## Risks
 
-- **MRO discipline.** `UiCard(UiLayout, AllowsChildren, HasInputValue, Updatable)` is four-base inheritance. Each mixin must `super().__init__(**kw)` first, then do its own work. Documented in code comments; pinned by `test_hierarchy.py`. If a mixin omits `super()`, errors surface immediately because `self._session` won't be set when `HasInputValue` reads it.
+- **MRO discipline.** `card(UiLayout, AllowsChildren, HasInputValue, Updatable)` is four-base inheritance. Each mixin must `super().__init__(**kw)` first, then do its own work. Documented in code comments; pinned by `test_hierarchy.py`. If a mixin omits `super()`, errors surface immediately because `self._session` won't be set when `HasInputValue` reads it.
 - **Snapshot drift.** Upstream `shiny.ui` markup can change between releases. Snapshot test runs against the installed `shiny`, so changes are caught on dependency bumps. Mitigation: pin `shiny>=1.2.0` (already done) and regenerate snapshots when bumping.
 - **Bookmark coupling to private session state.** Attaching `_shinyui_instances` to `Session` via `setattr` is a private-attribute pattern. Acceptable for a prototype; Stage B can negotiate a public hook in `py-shiny`.
 - **`@reactive_calc_method` local fork.** Drift from `shiny.render._data_frame_utils._reactive_method` is possible. Mitigation: 15-line implementation, comment pointing at the source, easy to compare during Stage B.
diff --git a/examples/app-py/14-unified-ui-prototype/README.md b/examples/app-py/14-unified-ui-prototype/README.md
index 0c3ffec2..0e1768c5 100644
--- a/examples/app-py/14-unified-ui-prototype/README.md
+++ b/examples/app-py/14-unified-ui-prototype/README.md
@@ -18,13 +18,13 @@ extras group).
 
 | Archetype | Class | Demonstrated by |
 |---|---|---|
-| Simple input | `UiInputSlider` | `n` and `seed` sliders |
-| Structured input | `UiInputSelect` | `dist` selector |
-| Plain output | `UiOutputCode` | `summary` and `diag` outputs |
-| Output with read-only signals | `UiOutputPlot` | `plot` with `click=True, brush=True` |
-| Layout with children + state | `UiCard` | `main_card.full_screen_value()`, `main_card.update(full_screen=...)` |
-| Layout with state + children | `UiAccordion` | `acc.open_panels()`, `acc.update(open=...)` |
-| Layout-as-child | `UiAccordionPanel` | Two panels inside `acc` |
+| Simple input | `input_slider` | `n` and `seed` sliders |
+| Structured input | `input_select` | `dist` selector |
+| Plain output | `output_code` | `summary` and `diag` outputs |
+| Output with read-only signals | `output_plot` | `plot` with `click=True, brush=True` |
+| Layout with children + state | `card` | `main_card.full_screen_value()`, `main_card.update(full_screen=...)` |
+| Layout with state + children | `accordion` | `acc.open_panels()`, `acc.update(open=...)` |
+| Layout-as-child | `accordion_panel` | Two panels inside `acc` |
 
 ## Class-per-component patterns in the server code
 
@@ -33,7 +33,7 @@ each component constructed in `app_ui(request)`. Through those handles, server
 code reads input values:
 
 ```python
-n_slider = cast(su.UiInputSlider, su.lookup_component(session, "n"))
+n_slider = cast(su.input_slider, su.lookup_component(session, "n"))
 
 @render.code
 def summary():
@@ -64,7 +64,7 @@ reactive contexts establish dependencies correctly.
 
 ## Notes on real-app fidelity
 
-- `UiCard.full_screen_value()` reads `input.()` — Stage A doesn't wire
+- `card.full_screen_value()` reads `input.()` — Stage A doesn't wire
   the browser-side push for `full_screen` state, so the value stays `False` in
   a live browser session until the JS binding is added. Unit tests exercise the
   full path with a mocked session.
diff --git a/examples/app-py/14-unified-ui-prototype/app.py b/examples/app-py/14-unified-ui-prototype/app.py
index 35787fd3..f9b6b651 100644
--- a/examples/app-py/14-unified-ui-prototype/app.py
+++ b/examples/app-py/14-unified-ui-prototype/app.py
@@ -1,11 +1,11 @@
 """End-to-end demo of shinyui's class-per-component hierarchy (Shiny Express).
 
 Exercises every reference class in one page:
-  - UiInputSlider, UiInputSelect, UiInputActionButton  (inputs)
-  - UiOutputCode                                       (output)
-  - UiOutputPlot                                       (output with read-only signals)
-  - UiCard                                             (layout with state)
-  - UiAccordion + UiAccordionPanel                     (layout + layout-as-child)
+  - input_slider, input_select, input_action_button  (inputs)
+  - output_code                                      (output)
+  - output_plot                                      (output with read-only signals)
+  - card                                             (layout with state)
+  - accordion + accordion_panel                      (layout + layout-as-child)
 
 This is the Express variant of the demo. In Express the script runs once per
 session — ``get_current_session()`` is bound while the module's top-level
diff --git a/pkg-py/src/shinyui/__init__.py b/pkg-py/src/shinyui/__init__.py
index 55184eea..b422b478 100644
--- a/pkg-py/src/shinyui/__init__.py
+++ b/pkg-py/src/shinyui/__init__.py
@@ -3,36 +3,28 @@
 See docs/superpowers/specs/2026-05-13-shinyui-metadata-consolidation-design.md.
 """
 
-from ._accordion import UiAccordion, accordion
-from ._accordion_panel import UiAccordionPanel, accordion_panel
+from ._accordion import accordion
+from ._accordion_panel import accordion_panel
 from ._base import UiComponent
 from ._bookmark import lookup_component
-from ._card import UiCard, card
+from ._card import card
 from ._children import AllowsChildren
-from ._input_action_button import UiInputActionButton, input_action_button
-from ._input_select import UiInputSelect, input_select
-from ._input_slider import UiInputSlider, input_slider
+from ._input_action_button import input_action_button
+from ._input_select import input_select
+from ._input_slider import input_slider
 from ._input_value import HasInputValue
-from ._output_code import UiOutputCode, output_code
-from ._output_plot import UiOutputPlot, output_plot
+from ._output_code import output_code
+from ._output_plot import output_plot
 from ._roles import UiInput, UiLayout, UiOutput
 from ._updatable import Updatable
 
 __all__ = [
     "AllowsChildren",
     "HasInputValue",
-    "UiAccordion",
-    "UiAccordionPanel",
-    "UiCard",
     "UiComponent",
     "UiInput",
-    "UiInputActionButton",
-    "UiInputSelect",
-    "UiInputSlider",
     "UiLayout",
     "UiOutput",
-    "UiOutputCode",
-    "UiOutputPlot",
     "Updatable",
     "accordion",
     "accordion_panel",
diff --git a/pkg-py/src/shinyui/_accordion.py b/pkg-py/src/shinyui/_accordion.py
index a7b6ec65..55766d7a 100644
--- a/pkg-py/src/shinyui/_accordion.py
+++ b/pkg-py/src/shinyui/_accordion.py
@@ -1,4 +1,4 @@
-"""UiAccordion — layout with collapsible panels; open-panel set exposed as input value.
+"""accordion — layout with collapsible panels; open-panel set exposed as input value.
 
 Implementation note: shiny's accordion already registers its own input binding that
 pushes the open-panel list to the server as a list.  No custom input handler is
@@ -11,7 +11,7 @@
 
 from htmltools import Tag
 
-from ._accordion_panel import UiAccordionPanel
+from ._accordion_panel import accordion_panel
 from ._children import AllowsChildren
 from ._input_value import HasInputValue
 from ._reactive import reactive_calc_method
@@ -21,7 +21,7 @@
 _MISSING = object()
 
 
-class UiAccordion(UiLayout, AllowsChildren, HasInputValue, Updatable):
+class accordion(UiLayout, AllowsChildren, HasInputValue, Updatable):  # noqa: N801
     """Accordion container; open-panel set is available via open_panels().
 
     No custom input handler is registered — shiny's own accordion binding handles
@@ -30,7 +30,7 @@ class UiAccordion(UiLayout, AllowsChildren, HasInputValue, Updatable):
 
     def __init__(
         self,
-        *args: UiAccordionPanel,
+        *args: accordion_panel,
         id: str,
         open: Optional[str | tuple[str, ...] | bool] = None,
         multiple: bool = True,
@@ -54,7 +54,7 @@ def tagify(self) -> Tag:
         import shiny.ui as _sui
 
         # `shiny.ui.accordion` does an explicit isinstance(panel, AccordionPanel)
-        # check, so children must be pre-resolved (UiAccordionPanel.tagify()
+        # check, so children must be pre-resolved (accordion_panel.tagify()
         # returns an AccordionPanel). A single .tagify() on the result lets
         # htmltools' walker resolve any remaining Tagifiable descendants
         # inside the panels (e.g. an input_slider inside a panel).
@@ -109,20 +109,3 @@ def update(
             targets = [hide] if isinstance(hide, str) else list(hide)
             for target in targets:
                 _sui.update_accordion_panel(self.id, target, show=False, session=sess)
-
-
-def accordion(*args: UiAccordionPanel, id: str, **kwargs: Any) -> UiAccordion:
-    """Create a UiAccordion.
-
-    Parameters
-    ----------
-    *args
-        :class:`UiAccordionPanel` children.
-    id
-        Input id; available as ``input.id()`` in the server, or via
-        ``accordion_obj.open_panels()``.
-    **kwargs
-        Forwarded to :class:`UiAccordion` (``open``, ``multiple``, ``class_``,
-        ``width``, ``height``).
-    """
-    return UiAccordion(*args, id=id, **kwargs)
diff --git a/pkg-py/src/shinyui/_accordion_panel.py b/pkg-py/src/shinyui/_accordion_panel.py
index 4f633ddc..52a2f695 100644
--- a/pkg-py/src/shinyui/_accordion_panel.py
+++ b/pkg-py/src/shinyui/_accordion_panel.py
@@ -1,9 +1,7 @@
-"""UiAccordionPanel — layout child of UiAccordion."""
+"""accordion_panel — layout child of accordion."""
 
 from __future__ import annotations
 
-from typing import Any
-
 from htmltools import TagChild
 from shiny.types import MISSING, MISSING_TYPE
 from shiny.ui._accordion import AccordionPanel
@@ -12,7 +10,7 @@
 from ._roles import UiLayout
 
 
-class UiAccordionPanel(UiLayout, AllowsChildren):
+class accordion_panel(UiLayout, AllowsChildren):  # noqa: N801
     def __init__(
         self,
         title: str,
@@ -40,7 +38,3 @@ def tagify(self) -> AccordionPanel:  # type: ignore[override]
             value=self._value,
             icon=self.icon,
         )
-
-
-def accordion_panel(title: str, *args: TagChild, **kwargs: Any) -> UiAccordionPanel:
-    return UiAccordionPanel(title, *args, **kwargs)
diff --git a/pkg-py/src/shinyui/_card.py b/pkg-py/src/shinyui/_card.py
index 79aa6eeb..145518b1 100644
--- a/pkg-py/src/shinyui/_card.py
+++ b/pkg-py/src/shinyui/_card.py
@@ -1,4 +1,4 @@
-"""UiCard — layout with optional full-screen toggle exposed as input value.
+"""card — layout with optional full-screen toggle exposed as input value.
 
 NOTE: real wire-level `full_screen` input is out of scope for the Stage A
 prototype (would require client-side JS). The `full_screen_value()` accessor
@@ -26,7 +26,7 @@
 _MISSING = object()
 
 
-class UiCard(UiLayout, AllowsChildren, HasInputValue, Updatable):
+class card(UiLayout, AllowsChildren, HasInputValue, Updatable):  # noqa: N801
     """Card container; full-screen state is available via full_screen_value().
 
     No custom input handler is registered — shiny's own card binding handles
@@ -81,7 +81,7 @@ def tagify(self) -> Tag:
             kwargs["class_"] = self.class_
 
         # `shiny.ui.card` accepts arbitrary TagChild members — including our
-        # Tagifiable UiAccordion / UiInputSlider / etc. — so we hand them in
+        # Tagifiable accordion / input_slider / etc. — so we hand them in
         # unchanged. A single .tagify() on the result lets htmltools' walker
         # resolve our Tagifiable descendants chain-style. (Card has no
         # isinstance check on children, unlike accordion's AccordionPanel.)
@@ -104,20 +104,3 @@ def update(
             return
         # There is no shiny.ui.update_card today; use send_input_message directly.
         sess.send_input_message(self.id, {"full_screen": full_screen})
-
-
-def card(*args: TagChild, id: str, **kwargs: Any) -> UiCard:
-    """Create a :class:`UiCard`.
-
-    Parameters
-    ----------
-    *args
-        UI children.
-    id
-        Input id; available as ``input.id()`` in the server, or via
-        ``card_obj.full_screen_value()``.
-    **kwargs
-        Forwarded to :class:`UiCard` (``full_screen``, ``height``,
-        ``max_height``, ``min_height``, ``fill``, ``class_``).
-    """
-    return UiCard(*args, id=id, **kwargs)
diff --git a/pkg-py/src/shinyui/_input_action_button.py b/pkg-py/src/shinyui/_input_action_button.py
index f8ce99be..24f678a4 100644
--- a/pkg-py/src/shinyui/_input_action_button.py
+++ b/pkg-py/src/shinyui/_input_action_button.py
@@ -1,4 +1,4 @@
-"""UiInputActionButton — class-based input_action_button with a clicked accessor.
+"""input_action_button — class-based input_action_button with a clicked accessor.
 
 Demonstrates the ``__init_subclass__`` registration pattern: by declaring
 ``input_handler_name`` and ``_input_handler`` on the class, the handler is
@@ -27,7 +27,7 @@
 _MISSING = object()
 
 
-class UiInputActionButton(UiInput, Updatable):
+class input_action_button(UiInput, Updatable):  # noqa: N801
     """Server-readable button.
 
     ``input.()`` is an integer counter that starts at 0 and increments on
@@ -99,11 +99,3 @@ def update(
         if disabled is not _MISSING:
             kwargs["disabled"] = disabled
         _sui.update_action_button(self.id, session=sess, **kwargs)
-
-
-def input_action_button(
-    id: str,
-    label: TagChild,
-    **kwargs: Any,
-) -> UiInputActionButton:
-    return UiInputActionButton(id, label, **kwargs)
diff --git a/pkg-py/src/shinyui/_input_select.py b/pkg-py/src/shinyui/_input_select.py
index 11cdd602..ce95b97f 100644
--- a/pkg-py/src/shinyui/_input_select.py
+++ b/pkg-py/src/shinyui/_input_select.py
@@ -1,4 +1,4 @@
-"""UiInputSelect — class-based input_select."""
+"""input_select — class-based input_select."""
 
 from __future__ import annotations
 
@@ -22,7 +22,7 @@
 ]
 
 
-class UiInputSelect(UiInput, Updatable):
+class input_select(UiInput, Updatable):  # noqa: N801
     def __init__(
         self,
         id: str,
@@ -77,12 +77,3 @@ def update(
         if selected is not _MISSING:
             kwargs["selected"] = selected
         _sui.update_select(self.id, session=sess, **kwargs)
-
-
-def input_select(
-    id: str,
-    label: TagChild,
-    choices: SelectChoicesArg,
-    **kwargs: Any,
-) -> UiInputSelect:
-    return UiInputSelect(id, label, choices, **kwargs)
diff --git a/pkg-py/src/shinyui/_input_slider.py b/pkg-py/src/shinyui/_input_slider.py
index 751b77e7..6baebfde 100644
--- a/pkg-py/src/shinyui/_input_slider.py
+++ b/pkg-py/src/shinyui/_input_slider.py
@@ -1,4 +1,4 @@
-"""UiInputSlider — class-based input_slider with typed update() and value() accessor."""
+"""input_slider — class-based input_slider with typed update() and value() accessor."""
 
 from __future__ import annotations
 
@@ -13,7 +13,7 @@
 _MISSING = object()
 
 
-class UiInputSlider(UiInput, Updatable):
+class input_slider(UiInput, Updatable):  # noqa: N801
     def __init__(
         self,
         id: str,
@@ -97,14 +97,3 @@ def update(
         if label is not _MISSING:
             msg["label"] = label
         sess.send_input_message(self.id, msg)
-
-
-def input_slider(
-    id: str,
-    label: str,
-    min: float,
-    max: float,
-    value: float | tuple[float, float],
-    **kwargs: Any,
-) -> UiInputSlider:
-    return UiInputSlider(id, label, min, max, value, **kwargs)
diff --git a/pkg-py/src/shinyui/_output_code.py b/pkg-py/src/shinyui/_output_code.py
index 279eadce..60d79c74 100644
--- a/pkg-py/src/shinyui/_output_code.py
+++ b/pkg-py/src/shinyui/_output_code.py
@@ -1,4 +1,4 @@
-"""UiOutputCode — class-based output_code."""
+"""output_code — class-based output_code."""
 
 from __future__ import annotations
 
@@ -7,7 +7,7 @@
 from ._roles import UiOutput
 
 
-class UiOutputCode(UiOutput):
+class output_code(UiOutput):  # noqa: N801
     def __init__(self, id: str, *, placeholder: bool = True) -> None:
         self.id = id
         self.placeholder = placeholder
@@ -17,7 +17,3 @@ def tagify(self) -> Tag:
         import shiny.ui as _sui
 
         return _sui.output_code(self.id, placeholder=self.placeholder)
-
-
-def output_code(id: str, *, placeholder: bool = True) -> UiOutputCode:
-    return UiOutputCode(id, placeholder=placeholder)
diff --git a/pkg-py/src/shinyui/_output_plot.py b/pkg-py/src/shinyui/_output_plot.py
index f28d4783..2984e017 100644
--- a/pkg-py/src/shinyui/_output_plot.py
+++ b/pkg-py/src/shinyui/_output_plot.py
@@ -1,14 +1,14 @@
-"""UiOutputPlot — output with read-only client-side interaction signals.
+"""output_plot — output with read-only client-side interaction signals.
 
 Derived input ids (the four that ``shiny.ui.output_plot`` actually pushes):
 
   ===================  ============================================
   Wire id              Accessor (reactive read)
   ===================  ============================================
-  input._click     :meth:`UiOutputPlot.click_value`
-  input._dblclick  :meth:`UiOutputPlot.dbl_value`
-  input._hover     :meth:`UiOutputPlot.hover_value`
-  input._brush     :meth:`UiOutputPlot.brush_value`
+  input._click     :meth:`output_plot.click_value`
+  input._dblclick  :meth:`output_plot.dbl_value`
+  input._hover     :meth:`output_plot.hover_value`
+  input._brush     :meth:`output_plot.brush_value`
   ===================  ============================================
 
 No HasInputValue, no Updatable. Derived inputs flow through Shiny's
@@ -31,7 +31,7 @@
 from ._roles import UiOutput
 
 
-class UiOutputPlot(UiOutput):
+class output_plot(UiOutput):  # noqa: N801
     def __init__(
         self,
         id: str,
@@ -86,7 +86,3 @@ def tagify(self) -> Tag:
             brush=self.brush_enabled,
             fill=self.fill,
         )
-
-
-def output_plot(id: str, **kwargs: Any) -> UiOutputPlot:
-    return UiOutputPlot(id, **kwargs)
diff --git a/pkg-py/tests/shinyui/test_accordion.py b/pkg-py/tests/shinyui/test_accordion.py
index e1f63d24..d2d3bad1 100644
--- a/pkg-py/tests/shinyui/test_accordion.py
+++ b/pkg-py/tests/shinyui/test_accordion.py
@@ -2,7 +2,7 @@
 
 import pytest
 from shiny import reactive
-from shinyui._accordion import UiAccordion, accordion
+from shinyui._accordion import accordion
 from shinyui._accordion_panel import accordion_panel
 from shinyui._children import AllowsChildren
 from shinyui._input_value import HasInputValue
@@ -11,7 +11,7 @@
 
 def test_factory_returns_instance():
     a = accordion(accordion_panel("A"), accordion_panel("B"), id="acc")
-    assert isinstance(a, UiAccordion)
+    assert isinstance(a, accordion)
     assert isinstance(a, HasInputValue)
     assert isinstance(a, AllowsChildren)
     assert isinstance(a, Updatable)
diff --git a/pkg-py/tests/shinyui/test_accordion_panel.py b/pkg-py/tests/shinyui/test_accordion_panel.py
index f2c606d0..09fadd8a 100644
--- a/pkg-py/tests/shinyui/test_accordion_panel.py
+++ b/pkg-py/tests/shinyui/test_accordion_panel.py
@@ -2,13 +2,13 @@
 
 import shiny.ui as sui
 from htmltools import tags
-from shinyui._accordion_panel import UiAccordionPanel, accordion_panel
+from shinyui._accordion_panel import accordion_panel
 from shinyui._children import AllowsChildren
 
 
 def test_factory_returns_instance():
     p = accordion_panel("Settings", "body")
-    assert isinstance(p, UiAccordionPanel)
+    assert isinstance(p, accordion_panel)
     assert isinstance(p, AllowsChildren)
 
 
diff --git a/pkg-py/tests/shinyui/test_card.py b/pkg-py/tests/shinyui/test_card.py
index f0d4ef10..921d2c9c 100644
--- a/pkg-py/tests/shinyui/test_card.py
+++ b/pkg-py/tests/shinyui/test_card.py
@@ -3,7 +3,7 @@
 import pytest
 import shiny.ui as sui
 from shiny import reactive
-from shinyui._card import UiCard, card
+from shinyui._card import card
 from shinyui._children import AllowsChildren
 from shinyui._input_value import HasInputValue
 from shinyui._updatable import Updatable
@@ -11,7 +11,7 @@
 
 def test_factory_returns_instance():
     c = card("body", id="main")
-    assert isinstance(c, UiCard)
+    assert isinstance(c, card)
     assert isinstance(c, HasInputValue)
     assert isinstance(c, AllowsChildren)
     assert isinstance(c, Updatable)
diff --git a/pkg-py/tests/shinyui/test_hierarchy.py b/pkg-py/tests/shinyui/test_hierarchy.py
index cbb6761a..696df42c 100644
--- a/pkg-py/tests/shinyui/test_hierarchy.py
+++ b/pkg-py/tests/shinyui/test_hierarchy.py
@@ -6,31 +6,31 @@
 
 def _maker(cls):
     """Build a representative instance of `cls` with whatever args its factory needs."""
-    if cls is sui.UiInputSlider:
+    if cls is sui.input_slider:
         return sui.input_slider("n", "N", 1, 10, 5)
-    if cls is sui.UiInputSelect:
+    if cls is sui.input_select:
         return sui.input_select("c", "C", {"a": "A"})
-    if cls is sui.UiOutputCode:
+    if cls is sui.output_code:
         return sui.output_code("o")
-    if cls is sui.UiOutputPlot:
+    if cls is sui.output_plot:
         return sui.output_plot("p")
-    if cls is sui.UiCard:
+    if cls is sui.card:
         return sui.card("b", id="m")
-    if cls is sui.UiAccordion:
+    if cls is sui.accordion:
         return sui.accordion(sui.accordion_panel("A"), id="acc")
-    if cls is sui.UiAccordionPanel:
+    if cls is sui.accordion_panel:
         return sui.accordion_panel("X", "y")
     raise AssertionError(f"no maker for {cls}")
 
 
 ALL_CLASSES = [
-    sui.UiInputSlider,
-    sui.UiInputSelect,
-    sui.UiOutputCode,
-    sui.UiOutputPlot,
-    sui.UiCard,
-    sui.UiAccordion,
-    sui.UiAccordionPanel,
+    sui.input_slider,
+    sui.input_select,
+    sui.output_code,
+    sui.output_plot,
+    sui.card,
+    sui.accordion,
+    sui.accordion_panel,
 ]
 
 
@@ -42,19 +42,19 @@ def test_is_uicomponent(cls):
 @pytest.mark.parametrize(
     "cls,expected",
     [
-        (sui.UiInputSlider, {sui.UiInput, sui.HasInputValue, sui.Updatable}),
-        (sui.UiInputSelect, {sui.UiInput, sui.HasInputValue, sui.Updatable}),
-        (sui.UiOutputCode, {sui.UiOutput}),
-        (sui.UiOutputPlot, {sui.UiOutput}),
+        (sui.input_slider, {sui.UiInput, sui.HasInputValue, sui.Updatable}),
+        (sui.input_select, {sui.UiInput, sui.HasInputValue, sui.Updatable}),
+        (sui.output_code, {sui.UiOutput}),
+        (sui.output_plot, {sui.UiOutput}),
         (
-            sui.UiCard,
+            sui.card,
             {sui.UiLayout, sui.AllowsChildren, sui.HasInputValue, sui.Updatable},
         ),
         (
-            sui.UiAccordion,
+            sui.accordion,
             {sui.UiLayout, sui.AllowsChildren, sui.HasInputValue, sui.Updatable},
         ),
-        (sui.UiAccordionPanel, {sui.UiLayout, sui.AllowsChildren}),
+        (sui.accordion_panel, {sui.UiLayout, sui.AllowsChildren}),
     ],
 )
 def test_expected_bases(cls, expected):
@@ -68,13 +68,13 @@ def test_expected_bases(cls, expected):
 @pytest.mark.parametrize(
     "cls,allows_children",
     [
-        (sui.UiInputSlider, False),
-        (sui.UiInputSelect, False),
-        (sui.UiOutputCode, False),
-        (sui.UiOutputPlot, False),
-        (sui.UiCard, True),
-        (sui.UiAccordion, True),
-        (sui.UiAccordionPanel, True),
+        (sui.input_slider, False),
+        (sui.input_select, False),
+        (sui.output_code, False),
+        (sui.output_plot, False),
+        (sui.card, True),
+        (sui.accordion, True),
+        (sui.accordion_panel, True),
     ],
 )
 def test_with_block_protocol(cls, allows_children):
diff --git a/pkg-py/tests/shinyui/test_input_action_button.py b/pkg-py/tests/shinyui/test_input_action_button.py
index 232e7897..028361c0 100644
--- a/pkg-py/tests/shinyui/test_input_action_button.py
+++ b/pkg-py/tests/shinyui/test_input_action_button.py
@@ -4,12 +4,12 @@
 import shiny.ui as sui
 from shiny import reactive
 from shiny.input_handler import input_handlers
-from shinyui._input_action_button import UiInputActionButton, input_action_button
+from shinyui._input_action_button import input_action_button
 
 
 def test_factory_returns_instance():
     b = input_action_button("go", "Go")
-    assert isinstance(b, UiInputActionButton)
+    assert isinstance(b, input_action_button)
     assert b.id == "go"
 
 
@@ -44,13 +44,13 @@ def test_input_handler_auto_registered_via_init_subclass():
     """The class is registered under 'shinyui.action' at class-definition
     time via the _InputHandlerAutoRegister mixin's __init_subclass__ hook.
     """
-    assert UiInputActionButton.input_handler_name == "shinyui.action"
+    assert input_action_button.input_handler_name == "shinyui.action"
     # `input_handlers` is dict-like.
     assert "shinyui.action" in input_handlers
 
 
 def test_input_handler_coerces_to_int():
-    h = UiInputActionButton._input_handler
+    h = input_action_button._input_handler
     assert h(None, None, None) == 0
     assert h(3, None, None) == 3
     assert h("5", None, None) == 5
diff --git a/pkg-py/tests/shinyui/test_input_handler_registration.py b/pkg-py/tests/shinyui/test_input_handler_registration.py
index 2e49289e..1b3cf1a1 100644
--- a/pkg-py/tests/shinyui/test_input_handler_registration.py
+++ b/pkg-py/tests/shinyui/test_input_handler_registration.py
@@ -2,7 +2,7 @@
 
 Most shinyui classes do NOT declare a custom input handler — shiny's
 built-in bindings handle the wire format. The one exception is
-UiInputActionButton, which carries a "shinyui.action" handler purely
+input_action_button, which carries a "shinyui.action" handler purely
 to demonstrate the __init_subclass__ auto-registration pattern (see
 the module docstring on _input_action_button.py for the trade-off).
 """
@@ -16,10 +16,10 @@
 @pytest.mark.parametrize(
     "cls",
     [
-        sui.UiInputSlider,
-        sui.UiInputSelect,
-        sui.UiCard,
-        sui.UiAccordion,
+        sui.input_slider,
+        sui.input_select,
+        sui.card,
+        sui.accordion,
     ],
 )
 def test_class_declares_no_custom_handler(cls):
@@ -29,6 +29,6 @@ def test_class_declares_no_custom_handler(cls):
 
 
 def test_action_button_declares_custom_handler():
-    """UiInputActionButton ships a 'shinyui.action' handler via __init_subclass__."""
-    assert sui.UiInputActionButton.input_handler_name == "shinyui.action"
-    assert sui.UiInputActionButton._input_handler is not None
+    """input_action_button ships a 'shinyui.action' handler via __init_subclass__."""
+    assert sui.input_action_button.input_handler_name == "shinyui.action"
+    assert sui.input_action_button._input_handler is not None
diff --git a/pkg-py/tests/shinyui/test_input_select.py b/pkg-py/tests/shinyui/test_input_select.py
index 78075d95..48927588 100644
--- a/pkg-py/tests/shinyui/test_input_select.py
+++ b/pkg-py/tests/shinyui/test_input_select.py
@@ -3,12 +3,12 @@
 import pytest
 import shiny.ui as sui
 from shiny import reactive
-from shinyui._input_select import UiInputSelect, input_select
+from shinyui._input_select import input_select
 
 
 def test_factory_returns_instance():
     s = input_select("col", "Column", {"a": "A", "b": "B"})
-    assert isinstance(s, UiInputSelect)
+    assert isinstance(s, input_select)
 
 
 def test_tagify_matches_shiny_ui_input_select():
diff --git a/pkg-py/tests/shinyui/test_input_slider.py b/pkg-py/tests/shinyui/test_input_slider.py
index a23cfafd..25718890 100644
--- a/pkg-py/tests/shinyui/test_input_slider.py
+++ b/pkg-py/tests/shinyui/test_input_slider.py
@@ -3,12 +3,12 @@
 import pytest
 import shiny.ui as sui
 from shiny import reactive
-from shinyui._input_slider import UiInputSlider, input_slider
+from shinyui._input_slider import input_slider
 
 
 def test_factory_returns_instance():
     s = input_slider("n", "N", 1, 100, 50)
-    assert isinstance(s, UiInputSlider)
+    assert isinstance(s, input_slider)
     assert s.id == "n"
 
 
@@ -28,7 +28,7 @@ def test_value_accessor_reads_input(mock_session):
 
 def test_update_outside_session_raises():
     s = input_slider("n", "N", 1, 100, 50)  # no session
-    match = r"UiInputSlider\.update\(\) requires an active session"
+    match = r"input_slider\.update\(\) requires an active session"
     with pytest.raises(RuntimeError, match=match):
         s.update(value=42)
 
diff --git a/pkg-py/tests/shinyui/test_output_code.py b/pkg-py/tests/shinyui/test_output_code.py
index e4e89ac9..694c0fe6 100644
--- a/pkg-py/tests/shinyui/test_output_code.py
+++ b/pkg-py/tests/shinyui/test_output_code.py
@@ -1,12 +1,12 @@
 from __future__ import annotations
 
 import shiny.ui as sui
-from shinyui._output_code import UiOutputCode, output_code
+from shinyui._output_code import output_code
 
 
 def test_factory_returns_instance():
     o = output_code("summary")
-    assert isinstance(o, UiOutputCode)
+    assert isinstance(o, output_code)
     assert o.id == "summary"
 
 
diff --git a/pkg-py/tests/shinyui/test_output_plot.py b/pkg-py/tests/shinyui/test_output_plot.py
index 56c87db4..033b2ee5 100644
--- a/pkg-py/tests/shinyui/test_output_plot.py
+++ b/pkg-py/tests/shinyui/test_output_plot.py
@@ -2,12 +2,12 @@
 
 import shiny.ui as sui
 from shiny import reactive
-from shinyui._output_plot import UiOutputPlot, output_plot
+from shinyui._output_plot import output_plot
 
 
 def test_factory_returns_instance():
     p = output_plot("p", click=True, brush=True)
-    assert isinstance(p, UiOutputPlot)
+    assert isinstance(p, output_plot)
     assert p.id == "p"
 
 
diff --git a/pkg-py/tests/shinyui/test_public_exports.py b/pkg-py/tests/shinyui/test_public_exports.py
index 8a116b7e..4aaade2b 100644
--- a/pkg-py/tests/shinyui/test_public_exports.py
+++ b/pkg-py/tests/shinyui/test_public_exports.py
@@ -1,19 +1,17 @@
 def test_public_exports():
     import shinyui as sui
 
-    # Class names
+    # Base/mixin class names (PascalCase, same as shiny.render.Renderer)
     assert sui.UiComponent
     assert sui.UiInput and sui.UiOutput and sui.UiLayout
     assert sui.HasInputValue and sui.Updatable and sui.AllowsChildren
-    assert sui.UiInputSlider and sui.UiInputSelect
-    assert sui.UiOutputCode and sui.UiOutputPlot
-    assert sui.UiCard and sui.UiAccordion and sui.UiAccordionPanel
 
-    # Factory names
-    assert callable(sui.input_slider)
-    assert callable(sui.input_select)
-    assert callable(sui.output_code)
-    assert callable(sui.output_plot)
-    assert callable(sui.card)
-    assert callable(sui.accordion)
-    assert callable(sui.accordion_panel)
+    # Concrete classes (snake_case, same as shiny.render.data_frame)
+    assert isinstance(sui.input_slider, type)
+    assert isinstance(sui.input_select, type)
+    assert isinstance(sui.input_action_button, type)
+    assert isinstance(sui.output_code, type)
+    assert isinstance(sui.output_plot, type)
+    assert isinstance(sui.card, type)
+    assert isinstance(sui.accordion, type)
+    assert isinstance(sui.accordion_panel, type)

From c8f569179485e6a698f071141297f0e5a6b7149b Mon Sep 17 00:00:00 2001
From: Barret Schloerke 
Date: Wed, 13 May 2026 16:49:46 -0400
Subject: [PATCH 39/45] feat(shinyui): add Express/Core overloads to container
 classes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The three classes with AllowsChildren (card, accordion, accordion_panel)
now expose two @overload signatures on __init__:

1. Express overload (no positional children) — listed first so IDEs
   prefer it when the user writes `with card(id=...) as c: ...`.
2. Core overload (positional children) — for inline construction like
   `card(child_a, child_b, id=...)`.

The runtime __init__ is unchanged; the overloads are pure type hints
for IDE / pyright consumption, mirroring the umbrella spec's sub-issue
2 plan for Core/Express signature unification.
---
 pkg-py/src/shinyui/_accordion.py       | 28 +++++++++++++++++++++-
 pkg-py/src/shinyui/_accordion_panel.py | 22 ++++++++++++++++++
 pkg-py/src/shinyui/_card.py            | 32 +++++++++++++++++++++++++-
 3 files changed, 80 insertions(+), 2 deletions(-)

diff --git a/pkg-py/src/shinyui/_accordion.py b/pkg-py/src/shinyui/_accordion.py
index 55766d7a..c2cf6977 100644
--- a/pkg-py/src/shinyui/_accordion.py
+++ b/pkg-py/src/shinyui/_accordion.py
@@ -7,7 +7,7 @@
 
 from __future__ import annotations
 
-from typing import Any, Optional
+from typing import Any, Optional, overload
 
 from htmltools import Tag
 
@@ -28,6 +28,32 @@ class accordion(UiLayout, AllowsChildren, HasInputValue, Updatable):  # noqa: N8
     the wire format.  open_panels() coerces the received list to a tuple at read time.
     """
 
+    # Express overload: `with accordion(id="acc"): accordion_panel(...)`.
+    @overload
+    def __init__(
+        self,
+        *,
+        id: str,
+        open: Optional[str | tuple[str, ...] | bool] = None,
+        multiple: bool = True,
+        class_: Optional[str] = None,
+        width: Optional[str] = None,
+        height: Optional[str] = None,
+    ) -> None: ...
+
+    # Core overload: `accordion(panel_a, panel_b, id="acc", open="A")`.
+    @overload
+    def __init__(
+        self,
+        *args: accordion_panel,
+        id: str,
+        open: Optional[str | tuple[str, ...] | bool] = None,
+        multiple: bool = True,
+        class_: Optional[str] = None,
+        width: Optional[str] = None,
+        height: Optional[str] = None,
+    ) -> None: ...
+
     def __init__(
         self,
         *args: accordion_panel,
diff --git a/pkg-py/src/shinyui/_accordion_panel.py b/pkg-py/src/shinyui/_accordion_panel.py
index 52a2f695..daec7e67 100644
--- a/pkg-py/src/shinyui/_accordion_panel.py
+++ b/pkg-py/src/shinyui/_accordion_panel.py
@@ -2,6 +2,8 @@
 
 from __future__ import annotations
 
+from typing import overload
+
 from htmltools import TagChild
 from shiny.types import MISSING, MISSING_TYPE
 from shiny.ui._accordion import AccordionPanel
@@ -11,6 +13,26 @@
 
 
 class accordion_panel(UiLayout, AllowsChildren):  # noqa: N801
+    # Express overload: `with accordion_panel("Settings"): input_slider(...)`.
+    @overload
+    def __init__(
+        self,
+        title: str,
+        *,
+        value: str | MISSING_TYPE = MISSING,
+        icon: TagChild | None = None,
+    ) -> None: ...
+
+    # Core overload: `accordion_panel("Settings", input_slider(...), ...)`.
+    @overload
+    def __init__(
+        self,
+        title: str,
+        *args: TagChild,
+        value: str | MISSING_TYPE = MISSING,
+        icon: TagChild | None = None,
+    ) -> None: ...
+
     def __init__(
         self,
         title: str,
diff --git a/pkg-py/src/shinyui/_card.py b/pkg-py/src/shinyui/_card.py
index 145518b1..24c31920 100644
--- a/pkg-py/src/shinyui/_card.py
+++ b/pkg-py/src/shinyui/_card.py
@@ -13,7 +13,7 @@
 
 from __future__ import annotations
 
-from typing import Any, Optional
+from typing import Any, Optional, overload
 
 from htmltools import Tag, TagChild
 
@@ -34,6 +34,36 @@ class card(UiLayout, AllowsChildren, HasInputValue, Updatable):  # noqa: N801
     input keyed by self.id (mocked in tests).
     """
 
+    # Express overload: `with card(id="m"): child_a; child_b` — no positional
+    # children. Listed first so IDEs prefer it for the `with ...:` idiom.
+    @overload
+    def __init__(
+        self,
+        *,
+        id: str,
+        full_screen: bool = False,
+        height: Optional[str] = None,
+        max_height: Optional[str] = None,
+        min_height: Optional[str] = None,
+        fill: bool = True,
+        class_: Optional[str] = None,
+    ) -> None: ...
+
+    # Core overload: `card(child_a, child_b, id="m", ...)` — inline positional
+    # children, the classic Shiny Core pattern.
+    @overload
+    def __init__(
+        self,
+        *args: TagChild,
+        id: str,
+        full_screen: bool = False,
+        height: Optional[str] = None,
+        max_height: Optional[str] = None,
+        min_height: Optional[str] = None,
+        fill: bool = True,
+        class_: Optional[str] = None,
+    ) -> None: ...
+
     def __init__(
         self,
         *args: TagChild,

From 0aac28a617f2a56e1ce51024afb1534d97ca6995 Mon Sep 17 00:00:00 2001
From: Barret Schloerke 
Date: Thu, 14 May 2026 10:53:53 -0400
Subject: [PATCH 40/45] docs(shinyui): add class + __init__ docstrings to
 concrete UI classes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Each of the eight concrete classes (input_slider, input_select,
input_action_button, output_code, output_plot, card, accordion,
accordion_panel) now ships:

- A class-level docstring naming the wire id, the matching class
  accessor (if any), and a small idiomatic Example block.
- An __init__ docstring documenting id/label (or title), the key
  shape parameters, and a pointer to shiny.ui. for the
  long tail of pass-through kwargs.

Pure documentation — no signature or runtime behaviour changes.
input_slider already had this treatment; the others now match its
template.
---
 pkg-py/src/shinyui/_accordion.py           | 45 +++++++++++++++++--
 pkg-py/src/shinyui/_accordion_panel.py     | 36 ++++++++++++++++
 pkg-py/src/shinyui/_card.py                | 44 +++++++++++++++++--
 pkg-py/src/shinyui/_input_action_button.py | 42 +++++++++++++++---
 pkg-py/src/shinyui/_input_select.py        | 42 ++++++++++++++++++
 pkg-py/src/shinyui/_output_code.py         | 28 ++++++++++++
 pkg-py/src/shinyui/_output_plot.py         | 50 ++++++++++++++++++++++
 7 files changed, 275 insertions(+), 12 deletions(-)

diff --git a/pkg-py/src/shinyui/_accordion.py b/pkg-py/src/shinyui/_accordion.py
index c2cf6977..35be7555 100644
--- a/pkg-py/src/shinyui/_accordion.py
+++ b/pkg-py/src/shinyui/_accordion.py
@@ -22,10 +22,29 @@
 
 
 class accordion(UiLayout, AllowsChildren, HasInputValue, Updatable):  # noqa: N801
-    """Accordion container; open-panel set is available via open_panels().
+    """Accordion container with collapsible panels.
 
-    No custom input handler is registered — shiny's own accordion binding handles
-    the wire format.  open_panels() coerces the received list to a tuple at read time.
+    Wire id: ``input.()`` is a list of the currently-open panel values,
+    pushed by shiny's accordion binding. The class accessor :meth:`open_panels`
+    returns the same set as a ``tuple[str, ...]``.
+
+    Example
+    -------
+    .. code-block:: python
+
+        acc = accordion(
+            accordion_panel("Settings", input_slider("n", "N", 1, 10, 5)),
+            accordion_panel("Diagnostics", output_code("diag")),
+            id="acc",
+            open="Settings",
+        )
+
+        # In server:
+        acc.open_panels()               # tuple of open panel values
+
+        # Push open/closed state from the server:
+        acc.update(open=("Settings", "Diagnostics"))
+        acc.update(open=False)          # close all
     """
 
     # Express overload: `with accordion(id="acc"): accordion_panel(...)`.
@@ -64,6 +83,26 @@ def __init__(
         width: Optional[str] = None,
         height: Optional[str] = None,
     ) -> None:
+        """Build an accordion container.
+
+        Parameters
+        ----------
+        *args
+            Child :class:`accordion_panel` instances. Omit when using the
+            Express ``with accordion(id=...):`` context-manager pattern.
+        id
+            Input id; the open-panel list is available as ``input.()``
+            server-side, or via :meth:`open_panels`.
+        open
+            Initially open panel(s). Pass a string for a single panel, a
+            tuple for multiple, ``True`` to open all, or ``False`` to close all.
+            ``None`` delegates to shiny's default (first panel open).
+        multiple
+            Allow more than one panel to be open at a time.
+        class_, width, height
+            Forwarded verbatim to :func:`shiny.ui.accordion`; see shiny's docs
+            for semantics.
+        """
         self._open = open
         self.multiple = multiple
         self.class_ = class_
diff --git a/pkg-py/src/shinyui/_accordion_panel.py b/pkg-py/src/shinyui/_accordion_panel.py
index daec7e67..896142f9 100644
--- a/pkg-py/src/shinyui/_accordion_panel.py
+++ b/pkg-py/src/shinyui/_accordion_panel.py
@@ -13,6 +13,24 @@
 
 
 class accordion_panel(UiLayout, AllowsChildren):  # noqa: N801
+    """A single collapsible panel within an :class:`accordion`.
+
+    ``accordion_panel`` has no wire id of its own. The parent
+    :class:`accordion` identifies each panel by its ``value`` attribute (which
+    defaults to ``title`` when not supplied explicitly). Pass that string to
+    :meth:`accordion.update` to open or close a specific panel.
+
+    Example
+    -------
+    .. code-block:: python
+
+        accordion_panel("Settings", input_slider("seed", "Seed", 1, 100, 42))
+
+        # Express pattern:
+        with accordion_panel("Settings"):
+            input_slider("seed", "Seed", 1, 100, 42)
+    """
+
     # Express overload: `with accordion_panel("Settings"): input_slider(...)`.
     @overload
     def __init__(
@@ -40,6 +58,24 @@ def __init__(
         value: str | MISSING_TYPE = MISSING,
         icon: TagChild | None = None,
     ) -> None:
+        """Build an accordion panel.
+
+        Parameters
+        ----------
+        title
+            Panel header text. Also used as the panel's ``value`` identifier
+            when ``value`` is not supplied.
+        *args
+            Child elements (any ``TagChild``). Omit when using the Express
+            ``with accordion_panel(...):`` context-manager pattern.
+        value
+            String identifier for this panel within the accordion. Defaults to
+            ``title``. The parent :class:`accordion` uses this when reporting
+            which panels are open via ``input.()`` / :meth:`accordion.open_panels`.
+        icon
+            Optional icon displayed in the panel header. Forwarded to
+            :func:`shiny.ui.accordion_panel`.
+        """
         self.title = title
         self._value: str | MISSING_TYPE = value
         self.icon = icon
diff --git a/pkg-py/src/shinyui/_card.py b/pkg-py/src/shinyui/_card.py
index 24c31920..3b5b41eb 100644
--- a/pkg-py/src/shinyui/_card.py
+++ b/pkg-py/src/shinyui/_card.py
@@ -27,11 +27,31 @@
 
 
 class card(UiLayout, AllowsChildren, HasInputValue, Updatable):  # noqa: N801
-    """Card container; full-screen state is available via full_screen_value().
+    """Card container with optional full-screen toggle.
 
-    No custom input handler is registered — shiny's own card binding handles
-    the wire format when client JS is present. full_screen_value() reads the
-    input keyed by self.id (mocked in tests).
+    Wire id: ``input._full_screen`` is a boolean pushed by shiny's card
+    binding when the user toggles full-screen mode. The class accessor
+    :meth:`full_screen_value` returns the same.
+
+    **Prototype note:** the browser-side JS that pushes ``full_screen`` is out
+    of scope for the Stage A prototype. :meth:`full_screen_value` and
+    :meth:`update` work correctly under mocked sessions and in unit tests, but
+    in a live app the value stays ``False`` until the client-side JS lands in
+    Stage B.
+
+    Example
+    -------
+    .. code-block:: python
+
+        c = card(output_code("summary"), id="main", full_screen=True)
+
+        # In server:
+        @reactive.calc
+        def is_full():
+            return c.full_screen_value()
+
+        # Push a state change from the server:
+        c.update(full_screen=False)
     """
 
     # Express overload: `with card(id="m"): child_a; child_b` — no positional
@@ -75,6 +95,22 @@ def __init__(
         fill: bool = True,
         class_: Optional[str] = None,
     ) -> None:
+        """Build a card container.
+
+        Parameters
+        ----------
+        *args
+            Child elements (any ``TagChild``). Omit when using the Express
+            ``with card(id=...):`` context-manager pattern.
+        id
+            Input id used to read ``input._full_screen`` via
+            :meth:`full_screen_value`.
+        full_screen
+            Initial full-screen state rendered into the HTML.
+        height, max_height, min_height, fill, class_
+            Forwarded verbatim to :func:`shiny.ui.card`; see shiny's docs for
+            semantics.
+        """
         self._full_screen = full_screen
         self.height = height
         self.max_height = max_height
diff --git a/pkg-py/src/shinyui/_input_action_button.py b/pkg-py/src/shinyui/_input_action_button.py
index 24f678a4..31e13b2f 100644
--- a/pkg-py/src/shinyui/_input_action_button.py
+++ b/pkg-py/src/shinyui/_input_action_button.py
@@ -28,12 +28,29 @@
 
 
 class input_action_button(UiInput, Updatable):  # noqa: N801
-    """Server-readable button.
+    """Server-readable action button.
 
-    ``input.()`` is an integer counter that starts at 0 and increments on
-    each click. The class accessor :meth:`clicked` returns the current value as
-    a reactive read; pair with :func:`shiny.reactive.event` to run code on each
-    click without firing on the initial value.
+    Wire id: ``input.()`` is an integer click counter that starts at ``0``
+    and increments on each click. The class accessor :meth:`clicked` returns
+    the same value as a reactive read.
+
+    Pair :meth:`clicked` with :func:`shiny.reactive.event` and
+    ``ignore_init=True`` to respond only to real clicks, not the initial
+    ``0`` value registered at page load.
+
+    Example
+    -------
+    .. code-block:: python
+
+        go = input_action_button("go", "Run")
+
+        # In server:
+        @reactive.event(go.clicked, ignore_init=True)
+        def _on_click():
+            ...
+
+        # Push label / disabled state from the server:
+        go.update(label="Running...", disabled=True)
     """
 
     # Auto-registered via HasInputValue.__init_subclass__ when this class
@@ -58,6 +75,21 @@ def __init__(
         width: Optional[str] = None,
         disabled: bool = False,
     ) -> None:
+        """Build an action button.
+
+        Parameters
+        ----------
+        id
+            Input id; available as ``input.()`` server-side, or via
+            :meth:`clicked`.
+        label
+            Button label text (or any ``TagChild``).
+        icon
+            Optional icon to display before the label.
+        width, disabled
+            Forwarded verbatim to :func:`shiny.ui.input_action_button`; see
+            shiny's docs for semantics.
+        """
         self.label = label
         self.icon = icon
         self.width = width
diff --git a/pkg-py/src/shinyui/_input_select.py b/pkg-py/src/shinyui/_input_select.py
index ce95b97f..1426fed0 100644
--- a/pkg-py/src/shinyui/_input_select.py
+++ b/pkg-py/src/shinyui/_input_select.py
@@ -23,6 +23,26 @@
 
 
 class input_select(UiInput, Updatable):  # noqa: N801
+    """Dropdown / multi-select input.
+
+    Wire id: ``input.()`` is the selected key string, or a ``list[str]``
+    when ``multiple=True``. The class accessor :meth:`value` returns the same.
+
+    Example
+    -------
+    .. code-block:: python
+
+        c = input_select("c", "Column", {"a": "Alpha", "b": "Beta"})
+
+        # In server:
+        @render.code
+        def summary():
+            return f"column = {c.value()}"
+
+        # Push a new selection from the server:
+        c.update(selected="b")
+    """
+
     def __init__(
         self,
         id: str,
@@ -34,6 +54,28 @@ def __init__(
         width: Optional[str] = None,
         size: Optional[str] = None,
     ) -> None:
+        """Build a select input.
+
+        Parameters
+        ----------
+        id
+            Input id; available as ``input.()`` server-side, or via
+            :meth:`value`.
+        label
+            Display label.
+        choices
+            Selectable options — a list/tuple of strings, a ``{value: label}``
+            mapping, or a nested ``{group: {value: label}}`` mapping for
+            option-group rendering.
+        selected
+            Initially selected value(s). ``None`` defaults to the first choice.
+            Pass a list when ``multiple=True``.
+        multiple
+            Allow multiple simultaneous selections.
+        width, size
+            Forwarded verbatim to :func:`shiny.ui.input_select`; see shiny's
+            docs for semantics.
+        """
         self.label = label
         self.choices = choices
         self._init_selected = selected
diff --git a/pkg-py/src/shinyui/_output_code.py b/pkg-py/src/shinyui/_output_code.py
index 60d79c74..66943f1d 100644
--- a/pkg-py/src/shinyui/_output_code.py
+++ b/pkg-py/src/shinyui/_output_code.py
@@ -8,7 +8,35 @@
 
 
 class output_code(UiOutput):  # noqa: N801
+    """Verbatim-text output placeholder.
+
+    No wire input value — this is a pure output element. The server populates
+    it by decorating a function with ``@render.code`` whose name matches ``id``.
+
+    Example
+    -------
+    .. code-block:: python
+
+        output_code("summary")
+
+        # In server:
+        @render.code
+        def summary():
+            return f"n = {n.value()}"
+    """
+
     def __init__(self, id: str, *, placeholder: bool = True) -> None:
+        """Build a verbatim-text output.
+
+        Parameters
+        ----------
+        id
+            Output id; must match a ``@render.code``-decorated function in the
+            server.
+        placeholder
+            Show a placeholder block in the UI before the server renders.
+            Forwarded to :func:`shiny.ui.output_code`.
+        """
         self.id = id
         self.placeholder = placeholder
         super().__init__()
diff --git a/pkg-py/src/shinyui/_output_plot.py b/pkg-py/src/shinyui/_output_plot.py
index 2984e017..af551f36 100644
--- a/pkg-py/src/shinyui/_output_plot.py
+++ b/pkg-py/src/shinyui/_output_plot.py
@@ -32,6 +32,35 @@
 
 
 class output_plot(UiOutput):  # noqa: N801
+    """Plot output with optional client-side interaction signals.
+
+    No primary ``input.()`` value. When interaction flags are enabled,
+    the browser pushes derived wire ids that are accessible via class
+    accessors:
+
+    =====================  ======================================
+    Wire id                Accessor
+    =====================  ======================================
+    ``input._click``   :meth:`click_value`
+    ``input._dblclick`` :meth:`dbl_value`
+    ``input._hover``   :meth:`hover_value`
+    ``input._brush``   :meth:`brush_value`
+    =====================  ======================================
+
+    Example
+    -------
+    .. code-block:: python
+
+        p = output_plot("plot", click=True, brush=True)
+
+        # In server:
+        @reactive.effect
+        def _():
+            coords = p.click_value()
+            if coords:
+                print(coords["x"], coords["y"])
+    """
+
     def __init__(
         self,
         id: str,
@@ -45,6 +74,27 @@ def __init__(
         brush: bool = False,
         fill: bool | MISSING_TYPE = MISSING,
     ) -> None:
+        """Build a plot output.
+
+        Parameters
+        ----------
+        id
+            Output id; must match a ``@render.plot``-decorated function in the
+            server.
+        width, height
+            CSS dimensions of the plot container.
+        click
+            Enable click interaction; read via :meth:`click_value`.
+        dblclick
+            Enable double-click interaction; read via :meth:`dbl_value`.
+        hover
+            Enable hover interaction; read via :meth:`hover_value`.
+        brush
+            Enable brush (drag-select) interaction; read via :meth:`brush_value`.
+        inline, fill
+            Forwarded verbatim to :func:`shiny.ui.output_plot`; see shiny's
+            docs for semantics.
+        """
         self.id = id
         self.width = width
         self.height = height

From a5f88f880ce062cca9011cc3d9ed4c66b529c095 Mon Sep 17 00:00:00 2001
From: Barret Schloerke 
Date: Thu, 14 May 2026 10:57:56 -0400
Subject: [PATCH 41/45] refactor(shinyui): accordion_panel.tagify() returns
 Tag; drop type:ignore
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Honor the UiComponent.tagify() -> Tag contract on accordion_panel by
chaining .tagify() on shiny's AccordionPanel wrapper. The previous
override that returned AccordionPanel (a Tagifiable, not a Tag) needed
a # type: ignore[override] annotation; that's now gone.

Two coupling points required care:

- shiny.ui.accordion does an explicit isinstance(panel, AccordionPanel)
  check on its positional args, so the parent accordion can't consume
  our rendered Tag. Add a small private helper _build_accordion_panel()
  that returns the AccordionPanel wrapper; the accordion's tagify()
  uses that helper instead of calling child.tagify().

- shiny's AccordionPanel.tagify() raises if _accordion_id is not set
  (normally written by the parent accordion). For standalone rendering
  (snapshot tests, ad-hoc inspection), stamp a placeholder id keyed off
  the panel's value before calling .tagify(). The parent path is
  unaffected — it gets a fresh AccordionPanel each time via the helper.

Snapshot test for accordion_panel switched from attribute comparison
(_title, _args, _data_value, _icon) to asserting the returned object
isinstance(Tag) with the title and body content present in the
rendered HTML.
---
 pkg-py/src/shinyui/_accordion.py             | 11 +++---
 pkg-py/src/shinyui/_accordion_panel.py       | 24 +++++++++++-
 pkg-py/src/shinyui/_input_slider.py          | 40 ++++++++++++++++++++
 pkg-py/tests/shinyui/test_accordion_panel.py | 21 +++++-----
 4 files changed, 79 insertions(+), 17 deletions(-)

diff --git a/pkg-py/src/shinyui/_accordion.py b/pkg-py/src/shinyui/_accordion.py
index 35be7555..66ed9ee4 100644
--- a/pkg-py/src/shinyui/_accordion.py
+++ b/pkg-py/src/shinyui/_accordion.py
@@ -119,11 +119,12 @@ def tagify(self) -> Tag:
         import shiny.ui as _sui
 
         # `shiny.ui.accordion` does an explicit isinstance(panel, AccordionPanel)
-        # check, so children must be pre-resolved (accordion_panel.tagify()
-        # returns an AccordionPanel). A single .tagify() on the result lets
-        # htmltools' walker resolve any remaining Tagifiable descendants
-        # inside the panels (e.g. an input_slider inside a panel).
-        panels: list = [child.tagify() for child in self.children]  # type: ignore[union-attr]
+        # check, so children must supply shiny's AccordionPanel wrapper rather
+        # than the rendered Tag. accordion_panel._build_accordion_panel() is
+        # the internal hook for that. A single .tagify() on the outer result
+        # lets htmltools' walker resolve any Tagifiable descendants inside
+        # the panels (e.g. an input_slider inside a panel).
+        panels = [child._build_accordion_panel() for child in self.children]  # type: ignore[union-attr]
         return _sui.accordion(
             *panels,
             id=self.id,
diff --git a/pkg-py/src/shinyui/_accordion_panel.py b/pkg-py/src/shinyui/_accordion_panel.py
index 896142f9..dfc03621 100644
--- a/pkg-py/src/shinyui/_accordion_panel.py
+++ b/pkg-py/src/shinyui/_accordion_panel.py
@@ -4,7 +4,7 @@
 
 from typing import overload
 
-from htmltools import TagChild
+from htmltools import Tag, TagChild
 from shiny.types import MISSING, MISSING_TYPE
 from shiny.ui._accordion import AccordionPanel
 
@@ -87,7 +87,12 @@ def value(self) -> str:
             return self.title
         return self._value
 
-    def tagify(self) -> AccordionPanel:  # type: ignore[override]
+    def _build_accordion_panel(self) -> AccordionPanel:
+        """Internal: produce shiny's ``AccordionPanel`` wrapper for the parent
+        :class:`accordion` to consume. ``shiny.ui.accordion`` does an explicit
+        ``isinstance(panel, AccordionPanel)`` check on its positional args, so
+        the parent reaches for this helper instead of calling :meth:`tagify`.
+        """
         import shiny.ui as _sui
 
         return _sui.accordion_panel(
@@ -96,3 +101,18 @@ def tagify(self) -> AccordionPanel:  # type: ignore[override]
             value=self._value,
             icon=self.icon,
         )
+
+    def tagify(self) -> Tag:
+        # Chain .tagify() on the AccordionPanel wrapper so we honor the
+        # UiComponent.tagify() -> Tag contract. The parent accordion doesn't
+        # call this directly (see _build_accordion_panel above).
+        #
+        # shiny's AccordionPanel.tagify() requires `_accordion_id` to be set,
+        # normally written by the parent `_sui.accordion(*panels)` call. For
+        # standalone rendering (e.g. snapshot tests, ad-hoc inspection) we
+        # stamp a placeholder so .tagify() works in isolation. The parent
+        # rendering path uses a separate AccordionPanel instance via
+        # `_build_accordion_panel()` and is unaffected.
+        panel = self._build_accordion_panel()
+        panel._accordion_id = f"_orphan_{self.value}"
+        return panel.tagify()
diff --git a/pkg-py/src/shinyui/_input_slider.py b/pkg-py/src/shinyui/_input_slider.py
index 6baebfde..967af57c 100644
--- a/pkg-py/src/shinyui/_input_slider.py
+++ b/pkg-py/src/shinyui/_input_slider.py
@@ -14,6 +14,27 @@
 
 
 class input_slider(UiInput, Updatable):  # noqa: N801
+    """Numeric slider input.
+
+    Wire id: ``input.()`` is the current slider value (a ``float``, or a
+    ``(min, max)`` tuple if ``value`` was passed as a 2-tuple — i.e. a
+    range-slider). The class accessor :meth:`value` returns the same.
+
+    Example
+    -------
+    .. code-block:: python
+
+        n = input_slider("n", "Sample size", 1, 1000, 100)
+
+        # In server:
+        @render.code
+        def summary():
+            return f"n = {n.value()}"
+
+        # Push a new value from the server:
+        n.update(value=500)
+    """
+
     def __init__(
         self,
         id: str,
@@ -33,6 +54,25 @@ def __init__(
         timezone: str | None = None,
         drag_range: bool = True,
     ) -> None:
+        """Build a slider.
+
+        Parameters
+        ----------
+        id
+            Input id; available as ``input.()`` server-side, or via
+            :meth:`value`.
+        label
+            Display label.
+        min, max
+            Inclusive slider range.
+        value
+            Initial value. Pass a ``(low, high)`` tuple for a range slider.
+        step
+            Minimum delta between adjacent values; ``None`` lets shiny pick.
+        ticks, animate, width, sep, pre, post, time_format, timezone, drag_range
+            Forwarded verbatim to :func:`shiny.ui.input_slider`; see shiny's
+            docs for semantics.
+        """
         self.label = label
         self.min = min
         self.max = max
diff --git a/pkg-py/tests/shinyui/test_accordion_panel.py b/pkg-py/tests/shinyui/test_accordion_panel.py
index 09fadd8a..252a636f 100644
--- a/pkg-py/tests/shinyui/test_accordion_panel.py
+++ b/pkg-py/tests/shinyui/test_accordion_panel.py
@@ -1,6 +1,5 @@
 from __future__ import annotations
 
-import shiny.ui as sui
 from htmltools import tags
 from shinyui._accordion_panel import accordion_panel
 from shinyui._children import AllowsChildren
@@ -17,16 +16,18 @@ def test_children_collected():
     assert "a" in p.children and "b" in p.children
 
 
-def test_tagify_matches_shiny():
-    # accordion_panel() returns an AccordionPanel (not a plain Tag).
-    # Compare key attributes that drive rendered output — random bslib panel IDs
-    # make full HTML string comparison non-deterministic.
+def test_tagify_returns_tag():
+    # accordion_panel.tagify() now returns a rendered Tag (chained .tagify()
+    # on shiny's AccordionPanel wrapper). The class stamps a placeholder
+    # _accordion_id so standalone rendering works outside a parent accordion.
+    from htmltools import Tag
+
     ours = accordion_panel("Settings", "body").tagify()
-    theirs = sui.accordion_panel("Settings", "body")
-    assert ours._title == theirs._title
-    assert ours._args == theirs._args
-    assert ours._data_value == theirs._data_value
-    assert ours._icon == theirs._icon
+    assert isinstance(ours, Tag)
+    # Sanity: rendered HTML contains the panel title and body content.
+    html = ours.get_html_string()
+    assert "Settings" in html
+    assert "body" in html
 
 
 def test_with_block_appends():

From 267318a065e15e4819ee76ef528ce8cb197a03e9 Mon Sep 17 00:00:00 2001
From: Barret Schloerke 
Date: Thu, 14 May 2026 10:59:36 -0400
Subject: [PATCH 42/45] docs(shinyui): move container docstrings onto @overload
 signatures

card, accordion, and accordion_panel each have an Express overload (no
positional children, used in 'with X():' blocks) and a Core overload
(inline positional children). Move the doc + Example block onto each
overload separately so IDE tooltips show the right idiom for the call
site. The implementation __init__ at the bottom of each class loses its
docstring (overload stubs carry the docs now).
---
 pkg-py/src/shinyui/_accordion.py       | 81 +++++++++++++++++++-------
 pkg-py/src/shinyui/_accordion_panel.py | 68 +++++++++++++++------
 pkg-py/src/shinyui/_card.py            | 73 ++++++++++++++++-------
 3 files changed, 164 insertions(+), 58 deletions(-)

diff --git a/pkg-py/src/shinyui/_accordion.py b/pkg-py/src/shinyui/_accordion.py
index 66ed9ee4..2cf55a4e 100644
--- a/pkg-py/src/shinyui/_accordion.py
+++ b/pkg-py/src/shinyui/_accordion.py
@@ -47,7 +47,7 @@ class accordion(UiLayout, AllowsChildren, HasInputValue, Updatable):  # noqa: N8
         acc.update(open=False)          # close all
     """
 
-    # Express overload: `with accordion(id="acc"): accordion_panel(...)`.
+    # Express overload — listed first so IDEs prefer it for `with ...:` idioms.
     @overload
     def __init__(
         self,
@@ -58,21 +58,38 @@ def __init__(
         class_: Optional[str] = None,
         width: Optional[str] = None,
         height: Optional[str] = None,
-    ) -> None: ...
+    ) -> None:
+        """Build an accordion as an Express context manager.
 
-    # Core overload: `accordion(panel_a, panel_b, id="acc", open="A")`.
-    @overload
-    def __init__(
-        self,
-        *args: accordion_panel,
-        id: str,
-        open: Optional[str | tuple[str, ...] | bool] = None,
-        multiple: bool = True,
-        class_: Optional[str] = None,
-        width: Optional[str] = None,
-        height: Optional[str] = None,
-    ) -> None: ...
+        Children come from the ``with`` block, not from positional args.
+
+        Example
+        -------
+        .. code-block:: python
+
+            with accordion(id="acc", open="Settings"):
+                accordion_panel("Settings", input_slider("n", "N", 1, 10, 5))
+                accordion_panel("Diagnostics", output_code("diag"))
 
+        Parameters
+        ----------
+        id
+            Input id; the open-panel list is available as ``input.()``
+            server-side, or via :meth:`open_panels`.
+        open
+            Initially open panel(s). Pass a string for a single panel, a
+            tuple for multiple, ``True`` to open all, or ``False`` to close
+            all. ``None`` delegates to shiny's default (first panel open).
+        multiple
+            Allow more than one panel to be open at a time.
+        class_, width, height
+            Forwarded verbatim to :func:`shiny.ui.accordion`; see shiny's
+            docs for semantics.
+        """
+        ...
+
+    # Core overload — inline positional :class:`accordion_panel` instances.
+    @overload
     def __init__(
         self,
         *args: accordion_panel,
@@ -83,26 +100,48 @@ def __init__(
         width: Optional[str] = None,
         height: Optional[str] = None,
     ) -> None:
-        """Build an accordion container.
+        """Build an accordion with inline positional panels.
+
+        Example
+        -------
+        .. code-block:: python
+
+            accordion(
+                accordion_panel("Settings", input_slider("n", "N", 1, 10, 5)),
+                accordion_panel("Diagnostics", output_code("diag")),
+                id="acc",
+                open="Settings",
+            )
 
         Parameters
         ----------
         *args
-            Child :class:`accordion_panel` instances. Omit when using the
-            Express ``with accordion(id=...):`` context-manager pattern.
+            Child :class:`accordion_panel` instances.
         id
             Input id; the open-panel list is available as ``input.()``
             server-side, or via :meth:`open_panels`.
         open
             Initially open panel(s). Pass a string for a single panel, a
-            tuple for multiple, ``True`` to open all, or ``False`` to close all.
-            ``None`` delegates to shiny's default (first panel open).
+            tuple for multiple, ``True`` to open all, or ``False`` to close
+            all. ``None`` delegates to shiny's default (first panel open).
         multiple
             Allow more than one panel to be open at a time.
         class_, width, height
-            Forwarded verbatim to :func:`shiny.ui.accordion`; see shiny's docs
-            for semantics.
+            Forwarded verbatim to :func:`shiny.ui.accordion`; see shiny's
+            docs for semantics.
         """
+        ...
+
+    def __init__(
+        self,
+        *args: accordion_panel,
+        id: str,
+        open: Optional[str | tuple[str, ...] | bool] = None,
+        multiple: bool = True,
+        class_: Optional[str] = None,
+        width: Optional[str] = None,
+        height: Optional[str] = None,
+    ) -> None:
         self._open = open
         self.multiple = multiple
         self.class_ = class_
diff --git a/pkg-py/src/shinyui/_accordion_panel.py b/pkg-py/src/shinyui/_accordion_panel.py
index dfc03621..d9a8bf97 100644
--- a/pkg-py/src/shinyui/_accordion_panel.py
+++ b/pkg-py/src/shinyui/_accordion_panel.py
@@ -31,7 +31,7 @@ class accordion_panel(UiLayout, AllowsChildren):  # noqa: N801
             input_slider("seed", "Seed", 1, 100, 42)
     """
 
-    # Express overload: `with accordion_panel("Settings"): input_slider(...)`.
+    # Express overload — listed first so IDEs prefer it for `with ...:` idioms.
     @overload
     def __init__(
         self,
@@ -39,18 +39,35 @@ def __init__(
         *,
         value: str | MISSING_TYPE = MISSING,
         icon: TagChild | None = None,
-    ) -> None: ...
+    ) -> None:
+        """Build an accordion panel as an Express context manager.
 
-    # Core overload: `accordion_panel("Settings", input_slider(...), ...)`.
-    @overload
-    def __init__(
-        self,
-        title: str,
-        *args: TagChild,
-        value: str | MISSING_TYPE = MISSING,
-        icon: TagChild | None = None,
-    ) -> None: ...
+        Children come from the ``with`` block, not from positional args.
+
+        Example
+        -------
+        .. code-block:: python
+
+            with accordion_panel("Settings"):
+                input_slider("seed", "Seed", 1, 100, 42)
+
+        Parameters
+        ----------
+        title
+            Panel header text. Also used as the panel's ``value`` identifier
+            when ``value`` is not supplied.
+        value
+            String identifier for this panel within the accordion. Defaults
+            to ``title``. The parent :class:`accordion` uses this when
+            reporting which panels are open.
+        icon
+            Optional icon displayed in the panel header. Forwarded to
+            :func:`shiny.ui.accordion_panel`.
+        """
+        ...
 
+    # Core overload — inline positional children.
+    @overload
     def __init__(
         self,
         title: str,
@@ -58,7 +75,16 @@ def __init__(
         value: str | MISSING_TYPE = MISSING,
         icon: TagChild | None = None,
     ) -> None:
-        """Build an accordion panel.
+        """Build an accordion panel with inline positional children.
+
+        Example
+        -------
+        .. code-block:: python
+
+            accordion_panel(
+                "Settings",
+                input_slider("seed", "Seed", 1, 100, 42),
+            )
 
         Parameters
         ----------
@@ -66,16 +92,24 @@ def __init__(
             Panel header text. Also used as the panel's ``value`` identifier
             when ``value`` is not supplied.
         *args
-            Child elements (any ``TagChild``). Omit when using the Express
-            ``with accordion_panel(...):`` context-manager pattern.
+            Child elements (any ``TagChild``).
         value
-            String identifier for this panel within the accordion. Defaults to
-            ``title``. The parent :class:`accordion` uses this when reporting
-            which panels are open via ``input.()`` / :meth:`accordion.open_panels`.
+            String identifier for this panel within the accordion. Defaults
+            to ``title``. The parent :class:`accordion` uses this when
+            reporting which panels are open.
         icon
             Optional icon displayed in the panel header. Forwarded to
             :func:`shiny.ui.accordion_panel`.
         """
+        ...
+
+    def __init__(
+        self,
+        title: str,
+        *args: TagChild,
+        value: str | MISSING_TYPE = MISSING,
+        icon: TagChild | None = None,
+    ) -> None:
         self.title = title
         self._value: str | MISSING_TYPE = value
         self.icon = icon
diff --git a/pkg-py/src/shinyui/_card.py b/pkg-py/src/shinyui/_card.py
index 3b5b41eb..347d9572 100644
--- a/pkg-py/src/shinyui/_card.py
+++ b/pkg-py/src/shinyui/_card.py
@@ -54,8 +54,7 @@ def is_full():
         c.update(full_screen=False)
     """
 
-    # Express overload: `with card(id="m"): child_a; child_b` — no positional
-    # children. Listed first so IDEs prefer it for the `with ...:` idiom.
+    # Express overload — listed first so IDEs prefer it for `with ...:` idioms.
     @overload
     def __init__(
         self,
@@ -67,23 +66,34 @@ def __init__(
         min_height: Optional[str] = None,
         fill: bool = True,
         class_: Optional[str] = None,
-    ) -> None: ...
+    ) -> None:
+        """Build a card as an Express context manager.
 
-    # Core overload: `card(child_a, child_b, id="m", ...)` — inline positional
-    # children, the classic Shiny Core pattern.
-    @overload
-    def __init__(
-        self,
-        *args: TagChild,
-        id: str,
-        full_screen: bool = False,
-        height: Optional[str] = None,
-        max_height: Optional[str] = None,
-        min_height: Optional[str] = None,
-        fill: bool = True,
-        class_: Optional[str] = None,
-    ) -> None: ...
+        Children come from the ``with`` block, not from positional args.
+
+        Example
+        -------
+        .. code-block:: python
+
+            with card(id="main", full_screen=True):
+                output_code("summary")
+                output_plot("plot", click=True)
 
+        Parameters
+        ----------
+        id
+            Input id used to read ``input._full_screen`` via
+            :meth:`full_screen_value`.
+        full_screen
+            Initial full-screen state rendered into the HTML.
+        height, max_height, min_height, fill, class_
+            Forwarded verbatim to :func:`shiny.ui.card`; see shiny's docs for
+            semantics.
+        """
+        ...
+
+    # Core overload — inline positional children, the classic Shiny Core pattern.
+    @overload
     def __init__(
         self,
         *args: TagChild,
@@ -95,13 +105,23 @@ def __init__(
         fill: bool = True,
         class_: Optional[str] = None,
     ) -> None:
-        """Build a card container.
+        """Build a card with inline positional children.
+
+        Example
+        -------
+        .. code-block:: python
+
+            card(
+                output_code("summary"),
+                output_plot("plot", click=True),
+                id="main",
+                full_screen=True,
+            )
 
         Parameters
         ----------
         *args
-            Child elements (any ``TagChild``). Omit when using the Express
-            ``with card(id=...):`` context-manager pattern.
+            Child elements (any ``TagChild``).
         id
             Input id used to read ``input._full_screen`` via
             :meth:`full_screen_value`.
@@ -111,6 +131,19 @@ def __init__(
             Forwarded verbatim to :func:`shiny.ui.card`; see shiny's docs for
             semantics.
         """
+        ...
+
+    def __init__(
+        self,
+        *args: TagChild,
+        id: str,
+        full_screen: bool = False,
+        height: Optional[str] = None,
+        max_height: Optional[str] = None,
+        min_height: Optional[str] = None,
+        fill: bool = True,
+        class_: Optional[str] = None,
+    ) -> None:
         self._full_screen = full_screen
         self.height = height
         self.max_height = max_height

From 6dc09789ff21c9c67ced1ddb283436bfbbb812fa Mon Sep 17 00:00:00 2001
From: Barret Schloerke 
Date: Thu, 14 May 2026 11:06:05 -0400
Subject: [PATCH 43/45] refactor(shinyui): accordion_panel.tagify() returns
 Tag; drop _build_accordion_panel helper
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Honor the UiComponent.tagify() -> Tag contract on accordion_panel by
chaining .tagify() on shiny's AccordionPanel wrapper. Stamp a placeholder
_accordion_id so standalone .tagify() works outside a parent accordion
(shiny's AccordionPanel.tagify() raises if _accordion_id is unset).

The parent accordion now builds AccordionPanel wrappers inline from each
child's stored title/children/_value/icon — rather than calling a helper
method on the child — since shiny.ui.accordion does an isinstance check
on positional args and rejects rendered Tags. No _build_accordion_panel
indirection.
---
 pkg-py/src/shinyui/_accordion.py       | 20 +++++++++++-----
 pkg-py/src/shinyui/_accordion_panel.py | 32 +++++++++-----------------
 2 files changed, 25 insertions(+), 27 deletions(-)

diff --git a/pkg-py/src/shinyui/_accordion.py b/pkg-py/src/shinyui/_accordion.py
index 2cf55a4e..41b90a01 100644
--- a/pkg-py/src/shinyui/_accordion.py
+++ b/pkg-py/src/shinyui/_accordion.py
@@ -158,12 +158,20 @@ def tagify(self) -> Tag:
         import shiny.ui as _sui
 
         # `shiny.ui.accordion` does an explicit isinstance(panel, AccordionPanel)
-        # check, so children must supply shiny's AccordionPanel wrapper rather
-        # than the rendered Tag. accordion_panel._build_accordion_panel() is
-        # the internal hook for that. A single .tagify() on the outer result
-        # lets htmltools' walker resolve any Tagifiable descendants inside
-        # the panels (e.g. an input_slider inside a panel).
-        panels = [child._build_accordion_panel() for child in self.children]  # type: ignore[union-attr]
+        # check on its positional args and rejects rendered Tags. So instead of
+        # calling child.tagify() (which now returns Tag), we read each child's
+        # stored state and build shiny's AccordionPanel wrapper inline. A single
+        # .tagify() on the outer result lets htmltools' walker resolve any
+        # remaining Tagifiable descendants (e.g. an input_slider inside a panel).
+        panels = [
+            _sui.accordion_panel(
+                child.title,  # type: ignore[union-attr]
+                *child.children,  # type: ignore[union-attr]
+                value=child._value,  # type: ignore[union-attr]
+                icon=child.icon,  # type: ignore[union-attr]
+            )
+            for child in self.children
+        ]
         return _sui.accordion(
             *panels,
             id=self.id,
diff --git a/pkg-py/src/shinyui/_accordion_panel.py b/pkg-py/src/shinyui/_accordion_panel.py
index d9a8bf97..b639d118 100644
--- a/pkg-py/src/shinyui/_accordion_panel.py
+++ b/pkg-py/src/shinyui/_accordion_panel.py
@@ -6,7 +6,6 @@
 
 from htmltools import Tag, TagChild
 from shiny.types import MISSING, MISSING_TYPE
-from shiny.ui._accordion import AccordionPanel
 
 from ._children import AllowsChildren
 from ._roles import UiLayout
@@ -121,32 +120,23 @@ def value(self) -> str:
             return self.title
         return self._value
 
-    def _build_accordion_panel(self) -> AccordionPanel:
-        """Internal: produce shiny's ``AccordionPanel`` wrapper for the parent
-        :class:`accordion` to consume. ``shiny.ui.accordion`` does an explicit
-        ``isinstance(panel, AccordionPanel)`` check on its positional args, so
-        the parent reaches for this helper instead of calling :meth:`tagify`.
-        """
+    def tagify(self) -> Tag:
+        # Honor the UiComponent.tagify() -> Tag contract by chaining .tagify()
+        # on shiny's AccordionPanel wrapper. shiny's AccordionPanel.tagify()
+        # requires `_accordion_id` (normally stamped by the parent accordion's
+        # `_sui.accordion(*panels)` call). For standalone rendering we set a
+        # placeholder keyed off the panel's value. The parent :class:`accordion`
+        # builds its own AccordionPanel wrappers from this instance's
+        # attributes (it can't reuse the rendered Tag — shiny.ui.accordion
+        # does an isinstance(panel, AccordionPanel) check on its positional
+        # args).
         import shiny.ui as _sui
 
-        return _sui.accordion_panel(
+        panel = _sui.accordion_panel(
             self.title,
             *self.children,
             value=self._value,
             icon=self.icon,
         )
-
-    def tagify(self) -> Tag:
-        # Chain .tagify() on the AccordionPanel wrapper so we honor the
-        # UiComponent.tagify() -> Tag contract. The parent accordion doesn't
-        # call this directly (see _build_accordion_panel above).
-        #
-        # shiny's AccordionPanel.tagify() requires `_accordion_id` to be set,
-        # normally written by the parent `_sui.accordion(*panels)` call. For
-        # standalone rendering (e.g. snapshot tests, ad-hoc inspection) we
-        # stamp a placeholder so .tagify() works in isolation. The parent
-        # rendering path uses a separate AccordionPanel instance via
-        # `_build_accordion_panel()` and is unaffected.
-        panel = self._build_accordion_panel()
         panel._accordion_id = f"_orphan_{self.value}"
         return panel.tagify()

From 6f8be5ccf7ff010cb8b900abe2af9c2724d20588 Mon Sep 17 00:00:00 2001
From: Barret Schloerke 
Date: Thu, 14 May 2026 11:08:46 -0400
Subject: [PATCH 44/45] style(shinyui): package-wide N801 ignore; drop per-line
 noqa pragmas
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Concrete shinyui classes are intentionally snake_case (matching
shiny.render.data_frame's convention — class name == call-site name,
no parallel factory functions). Configure ruff to ignore N801
('class name should use CapWords') package-wide for pkg-py/src/shinyui
in pyproject.toml, and remove the eight per-line '# noqa: N801'
pragmas the concrete classes carried.
---
 pkg-py/src/shinyui/_accordion.py           | 2 +-
 pkg-py/src/shinyui/_accordion_panel.py     | 2 +-
 pkg-py/src/shinyui/_card.py                | 2 +-
 pkg-py/src/shinyui/_input_action_button.py | 2 +-
 pkg-py/src/shinyui/_input_select.py        | 2 +-
 pkg-py/src/shinyui/_input_slider.py        | 2 +-
 pkg-py/src/shinyui/_output_code.py         | 2 +-
 pkg-py/src/shinyui/_output_plot.py         | 2 +-
 pyproject.toml                             | 7 +++++++
 9 files changed, 15 insertions(+), 8 deletions(-)

diff --git a/pkg-py/src/shinyui/_accordion.py b/pkg-py/src/shinyui/_accordion.py
index 41b90a01..eef70efc 100644
--- a/pkg-py/src/shinyui/_accordion.py
+++ b/pkg-py/src/shinyui/_accordion.py
@@ -21,7 +21,7 @@
 _MISSING = object()
 
 
-class accordion(UiLayout, AllowsChildren, HasInputValue, Updatable):  # noqa: N801
+class accordion(UiLayout, AllowsChildren, HasInputValue, Updatable):
     """Accordion container with collapsible panels.
 
     Wire id: ``input.()`` is a list of the currently-open panel values,
diff --git a/pkg-py/src/shinyui/_accordion_panel.py b/pkg-py/src/shinyui/_accordion_panel.py
index b639d118..25553d98 100644
--- a/pkg-py/src/shinyui/_accordion_panel.py
+++ b/pkg-py/src/shinyui/_accordion_panel.py
@@ -11,7 +11,7 @@
 from ._roles import UiLayout
 
 
-class accordion_panel(UiLayout, AllowsChildren):  # noqa: N801
+class accordion_panel(UiLayout, AllowsChildren):
     """A single collapsible panel within an :class:`accordion`.
 
     ``accordion_panel`` has no wire id of its own. The parent
diff --git a/pkg-py/src/shinyui/_card.py b/pkg-py/src/shinyui/_card.py
index 347d9572..289f4883 100644
--- a/pkg-py/src/shinyui/_card.py
+++ b/pkg-py/src/shinyui/_card.py
@@ -26,7 +26,7 @@
 _MISSING = object()
 
 
-class card(UiLayout, AllowsChildren, HasInputValue, Updatable):  # noqa: N801
+class card(UiLayout, AllowsChildren, HasInputValue, Updatable):
     """Card container with optional full-screen toggle.
 
     Wire id: ``input._full_screen`` is a boolean pushed by shiny's card
diff --git a/pkg-py/src/shinyui/_input_action_button.py b/pkg-py/src/shinyui/_input_action_button.py
index 31e13b2f..2a5d69bf 100644
--- a/pkg-py/src/shinyui/_input_action_button.py
+++ b/pkg-py/src/shinyui/_input_action_button.py
@@ -27,7 +27,7 @@
 _MISSING = object()
 
 
-class input_action_button(UiInput, Updatable):  # noqa: N801
+class input_action_button(UiInput, Updatable):
     """Server-readable action button.
 
     Wire id: ``input.()`` is an integer click counter that starts at ``0``
diff --git a/pkg-py/src/shinyui/_input_select.py b/pkg-py/src/shinyui/_input_select.py
index 1426fed0..b198bcbf 100644
--- a/pkg-py/src/shinyui/_input_select.py
+++ b/pkg-py/src/shinyui/_input_select.py
@@ -22,7 +22,7 @@
 ]
 
 
-class input_select(UiInput, Updatable):  # noqa: N801
+class input_select(UiInput, Updatable):
     """Dropdown / multi-select input.
 
     Wire id: ``input.()`` is the selected key string, or a ``list[str]``
diff --git a/pkg-py/src/shinyui/_input_slider.py b/pkg-py/src/shinyui/_input_slider.py
index 967af57c..2dbf60a0 100644
--- a/pkg-py/src/shinyui/_input_slider.py
+++ b/pkg-py/src/shinyui/_input_slider.py
@@ -13,7 +13,7 @@
 _MISSING = object()
 
 
-class input_slider(UiInput, Updatable):  # noqa: N801
+class input_slider(UiInput, Updatable):
     """Numeric slider input.
 
     Wire id: ``input.()`` is the current slider value (a ``float``, or a
diff --git a/pkg-py/src/shinyui/_output_code.py b/pkg-py/src/shinyui/_output_code.py
index 66943f1d..d75eff2e 100644
--- a/pkg-py/src/shinyui/_output_code.py
+++ b/pkg-py/src/shinyui/_output_code.py
@@ -7,7 +7,7 @@
 from ._roles import UiOutput
 
 
-class output_code(UiOutput):  # noqa: N801
+class output_code(UiOutput):
     """Verbatim-text output placeholder.
 
     No wire input value — this is a pure output element. The server populates
diff --git a/pkg-py/src/shinyui/_output_plot.py b/pkg-py/src/shinyui/_output_plot.py
index af551f36..3dec8582 100644
--- a/pkg-py/src/shinyui/_output_plot.py
+++ b/pkg-py/src/shinyui/_output_plot.py
@@ -31,7 +31,7 @@
 from ._roles import UiOutput
 
 
-class output_plot(UiOutput):  # noqa: N801
+class output_plot(UiOutput):
     """Plot output with optional client-side interaction signals.
 
     No primary ``input.()`` value. When interaction flags are enabled,
diff --git a/pyproject.toml b/pyproject.toml
index 05ec055c..202b7844 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -63,6 +63,13 @@ extend-exclude = ["examples/shiny-react-upstream"]
 [tool.ruff.lint]
 select = ["E", "F", "I"]
 
+[tool.ruff.lint.per-file-ignores]
+# Concrete shinyui classes are intentionally snake_case to match
+# shiny.render.data_frame's convention (class name == call-site name, no
+# parallel factory functions). Ignore N801 ("class name should use CapWords")
+# package-wide so individual class definitions don't carry the noqa pragma.
+"pkg-py/src/shinyui/**/*.py" = ["N801"]
+
 [tool.tox]
 legacy_tox_ini = """
 [tox]

From 9ab9de728e83ab5cabbe7041bbe97ff6b8fd5985 Mon Sep 17 00:00:00 2001
From: Barret Schloerke 
Date: Thu, 14 May 2026 11:14:25 -0400
Subject: [PATCH 45/45] docs: README + spec fixes from Copilot PR review
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Addresses PR #100 review threads from Copilot:

- examples/.../README.md: full rewrite for the current Express app —
  drops the stale lookup_component snippet, the removed
  _auto_expand_at_high_n effect, and the n>800 'What to try' bullet.
  Adds the Open all / Close all button section that's actually wired
  in app.py. Corrects the card full_screen wire id to _full_screen.

- docs/.../spec: 'seven concrete classes' -> 'at least seven', and
  the lifecycle bullet for handler registration now describes the
  __init_subclass__ approach actually used (HasInputValue auto-fires
  cls._register_input_handler() on subclass creation), with
  input_action_button called out as the demonstrating class.

- pkg-py/src/shinyui/_card.py: module docstring corrected to say the
  accessor reads input._full_screen (the actual wire suffix) —
  the previous text contradicted full_screen_value()'s implementation.

- pkg-py/src/shinyui/_reactive.py: implement the on_ended cache
  eviction the module docstring already promised. The cached
  (session, calc) attribute is now dropped proactively when the
  captured session ends, before the lazy mismatch-detection at the
  next accessor call would have replaced it.
---
 ...3-shinyui-metadata-consolidation-design.md |  4 +-
 .../app-py/14-unified-ui-prototype/README.md  | 61 +++++++++++--------
 pkg-py/src/shinyui/_card.py                   | 21 ++++---
 pkg-py/src/shinyui/_reactive.py               | 27 +++++++-
 4 files changed, 73 insertions(+), 40 deletions(-)

diff --git a/docs/superpowers/specs/2026-05-13-shinyui-metadata-consolidation-design.md b/docs/superpowers/specs/2026-05-13-shinyui-metadata-consolidation-design.md
index 5733b85e..20a863ae 100644
--- a/docs/superpowers/specs/2026-05-13-shinyui-metadata-consolidation-design.md
+++ b/docs/superpowers/specs/2026-05-13-shinyui-metadata-consolidation-design.md
@@ -10,11 +10,11 @@
 
 Build a new Python package `shinyui` at `pkg-py/src/shinyui/` that prototypes a class-per-component UI hierarchy. Each class owns its own metadata (input handler, bookmark serializer, HTML deps, `update()` method, server-side read accessors). The package depends only on `shiny` and `htmltools` — *not* on `shinyreact` — so the eventual Stage B port into `py-shiny` is a near-mechanical copy.
 
-The prototype ships seven concrete classes covering every archetype: simple input, structured input, plain output, output-with-read-only-signals (plot), layout-with-children, layout-with-state (card, accordion), and layout-as-child-of-layout.
+The prototype ships at least seven concrete classes covering every archetype: simple input, structured input, plain output, output-with-read-only-signals (plot), layout-with-children, layout-with-state (card, accordion), and layout-as-child-of-layout. The implementation also adds `input_action_button` to exercise the `__init_subclass__` handler-registration demo (see below).
 
 Three open questions from the umbrella are resolved in this spec:
 
-- **Handler registration:** explicit `cls._register_input_handler()` call at module level (no `__init_subclass__`).
+- **Handler registration:** `cls._register_input_handler()` is a classmethod on `HasInputValue`; it is auto-fired by `HasInputValue.__init_subclass__` whenever any subclass is defined. Subclasses that leave `input_handler_name = ""` (the default) are a no-op — slider, select, card, accordion, and the plot/code outputs all take that path. `input_action_button` is the one class in the prototype that opts into a custom wire-side coercion handler, registered under `"shinyui.action"`.
 - **Bookmark id → instance lookup:** register-on-construction; `__init__` queries `get_current_session()` and registers `(id, self)` on the session if one is in scope. No-op if not (module-level UI keeps working, just without class-owned serializers).
 - **`update()` signature:** typed per-class keyword arguments. No `session=` kwarg — session is captured at `__init__` and resolved at call time via a shared `_require_session()` helper.
 
diff --git a/examples/app-py/14-unified-ui-prototype/README.md b/examples/app-py/14-unified-ui-prototype/README.md
index 0e1768c5..61fa8dc7 100644
--- a/examples/app-py/14-unified-ui-prototype/README.md
+++ b/examples/app-py/14-unified-ui-prototype/README.md
@@ -1,9 +1,9 @@
 # 14 — Unified UI prototype (shinyui Stage A)
 
-End-to-end demo of [shinyui](../../../pkg-py/src/shinyui), the class-per-component
-UI hierarchy from issue #69 (umbrella #68). Each UI component is a Python class
-that owns its own metadata (handler, serializer, HTML deps, `update()`, server-side
-read accessors).
+End-to-end Shiny Express demo of [shinyui](../../../pkg-py/src/shinyui), the
+class-per-component UI hierarchy from issue #69 (umbrella #68). Each UI
+component is a Python class that owns its own metadata (handler, serializer,
+HTML deps, `update()`, server-side read accessors).
 
 ## Run
 
@@ -11,8 +11,7 @@ read accessors).
 uv run shiny run examples/app-py/14-unified-ui-prototype/app.py
 ```
 
-Requires `matplotlib` for the placeholder plot (already in this repo's `examples`
-extras group).
+Requires `matplotlib` and `numpy` (already in the repo's `examples` extras).
 
 ## What this demonstrates
 
@@ -20,54 +19,64 @@ extras group).
 |---|---|---|
 | Simple input | `input_slider` | `n` and `seed` sliders |
 | Structured input | `input_select` | `dist` selector |
+| Action input | `input_action_button` | `Open all panels` / `Close all panels` |
 | Plain output | `output_code` | `summary` and `diag` outputs |
 | Output with read-only signals | `output_plot` | `plot` with `click=True, brush=True` |
 | Layout with children + state | `card` | `main_card.full_screen_value()`, `main_card.update(full_screen=...)` |
 | Layout with state + children | `accordion` | `acc.open_panels()`, `acc.update(open=...)` |
-| Layout-as-child | `accordion_panel` | Two panels inside `acc` |
+| Layout-as-child | `accordion_panel` | `Settings` and `Diagnostics` panels |
 
 ## Class-per-component patterns in the server code
 
-The server uses `su.lookup_component(session, id)` to retrieve typed handles for
-each component constructed in `app_ui(request)`. Through those handles, server
-code reads input values:
+Components are constructed at module level so they're shared between the
+top-level Express layout and the server-side renderers via closure. Each
+class accessor reads a wire-side input value reactively:
 
 ```python
-n_slider = cast(su.input_slider, su.lookup_component(session, "n"))
+n_slider = su.input_slider("n", "Sample size", 10, 1000, 100)
 
 @render.code
 def summary():
     return f"n = {n_slider.value()}"
 ```
 
-…and pushes updates:
+…and `.update()` pushes state back to the client. The Open all / Close all
+action buttons drive `accordion.update()`:
 
 ```python
 @reactive.effect
-def _auto_expand_at_high_n():
-    if n_slider.value() > 800:
-        main_card.update(full_screen=True)
-        acc.update(open=("Settings", "Diagnostics"))
+@reactive.event(open_all_btn.clicked, ignore_init=True)
+def _open_all_panels():
+    acc.update(open=("Settings", "Diagnostics"))
+
+
+@reactive.effect
+@reactive.event(close_all_btn.clicked, ignore_init=True)
+def _close_all_panels():
+    acc.update(open=False)
 ```
 
-Each `.value()` / `.full_screen_value()` / `.open_panels()` / `.click_value()` /
-`.brush_value()` accessor is a `@reactive.calc` under the hood, so reads inside
-reactive contexts establish dependencies correctly.
+Each `.value()` / `.clicked()` / `.full_screen_value()` / `.open_panels()` /
+`.click_value()` / `.brush_value()` accessor is a `@reactive.calc` under the
+hood, so reads inside reactive contexts establish dependencies correctly.
 
 ## What to try
 
-- Drag `n` — `summary` updates immediately.
-- Drag past `n=800` — the card auto-expands to full-screen and both accordion
-  panels open via `.update()` calls.
+- Drag `n`, change `dist`, or change `seed` — the scatter plot redraws and
+  `summary` updates immediately.
+- Click **Open all panels** / **Close all panels** — the accordion expands
+  or collapses via `acc.update(open=...)`.
 - Click or brush on the plot — coordinates appear in the `diag` panel via
   `plot_handle.click_value()` / `plot_handle.brush_value()`.
 
 ## Notes on real-app fidelity
 
-- `card.full_screen_value()` reads `input.()` — Stage A doesn't wire
-  the browser-side push for `full_screen` state, so the value stays `False` in
-  a live browser session until the JS binding is added. Unit tests exercise the
-  full path with a mocked session.
+- `card.full_screen_value()` reads `input._full_screen` — that's
+  the wire id shiny's card binding pushes when the user toggles full-screen
+  mode. Stage A doesn't ship the browser-side JS for `card.update(full_screen=)`
+  to flip the card from the server, so server-driven full-screen changes are
+  out of scope for this demo. Unit tests exercise the full accessor path
+  with a mocked session.
 - Plot click/brush bindings ARE wired by shiny natively — `output_plot(click=True,
   brush=True)` registers the standard shiny.plot bindings, so the JSON-shaped
   values flow into `input._click` / `input._brush` as expected.
diff --git a/pkg-py/src/shinyui/_card.py b/pkg-py/src/shinyui/_card.py
index 289f4883..0f505ab9 100644
--- a/pkg-py/src/shinyui/_card.py
+++ b/pkg-py/src/shinyui/_card.py
@@ -1,14 +1,17 @@
 """card — layout with optional full-screen toggle exposed as input value.
 
-NOTE: real wire-level `full_screen` input is out of scope for the Stage A
-prototype (would require client-side JS). The `full_screen_value()` accessor
-exists for the class-design test path; in a live app it will be None until
-client JS is added.
-
-`shiny.ui.card` already accepts an `id` kwarg and reports
-``input._full_screen`` as a bool when the browser's card JS fires, but
-that browser JS is not wired in the prototype. This class uses the plain
-``self.id`` key so unit tests can mock it straightforwardly.
+Wire id: ``shiny.ui.card`` accepts an ``id`` kwarg and shiny's card binding
+pushes the full-screen state to ``input._full_screen``. The class accessor
+:meth:`card.full_screen_value` reads that derived id directly (suffix
+``_full_screen``) — there is no primary ``input.()`` value.
+
+Stage A scope note: server → client wiring for ``card.update(full_screen=)``
+is out of scope (would require a small client-side JS adapter, since shiny
+does not currently listen for a server-pushed ``full_screen`` message on
+card). ``full_screen_value()`` reads the bound input correctly under unit
+tests with a mocked session; in a live browser the value reflects whatever
+state the user toggled client-side. The Stage B port to py-shiny may add the
+JS hook.
 """
 
 from __future__ import annotations
diff --git a/pkg-py/src/shinyui/_reactive.py b/pkg-py/src/shinyui/_reactive.py
index 973b1fb8..2bf35391 100644
--- a/pkg-py/src/shinyui/_reactive.py
+++ b/pkg-py/src/shinyui/_reactive.py
@@ -14,9 +14,16 @@
 only), the second session sees a destroyed calc → ``DestroyedReactiveError``
 → session-wide crash → "grey overlay" in the browser.
 
-The cache below is keyed by ``(instance, session)`` so each session gets a
-fresh ``@reactive.calc``. Entries are evicted on session-end via
-``shiny.session.Session.on_ended``.
+The cache is keyed by the **current session** so each session gets a fresh
+``@reactive.calc``. Two cleanup paths run together:
+
+1. **Proactive:** when the captured session ends, the ``on_ended`` callback
+   below clears the cached attribute on the instance. This drops the strong
+   reference to the dead session promptly, before any subsequent accessor
+   call.
+2. **Lazy fallback:** if for any reason the ``on_ended`` callback hasn't
+   fired yet (or the cache survived for another reason), the wrapper also
+   detects a session mismatch at call time and rebuilds the calc.
 """
 
 from __future__ import annotations
@@ -44,6 +51,20 @@ def _calc() -> T:
                 return fn(self)
 
             setattr(self, attr_name, (sess, _calc))
+            if sess is not None:
+                # Proactively drop the cached slot for this session when the
+                # session ends. Avoids holding a strong reference to the dead
+                # session via this attribute until the next accessor call
+                # would otherwise overwrite it.
+                def _evict(_sess=sess) -> None:
+                    current = getattr(self, attr_name, None)
+                    if current is not None and current[0] is _sess:
+                        try:
+                            delattr(self, attr_name)
+                        except AttributeError:
+                            pass
+
+                sess.on_ended(_evict)
             return _calc()
         return cached[1]()