diff --git a/.ai/DECISIONS.md b/.ai/DECISIONS.md index 02e1df14..c1d37240 100644 --- a/.ai/DECISIONS.md +++ b/.ai/DECISIONS.md @@ -13,6 +13,32 @@ --- +## 2026-05-28 — Scope Anthropic structured outputs to flat-array JSON only + +**Context:** Optimizing the existing Claude API usage (no model change). The Anthropic path in `generate_json` (`ai_provider.py`) had no equivalent to the Gemini path's `response_mime_type="application/json"` — it prompted for JSON and relied on downstream defenses: `_strip_markdown_fences` (ai_fix), `parse_llm_json` (knowledge_flywheel), and `_try_repair_json` (kb_conversion, which balances unclosed braces on truncated output). Anthropic structured outputs (`output_config.format` with a JSON schema) guarantee valid, parseable JSON and would eliminate those band-aids. The question was which of the four `generate_json` call sites can adopt it. + +Structured outputs has hard schema limits: **no recursive schemas**, and **every object must set `additionalProperties: false`** (so the schema must enumerate exactly the fields the model emits — a superset is impossible, an omission makes a field unproducible). Tracing the call sites against those limits: + +- **kb_conversion** → output is `{title, description, nodes: [...]}` / `{...steps[], intake_form[]}` — **flat arrays**, references by `next_node_id`/id, no nesting. Expressible. +- **ai_fix** → returns a fixed *node that is itself a subtree*; `_find_node_by_id` recurses `node["children"]` and the prompt requires decision nodes to have ≥2 children. **Recursive, arbitrary depth.** +- **knowledge_flywheel flow-gen** → emits `tree_structure`, a decision-tree root with nested `children`/`options`, persisted as an opaque blob. +- **knowledge_flywheel enhancement** → flat `new_nodes[] + modified_options[]`; expressible but low-frequency and only fence-stripped. + +**Decision:** Apply structured outputs to **flat-array outputs only** — i.e. `kb_conversion`. Wired via an optional `schema=` param on `AIProvider.generate_json` (`None` = legacy prompt-only behavior; Anthropic maps it to `output_config.format`, Gemini ignores it), with the two KB schemas + `_schema_for_target_type()` in `kb_conversion_service.py`, gated behind `settings.AI_KB_CONVERT_STRUCTURED_OUTPUT` (default **False**) pending a live constrained-decoding smoke-test in staging. The robustness fixes that motivated the work — `_extract_text_from_response` (skip non-text blocks, log `max_tokens`/`refusal`, raise on no-text) — live in the shared provider, so **all four** callers already benefit regardless of schema adoption. + +**Rejected:** +- **Forcing schemas on ai_fix / flow-gen.** Their outputs are recursive/nested decision trees; a bounded-depth schema would reject valid deeper trees and break generation. Wrong architecture for marginal/zero benefit (flow-gen's tree is stored as a blob, never schema-validated downstream). +- **Wiring the flywheel enhancement site.** Flat and technically expressible, but low call frequency and only fence-stripping today — marginal benefit against the risk of a blind (un-live-tested) `additionalProperties: false` schema. +- **Deleting the fence-strip / repair helpers now.** `_strip_markdown_fences` / `parse_llm_json` must stay — they protect the recursive paths that can't use schemas. Only `_try_repair_json` (kb-only) becomes removable, and only *after* the flag is validated in staging. + +**Consequences:** +- Structured outputs is the tool for flat JSON; recursive decision-tree outputs are excluded by design. New flat-JSON `generate_json` callers can opt in via `schema=`; recursive ones should not. +- `AI_KB_CONVERT_STRUCTURED_OUTPUT` must be smoke-tested against the live model (both target types) before production enablement. Open risk: whether Anthropic accepts optional (non-`required`) fields — if not, the schemas need every field in `required` with nullable types. The flag makes this fully reversible. +- Deferred cleanup: once the flag is validated, remove only `_try_repair_json` from the kb_conversion Anthropic path; leave the fence-strippers. +- Work lives on branch `feat/ai-structured-outputs` (commits `84a02a5`, `1388357`), based on `design/l1-workspace`. + +--- + ## 2026-05-13 — Session expiration policy: 3d idle / 14d absolute defaults + per-account override **Context:** User report: "I login to ResolutionFlow and never have to log back in." Investigation found refresh tokens at `REFRESH_TOKEN_EXPIRE_DAYS=7` with JTI rotation (`security.py:36`) — every `/auth/refresh` minted a fresh 7-day window. Net effect: a sliding 7-day session with no absolute cap. Visit once a week, logged in forever. Acceptable for pilot but not for MSP buyers whose SOC2 / cyber-insurance auditors require enforced session timeouts. Required for the same Phase O launch readiness as the other gates already in flight. diff --git a/.gitignore b/.gitignore index 63631b0e..b6c612a4 100644 --- a/.gitignore +++ b/.gitignore @@ -237,6 +237,10 @@ package.json package-lock.json .worktrees/ .gstack/ + +# Core dumps from crashed processes (e.g. core.12345) +core.[0-9]* +**/core.[0-9]* .gitnexus # graphify knowledge graph outputs diff --git a/backend/app/core/ai_provider.py b/backend/app/core/ai_provider.py index 1e0112b2..fba3b505 100644 --- a/backend/app/core/ai_provider.py +++ b/backend/app/core/ai_provider.py @@ -147,6 +147,40 @@ def build_anthropic_chat_messages( return messages +def _extract_text_from_response(response: Any, model: str) -> str: + """Return the first text block's text from an Anthropic message response. + + Robustness over the naive ``response.content[0].text``: + - Skips non-text leading blocks (e.g. ``thinking``) and returns the first + block whose ``type == "text"``. Indexing ``content[0]`` blindly throws or + returns garbage the moment a non-text block leads the response. + - Surfaces truncation/refusal: when ``stop_reason`` is ``max_tokens`` or + ``refusal``, emits a structured warning so silent output corruption + (truncated JSON, empty refusals) is observable rather than handed + downstream to be guessed at. + - Raises ``ValueError`` when no text block is present (e.g. a bare refusal) + instead of returning a non-text block's attributes. + """ + stop_reason = getattr(response, "stop_reason", None) + if stop_reason in ("max_tokens", "refusal"): + logger.warning( + "anthropic.stop_reason", + extra={ + "event": "anthropic.stop_reason", + "model": model, + "stop_reason": stop_reason, + }, + ) + + for block in response.content: + if getattr(block, "type", None) == "text": + return block.text + + raise ValueError( + f"Anthropic response contained no text block (stop_reason={stop_reason!r})" + ) + + def _log_anthropic_cache_usage(usage: Any, model: str) -> None: """Emit a structured log line capturing cache_read / cache_creation tokens.""" cache_read = getattr(usage, "cache_read_input_tokens", 0) or 0 @@ -176,6 +210,7 @@ class AIProvider(ABC): system_prompt: str | list[SystemBlock], messages: list[dict[str, Any]], max_tokens: int = 4096, + schema: dict[str, Any] | None = None, ) -> tuple[str, int, int]: """Generate a JSON response from the AI model. @@ -185,6 +220,15 @@ class AIProvider(ABC): Anthropic prompt caching per module-docstring policy. messages: List of message dicts with "role" and "content" keys. max_tokens: Maximum output tokens. + schema: Optional JSON Schema constraining the response shape. + When provided, the Anthropic backend uses structured outputs + (`output_config.format`) to guarantee valid, parseable JSON — + no markdown fences, no truncated-brace repair. Must satisfy the + structured-output schema limits (every object needs + `additionalProperties: false`; no recursion; numeric/string + constraints are stripped). `None` preserves the legacy + prompt-only behavior. The Gemini backend currently ignores this + argument (it already requests `application/json`). Returns: Tuple of (response_text, input_tokens, output_tokens). @@ -231,7 +275,11 @@ class GeminiProvider(AIProvider): system_prompt: str | list[SystemBlock], messages: list[dict[str, Any]], max_tokens: int = 4096, + schema: dict[str, Any] | None = None, ) -> tuple[str, int, int]: + # `schema` is accepted for interface parity but ignored: Gemini already + # constrains output via response_mime_type="application/json" below. + # Mapping JSON Schema -> Gemini response_schema is deferred. from google import genai from google.genai import types as genai_types @@ -362,18 +410,28 @@ class AnthropicProvider(AIProvider): system_prompt: str | list[SystemBlock], messages: list[dict[str, Any]], max_tokens: int = 4096, + schema: dict[str, Any] | None = None, ) -> tuple[str, int, int]: client = _get_anthropic_client(self._api_key, self._timeout) normalized_system = _normalize_system_for_anthropic(system_prompt) - response = await client.messages.create( - model=self._model, - max_tokens=max_tokens, - system=normalized_system, - messages=messages, - ) + create_kwargs: dict[str, Any] = { + "model": self._model, + "max_tokens": max_tokens, + "system": normalized_system, + "messages": messages, + } + if schema is not None: + # Structured outputs: constrain the response to valid JSON matching + # the schema (Sonnet 4.6 / Haiku 4.5). Removes the need for + # markdown-fence stripping and truncated-JSON repair downstream. + create_kwargs["output_config"] = { + "format": {"type": "json_schema", "schema": schema} + } - text = response.content[0].text + response = await client.messages.create(**create_kwargs) + + text = _extract_text_from_response(response, self._model) input_tokens = response.usage.input_tokens output_tokens = response.usage.output_tokens diff --git a/backend/app/core/config.py b/backend/app/core/config.py index d1582265..5f215cda 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -155,6 +155,12 @@ class Settings(BaseSettings): AI_CONVERSATION_TTL_HOURS: int = 24 AI_MAX_CALLS_PER_FLOW: int = 10 AI_REQUEST_TIMEOUT_SECONDS: int = 120 + # When True, KB conversion constrains the Anthropic response with a JSON + # schema (structured outputs) instead of relying on prompt-only JSON + + # downstream fence-stripping / brace-repair. Default OFF: enable in staging + # and smoke-test constrained decoding against the live model before turning + # it on in production. Only affects the Anthropic backend. + AI_KB_CONVERT_STRUCTURED_OUTPUT: bool = False # AI Provider selection AI_PROVIDER: str = "anthropic" # "gemini" or "anthropic" GOOGLE_AI_API_KEY: Optional[str] = None diff --git a/backend/app/core/kb_conversion_service.py b/backend/app/core/kb_conversion_service.py index b5e7f636..1145a4f7 100644 --- a/backend/app/core/kb_conversion_service.py +++ b/backend/app/core/kb_conversion_service.py @@ -202,6 +202,115 @@ the engineer attached, NOT from this schema): 9. Return ONLY valid JSON — no markdown fences, no explanation text.""" +# ── Structured-output schemas ── +# +# These constrain the model's JSON via Anthropic structured outputs +# (output_config.format) so the response is guaranteed valid and parseable — +# no markdown fences, no truncated-brace repair. They must be a SUPERSET of +# every field the corresponding system prompt instructs the model to emit: +# additionalProperties is False everywhere, so any field the prompt asks for +# but the schema omits would be impossible to produce. +# +# `type`/`field_type` are intentionally left as plain strings (no enum): the +# downstream parser already normalizes/tolerates the type values, and an enum +# risks constraining the model away from a value the prompt would yield. + +_TROUBLESHOOTING_OPTION_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "label": {"type": "string"}, + "next_node_id": {"type": "string"}, + }, + "required": ["label", "next_node_id"], + "additionalProperties": False, +} + +_TROUBLESHOOTING_NODE_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "id": {"type": "string"}, + "type": {"type": "string"}, + "question": {"type": "string"}, + "options": {"type": "array", "items": _TROUBLESHOOTING_OPTION_SCHEMA}, + "next_node_id": {"type": "string"}, + "confidence": {"type": "number"}, + "source_excerpt": {"type": "string"}, + }, + # Only the universal fields are required. `question`/`options`/`next_node_id` + # vary by node type and stay optional so a resolution node need not carry + # options and an action node need not carry a question. + "required": ["id", "type", "confidence", "source_excerpt"], + "additionalProperties": False, +} + +TROUBLESHOOTING_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "title": {"type": "string"}, + "description": {"type": "string"}, + "nodes": {"type": "array", "items": _TROUBLESHOOTING_NODE_SCHEMA}, + }, + "required": ["title", "description", "nodes"], + "additionalProperties": False, +} + +_PROCEDURAL_STEP_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "id": {"type": "string"}, + "type": {"type": "string"}, + "content": {"type": "string"}, + "confidence": {"type": "number"}, + "source_excerpt": {"type": "string"}, + }, + "required": ["id", "type", "content", "confidence", "source_excerpt"], + "additionalProperties": False, +} + +_PROCEDURAL_INTAKE_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "variable_name": {"type": "string"}, + "label": {"type": "string"}, + "field_type": {"type": "string"}, + "required": {"type": "boolean"}, + "display_order": {"type": "integer"}, + }, + "required": [ + "variable_name", + "label", + "field_type", + "required", + "display_order", + ], + "additionalProperties": False, +} + +PROCEDURAL_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "title": {"type": "string"}, + "description": {"type": "string"}, + "steps": {"type": "array", "items": _PROCEDURAL_STEP_SCHEMA}, + "intake_form": {"type": "array", "items": _PROCEDURAL_INTAKE_SCHEMA}, + }, + "required": ["title", "description", "steps", "intake_form"], + "additionalProperties": False, +} + + +def _schema_for_target_type(target_type: str) -> dict[str, Any]: + """Return the structured-output schema for a KB conversion target type. + + Mirrors the prompt selection in ``convert_document``: only + ``"troubleshooting"`` uses the decision-tree schema; everything else is + treated as a procedural flow. + """ + if target_type == "troubleshooting": + return TROUBLESHOOTING_SCHEMA + return PROCEDURAL_SCHEMA + + def _build_user_message( source_text: str, source_metadata: dict[str, Any] | None, @@ -404,6 +513,16 @@ async def convert_document( model = settings.get_model_for_action("kb_convert") provider = get_ai_provider(model=model) + # Structured outputs (flagged): constrain the response to a JSON schema so + # the model can't emit fences or truncated JSON. Falls back to prompt-only + # JSON (schema=None) when disabled; the parse path below stays intact either + # way as a belt-and-suspenders fallback. + schema = ( + _schema_for_target_type(kb_import.target_type) + if settings.AI_KB_CONVERT_STRUCTURED_OUTPUT + else None + ) + try: raw_text, input_tokens, output_tokens = await provider.generate_json( system_prompt=[ @@ -414,6 +533,7 @@ async def convert_document( ], messages=[{"role": "user", "content": user_message}], max_tokens=16384, + schema=schema, ) except Exception as e: logger.error("AI conversion failed for kb_import=%s: %s", kb_import.id, e) diff --git a/backend/tests/test_ai_provider.py b/backend/tests/test_ai_provider.py index 611c8e7b..2e9d4368 100644 --- a/backend/tests/test_ai_provider.py +++ b/backend/tests/test_ai_provider.py @@ -96,7 +96,8 @@ class TestAnthropicProvider: ) mock_response = MagicMock() - mock_response.content = [MagicMock(text='{"result": "ok"}')] + mock_response.content = [MagicMock(type="text", text='{"result": "ok"}')] + mock_response.stop_reason = "end_turn" mock_response.usage = MagicMock(input_tokens=100, output_tokens=50) mock_client = AsyncMock() @@ -120,6 +121,170 @@ class TestAnthropicProvider: messages=[{"role": "user", "content": "Hello"}], ) + @pytest.mark.asyncio + async def test_generate_json_skips_non_text_blocks(self): + """A leading non-text block (e.g. thinking) is skipped; the first + text block's text is returned instead of content[0].text.""" + from app.core import ai_provider + + ai_provider._anthropic_clients.clear() + + provider = AnthropicProvider( + api_key="skip-key", model="claude-sonnet-4-6", timeout=31 + ) + + thinking_block = MagicMock(type="thinking", thinking="hmm...") + text_block = MagicMock(type="text", text='{"ok": 1}') + mock_response = MagicMock() + mock_response.content = [thinking_block, text_block] + mock_response.stop_reason = "end_turn" + mock_response.usage = MagicMock(input_tokens=10, output_tokens=5) + + mock_client = AsyncMock() + mock_client.messages.create = AsyncMock(return_value=mock_response) + + with patch("anthropic.AsyncAnthropic", return_value=mock_client): + text, _, _ = await provider.generate_json( + system_prompt="You are a helper.", + messages=[{"role": "user", "content": "Hi"}], + ) + + assert text == '{"ok": 1}' + + @pytest.mark.asyncio + async def test_generate_json_raises_when_no_text_block(self): + """A response with no text block (e.g. a bare refusal) raises a clear + error instead of returning a non-text block's attributes.""" + from app.core import ai_provider + + ai_provider._anthropic_clients.clear() + + provider = AnthropicProvider( + api_key="empty-key", model="claude-sonnet-4-6", timeout=32 + ) + + mock_response = MagicMock() + mock_response.content = [MagicMock(type="thinking", thinking="...")] + mock_response.stop_reason = "refusal" + mock_response.usage = MagicMock(input_tokens=10, output_tokens=0) + + mock_client = AsyncMock() + mock_client.messages.create = AsyncMock(return_value=mock_response) + + with patch("anthropic.AsyncAnthropic", return_value=mock_client): + with pytest.raises(ValueError, match="no text block"): + await provider.generate_json( + system_prompt="You are a helper.", + messages=[{"role": "user", "content": "Hi"}], + ) + + @pytest.mark.asyncio + async def test_generate_json_logs_warning_on_truncation(self, caplog): + """When stop_reason is max_tokens, a warning is logged (truncation + signal) and the partial text is still returned.""" + import logging + + from app.core import ai_provider + + ai_provider._anthropic_clients.clear() + + provider = AnthropicProvider( + api_key="trunc-key", model="claude-sonnet-4-6", timeout=33 + ) + + text_block = MagicMock(type="text", text='{"partial": tr') + mock_response = MagicMock() + mock_response.content = [text_block] + mock_response.stop_reason = "max_tokens" + mock_response.usage = MagicMock(input_tokens=10, output_tokens=4096) + + mock_client = AsyncMock() + mock_client.messages.create = AsyncMock(return_value=mock_response) + + with patch("anthropic.AsyncAnthropic", return_value=mock_client): + with caplog.at_level(logging.WARNING, logger="app.core.ai_provider"): + text, _, _ = await provider.generate_json( + system_prompt="You are a helper.", + messages=[{"role": "user", "content": "Hi"}], + ) + + assert text == '{"partial": tr' + truncation_records = [ + r for r in caplog.records if getattr(r, "stop_reason", None) == "max_tokens" + ] + assert truncation_records, "expected a warning record for max_tokens truncation" + + @pytest.mark.asyncio + async def test_generate_json_passes_output_config_when_schema_given(self): + """When a JSON schema is supplied, it is forwarded as + output_config.format so the API constrains the response shape.""" + from app.core import ai_provider + + ai_provider._anthropic_clients.clear() + + provider = AnthropicProvider( + api_key="schema-key", model="claude-sonnet-4-6", timeout=34 + ) + + mock_response = MagicMock() + mock_response.content = [MagicMock(type="text", text='{"title": "x"}')] + mock_response.stop_reason = "end_turn" + mock_response.usage = MagicMock(input_tokens=10, output_tokens=5) + + mock_client = AsyncMock() + mock_client.messages.create = AsyncMock(return_value=mock_response) + + schema = { + "type": "object", + "properties": {"title": {"type": "string"}}, + "required": ["title"], + "additionalProperties": False, + } + + with patch("anthropic.AsyncAnthropic", return_value=mock_client): + await provider.generate_json( + system_prompt="You are a helper.", + messages=[{"role": "user", "content": "Hi"}], + max_tokens=512, + schema=schema, + ) + + mock_client.messages.create.assert_called_once_with( + model="claude-sonnet-4-6", + max_tokens=512, + system="You are a helper.", + messages=[{"role": "user", "content": "Hi"}], + output_config={"format": {"type": "json_schema", "schema": schema}}, + ) + + @pytest.mark.asyncio + async def test_generate_json_no_output_config_when_schema_none(self): + """With no schema, output_config is not sent (backward compatible).""" + from app.core import ai_provider + + ai_provider._anthropic_clients.clear() + + provider = AnthropicProvider( + api_key="noschema-key", model="claude-sonnet-4-6", timeout=35 + ) + + mock_response = MagicMock() + mock_response.content = [MagicMock(type="text", text="{}")] + mock_response.stop_reason = "end_turn" + mock_response.usage = MagicMock(input_tokens=1, output_tokens=1) + + mock_client = AsyncMock() + mock_client.messages.create = AsyncMock(return_value=mock_response) + + with patch("anthropic.AsyncAnthropic", return_value=mock_client): + await provider.generate_json( + system_prompt="You are a helper.", + messages=[{"role": "user", "content": "Hi"}], + ) + + _, call_kwargs = mock_client.messages.create.call_args + assert "output_config" not in call_kwargs + class TestGeminiProvider: """Tests for GeminiProvider.generate_json.""" @@ -174,6 +339,48 @@ class TestGeminiProvider: mock_client.aio.models.generate_content.assert_called_once() + @pytest.mark.asyncio + async def test_generate_json_accepts_and_ignores_schema(self): + """Gemini accepts the schema kwarg (interface parity) and still + returns JSON; it does not error on the param.""" + provider = GeminiProvider(api_key="test-key", model="gemini-2.5-flash") + + mock_usage = MagicMock() + mock_usage.prompt_token_count = 5 + mock_usage.candidates_token_count = 3 + + mock_response = MagicMock() + mock_response.text = '{"answer": 1}' + mock_response.usage_metadata = mock_usage + + mock_client = MagicMock() + mock_client.aio.models.generate_content = AsyncMock(return_value=mock_response) + + mock_genai_module = MagicMock() + mock_genai_module.Client.return_value = mock_client + + mock_types = MagicMock() + mock_types.Content.side_effect = lambda **kw: kw + mock_types.Part.side_effect = lambda **kw: kw + mock_types.GenerateContentConfig.side_effect = lambda **kw: kw + + mock_google = MagicMock() + mock_google.genai = mock_genai_module + mock_genai_module.types = mock_types + + with patch.dict(sys.modules, { + "google": mock_google, + "google.genai": mock_genai_module, + "google.genai.types": mock_types, + }): + text, _, _ = await provider.generate_json( + system_prompt="Generate JSON.", + messages=[{"role": "user", "content": "data"}], + schema={"type": "object"}, + ) + + assert text == '{"answer": 1}' + @pytest.mark.asyncio async def test_generate_json_handles_none_usage(self): """Token counts default to 0 when usage_metadata attributes are None.""" diff --git a/backend/tests/test_kb_conversion_schema.py b/backend/tests/test_kb_conversion_schema.py new file mode 100644 index 00000000..fae4da3f --- /dev/null +++ b/backend/tests/test_kb_conversion_schema.py @@ -0,0 +1,104 @@ +"""Tests for the structured-output JSON schemas used by KB conversion. + +These validate that the schemas are well-formed against the Anthropic +structured-output limits (every object carries additionalProperties: false, +`required` is a subset of declared properties, no numeric/length constraints) +and that the target_type -> schema selector returns the right shape. They do +NOT exercise the live API — constrained decoding must be smoke-tested against +a real model before AI_KB_CONVERT_STRUCTURED_OUTPUT is enabled in production. +""" + +from app.core.kb_conversion_service import ( + PROCEDURAL_SCHEMA, + TROUBLESHOOTING_SCHEMA, + _schema_for_target_type, +) + +# Constraints disallowed by Anthropic structured outputs (must be absent so the +# API does not reject the schema or silently strip them). +_DISALLOWED_KEYS = { + "minimum", + "maximum", + "multipleOf", + "minLength", + "maxLength", + "minItems", + "maxItems", +} + + +def _assert_well_formed(schema: dict) -> None: + """Recursively assert a JSON schema obeys the structured-output limits.""" + if schema.get("type") == "object": + assert schema.get("additionalProperties") is False, ( + f"object schema missing additionalProperties: false: {schema}" + ) + props = schema.get("properties", {}) + required = set(schema.get("required", [])) + assert required <= set(props), ( + f"required keys not all declared as properties: {required - set(props)}" + ) + for sub in props.values(): + _assert_well_formed(sub) + elif schema.get("type") == "array": + _assert_well_formed(schema["items"]) + + assert not (_DISALLOWED_KEYS & set(schema)), ( + f"schema uses unsupported constraint(s): {_DISALLOWED_KEYS & set(schema)}" + ) + + +class TestStructuredOutputSchemas: + def test_troubleshooting_schema_is_well_formed(self): + _assert_well_formed(TROUBLESHOOTING_SCHEMA) + + def test_procedural_schema_is_well_formed(self): + _assert_well_formed(PROCEDURAL_SCHEMA) + + def test_troubleshooting_schema_top_level_shape(self): + props = TROUBLESHOOTING_SCHEMA["properties"] + assert set(props) >= {"title", "description", "nodes"} + node = props["nodes"]["items"] + # Every field the troubleshooting prompt may emit must be modelled, + # else additionalProperties: false makes them impossible to produce. + assert set(node["properties"]) >= { + "id", + "type", + "question", + "options", + "next_node_id", + "confidence", + "source_excerpt", + } + + def test_procedural_schema_top_level_shape(self): + props = PROCEDURAL_SCHEMA["properties"] + assert set(props) >= {"title", "description", "steps", "intake_form"} + step = props["steps"]["items"] + assert set(step["properties"]) >= { + "id", + "type", + "content", + "confidence", + "source_excerpt", + } + intake = props["intake_form"]["items"] + assert set(intake["properties"]) >= { + "variable_name", + "label", + "field_type", + "required", + "display_order", + } + + +class TestSchemaSelector: + def test_returns_troubleshooting_schema(self): + assert _schema_for_target_type("troubleshooting") is TROUBLESHOOTING_SCHEMA + + def test_returns_procedural_schema_for_procedural(self): + assert _schema_for_target_type("procedural") is PROCEDURAL_SCHEMA + + def test_defaults_to_procedural_for_unknown(self): + # convert_document treats any non-"troubleshooting" target as procedural. + assert _schema_for_target_type("something-else") is PROCEDURAL_SCHEMA diff --git a/frontend/e2e/auth.spec.ts b/frontend/e2e/auth.spec.ts index e5a261d9..a8212d38 100644 --- a/frontend/e2e/auth.spec.ts +++ b/frontend/e2e/auth.spec.ts @@ -7,7 +7,7 @@ test.describe('authentication smoke tests', () => { test('team admin can sign in through the login form', async ({ page }) => { await signIn(page) - await expect(page).toHaveURL(/\/$/) + await expect(page).toHaveURL(/\/home$/) await expect(page.getByTestId('app-shell')).toBeVisible() }) }) diff --git a/frontend/e2e/public.spec.ts b/frontend/e2e/public.spec.ts index 81fd469e..f78e3a39 100644 --- a/frontend/e2e/public.spec.ts +++ b/frontend/e2e/public.spec.ts @@ -4,7 +4,7 @@ test.use({ storageState: { cookies: [], origins: [] } }) test.describe('public route smoke tests', () => { test('landing page loads', async ({ page }) => { - await page.goto('/landing') + await page.goto('/') await expect( page.getByRole('link', { name: 'Start Free', exact: true }), @@ -17,7 +17,7 @@ test.describe('public route smoke tests', () => { test('protected routes redirect unauthenticated users to landing', async ({ page }) => { await page.goto('/sessions') - await expect(page).toHaveURL(/\/landing$/) + await expect(page).toHaveURL(/\/$/) await expect( page.getByRole('link', { name: 'Sign In' }), ).toBeVisible() diff --git a/frontend/index.html b/frontend/index.html index 6e98ac5c..b821fef3 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -10,7 +10,7 @@ - + diff --git a/frontend/public/robots.txt b/frontend/public/robots.txt new file mode 100644 index 00000000..1c41f9ab --- /dev/null +++ b/frontend/public/robots.txt @@ -0,0 +1,36 @@ +User-agent: * +Allow: / +Allow: /terms +Allow: /policies +Allow: /privacy +Allow: /contact +Allow: /contact-sales +Allow: /pricing +Allow: /promotions +Allow: /templates +Disallow: /home +Disallow: /trees/ +Disallow: /my-trees +Disallow: /pilot/ +Disallow: /admin/ +Disallow: /account/ +Disallow: /script-builder +Disallow: /scripts +Disallow: /sessions +Disallow: /analytics +Disallow: /escalations +Disallow: /queue +Disallow: /review-queue +Disallow: /network-diagrams +Disallow: /kb-accelerator +Disallow: /step-library +Disallow: /tickets +Disallow: /shares +Disallow: /feedback +Disallow: /welcome +Disallow: /flow-assist +Disallow: /dev/ +Disallow: /flows/ +Disallow: /guides + +Sitemap: https://resolutionflow.com/sitemap.xml diff --git a/frontend/public/sitemap.xml b/frontend/public/sitemap.xml new file mode 100644 index 00000000..c0db68a5 --- /dev/null +++ b/frontend/public/sitemap.xml @@ -0,0 +1,57 @@ + + + + https://resolutionflow.com/ + 2026-05-13 + weekly + 1.0 + + + https://resolutionflow.com/pricing + 2026-05-13 + monthly + 0.9 + + + https://resolutionflow.com/contact-sales + 2026-05-13 + monthly + 0.8 + + + https://resolutionflow.com/contact + 2026-05-13 + monthly + 0.7 + + + https://resolutionflow.com/templates + 2026-05-13 + weekly + 0.7 + + + https://resolutionflow.com/terms + 2026-05-13 + yearly + 0.4 + + + https://resolutionflow.com/privacy + 2026-05-13 + yearly + 0.4 + + + https://resolutionflow.com/policies + 2026-05-13 + yearly + 0.4 + + + https://resolutionflow.com/promotions + 2026-05-13 + monthly + 0.4 + + diff --git a/frontend/src/components/common/PageMeta.tsx b/frontend/src/components/common/PageMeta.tsx index 342aad6b..f2d8b962 100644 --- a/frontend/src/components/common/PageMeta.tsx +++ b/frontend/src/components/common/PageMeta.tsx @@ -5,6 +5,8 @@ interface PageMetaProps { description?: string ogImage?: string ogType?: string + /** Canonical/Open Graph URL. Defaults to `window.location.href` in the browser. */ + url?: string } const SITE_NAME = 'ResolutionFlow' @@ -20,8 +22,12 @@ export function PageMeta({ description = DEFAULT_DESCRIPTION, ogImage, ogType = 'website', + url, }: PageMetaProps) { const fullTitle = title ? `${title} | ${SITE_NAME}` : `${SITE_NAME} — ${DEFAULT_TAGLINE}` + const resolvedUrl = + url ?? (typeof window !== 'undefined' ? window.location.href : undefined) + const twitterCard = ogImage ? 'summary_large_image' : 'summary' return ( @@ -33,10 +39,11 @@ export function PageMeta({ + {resolvedUrl && } {ogImage && } {/* Twitter */} - + {ogImage && } diff --git a/frontend/src/components/dashboard/NextStepCard.tsx b/frontend/src/components/dashboard/NextStepCard.tsx index 9b158d5a..9dc1fc7f 100644 --- a/frontend/src/components/dashboard/NextStepCard.tsx +++ b/frontend/src/components/dashboard/NextStepCard.tsx @@ -79,7 +79,7 @@ export function pickNextStep( title: 'Run your first FlowPilot session', description: 'Paste a ticket or pick a flow to see ResolutionFlow in action.', ctaLabel: 'Start a session', - ctaPath: '/', + ctaPath: '/home', } } if (!status.connected_psa) { diff --git a/frontend/src/components/dashboard/SetupChecklist.tsx b/frontend/src/components/dashboard/SetupChecklist.tsx index 13ddcb1f..5ee7d726 100644 --- a/frontend/src/components/dashboard/SetupChecklist.tsx +++ b/frontend/src/components/dashboard/SetupChecklist.tsx @@ -51,7 +51,7 @@ export function buildChecklistItems( { key: 'ran_session', label: 'Run your first FlowPilot session', - path: '/', + path: '/home', done: status.ran_session, }, { diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index 5eaaf104..8366c699 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -58,7 +58,7 @@ export function AppLayout() { } const mobileNavItems = [ - { path: '/', label: 'Dashboard', icon: LayoutGrid }, + { path: '/home', label: 'Dashboard', icon: LayoutGrid }, { path: '/sessions', label: 'Session History', icon: Clock }, { path: '/escalations', label: 'Escalations', icon: AlertTriangle }, { path: '/trees', label: 'Guided Flows', icon: GitBranch }, @@ -106,7 +106,7 @@ export function AppLayout() { style={{ background: 'var(--color-bg-sidebar)', borderRight: '1px solid var(--color-border-default)' }} >
- + ResolutionFlow diff --git a/frontend/src/components/layout/CommandPalette.tsx b/frontend/src/components/layout/CommandPalette.tsx index 7bac283f..de411391 100644 --- a/frontend/src/components/layout/CommandPalette.tsx +++ b/frontend/src/components/layout/CommandPalette.tsx @@ -40,7 +40,7 @@ interface Group { } const PAGES: PaletteItem[] = [ - { id: 'page-dashboard', group: 'pages', title: 'Dashboard', path: '/', icon: 'page' }, + { id: 'page-dashboard', group: 'pages', title: 'Dashboard', path: '/home', icon: 'page' }, { id: 'page-flows', group: 'pages', title: 'All Flows', subtitle: 'Browse your flow library', path: '/trees', icon: 'page' }, { id: 'page-sessions', group: 'pages', title: 'Sessions', subtitle: 'View session history', path: '/sessions', icon: 'page' }, { id: 'page-flowpilot', group: 'pages', title: 'FlowPilot', subtitle: 'AI troubleshooting', path: '/pilot', icon: 'page' }, diff --git a/frontend/src/components/layout/ProtectedRoute.tsx b/frontend/src/components/layout/ProtectedRoute.tsx index 0b48c97c..184df619 100644 --- a/frontend/src/components/layout/ProtectedRoute.tsx +++ b/frontend/src/components/layout/ProtectedRoute.tsx @@ -22,7 +22,7 @@ export function ProtectedRoute({ requiredRole, children }: ProtectedRouteProps) } if (!isAuthenticated) { - return + return } // Enforce must_change_password — redirect unless already on /change-password @@ -49,6 +49,17 @@ export function ProtectedRoute({ requiredRole, children }: ProtectedRouteProps) return } + // L1 users hitting engineer-only AI surfaces (Pilot / Assistant) get pushed + // back to /l1 — POST /api/v1/ai-sessions rejects them with 403 anyway, so + // this just turns a backend error into a clean route-level redirect. + if ( + effectiveRole === 'l1_tech' && + (location.pathname.startsWith('/pilot') || + location.pathname.startsWith('/assistant')) + ) { + return + } + return <>{children} } diff --git a/frontend/src/components/layout/TopBar.tsx b/frontend/src/components/layout/TopBar.tsx index 42e107df..91c2bf5b 100644 --- a/frontend/src/components/layout/TopBar.tsx +++ b/frontend/src/components/layout/TopBar.tsx @@ -63,7 +63,7 @@ export function TopBar() { > {/* Logo area */} diff --git a/frontend/src/components/layout/__tests__/AppLayout.test.tsx b/frontend/src/components/layout/__tests__/AppLayout.test.tsx index 26bf3fcd..0ada0458 100644 --- a/frontend/src/components/layout/__tests__/AppLayout.test.tsx +++ b/frontend/src/components/layout/__tests__/AppLayout.test.tsx @@ -71,11 +71,11 @@ const FROZEN_NOW = new Date('2026-05-06T00:00:00Z') function renderAppLayout() { return render( - + }> child route
} /> diff --git a/frontend/src/components/layout/__tests__/ProtectedRoute.test.tsx b/frontend/src/components/layout/__tests__/ProtectedRoute.test.tsx new file mode 100644 index 00000000..a87f3bf9 --- /dev/null +++ b/frontend/src/components/layout/__tests__/ProtectedRoute.test.tsx @@ -0,0 +1,59 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom' + +import { ProtectedRoute } from '../ProtectedRoute' +import { useAuthStore } from '@/store/authStore' + +/** + * Probe component: surfaces the current pathname and `location.state.from` so + * the test can assert both the redirect target and that the original + * destination is preserved for post-login return. + */ +function LocationProbe() { + const loc = useLocation() + const from = + (loc.state as { from?: { pathname?: string } } | null)?.from?.pathname ?? '' + return ( + <> +
{loc.pathname}
+
{from}
+ + ) +} + +describe('ProtectedRoute — unauthenticated redirect', () => { + beforeEach(() => { + useAuthStore.setState({ + user: null, + token: null, + isAuthenticated: false, + isLoading: false, + }) + }) + + it('redirects unauthenticated visits to /home → / and preserves origin in state.from', () => { + render( + + + +
home
+ + } + /> + } /> +
+
, + ) + + // The protected page should not render. + expect(screen.queryByTestId('home-content')).not.toBeInTheDocument() + + // We landed on / (the public landing route), not /landing. + expect(screen.getByTestId('probe-pathname')).toHaveTextContent('/') + expect(screen.getByTestId('probe-from')).toHaveTextContent('/home') + }) +}) diff --git a/frontend/src/pages/AssistantChatPage.tsx b/frontend/src/pages/AssistantChatPage.tsx index 4e6f6fca..bcd214de 100644 --- a/frontend/src/pages/AssistantChatPage.tsx +++ b/frontend/src/pages/AssistantChatPage.tsx @@ -2416,7 +2416,7 @@ export default function AssistantChatPage() { setShowConclude(false) if (activeSessionStatus === 'escalated') { toast.info('Session escalated. Heading back to your dashboard.') - navigate('/') + navigate('/home') } }} onConclude={handleConclude} diff --git a/frontend/src/pages/ContactPage.tsx b/frontend/src/pages/ContactPage.tsx index e7d68adf..dade0f1a 100644 --- a/frontend/src/pages/ContactPage.tsx +++ b/frontend/src/pages/ContactPage.tsx @@ -7,7 +7,7 @@ export default function ContactPage() {
- ← Back to home + ← Back to home

Contact ResolutionFlow

We respond to customer inquiries Monday through Friday during U.S. business hours, excluding federal holidays. Email is the fastest path to a response. diff --git a/frontend/src/pages/LandingPage.tsx b/frontend/src/pages/LandingPage.tsx index 3c352033..3a8c4610 100644 --- a/frontend/src/pages/LandingPage.tsx +++ b/frontend/src/pages/LandingPage.tsx @@ -164,46 +164,74 @@ export default function LandingPage() {

- {/* Problem — asymmetric: headline left, cards right */} -
+ {/* Problem — editorial list, no cards */} +
The Problem

Documentation is broken.
Everyone knows it.

-

Engineers don't want to write it. Managers hate chasing it. Clients never see it. The same issues get solved from scratch — every time.

-
-
- - - - +

Engineers don't want to write it. Managers hate chasing it. Clients never see it. The same issues get solved from scratch, every time.

+
    +
  1. + 01 +
    +

    15–25 min lost per ticket

    +

    More time documenting than resolving. After a complex issue, writing notes is the last thing anyone does.

    +
    +
  2. +
  3. + 02 +
    +

    Vague, useless notes

    +

    “Fixed Outlook” tells no one anything. Notes under pressure are always too vague to help next time.

    +
    +
  4. +
  5. + 03 +
    +

    Knowledge walks out the door

    +

    When a senior engineer leaves, years of tribal knowledge vanish overnight.

    +
    +
  6. +
  7. + 04 +
    +

    Context switching kills speed

    +

    Jumping between the issue, docs, PSA tickets, and knowledge bases fragments focus.

    +
    +
  8. +
- {/* Equation */} + {/* Equation — typographic moment */}
The Answer
-
- Resolution - + - Documentation - - Time - = - ResolutionFlow +
+
+ Resolution + + + Documentation + + Time +
+
+ = +
+
ResolutionFlow

- What if documentation was a byproduct of solving the issue — not a separate task? + What if documentation was a byproduct of solving the issue, not a separate task?

{/* How It Works — zigzag */} -
+
How It Works

Three steps. Zero note-writing.

@@ -268,54 +296,47 @@ export default function LandingPage() {
- {/* Features */} -
+ {/* Features — editorial spec list */} +
Features

Everything you need to troubleshoot faster.

-
- -
+
-

FlowPilot — Your AI Copilot

-

Like having a senior engineer on every call. Describe the issue, get expert troubleshooting guidance, and documentation writes itself — as a byproduct of solving the problem.

+

FlowPilot, your AI copilot

+

Like having a senior engineer on every call. Describe the issue, get expert troubleshooting guidance, and documentation writes itself, as a byproduct of solving the problem.

-
- } - title="Guided Flows" - description="Build step-by-step troubleshooting paths your team can follow. Great for onboarding and consistency." - /> - } - title="Zero Empty Tickets" - description="Every session generates timestamped notes, formatted for your PSA. No more empty ticket closures." - /> - } - title="Team Knowledge" - description="Solutions are saved and surfaced when the next engineer hits a similar issue." - /> - } - title="Session Analytics" - description="Track resolution times, identify recurring issues, and measure team performance." - /> - } - title="PSA Integration" - description="Connect to ConnectWise, Atera, and Syncro. Push session docs straight to tickets." - /> -
+
+
+
Guided Flows
+
Build step-by-step troubleshooting paths your team can follow. Great for onboarding and consistency.
+
+
+
Zero Empty Tickets
+
Every session generates timestamped notes, formatted for your PSA. No more empty ticket closures.
+
+
+
Team Knowledge
+
Solutions are saved and surfaced when the next engineer hits a similar issue.
+
+
+
Session Analytics
+
Track resolution times, identify recurring issues, and measure team performance.
+
+
+
PSA Integration
+
Connect to ConnectWise, Atera, and Syncro. Push session docs straight to tickets.
+
+
{/* Pricing */} -
+
Pricing

