diff --git a/src/website_profiling/crawl/crawler.py b/src/website_profiling/crawl/crawler.py index c74d727..1c68926 100644 --- a/src/website_profiling/crawl/crawler.py +++ b/src/website_profiling/crawl/crawler.py @@ -302,6 +302,8 @@ def worker(self, url: str) -> dict: ) status = result.status + is_success = isinstance(status, int) and 200 <= status < 300 + is_redirect = isinstance(status, int) and 300 <= status < 400 ct = result.content_type text = result.text response_time_ms = result.response_time_ms @@ -371,7 +373,15 @@ def worker(self, url: str) -> dict: if self.store_outlinks: outlink_list.append(link) self.link_edges_accum.append({"from_url": url, **edge}) - self.frontier.try_enqueue_link(link, url) + # Only crawl links discovered on successful (2xx) pages; links + # parsed from custom 4xx/5xx error pages should not be followed. + if is_success: + self.frontier.try_enqueue_link(link, url) + + # A redirect (3xx) has no crawlable body; enqueue its target so the + # destination is fetched and recorded as its own row (per-hop chain). + if is_redirect and final_url and final_url.rstrip("/") != url.rstrip("/"): + self.frontier.try_enqueue_link(final_url, url) ext["response_time_ms"] = response_time_ms if response_time_ms is not None else "" ext["content_length"] = content_length or 0 diff --git a/src/website_profiling/crawl/fetchers/browser.py b/src/website_profiling/crawl/fetchers/browser.py index 4d6a7d6..bf2e258 100644 --- a/src/website_profiling/crawl/fetchers/browser.py +++ b/src/website_profiling/crawl/fetchers/browser.py @@ -299,6 +299,21 @@ async def _fetch_page(self, page: Any, url: str) -> FetchResult: max_per_page=self.console_max_per_page, ) collector.attach(page) + + # Record main-frame navigation responses in order so that (a) a response + # that was received is not lost when goto raises, and (b) we can report + # the URL's OWN status (e.g. a 301) instead of the followed destination. + nav_responses: list[Any] = [] + + def _on_response(resp: Any) -> None: + try: + req = resp.request + if req.is_navigation_request() and resp.frame == page.main_frame: + nav_responses.append(resp) + except Exception: + pass + + page.on("response", _on_response) try: try: response = await page.goto( @@ -312,15 +327,21 @@ async def _fetch_page(self, page: Any, url: str) -> FetchResult: if self.extra_wait_ms and response is not None: await asyncio.sleep(self.extra_wait_ms / 1000.0) finally: + try: + page.remove_listener("response", _on_response) + except Exception: + pass if collector is not None: collector.detach(page) response_time_ms = int((time.perf_counter() - t0) * 1000) final_url = page.url or url - redirect_chain_length = 1 if final_url.rstrip("/") != url.rstrip("/") else 0 browser_diagnostics = collector.build() if collector is not None else None - if response is None: + # Prefer the first observed main-frame response: this is the URL's own + # response (a 3xx redirect or an error status), not the final hop. + own_response = nav_responses[0] if nav_responses else response + if own_response is None: return FetchResult( status=None, content_type=None, @@ -329,20 +350,27 @@ async def _fetch_page(self, page: Any, url: str) -> FetchResult: content_length=0, final_url=final_url, headers_dict={}, - redirect_chain_length=redirect_chain_length, + redirect_chain_length=1 if final_url.rstrip("/") != url.rstrip("/") else 0, fetch_method="rendered", browser_diagnostics=browser_diagnostics, ) - status = response.status - headers = response.headers or {} + status = own_response.status + redirect_chain_length = sum( + 1 for r in nav_responses if 300 <= int(getattr(r, "status", 0) or 0) < 400 + ) + headers = own_response.headers or {} lower_headers = {str(k).lower(): v for k, v in headers.items()} ct = lower_headers.get("content-type", "") headers_dict = { k: (headers.get(k) or lower_headers.get(k.lower(), "")) for k in HEADER_KEYS } - is_html = status == 200 and ("text/html" in ct or "application/xhtml+xml" in ct) + is_redirect = 300 <= status < 400 + # Capture body for 2xx and error (4xx/5xx) HTML pages; skip redirects. + is_html = (not is_redirect) and ( + "text/html" in ct or "application/xhtml+xml" in ct + ) text: Optional[str] = None content_length = 0 if is_html: diff --git a/src/website_profiling/crawl/fetchers/static.py b/src/website_profiling/crawl/fetchers/static.py index 37a867a..44d9f57 100644 --- a/src/website_profiling/crawl/fetchers/static.py +++ b/src/website_profiling/crawl/fetchers/static.py @@ -5,6 +5,7 @@ import threading import time from typing import Callable, Optional +from urllib.parse import urljoin import requests @@ -71,16 +72,30 @@ def fetch(self, url: str) -> FetchResult: session = self.session try: t0 = time.perf_counter() - resp = session.get(url, timeout=self.timeout, allow_redirects=True) + # Do NOT auto-follow redirects: we want to record the URL's own + # response (e.g. 301/308) rather than collapsing the chain into the + # final 200. The crawler enqueues the Location target so each hop is + # crawled and recorded as its own row. + resp = session.get(url, timeout=self.timeout, allow_redirects=False) response_time_ms = int((time.perf_counter() - t0) * 1000) ct = resp.headers.get("Content-Type", "") - is_html = resp.status_code == 200 and ( + location = resp.headers.get("Location") or resp.headers.get("location") or "" + # A redirect is a 3xx with a Location header (matches requests' own + # definition; excludes 304 Not Modified). + is_redirect = resp.status_code in (301, 302, 303, 307, 308) and bool(location) + # Capture the body for 2xx and error (4xx/5xx) HTML pages so custom + # error pages can be analysed; redirects have no meaningful body. + is_html = (not is_redirect) and ( "text/html" in ct or "application/xhtml+xml" in ct ) text = resp.text if is_html else None content_length = len(resp.content) if resp.content is not None else 0 - final_url = resp.url or url - redirect_chain_length = len(resp.history) + if is_redirect: + final_url = urljoin(url, location) + redirect_chain_length = 1 + else: + final_url = resp.url or url + redirect_chain_length = len(resp.history) headers_dict = {k: (resp.headers.get(k) or "") for k in HEADER_KEYS} return FetchResult( status=resp.status_code, diff --git a/src/website_profiling/reporting/seo_summary.py b/src/website_profiling/reporting/seo_summary.py index bd49de9..e930063 100644 --- a/src/website_profiling/reporting/seo_summary.py +++ b/src/website_profiling/reporting/seo_summary.py @@ -10,10 +10,29 @@ META_DESC_LEN_MAX = 160 THIN_CONTENT_CHARS = 300 + +def _status_text(value: object) -> str: + """Normalize a status value to a clean string (e.g. 400.0 -> "400"). + + Numeric statuses can arrive as ints, strings, or floats (when pandas coerces + a column containing NaN); non-numeric markers like "error"/"blocked_by_robots" + pass through unchanged. Keeps status-code matching robust across all of them. + """ + if value is None: + return "" + try: + f = float(value) # type: ignore[arg-type] + except (TypeError, ValueError): + return str(value).strip() + if f != f: # NaN + return "" + return str(int(f)) + + def _compute_summary_seo_issues(df: pd.DataFrame) -> dict: """Compute crawl summary, SEO health metrics, issues list, and recommendations from crawl DataFrame.""" total = len(df) - status_str = df["status"].astype(str) if "status" in df.columns else pd.Series(["unknown"] * len(df)) + status_str = df["status"].map(_status_text) if "status" in df.columns else pd.Series(["unknown"] * len(df)) count_2xx = int((status_str.str.match(r"2\d{2}").fillna(False)).sum()) count_3xx = int((status_str.str.match(r"3\d{2}").fillna(False)).sum()) count_4xx = int((status_str.str.match(r"4\d{2}").fillna(False)).sum()) @@ -76,7 +95,7 @@ def _compute_summary_seo_issues(df: pd.DataFrame) -> dict: if pd.isna(u) or not u: continue u = str(u).strip() - st = str(row.get("status", "")).strip() + st = _status_text(row.get("status", "")) if st.startswith("4") or st.startswith("5") or st in ("error", "blocked_by_robots"): issues["broken"].append({"url": u, "status": st}) elif st.startswith("3"): diff --git a/tests/reporting/test_reporting_builder_modules.py b/tests/reporting/test_reporting_builder_modules.py index 9720689..b348e78 100644 --- a/tests/reporting/test_reporting_builder_modules.py +++ b/tests/reporting/test_reporting_builder_modules.py @@ -122,6 +122,40 @@ def test_compute_summary_seo_issues() -> None: assert out["recommendations"] +def test_status_text_normalization() -> None: + assert seo_summary._status_text(400) == "400" + assert seo_summary._status_text(400.0) == "400" + assert seo_summary._status_text("301") == "301" + assert seo_summary._status_text("error") == "error" + assert seo_summary._status_text(None) == "" + assert seo_summary._status_text(float("nan")) == "" + + +def test_compute_summary_classifies_numeric_and_float_statuses() -> None: + df = pd.DataFrame( + [ + {"url": "https://example.com/ok", "status": 200.0}, + {"url": "https://example.com/redir", "status": 301, "final_url": "https://example.com/dest"}, + {"url": "https://example.com/bad", "status": 400.0}, + {"url": "https://example.com/boom", "status": 500}, + ] + ) + out = seo_summary._compute_summary_seo_issues(df) + summary = out["summary"] + assert summary["count_2xx"] == 1 + assert summary["count_3xx"] == 1 + assert summary["count_4xx"] == 1 + assert summary["count_5xx"] == 1 + + broken = {b["url"] for b in out["issues"]["broken"]} + assert {"https://example.com/bad", "https://example.com/boom"} <= broken + + redirects = {r["url"]: r for r in out["issues"]["redirects"]} + assert "https://example.com/redir" in redirects + assert redirects["https://example.com/redir"]["status"] == "301" + assert redirects["https://example.com/redir"]["final_url"] == "https://example.com/dest" + + def test_content_analytics_helpers() -> None: df = _crawl_df() content = content_analytics._build_content_analytics(df) diff --git a/tests/test_browser_fetcher_unit.py b/tests/test_browser_fetcher_unit.py index a1a7538..29032a8 100644 --- a/tests/test_browser_fetcher_unit.py +++ b/tests/test_browser_fetcher_unit.py @@ -846,3 +846,180 @@ def test_browser_fetcher_close_when_loop_unavailable(fake_playwright) -> None: fetcher._loop = None fetcher._closed = False fetcher.close() + + +class _NavRequest: + def __init__(self, *, is_navigation: bool) -> None: + self._is_navigation = is_navigation + + def is_navigation_request(self) -> bool: + return self._is_navigation + + +class _NavResponse: + def __init__(self, page: "_NavResponsePage", *, status: int, is_navigation: bool) -> None: + self.status = status + self.headers = {"content-type": "text/html", "Location": "https://example.com/new"} + self.request = _NavRequest(is_navigation=is_navigation) + self.frame = page.main_frame + + +class _NavResponsePage(_FakePage): + def __init__(self) -> None: + super().__init__() + self.main_frame = object() + self.url = "https://example.com/new" + + async def goto(self, _url: str, **_kwargs: Any) -> _FakeResponse: + for handler in self._handlers.get("response", []): + handler(_NavResponse(self, status=301, is_navigation=True)) + return _FakeResponse() + + +class _NavResponseContext(_FakeContext): + async def new_page(self) -> _NavResponsePage: + return _NavResponsePage() + + +class _NavResponseBrowser(_FakeBrowser): + async def new_context(self, **_kwargs: Any) -> _NavResponseContext: + return _NavResponseContext() + + +class _NavResponseChromium: + async def launch(self, **_kwargs: Any) -> _NavResponseBrowser: + return _NavResponseBrowser() + + +class _NavResponsePlaywright: + chromium = _NavResponseChromium() + + async def stop(self) -> None: + return None + + +class _NavResponsePlaywrightContext: + async def start(self) -> _NavResponsePlaywright: + return _NavResponsePlaywright() + + +def test_browser_fetcher_prefers_recorded_navigation_response(monkeypatch: pytest.MonkeyPatch) -> None: + from website_profiling.crawl.fetchers.browser import BrowserFetcher + + fake_api = MagicMock() + fake_api.async_playwright = lambda: _NavResponsePlaywrightContext() + monkeypatch.setitem(__import__("sys").modules, "playwright", MagicMock(async_api=fake_api)) + monkeypatch.setitem(__import__("sys").modules, "playwright.async_api", fake_api) + + fetcher = BrowserFetcher(timeout=5, js_concurrency=1, extra_wait_ms=0, block_resources=False) + try: + result = fetcher.fetch("https://example.com/old") + assert result.status == 301 + assert result.redirect_chain_length == 1 + assert result.text is None + finally: + fetcher.close() + + +class _BrokenNavResponse: + @property + def request(self) -> None: + raise RuntimeError("response handler failed") + + +class _BrokenNavResponsePage(_FakePage): + async def goto(self, _url: str, **_kwargs: Any) -> _FakeResponse: + for handler in self._handlers.get("response", []): + handler(_BrokenNavResponse()) + return _FakeResponse() + + +class _BrokenNavResponseContext(_FakeContext): + async def new_page(self) -> _BrokenNavResponsePage: + return _BrokenNavResponsePage() + + +class _BrokenNavResponseBrowser(_FakeBrowser): + async def new_context(self, **_kwargs: Any) -> _BrokenNavResponseContext: + return _BrokenNavResponseContext() + + +class _BrokenNavResponseChromium: + async def launch(self, **_kwargs: Any) -> _BrokenNavResponseBrowser: + return _BrokenNavResponseBrowser() + + +class _BrokenNavResponsePlaywright: + chromium = _BrokenNavResponseChromium() + + async def stop(self) -> None: + return None + + +class _BrokenNavResponsePlaywrightContext: + async def start(self) -> _BrokenNavResponsePlaywright: + return _BrokenNavResponsePlaywright() + + +def test_browser_fetcher_response_handler_swallows_errors(monkeypatch: pytest.MonkeyPatch) -> None: + from website_profiling.crawl.fetchers.browser import BrowserFetcher + + fake_api = MagicMock() + fake_api.async_playwright = lambda: _BrokenNavResponsePlaywrightContext() + monkeypatch.setitem(__import__("sys").modules, "playwright", MagicMock(async_api=fake_api)) + monkeypatch.setitem(__import__("sys").modules, "playwright.async_api", fake_api) + + fetcher = BrowserFetcher(timeout=5, js_concurrency=1, extra_wait_ms=0, block_resources=False) + try: + result = fetcher.fetch("https://example.com/") + assert result.status == 200 + finally: + fetcher.close() + + +class _RemoveListenerFailPage(_FakePage): + def remove_listener(self, event: str, handler: Any) -> None: + raise RuntimeError("listener missing") + + +class _RemoveListenerFailContext(_FakeContext): + async def new_page(self) -> _RemoveListenerFailPage: + return _RemoveListenerFailPage() + + +class _RemoveListenerFailBrowser(_FakeBrowser): + async def new_context(self, **_kwargs: Any) -> _RemoveListenerFailContext: + return _RemoveListenerFailContext() + + +class _RemoveListenerFailChromium: + async def launch(self, **_kwargs: Any) -> _RemoveListenerFailBrowser: + return _RemoveListenerFailBrowser() + + +class _RemoveListenerFailPlaywright: + chromium = _RemoveListenerFailChromium() + + async def stop(self) -> None: + return None + + +class _RemoveListenerFailPlaywrightContext: + async def start(self) -> _RemoveListenerFailPlaywright: + return _RemoveListenerFailPlaywright() + + +def test_browser_fetcher_remove_listener_failure_swallowed(monkeypatch: pytest.MonkeyPatch) -> None: + from website_profiling.crawl.fetchers.browser import BrowserFetcher + + fake_api = MagicMock() + fake_api.async_playwright = lambda: _RemoveListenerFailPlaywrightContext() + monkeypatch.setitem(__import__("sys").modules, "playwright", MagicMock(async_api=fake_api)) + monkeypatch.setitem(__import__("sys").modules, "playwright.async_api", fake_api) + + fetcher = BrowserFetcher(timeout=5, js_concurrency=1, extra_wait_ms=0, block_resources=False) + try: + result = fetcher.fetch("https://example.com/") + assert result.status == 200 + finally: + fetcher.close() diff --git a/tests/test_crawl_fetchers.py b/tests/test_crawl_fetchers.py index e599809..3545629 100644 --- a/tests/test_crawl_fetchers.py +++ b/tests/test_crawl_fetchers.py @@ -40,6 +40,84 @@ def test_static_fetcher_parses_html(): assert result.browser_diagnostics is None +class _FakeResp: + def __init__(self, status_code, headers=None, text="", url=""): + self.status_code = status_code + self.headers = headers or {} + self.text = text + self.content = text.encode("utf-8") + self.url = url + self.history = [] + + @property + def is_redirect(self): + return self.status_code in (301, 302, 303, 307, 308) and "Location" in self.headers + + @property + def is_permanent_redirect(self): + return self.status_code in (301, 308) and "Location" in self.headers + + +class _FakeSession: + def __init__(self, resp): + self._resp = resp + self.headers = {} + self.calls = [] + + def get(self, url, timeout=None, allow_redirects=True): + self.calls.append({"url": url, "allow_redirects": allow_redirects}) + return self._resp + + def close(self): + pass + + +def test_static_fetcher_records_permanent_redirect_without_following(): + resp = _FakeResp( + 301, + headers={"Location": "https://example.com/new", "Content-Type": "text/html"}, + text="ignored redirect body", + url="https://example.com/old", + ) + session = _FakeSession(resp) + fetcher = StaticFetcher(timeout=5, session=session) + result = fetcher.fetch("https://example.com/old") + + # The redirect must NOT be followed: status is the real 301, not the dest's 200. + assert session.calls[0]["allow_redirects"] is False + assert result.status == 301 + assert result.final_url == "https://example.com/new" + assert result.text is None + assert result.redirect_chain_length == 1 + + +def test_static_fetcher_resolves_relative_redirect_location(): + resp = _FakeResp( + 308, + headers={"Location": "/moved"}, + url="https://example.com/old", + ) + fetcher = StaticFetcher(timeout=5, session=_FakeSession(resp)) + result = fetcher.fetch("https://example.com/old") + assert result.status == 308 + assert result.final_url == "https://example.com/moved" + + +def test_static_fetcher_records_client_error_and_captures_body(): + resp = _FakeResp( + 400, + headers={"Content-Type": "text/html"}, + text="Bad Request", + url="https://example.com/bad", + ) + fetcher = StaticFetcher(timeout=5, session=_FakeSession(resp)) + result = fetcher.fetch("https://example.com/bad") + assert result.status == 400 + # Non-200 HTML body is now captured so custom error pages can be analysed. + assert result.text is not None and "Bad Request" in result.text + assert result.redirect_chain_length == 0 + + def test_page_diagnostics_collector_builds_summary(): from website_profiling.crawl.fetchers.browser import _PageDiagnosticsCollector diff --git a/tests/test_crawler_unit.py b/tests/test_crawler_unit.py index 06717b3..16f3524 100644 --- a/tests/test_crawler_unit.py +++ b/tests/test_crawler_unit.py @@ -743,3 +743,59 @@ def test_worker_custom_extraction_invalid_regex_is_ignored(monkeypatch) -> None: out = c.worker("https://site.com/a") assert "custom_extract" not in out + +def test_worker_records_redirect_and_enqueues_target(monkeypatch) -> None: + from website_profiling.crawl.crawler import Crawler + from website_profiling.crawl.fetchers.base import FetchResult + + monkeypatch.setattr( + "website_profiling.crawl.sitemap.discover_sitemap_urls", + lambda *_a, **_k: [], + ) + c = Crawler(start_url="https://site.com", ignore_robots=True, use_wappalyzer=False) + c.fetch = lambda _url: FetchResult( # type: ignore[method-assign] + status=301, + content_type="", + text=None, + response_time_ms=1, + content_length=0, + final_url="https://site.com/new", + headers_dict={}, + redirect_chain_length=1, + fetch_method="static", + ) + out = c.worker("https://site.com/old") + # The redirect is recorded as a 3xx row (not collapsed to the destination). + assert str(out["status"]) == "301" + assert out["final_url"] == "https://site.com/new" + # ...and the redirect target is enqueued so the destination is crawled too. + assert c.frontier.queue_contains("https://site.com/new") + + +def test_worker_does_not_enqueue_links_from_error_page(monkeypatch) -> None: + from website_profiling.crawl.crawler import Crawler + from website_profiling.crawl.fetchers.base import FetchResult + + monkeypatch.setattr( + "website_profiling.crawl.sitemap.discover_sitemap_urls", + lambda *_a, **_k: [], + ) + c = Crawler(start_url="https://site.com", ignore_robots=True, use_wappalyzer=False) + html = 'x' + c.fetch = lambda _url: FetchResult( # type: ignore[method-assign] + status=404, + content_type="text/html", + text=html, + response_time_ms=1, + content_length=len(html), + final_url="https://site.com/bad", + headers_dict={}, + redirect_chain_length=0, + fetch_method="static", + ) + out = c.worker("https://site.com/bad") + # The 404 is recorded with its real numeric status... + assert str(out["status"]) == "404" + # ...but links discovered on an error page are NOT followed. + assert not c.frontier.queue_contains("https://site.com/error-link") + diff --git a/web/app/client-providers.tsx b/web/app/client-providers.tsx index 83de93f..46d3ec0 100644 --- a/web/app/client-providers.tsx +++ b/web/app/client-providers.tsx @@ -7,13 +7,10 @@ import { PipelineProvider } from '@/context/PipelineContext'; import { SessionProvider } from '@/context/SessionContext'; import ChatFab from '@/components/chat/ChatFab'; import PipelineRunnerFab from '@/components/pipeline/PipelineRunnerFab'; +import AppLoadingScreen from '@/components/AppLoadingScreen'; function LoadingFallback() { - return ( -
-

