diff --git a/docs/app/README.md b/docs/app/README.md index 4a9f3419..a3a7104b 100644 --- a/docs/app/README.md +++ b/docs/app/README.md @@ -83,9 +83,15 @@ The three `assets/docs-*-theme.css` / `assets/docs-theme.css` stylesheets adapt the docs-only styling from [Reflex PR #7549](https://github.com/reflex-dev/reflex/pull/7549): Inter body text, Geist headings, warm neutral light/dark palettes, compact controls, and the opal header artwork. They are loaded after the shared base -theme. The header styles reuse the shared announcement markup and dismissal -state; the artwork URL includes the `/docs/xy` mount prefix. Fonts are +theme. The header reuses the shared announcement body and close event, adapting +its decorative markup and styling to `reflex-marketing`'s five opal panels, +44px desktop / 40px mobile contribution, and text-only CTA. The head script in +`xy_docs/announcement.py` uses marketing's release-scoped local-storage dismissal +key so dismissal survives reloads and same-origin navigation without a flash +or reserved gap. The artwork URL includes the `/docs/xy` mount prefix. Fonts are bundled from the pinned Fontsource frontend packages, without a font CDN. +Announcement title, destination, and release in `xy_docs/announcement.py` follow +marketing's main branch and must be updated together when its copy changes. Editorial styles use shared `docs-*` hooks and the hooks added by `xy_docs_layout`; keep them scoped so live chart examples retain their own typography and data colors. The shared Markdown layout still owns navigation diff --git a/docs/app/assets/docs-header-theme.css b/docs/app/assets/docs-header-theme.css index 1de1640b..d13f3d2b 100644 --- a/docs/app/assets/docs-header-theme.css +++ b/docs/app/assets/docs-header-theme.css @@ -1,6 +1,8 @@ /* XY docs header brand layer, adapted from reflex-dev/reflex#7549. */ -/* Docs keep a 64px fixed header and a 40px announcement contribution. - The banner artwork and glass treatment match the marketing site. */ +/* Keep the 64px docs header; match reflex-marketing's 44px desktop / 40px + mobile announcement, layered opal artwork, and persisted dismissal. */ +html:has(.docs-nav-frame) { --docs-header-height: 64px; } +html:not([data-announcement-dismissed='true']):has(.docs-nav-frame [data-announcement]) { --docs-header-height: 108px; } .docs-nav-frame { left: 0; --docs-control-shadow: 0 0 0 .5px rgb(0 0 0 / 12.5%), 0 1px 1px rgb(0 0 0 / 4%), 0 2px 4px rgb(0 0 0 / 4%); @@ -14,8 +16,8 @@ box-shadow: none; transition: border-radius 200ms ease, background-color 200ms ease; } -.docs-nav-frame:has([data-announcement]) > .docs-navbar-header { - margin-top: -24px; +html:not([data-announcement-dismissed='true']) .docs-nav-frame:has([data-announcement]) > .docs-navbar-header { + margin-top: -28px; border-radius: 24px 24px 0 0; } .docs-nav-frame > .docs-navbar-header > .docs-navbar-inner { @@ -71,8 +73,8 @@ .docs-nav-frame [data-announcement] { position: relative; isolation: isolate; - height: 64px; - padding-bottom: 24px; + height: 72px; + padding-bottom: 28px; width: 100%; overflow: hidden; background: #e8e7f1; @@ -80,17 +82,26 @@ border: 0; font-family: Inter, Arial, sans-serif; } -/* Shared banner content, links, and state stay in reflex-site-shared. */ -.docs-nav-frame [data-announcement]::before { - content: ''; +html[data-announcement-dismissed='true'] .docs-nav-frame [data-announcement] { display: none; } +/* Five mirrored artwork panels match reflex-marketing/assets/marketing.css. */ +.docs-nav-frame .docs-announcement-glass { + display: flex; position: absolute; z-index: -2; inset: -48px -12%; pointer-events: none; - background: url('/docs/xy/header/opal.avif') center 38% / cover no-repeat; filter: blur(6px); + will-change: transform; animation: docs-announcement-glass-drift 26s ease-in-out infinite alternate; } +.docs-announcement-glass > span { + flex: 1; + background: url('/docs/xy/header/opal.avif') center 22% / cover no-repeat; +} +.docs-announcement-glass > span:nth-child(2) { background-position: center 62%; transform: scaleX(-1); } +.docs-announcement-glass > span:nth-child(3) { background-position: center 38%; } +.docs-announcement-glass > span:nth-child(4) { background-position: center 78%; transform: scaleX(-1); } +.docs-announcement-glass > span:nth-child(5) { background-position: center 50%; } .docs-nav-frame [data-announcement]::after { content: ''; position: absolute; @@ -146,10 +157,11 @@ box-shadow: var(--docs-control-shadow); white-space: nowrap; } +.docs-nav-frame [data-announcement] > a > span:first-child > span:nth-child(3) svg { display: none; } .docs-nav-frame [data-announcement] > button { position: absolute; - /* The shared button's -translate-y-1/2 centers its 28px box at y=20. */ - top: 20px; + /* Center in marketing's visible 44px announcement contribution. */ + top: 22px; right: 16px; display: inline-flex; align-items: center; @@ -172,26 +184,35 @@ to { transform: translate3d(5%, 0, 0) scale(1); } } @media (max-width: 1279px) { - .docs-nav-frame:has(.docs-navbar-menu[open]) > .docs-navbar-header { + html .docs-nav-frame:has(.docs-navbar-menu[open]) > .docs-navbar-header { border-radius: 0; } } +@media (max-width: 960px) { + html:not([data-announcement-dismissed='true']):has(.docs-nav-frame [data-announcement]) { --docs-header-height: 104px; } + .docs-nav-frame [data-announcement] { height: 64px; padding-bottom: 24px; } + html:not([data-announcement-dismissed='true']) .docs-nav-frame:has([data-announcement]) > .docs-navbar-header { margin-top: -24px; } + .docs-nav-frame [data-announcement] > button { top: 20px; } +} @media (max-width: 640px) { .docs-nav-frame [data-announcement] > a > span:first-child > span:nth-child(3) { display: none; } + .docs-nav-frame [data-announcement] > a > span:first-child > span:nth-child(2) { + min-width: 0; + white-space: normal; + text-align: center; + line-height: 18px; + } } @media (max-width: 639px) { .docs-nav-frame > .docs-navbar-header { padding-inline: 16px; } .docs-navbar-inner { gap: 16px; } .docs-navbar-brand { gap: 10px; } .docs-navbar-mobile-panel { padding-inline: 16px; } - .docs-nav-frame [data-announcement] > a > span:first-child { padding-inline: 40px; gap: 8px; } + .docs-nav-frame [data-announcement] > a > span:first-child { padding-inline: 40px; } .docs-nav-frame [data-announcement] > button { right: 8px; } } -@media (max-width: 379px) { - .docs-nav-frame [data-announcement] > a > span:first-child > span:nth-child(2) { font-size: 13px; } -} @media (prefers-reduced-motion: reduce) { - .docs-nav-frame [data-announcement]::before { animation: none; } + .docs-nav-frame .docs-announcement-glass { animation: none; will-change: auto; } .docs-nav-frame > .docs-navbar-header, .docs-navbar-mobile-link { transition: none; } } diff --git a/docs/app/tests/test_announcement.py b/docs/app/tests/test_announcement.py new file mode 100644 index 00000000..3c1057f6 --- /dev/null +++ b/docs/app/tests/test_announcement.py @@ -0,0 +1,96 @@ +"""Production contracts for the announcement shared with reflex-marketing.""" + +import os + +import pytest +from xy_docs.announcement import ANNOUNCEMENT_RELEASE + + +def test_production_announcement_matches_marketing_and_persists_dismissal() -> None: + """Check responsive artwork, storage, reloads, and hidden-header geometry.""" + base_url = os.environ.get("XY_DOCS_BASE_URL") + if base_url is None: + pytest.skip("XY_DOCS_BASE_URL is only set for the production-DOM check") + + from playwright.sync_api import expect, sync_playwright + + with sync_playwright() as playwright: + browser = playwright.chromium.launch() + context = browser.new_context() + context.add_init_script( + """if (!localStorage.getItem('reflex_announcement_dismissed_release')) { + localStorage.setItem('reflex_announcement_dismissed_release', 'xy-in-reflex-build-v1'); + }""" + ) + page = context.new_page() + for width in (1440, 1280, 1279, 960, 390, 320): + page.set_viewport_size({"width": width, "height": 900}) + page.goto(base_url, wait_until="networkidle") + banner = page.locator("[data-announcement]") + expect(banner).to_be_visible() + expect(banner.locator(".docs-announcement-glass > span")).to_have_count(5) + link = banner.locator("a") + expect(link).to_have_accessible_name( + "New: Build vs. Buy for Internal Software — Read more (opens in a new tab)" + ) + expect(link).to_have_attribute( + "href", "https://reflex.dev/blog/build-vs-buy-internal-software/" + ) + expect(link).to_have_attribute("target", "_blank") + assert {"noopener", "noreferrer"} <= set((link.get_attribute("rel") or "").split()) + assert page.locator(".docs-nav-frame").bounding_box()["height"] == ( + 108 if width > 960 else 104 + ) + message = banner.locator(".announcement-shimmer").bounding_box() + assert 0 <= message["x"] < message["x"] + message["width"] <= width + cta = banner.get_by_text("Read more", exact=True) + assert cta.is_visible() == (width > 640) + assert not banner.locator("a svg").is_visible() + header = page.locator(".docs-navbar-header") + expect(header).to_have_css("border-top-left-radius", "24px") + expect(header).to_have_css("border-top-right-radius", "24px") + if width < 1280: + menu = page.locator(".docs-navbar-menu > summary") + menu.click() + expect(header).to_have_css("border-top-left-radius", "0px") + expect(header).to_have_css("border-top-right-radius", "0px") + menu.click() + expect(header).to_have_css("border-top-left-radius", "24px") + expect(header).to_have_css("border-top-right-radius", "24px") + for color_mode in ("light", "dark"): + page.emulate_media(color_scheme=color_mode, reduced_motion="reduce") + assert banner.evaluate("el => getComputedStyle(el).color") == "rgb(0, 0, 0)" + assert ( + banner.locator(".docs-announcement-glass").evaluate( + "el => getComputedStyle(el).animationName" + ) + == "none" + ) + + # A second open tab receives marketing's same-origin storage event. + other = context.new_page() + other.goto(base_url, wait_until="networkidle") + page.get_by_role("button", name="Dismiss announcement").click() + for current in (page, other): + expect(current.locator("[data-announcement]")).to_be_hidden() + expect(current.locator(".docs-nav-frame")).to_have_css("height", "64px") + assert ( + page.evaluate("localStorage.getItem('reflex_announcement_dismissed_release')") + == ANNOUNCEMENT_RELEASE + ) + page.reload(wait_until="networkidle") + expect(page.locator("[data-announcement]")).to_be_hidden() + expect(page.locator(".docs-nav-frame")).to_have_css("height", "64px") + context.close() + + blocked = browser.new_context() + blocked.add_init_script( + "Object.defineProperty(window, 'localStorage', {get() {throw new Error('blocked')}})" + ) + page = blocked.new_page() + page.goto(base_url, wait_until="networkidle") + expect(page.locator("[data-announcement]")).to_be_visible() + page.get_by_role("button", name="Dismiss announcement").click() + expect(page.locator("[data-announcement]")).to_be_hidden() + expect(page.locator(".docs-nav-frame")).to_have_css("height", "64px") + browser.close() diff --git a/docs/app/tests/test_docs_site.py b/docs/app/tests/test_docs_site.py index bf0dd95d..4eefaf81 100644 --- a/docs/app/tests/test_docs_site.py +++ b/docs/app/tests/test_docs_site.py @@ -11,7 +11,7 @@ from collections.abc import Iterator from pathlib import Path from types import SimpleNamespace -from urllib.parse import urlparse +from urllib.parse import unquote, urlparse import pytest from reflex_base.components.memo import MemoComponent @@ -149,6 +149,17 @@ def _walk_blocks(blocks: tuple[Block, ...]) -> Iterator[LinkSpan]: yield from _walk_spans(cell.children) +def _rendered_ids(component: object) -> Iterator[str]: + """Collect literal IDs from the rendered component tree.""" + value = getattr(component, "id", None) + if not isinstance(value, str): + value = getattr(value, "_var_value", None) + if isinstance(value, str): + yield value + for child in getattr(component, "children", ()): + yield from _rendered_ids(child) + + def _normalize_xy_docs_path(url: str) -> str | None: """Return an app-relative route for a canonical XY docs URL. @@ -645,6 +656,24 @@ def test_markdown_heading_links_are_route_local_after_client_navigation() -> Non assert "router_rx_state_" not in rendered +def test_authored_in_page_links_resolve_to_rendered_ids() -> None: + """Check actual fragment targets, including punctuation kept in heading IDs.""" + failures = [] + for page in discover_docs(DOCS_CONFIG): + links = [ + link + for link in _walk_blocks(parse_document(page.content).blocks) + if link.target.startswith("#") + ] + if not links: + continue + targets = set(_rendered_ids(render_xy_markdown_page(page))) + failures.extend( + (page.route, link.target) for link in links if unquote(link.target[1:]) not in targets + ) + assert not failures, failures + + def test_colorbar_docs_match_the_declarative_and_custom_boundaries() -> None: """Keep inferred built-ins distinct from opaque framework replacements.""" content = (DOCS_ROOT / "components/colorbars.md").read_text(encoding="utf-8") @@ -2453,9 +2482,19 @@ def test_xy_navbar_uses_xy_links_github_and_the_official_drawer() -> None: assert " None: + """Adapt shared markup to reflex-marketing without copying its banner body.""" + if "data-announcement" in component.custom_attrs: + link, dismiss = component.children + link.href = rx.Var.create(ANNOUNCEMENT_URL) + link.custom_attrs["aria-label"] = ( + f"New: {ANNOUNCEMENT_TITLE} — Read more (opens in a new tab)" + ) + link.children = link.children[:1] + link.children[0].children[0].custom_attrs["aria-hidden"] = True + message = link.children[0].children[1] + message.children = rx.fragment(ANNOUNCEMENT_TITLE).children + message.custom_attrs["data-shimmer-text"] = ANNOUNCEMENT_TITLE + dismiss.custom_attrs["data-announcement-dismiss"] = "" + component.children.insert( + 0, + rx.el.div( + *[rx.el.span() for _ in range(5)], + class_name="docs-announcement-glass", + aria_hidden=True, + ), + ) + return + for child in component.children: + match_marketing_announcement(child) + + +def announcement_visibility_script() -> str: + """Restore marketing's release-scoped dismissal before the page paints.""" + return """(() => { + const release = __RELEASE__; + const key = 'reflex_announcement_dismissed_release'; + const sync = () => { + let dismissed = false; + try { dismissed = localStorage.getItem(key) === release; } catch (_) {} + if (dismissed) document.documentElement.dataset.announcementDismissed = 'true'; + else delete document.documentElement.dataset.announcementDismissed; + }; + sync(); + if (window.__xyAnnouncementVisibilityInstalled) return; + window.__xyAnnouncementVisibilityInstalled = true; + document.addEventListener('click', (event) => { + if (!(event.target instanceof Element) || + !event.target.closest('.docs-nav-frame [data-announcement-dismiss]')) return; + try { localStorage.setItem(key, release); } catch (_) {} + document.documentElement.dataset.announcementDismissed = 'true'; + }, true); + window.addEventListener('storage', (event) => { + if (event.key === key || event.key === null) sync(); + }); +})();""".replace("__RELEASE__", json.dumps(ANNOUNCEMENT_RELEASE)) diff --git a/docs/app/xy_docs/navbar.py b/docs/app/xy_docs/navbar.py index 4e1ba975..461b6348 100644 --- a/docs/app/xy_docs/navbar.py +++ b/docs/app/xy_docs/navbar.py @@ -7,6 +7,8 @@ from reflex_site_shared.components.icons import get_icon from reflex_site_shared.components.marketing_button import button +from xy_docs.announcement import match_marketing_announcement + XY_REPOSITORY_URL = "https://github.com/reflex-dev/xy" _XY_NAV_LINKS = ( @@ -211,6 +213,7 @@ def xy_docs_navbar() -> rx.Component: ), _navigation_menu(), ) + match_marketing_announcement(frame.children[0]) header = frame.children[1] header.class_name = "docs-navbar-header " + header.class_name header.children[0].class_name = "docs-navbar-inner " + header.children[0].class_name diff --git a/docs/app/xy_docs/xy_docs.py b/docs/app/xy_docs/xy_docs.py index d30b9dc3..b7717c7c 100644 --- a/docs/app/xy_docs/xy_docs.py +++ b/docs/app/xy_docs/xy_docs.py @@ -10,6 +10,7 @@ from reflex_site_shared.templates.docs import docs_layout from reflex_site_shared.utils.docpage import right_sidebar_item_highlight +from xy_docs.announcement import announcement_visibility_script from xy_docs.breadcrumb import xy_docs_breadcrumb from xy_docs.code import CODE_COPY_STYLE, code_copy_feedback_script from xy_docs.config import DOCS_CONFIG, DOCS_REDIRECTS @@ -55,6 +56,7 @@ def _llms_txt_directive() -> rx.Component: accent_color="gray", ), head_components=[ + rx.el.script(announcement_visibility_script()), *get_pixel_website_trackers(), rx.el.style(CODE_COPY_STYLE), rx.el.meta(name="application-name", content="XY"), diff --git a/docs/charts/bar-chart.md b/docs/charts/bar-chart.md index 9a7b4f31..0e76fb42 100644 --- a/docs/charts/bar-chart.md +++ b/docs/charts/bar-chart.md @@ -14,11 +14,11 @@ Python bar chart that is interactive by default — pan, zoom, and hover all wor without configuration — and horizontal bars keep long category labels readable where a vertical layout would crowd them. Layer several series to make grouped or stacked bar charts from the same simple API. The vertical form of the same -mark is a **column chart** — see [Column Charts](#column-charts-vertical-bars) +mark is a **column chart** — see [Column Charts](#column-charts-(vertical-bars)) below. Jump to [grouped and stacked bars](#grouped-and-stacked-bars), -[column charts](#column-charts-vertical-bars), or +[column charts](#column-charts-(vertical-bars)), or [options](#bar-chart-options). ## Create a Bar Chart diff --git a/docs/charts/contour-plot.md b/docs/charts/contour-plot.md index 883efd39..7f924b52 100644 --- a/docs/charts/contour-plot.md +++ b/docs/charts/contour-plot.md @@ -13,7 +13,7 @@ interactive contour plot from a NumPy grid: choose the number of levels, fill between them or keep clean lines, and pan, zoom, and hover work by default. Jump to [creating a contour plot](#create-a-contour-plot), -[levels and fills](#levels-filled-and-line-contours), or +[levels and fills](#levels,-filled,-and-line-contours), or [the options table](#contour-plot-options). ## Create a Contour Plot diff --git a/docs/charts/line-chart.md b/docs/charts/line-chart.md index 0d9aef9c..170959d1 100644 --- a/docs/charts/line-chart.md +++ b/docs/charts/line-chart.md @@ -14,7 +14,7 @@ that stays smooth at millions of points: pan, zoom, and hover work by default, and long lines are decimated to the visible resolution and refined as you zoom. Jump to [multiple series](#plot-multiple-line-series), -[smooth curves](#smooth-vs-straight-lines), or +[smooth curves](#smooth-vs.-straight-lines), or [large and real-time data](#large-and-real-time-line-charts). ## Create a Line Chart diff --git a/spec/process/production-readiness.md b/spec/process/production-readiness.md index 044fdcf1..ea80c413 100644 --- a/spec/process/production-readiness.md +++ b/spec/process/production-readiness.md @@ -72,11 +72,29 @@ The local layer uses Inter body text, Geist headings, warm neutral light/dark tokens, pill navigation and demo tabs, compact two-column Back/Next links, and the opal banner artwork without a white top-edge inset shadow. Fonts ship through pinned Fontsource packages; the artwork URL preserves the `/docs/xy` mount prefix. -Banner content, dismissal state, -and header-height tracking remain shared. Editorial selectors target `docs-*` +Banner structure and the close event remain shared. Editorial selectors target `docs-*` hooks, avoiding live chart typography and data-series colors. Mobile navigation, the named closeable docs drawer, clipboard feedback, keyboard focus, and reduced-motion behavior remain required validation surfaces. +XY's announcement matches `reflex-marketing`'s current main branch: +"Build vs. Buy for Internal Software" links to +`https://reflex.dev/blog/build-vs-buy-internal-software/`, with five mirrored +opal panels with reduced-motion support, a black New +badge, and a white text-only Read more pill hidden at 640px and below. Its visible +contribution is 44px on desktop and 40px at 960px and below, above the fixed 64px +docs header. A head script restores the same `reflex_announcement_dismissed_release` +storage key and `build-vs-buy-internal-software-v1` release before paint; dismissal persists +across reloads and same-origin marketing/docs navigation, with a working +visit-local fallback when storage is unavailable. Header and sidebar offsets +reserve only the visible announcement. The wrapper retains the shared native +link and close event while adapting its destination, copy, decorative markup, +and accessible name. The message wraps at narrow widths as it does in marketing. +Below 1280px, opening the navigation menu removes both top header corners; +closing it restores the announcement's rounded header. Announcement links open +in a new tab with `noopener noreferrer`, matching their accessible name. +Authored in-page links must +resolve to rendered IDs, including the parentheses retained by the shared +heading slug convention (lowercase text with spaces replaced by hyphens). Unpublished shared changes are previewed with the editable-package workflow in `docs/app/README.md`, followed by production compilation and the docs validators below. The portable Git lock includes the merged design's shared