Simple pricing. No surprises.

@@ -364,7 +385,7 @@ export default function LandingPage() {
{/* FAQ */} -
+
FAQ

Common questions

@@ -399,15 +420,16 @@ export default function LandingPage() {
- {/* CTA */} -
+ {/* CTA — drenched */} +
-

Ready to stop writing ticket notes?

-

Get early access. Troubleshoot your next ticket with FlowPilot.

+
Stop writing ticket notes
+

Troubleshoot your next ticket with FlowPilot.

+

Get early access. Free to start, no credit card.

- Get started + Get started + See how it works
-

Free to start. No credit card required.

@@ -421,30 +443,6 @@ export default function LandingPage() { /* ---- Sub-components ---- */ -function ProblemCard({ icon, color, title, description }: { - icon: string; color: string; title: string; description: string -}) { - return ( -
-
{icon}
-

{title}

-

{description}

-
- ) -} - -function FeatureCard({ icon, title, description }: { - icon: React.ReactNode; title: string; description: string -}) { - return ( -
-
{icon}
-

{title}

-

{description}

-
- ) -} - function PricingCard({ name, target, amount, period, note, features, btnLabel, btnStyle, featured, plan }: { name: string; target: string; amount: string; period?: string; note: string features: string[]; btnLabel: string; btnStyle: 'outline' | 'filled'; featured?: boolean; plan: string diff --git a/frontend/src/pages/OAuthCallbackPage.tsx b/frontend/src/pages/OAuthCallbackPage.tsx index b32dd080..e606ac27 100644 --- a/frontend/src/pages/OAuthCallbackPage.tsx +++ b/frontend/src/pages/OAuthCallbackPage.tsx @@ -112,10 +112,10 @@ export function OAuthCallbackPage() { // Invitee path lands on the dashboard with the teammate-welcome // marker; new self-serve owners go to the welcome wizard; returning - // users to /. - let dest = '/' + // users to /home. + let dest = '/home' if (decoded?.accountInviteCode) { - dest = '/?welcome=teammate' + dest = '/home?welcome=teammate' } else if (result.is_new_user) { dest = '/welcome' } diff --git a/frontend/src/pages/PoliciesPage.tsx b/frontend/src/pages/PoliciesPage.tsx index eaabb472..1da097cd 100644 --- a/frontend/src/pages/PoliciesPage.tsx +++ b/frontend/src/pages/PoliciesPage.tsx @@ -7,7 +7,7 @@ export default function PoliciesPage() {
- ← Back to home + ← Back to home

Customer Policies

Last updated: May 7, 2026

Operator: ResolutionFlow, LLC (the “Company”), operator of ResolutionFlow (“Service”).

diff --git a/frontend/src/pages/PrivacyPage.tsx b/frontend/src/pages/PrivacyPage.tsx index 1478bbca..9a6fc5f3 100644 --- a/frontend/src/pages/PrivacyPage.tsx +++ b/frontend/src/pages/PrivacyPage.tsx @@ -7,7 +7,7 @@ export default function PrivacyPage() {
- ← Back to home + ← Back to home

Privacy Policy

Last updated: March 21, 2026

diff --git a/frontend/src/pages/PromotionsPage.tsx b/frontend/src/pages/PromotionsPage.tsx index 132ad10b..f495f1dd 100644 --- a/frontend/src/pages/PromotionsPage.tsx +++ b/frontend/src/pages/PromotionsPage.tsx @@ -7,7 +7,7 @@ export default function PromotionsPage() {
- ← Back to home + ← Back to home

Promotions

Last updated: May 7, 2026

diff --git a/frontend/src/pages/PublicTemplatesPage.tsx b/frontend/src/pages/PublicTemplatesPage.tsx index ec12e088..137aa4a0 100644 --- a/frontend/src/pages/PublicTemplatesPage.tsx +++ b/frontend/src/pages/PublicTemplatesPage.tsx @@ -168,7 +168,7 @@ export default function PublicTemplatesPage() { {/* Header */}
- + Resolution @@ -406,7 +406,7 @@ export default function PublicTemplatesPage() {
Powered by ResolutionFlow diff --git a/frontend/src/pages/SessionHistoryPage.tsx b/frontend/src/pages/SessionHistoryPage.tsx index 5823208d..1717eb3e 100644 --- a/frontend/src/pages/SessionHistoryPage.tsx +++ b/frontend/src/pages/SessionHistoryPage.tsx @@ -423,7 +423,7 @@ export default function SessionHistoryPage() { description="Start a FlowPilot or chat session to begin. All your sessions will appear here." action={ Start a Session diff --git a/frontend/src/pages/TermsPage.tsx b/frontend/src/pages/TermsPage.tsx index cd65338e..e7d31ce3 100644 --- a/frontend/src/pages/TermsPage.tsx +++ b/frontend/src/pages/TermsPage.tsx @@ -7,7 +7,7 @@ export default function TermsPage() {
- ← Back to home + ← Back to home

Terms of Service

Last updated: March 21, 2026

diff --git a/frontend/src/pages/VerifyEmailPage.tsx b/frontend/src/pages/VerifyEmailPage.tsx index da83ea83..0ac355a0 100644 --- a/frontend/src/pages/VerifyEmailPage.tsx +++ b/frontend/src/pages/VerifyEmailPage.tsx @@ -20,8 +20,7 @@ const SUCCESS_REDIRECT_MS = 1200 * "Already verified" state. No API call. * - Else fire `POST /auth/email/verify` exactly once (a `useRef` guard keeps * React 19 strict-mode double-invoke from double-firing the call). On - * success, refresh the auth store and bounce to `/?verified=1` so the - * dashboard surfaces a toast. + * success, refresh the auth store and bounce to `/home`. * - On error, show "Invalid or expired token" + a "Resend" CTA that calls * `POST /auth/email/send-verification`. */ @@ -70,10 +69,9 @@ export function VerifyEmailPage() { if (cancelled) return setStatus('success') toast.success('Email verified') - // Brief success state, then redirect with a query flag so the - // dashboard can re-surface confirmation if it wants to. + // Brief success state, then redirect to the dashboard. window.setTimeout(() => { - navigate('/?verified=1', { replace: true }) + navigate('/home', { replace: true }) }, SUCCESS_REDIRECT_MS) }) .catch((err) => { @@ -126,7 +124,7 @@ export function VerifyEmailPage() { Redirecting you to the dashboard…

} /> - dashboard
} /> + dashboard
} /> , @@ -130,7 +130,7 @@ describe('VerifyEmailPage', () => { } /> - dashboard
} /> + dashboard
} /> , @@ -142,7 +142,7 @@ describe('VerifyEmailPage', () => { } /> - dashboard
} /> + dashboard
} /> , diff --git a/frontend/src/pages/welcome/WelcomeRouter.tsx b/frontend/src/pages/welcome/WelcomeRouter.tsx index 024dbe97..46f3bd6b 100644 --- a/frontend/src/pages/welcome/WelcomeRouter.tsx +++ b/frontend/src/pages/welcome/WelcomeRouter.tsx @@ -6,8 +6,8 @@ import { PageLoader } from '@/components/common/PageLoader' * `/welcome` index — redirect to the next incomplete step (or `/` if done / * dismissed). Decision table: * - * onboarding_dismissed === true → / - * onboarding_step_completed >= 3 → / + * onboarding_dismissed === true → /home + * onboarding_step_completed >= 3 → /home * onboarding_step_completed === null/0 → /welcome/step-1 * onboarding_step_completed === 1 → /welcome/step-2 * onboarding_step_completed === 2 → /welcome/step-3 @@ -19,10 +19,10 @@ export function WelcomeRouter() { // the page loader rather than racing past the redirect. if (!user) return - if (user.onboarding_dismissed) return + if (user.onboarding_dismissed) return const completed = user.onboarding_step_completed ?? 0 - if (completed >= 3) return + if (completed >= 3) return if (completed === 2) return if (completed === 1) return return diff --git a/frontend/src/pages/welcome/WelcomeStep1.tsx b/frontend/src/pages/welcome/WelcomeStep1.tsx index 412217e9..60763b4d 100644 --- a/frontend/src/pages/welcome/WelcomeStep1.tsx +++ b/frontend/src/pages/welcome/WelcomeStep1.tsx @@ -85,7 +85,7 @@ export function WelcomeStep1() { try { await onboardingApi.dismissRest() await fetchUser() - navigate('/') + navigate('/home') } catch { setError('Could not save. Please try again.') setSubmitting(null) diff --git a/frontend/src/pages/welcome/WelcomeStep2.tsx b/frontend/src/pages/welcome/WelcomeStep2.tsx index c5f84805..bbc3acf8 100644 --- a/frontend/src/pages/welcome/WelcomeStep2.tsx +++ b/frontend/src/pages/welcome/WelcomeStep2.tsx @@ -90,7 +90,7 @@ export function WelcomeStep2() { try { await onboardingApi.dismissRest() await fetchUser() - navigate('/') + navigate('/home') } catch { setError('Could not save. Please try again.') setSubmitting(null) diff --git a/frontend/src/pages/welcome/WelcomeStep3.tsx b/frontend/src/pages/welcome/WelcomeStep3.tsx index d3659037..be2f6309 100644 --- a/frontend/src/pages/welcome/WelcomeStep3.tsx +++ b/frontend/src/pages/welcome/WelcomeStep3.tsx @@ -39,7 +39,7 @@ function makeEmptyRow(): InviteRow { * * 1. POST `/accounts/me/invites/bulk` with populated rows. * 2. PATCH `/users/me/onboarding-step` `{step: 3, action: "complete"}`. - * 3. Navigate to `/?welcome=true` and fire a "You're all set" toast. + * 3. Navigate to `/home` and fire a "You're all set" toast. * * Partial-failure UX: rows in `failed[]` keep their input and show an * inline error. The wizard does NOT auto-advance when there are failures — @@ -109,7 +109,7 @@ export function WelcomeStep3() { await onboardingApi.updateStep({ step: 3, action: 'complete' }) await fetchUser() toast.success("You're all set!") - navigate('/?welcome=true') + navigate('/home') } const handleSendInvites = async () => { @@ -177,7 +177,7 @@ export function WelcomeStep3() { await onboardingApi.updateStep({ step: 3, action: 'skip' }) await fetchUser() toast.success("You're all set!") - navigate('/?welcome=true') + navigate('/home') } catch { setError('Could not save. Please try again.') setSubmitting(null) @@ -191,7 +191,7 @@ export function WelcomeStep3() { try { await onboardingApi.dismissRest() await fetchUser() - navigate('/') + navigate('/home') } catch { setError('Could not save. Please try again.') setSubmitting(null) diff --git a/frontend/src/pages/welcome/__tests__/WelcomeRouter.test.tsx b/frontend/src/pages/welcome/__tests__/WelcomeRouter.test.tsx index 94c81f30..47a45626 100644 --- a/frontend/src/pages/welcome/__tests__/WelcomeRouter.test.tsx +++ b/frontend/src/pages/welcome/__tests__/WelcomeRouter.test.tsx @@ -39,7 +39,7 @@ function renderRouter() { step-1
} /> step-2
} /> step-3
} /> - dashboard
} /> + dashboard
} /> , ) @@ -100,7 +100,7 @@ describe('WelcomeRouter', () => { }) }) - it('redirects to / when onboarding_step_completed >= 3', async () => { + it('redirects to /home when onboarding_step_completed >= 3', async () => { useAuthStore.setState({ user: makeUser({ onboarding_step_completed: 3 }), }) @@ -110,7 +110,7 @@ describe('WelcomeRouter', () => { }) }) - it('redirects to / when onboarding_dismissed is true', async () => { + it('redirects to /home when onboarding_dismissed is true', async () => { useAuthStore.setState({ user: makeUser({ onboarding_step_completed: 1, diff --git a/frontend/src/pages/welcome/__tests__/WelcomeStep1.test.tsx b/frontend/src/pages/welcome/__tests__/WelcomeStep1.test.tsx index 93483add..a067d171 100644 --- a/frontend/src/pages/welcome/__tests__/WelcomeStep1.test.tsx +++ b/frontend/src/pages/welcome/__tests__/WelcomeStep1.test.tsx @@ -65,7 +65,7 @@ function renderPage() { } /> step-2} /> - dashboard} /> + dashboard} /> , ) @@ -148,7 +148,7 @@ describe('WelcomeStep1', () => { }) }) - it('Skip-the-rest dismisses and navigates to /', async () => { + it('Skip-the-rest dismisses and navigates to /home', async () => { const user = userEvent.setup() renderPage() diff --git a/frontend/src/pages/welcome/__tests__/WelcomeStep2.test.tsx b/frontend/src/pages/welcome/__tests__/WelcomeStep2.test.tsx index 57b2a9bd..3cb5c9ef 100644 --- a/frontend/src/pages/welcome/__tests__/WelcomeStep2.test.tsx +++ b/frontend/src/pages/welcome/__tests__/WelcomeStep2.test.tsx @@ -66,7 +66,7 @@ function renderPage() { } /> step-3} /> integrations} /> - dashboard} /> + dashboard} /> , ) @@ -158,7 +158,7 @@ describe('WelcomeStep2', () => { expect(screen.queryByTestId('welcome-step-2-connect-now')).not.toBeInTheDocument() }) - it('Skip-the-rest dismisses and navigates to /', async () => { + it('Skip-the-rest dismisses and navigates to /home', async () => { const user = userEvent.setup() renderPage() diff --git a/frontend/src/pages/welcome/__tests__/WelcomeStep3.test.tsx b/frontend/src/pages/welcome/__tests__/WelcomeStep3.test.tsx index c90be2dd..b6d0788e 100644 --- a/frontend/src/pages/welcome/__tests__/WelcomeStep3.test.tsx +++ b/frontend/src/pages/welcome/__tests__/WelcomeStep3.test.tsx @@ -88,7 +88,7 @@ function renderPage() { } /> - dashboard} /> + dashboard} /> , ) diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index c57664f4..78c6336d 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -7,6 +7,7 @@ import { RouteError } from '@/components/common/RouteError' import { ErrorBoundary } from '@/components/common/ErrorBoundary' import { PageLoader } from '@/components/common/PageLoader' import { lazyWithRetry } from '@/lib/lazyWithRetry' +import { useAuthStore } from '@/store/authStore' import { L1RouteGuard } from '@/components/layout/L1RouteGuard' const sentryCreateBrowserRouter = Sentry.wrapCreateBrowserRouterV7(createBrowserRouter) @@ -125,10 +126,27 @@ function page(Component: React.LazyExoticComponent) { ) } +/** + * Public `/` wrapper — sends authenticated users to /home before LandingPage + * mounts, so they never see marketing-frame flicker. + */ +// eslint-disable-next-line react-refresh/only-export-components -- router.tsx exports a router instance, not a component +function PublicLanding() { + const isAuthed = useAuthStore((s) => s.isAuthenticated) + if (isAuthed) return + return page(LandingPage) +} + export const router = sentryCreateBrowserRouter([ + { + path: '/', + element: , + errorElement: , + }, + // Stale-bookmark redirect — keep one release, delete in a follow-up. { path: '/landing', - element: page(LandingPage), + element: , errorElement: , }, { @@ -236,7 +254,6 @@ export const router = sentryCreateBrowserRouter([ errorElement: , }, { - path: '/', element: ( @@ -244,61 +261,61 @@ export const router = sentryCreateBrowserRouter([ ), errorElement: , children: [ - { index: true, element: page(QuickStartPage) }, - { path: 'trees', element: page(TreeLibraryPage) }, - { path: 'my-trees', element: page(MyTreesPage) }, - { path: 'trees/new', element: page(TreeEditorPage) }, - { path: 'trees/:id/edit', element: page(TreeEditorPage) }, - { path: 'flows/new', element: page(ProceduralEditorPage) }, - { path: 'flows/:id/edit', element: page(ProceduralEditorPage) }, - { path: 'flows/:id/navigate', element: page(ProceduralNavigationPage) }, - { path: 'flows/:id/maintenance', element: page(MaintenanceFlowDetailPage) }, - { path: 'flows/:id/batches/:batchId', element: page(BatchStatusPage) }, - { path: 'trees/:id/navigate', element: page(TreeNavigationPage) }, - { path: 'sessions', element: page(SessionHistoryPage) }, - { path: 'sessions/:id', element: page(SessionDetailPage) }, - { path: 'tickets', element: page(TicketsPage) }, - { path: 'shares', element: page(MySharesPage) }, - { path: 'analytics', element: page(TeamAnalyticsPage) }, - { path: 'analytics/me', element: page(MyAnalyticsPage) }, - { path: 'feedback', element: page(FeedbackPage) }, - { path: 'step-library', element: page(StepLibraryPage) }, - { path: 'scripts', element: page(ScriptLibraryPage) }, - { path: 'scripts/manage', element: page(ScriptManagePage) }, - { path: 'script-builder', element: page(ScriptBuilderPage) }, - { path: 'network-diagrams', element: page(NetworkDiagramsPage) }, - { path: 'network-diagrams/new', element: page(DiagramEditorPage) }, - { path: 'network-diagrams/:id', element: page(DiagramEditorPage) }, - { path: 'kb-accelerator', element: page(KBAcceleratorPage) }, + { path: '/home', element: page(QuickStartPage) }, + { path: '/trees', element: page(TreeLibraryPage) }, + { path: '/my-trees', element: page(MyTreesPage) }, + { path: '/trees/new', element: page(TreeEditorPage) }, + { path: '/trees/:id/edit', element: page(TreeEditorPage) }, + { path: '/flows/new', element: page(ProceduralEditorPage) }, + { path: '/flows/:id/edit', element: page(ProceduralEditorPage) }, + { path: '/flows/:id/navigate', element: page(ProceduralNavigationPage) }, + { path: '/flows/:id/maintenance', element: page(MaintenanceFlowDetailPage) }, + { path: '/flows/:id/batches/:batchId', element: page(BatchStatusPage) }, + { path: '/trees/:id/navigate', element: page(TreeNavigationPage) }, + { path: '/sessions', element: page(SessionHistoryPage) }, + { path: '/sessions/:id', element: page(SessionDetailPage) }, + { path: '/tickets', element: page(TicketsPage) }, + { path: '/shares', element: page(MySharesPage) }, + { path: '/analytics', element: page(TeamAnalyticsPage) }, + { path: '/analytics/me', element: page(MyAnalyticsPage) }, + { path: '/feedback', element: page(FeedbackPage) }, + { path: '/step-library', element: page(StepLibraryPage) }, + { path: '/scripts', element: page(ScriptLibraryPage) }, + { path: '/scripts/manage', element: page(ScriptManagePage) }, + { path: '/script-builder', element: page(ScriptBuilderPage) }, + { path: '/network-diagrams', element: page(NetworkDiagramsPage) }, + { path: '/network-diagrams/new', element: page(DiagramEditorPage) }, + { path: '/network-diagrams/:id', element: page(DiagramEditorPage) }, + { path: '/kb-accelerator', element: page(KBAcceleratorPage) }, // Phase 1 — FlowPilot migration. The unified chat-primary surface lives at // /pilot; /assistant permanently redirects. FlowPilotSessionPage (old // guided surface) is no longer mounted. - { path: 'pilot', element: page(AssistantChatPage) }, - { path: 'pilot/:sessionId', element: page(AssistantChatPage) }, - { path: 'assistant', element: }, - { path: 'assistant/:sessionId', element: }, - { path: 'flow-assist', element: page(FlowAssistPage) }, - { path: 'escalations', element: page(EscalationQueuePage) }, - { path: 'queue', element: page(SessionQueuePage) }, - { path: 'review-queue', element: page(ReviewQueuePage) }, - { path: 'analytics/flowpilot', element: page(FlowPilotAnalyticsPage) }, - { path: 'dev/branching', element: page(DevBranchingPage) }, - { path: 'guides', element: page(GuidesHubPage) }, - { path: 'guides/:slug', element: page(GuideDetailPage) }, + { path: '/pilot', element: page(AssistantChatPage) }, + { path: '/pilot/:sessionId', element: page(AssistantChatPage) }, + { path: '/assistant', element: }, + { path: '/assistant/:sessionId', element: }, + { path: '/flow-assist', element: page(FlowAssistPage) }, + { path: '/escalations', element: page(EscalationQueuePage) }, + { path: '/queue', element: page(SessionQueuePage) }, + { path: '/review-queue', element: page(ReviewQueuePage) }, + { path: '/analytics/flowpilot', element: page(FlowPilotAnalyticsPage) }, + { path: '/dev/branching', element: page(DevBranchingPage) }, + { path: '/guides', element: page(GuidesHubPage) }, + { path: '/guides/:slug', element: page(GuideDetailPage) }, // Welcome wizard (Phase 2). Mounted inside AppLayout so the email- // verification banner persists above each step. - { path: 'welcome', element: page(WelcomeRouter) }, - { path: 'welcome/step-1', element: page(WelcomeStep1) }, - { path: 'welcome/step-2', element: page(WelcomeStep2) }, - { path: 'welcome/step-3', element: page(WelcomeStep3) }, + { path: '/welcome', element: page(WelcomeRouter) }, + { path: '/welcome/step-1', element: page(WelcomeStep1) }, + { path: '/welcome/step-2', element: page(WelcomeStep2) }, + { path: '/welcome/step-3', element: page(WelcomeStep3) }, // L1 workspace routes — gated by canUseL1Surface - { path: 'l1', element: {page(L1Dashboard)} }, - { path: 'l1/walk/:sessionId', element: {page(L1WalkPage)} }, - { path: 'l1/drafts', element: {page(L1DraftsPage)} }, - { path: 'l1/tickets', element: {page(L1TicketsPage)} }, + { path: '/l1', element: {page(L1Dashboard)} }, + { path: '/l1/walk/:sessionId', element: {page(L1WalkPage)} }, + { path: '/l1/drafts', element: {page(L1DraftsPage)} }, + { path: '/l1/tickets', element: {page(L1TicketsPage)} }, // Admin routes { - path: 'admin', + path: '/admin', element: ( }> @@ -327,7 +344,7 @@ export const router = sentryCreateBrowserRouter([ }, // Account routes { - path: 'account', + path: '/account', element: ( }> diff --git a/frontend/src/styles/landing.css b/frontend/src/styles/landing.css index 313b4d95..edc130b3 100644 --- a/frontend/src/styles/landing.css +++ b/frontend/src/styles/landing.css @@ -7,7 +7,7 @@ /* ---- LANDING COLOR PALETTE ---- */ .landing-page { --lp-bg: #14161d; - --lp-bg-alt: #181a22; + --lp-bg-alt: #1c1f2a; --lp-card: #1e2028; --lp-elevated: #262830; --lp-border: #2a2e3a; @@ -23,14 +23,24 @@ --lp-success: #34d399; --lp-danger: #f87171; --lp-warning: #fbbf24; + + /* Typeset: a single hyperlegibility-engineered family across the page. + Atkinson Hyperlegible Next (Braille Institute, 2024) — designed for + low-vision readers. Picked here because MSP engineers read this page + mid-ticket, under pressure, often glancing. Hyperlegibility IS the + brand value, not decoration. Mono sibling pairs naturally for + timestamps and ticket IDs. */ + --lp-font-display: 'Atkinson Hyperlegible Next', system-ui, sans-serif; + --lp-font-body: 'Atkinson Hyperlegible Next', system-ui, sans-serif; + --lp-font-mono: 'Atkinson Hyperlegible Mono', ui-monospace, monospace; } /* ---- BASE ---- */ .landing-page { - font-family: 'IBM Plex Sans', sans-serif; + font-family: var(--lp-font-body); background: var(--lp-bg); color: var(--lp-text-body); - line-height: 1.6; + line-height: 1.55; overflow-x: hidden; -webkit-font-smoothing: antialiased; min-height: 100vh; @@ -110,7 +120,7 @@ } .landing-nav-wordmark { - font-family: 'Bricolage Grotesque', sans-serif; + font-family: var(--lp-font-display); font-size: 1.25rem; font-weight: 700; color: var(--lp-text-heading); @@ -189,6 +199,14 @@ padding: 5rem 2rem; } +.landing-section-tight { + padding: 4rem 2rem 5rem; +} + +.landing-section-generous { + padding: 7rem 2rem 6rem; +} + .landing-section-alt { background: var(--lp-bg-alt); } @@ -199,24 +217,36 @@ } .landing-section-label { - font-family: 'IBM Plex Sans', sans-serif; - font-size: 0.7rem; - font-weight: 600; - color: var(--lp-accent-text); - letter-spacing: 0.14em; + display: flex; + align-items: center; + gap: 0.9rem; + font-family: var(--lp-font-display); + font-size: 0.9rem; + font-weight: 700; + color: var(--lp-accent); + letter-spacing: 0.08em; text-transform: uppercase; - margin-bottom: 0.75rem; + margin-bottom: 1.25rem; +} + +.landing-section-label::before { + content: ''; + width: 40px; + height: 2px; + background: var(--lp-accent); + flex-shrink: 0; } .landing-section-title { - font-family: 'Bricolage Grotesque', sans-serif; - font-size: clamp(2rem, 4vw, 2.75rem); + font-family: var(--lp-font-display); + font-size: clamp(2rem, 4.5vw, 3.25rem); font-weight: 800; color: var(--lp-text-heading); - letter-spacing: -0.03em; - line-height: 1.15; + letter-spacing: -0.035em; + line-height: 1.05; margin-top: 0; margin-bottom: 1rem; + max-width: 22ch; } .landing-section-desc { @@ -251,8 +281,8 @@ position: absolute; inset: 0; background: - linear-gradient(to right, #14161d 22%, rgba(20, 22, 29, 0.80) 38%, rgba(20, 22, 29, 0.20) 58%, transparent 78%), - linear-gradient(to top, #14161d 0%, rgba(20, 22, 29, 0) 16%); + linear-gradient(to right, #14161d 0%, #14161d 38%, rgba(20, 22, 29, 0.92) 52%, rgba(20, 22, 29, 0.35) 72%, transparent 92%), + linear-gradient(to top, #14161d 0%, rgba(20, 22, 29, 0.4) 24%, rgba(20, 22, 29, 0) 48%); z-index: 1; } @@ -300,7 +330,7 @@ } .landing-hero h1 { - font-family: 'Bricolage Grotesque', sans-serif; + font-family: var(--lp-font-display); font-size: clamp(2.5rem, 5vw, 3.75rem); font-weight: 800; line-height: 1.08; @@ -447,7 +477,7 @@ font-size: 0.65rem; font-weight: 700; color: var(--lp-text-secondary); - font-family: 'IBM Plex Sans', sans-serif; + font-family: var(--lp-font-body); } .tc-status { @@ -531,7 +561,7 @@ .tc-time { font-size: 0.55rem; color: var(--lp-text-dim); - font-family: 'JetBrains Mono', monospace; + font-family: var(--lp-font-mono); flex-shrink: 0; min-width: 28px; } @@ -601,7 +631,7 @@ align-items: flex-start; gap: 8px; font-size: 0.65rem; - font-family: 'IBM Plex Sans', sans-serif; + font-family: var(--lp-font-body); padding: 6px 10px; border-radius: 6px; } @@ -641,7 +671,7 @@ } .landing-mock-chat-line code { - font-family: 'JetBrains Mono', monospace; + font-family: var(--lp-font-mono); font-size: 0.6rem; padding: 1px 5px; border-radius: 3px; @@ -708,142 +738,172 @@ color: var(--lp-success); } -/* ---- PROBLEM SECTION (asymmetric) ---- */ +/* ---- PROBLEM SECTION (editorial, no cards) ---- */ .landing-problem-layout { display: grid; - grid-template-columns: 2fr 3fr; - gap: 3rem; + grid-template-columns: 5fr 7fr; + gap: clamp(2.5rem, 6vw, 6rem); align-items: start; } +.landing-problem-headline { + position: sticky; + top: 6rem; +} + .landing-problem-headline h2 { - font-family: 'Bricolage Grotesque', sans-serif; - font-size: clamp(1.75rem, 3.5vw, 2.5rem); + font-family: var(--lp-font-display); + font-size: clamp(2rem, 4vw, 3rem); font-weight: 800; color: var(--lp-text-heading); - letter-spacing: -0.03em; - line-height: 1.15; - margin: 0 0 1rem; + letter-spacing: -0.035em; + line-height: 1.05; + margin: 0 0 1.25rem; } .landing-problem-headline > p { - font-size: 1rem; + font-size: 1.05rem; color: var(--lp-text-secondary); - line-height: 1.7; + line-height: 1.65; + max-width: 36ch; } -.landing-problem-grid { +.landing-problem-list { + list-style: none; + margin: 0; + padding: 0; +} + +.landing-problem-item { display: grid; - grid-template-columns: 1fr 1fr; - gap: 1rem; + grid-template-columns: 4.5rem 1fr; + column-gap: 1.25rem; + padding: 2rem 0; + border-top: 1px solid var(--lp-border); } -.landing-problem-card { - padding: 1.25rem; - border-radius: 8px; - background: var(--lp-card); - border: 1px solid var(--lp-border); - transition: border-color 0.3s; +.landing-problem-item:last-child { + border-bottom: 1px solid var(--lp-border); } -.landing-problem-card:hover { - border-color: var(--lp-border-hover); +.landing-problem-num { + font-family: var(--lp-font-display); + font-size: 0.85rem; + font-weight: 700; + color: var(--lp-accent); + letter-spacing: 0.08em; + padding-top: 0.65rem; } -.landing-problem-icon { - width: 36px; - height: 36px; - border-radius: 8px; - display: flex; - align-items: center; - justify-content: center; - margin-bottom: 0.75rem; - font-size: 1.1rem; -} - -.landing-problem-icon.red { - background: rgba(248, 113, 113, 0.1); - color: var(--lp-danger); -} - -.landing-problem-icon.amber { - background: rgba(251, 191, 36, 0.1); - color: var(--lp-warning); -} - -.landing-problem-icon.slate { - background: rgba(145, 152, 168, 0.1); - color: var(--lp-text-secondary); -} - -.landing-problem-icon.violet { - background: rgba(139, 92, 246, 0.1); - color: #a78bfa; -} - -.landing-problem-card h3 { - font-family: 'Bricolage Grotesque', sans-serif; - font-size: 0.95rem; +.landing-problem-body h3 { + font-family: var(--lp-font-display); + font-size: clamp(1.35rem, 2.5vw, 1.85rem); font-weight: 700; color: var(--lp-text-heading); - margin-bottom: 0.4rem; - letter-spacing: -0.01em; + letter-spacing: -0.025em; + line-height: 1.15; + margin: 0 0 0.6rem; } -.landing-problem-card p { - font-size: 0.8rem; +.landing-problem-body p { + font-size: 0.95rem; color: var(--lp-text-secondary); - line-height: 1.6; + line-height: 1.65; margin: 0; + max-width: 52ch; } -/* ---- EQUATION ---- */ +/* ---- EQUATION (hero-scale typographic moment) ---- */ .landing-equation-section { - text-align: center; - padding: 4rem 2rem; + text-align: left; + padding: 9rem 2rem 8rem; + position: relative; + overflow: hidden; +} + +.landing-equation-section::before { + content: ''; + position: absolute; + inset: 0; + background: + radial-gradient(60% 80% at 78% 30%, rgba(96, 165, 250, 0.10), transparent 70%); + pointer-events: none; + z-index: 0; } .landing-equation-inner { - max-width: 900px; + max-width: 1200px; margin: 0 auto; + position: relative; + z-index: 1; } .landing-brand-equation { + font-family: var(--lp-font-display); + font-weight: 800; + letter-spacing: -0.04em; + line-height: 0.95; + margin: 1.5rem 0 2rem; +} + +.landing-eq-lhs { display: flex; - align-items: center; - justify-content: center; - gap: 0.75rem; + align-items: baseline; flex-wrap: wrap; - font-family: 'Bricolage Grotesque', sans-serif; - font-size: clamp(1.25rem, 3vw, 2.25rem); - font-weight: 700; - letter-spacing: -0.02em; - margin-bottom: 1.5rem; + gap: 0.5em; + font-size: clamp(1.5rem, 4vw, 3rem); + color: var(--lp-text-secondary); + margin-bottom: 0.4rem; } .landing-eq-item { - padding: 0.4rem 1rem; - border-radius: 8px; - background: var(--lp-card); - border: 1px solid var(--lp-border); color: var(--lp-text-heading); } .landing-eq-operator { color: var(--lp-accent); - font-size: 1.5em; + font-weight: 600; +} + +.landing-eq-equals { + font-size: clamp(2rem, 5vw, 4rem); + color: var(--lp-accent); + line-height: 1; + margin: 0.1em 0 0.05em; +} + +.landing-eq-operator-equals { + display: inline-block; } .landing-eq-result { - color: var(--lp-accent-text); + font-size: clamp(2.25rem, 11vw, 9.5rem); + font-weight: 800; + color: var(--lp-text-heading); + letter-spacing: -0.055em; + line-height: 0.92; + display: inline-block; + position: relative; + padding-bottom: 0.05em; + max-width: 100%; +} + +.landing-eq-result::after { + content: ''; + position: absolute; + left: 0; + right: 14%; + bottom: 0.02em; + height: 0.12em; + background: linear-gradient(to right, var(--lp-accent), rgba(96, 165, 250, 0)); } .landing-equation-desc { - font-size: 1.05rem; + font-size: 1.1rem; color: var(--lp-text-secondary); - max-width: 480px; - margin: 0 auto; - line-height: 1.7; + max-width: 520px; + margin: 2rem 0 0; + line-height: 1.6; } /* ---- HOW IT WORKS (zigzag) ---- */ @@ -872,7 +932,7 @@ } .landing-zigzag-number { - font-family: 'Bricolage Grotesque', sans-serif; + font-family: var(--lp-font-display); font-size: 0.75rem; font-weight: 700; color: var(--lp-accent); @@ -881,7 +941,7 @@ } .landing-zigzag-text h3 { - font-family: 'Bricolage Grotesque', sans-serif; + font-family: var(--lp-font-display); font-size: 1.5rem; font-weight: 700; color: var(--lp-text-heading); @@ -907,100 +967,79 @@ background: var(--lp-bg-alt); } -/* ---- FEATURES ---- */ +/* ---- FEATURES (editorial spec list) ---- */ .landing-feature-highlight { - display: flex; - align-items: flex-start; - gap: 1.5rem; - padding: 2rem; - border-radius: 8px; - background: var(--lp-accent-soft); - border: 1px solid rgba(96, 165, 250, 0.15); + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + gap: 1.75rem; + padding: 2.25rem 2rem; + border-radius: 0; + background: transparent; + border: none; + border-top: 1px solid var(--lp-accent); + border-bottom: 1px solid var(--lp-border); margin-top: 2.5rem; - margin-bottom: 1.5rem; + margin-bottom: 0; } -.landing-feature-highlight-icon { - width: 52px; - height: 52px; - border-radius: 8px; - background: rgba(96, 165, 250, 0.15); - display: flex; - align-items: center; - justify-content: center; +.landing-feature-highlight-marker { + font-family: var(--lp-font-display); + font-size: 2.25rem; + font-weight: 800; color: var(--lp-accent); - flex-shrink: 0; + letter-spacing: -0.04em; + line-height: 1; + padding-right: 1.75rem; + border-right: 1px solid var(--lp-border); } .landing-feature-highlight-content h3 { - font-family: 'Bricolage Grotesque', sans-serif; - font-size: 1.35rem; + font-family: var(--lp-font-display); + font-size: clamp(1.35rem, 2.4vw, 1.65rem); font-weight: 700; color: var(--lp-text-heading); - letter-spacing: -0.01em; - margin-bottom: 0.5rem; + letter-spacing: -0.02em; + margin: 0 0 0.4rem; } .landing-feature-highlight-content p { - font-size: 0.95rem; + font-size: 1rem; color: var(--lp-text-secondary); line-height: 1.65; margin: 0; + max-width: 68ch; } -.landing-features-grid { +.landing-feature-spec { + margin: 0; + padding: 0; +} + +.landing-feature-row { display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 1rem; + grid-template-columns: minmax(180px, 24%) 1fr; + column-gap: 3rem; + align-items: baseline; + padding: 1.75rem 0; + border-bottom: 1px solid var(--lp-border); } -.landing-feature-card { - padding: 1.5rem; - border-radius: 8px; - background: var(--lp-card); - border: 1px solid var(--lp-border); - transition: border-color 0.3s; -} - -.landing-feature-card:hover { - border-color: var(--lp-border-hover); -} - -.landing-feature-icon { - width: 36px; - height: 36px; - border-radius: 8px; - background: var(--lp-accent-soft); - display: flex; - align-items: center; - justify-content: center; - margin-bottom: 1rem; - color: var(--lp-accent-text); -} - -.landing-feature-card h3 { - font-family: 'Bricolage Grotesque', sans-serif; - font-size: 1rem; +.landing-feature-row dt { + font-family: var(--lp-font-display); + font-size: clamp(1.15rem, 2vw, 1.5rem); font-weight: 700; color: var(--lp-text-heading); - margin-bottom: 0.4rem; - letter-spacing: -0.01em; + letter-spacing: -0.02em; + line-height: 1.15; } -.landing-feature-card p { - font-size: 0.85rem; +.landing-feature-row dd { + font-size: 1rem; color: var(--lp-text-secondary); - line-height: 1.6; + line-height: 1.65; margin: 0; -} - -/* 5 cards: 3 + 2 bottom row centered */ -.landing-features-grid .landing-feature-card:nth-child(4) { - grid-column: 1 / 2; -} - -.landing-features-grid .landing-feature-card:nth-child(5) { - grid-column: 2 / 3; + max-width: 62ch; } /* ---- PRICING ---- */ @@ -1048,7 +1087,7 @@ } .landing-pricing-plan-name { - font-family: 'Bricolage Grotesque', sans-serif; + font-family: var(--lp-font-display); font-size: 1.1rem; font-weight: 700; color: var(--lp-text-heading); @@ -1069,7 +1108,7 @@ } .landing-pricing-price .amount { - font-family: 'Bricolage Grotesque', sans-serif; + font-family: var(--lp-font-display); font-size: 2.5rem; font-weight: 800; color: var(--lp-text-heading); @@ -1199,7 +1238,7 @@ border: none; cursor: pointer; text-align: left; - font-family: 'IBM Plex Sans', sans-serif; + font-family: var(--lp-font-body); font-size: 1rem; font-weight: 600; color: var(--lp-text-heading); @@ -1255,7 +1294,7 @@ } .landing-founder-section blockquote { - font-family: 'Bricolage Grotesque', sans-serif; + font-family: var(--lp-font-display); font-size: 1.25rem; font-weight: 500; line-height: 1.6; @@ -1271,32 +1310,125 @@ padding-left: 1.25rem; } -/* ---- CTA ---- */ +/* ---- CTA (drenched) ---- */ .landing-cta-section { - text-align: center; - padding: 5rem 2rem; + padding: 6rem 2rem; background: var(--lp-bg-alt); } +.landing-cta-section.landing-cta-drench { + background: var(--lp-accent); + color: #0a1430; + padding: 7rem 2rem; + position: relative; + overflow: hidden; +} + +.landing-cta-section.landing-cta-drench::before { + content: ''; + position: absolute; + inset: 0; + background: + radial-gradient(80% 60% at 100% 0%, rgba(255, 255, 255, 0.18), transparent 60%), + radial-gradient(60% 80% at 0% 100%, rgba(13, 15, 21, 0.18), transparent 60%); + pointer-events: none; +} + .landing-cta-inner { - max-width: 520px; + max-width: 1100px; margin: 0 auto; + position: relative; + z-index: 1; +} + +.landing-cta-eyebrow { + display: flex; + align-items: center; + gap: 0.9rem; + font-family: var(--lp-font-display); + font-size: 0.9rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #0a1430; + margin-bottom: 1.5rem; +} + +.landing-cta-eyebrow::before { + content: ''; + width: 40px; + height: 2px; + background: #0a1430; + flex-shrink: 0; + opacity: 0.55; } .landing-cta-section h2 { - font-family: 'Bricolage Grotesque', sans-serif; - font-size: clamp(1.75rem, 3.5vw, 2.5rem); + font-family: var(--lp-font-display); + font-size: clamp(2.25rem, 5vw, 4rem); font-weight: 800; - color: var(--lp-text-heading); - letter-spacing: -0.03em; - margin-bottom: 0.75rem; + color: #0a1430; + letter-spacing: -0.04em; + line-height: 1.02; + margin: 0 0 1.25rem; + max-width: 22ch; } .landing-cta-section h2 + p { + font-size: 1.15rem; + color: #0a1430; + opacity: 0.78; + margin-bottom: 2.5rem; + line-height: 1.55; + max-width: 44ch; +} + +.landing-cta-actions { + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; +} + +.landing-btn-cta-invert { + display: inline-flex; + align-items: center; + justify-content: center; font-size: 1rem; - color: var(--lp-text-secondary); - margin-bottom: 2rem; - line-height: 1.7; + font-weight: 600; + color: var(--lp-accent); + text-decoration: none; + padding: 0.95rem 2rem; + border-radius: 8px; + background: #ffffff; + transition: transform 0.25s ease-out, box-shadow 0.25s ease-out; + letter-spacing: -0.01em; + box-shadow: 0 1px 0 rgba(13, 15, 21, 0.08); +} + +.landing-btn-cta-invert:hover { + transform: translateY(-2px); + box-shadow: 0 12px 28px rgba(10, 20, 48, 0.25); +} + +.landing-btn-cta-ghost { + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 1rem; + font-weight: 600; + color: #0a1430; + text-decoration: none; + padding: 0.95rem 1.5rem; + border-radius: 8px; + border: 1px solid rgba(10, 20, 48, 0.35); + background: transparent; + transition: background 0.25s, border-color 0.25s; +} + +.landing-btn-cta-ghost:hover { + background: rgba(10, 20, 48, 0.06); + border-color: rgba(10, 20, 48, 0.55); } .landing-cta-email-form { @@ -1317,7 +1449,7 @@ border: 1px solid var(--lp-border); background: var(--lp-card); color: var(--lp-text-heading); - font-family: 'IBM Plex Sans', sans-serif; + font-family: var(--lp-font-body); font-size: 0.9rem; outline: none; transition: border-color 0.3s, box-shadow 0.3s; @@ -1605,8 +1737,18 @@ gap: 2rem; } - .landing-problem-grid { - grid-template-columns: 1fr; + .landing-problem-headline { + position: static; + } + + .landing-problem-item { + grid-template-columns: 3rem 1fr; + column-gap: 1rem; + padding: 1.5rem 0; + } + + .landing-problem-num { + padding-top: 0.5rem; } .landing-zigzag { @@ -1628,19 +1770,22 @@ order: 2; } - .landing-features-grid { - grid-template-columns: 1fr; - } - - .landing-features-grid .landing-feature-card:nth-child(4), - .landing-features-grid .landing-feature-card:nth-child(5) { - grid-column: auto; - } - .landing-feature-highlight { - flex-direction: column; + grid-template-columns: 1fr; gap: 1rem; - padding: 1.5rem; + padding: 1.5rem 0; + } + + .landing-feature-highlight-marker { + padding-right: 0; + border-right: none; + font-size: 1.75rem; + } + + .landing-feature-row { + grid-template-columns: 1fr; + row-gap: 0.6rem; + padding: 1.5rem 0; } .landing-pricing-grid { @@ -1669,16 +1814,8 @@ } .landing-equation-section { - padding: 3rem 1.25rem; - } - - .landing-brand-equation { - font-size: 1.1rem; - gap: 0.4rem; - } - - .landing-eq-item { - padding: 0.3rem 0.6rem; + padding: 5rem 1.25rem 4.5rem; + text-align: left; } .landing-founder-section {