Loading…

-
- ); + return ; } export default function ClientProviders({ children }: { children: ReactNode }): ReactNode { diff --git a/web/app/globals.css b/web/app/globals.css index b431661..6f36277 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -654,6 +654,291 @@ select:focus-visible { background: color-mix(in srgb, var(--app-bg-muted) 35%, transparent); } +/* Horizontal slide deck — one panel visible, no vertical scroll. */ +.landing-deck-viewport { + position: relative; + overflow: hidden; + overscroll-behavior: none; +} + +.landing-deck-track { + position: relative; + z-index: 1; + display: flex; + height: 100%; + width: 100%; + will-change: transform; + transition: transform 0.45s var(--ease-out); +} + +.landing-deck-track--instant { + transition: none; +} + +@media (prefers-reduced-motion: reduce) { + .landing-deck-track { + transition: none; + } +} + +.landing-deck-slide, +.landing-section { + position: relative; + display: flex; + width: 100%; + height: 100%; + min-width: 100%; + flex: 0 0 100%; + flex-direction: column; + overflow: hidden; +} + +.landing-section-body { + min-height: 0; + flex: 1; + overflow: hidden; +} + +.landing-section-body-centered { + display: flex; + flex-direction: column; + justify-content: center; +} + +.landing-section-split { + display: grid; + width: 100%; + min-width: 0; + align-items: stretch; + gap: 1.5rem; +} + +@media (min-width: 768px) { + .landing-section-split { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 0; + } +} + +.landing-split-copy { + display: flex; + min-height: 0; + flex-direction: column; + justify-content: center; +} + +.landing-split-visual { + display: flex; + min-height: 0; + align-items: center; + justify-content: center; +} + +@media (min-width: 768px) { + .landing-split-visual { + height: 100%; + padding: 0.75rem 1.5rem 0.75rem 0.75rem; + } + + .landing-section-split--reversed .landing-split-visual { + padding: 0.75rem 0.75rem 0.75rem 1.5rem; + } +} + +.landing-split-mock { + height: 100%; + max-height: 100%; + width: 100%; + min-height: 0; +} + +.landing-footer-snap { + flex: 0 0 100%; + width: 100%; + height: 100%; + min-width: 100%; + overflow: hidden; +} + +/* Landing deck — progress, presenter mode, edge navigation */ +.landing-deck-progress-track { + height: 2px; + overflow: hidden; + border-radius: 9999px; + background: color-mix(in srgb, var(--app-text-subtle) 25%, transparent); +} + +.landing-deck-progress-fill { + height: 100%; + width: 100%; + transform-origin: left center; + border-radius: inherit; + background: linear-gradient(90deg, var(--app-link), var(--accent-2)); + transition: transform 0.35s var(--ease-out); +} + +.landing-deck-edge-nav { + pointer-events: none; + position: absolute; + inset: 0; + z-index: 20; +} + +.landing-deck-edge-btn { + pointer-events: auto; + position: absolute; + top: 50%; + display: flex; + height: 3rem; + width: 3rem; + align-items: center; + justify-content: center; + transform: translateY(-50%); + border-radius: 9999px; + border: 1px solid color-mix(in srgb, var(--app-border) 70%, transparent); + background: color-mix(in srgb, var(--app-bg) 75%, transparent); + color: var(--app-text-muted); + opacity: 0; + backdrop-filter: blur(8px); + transition: + opacity var(--dur-base) var(--ease-out), + color var(--dur-base) var(--ease-out), + background var(--dur-base) var(--ease-out); +} + +.landing-deck-viewport:hover .landing-deck-edge-btn:not(:disabled) { + opacity: 0.85; +} + +.landing-deck-edge-btn:hover:not(:disabled) { + color: var(--app-text); + background: color-mix(in srgb, var(--app-bg-muted) 80%, transparent); +} + +.landing-deck-edge-btn:disabled { + cursor: not-allowed; + opacity: 0; +} + +.landing-deck-edge-btn--prev { + left: 0.75rem; +} + +.landing-deck-edge-btn--next { + right: 0.75rem; +} + +.landing-deck-controls { + pointer-events: none; + position: absolute; + right: 1rem; + bottom: 2.25rem; + left: 1rem; + z-index: 30; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.35rem; +} + +.landing-deck-controls-inner { + pointer-events: auto; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 0.35rem; + border-radius: 0.75rem; + border: 1px solid color-mix(in srgb, var(--app-border) 80%, transparent); + background: color-mix(in srgb, var(--app-bg) 88%, transparent); + padding: 0.35rem; + backdrop-filter: blur(12px); +} + +.landing-deck-control-btn { + display: inline-flex; + align-items: center; + gap: 0.35rem; + border-radius: 0.5rem; + padding: 0.4rem 0.65rem; + font-size: 0.75rem; + font-weight: 500; + color: var(--app-text-muted); + transition: + background var(--dur-base) var(--ease-out), + color var(--dur-base) var(--ease-out); +} + +.landing-deck-control-btn:hover:not(:disabled) { + background: color-mix(in srgb, var(--app-bg-muted) 70%, transparent); + color: var(--app-text); +} + +.landing-deck-control-btn:disabled { + cursor: not-allowed; + opacity: 0.4; +} + +.landing-deck-control-btn:focus-visible, +.landing-deck-edge-btn:focus-visible { + outline: 2px solid var(--app-link); + outline-offset: 2px; +} + +.landing-deck-control-btn--exit { + color: var(--app-text); +} + +.landing-deck-control-select { + border-radius: 0.5rem; + border: 1px solid color-mix(in srgb, var(--app-border) 70%, transparent); + background: color-mix(in srgb, var(--app-bg-muted) 50%, transparent); + padding: 0.35rem 0.5rem; + font-size: 0.75rem; + color: var(--app-text-muted); +} + +.landing-deck-shortcuts-hint { + pointer-events: none; + font-size: 0.65rem; + color: var(--app-text-subtle); +} + +.landing-deck-speaker-note { + pointer-events: none; + max-width: 28rem; + text-align: right; + font-size: 0.7rem; + line-height: 1.35; + color: var(--app-text-muted); +} + +.landing-slide-badge { + pointer-events: none; + position: absolute; + top: 0.75rem; + right: 1.25rem; + z-index: 10; + display: none; + border-radius: 9999px; + border: 1px solid color-mix(in srgb, var(--app-link) 35%, transparent); + background: color-mix(in srgb, var(--app-link) 12%, transparent); + padding: 0.25rem 0.65rem; + font-size: 0.65rem; + font-weight: 600; + letter-spacing: 0.02em; + color: var(--app-link); +} + +.landing-presenter .landing-slide-badge { + display: inline-flex; +} + +.landing-presenter .landing-section { + padding-top: 0.75rem; + padding-bottom: 2.5rem; +} + /* ───────────────────────────────────────────────────────────── Reusable redesign utilities — depth, motion, warmth ───────────────────────────────────────────────────────────── */ @@ -780,4 +1065,28 @@ select:focus-visible { transform: translateX(100%); } } + + .shimmer-text { + background: linear-gradient( + 90deg, + var(--app-text-subtle) 0%, + var(--app-text-heading) 45%, + var(--app-link) 55%, + var(--app-text-subtle) 100% + ); + background-size: 200% auto; + -webkit-background-clip: text; + background-clip: text; + color: transparent; + animation: shimmerText 2.2s ease-in-out infinite; + } + @keyframes shimmerText { + 0%, + 100% { + background-position: 0% center; + } + 50% { + background-position: 100% center; + } + } } diff --git a/web/app/write/page.tsx b/web/app/write/page.tsx index 145e65f..f486d93 100644 --- a/web/app/write/page.tsx +++ b/web/app/write/page.tsx @@ -1,17 +1,12 @@ import { Suspense } from 'react'; +import AppLoadingScreen from '@/components/AppLoadingScreen'; import WriteStudio from '@/views/WriteStudio'; export const dynamic = 'force-dynamic'; export default function WritePage() { return ( - - Loading… - - } - > + }> ); diff --git a/web/src/components/AppLoadingScreen.tsx b/web/src/components/AppLoadingScreen.tsx new file mode 100644 index 0000000..6aec3c3 --- /dev/null +++ b/web/src/components/AppLoadingScreen.tsx @@ -0,0 +1,45 @@ +import { Skeleton } from '@/components/Skeleton'; +import { strings } from '@/lib/strings'; + +const app = strings.app; + +export default function AppLoadingScreen() { + return ( +
+
+ +
+

+ {app.productName} +

+

{app.productSubtitle}

+ +
+
+ + +
+ + +
+ + + +
+
+ +

{app.loading}

+

{app.loadingSubtitle}

+
+
+ ); +} diff --git a/web/src/components/LandingShell.tsx b/web/src/components/LandingShell.tsx index 55119f2..56796f6 100644 --- a/web/src/components/LandingShell.tsx +++ b/web/src/components/LandingShell.tsx @@ -1,101 +1,94 @@ 'use client'; -import Link from 'next/link'; -import type { ReactNode } from 'react'; -import AppLogo from '@/components/AppLogo'; -import ThemeToggle from '@/components/ThemeToggle'; -import { strings } from '@/lib/strings'; +import { useEffect, useRef, type ReactNode } from 'react'; +import LandingDeckControls from '@/components/landing/LandingDeckControls'; +import { + LandingDeckProvider, + useLandingDeckRequired, +} from '@/components/landing/LandingDeckContext'; +import LandingDeckProgress from '@/components/landing/LandingDeckProgress'; +import LandingDeckTrack from '@/components/landing/LandingDeckTrack'; +import { LANDING_SECTION_IDS } from '@/components/landing/landingLayout'; export interface LandingShellProps { children: ReactNode; footer?: ReactNode; + /** Decorative layers rendered behind the slide track (fixed within viewport). */ + backdrop?: ReactNode; } -const NAV_ITEMS = [ - { href: '#features', labelKey: 'navFeatures' as const }, - { href: '#quick-start', labelKey: 'navQuickStart' as const }, - { href: '#google-setup', labelKey: 'navGoogleSetup' as const }, -] as const; +export default function LandingShell({ children, footer, backdrop }: LandingShellProps) { + return ( + + + {children} + + + ); +} + +function LandingShellInner({ children, footer, backdrop }: LandingShellProps) { + const viewportRef = useRef(null); + const { goToSlide, goNext, goPrev, presenterMode } = useLandingDeckRequired(); + + useEffect(() => { + const viewport = viewportRef.current; + if (!viewport) return; + + const onClick = (event: MouseEvent) => { + const anchor = (event.target as Element).closest('a[href^="#"]'); + if (!(anchor instanceof HTMLAnchorElement)) return; + const hash = anchor.getAttribute('href'); + if (!hash || hash === '#') return; + const id = hash.slice(1); + event.preventDefault(); + goToSlide(id); + }; + + viewport.addEventListener('click', onClick); + return () => viewport.removeEventListener('click', onClick); + }, [goToSlide]); + + useEffect(() => { + const viewport = viewportRef.current; + if (!viewport) return; -export default function LandingShell({ children, footer }: LandingShellProps) { - const vl = strings.views.landing; - const app = strings.app; + const onWheel = (event: WheelEvent) => { + event.preventDefault(); + if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return; + if (event.deltaY > 0) goNext(); + else goPrev(); + }; + + viewport.addEventListener('wheel', onWheel, { passive: false }); + return () => viewport.removeEventListener('wheel', onWheel); + }, [goNext, goPrev]); return ( -
-
-
- - - {app.productName} - - -
- - - {vl.navOpenApp} - - - {vl.navRunAudit} - -
-
- -
+
+
+ {backdrop ? ( +
{backdrop}
+ ) : null} + + + {children} -
{children}
+ {footer ? ( +
+ {footer} +
+ ) : null} +
- {footer ? ( -
{footer}
- ) : null} + + +
); } diff --git a/web/src/components/landing/LandingCodeBlock.tsx b/web/src/components/landing/LandingCodeBlock.tsx index 8daa0d3..40d3f11 100644 --- a/web/src/components/landing/LandingCodeBlock.tsx +++ b/web/src/components/landing/LandingCodeBlock.tsx @@ -9,9 +9,10 @@ const vl = strings.views.landing; interface LandingCodeBlockProps { label?: string; command: string; + prominent?: boolean; } -export default function LandingCodeBlock({ label, command }: LandingCodeBlockProps) { +export default function LandingCodeBlock({ label, command, prominent = false }: LandingCodeBlockProps) { const [copied, setCopied] = useState(false); const handleCopy = useCallback(async () => { @@ -25,24 +26,36 @@ export default function LandingCodeBlock({ label, command }: LandingCodeBlockPro }, [command]); return ( -
-
+
+
{label ? ( -

{label}

+

+ {label} +

) : ( )}
-
-        
+      
+        
           $ 
           {command}
         
diff --git a/web/src/components/landing/LandingDeckContext.tsx b/web/src/components/landing/LandingDeckContext.tsx
new file mode 100644
index 0000000..4a7d8b7
--- /dev/null
+++ b/web/src/components/landing/LandingDeckContext.tsx
@@ -0,0 +1,32 @@
+'use client';
+
+import { createContext, useContext, type ReactNode } from 'react';
+import { LANDING_DECK_SECTION_ORDER } from '@/components/landing/landingLayout';
+import { useLandingDeck, type UseLandingDeckResult } from '@/hooks/useLandingDeck';
+
+const LandingDeckContext = createContext(null);
+
+export interface LandingDeckProviderProps {
+  children: ReactNode;
+}
+
+export function LandingDeckProvider({ children }: LandingDeckProviderProps) {
+  const deck = useLandingDeck({
+    sectionIds: LANDING_DECK_SECTION_ORDER,
+  });
+
+  return {children};
+}
+
+export function useLandingDeckContext(): UseLandingDeckResult | null {
+  return useContext(LandingDeckContext);
+}
+
+/** Required inside landing deck UI (progress, controls, scroll cues). */
+export function useLandingDeckRequired(): UseLandingDeckResult {
+  const ctx = useContext(LandingDeckContext);
+  if (!ctx) {
+    throw new Error('useLandingDeckRequired must be used within LandingDeckProvider');
+  }
+  return ctx;
+}
diff --git a/web/src/components/landing/LandingDeckControls.tsx b/web/src/components/landing/LandingDeckControls.tsx
new file mode 100644
index 0000000..023e0a3
--- /dev/null
+++ b/web/src/components/landing/LandingDeckControls.tsx
@@ -0,0 +1,155 @@
+'use client';
+
+import {
+  ChevronLeft,
+  ChevronRight,
+  MonitorPlay,
+  Pause,
+  Play,
+  X,
+} from 'lucide-react';
+import { useLandingDeckRequired } from '@/components/landing/LandingDeckContext';
+import {
+  LANDING_DECK_AUTO_ADVANCE_INTERVALS_MS,
+  type LandingDeckAutoAdvanceMs,
+} from '@/components/landing/landingLayout';
+import { strings } from '@/lib/strings';
+
+export default function LandingDeckControls() {
+  const vl = strings.views.landing;
+  const {
+    activeIndex,
+    total,
+    activeId,
+    goNext,
+    goPrev,
+    presenterMode,
+    setPresenterMode,
+    autoAdvance,
+    setAutoAdvance,
+    autoAdvanceMs,
+    setAutoAdvanceMs,
+  } = useLandingDeckRequired();
+
+  const deckNotes = vl.deckNotes as Record | undefined;
+  const speakerNote = activeId && deckNotes ? deckNotes[activeId] ?? '' : '';
+
+  const atStart = activeIndex <= 0;
+  const atEnd = activeIndex >= total - 1;
+
+  return (
+    <>
+      {!presenterMode ? (
+        
+ + +
+ ) : null} + + {presenterMode ? ( +
+
+ + + + + + + + + +
+

{vl.deckShortcutsHint}

+ {speakerNote ? ( +

{speakerNote}

+ ) : null} +
+ ) : null} + + ); +} + +export function LandingDeckPresentButton() { + const vl = strings.views.landing; + const { setPresenterMode } = useLandingDeckRequired(); + + return ( + + ); +} diff --git a/web/src/components/landing/LandingDeckProgress.tsx b/web/src/components/landing/LandingDeckProgress.tsx new file mode 100644 index 0000000..d0f1df7 --- /dev/null +++ b/web/src/components/landing/LandingDeckProgress.tsx @@ -0,0 +1,35 @@ +'use client'; + +import { useLandingDeckRequired } from '@/components/landing/LandingDeckContext'; +import { strings } from '@/lib/strings'; + +export default function LandingDeckProgress() { + const vl = strings.views.landing; + const { activeIndex, total } = useLandingDeckRequired(); + const progress = total > 0 ? (activeIndex + 1) / total : 0; + + return ( +
+
+
+
+
+ + {activeIndex + 1} / {total} + +
+
+ ); +} diff --git a/web/src/components/landing/LandingDeckTrack.tsx b/web/src/components/landing/LandingDeckTrack.tsx new file mode 100644 index 0000000..234949d --- /dev/null +++ b/web/src/components/landing/LandingDeckTrack.tsx @@ -0,0 +1,23 @@ +'use client'; + +import type { ReactNode } from 'react'; +import { useLandingDeckRequired } from '@/components/landing/LandingDeckContext'; + +export interface LandingDeckTrackProps { + children: ReactNode; +} + +/** Horizontal slide strip — only one child panel visible in the viewport at a time. */ +export default function LandingDeckTrack({ children }: LandingDeckTrackProps) { + const { activeIndex, slideTransition } = useLandingDeckRequired(); + + return ( +
+ {children} +
+ ); +} diff --git a/web/src/components/landing/LandingFeatureSpotlight.tsx b/web/src/components/landing/LandingFeatureSpotlight.tsx index 8a6bae3..949e561 100644 --- a/web/src/components/landing/LandingFeatureSpotlight.tsx +++ b/web/src/components/landing/LandingFeatureSpotlight.tsx @@ -3,6 +3,15 @@ import Link from 'next/link'; import { CheckCircle2, ChevronRight } from 'lucide-react'; import LandingProductMock, { type LandingProductMockVariant } from '@/components/landing/LandingProductMock'; +import { + landingGutterClass, + landingSectionSplitClass, + landingSplitCopyClass, + landingSplitMockClass, + landingSplitVisualClass, +} from '@/components/landing/landingLayout'; +import { useInView } from '@/lib/useInView'; +import type { CSSProperties } from 'react'; interface LandingFeatureSpotlightProps { eyebrow: string; @@ -12,6 +21,9 @@ interface LandingFeatureSpotlightProps { mockVariant: LandingProductMockVariant; ctaHref: string; ctaLabel: string; + secondaryCtaHref?: string; + secondaryCtaLabel?: string; + secondaryCtaExternal?: boolean; reversed?: boolean; } @@ -23,45 +35,82 @@ export default function LandingFeatureSpotlight({ mockVariant, ctaHref, ctaLabel, + secondaryCtaHref, + secondaryCtaLabel, + secondaryCtaExternal = false, reversed = false, }: LandingFeatureSpotlightProps) { - const textCol = ( -
-

{eyebrow}

-

{title}

-

{description}

-
    - {bullets.map((bullet) => ( -
  • - - {bullet} + const { ref: bulletsRef, inView: bulletsInView } = useInView(); + + const copy = ( + <> +

    {eyebrow}

    +

    {title}

    +

    {description}

    +
      + {bullets.slice(0, 3).map((bullet, index) => ( +
    • + + {bullet}
    • ))}
    {ctaLabel} -
- ); - - const mockCol = ( -
- -
+ {secondaryCtaHref && secondaryCtaLabel ? ( + secondaryCtaExternal ? ( + + {secondaryCtaLabel} + + + ) : ( + + {secondaryCtaLabel} + + + ) + ) : null} + ); return ( -
*:first-child]:order-2' : '' - }`} - > - {textCol} - {mockCol} +
+
+ {copy} +
+
+
+ +
+
); } diff --git a/web/src/components/landing/LandingFinalCta.tsx b/web/src/components/landing/LandingFinalCta.tsx index 0a25c11..ff3fdb4 100644 --- a/web/src/components/landing/LandingFinalCta.tsx +++ b/web/src/components/landing/LandingFinalCta.tsx @@ -1,35 +1,78 @@ 'use client'; import Link from 'next/link'; -import Button from '@/components/Button'; +import { CheckCircle2, ChevronRight } from 'lucide-react'; +import LandingProductMock from '@/components/landing/LandingProductMock'; +import LandingSectionHeader from '@/components/landing/LandingSectionHeader'; +import { + landingContentClass, + landingGutterClass, + landingSectionSplitClass, + landingSplitCopyClass, + landingSplitMockClass, + landingSplitVisualClass, +} from '@/components/landing/landingLayout'; import { strings } from '@/lib/strings'; const vl = strings.views.landing; export default function LandingFinalCta() { return ( -
-
-

{vl.finalCtaTitle}

-

- {vl.finalCtaSubtitle} -

-
- - - - - - + + +
+

+ {vl.heroProofNoSubscription} · {vl.heroProofLocalData} +

-

- {vl.heroProofNoSubscription} · {vl.heroProofLocalData} -

+ +
+
+ +
+
+
+ + - +
); } diff --git a/web/src/components/landing/LandingFooter.tsx b/web/src/components/landing/LandingFooter.tsx index 2c81b8f..71753ea 100644 --- a/web/src/components/landing/LandingFooter.tsx +++ b/web/src/components/landing/LandingFooter.tsx @@ -3,6 +3,7 @@ import Link from 'next/link'; import type { ReactNode } from 'react'; import { ExternalLink } from 'lucide-react'; +import { landingGutterClass } from '@/components/landing/landingLayout'; import { strings } from '@/lib/strings'; const vl = strings.views.landing; @@ -35,7 +36,7 @@ function FooterLink({ export default function LandingFooter() { return ( -
+

{app.productName}

diff --git a/web/src/components/landing/LandingGoogleSetup.tsx b/web/src/components/landing/LandingGoogleSetup.tsx index 1220270..9c72324 100644 --- a/web/src/components/landing/LandingGoogleSetup.tsx +++ b/web/src/components/landing/LandingGoogleSetup.tsx @@ -2,8 +2,9 @@ import { useState } from 'react'; import Link from 'next/link'; -import { ChevronDown, ExternalLink } from 'lucide-react'; +import { ExternalLink } from 'lucide-react'; import Button from '@/components/Button'; +import { landingContentClass } from '@/components/landing/landingLayout'; import { strings } from '@/lib/strings'; type GuideSection = { @@ -28,118 +29,73 @@ const SECTION_ORDER = [ export default function LandingGoogleSetup() { const vl = strings.views.landing; const sections = vl.googleSetupSections as Record; - const [openIds, setOpenIds] = useState>(() => new Set(['prerequisites', 'oauthClient'])); - - const toggle = (id: string) => { - setOpenIds((prev) => { - const next = new Set(prev); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }); - }; - - const expandAll = () => setOpenIds(new Set(SECTION_ORDER)); - const collapseAll = () => setOpenIds(new Set()); + const [activeId, setActiveId] = useState('oauthClient'); + const active = sections[activeId]; return ( -
-
-
-

+

+
+
+

{vl.sectionSetupGuide}

-
-
-

{vl.googleSetupTitle}

-

- {vl.googleSetupSubtitle} -

-

- {vl.googleSetupNote} -

-
-
- - - - - -
-
+

{vl.googleSetupTitle}

+

+ {vl.googleSetupSubtitle} +

+ + + +
-
- {SECTION_ORDER.map((id, index) => { - const section = sections[id]; - if (!section) return null; - const expanded = openIds.has(id); - const panelId = `google-setup-panel-${id}`; - return ( -
- - {expanded ? ( -
-
    - {section.items.map((item) => ( -
  1. {item}
  2. - ))} -
- {section.linkLabel && section.linkUrl ? ( - - {section.linkLabel} - - - ) : null} -
- ) : null} -
- ); - })} -
+
+ {SECTION_ORDER.map((id, index) => { + const section = sections[id]; + if (!section) return null; + const selected = activeId === id; + return ( + + ); + })}
-
+ + {active ? ( +
+

{active.title}

+

+ {active.items[0]} +

+ {active.linkLabel && active.linkUrl ? ( + + {active.linkLabel} + + + ) : null} +
+ ) : null} + +

+ {vl.googleSetupNote} +

+
); } diff --git a/web/src/components/landing/LandingHero.tsx b/web/src/components/landing/LandingHero.tsx index 285f537..d2cc534 100644 --- a/web/src/components/landing/LandingHero.tsx +++ b/web/src/components/landing/LandingHero.tsx @@ -2,49 +2,83 @@ import Link from 'next/link'; import { CheckCircle2, ChevronRight } from 'lucide-react'; +import LandingHeroTopBar from '@/components/landing/LandingHeroTopBar'; import LandingProductMock from '@/components/landing/LandingProductMock'; +import LandingSlideBadge from '@/components/landing/LandingSlideBadge'; +import { + LANDING_SECTION_IDS, + landingGutterClass, + landingSectionClass, + landingSectionSplitClass, + landingSplitCopyClass, + landingSplitMockClass, + landingSplitVisualClass, +} from '@/components/landing/landingLayout'; +import { useInView } from '@/lib/useInView'; import { strings } from '@/lib/strings'; +import type { CSSProperties } from 'react'; const vl = strings.views.landing; export default function LandingHero() { + const { ref: bulletsRef, inView: bulletsInView } = useInView(); + return ( -
-
-
-

{vl.heroEyebrow}

-

- {vl.heroTitle} -

-

{vl.heroSubtitle}

-
    - {vl.heroBullets.map((bullet) => ( -
  • - - {bullet} -
  • - ))} -
-
- - {vl.ctaRunAudit} - - - + ).hero ?? ''} /> + + +
+
+
+

{vl.heroEyebrow}

+

+ {vl.heroTitle} +

+

+ {vl.heroSubtitle} +

+
    - {vl.ctaDashboard} - - + {vl.heroBullets.map((bullet, index) => ( +
  • + + {bullet} +
  • + ))} +
+
+ + {vl.ctaRunAudit} + + + + {vl.ctaDashboard} + + +
+

+ {vl.heroProofNoSubscription} · {vl.heroProofLocalData} +

-
-
- +
+
+ +
+
diff --git a/web/src/components/landing/LandingHeroTopBar.tsx b/web/src/components/landing/LandingHeroTopBar.tsx new file mode 100644 index 0000000..8f8e08f --- /dev/null +++ b/web/src/components/landing/LandingHeroTopBar.tsx @@ -0,0 +1,113 @@ +'use client'; + +import Link from 'next/link'; +import AppLogo from '@/components/AppLogo'; +import ThemeToggle from '@/components/ThemeToggle'; +import { LandingDeckPresentButton } from '@/components/landing/LandingDeckControls'; +import { useLandingDeckRequired } from '@/components/landing/LandingDeckContext'; +import { landingGutterClass } from '@/components/landing/landingLayout'; +import { strings } from '@/lib/strings'; + +const NAV_ITEMS = [ + { href: '#features', labelKey: 'navFeatures' as const }, + { href: '#quick-start', labelKey: 'navQuickStart' as const }, + { href: '#spotlight-google', labelKey: 'navGoogleSetup' as const }, +] as const; + +/** Title-slide chrome: logo, section links, and primary actions (hero slide only). */ +export default function LandingHeroTopBar() { + const vl = strings.views.landing; + const app = strings.app; + const { presenterMode } = useLandingDeckRequired(); + + if (presenterMode) { + return ( +
+ + + {app.productName} + +
+ + + {vl.navRunAudit} + +
+
+ ); + } + + return ( +
+
+ + + {app.productName} + + +
+ + + + {vl.navOpenApp} + + + {vl.navRunAudit} + +
+
+ +
+ ); +} diff --git a/web/src/components/landing/LandingLimitations.tsx b/web/src/components/landing/LandingLimitations.tsx index fda5fa2..66653c1 100644 --- a/web/src/components/landing/LandingLimitations.tsx +++ b/web/src/components/landing/LandingLimitations.tsx @@ -1,55 +1,73 @@ 'use client'; -import { ExternalLink } from 'lucide-react'; +import { CheckCircle2, ExternalLink, XCircle } from 'lucide-react'; import LandingSectionHeader from '@/components/landing/LandingSectionHeader'; +import { + landingContentClass, + landingGutterClass, + landingSectionSplitClass, + landingSplitCopyClass, +} from '@/components/landing/landingLayout'; import { strings } from '@/lib/strings'; const vl = strings.views.landing; export default function LandingLimitations() { return ( -
-
- -
-
-

{vl.limitationsIsTitle}

-
    - {vl.limitationsIsItems.map((item) => ( -
  • - - {item} -
  • - ))} -
-
-
-

{vl.limitationsIsntTitle}

-
    - {vl.limitationsIsntItems.map((item) => ( -
  • - - {item} -
  • - ))} -
+
+
+
+ +

+ {vl.limitationsContext} +

+
+ +
+
+
+

{vl.limitationsIsTitle}

+
    + {vl.limitationsIsItems.map((item) => ( +
  • + + {item} +
  • + ))} +
+
+
+

{vl.limitationsIsntTitle}

+
    + {vl.limitationsIsntItems.map((item) => ( +
  • + + {item} +
  • + ))} +
+
-

- - {vl.limitationsReadmeLink} - - -

-
+ + +
); } diff --git a/web/src/components/landing/LandingPageSection.tsx b/web/src/components/landing/LandingPageSection.tsx new file mode 100644 index 0000000..8f25d2f --- /dev/null +++ b/web/src/components/landing/LandingPageSection.tsx @@ -0,0 +1,52 @@ +'use client'; + +import type { ReactNode } from 'react'; +import Reveal from '@/components/Reveal'; +import LandingSlideBadge from '@/components/landing/LandingSlideBadge'; +import { + landingContentClass, + landingGutterClass, + landingSectionBodyCenteredClass, + landingSectionBodyClass, + landingSectionClass, + landingSectionPad, +} from '@/components/landing/landingLayout'; +import { strings } from '@/lib/strings'; + +export interface LandingPageSectionProps { + id?: string; + /** Edge-to-edge layout (hero / spotlight splits). Skips outer gutters. */ + fullBleed?: boolean; + className?: string; + children: ReactNode; +} + +function slideBadgeForId(id: string | undefined): string { + if (!id) return ''; + const badges = strings.views.landing.deckBadges as Record | undefined; + return badges?.[id] ?? ''; +} + +export default function LandingPageSection({ + id, + fullBleed = false, + className = '', + children, +}: LandingPageSectionProps) { + const bodyClass = fullBleed + ? `${landingSectionBodyClass} ${landingContentClass}` + : `${landingSectionBodyCenteredClass} ${landingContentClass} ${landingGutterClass}`; + + const badge = slideBadgeForId(id); + + return ( + + {badge ? : null} +
{children}
+
+ ); +} diff --git a/web/src/components/landing/LandingPathStrip.tsx b/web/src/components/landing/LandingPathStrip.tsx index 69fce38..a6b35a2 100644 --- a/web/src/components/landing/LandingPathStrip.tsx +++ b/web/src/components/landing/LandingPathStrip.tsx @@ -1,6 +1,13 @@ 'use client'; -import { ArrowRight, BarChart2, Download, Play, Settings2 } from 'lucide-react'; +import { BarChart2, ChevronRight, Download, Play, Settings2 } from 'lucide-react'; +import LandingSectionHeader from '@/components/landing/LandingSectionHeader'; +import { + landingContentClass, + landingGutterClass, + landingSectionSplitClass, + landingSplitCopyClass, +} from '@/components/landing/landingLayout'; import { strings } from '@/lib/strings'; const vl = strings.views.landing; @@ -8,52 +15,78 @@ const vl = strings.views.landing; const STEPS = [ { step: 1, id: 'quick-start', icon: Download, label: vl.pathStepInstall, hint: vl.pathStepInstallHint }, { step: 2, id: 'spotlights', icon: Play, label: vl.pathStepCrawl, hint: vl.pathStepCrawlHint }, - { step: 3, id: 'google-setup', icon: Settings2, label: vl.pathStepGoogle, hint: vl.pathStepGoogleHint }, - { step: 4, id: 'features', icon: BarChart2, label: vl.pathStepReport, hint: vl.pathStepReportHint }, + { step: 3, id: 'spotlight-google', icon: Settings2, label: vl.pathStepGoogle, hint: vl.pathStepGoogleHint }, + { step: 4, id: 'spotlight-compare-export', icon: BarChart2, label: vl.pathStepReport, hint: vl.pathStepReportHint }, ] as const; export default function LandingPathStrip() { return ( -
-

- {vl.pathTitle} -

-
-
- {STEPS.map(({ step, id, icon: Icon, label, hint }, index) => ( -
- - - - - - - {step} +
+
+ + +
+
+ {STEPS.map(({ step, id, icon: Icon, label, hint }) => ( + + + + + + + {step} + - - - {label} - {hint} - - - {index < STEPS.length - 1 ? ( - - ) : null} +

+ {label} +

+

{hint}

+ + ))}
- ))} +
+
+ + -
+
); } diff --git a/web/src/components/landing/LandingProductMock.tsx b/web/src/components/landing/LandingProductMock.tsx index 0560d3a..78f11e6 100644 --- a/web/src/components/landing/LandingProductMock.tsx +++ b/web/src/components/landing/LandingProductMock.tsx @@ -11,22 +11,45 @@ import { CompactStackedBar, } from '@/components/charts/compact'; -export type LandingProductMockVariant = 'default' | 'crawl' | 'issues'; +export type LandingProductMockVariant = + | 'default' + | 'crawl' + | 'issues' + | 'google' + | 'contentStudio' + | 'aiChat' + | 'compareExport'; interface LandingProductMockProps { variant?: LandingProductMockVariant; className?: string; elevated?: boolean; + compact?: boolean; + /** Stretch to fill a split-column visual area (hero / spotlights). */ + fillHeight?: boolean; } const NAV_ITEMS = [ { label: 'Overview', activeFor: ['default'] as const }, { label: 'Issues', activeFor: ['issues'] as const }, { label: 'All URLs', activeFor: ['crawl'] as const }, - { label: 'Search', activeFor: [] as const }, - { label: 'Export', activeFor: [] as const }, + { label: 'Search', activeFor: ['google'] as const }, + { label: 'Write', activeFor: ['contentStudio'] as const }, + { label: 'Chat', activeFor: ['aiChat'] as const }, + { label: 'Compare', activeFor: ['compareExport'] as const }, + { label: 'Export', activeFor: ['compareExport'] as const }, ]; +const MOCK_PATHS: Record = { + default: 'overview', + crawl: 'links', + issues: 'issues', + google: 'search-performance', + contentStudio: 'write', + aiChat: 'chat', + compareExport: 'compare', +}; + const MOCK_GSC_BAR_HEIGHTS = [40, 65, 52, 78, 45, 88, 60, 72, 55, 80, 68, 92]; function MockLineChart({ label }: { label?: string }) { @@ -268,34 +291,209 @@ function OverviewPanel() { ); } +function GooglePanel() { + return ( + <> +
+ + + +
+
+ + + + +
+ + +
+
+
+ +
+ + + +
+
+ + ); +} + +function MockTermRow({ term, count, target, tone }: { term: string; count: number; target: number; tone: 'ok' | 'warn' | 'bad' }) { + const toneClass = + tone === 'ok' ? 'bg-emerald-500' : tone === 'warn' ? 'bg-amber-500' : 'bg-red-500'; + const pct = Math.min(100, Math.round((count / Math.max(target, 1)) * 100)); + return ( +
+
+ {term} + + {count}/{target} + +
+
+
+
+
+ ); +} + +function ContentStudioPanel() { + return ( +
+
+
+

Title

+

Technical SEO Audit Guide 2026

+
+
+

Body

+
+ + + + +
+
+
+ +
+ ); +} + +function AiChatPanel() { + return ( +
+
+

Summarize site health and export a PDF report.

+
+
+

Health score 82 (+4). 12 critical issues remain…

+
+ + + +
+
+
+ + Download PDF + + + View issues table + +
+
+ ); +} + +function CompareExportPanel() { + return ( + <> +
+ + + +
+
+ + + + + + +
+
+ + Export PDF + + + Export HTML + +
+ + ); +} + +function renderPanel(variant: LandingProductMockVariant) { + switch (variant) { + case 'crawl': + return ; + case 'issues': + return ; + case 'google': + return ; + case 'contentStudio': + return ; + case 'aiChat': + return ; + case 'compareExport': + return ; + default: + return ; + } +} + export default function LandingProductMock({ variant = 'default', className = '', elevated = false, + compact = false, + fillHeight = false, }: LandingProductMockProps) { + const bodyMinH = fillHeight + ? 'min-h-0 flex-1' + : compact + ? 'min-h-[200px] sm:min-h-[220px]' + : 'min-h-[320px] sm:min-h-[360px]'; + return (
-
+
- - - + + + - - https://site-audit.local/{variant === 'crawl' ? 'links' : variant === 'issues' ? 'issues' : 'overview'} + + https://site-audit.local/{MOCK_PATHS[variant]}
-
-