diff --git a/dashboard/__main__.py b/dashboard/__main__.py index 664ce3bfa5..c42ac68ec3 100644 --- a/dashboard/__main__.py +++ b/dashboard/__main__.py @@ -10,6 +10,11 @@ from dash import dcc, html from dash.dependencies import Input, Output from dashboard.config import folder_path_overview +from dashboard.lib.cloud_promo import ( + cloud_promo_card, + cloud_tour_modal, + register_cloud_promo_callbacks, +) from prowler.config.config import orange_color from prowler.lib.banner import print_banner @@ -20,7 +25,7 @@ print_banner() print( f"{Fore.GREEN}Loading all CSV files from the folder {folder_path_overview} ...\n{Style.RESET_ALL}" ) -cli.show_server_banner = lambda *x: click.echo( +cli.show_server_banner = lambda *_: click.echo( f"{Fore.YELLOW}NOTE:{Style.RESET_ALL} If you are using {Fore.GREEN}{Style.BRIGHT}Prowler Cloud{Style.RESET_ALL} with the S3 integration or that integration \nfrom {Fore.CYAN}{Style.BRIGHT}Prowler CLI{Style.RESET_ALL} and you want to use your data from your S3 bucket,\nrun: `{orange_color}aws s3 cp s3:///output/csv ./output --recursive{Style.RESET_ALL}`\nand then run `prowler dashboard` again to load the new files." ) @@ -136,6 +141,8 @@ dashboard.layout = html.Div( ], className="grid custom-grid 2xl:custom-grid-large h-screen", ), + # Prowler Cloud product tour (image-only experience). + cloud_tour_modal(), ], className="h-screen mx-auto", ) @@ -157,20 +164,7 @@ def update_nav_bar(pathname): ), html.Nav( [ - html.A( - [ - html.Span( - [ - html.Img(src="assets/favicon.ico", className="w-5"), - "Subscribe to Prowler Cloud", - ], - className="flex items-center gap-x-3 text-white", - ), - ], - href="https://prowler.com/", - target="_blank", - className="block p-3 uppercase text-xs hover:bg-prowler-stone-950 hover:border-r-4 hover:border-solid hover:border-prowler-lime", - ), + cloud_promo_card(), html.Ul(generate_help_menu(), className=""), ], className="flex flex-col gap-y-6 mt-auto", @@ -178,3 +172,7 @@ def update_nav_bar(pathname): ], className="flex flex-col bg-prowler-stone-900 py-7 h-full", ) + + +# Wire the Prowler Cloud product tour open/close/navigation callbacks. +register_cloud_promo_callbacks(dashboard) diff --git a/dashboard/assets/cloud-promo.css b/dashboard/assets/cloud-promo.css new file mode 100644 index 0000000000..4edf3ca7a9 --- /dev/null +++ b/dashboard/assets/cloud-promo.css @@ -0,0 +1,269 @@ +/* Prowler Cloud promo: sidebar hero + product tour modal. + Styled to match the new Prowler Cloud app (cool-dark surface + mint accent), + intentionally distinct from the warm stone sidebar so it stands out. */ + +:root { + --cloud-bg: #0a0e16; + --cloud-bg-2: #111a2b; + --cloud-mint: #6ee7b7; + --cloud-mint-2: #2dd4bf; + --cloud-ink: #e6f0ef; + --cloud-muted: #9fb1c4; +} + +/* ---------- Sidebar hero card ---------- */ +.cloud-promo-card-wrap { + padding: 0 0.75rem; + margin-top: 0.5rem; +} + +.cloud-promo-card { + position: relative; + display: flex; + flex-direction: column; + gap: 0.4rem; + padding: 0.95rem 0.95rem 0.85rem; + border-radius: 14px; + cursor: pointer; + overflow: hidden; + color: var(--cloud-ink); + background: + radial-gradient(120% 120% at 0% 0%, rgba(45, 212, 191, 0.18) 0%, transparent 55%), + linear-gradient(160deg, var(--cloud-bg-2) 0%, var(--cloud-bg) 100%); + border: 1px solid rgba(110, 231, 183, 0.28); + box-shadow: 0 10px 24px -14px rgba(45, 212, 191, 0.55); + transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease; +} + +.cloud-promo-card:hover { + transform: translateY(-2px); + border-color: rgba(110, 231, 183, 0.6); + box-shadow: 0 16px 30px -12px rgba(45, 212, 191, 0.7); +} + +/* Soft sheen sweep on hover. */ +.cloud-promo-card::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(115deg, transparent 30%, rgba(255, 255, 255, 0.08) 50%, transparent 70%); + transform: translateX(-120%); + transition: transform 0.6s ease; +} +.cloud-promo-card:hover::after { + transform: translateX(120%); +} + +.cloud-promo-eyebrow { + font-size: 0.625rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--cloud-mint); +} + +.cloud-promo-title { + font-size: 0.95rem; + font-weight: 700; + line-height: 1.25; + color: #ffffff; +} + +.cloud-promo-cta { + display: inline-flex; + align-items: center; + gap: 0.35rem; + margin-top: 0.15rem; + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.02em; + color: var(--cloud-mint); +} + +.cloud-promo-arrow { + transition: transform 0.18s ease; +} +.cloud-promo-card:hover .cloud-promo-arrow { + transform: translateX(3px); +} + +/* ---------- Tour modal ---------- */ +.cloud-tour-overlay { + position: fixed; + inset: 0; + z-index: 1000; + align-items: center; + justify-content: center; + padding: 2rem; +} + +.cloud-tour-backdrop { + position: absolute; + inset: 0; + background: rgba(4, 7, 12, 0.74); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + cursor: pointer; +} + +.cloud-tour-dialog { + position: relative; + z-index: 1; + width: min(960px, 100%); + max-height: 92vh; + overflow: hidden; + display: flex; + flex-direction: column; + border-radius: 18px; + background: linear-gradient(180deg, var(--cloud-bg-2) 0%, var(--cloud-bg) 100%); + border: 1px solid rgba(110, 231, 183, 0.22); + box-shadow: 0 40px 90px -30px rgba(0, 0, 0, 0.85); + animation: cloud-tour-pop 0.22s ease; +} + +@keyframes cloud-tour-pop { + from { opacity: 0; transform: translateY(10px) scale(0.985); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +.cloud-tour-close { + position: absolute; + top: 0.75rem; + right: 0.75rem; + z-index: 2; + width: 32px; + height: 32px; + border: none; + border-radius: 9px; + cursor: pointer; + font-size: 0.9rem; + color: var(--cloud-ink); + background: rgba(255, 255, 255, 0.08); + transition: background 0.15s ease; +} +.cloud-tour-close:hover { + background: rgba(255, 255, 255, 0.18); +} + +.cloud-tour-stage { + padding: 1.4rem 1.4rem 0; + background: + radial-gradient(80% 60% at 50% 0%, rgba(45, 212, 191, 0.12) 0%, transparent 60%); +} + +.cloud-tour-image { + display: block; + margin: 0 auto; + /* Fixed 2:1 frame keeps the stage height identical across slides, while the + viewport cap guarantees the caption + nav controls always stay visible + (never clipped by the dialog's max-height). Tour images are pre-composited + to 2000x1000 (2:1), so the ratio is honored with no cropping. */ + aspect-ratio: 2 / 1; + max-width: 100%; + max-height: 48vh; + width: auto; + height: auto; + object-fit: contain; + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.08); + box-shadow: 0 24px 50px -24px rgba(0, 0, 0, 0.9); +} + +.cloud-tour-caption { + padding: 1.1rem 1.6rem 0.4rem; +} + +.cloud-tour-eyebrow { + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--cloud-mint); +} + +.cloud-tour-title { + margin: 0.35rem 0 0.4rem; + font-size: 1.4rem; + font-weight: 700; + color: #ffffff; +} + +.cloud-tour-text { + margin: 0; + font-size: 0.95rem; + line-height: 1.5; + color: var(--cloud-muted); + max-width: 60ch; + /* Reserve ~3 lines so captions of different lengths don't shift height. */ + min-height: 4.3rem; +} + +a.cloud-tour-cta { + align-items: center; + gap: 0.4rem; + margin-top: 0.9rem; + padding: 0.6rem 1.1rem; + border-radius: 10px; + font-size: 0.85rem; + font-weight: 700; + text-decoration: none; + color: #04231d; + background: linear-gradient(135deg, var(--cloud-mint) 0%, var(--cloud-mint-2) 100%); + box-shadow: 0 12px 24px -12px rgba(45, 212, 191, 0.8); + transition: transform 0.15s ease, box-shadow 0.15s ease; +} +a.cloud-tour-cta:hover { + transform: translateY(-1px); + box-shadow: 0 16px 30px -12px rgba(45, 212, 191, 0.95); +} + +.cloud-tour-controls { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.9rem 1.6rem 1.4rem; +} + +.cloud-tour-nav { + width: 38px; + height: 38px; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 10px; + cursor: pointer; + font-size: 1.2rem; + line-height: 1; + color: var(--cloud-ink); + background: rgba(255, 255, 255, 0.05); + transition: background 0.15s ease, border-color 0.15s ease; +} +.cloud-tour-nav:hover { + background: rgba(110, 231, 183, 0.14); + border-color: rgba(110, 231, 183, 0.5); +} + +.cloud-tour-dots { + display: flex; + align-items: center; + gap: 0.45rem; +} + +.cloud-tour-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.22); + transition: background 0.15s ease, transform 0.15s ease; +} + +.cloud-tour-dot--active { + background: linear-gradient(135deg, var(--cloud-mint) 0%, var(--cloud-mint-2) 100%); + transform: scale(1.25); +} + +@media (max-width: 640px) { + .cloud-tour-title { font-size: 1.15rem; } + .cloud-tour-stage { padding: 1rem 1rem 0; } + .cloud-tour-caption { padding: 0.9rem 1rem 0.3rem; } + .cloud-tour-controls { padding: 0.8rem 1rem 1.1rem; } +} diff --git a/dashboard/assets/images/cloud/tour-1-overview.png b/dashboard/assets/images/cloud/tour-1-overview.png new file mode 100644 index 0000000000..47f1b584fa Binary files /dev/null and b/dashboard/assets/images/cloud/tour-1-overview.png differ diff --git a/dashboard/assets/images/cloud/tour-2-attack-paths.png b/dashboard/assets/images/cloud/tour-2-attack-paths.png new file mode 100644 index 0000000000..41c4997dc9 Binary files /dev/null and b/dashboard/assets/images/cloud/tour-2-attack-paths.png differ diff --git a/dashboard/assets/images/cloud/tour-3-lighthouse.png b/dashboard/assets/images/cloud/tour-3-lighthouse.png new file mode 100644 index 0000000000..3458995c54 Binary files /dev/null and b/dashboard/assets/images/cloud/tour-3-lighthouse.png differ diff --git a/dashboard/assets/images/cloud/tour-4-scans.png b/dashboard/assets/images/cloud/tour-4-scans.png new file mode 100644 index 0000000000..7508210826 Binary files /dev/null and b/dashboard/assets/images/cloud/tour-4-scans.png differ diff --git a/dashboard/assets/images/cloud/tour-5-compliance.png b/dashboard/assets/images/cloud/tour-5-compliance.png new file mode 100644 index 0000000000..5f748a46be Binary files /dev/null and b/dashboard/assets/images/cloud/tour-5-compliance.png differ diff --git a/dashboard/assets/images/cloud/tour-6-integrations.png b/dashboard/assets/images/cloud/tour-6-integrations.png new file mode 100644 index 0000000000..a6d7c82074 Binary files /dev/null and b/dashboard/assets/images/cloud/tour-6-integrations.png differ diff --git a/dashboard/assets/images/cloud/tour-7-alerts.png b/dashboard/assets/images/cloud/tour-7-alerts.png new file mode 100644 index 0000000000..c36ec85128 Binary files /dev/null and b/dashboard/assets/images/cloud/tour-7-alerts.png differ diff --git a/dashboard/assets/images/cloud/tour-8-import.png b/dashboard/assets/images/cloud/tour-8-import.png new file mode 100644 index 0000000000..c8bbd3fc56 Binary files /dev/null and b/dashboard/assets/images/cloud/tour-8-import.png differ diff --git a/dashboard/lib/cloud_promo.py b/dashboard/lib/cloud_promo.py new file mode 100644 index 0000000000..1588afe150 --- /dev/null +++ b/dashboard/lib/cloud_promo.py @@ -0,0 +1,328 @@ +"""Prowler Cloud promo: sidebar hero + image-only product tour. + +Drives CLI dashboard users toward Prowler Cloud. The sidebar shows a catchy, +on-brand hero card; clicking it opens a modal that walks through the real +Prowler Cloud experience using product screenshots. + +The visual styling lives in ``dashboard/assets/cloud-promo.css`` (Dash loads +every CSS file under ``assets/`` automatically, so this does not depend on the +precompiled Tailwind bundle). +""" + +from dash import dcc, html +from dash.dependencies import Input, Output, State + +# Where every CTA points. Single source of truth so links stay consistent. +CLOUD_URL = "https://prowler.com/" + +# Ordered product tour. Each slide is one real Prowler Cloud screenshot plus a +# short, benefit-led caption. The last slide carries the closing call to action. +TOUR_SLIDES = [ + { + "image": "/assets/images/cloud/tour-1-overview.png", + "eyebrow": "01 · Overview", + "title": "Your whole cloud, one view", + "text": ( + "Prowler ThreatScore, severity breakdown, resource inventory and " + "attack surface — thousands of findings, prioritized at a glance." + ), + }, + { + "image": "/assets/images/cloud/tour-2-attack-paths.png", + "eyebrow": "02 · Attack Paths", + "title": "See the attack path before they do", + "text": ( + "Visualize how attackers chain misconfigurations across roles, " + "policies and identities to reach your crown jewels." + ), + }, + { + "image": "/assets/images/cloud/tour-3-lighthouse.png", + "eyebrow": "03 · Lighthouse AI", + "title": "Ask your cloud anything", + "text": ( + "Lighthouse AI + MCP delivers autonomous triage, prioritization and " + "remediation — risk answers in plain English, not spreadsheets." + ), + }, + { + "image": "/assets/images/cloud/tour-4-scans.png", + "eyebrow": "04 · Continuous scanning", + "title": "Scan every account, on a schedule", + "text": ( + "Onboard whole organizations and scan all your AWS, Azure, GCP, " + "M365 and Kubernetes accounts at once, with full history and trends." + ), + }, + { + "image": "/assets/images/cloud/tour-5-compliance.png", + "eyebrow": "05 · Compliance", + "title": "50+ frameworks, always live", + "text": ( + "CIS, NIST, PCI DSS, ISO 27001, HIPAA, SOC 2, ENS and more — scored " + "continuously with one-click evidence export." + ), + }, + { + "image": "/assets/images/cloud/tour-6-integrations.png", + "eyebrow": "06 · Integrations", + "title": "Plugs into your stack", + "text": ( + "Jira, Slack, AWS Security Hub, Amazon S3, SAML SSO and RBAC — push " + "findings where your team already works." + ), + }, + { + "image": "/assets/images/cloud/tour-7-alerts.png", + "eyebrow": "07 · Alerts", + "title": "Stay ahead with smart alerts", + "text": ( + "Get notified the moment findings match your conditions — daily " + "digests or real-time, routed to the people who need them." + ), + }, + { + "image": "/assets/images/cloud/tour-8-import.png", + "eyebrow": "From CLI to Cloud", + "title": "Bring what you already have", + "text": ( + "Already scanning with the Prowler CLI? Import your existing findings " + "into Prowler Cloud in one click — no rescan, no lost history." + ), + "cta": "Start free on Prowler Cloud", + }, +] + +# Component ids reused by the layout and the callbacks. +TRIGGER_ID = "cloud-promo-trigger" +CLOSE_ID = "cloud-tour-close" +BACKDROP_ID = "cloud-tour-backdrop" +PREV_ID = "cloud-tour-prev" +NEXT_ID = "cloud-tour-next" +OVERLAY_ID = "cloud-tour-overlay" +OPEN_STORE_ID = "cloud-tour-open" +INDEX_STORE_ID = "cloud-tour-index" + +_OVERLAY_HIDDEN = {"display": "none"} +_OVERLAY_VISIBLE = {"display": "flex"} + + +def next_tour_state(trigger_id: str, is_open: bool, index: int, total: int): + """Pure reducer for the tour modal. + + Returns the next ``(is_open, index)`` given which control fired. Kept free of + Dash so the navigation logic is unit-testable in isolation. + """ + if total <= 0: + return False, 0 + if trigger_id == TRIGGER_ID: + return True, 0 + if trigger_id in (CLOSE_ID, BACKDROP_ID): + return False, index + if trigger_id == NEXT_ID: + return is_open, (index + 1) % total + if trigger_id == PREV_ID: + return is_open, (index - 1) % total + return is_open, index + + +def cloud_promo_card() -> html.Div: + """Catchy, on-brand sidebar hero. Click opens the product tour.""" + return html.Div( + html.Div( + [ + html.Span("Prowler Cloud", className="cloud-promo-eyebrow"), + html.Span( + "Your cloud, continuously secured.", + className="cloud-promo-title", + ), + html.Span( + [ + "See it in action", + html.Span("→", className="cloud-promo-arrow"), + ], + className="cloud-promo-cta", + ), + ], + className="cloud-promo-card", + id=TRIGGER_ID, + n_clicks=0, + ), + className="cloud-promo-card-wrap", + ) + + +def _slide_dots(total: int) -> list: + return [ + html.Span(className="cloud-tour-dot", id={"type": "cloud-tour-dot", "index": i}) + for i in range(total) + ] + + +def cloud_tour_modal() -> html.Div: + """Root-level modal that plays the image-only Prowler Cloud tour.""" + first = TOUR_SLIDES[0] + total = len(TOUR_SLIDES) + return html.Div( + [ + dcc.Store(id=OPEN_STORE_ID, data=False), + dcc.Store(id=INDEX_STORE_ID, data=0), + html.Div( + [ + html.Div( + id=BACKDROP_ID, className="cloud-tour-backdrop", n_clicks=0 + ), + html.Div( + [ + html.Button( + "✕", + id=CLOSE_ID, + className="cloud-tour-close", + n_clicks=0, + **{"aria-label": "Close tour"}, + ), + html.Div( + html.Img( + id="cloud-tour-image", + src=first["image"], + className="cloud-tour-image", + alt="Prowler Cloud product screenshot", + ), + className="cloud-tour-stage", + ), + html.Div( + [ + html.Span( + first["eyebrow"], + id="cloud-tour-eyebrow", + className="cloud-tour-eyebrow", + ), + html.H3( + first["title"], + id="cloud-tour-title", + className="cloud-tour-title", + ), + html.P( + first["text"], + id="cloud-tour-text", + className="cloud-tour-text", + ), + html.A( + first.get("cta", "Start free on Prowler Cloud"), + id="cloud-tour-cta", + href=CLOUD_URL, + target="_blank", + className="cloud-tour-cta", + style={"display": "none"}, + ), + ], + className="cloud-tour-caption", + ), + html.Div( + [ + html.Button( + "‹", + id=PREV_ID, + className="cloud-tour-nav", + n_clicks=0, + **{"aria-label": "Previous"}, + ), + html.Div( + _slide_dots(total), + id="cloud-tour-dots", + className="cloud-tour-dots", + ), + html.Button( + "›", + id=NEXT_ID, + className="cloud-tour-nav", + n_clicks=0, + **{"aria-label": "Next"}, + ), + ], + className="cloud-tour-controls", + ), + ], + className="cloud-tour-dialog", + ), + ], + id=OVERLAY_ID, + className="cloud-tour-overlay", + style=_OVERLAY_HIDDEN, + ), + ] + ) + + +def register_cloud_promo_callbacks(app) -> None: + """Wire the tour open/close/navigation into a Dash app.""" + + @app.callback( + Output(OPEN_STORE_ID, "data"), + Output(INDEX_STORE_ID, "data"), + Input(TRIGGER_ID, "n_clicks"), + Input(CLOSE_ID, "n_clicks"), + Input(BACKDROP_ID, "n_clicks"), + Input(PREV_ID, "n_clicks"), + Input(NEXT_ID, "n_clicks"), + State(OPEN_STORE_ID, "data"), + State(INDEX_STORE_ID, "data"), + prevent_initial_call=True, + ) + def _drive_tour(_t, _c, _b, _p, _n, is_open, index): + from dash import ctx + from dash.exceptions import PreventUpdate + + # The trigger button lives inside the dynamically rendered sidebar, so it + # appears after the initial load. Ignore the spurious fire when a control + # merely mounts (n_clicks 0/None) — only react to real clicks. + if not ctx.triggered or not ctx.triggered[0]["value"]: + raise PreventUpdate + trigger_id = ctx.triggered_id + return next_tour_state( + trigger_id, bool(is_open), int(index or 0), len(TOUR_SLIDES) + ) + + @app.callback( + Output(OVERLAY_ID, "style"), + Output("cloud-tour-image", "src"), + Output("cloud-tour-eyebrow", "children"), + Output("cloud-tour-title", "children"), + Output("cloud-tour-text", "children"), + Output("cloud-tour-cta", "children"), + Output("cloud-tour-cta", "style"), + Output("cloud-tour-dots", "children"), + Input(OPEN_STORE_ID, "data"), + Input(INDEX_STORE_ID, "data"), + ) + def _render_tour(is_open, index): + index = int(index or 0) % len(TOUR_SLIDES) + slide = TOUR_SLIDES[index] + overlay_style = _OVERLAY_VISIBLE if is_open else _OVERLAY_HIDDEN + cta_label = slide.get("cta") + # Always occupy the CTA box (reserve space) so the dialog height stays + # constant across slides; only its visibility changes. + cta_style = { + "display": "inline-flex", + "visibility": "visible" if cta_label else "hidden", + } + dots = [ + html.Span( + className=( + "cloud-tour-dot cloud-tour-dot--active" + if i == index + else "cloud-tour-dot" + ) + ) + for i in range(len(TOUR_SLIDES)) + ] + return ( + overlay_style, + slide["image"], + slide["eyebrow"], + slide["title"], + slide["text"], + cta_label or "Start free on Prowler Cloud", + cta_style, + dots, + ) diff --git a/dashboard/tests/__init__.py b/dashboard/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/dashboard/tests/test_cloud_promo.py b/dashboard/tests/test_cloud_promo.py new file mode 100644 index 0000000000..0051c17ed4 --- /dev/null +++ b/dashboard/tests/test_cloud_promo.py @@ -0,0 +1,94 @@ +"""Tests for the Prowler Cloud promo tour logic and layout.""" + +from dashboard.lib import cloud_promo as cp + + +class TestNextTourState: + total = len(cp.TOUR_SLIDES) + + def test_trigger_opens_at_first_slide(self): + assert cp.next_tour_state(cp.TRIGGER_ID, False, 3, self.total) == (True, 0) + + def test_close_keeps_index_and_closes(self): + assert cp.next_tour_state(cp.CLOSE_ID, True, 2, self.total) == (False, 2) + + def test_backdrop_closes(self): + assert cp.next_tour_state(cp.BACKDROP_ID, True, 1, self.total) == (False, 1) + + def test_next_advances_and_wraps(self): + assert cp.next_tour_state(cp.NEXT_ID, True, 0, self.total) == (True, 1) + assert cp.next_tour_state(cp.NEXT_ID, True, self.total - 1, self.total) == ( + True, + 0, + ) + + def test_prev_goes_back_and_wraps(self): + assert cp.next_tour_state(cp.PREV_ID, True, 1, self.total) == (True, 0) + assert cp.next_tour_state(cp.PREV_ID, True, 0, self.total) == ( + True, + self.total - 1, + ) + + def test_unknown_trigger_is_noop(self): + assert cp.next_tour_state("whatever", True, 2, self.total) == (True, 2) + + def test_empty_tour_stays_closed(self): + assert cp.next_tour_state(cp.NEXT_ID, True, 0, 0) == (False, 0) + + +class TestSlides: + def test_every_slide_has_image_and_copy(self): + for slide in cp.TOUR_SLIDES: + assert slide["image"].startswith("/assets/images/cloud/") + assert slide["title"] + assert slide["text"] + assert slide["eyebrow"] + + def test_exactly_one_closing_cta(self): + with_cta = [s for s in cp.TOUR_SLIDES if s.get("cta")] + assert len(with_cta) == 1 + assert with_cta[0] is cp.TOUR_SLIDES[-1] + + +class TestLayout: + def test_card_uses_trigger_id(self): + card = cp.cloud_promo_card() + # The clickable element carries the trigger id used by the callback. + ids = _collect_ids(card) + assert cp.TRIGGER_ID in ids + + def test_modal_exposes_stores_and_controls(self): + modal = cp.cloud_tour_modal() + ids = _collect_ids(modal) + for expected in ( + cp.OVERLAY_ID, + cp.OPEN_STORE_ID, + cp.INDEX_STORE_ID, + cp.CLOSE_ID, + cp.BACKDROP_ID, + cp.PREV_ID, + cp.NEXT_ID, + "cloud-tour-image", + ): + assert expected in ids + + +def _collect_ids(component): + """Walk a Dash component tree collecting every string id.""" + ids = [] + + def visit(node): + comp_id = getattr(node, "id", None) + if isinstance(comp_id, str): + ids.append(comp_id) + children = getattr(node, "children", None) + if children is None: + return + if isinstance(children, (list, tuple)): + for child in children: + visit(child) + else: + visit(children) + + visit(component) + return ids