Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 22 additions & 9 deletions native/pages/create.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ def _option_label(opt: dict) -> str:
),
class_name="flex flex-col gap-2 px-1 pt-2",
),
class_name="px-3 pb-3",
class_name="min-w-[10rem] shrink-0 lg:min-w-0 lg:px-3 lg:pb-3",
)


Expand Down Expand Up @@ -186,7 +186,14 @@ def _sidebar_desktop():
return rx.el.aside(
rx.el.div(
button(
"Get Code",
rx.el.span(
"Get Code",
class_name="group-data-[show=true]:hidden",
),
rx.el.span(
"Preview",
class_name="hidden group-data-[show=true]:inline",
),
id="copy-theme-button",
type="button",
class_name="w-full",
Expand Down Expand Up @@ -230,6 +237,7 @@ def _sidebar_desktop():
"__match_base__",
swatch=True,
),
class_name="min-w-[10rem] shrink-0 lg:min-w-0",
),
_theme_select(
"radius-select",
Expand All @@ -245,7 +253,11 @@ def _sidebar_desktop():
FONT_REGISTRY[0]["id"],
icon=hi("TextFontIcon"),
),
class_name="flex-1 min-h-0 overflow-y-auto scrollbar-none divide-y divide-input",
class_name=(
"px-2 py-1 lg:p-0 flex gap-1 overflow-x-auto scrollbar-none "
"lg:flex-1 lg:flex-col lg:gap-0 lg:overflow-y-auto lg:overflow-x-hidden "
"lg:divide-y lg:divide-input"
),
),
rx.el.div(
button(
Expand All @@ -263,16 +275,16 @@ def _sidebar_desktop():
class_name="w-full flex flex-row items-center justify-center gap-1",
),
open_preset(),
class_name="p-4 flex flex-col gap-3 bg-card/20",
class_name="p-3 lg:p-4 flex flex-col gap-3 bg-card/20",
),
rx.el.div(
reset_confirm(),
class_name="p-3 bg-card/20",
),
class_name=(
"hidden lg:flex w-full max-w-[12rem] shrink-0 !overflow-hidden flex-col "
"border border-input/90 divide-y divide-input h-full text-sm "
"text-card-foreground dark bg-card/90 isolate rounded-2xl"
"flex flex-col gap-3 text-sm border border-input/90 dark "
"text-card-foreground bg-card/90 isolate rounded-2xl "
"lg:w-[12rem] lg:max-w-[12rem] lg:h-full lg:gap-0 lg:divide-y lg:divide-input"
),
)

Expand Down Expand Up @@ -345,7 +357,7 @@ def preview_space():
),
class_name="w-full h-full",
),
class_name="w-full flex-[2] min-h-0 order-first lg:order-none lg:flex-1 lg:min-w-0 lg:h-full",
class_name="w-full flex-[2] min-h-0 order-first lg:order-none lg:flex-1 lg:min-w-0 lg:h-full max-lg:group-data-[show=true]:hidden",
)


Expand Down Expand Up @@ -375,7 +387,8 @@ def source_space():
"hidden "
"group-data-[show=true]:flex flex-col gap-y-4 "
"w-full flex-initial h-auto min-h-0 min-w-0 max-w-full lg:flex-1 "
"lg:h-full lg:w-sm lg:max-w-sm lg:shrink-0"
"lg:h-full lg:w-sm lg:max-w-sm lg:shrink-0 "
"max-lg:group-data-[show=true]:flex-1 max-lg:group-data-[show=true]:min-h-0"
),
)

Expand Down
179 changes: 179 additions & 0 deletions tests/test_create_page.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
"""Regression tests for the /create page layout.

Guards against the mobile-unreachable bug where the entire customizer
sidebar rendered with ``hidden lg:flex`` (``display: none`` below the
Tailwind ``lg`` breakpoint, 1024px) while the *only* element that could
toggle the source-code panel (``#copy-theme-button`` flipping
``data-show`` on ``#toggle-wrapper``) lived inside that hidden aside.

Below ``lg`` the result was a read-only card preview with every
customizer control (Style/Base/Color/Chart/Radius/Font selects, Get
Code, Shuffle, Open Preset, Reset) and the source-code panel
permanently unreachable. The fix mirrors the responsive pattern used
by the sibling ``/typeset`` page (commit e3a5428): the sidebar renders
on mobile (no ``hidden lg:flex``), selects reflow into a horizontal
scroll strip, and the toggle-reveal pair (``group-data-[show=true]``
on ``#source-space`` plus ``max-lg:group-data-[show=true]:hidden`` on
the preview) lets mobile users switch between preview and code.

These tests assert the class-string contract statically (no browser
required) so the regression cannot silently return.
"""

from typing import Any

from native.pages.create import (
_sidebar_desktop, # pyright: ignore[reportPrivateUsage]
create_page,
preview_space,
source_space,
)

_CLASS_DELIMS = str.maketrans({'"': " ", "(": " ", ")": " ", ",": " "})


def _is_component(node: Any) -> bool:
return hasattr(node, "children") and hasattr(node, "class_name")


