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/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..d7091a01 --- /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: 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/ + __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: `input_slider` + +**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 input_slider, input_slider + + +def test_factory_returns_instance(): + s = input_slider("n", "N", 1, 100, 50) + assert isinstance(s, input_slider) + 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"input_slider\.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 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.`. + +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 +"""input_slider — 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 input_slider(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, +) -> 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. + +- [ ] **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 `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. + +- [ ] **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): input_slider + input_slider() factory" +``` + +--- + +## Task 10: `input_select` + +**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 input_select, input_select + + +def test_factory_returns_instance(): + s = input_select("col", "Column", {"a": "A", "b": "B"}) + assert isinstance(s, input_select) + + +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 input_select** + +Create `pkg-py/src/shinyui/_input_select.py`: + +```python +"""input_select — 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 input_select(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, +) -> input_select: + return input_select(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): input_select + input_select() factory" +``` + +--- + +## Task 11: `output_code` + +**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 output_code, output_code + + +def test_factory_returns_instance(): + o = output_code("summary") + assert isinstance(o, output_code) + 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 output_code** + +Create `pkg-py/src/shinyui/_output_code.py`: + +```python +"""output_code — class-based output_code.""" +from __future__ import annotations + +from typing import Any + +from htmltools import Tag + +from ._roles import UiOutput + + +class output_code(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) -> output_code: + return output_code(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): output_code + output_code() factory" +``` + +--- + +## Task 12: `output_plot` + +**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 output_plot, output_plot + + +def test_factory_returns_instance(): + p = output_plot("p", click=True, brush=True) + assert isinstance(p, output_plot) + 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 output_plot** + +Create `pkg-py/src/shinyui/_output_plot.py`: + +```python +"""output_plot — 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 output_plot(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) -> output_plot: + return output_plot(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): output_plot with read-only signal accessors" +``` + +--- + +## Task 13: `accordion_panel` + +**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 accordion_panel, accordion_panel +from shinyui._children import AllowsChildren + + +def test_factory_returns_instance(): + p = accordion_panel("Settings", "body") + assert isinstance(p, accordion_panel) + 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 accordion_panel** + +Create `pkg-py/src/shinyui/_accordion_panel.py`: + +```python +"""accordion_panel — layout child of accordion.""" +from __future__ import annotations + +from typing import Any + +from htmltools import Tag, TagChild + +from ._children import AllowsChildren +from ._roles import UiLayout + + +class accordion_panel(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) -> accordion_panel: + return accordion_panel(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): accordion_panel + accordion_panel() factory" +``` + +--- + +## Task 14: `accordion` + +**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 accordion, 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, accordion) + 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 accordion.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 accordion** + +Create `pkg-py/src/shinyui/_accordion.py`: + +```python +"""accordion — 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 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. + 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) + + +accordion._register_input_handler() + + +def accordion(*args: Any, id: str, **kwargs: Any) -> accordion: + return accordion(*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): accordion + accordion() factory" +``` + +--- + +## Task 15: `card` + +**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 card, 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, card) + 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 card** + +Create `pkg-py/src/shinyui/_card.py`: + +```python +"""card — 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 card(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) -> card: + return card(*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): card 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.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) + 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 accordion, accordion +from ._accordion_panel import accordion_panel, accordion_panel +from ._base import UiComponent +from ._card import card, card +from ._children import AllowsChildren +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 output_code, output_code +from ._output_plot import output_plot, output_plot +from ._roles import UiInput, UiLayout, UiOutput +from ._updatable import Updatable + +__all__ = [ + # Bases / mixins + "UiComponent", "UiInput", "UiOutput", "UiLayout", + "HasInputValue", "Updatable", "AllowsChildren", + # Concrete classes + "input_slider", "input_select", + "output_code", "output_plot", + "card", "accordion", "accordion_panel", + # 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.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.input_slider, sui.input_select, + sui.output_code, sui.output_plot, + sui.card, sui.accordion, sui.accordion_panel, +] + + +@pytest.mark.parametrize("cls", ALL_CLASSES) +def test_is_uicomponent(cls): + assert isinstance(_maker(cls), sui.UiComponent) + + +@pytest.mark.parametrize("cls,expected", [ + (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) + for base in expected: + assert isinstance(inst, base), f"{cls.__name__} should be instance of {base.__name__}" + + +@pytest.mark.parametrize("cls,allows_children", [ + (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) + 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.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.input_slider.input_handler_name == "" + assert sui.input_slider._input_handler is None + + +def test_card_does_not_register_handler(): + assert sui.card.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: + - 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 +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 | `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 + +- 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. `accordion`'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 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. + +--- + +## 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. 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..20a863ae --- /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 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:** `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. + +A fourth refinement that emerged during design: + +- 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) + +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 `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()`. + +## 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 | +|---|---|---|---|---| +| `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` | — | — | — | + +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 `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. + +## 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 input_date(UiInput): # noqa: N801 + input_handler_name = "shiny.date" + + @staticmethod + def _input_handler(value, name, session): + return parse_iso_date(value) + + +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." + +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 input_slider(UiInput, Updatable): # noqa: N801 + 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 input_slider(UiInput, Updatable): # noqa: N801 + @reactive_calc_method + def value(self) -> float: + return self._read_input() + + +class card(UiLayout, AllowsChildren, HasInputValue, Updatable): # noqa: N801 + @reactive_calc_method + def full_screen_value(self) -> bool: + return bool(self._read_input()) + + +class accordion(UiLayout, AllowsChildren, HasInputValue, Updatable): # noqa: N801 + @reactive_calc_method + def open_panels(self) -> tuple[str, ...]: + return tuple(self._read_input() or ()) +``` + +For `output_plot` (multi-signal, not `HasInputValue`): + +```python +class output_plot(UiOutput): # noqa: N801 + 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 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 `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. | +| `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 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 + +- **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. +- **`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.** `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. +- **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). 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..61fa8dc7 --- /dev/null +++ b/examples/app-py/14-unified-ui-prototype/README.md @@ -0,0 +1,82 @@ +# 14 — Unified UI prototype (shinyui Stage A) + +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 + +``` +uv run shiny run examples/app-py/14-unified-ui-prototype/app.py +``` + +Requires `matplotlib` and `numpy` (already in the repo's `examples` extras). + +## What this demonstrates + +| Archetype | Class | Demonstrated by | +|---|---|---| +| 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` | `Settings` and `Diagnostics` panels | + +## Class-per-component patterns in the server code + +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 = su.input_slider("n", "Sample size", 10, 1000, 100) + +@render.code +def summary(): + return f"n = {n_slider.value()}" +``` + +…and `.update()` pushes state back to the client. The Open all / Close all +action buttons drive `accordion.update()`: + +```python +@reactive.effect +@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()` / `.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`, 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._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/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..f9b6b651 --- /dev/null +++ b/examples/app-py/14-unified-ui-prototype/app.py @@ -0,0 +1,127 @@ +"""End-to-end demo of shinyui's class-per-component hierarchy (Shiny Express). + +Exercises every reference class in one page: + - 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 +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 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) +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) +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( + "Diagnostics", + su.output_code("summary"), + su.output_code("diag"), + ), + id="acc", + open="Settings", +) +main_card = su.card( + # 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") + +# 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 + + +# --- 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.
+        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():
+        return (
+            f"click = {plot_handle.click_value()}\n"
+            f"brush = {plot_handle.brush_value()}\n"
+        )
+
+    @render.plot
+    def plot():
+        # Real scatter plot driven by the slider/select/seed inputs.
+        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
+
+
+# --- Reactive effects ------------------------------------------------------
+@reactive.effect
+@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():
+    # update_accordion's `show=` takes a panel value list, OR True/False.
+    # False closes all panels in the set.
+    acc.update(open=False)
diff --git a/pkg-py/src/shinyui/__init__.py b/pkg-py/src/shinyui/__init__.py
new file mode 100644
index 00000000..b422b478
--- /dev/null
+++ b/pkg-py/src/shinyui/__init__.py
@@ -0,0 +1,38 @@
+"""shinyui — prototype class-per-component UI hierarchy.
+
+See docs/superpowers/specs/2026-05-13-shinyui-metadata-consolidation-design.md.
+"""
+
+from ._accordion import accordion
+from ._accordion_panel import accordion_panel
+from ._base import UiComponent
+from ._bookmark import lookup_component
+from ._card import card
+from ._children import AllowsChildren
+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 output_code
+from ._output_plot import output_plot
+from ._roles import UiInput, UiLayout, UiOutput
+from ._updatable import Updatable
+
+__all__ = [
+    "AllowsChildren",
+    "HasInputValue",
+    "UiComponent",
+    "UiInput",
+    "UiLayout",
+    "UiOutput",
+    "Updatable",
+    "accordion",
+    "accordion_panel",
+    "card",
+    "input_action_button",
+    "input_select",
+    "input_slider",
+    "lookup_component",
+    "output_code",
+    "output_plot",
+]
diff --git a/pkg-py/src/shinyui/_accordion.py b/pkg-py/src/shinyui/_accordion.py
new file mode 100644
index 00000000..eef70efc
--- /dev/null
+++ b/pkg-py/src/shinyui/_accordion.py
@@ -0,0 +1,224 @@
+"""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
+registered here (approach A); open_panels() coerces list -> tuple at read time.
+"""
+
+from __future__ import annotations
+
+from typing import Any, Optional, overload
+
+from htmltools import Tag
+
+from ._accordion_panel import accordion_panel
+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 accordion(UiLayout, AllowsChildren, HasInputValue, Updatable):
+    """Accordion container with collapsible panels.
+
+    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 — listed first so IDEs prefer it for `with ...:` idioms.
+    @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:
+        """Build an accordion as an Express context manager.
+
+        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,
+        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:
+        """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.
+        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.
+        """
+        ...
+
+    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_
+        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
+
+        # `shiny.ui.accordion` does an explicit isinstance(panel, AccordionPanel)
+        # 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,
+            open=self._open,
+            multiple=self.multiple,
+            class_=self.class_,
+            width=self.width,
+            height=self.height,
+        ).tagify()
+
+    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)
diff --git a/pkg-py/src/shinyui/_accordion_panel.py b/pkg-py/src/shinyui/_accordion_panel.py
new file mode 100644
index 00000000..25553d98
--- /dev/null
+++ b/pkg-py/src/shinyui/_accordion_panel.py
@@ -0,0 +1,142 @@
+"""accordion_panel — layout child of accordion."""
+
+from __future__ import annotations
+
+from typing import overload
+
+from htmltools import Tag, TagChild
+from shiny.types import MISSING, MISSING_TYPE
+
+from ._children import AllowsChildren
+from ._roles import UiLayout
+
+
+class accordion_panel(UiLayout, AllowsChildren):
+    """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 — listed first so IDEs prefer it for `with ...:` idioms.
+    @overload
+    def __init__(
+        self,
+        title: str,
+        *,
+        value: str | MISSING_TYPE = MISSING,
+        icon: TagChild | None = None,
+    ) -> None:
+        """Build an accordion panel as an Express context manager.
+
+        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,
+        *args: TagChild,
+        value: str | MISSING_TYPE = MISSING,
+        icon: TagChild | None = None,
+    ) -> None:
+        """Build an accordion panel with inline positional children.
+
+        Example
+        -------
+        .. code-block:: python
+
+            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.
+        *args
+            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.
+        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
+        super().__init__(*args)
+
+    @property
+    def value(self) -> str:
+        if isinstance(self._value, MISSING_TYPE):
+            return self.title
+        return self._value
+
+    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
+
+        panel = _sui.accordion_panel(
+            self.title,
+            *self.children,
+            value=self._value,
+            icon=self.icon,
+        )
+        panel._accordion_id = f"_orphan_{self.value}"
+        return panel.tagify()
diff --git a/pkg-py/src/shinyui/_base.py b/pkg-py/src/shinyui/_base.py
new file mode 100644
index 00000000..1caa3595
--- /dev/null
+++ b/pkg-py/src/shinyui/_base.py
@@ -0,0 +1,68 @@
+"""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.
+
+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 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, *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__(*args, **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:
+        # 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."
+        )
+
+    def __exit__(self, *exc: object) -> None:
+        return None
diff --git a/pkg-py/src/shinyui/_bookmark.py b/pkg-py/src/shinyui/_bookmark.py
new file mode 100644
index 00000000..c12ca315
--- /dev/null
+++ b/pkg-py/src/shinyui/_bookmark.py
@@ -0,0 +1,38 @@
+"""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)
+
+
+def lookup_component(session: "Session", id: str) -> "HasInputValue | None":
+    """Public helper: find a HasInputValue instance registered on this session."""
+    return lookup_instance(session, id)
diff --git a/pkg-py/src/shinyui/_card.py b/pkg-py/src/shinyui/_card.py
new file mode 100644
index 00000000..0f505ab9
--- /dev/null
+++ b/pkg-py/src/shinyui/_card.py
@@ -0,0 +1,208 @@
+"""card — layout with optional full-screen toggle exposed as input value.
+
+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
+
+from typing import Any, Optional, overload
+
+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 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
+    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 — listed first so IDEs prefer it for `with ...:` idioms.
+    @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:
+        """Build a card as an Express context manager.
+
+        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,
+        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:
+        """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``).
+        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.
+        """
+        ...
+
+    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.
+
+        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
+
+        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_
+
+        # `shiny.ui.card` accepts arbitrary TagChild members — including our
+        # 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.)
+        return _sui.card(*self.children, **kwargs).tagify()
+
+    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})
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/src/shinyui/_input_action_button.py b/pkg-py/src/shinyui/_input_action_button.py
new file mode 100644
index 00000000..2a5d69bf
--- /dev/null
+++ b/pkg-py/src/shinyui/_input_action_button.py
@@ -0,0 +1,133 @@
+"""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
+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 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
+
+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 input_action_button(UiInput, Updatable):
+    """Server-readable action button.
+
+    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
+    # body finishes executing. See the module docstring for the trade-off.
+    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 since the demo doesn't actually receive wire traffic.)
+        """
+        return int(value or 0)
+
+    def __init__(
+        self,
+        id: str,
+        label: TagChild,
+        *,
+        icon: TagChild = None,
+        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
+        self.disabled = disabled
+        super().__init__(id=id)
+
+    @reactive_calc_method
+    def clicked(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)
diff --git a/pkg-py/src/shinyui/_input_select.py b/pkg-py/src/shinyui/_input_select.py
new file mode 100644
index 00000000..b198bcbf
--- /dev/null
+++ b/pkg-py/src/shinyui/_input_select.py
@@ -0,0 +1,121 @@
+"""input_select — 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 input_select(UiInput, Updatable):
+    """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,
+        label: TagChild,
+        choices: SelectChoicesArg,
+        *,
+        selected: Optional[str | list[str]] = None,
+        multiple: bool = False,
+        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
+        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)
diff --git a/pkg-py/src/shinyui/_input_slider.py b/pkg-py/src/shinyui/_input_slider.py
new file mode 100644
index 00000000..2dbf60a0
--- /dev/null
+++ b/pkg-py/src/shinyui/_input_slider.py
@@ -0,0 +1,139 @@
+"""input_slider — 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 input_slider(UiInput, Updatable):
+    """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,
+        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:
+        """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
+        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)
diff --git a/pkg-py/src/shinyui/_input_value.py b/pkg-py/src/shinyui/_input_value.py
new file mode 100644
index 00000000..33b3f21f
--- /dev/null
+++ b/pkg-py/src/shinyui/_input_value.py
@@ -0,0 +1,65 @@
+"""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, 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.
+"""
+
+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: 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,
+        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/src/shinyui/_output_code.py b/pkg-py/src/shinyui/_output_code.py
new file mode 100644
index 00000000..d75eff2e
--- /dev/null
+++ b/pkg-py/src/shinyui/_output_code.py
@@ -0,0 +1,47 @@
+"""output_code — class-based output_code."""
+
+from __future__ import annotations
+
+from htmltools import Tag
+
+from ._roles import UiOutput
+
+
+class output_code(UiOutput):
+    """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__()
+
+    def tagify(self) -> Tag:
+        import shiny.ui as _sui
+
+        return _sui.output_code(self.id, placeholder=self.placeholder)
diff --git a/pkg-py/src/shinyui/_output_plot.py b/pkg-py/src/shinyui/_output_plot.py
new file mode 100644
index 00000000..3dec8582
--- /dev/null
+++ b/pkg-py/src/shinyui/_output_plot.py
@@ -0,0 +1,138 @@
+"""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:`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
+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
+
+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 output_plot(UiOutput):
+    """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,
+        *,
+        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:
+        """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
+        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 dbl_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,
+        )
diff --git a/pkg-py/src/shinyui/_reactive.py b/pkg-py/src/shinyui/_reactive.py
new file mode 100644
index 00000000..2bf35391
--- /dev/null
+++ b/pkg-py/src/shinyui/_reactive.py
@@ -0,0 +1,73 @@
+"""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 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 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
+
+from typing import Any, Callable, TypeVar
+
+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]:
+    # 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:
+        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)
+
+            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]()
+
+    wrapper.__name__ = fn.__name__
+    wrapper.__doc__ = fn.__doc__
+    return wrapper
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/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/__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..c955c395
--- /dev/null
+++ b/pkg-py/tests/shinyui/conftest.py
@@ -0,0 +1,38 @@
+"""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 = ""
+    # 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
+
+
+@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_accordion.py b/pkg-py/tests/shinyui/test_accordion.py
new file mode 100644
index 00000000..d2d3bad1
--- /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 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, accordion)
+    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() 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..252a636f --- /dev/null +++ b/pkg-py/tests/shinyui/test_accordion_panel.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from htmltools import tags +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, accordion_panel) + 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_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() + 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(): + with accordion_panel("Settings") as p: + p.append(tags.p("inside")) + assert len(p.children) == 1 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_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") 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 diff --git a/pkg-py/tests/shinyui/test_card.py b/pkg-py/tests/shinyui/test_card.py new file mode 100644 index 00000000..921d2c9c --- /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 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, card) + 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_full_screen") + + +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 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 diff --git a/pkg-py/tests/shinyui/test_hierarchy.py b/pkg-py/tests/shinyui/test_hierarchy.py new file mode 100644 index 00000000..696df42c --- /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.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.input_slider, + sui.input_select, + sui.output_code, + sui.output_plot, + sui.card, + sui.accordion, + sui.accordion_panel, +] + + +@pytest.mark.parametrize("cls", ALL_CLASSES) +def test_is_uicomponent(cls): + assert isinstance(_maker(cls), sui.UiComponent) + + +@pytest.mark.parametrize( + "cls,expected", + [ + (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) + for base in expected: + assert isinstance(inst, base), ( + f"{cls.__name__} should be instance of {base.__name__}" + ) + + +@pytest.mark.parametrize( + "cls,allows_children", + [ + (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) + 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_action_button.py b/pkg-py/tests/shinyui/test_input_action_button.py new file mode 100644 index 00000000..028361c0 --- /dev/null +++ b/pkg-py/tests/shinyui/test_input_action_button.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import pytest +import shiny.ui as sui +from shiny import reactive +from shiny.input_handler import input_handlers +from shinyui._input_action_button import input_action_button + + +def test_factory_returns_instance(): + b = input_action_button("go", "Go") + assert isinstance(b, input_action_button) + 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_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.clicked() == 0 + + +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.clicked() == 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_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 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 = input_action_button._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) + # `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 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..1b3cf1a1 --- /dev/null +++ b/pkg-py/tests/shinyui/test_input_handler_registration.py @@ -0,0 +1,34 @@ +"""Pin the prototype's handler-registration state. + +Most shinyui classes do NOT declare a custom input handler — shiny's +built-in bindings handle the wire format. The one exception is +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). +""" + +from __future__ import annotations + +import pytest +import shinyui as sui + + +@pytest.mark.parametrize( + "cls", + [ + sui.input_slider, + sui.input_select, + sui.card, + sui.accordion, + ], +) +def test_class_declares_no_custom_handler(cls): + """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(): + """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 new file mode 100644 index 00000000..48927588 --- /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 input_select + + +def test_factory_returns_instance(): + s = input_select("col", "Column", {"a": "A", "b": "B"}) + assert isinstance(s, input_select) + + +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"] 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..25718890 --- /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 input_slider + + +def test_factory_returns_instance(): + s = input_slider("n", "N", 1, 100, 50) + assert isinstance(s, input_slider) + 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"input_slider\.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 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 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..694c0fe6 --- /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 output_code + + +def test_factory_returns_instance(): + o = output_code("summary") + assert isinstance(o, output_code) + 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() 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..033b2ee5 --- /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 output_plot + + +def test_factory_returns_instance(): + p = output_plot("p", click=True, brush=True) + assert isinstance(p, output_plot) + 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_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.dbl_value() == {"x": 2} + + +def test_no_update_method(): + p = output_plot("p") + assert not hasattr(p, "update") 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..4aaade2b --- /dev/null +++ b/pkg-py/tests/shinyui/test_public_exports.py @@ -0,0 +1,17 @@ +def test_public_exports(): + import shinyui as sui + + # 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 + + # 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) 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 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..9235f26a --- /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", "_full_screen", 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_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) diff --git a/pkg-py/tests/shinyui/test_smoke.py b/pkg-py/tests/shinyui/test_smoke.py new file mode 100644 index 00000000..014d9ab3 --- /dev/null +++ b/pkg-py/tests/shinyui/test_smoke.py @@ -0,0 +1,8 @@ +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/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} 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 diff --git a/pyproject.toml b/pyproject.toml index 6199e643..202b7844 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" @@ -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]