From ac9add922e66a5971a0062440b2b2598639cade3 Mon Sep 17 00:00:00 2001 From: carlosabadia Date: Mon, 26 Oct 2026 11:12:09 +0100 Subject: [PATCH 1/2] Fix XY docs anchors and match the current marketing announcement --- docs/app/README.md | 10 +++- docs/app/assets/docs-header-theme.css | 55 ++++++++++++------ docs/app/tests/test_announcement.py | 82 +++++++++++++++++++++++++++ docs/app/tests/test_docs_site.py | 47 +++++++++++++-- docs/app/xy_docs/announcement.py | 63 ++++++++++++++++++++ docs/app/xy_docs/navbar.py | 3 + docs/app/xy_docs/xy_docs.py | 2 + docs/charts/bar-chart.md | 4 +- docs/charts/contour-plot.md | 2 +- docs/charts/line-chart.md | 2 +- spec/process/production-readiness.md | 20 ++++++- 11 files changed, 261 insertions(+), 29 deletions(-) create mode 100644 docs/app/tests/test_announcement.py create mode 100644 docs/app/xy_docs/announcement.py diff --git a/docs/app/README.md b/docs/app/README.md index 4a9f34190..a3a7104bf 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 1de1640b4..fd6c545eb 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; @@ -176,22 +188,31 @@ 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 000000000..381223bb1 --- /dev/null +++ b/docs/app/tests/test_announcement.py @@ -0,0 +1,82 @@ +"""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, 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) + expect(banner.locator("a")).to_have_accessible_name( + "New: Build vs. Buy for Internal Software — Read more (opens in a new tab)" + ) + expect(banner.locator("a")).to_have_attribute( + "href", "https://reflex.dev/blog/build-vs-buy-internal-software/" + ) + 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() + 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 bf0dd95d9..4eefaf816 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 4e1ba9759..461b63485 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 d30b9dc35..b7717c7cf 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 9a7b4f313..0e76fb42d 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 883efd39d..7f924b526 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 0d9aef9c6..170959d1d 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 044fdcf13..789caa168 100644 --- a/spec/process/production-readiness.md +++ b/spec/process/production-readiness.md @@ -72,11 +72,27 @@ 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 server +dismissal state 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. +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 From f7844ca7b88384402a467a86a4fdf6ab25c68968 Mon Sep 17 00:00:00 2001 From: carlosabadia Date: Mon, 26 Oct 2026 11:29:56 +0100 Subject: [PATCH 2/2] Fix docs mobile menu corners and strengthen announcement checks --- docs/app/assets/docs-header-theme.css | 2 +- docs/app/tests/test_announcement.py | 20 +++++++++++++++++--- spec/process/production-readiness.md | 6 ++++-- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/docs/app/assets/docs-header-theme.css b/docs/app/assets/docs-header-theme.css index fd6c545eb..d13f3d2bd 100644 --- a/docs/app/assets/docs-header-theme.css +++ b/docs/app/assets/docs-header-theme.css @@ -184,7 +184,7 @@ html[data-announcement-dismissed='true'] .docs-nav-frame [data-announcement] { d 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; } } diff --git a/docs/app/tests/test_announcement.py b/docs/app/tests/test_announcement.py index 381223bb1..3c1057f67 100644 --- a/docs/app/tests/test_announcement.py +++ b/docs/app/tests/test_announcement.py @@ -23,18 +23,21 @@ def test_production_announcement_matches_marketing_and_persists_dismissal() -> N }""" ) page = context.new_page() - for width in (1440, 960, 390, 320): + 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) - expect(banner.locator("a")).to_have_accessible_name( + 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(banner.locator("a")).to_have_attribute( + 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 ) @@ -43,6 +46,17 @@ def test_production_announcement_matches_marketing_and_persists_dismissal() -> N 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)" diff --git a/spec/process/production-readiness.md b/spec/process/production-readiness.md index 789caa168..ea80c413c 100644 --- a/spec/process/production-readiness.md +++ b/spec/process/production-readiness.md @@ -72,8 +72,7 @@ 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 structure and server -dismissal state 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. @@ -90,6 +89,9 @@ 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).