def _find_by_id(root: Any, target_id: str) -> Any:
"""Depth-first search for a Reflex component whose ``id`` equals ``target_id``."""
if _is_component(root):
cid = root.id if hasattr(root, "id") else None
if isinstance(cid, str) and cid == target_id:
return root
children = root.children if hasattr(root, "children") else None
if isinstance(children, list):
for child in children:
found = _find_by_id(child, target_id)
if found is not None:
return found
return None


def _class_str(component: Any) -> str:
cls = component.class_name
return cls if isinstance(cls, str) else str(cls)


def _tokens(cls: str) -> set[str]:
return set(cls.translate(_CLASS_DELIMS).split())


def test_sidebar_is_visible_on_mobile_not_hidden_below_lg() -> None:
"""The customizer aside must render on mobile; ``hidden lg:flex`` made
every sidebar control (including the source-panel toggle) unreachable
below 1024px.
"""
aside = _sidebar_desktop()
tokens = _tokens(_class_str(aside))

assert "hidden" not in tokens, (
"the sidebar must not be display:none at base breakpoint; every "
"customizer control and the Get Code toggle live inside it"
)
assert "lg:flex" not in tokens or "flex" in tokens, (
"the aside must be visible at base breakpoint (flex), not gated "
"behind lg:flex alone"
)
assert "flex" in tokens, "the aside must use flex at base breakpoint"


def test_toggle_button_is_reachable_outside_a_hidden_container() -> None:
"""``#copy-theme-button`` is the only toggler of ``data-show`` on
``#toggle-wrapper``; it must live in a sidebar that renders on mobile.
"""
page = create_page()

toggle = _find_by_id(page, "copy-theme-button")
assert toggle is not None, "copy-theme-button toggle must exist"

aside = _sidebar_desktop()
aside_tokens = _tokens(_class_str(aside))
assert "hidden" not in aside_tokens, (
"the toggle lives inside the sidebar; the sidebar must not be "
"hidden at base breakpoint or the toggle is unreachable on mobile"
)

wrapper = _find_by_id(page, "toggle-wrapper")
assert wrapper is not None, "toggle-wrapper must exist"
custom_attrs = getattr(wrapper, "custom_attrs", {}) or {}
assert custom_attrs.get("data-show") == "false", (
"toggle-wrapper must start with data-show=false so the source "
"panel is hidden until toggled"
)


def test_toggle_flips_data_show_on_toggle_wrapper() -> None:
"""The toggle's on_click must mutate ``data-show`` on
``#toggle-wrapper`` so the source panel can be revealed on mobile.
"""
toggle = _find_by_id(create_page(), "copy-theme-button")
assert toggle is not None
triggers = getattr(toggle, "event_triggers", {}) or {}
on_click = triggers.get("on_click")
assert on_click is not None, "toggle must have an on_click handler"
script_src = str(on_click)
assert "toggle-wrapper" in script_src, "the toggle must target #toggle-wrapper"
assert "dataset.show" in script_src, (
"the toggle must flip data-show to reveal/hide the source panel"
)


def test_source_panel_is_revealable_on_mobile() -> None:
"""``#source-space`` must become visible on mobile when
``data-show`` is flipped to true; previously the panel was
``hidden group-data-[show=true]:flex`` but the only toggler was
hidden on mobile, so the panel could never appear.
"""
cls = source_space().class_name
assert isinstance(cls, str)
tokens = _tokens(cls)

assert "hidden" in tokens, "base visibility must be hidden (default)"
assert "group-data-[show=true]:flex" in tokens, (
"mobile reveal rule must trigger when #toggle-wrapper[data-show=true]"
)
assert "max-lg:group-data-[show=true]:flex-1" in tokens, (
"source panel must fill available height on mobile when revealed"
)
assert "max-lg:group-data-[show=true]:min-h-0" in tokens, (
"source panel must be allowed to shrink within flex on mobile"
)


def test_preview_hides_on_mobile_when_source_shown() -> None:
"""On mobile the preview must step aside when the source panel is
shown so the two don't compete for the small viewport; mirroring
``/typeset``'s ``max-lg:group-data-[show=true]:hidden`` on the preview.
"""
page = create_page()

wrapper = _find_by_id(page, "toggle-wrapper")
assert wrapper is not None, "toggle-wrapper must exist"
assert "group" in _tokens(_class_str(wrapper)), (
"toggle-wrapper must carry the ``group`` class so its "
"data-show state drives group-data-[show=true] descendants"
)

preview_tokens = _tokens(_class_str(preview_space()))
assert "max-lg:group-data-[show=true]:hidden" in preview_tokens, (
"preview must be hidden on mobile when #toggle-wrapper[data-show=true] "
"so it steps aside for the source panel"
)


def test_create_and_typeset_share_mobile_reachability_contract() -> None:
"""Quick parity check: both customizer pages must render their
sidebars on mobile (no ``hidden lg:flex``) and expose a source panel
that is revealable via a reachable toggle.
"""
from native.pages.typeset import (
_sidebar_desktop as typeset_sidebar, # pyright: ignore[reportPrivateUsage]
)

create_tokens = _tokens(_class_str(_sidebar_desktop()))
typeset_tokens = _tokens(_class_str(typeset_sidebar()))

assert "hidden" not in create_tokens, "create sidebar must render on mobile"
assert "hidden" not in typeset_tokens, "typeset sidebar must render on mobile"