10 Commits

Author SHA1 Message Date
5f775e360e feat(landing): redesign hero + editorial layout with Atkinson Hyperlegible
All checks were successful
Mirror to GitHub / mirror (push) Successful in 6s
Recover and commit the landing-page redesign that had been sitting
uncommitted in the working tree: refreshed dark palette (adjusted
--lp-bg-alt, electric-blue accent), Atkinson Hyperlegible Next display
+ body type, and editorial hero/section layout in LandingPage.tsx, with
the matching font preload in index.html.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 14:48:18 -04:00
6937bcaabd test(l1): E2E Playwright suite + seed L1 + coverage engineer test users
l1-workspace.spec.ts covers:
- L1 user lands on /l1, intakes a problem, takes notes (autosave), resolves
- L1 cannot access /pilot, /trees/new, /escalations (route guards)
- Engineer with can_cover_l1 sees the L1 Workspace nav + coverage banner
- escalate-without-walk path via direct API call returns escalated session

Seed script adds l1@resolutionflow.example.com (l1_tech) and
engineer-coverage@resolutionflow.example.com (engineer + can_cover_l1).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 14:42:31 -04:00
1acc780359 feat(l1): drafts + tickets pages + coverage banner + seat counter widget
L1DraftsPage is a Phase 1 placeholder (AI drafts arrive in Phase 2).
L1TicketsPage replaces the stub with a status-filterable internal-tickets
queue. L1CoverageBanner renders inside L1RouteGuard so every /l1/* page
shows it for engineer-coverers (hidden for native L1). SeatCounterWidget
+ /api/seats.ts surface engineer + L1 seat usage from the /accounts/me/
seats endpoint (T9).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 14:28:27 -04:00
d3fd9143d7 feat(l1): adhoc walker variant with debounced notes autosave
The session variant that Phase 1 L1 users actually hit (intake creates
adhoc sessions when no flow_id is provided). Single-pane note-taking
surface with 300ms-debounced autosave to walk_notes. Shares header
shape + Resolve/Escalate modals with the tree variant. Splits the
notes textarea by paragraph and persists each as a structured
AdhocNote entry. Stops saving once status leaves 'active'.

L1WalkPage now dispatches both variants.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 14:22:15 -04:00
c0bddc289e feat(l1): L1WalkPage tree variant with Resolve/Escalate modals
Replaces the T20 stub. WalkPage dispatches by session_kind:
- 'flow' / 'proposal' → L1WalkTreeVariant (this commit)
- 'adhoc' → placeholder until T23

L1WalkTreeVariant: sticky header with back link + AI-built badge +
persistent Escalate/Resolve buttons; two-pane body (current step
yes/no card on left, walked-path transcript on right). ResolveModal
and EscalateModal extracted to shared WalkModals.tsx (T23 reuses).

Phase 1 caveat: this surface isn't reached by user-driven intake
(which creates adhoc sessions only). It's exercised via direct URL
or integration tests until Phase 2 wires match_or_build.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 14:17:02 -04:00
4e9610c252 feat(l1): real L1 dashboard with empty-state + resume widget
Replaces the T20 stub. L1 dashboard renders greeting, "Describe the
problem" intake card (autofocus textarea, optional customer fields,
primary "Start walk" CTA), open-tickets queue (Phase 1: display-only),
and a "Resume in progress" widget listing the L1's active sessions
ordered by last_step_at DESC. Empty-state card shows on accounts with
no queue + no active sessions (first-run nudge to upload KB or auth flows).

Adds /api/l1.ts (full L1 API client surface) and /types/l1.ts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 14:09:34 -04:00
d0561be6a1 feat(l1): register /l1/* routes + L1RouteGuard + page stubs
L1RouteGuard wraps the new routes and redirects users without
canUseL1Surface back to /. Page components are stubs in this task
(real UI in T21-T24): L1Dashboard, L1WalkPage, L1DraftsPage,
L1TicketsPage.

Routes: /l1, /l1/walk/:sessionId, /l1/drafts, /l1/tickets — all gated.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 14:03:26 -04:00
fbe25b3d68 feat(l1): role-based sidebar nav + L1 post-login redirect
L1 users see a focused sidebar with only their L1 surfaces (Workspace,
Tickets, My Drafts, Guides, Account). Engineers with can_cover_l1
(plus owners/super_admins) get an appended "L1 Workspace" entry in
their existing sidebar. ProtectedRoute redirects L1 users from / to /l1
on login.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 13:58:34 -04:00
4586010b87 feat(l1): usePermissions extensions for l1_tech + coverage flag
Adds 'l1_tech' to the AccountRole union, includes can_cover_l1 on the User
type, and exposes isL1Tech / canCoverL1 / canUseL1Surface /
canUseEngineerSurface from usePermissions. Existing isEngineer/isOwner/
etc. flags unchanged in semantics.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 13:54:52 -04:00
465b8ff880 test(l1): RLS regression tests for internal_tickets + l1_walk_sessions
Adds 8 synchronous psycopg2-based tests that connect as resolutionflow_app
and verify the tenant_isolation RLS policies (USING + WITH CHECK) on the two
new L1 Phase 1 tables block cross-tenant reads and reject cross-tenant INSERTs.

Uses psycopg2 (not asyncpg) to avoid the conftest pytest_runtest_teardown hook
that closes the asyncio event loop after every test — incompatible with
module-scoped asyncpg fixtures in pytest-asyncio 0.24.

conftest.py: extends _RLS_TEST_FILES set to include test_l1_rls.py so it is
excluded from the default create_all test suite (requires RUN_RLS_TESTS=1).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 13:49:39 -04:00
28 changed files with 2277 additions and 377 deletions

View File

@@ -2,11 +2,13 @@
"""
Create test user accounts for local development.
Creates 4 accounts:
1. Super Admin platform-wide admin (manages everything)
2. Pro Solo User single user on a "pro" plan
3. Team Admin admin of a team account ("team" plan)
4. Team Engineer regular engineer on the same team account
Creates 6 accounts:
1. Super Admin platform-wide admin (manages everything)
2. Pro Solo User single user on a "pro" plan
3. Team Admin admin of a team account ("team" plan)
4. Team Engineer regular engineer on the same team account
5. L1 Tech l1_tech role on the Acme MSP team (E2E: L1 happy path)
6. Coverage Engineer engineer with can_cover_l1=True (E2E: coverage banner)
Usage:
cd backend
@@ -71,6 +73,29 @@ USERS = [
"account_name": "Acme MSP", # same shared account
"account_role": "engineer",
"plan": None, # uses the team_admin's account & subscription
"can_cover_l1": False,
},
{
"key": "l1_tech",
"name": "Lee L1Tech",
"email": "l1@resolutionflow.example.com",
"is_super_admin": False,
"is_team_admin": False,
"account_name": "Acme MSP", # same shared account as team_admin
"account_role": "l1_tech",
"plan": None, # uses the team_admin's account & subscription
"can_cover_l1": False,
},
{
"key": "coverage_engineer",
"name": "Casey Coverage",
"email": "engineer-coverage@resolutionflow.example.com",
"is_super_admin": False,
"is_team_admin": False,
"account_name": "Acme MSP", # same shared account as team_admin
"account_role": "engineer",
"plan": None, # uses the team_admin's account & subscription
"can_cover_l1": True,
},
]
@@ -114,7 +139,9 @@ async def main() -> None:
continue
# ---- Create or reuse Account ----
if cfg["key"] == "team_engineer":
# Users that share the Acme MSP account (no own account to create)
_acme_members = {"team_engineer", "l1_tech", "coverage_engineer"}
if cfg["key"] in _acme_members:
if team_account_id is None:
result = await conn.execute(
text("SELECT id FROM accounts WHERE name = :name"),
@@ -145,13 +172,14 @@ async def main() -> None:
# 7-day verification grace immediately. Without this, fixtures hit
# require_verified_email_after_grace once their created_at ages past
# 7 days and get walled out of protected routes.
can_cover_l1 = cfg.get("can_cover_l1", False)
await conn.execute(
text("""
INSERT INTO users (id, email, password_hash, name, role, is_super_admin,
is_team_admin, is_active, account_id, account_role,
created_at, email_verified_at)
can_cover_l1, created_at, email_verified_at)
VALUES (:id, :email, :pw, :name, 'engineer', :is_sa, :is_ta, true,
:account_id, :account_role, :now, :now)
:account_id, :account_role, :can_cover_l1, :now, :now)
"""),
{
"id": user_id,
@@ -162,12 +190,13 @@ async def main() -> None:
"is_ta": cfg["is_team_admin"],
"account_id": account_id,
"account_role": cfg["account_role"],
"can_cover_l1": can_cover_l1,
"now": now,
},
)
# Set account owner (skip for team_engineer — they don't own the account)
if cfg["key"] != "team_engineer":
# Set account owner (skip for shared-account members — they don't own the account)
if cfg["key"] not in _acme_members:
await conn.execute(
text("UPDATE accounts SET owner_id = :uid WHERE id = :aid"),
{"uid": user_id, "aid": account_id},
@@ -183,7 +212,8 @@ async def main() -> None:
{"id": uuid.uuid4(), "aid": account_id, "plan": cfg["plan"], "now": now},
)
print(f" [OK] {cfg['email']:40s} account_role={cfg['account_role']:<10s} plan={cfg['plan'] or '(shared)'}")
cover_flag = " [can_cover_l1]" if can_cover_l1 else ""
print(f" [OK] {cfg['email']:40s} account_role={cfg['account_role']:<12s} plan={cfg['plan'] or '(shared)'}{cover_flag}")
await engine.dispose()
@@ -194,10 +224,12 @@ async def main() -> None:
print("=" * 60)
print()
print(" Accounts:")
print(f" Super Admin : admin@resolutionflow.example.com")
print(f" Pro Solo : pro@resolutionflow.example.com")
print(f" Team Admin : teamadmin@resolutionflow.example.com")
print(f" Team Engineer: engineer@resolutionflow.example.com")
print(f" Super Admin : admin@resolutionflow.example.com")
print(f" Pro Solo : pro@resolutionflow.example.com")
print(f" Team Admin : teamadmin@resolutionflow.example.com")
print(f" Team Engineer : engineer@resolutionflow.example.com")
print(f" L1 Tech : l1@resolutionflow.example.com")
print(f" Coverage Engineer : engineer-coverage@resolutionflow.example.com")
print()

View File

@@ -105,7 +105,7 @@ assert "test" in _test_db_name, (
)
_RUN_RLS_TESTS = os.environ.get("RUN_RLS_TESTS") == "1"
_RLS_ISOLATION_FILE = "test_rls_isolation.py"
_RLS_TEST_FILES = {"test_rls_isolation.py", "test_l1_rls.py"}
def pytest_collection_modifyitems(config, items):
@@ -117,7 +117,9 @@ def pytest_collection_modifyitems(config, items):
deselected = []
for item in items:
item_path = getattr(item, "path", None) or getattr(item, "fspath", None)
if item_path and str(item_path).endswith(_RLS_ISOLATION_FILE):
if item_path and any(
str(item_path).endswith(f) for f in _RLS_TEST_FILES
):
deselected.append(item)
else:
selected.append(item)

View File

@@ -0,0 +1,435 @@
# backend/tests/test_l1_rls.py
"""
RLS regression tests for L1 Phase 1 tables.
Verifies that `internal_tickets` and `l1_walk_sessions` — both with
FORCE ROW LEVEL SECURITY + `tenant_isolation` policy on `account_id` —
block cross-tenant reads AND reject WITH CHECK violations on INSERT.
Uses synchronous psycopg2 (not asyncpg) to avoid the conftest
teardown hook that closes the asyncio event loop after every test,
which is incompatible with module-scoped asyncpg fixtures.
Run with:
RUN_RLS_TESTS=1 DB_APP_ROLE_PASSWORD=app_secret_change_me \
pytest tests/test_l1_rls.py -v --override-ini="addopts="
"""
import os
import subprocess
import sys
import uuid
from pathlib import Path
from urllib.parse import unquote, urlsplit
import psycopg2
import psycopg2.errors
import pytest
pytestmark = pytest.mark.rls
_DATABASE_TEST_URL = os.getenv(
"DATABASE_TEST_URL",
"postgresql+asyncpg://postgres:postgres@localhost:5432/resolutionflow_test",
)
_DATABASE_TEST_URL_SYNC = _DATABASE_TEST_URL.replace(
"postgresql+asyncpg://",
"postgresql://",
1,
)
_TEST_DB_PARTS = urlsplit(_DATABASE_TEST_URL_SYNC)
_DB_HOST = os.getenv(
"TEST_DB_HOST", _TEST_DB_PARTS.hostname or "localhost"
)
_DB_PORT = int(os.getenv(
"TEST_DB_PORT", str(_TEST_DB_PARTS.port or 5432)
))
_DB_NAME = os.getenv(
"TEST_DB_NAME",
unquote(_TEST_DB_PARTS.path.lstrip("/") or "resolutionflow_test"),
)
_ADMIN_USER = os.getenv(
"TEST_DB_ADMIN_USER",
unquote(_TEST_DB_PARTS.username or "postgres"),
)
_ADMIN_PASSWORD = os.getenv(
"TEST_DB_ADMIN_PASSWORD",
unquote(_TEST_DB_PARTS.password or "postgres"),
)
_APP_PASSWORD = os.getenv("DB_APP_ROLE_PASSWORD", "app_secret_change_me")
ACCOUNT_A_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
ACCOUNT_B_ID = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
def _admin_dsn() -> dict:
return dict(
host=_DB_HOST, port=_DB_PORT, dbname=_DB_NAME,
user=_ADMIN_USER, password=_ADMIN_PASSWORD,
)
def _app_dsn() -> dict:
return dict(
host=_DB_HOST, port=_DB_PORT, dbname=_DB_NAME,
user="resolutionflow_app", password=_APP_PASSWORD,
)
# ---------------------------------------------------------------------------
# Schema bootstrap
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def _ensure_rls_schema():
"""Re-apply Alembic migrations so that RLS policies are present.
The standard test_db fixture uses Base.metadata.create_all which skips
RLS setup. Running 'alembic upgrade head' against the test DB ensures
the FORCE ROW LEVEL SECURITY + tenant_isolation policies created in the
L1 migrations (T5/T6) are active.
"""
backend_dir = Path(__file__).parent.parent
env = os.environ.copy()
env["DATABASE_URL"] = _DATABASE_TEST_URL
env["DATABASE_URL_SYNC"] = _DATABASE_TEST_URL_SYNC
subprocess.run(
[sys.executable, "-m", "alembic", "upgrade", "head"],
cwd=backend_dir,
env=env,
check=True,
capture_output=True,
)
# ---------------------------------------------------------------------------
# Seed fixture (module-scoped, synchronous psycopg2)
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def l1_rls_seed(_ensure_rls_schema):
"""Insert two accounts, two users, one internal_ticket and one
l1_walk_session per account using a superuser (BYPASSRLS) connection.
Returns a dict with the seeded IDs so tests can reference them.
Cleans up on module teardown.
"""
conn = psycopg2.connect(**_admin_dsn())
conn.autocommit = True
cur = conn.cursor()
# Accounts (idempotent — shared with test_rls_isolation.py)
cur.execute(
"INSERT INTO accounts (id, name, display_code, created_at, updated_at)"
" VALUES (%s, %s, %s, NOW(), NOW()),"
" (%s, %s, %s, NOW(), NOW())"
" ON CONFLICT (id) DO NOTHING",
(
ACCOUNT_A_ID, "L1 RLS Tenant A", "RLSA0001",
ACCOUNT_B_ID, "L1 RLS Tenant B", "RLSB0001",
),
)
user_a_tmp = str(uuid.uuid4())
user_b_tmp = str(uuid.uuid4())
cur.execute(
"INSERT INTO users"
" (id, email, password_hash, name, role, is_active,"
" account_id, account_role, created_at)"
" VALUES"
" (%s, %s, %s, %s, %s, %s, %s, %s, NOW()),"
" (%s, %s, %s, %s, %s, %s, %s, %s, NOW())"
" ON CONFLICT (email) DO NOTHING",
(
user_a_tmp, "l1-rls-a@example.com", "placeholder",
"L1 RLS User A", "engineer", True,
ACCOUNT_A_ID, "engineer",
user_b_tmp, "l1-rls-b@example.com", "placeholder",
"L1 RLS User B", "engineer", True,
ACCOUNT_B_ID, "engineer",
),
)
cur.execute(
"SELECT id FROM users WHERE email = 'l1-rls-a@example.com'"
)
user_a_id = str(cur.fetchone()[0])
cur.execute(
"SELECT id FROM users WHERE email = 'l1-rls-b@example.com'"
)
user_b_id = str(cur.fetchone()[0])
ticket_a_id = str(uuid.uuid4())
ticket_b_id = str(uuid.uuid4())
walk_a_id = str(uuid.uuid4())
walk_b_id = str(uuid.uuid4())
cur.execute(
"INSERT INTO internal_tickets"
" (id, account_id, created_by_user_id, problem_statement,"
" status, created_at, updated_at)"
" VALUES"
" (%s, %s, %s, %s, %s, NOW(), NOW()),"
" (%s, %s, %s, %s, %s, NOW(), NOW())",
(
ticket_a_id, ACCOUNT_A_ID, user_a_id,
"L1 RLS test ticket A", "open",
ticket_b_id, ACCOUNT_B_ID, user_b_id,
"L1 RLS test ticket B", "open",
),
)
cur.execute(
"INSERT INTO l1_walk_sessions"
" (id, account_id, created_by_user_id, ticket_id, ticket_kind,"
" session_kind, status, started_at, last_step_at)"
" VALUES"
" (%s, %s, %s, %s, %s, %s, %s, NOW(), NOW()),"
" (%s, %s, %s, %s, %s, %s, %s, NOW(), NOW())",
(
walk_a_id, ACCOUNT_A_ID, user_a_id,
"INT-A", "internal", "adhoc", "active",
walk_b_id, ACCOUNT_B_ID, user_b_id,
"INT-B", "internal", "adhoc", "active",
),
)
seed = {
"ticket_a": ticket_a_id,
"ticket_b": ticket_b_id,
"walk_a": walk_a_id,
"walk_b": walk_b_id,
"user_a": user_a_id,
"user_b": user_b_id,
}
yield seed
# Cleanup in reverse FK order.
# Delete all child rows for both test accounts before removing users —
# other test modules (test_rls_isolation.py) may have seeded rows for
# these same accounts, so we clean by account_id rather than by row ID.
cur.execute(
"DELETE FROM l1_walk_sessions WHERE account_id IN (%s, %s)",
(ACCOUNT_A_ID, ACCOUNT_B_ID),
)
cur.execute(
"DELETE FROM internal_tickets WHERE account_id IN (%s, %s)",
(ACCOUNT_A_ID, ACCOUNT_B_ID),
)
cur.execute(
"DELETE FROM users WHERE email IN (%s, %s)",
("l1-rls-a@example.com", "l1-rls-b@example.com"),
)
cur.execute(
"DELETE FROM accounts WHERE id IN (%s, %s)"
" AND display_code IN ('RLSA0001', 'RLSB0001')",
(ACCOUNT_A_ID, ACCOUNT_B_ID),
)
cur.close()
conn.close()
# ---------------------------------------------------------------------------
# Per-test helper: open an app-role connection with a given tenant context
# ---------------------------------------------------------------------------
def _app_conn(account_id: str | None = None) -> psycopg2.extensions.connection:
"""Open a psycopg2 connection as resolutionflow_app.
If account_id is given, SET LOCAL app.current_account_id so RLS applies
to the given tenant. Callers must begin a transaction first.
"""
conn = psycopg2.connect(**_app_dsn())
conn.autocommit = False
cur = conn.cursor()
if account_id:
cur.execute(
"SELECT set_config('app.current_account_id', %s, false)",
(account_id,),
)
cur.close()
return conn
# ---------------------------------------------------------------------------
# internal_tickets — read isolation
# ---------------------------------------------------------------------------
def test_l1_user_cannot_read_other_accounts_internal_tickets(l1_rls_seed):
"""RLS USING: Account A context must not see Account B's tickets."""
conn = _app_conn(ACCOUNT_A_ID)
try:
cur = conn.cursor()
cur.execute(
"SELECT id FROM internal_tickets WHERE id = %s",
(l1_rls_seed["ticket_b"],),
)
rows = cur.fetchall()
finally:
conn.rollback()
conn.close()
assert len(rows) == 0, (
"Account A must not read Account B's internal_tickets"
)
def test_internal_tickets_account_a_can_see_own_rows(l1_rls_seed):
"""Positive check: Account A can read its own internal_tickets."""
conn = _app_conn(ACCOUNT_A_ID)
try:
cur = conn.cursor()
cur.execute(
"SELECT id FROM internal_tickets WHERE id = %s",
(l1_rls_seed["ticket_a"],),
)
rows = cur.fetchall()
finally:
conn.rollback()
conn.close()
assert len(rows) == 1, (
"Account A must be able to read its own internal_tickets"
)
def test_internal_tickets_no_context_sees_nothing(l1_rls_seed):
"""Fail-closed: no tenant context → zero internal_tickets rows visible."""
conn = _app_conn() # no account_id
try:
cur = conn.cursor()
cur.execute(
"SELECT id FROM internal_tickets WHERE id IN (%s, %s)",
(l1_rls_seed["ticket_a"], l1_rls_seed["ticket_b"]),
)
rows = cur.fetchall()
finally:
conn.rollback()
conn.close()
assert len(rows) == 0, (
"No-context connection must not see any internal_tickets"
)
# ---------------------------------------------------------------------------
# l1_walk_sessions — read isolation
# ---------------------------------------------------------------------------
def test_l1_user_cannot_read_other_accounts_walk_sessions(l1_rls_seed):
"""RLS USING: Account A context must not see Account B's walk sessions."""
conn = _app_conn(ACCOUNT_A_ID)
try:
cur = conn.cursor()
cur.execute(
"SELECT id FROM l1_walk_sessions WHERE id = %s",
(l1_rls_seed["walk_b"],),
)
rows = cur.fetchall()
finally:
conn.rollback()
conn.close()
assert len(rows) == 0, (
"Account A must not read Account B's l1_walk_sessions"
)
def test_l1_walk_sessions_account_a_can_see_own_rows(l1_rls_seed):
"""Positive check: Account A can read its own l1_walk_sessions."""
conn = _app_conn(ACCOUNT_A_ID)
try:
cur = conn.cursor()
cur.execute(
"SELECT id FROM l1_walk_sessions WHERE id = %s",
(l1_rls_seed["walk_a"],),
)
rows = cur.fetchall()
finally:
conn.rollback()
conn.close()
assert len(rows) == 1, (
"Account A must be able to read its own l1_walk_sessions"
)
def test_l1_walk_sessions_no_context_sees_nothing(l1_rls_seed):
"""Fail-closed: no tenant context → zero l1_walk_sessions rows visible."""
conn = _app_conn() # no account_id
try:
cur = conn.cursor()
cur.execute(
"SELECT id FROM l1_walk_sessions WHERE id IN (%s, %s)",
(l1_rls_seed["walk_a"], l1_rls_seed["walk_b"]),
)
rows = cur.fetchall()
finally:
conn.rollback()
conn.close()
assert len(rows) == 0, (
"No-context connection must not see any l1_walk_sessions"
)
# ---------------------------------------------------------------------------
# internal_tickets — WITH CHECK (cross-tenant INSERT rejection)
# ---------------------------------------------------------------------------
def test_with_check_blocks_cross_tenant_insert_internal_tickets(l1_rls_seed):
"""RLS WITH CHECK: INSERT with account_id = A under context B is rejected.
psycopg2 raises InsufficientPrivilege (pgcode '42501') when a row
violates FORCE ROW LEVEL SECURITY WITH CHECK.
"""
new_id = str(uuid.uuid4())
user_b_id = l1_rls_seed["user_b"]
conn = _app_conn(ACCOUNT_B_ID)
try:
cur = conn.cursor()
with pytest.raises(psycopg2.errors.InsufficientPrivilege):
cur.execute(
"INSERT INTO internal_tickets"
" (id, account_id, created_by_user_id, problem_statement,"
" status, created_at, updated_at)"
" VALUES (%s, %s, %s, %s, %s, NOW(), NOW())",
(
new_id, ACCOUNT_A_ID, user_b_id,
"Cross-tenant injection attempt", "open",
),
)
finally:
conn.rollback()
conn.close()
# ---------------------------------------------------------------------------
# l1_walk_sessions — WITH CHECK (cross-tenant INSERT rejection)
# ---------------------------------------------------------------------------
def test_with_check_blocks_cross_tenant_insert_l1_walk_sessions(l1_rls_seed):
"""RLS WITH CHECK: INSERT with account_id = A under context B is rejected."""
new_id = str(uuid.uuid4())
user_b_id = l1_rls_seed["user_b"]
conn = _app_conn(ACCOUNT_B_ID)
try:
cur = conn.cursor()
with pytest.raises(psycopg2.errors.InsufficientPrivilege):
cur.execute(
"INSERT INTO l1_walk_sessions"
" (id, account_id, created_by_user_id, ticket_id,"
" ticket_kind, session_kind, status, started_at, last_step_at)"
" VALUES (%s, %s, %s, %s, %s, %s, %s, NOW(), NOW())",
(
new_id, ACCOUNT_A_ID, user_b_id,
"INT-cross", "internal", "adhoc", "active",
),
)
finally:
conn.rollback()
conn.close()

View File

@@ -0,0 +1,189 @@
/**
* E2E tests for the L1 Workspace surface (Phase 1).
*
* Covers:
* 1. L1 user lands on /l1 after login and can start an ad-hoc walk, take
* notes (autosave), and resolve the session.
* 2. L1 user cannot access /pilot, /trees/new, or /escalations — route
* guards bounce them back to /.
* 3. Engineer with can_cover_l1=true sees the "L1 Workspace" nav entry and
* the "You're covering L1" banner.
* 4. escalate-without-walk API endpoint returns an escalated adhoc session
* when called from an authenticated L1 user.
*
* Seed users (added by seed_test_users.py):
* l1@resolutionflow.example.com — account_role=l1_tech
* engineer-coverage@resolutionflow.example.com — engineer + can_cover_l1
*/
import { test, expect, type Page } from '@playwright/test'
// These tests always log in fresh — no shared storageState from auth.setup.ts.
test.use({ storageState: { cookies: [], origins: [] } })
const L1_EMAIL = 'l1@resolutionflow.example.com'
const COVERAGE_EMAIL = 'engineer-coverage@resolutionflow.example.com'
const PASSWORD = 'TestPass123!'
const apiOrigin = process.env.PLAYWRIGHT_API_ORIGIN || 'http://127.0.0.1:8000'
/**
* Log in via the login form using exact test-IDs / labels that LoginPage uses.
* Uses data-testid="login-form", getByLabel('Email address'), getByLabel('Password'),
* and data-testid="login-submit" — matching the actual LoginPage.tsx markup.
*/
async function login(page: Page, email: string): Promise<void> {
await page.goto('/login')
await expect(page.getByTestId('login-form')).toBeVisible()
await page.getByLabel('Email address').fill(email)
await page.getByLabel('Password').fill(PASSWORD)
await page.getByTestId('login-submit').click()
}
/**
* Obtain a bearer token for the given email via the JSON login endpoint.
* Used for direct API assertions without going through the browser.
*/
async function getToken(
page: Page,
email: string,
): Promise<string> {
const response = await page.request.post(`${apiOrigin}/api/v1/auth/login/json`, {
data: { email, password: PASSWORD },
})
expect(response.ok()).toBeTruthy()
const body = (await response.json()) as { access_token: string }
return body.access_token
}
test.describe('L1 Workspace', () => {
// -------------------------------------------------------------------------
// Test 1: Happy path — login → /l1 → start walk → notes → resolve
// -------------------------------------------------------------------------
test('L1 user lands on /l1 after login and can intake, take notes, and resolve', async ({ page }) => {
await login(page, L1_EMAIL)
// ProtectedRoute redirects l1_tech from / → /l1
await expect(page).toHaveURL(/\/l1$/, { timeout: 10_000 })
// Greeting heading: "Good morning|afternoon|evening, <name>."
await expect(
page.getByRole('heading', { name: /Good (morning|afternoon|evening)/i }),
).toBeVisible()
// Fill in problem statement textarea
const problemTextarea = page.getByPlaceholder("What's the user calling about?")
await expect(problemTextarea).toBeVisible()
await problemTextarea.fill('Customer says Outlook is broken after the latest update')
// Click "Start walk →" button
await page.getByRole('button', { name: /Start walk/i }).click()
// Should navigate to /l1/walk/<uuid>
await expect(page).toHaveURL(/\/l1\/walk\//, { timeout: 10_000 })
// The header badge shows "Ad-hoc walk"
await expect(page.getByText('Ad-hoc walk')).toBeVisible()
// Take notes in the walk textarea
const notesTextarea = page.getByPlaceholder(
'What did the customer say? What did you check? What did you try?',
)
await expect(notesTextarea).toBeVisible()
await notesTextarea.fill('Walked customer through closing and reopening Outlook — issue resolved')
// Autosave fires after 300ms debounce; wait up to 5s for the "Saved Xs ago" indicator
await expect(
page.getByText(/Saved \d+s ago|Saving…/i),
).toBeVisible({ timeout: 5_000 })
// Open the Resolve modal
await page.getByRole('button', { name: /Resolve/i }).click()
// Modal heading: "Did this resolve it?"
await expect(
page.getByRole('heading', { name: 'Did this resolve it?' }),
).toBeVisible()
// Click "Yes"
await page.getByRole('button', { name: 'Yes' }).click()
// Fill resolution notes
await page.getByPlaceholder('Resolution notes…').fill('Fixed via restarting Outlook')
// Confirm
await page.getByRole('button', { name: 'Confirm' }).click()
// After resolution, onDone() navigates back to /l1
await expect(page).toHaveURL(/\/l1$/, { timeout: 10_000 })
})
// -------------------------------------------------------------------------
// Test 2: Route guard — L1 user cannot access engineer-only routes
// -------------------------------------------------------------------------
test('L1 user cannot access /pilot, /trees/new, or /escalations', async ({ page }) => {
await login(page, L1_EMAIL)
await expect(page).toHaveURL(/\/l1$/, { timeout: 10_000 })
// /pilot — ProtectedRoute requires at least engineer rank; l1_tech gets bounced
await page.goto('/pilot')
await expect(page).not.toHaveURL(/\/pilot/, { timeout: 5_000 })
// /trees/new — same guard
await page.goto('/trees/new')
await expect(page).not.toHaveURL(/\/trees\/new/, { timeout: 5_000 })
// /escalations — if this route exists with a role guard it should bounce too
await page.goto('/escalations')
await expect(page).not.toHaveURL(/\/escalations/, { timeout: 5_000 })
})
// -------------------------------------------------------------------------
// Test 3: Coverage engineer sees the L1 nav link and the coverage banner
// -------------------------------------------------------------------------
test('Engineer with can_cover_l1 sees the L1 Workspace nav and coverage banner', async ({ page }) => {
await login(page, COVERAGE_EMAIL)
// Coverage engineer is not l1_tech — they land on the normal workspace root
await expect(page.getByTestId('app-shell')).toBeVisible({ timeout: 10_000 })
// Sidebar should show "L1 Workspace" link
const l1NavLink = page.getByRole('link', { name: /L1 Workspace/i })
await expect(l1NavLink).toBeVisible({ timeout: 10_000 })
// Navigate to /l1
await l1NavLink.click()
await expect(page).toHaveURL(/\/l1/, { timeout: 10_000 })
// L1CoverageBanner renders: "You're covering L1. Actions logged as coverage."
await expect(
page.getByText(/You're covering L1/i),
).toBeVisible({ timeout: 5_000 })
})
// -------------------------------------------------------------------------
// Test 4: escalate-without-walk endpoint — direct API assertion
// -------------------------------------------------------------------------
test('escalate-without-walk returns an escalated adhoc session', async ({ page }) => {
const token = await getToken(page, L1_EMAIL)
const response = await page.request.post(
`${apiOrigin}/api/v1/l1/escalate-without-walk`,
{
data: {
problem_statement: 'Customer issue with no KB content available',
reason_category: 'No KB available',
},
headers: { Authorization: `Bearer ${token}` },
},
)
expect(response.status()).toBe(200)
const body = (await response.json()) as {
status: string
session_kind: string
}
expect(body.status).toBe('escalated')
expect(body.session_kind).toBe('adhoc')
})
})

View File

@@ -10,7 +10,7 @@
<!-- Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@400;600;700;800&family=IBM+Plex+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible+Next:ital,wght@0,400;0,500;0,600;0,700;0,800;1,400;1,700&family=Atkinson+Hyperlegible+Mono:wght@400;500&family=Bricolage+Grotesque:wght@400;600;700;800&family=IBM+Plex+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<!-- PWA Icons -->
<link rel="apple-touch-icon" href="/icons/app-icon-gradient.svg" />

64
frontend/src/api/l1.ts Normal file
View File

@@ -0,0 +1,64 @@
import { apiClient } from './client'
import type {
IntakeRequest,
IntakeResponse,
QueueRow,
WalkSession,
AdhocNote,
} from '@/types/l1'
export const l1Api = {
intake: (body: IntakeRequest) =>
apiClient.post<IntakeResponse>('/l1/intake', body).then(r => r.data),
queue: (statusFilter?: string) =>
apiClient.get<QueueRow[]>('/l1/queue', {
params: statusFilter ? { status_filter: statusFilter } : {},
}).then(r => r.data),
listActiveSessions: () =>
apiClient.get<WalkSession[]>('/l1/sessions/active').then(r => r.data),
getSession: (sessionId: string) =>
apiClient.get<WalkSession>(`/l1/sessions/${sessionId}`).then(r => r.data),
step: (
sessionId: string,
step: { node_id: string; question: string; answer: string; note?: string | null },
) =>
apiClient
.post<WalkSession>(`/l1/sessions/${sessionId}/step`, step)
.then(r => r.data),
notes: (sessionId: string, notes: AdhocNote[]) =>
apiClient
.post<WalkSession>(`/l1/sessions/${sessionId}/notes`, { notes })
.then(r => r.data),
resolve: (
sessionId: string,
body: { helpful: boolean; resolution_notes: string },
) =>
apiClient
.post<WalkSession>(`/l1/sessions/${sessionId}/resolve`, body)
.then(r => r.data),
escalate: (
sessionId: string,
body: { reason: string; reason_category: string },
) =>
apiClient
.post<WalkSession>(`/l1/sessions/${sessionId}/escalate`, body)
.then(r => r.data),
escalateWithoutWalk: (body: {
problem_statement: string
customer_name?: string
customer_contact?: string
reason_category: string
reason?: string
}) =>
apiClient
.post<WalkSession>('/l1/escalate-without-walk', body)
.then(r => r.data),
}

17
frontend/src/api/seats.ts Normal file
View File

@@ -0,0 +1,17 @@
import { apiClient } from './client'
export interface SeatCheck {
available: boolean
current: number
limit: number | null
role: 'engineer' | 'l1_tech'
}
export interface SeatUsage {
engineer: SeatCheck
l1_tech: SeatCheck
}
export const seatsApi = {
getUsage: () => apiClient.get<SeatUsage>('/accounts/me/seats').then((r) => r.data),
}

View File

@@ -0,0 +1,33 @@
import { useEffect, useState } from 'react'
import { seatsApi, type SeatUsage } from '@/api/seats'
interface RowProps { label: string; check: SeatUsage['engineer'] }
function SeatRow({ label, check }: RowProps) {
const overLimit = check.limit !== null && check.current > check.limit
const limitText = check.limit === null ? '∞' : check.limit
return (
<div className={overLimit ? 'text-warning' : ''}>
<p className="text-xs uppercase tracking-wider text-muted-foreground mb-1">{label}</p>
<p className="text-lg font-mono">{check.current} / {limitText}</p>
{overLimit && <p className="text-xs">Over limit (grandfathered)</p>}
</div>
)
}
export function SeatCounterWidget() {
const [usage, setUsage] = useState<SeatUsage | null>(null)
useEffect(() => {
seatsApi.getUsage().then(setUsage).catch(() => setUsage(null))
}, [])
if (!usage) return null
return (
<div className="rounded-lg border border-default bg-card p-4 grid grid-cols-2 gap-4">
<SeatRow label="Engineer seats" check={usage.engineer} />
<SeatRow label="L1 seats" check={usage.l1_tech} />
</div>
)
}

View File

@@ -0,0 +1,35 @@
import { usePermissions } from '@/hooks/usePermissions'
interface Props {
onUploadClick?: () => void
}
export function EmptyStateCard({ onUploadClick }: Props) {
const { canCoverL1 } = usePermissions()
return (
<div className="rounded-lg border border-default bg-card p-6">
<h2 className="font-heading text-xl font-bold text-heading mb-2">
Your knowledge base is empty
</h2>
<p className="text-muted-foreground mb-4">
L1 Workspace works best when your account has KB content or authored flows.
Right now there's nothing to match against calls will start as ad-hoc walks.
</p>
{canCoverL1 && onUploadClick ? (
<button
type="button"
onClick={onUploadClick}
className="rounded-md bg-accent text-white px-4 py-2 text-sm font-medium hover:bg-accent/90 transition-colors"
>
Upload KB content
</button>
) : (
<ul className="text-sm text-muted-foreground space-y-1 ml-4 list-disc">
<li>Ask your admin to upload KB documents</li>
<li>Or ask them to author a flow in the Flows library</li>
</ul>
)}
</div>
)
}

View File

@@ -0,0 +1,23 @@
import { useNavigate } from 'react-router-dom'
import { usePermissions } from '@/hooks/usePermissions'
export function L1CoverageBanner() {
const perms = usePermissions()
const navigate = useNavigate()
// Show only for engineer-coverers / owners-stepping-in. Native L1 doesn't see it.
if (perms.isL1Tech) return null
if (!perms.canCoverL1) return null
return (
<div className="bg-info/10 text-info text-sm px-4 py-1.5 flex items-center justify-between border-b border-info/20">
<span>You're covering L1. Actions logged as coverage.</span>
<button
onClick={() => navigate('/')}
className="text-info hover:underline underline-offset-2"
>
Switch back
</button>
</div>
)
}

View File

@@ -0,0 +1,156 @@
import { useEffect, useRef, useState } from 'react'
import { ChevronLeft } from 'lucide-react'
import { Link } from 'react-router-dom'
import { l1Api } from '@/api/l1'
import type { AdhocNote, WalkSession } from '@/types/l1'
import { EscalateModal, ResolveModal } from '@/components/l1/WalkModals'
interface Props {
session: WalkSession
onSessionUpdate: (s: WalkSession) => void
onDone: () => void
}
export function L1WalkAdhocVariant({ session, onSessionUpdate, onDone }: Props) {
const [showResolve, setShowResolve] = useState(false)
const [showEscalate, setShowEscalate] = useState(false)
// Show prior notes as joined paragraphs so the L1 sees an editable timeline.
const [notesText, setNotesText] = useState(() =>
session.walk_notes.map((n) => n.content).join('\n\n')
)
const [savedAt, setSavedAt] = useState<Date | null>(null)
const [saving, setSaving] = useState(false)
const saveTimer = useRef<number | null>(null)
// Debounced autosave: 300ms after the last keystroke, send to the backend.
useEffect(() => {
if (session.status !== 'active') return
if (saveTimer.current) window.clearTimeout(saveTimer.current)
saveTimer.current = window.setTimeout(async () => {
// Split paragraphs into structured notes. Empty paragraphs are skipped.
const parts = notesText
.split('\n\n')
.map((c) => c.trim())
.filter(Boolean)
const notes: AdhocNote[] = parts.map((content) => ({
timestamp: new Date().toISOString(),
content,
}))
try {
setSaving(true)
const updated = await l1Api.notes(session.id, notes)
onSessionUpdate(updated)
setSavedAt(new Date())
} catch (err) {
console.error('notes save failed:', err)
} finally {
setSaving(false)
}
}, 300)
return () => {
if (saveTimer.current) window.clearTimeout(saveTimer.current)
}
}, [notesText, session.id, session.status, onSessionUpdate])
const savedAgo = savedAt ? Math.max(1, Math.round((Date.now() - savedAt.getTime()) / 1000)) : null
return (
<div className="flex flex-col h-full">
{/* Header */}
<header className="border-b border-default px-6 py-4 flex items-center justify-between bg-sidebar">
<Link
to="/l1"
className="flex items-center gap-2 text-muted-foreground hover:text-heading transition-colors"
>
<ChevronLeft className="w-4 h-4" />
<span className="font-mono text-xs">#{session.id.slice(0, 8)}</span>
<span className="ml-2 text-xs bg-info/10 text-info px-2 py-0.5 rounded">Ad-hoc walk</span>
</Link>
<div className="flex gap-2">
<button
onClick={() => setShowEscalate(true)}
disabled={session.status !== 'active'}
className="rounded-md border border-default px-3 py-1.5 text-sm hover:bg-elevated transition-colors disabled:opacity-50"
>
Escalate
</button>
<button
onClick={() => setShowResolve(true)}
disabled={session.status !== 'active'}
className="rounded-md bg-accent text-white px-3 py-1.5 text-sm hover:bg-accent/90 transition-colors disabled:opacity-50"
>
Resolve
</button>
</div>
</header>
{/* Single-pane body */}
<main className="flex-1 p-6 overflow-y-auto min-h-0">
<div className="max-w-3xl mx-auto">
{session.status !== 'active' ? (
<div className="rounded-lg border border-default bg-card p-6">
<p className="text-sm text-muted-foreground">
This session is <span className="font-semibold">{session.status}</span>.
</p>
<button
onClick={onDone}
className="mt-3 rounded-md bg-accent text-white px-3 py-1.5 text-sm hover:bg-accent/90 transition-colors"
>
Back to workspace
</button>
</div>
) : (
<>
<p className="text-sm text-muted-foreground mb-3">
Take notes as you work through the call. They're auto-saved.
</p>
<textarea
value={notesText}
onChange={(e) => setNotesText(e.target.value)}
rows={20}
placeholder="What did the customer say? What did you check? What did you try?"
className="w-full bg-card border border-default rounded-md px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-accent/40 leading-relaxed font-sans"
/>
<p className="text-xs text-muted-foreground mt-2">
{saving
? 'Saving'
: savedAgo !== null
? `Saved ${savedAgo}s ago`
: 'Not yet saved'}
</p>
</>
)}
</div>
</main>
{/* Modals */}
{showResolve && (
<ResolveModal
defaultNotes={notesText}
onClose={() => setShowResolve(false)}
onConfirm={async (helpful, resolutionNotes) => {
try {
await l1Api.resolve(session.id, { helpful, resolution_notes: resolutionNotes })
onDone()
} catch (err) {
console.error('resolve failed:', err)
}
}}
/>
)}
{showEscalate && (
<EscalateModal
onClose={() => setShowEscalate(false)}
onConfirm={async (category, reason) => {
try {
await l1Api.escalate(session.id, { reason, reason_category: category })
onDone()
} catch (err) {
console.error('escalate failed:', err)
}
}}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,173 @@
import { useState } from 'react'
import { ChevronLeft } from 'lucide-react'
import { Link } from 'react-router-dom'
import { l1Api } from '@/api/l1'
import type { WalkSession } from '@/types/l1'
import { EscalateModal, ResolveModal } from '@/components/l1/WalkModals'
interface Props {
session: WalkSession
onSessionUpdate: (s: WalkSession) => void
onDone: () => void
}
export function L1WalkTreeVariant({ session, onSessionUpdate, onDone }: Props) {
const [showResolve, setShowResolve] = useState(false)
const [showEscalate, setShowEscalate] = useState(false)
const [note, setNote] = useState('')
// Phase 1: we don't have the live flow-tree fetch wired up here yet
// (the tree-navigation pages have their own loader). The walker shows the
// walked-path side panel, advance buttons stubbed for now — Phase 2 wires
// the actual flow tree fetching + node advancement against tree data.
// The "Yes/No" buttons record a synthetic step so the walked_path JSONB
// grows; this gives us a functional roundtrip until Phase 2 wires the tree.
const handleAnswer = async (answer: 'yes' | 'no') => {
const nodeId = session.current_node_id || `step-${session.walked_path.length + 1}`
try {
const updated = await l1Api.step(session.id, {
node_id: nodeId,
question: `Step ${session.walked_path.length + 1}`,
answer,
note: note || null,
})
onSessionUpdate(updated)
setNote('')
} catch (err) {
// Keep silent for v1 — Phase 2 wires real error UI
console.error('step failed', err)
}
}
const lastError = (err: unknown): string => {
if (typeof err === 'object' && err && 'response' in err) {
const detail = (err as any).response?.data?.detail
if (typeof detail === 'string') return detail
}
return 'Unexpected error'
}
return (
<div className="flex flex-col h-full">
{/* Header */}
<header className="border-b border-default px-6 py-4 flex items-center justify-between bg-sidebar">
<Link to="/l1" className="flex items-center gap-2 text-muted-foreground hover:text-heading transition-colors">
<ChevronLeft className="w-4 h-4" />
<span className="font-mono text-xs">#{session.id.slice(0, 8)}</span>
{session.session_kind === 'proposal' && (
<span className="ml-2 text-xs bg-accent/10 text-accent px-2 py-0.5 rounded">AI-built</span>
)}
</Link>
<div className="flex gap-2">
<button
onClick={() => setShowEscalate(true)}
className="rounded-md border border-default px-3 py-1.5 text-sm hover:bg-elevated transition-colors"
disabled={session.status !== 'active'}
>
Escalate
</button>
<button
onClick={() => setShowResolve(true)}
className="rounded-md bg-accent text-white px-3 py-1.5 text-sm hover:bg-accent/90 transition-colors disabled:opacity-50"
disabled={session.status !== 'active'}
>
Resolve
</button>
</div>
</header>
{/* Two-pane body */}
<div className="flex-1 flex min-h-0">
<main className="flex-1 p-6 overflow-y-auto min-h-0">
<p className="font-sans text-[0.625rem] uppercase tracking-[0.12em] font-semibold text-muted-foreground mb-2">
Step {session.walked_path.length + 1}
</p>
{session.status !== 'active' ? (
<div className="rounded-lg border border-default bg-card p-6">
<p className="text-sm text-muted-foreground">
This session is <span className="font-semibold">{session.status}</span>.
</p>
<button onClick={onDone} className="mt-3 rounded-md bg-accent text-white px-3 py-1.5 text-sm">
Back to workspace
</button>
</div>
) : (
<div className="rounded-lg border border-default bg-card p-6 max-w-2xl">
<p className="text-lg mb-6">Continue the walk:</p>
<div className="flex gap-3">
<button
onClick={() => handleAnswer('yes')}
className="flex-1 rounded-md bg-accent text-white py-3 text-base font-medium hover:bg-accent/90 min-h-[44px] transition-colors"
>
Yes
</button>
<button
onClick={() => handleAnswer('no')}
className="flex-1 rounded-md border border-default py-3 text-base font-medium hover:bg-elevated min-h-[44px] transition-colors"
>
No
</button>
</div>
<textarea
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="Optional note for this step…"
rows={2}
className="mt-4 w-full bg-page border border-default rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent/40"
/>
</div>
)}
</main>
{/* Right pane: transcript */}
<aside className="w-80 border-l border-default bg-page p-4 overflow-y-auto">
<p className="font-sans text-[0.625rem] uppercase tracking-[0.12em] font-semibold text-muted-foreground mb-3">
Walked so far
</p>
{session.walked_path.length === 0 ? (
<p className="text-xs text-muted-foreground">No steps yet.</p>
) : (
<ol className="space-y-3 text-sm">
{session.walked_path.map((step, i) => (
<li key={i} className="flex flex-col">
<span className="text-muted-foreground text-xs">{step.question}</span>
<span className="font-medium"> {step.answer}</span>
{step.l1_note && <span className="text-muted-foreground text-xs italic mt-0.5">{step.l1_note}</span>}
</li>
))}
</ol>
)}
</aside>
</div>
{/* Modals */}
{showResolve && (
<ResolveModal
onClose={() => setShowResolve(false)}
onConfirm={async (helpful, resolutionNotes) => {
try {
await l1Api.resolve(session.id, { helpful, resolution_notes: resolutionNotes })
onDone()
} catch (err) {
console.error('resolve failed:', lastError(err))
}
}}
/>
)}
{showEscalate && (
<EscalateModal
onClose={() => setShowEscalate(false)}
onConfirm={async (category, reason) => {
try {
await l1Api.escalate(session.id, { reason, reason_category: category })
onDone()
} catch (err) {
console.error('escalate failed:', lastError(err))
}
}}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,49 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { l1Api } from '@/api/l1'
import type { WalkSession } from '@/types/l1'
export function ResumeInProgress() {
const [sessions, setSessions] = useState<WalkSession[] | null>(null)
useEffect(() => {
l1Api
.listActiveSessions()
.then(setSessions)
.catch(() => setSessions([]))
}, [])
if (!sessions || sessions.length === 0) return null
return (
<section>
<div className="flex items-center gap-3 mb-3">
<span className="font-sans text-[0.625rem] uppercase tracking-[0.12em] font-semibold text-muted-foreground">
Resume in progress · {sessions.length}
</span>
<div className="flex-1 h-px bg-border" />
</div>
<div className="rounded-lg border border-default bg-card overflow-hidden">
{sessions.map((s) => (
<Link
key={s.id}
to={`/l1/walk/${s.id}`}
className="flex items-center justify-between px-4 py-3 hover:bg-elevated transition-colors border-b border-default last:border-b-0"
>
<div className="flex items-center gap-3">
<span className="font-mono text-xs text-muted-foreground">#{s.id.slice(0, 8)}</span>
<span className="text-sm">
{s.session_kind === 'adhoc'
? `Ad-hoc · ${s.walk_notes.length} notes`
: `Step ${s.walked_path.length}`}
</span>
</div>
<span className="text-xs text-muted-foreground">
{new Date(s.last_step_at).toLocaleTimeString()}
</span>
</Link>
))}
</div>
</section>
)
}

View File

@@ -0,0 +1,121 @@
import { useState } from 'react'
export interface ResolveModalProps {
defaultNotes?: string
onClose: () => void
onConfirm: (helpful: boolean, notes: string) => Promise<void>
}
export function ResolveModal({ defaultNotes = '', onClose, onConfirm }: ResolveModalProps) {
const [helpful, setHelpful] = useState<boolean | null>(null)
const [notes, setNotes] = useState(defaultNotes)
const [submitting, setSubmitting] = useState(false)
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<div className="bg-card border border-default rounded-lg p-6 max-w-md w-full">
<h3 className="font-heading text-lg font-bold mb-4">Did this resolve it?</h3>
<div className="flex gap-3 mb-4">
<button
onClick={() => setHelpful(true)}
className={`flex-1 py-2 rounded-md transition-colors ${helpful === true ? 'bg-accent text-white' : 'border border-default hover:bg-elevated'}`}
>
Yes
</button>
<button
onClick={() => setHelpful(false)}
className={`flex-1 py-2 rounded-md transition-colors ${helpful === false ? 'bg-warning text-white' : 'border border-default hover:bg-elevated'}`}
>
No
</button>
</div>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
placeholder="Resolution notes…"
className="w-full bg-page border border-default rounded-md px-3 py-2 text-sm mb-4 focus:outline-none focus:ring-2 focus:ring-accent/40"
/>
<div className="flex justify-end gap-2">
<button
onClick={onClose}
className="rounded-md border border-default px-4 py-2 text-sm hover:bg-elevated transition-colors"
>
Cancel
</button>
<button
disabled={helpful === null || submitting}
onClick={async () => {
setSubmitting(true)
try { await onConfirm(helpful!, notes) } finally { setSubmitting(false) }
}}
className="rounded-md bg-accent text-white px-4 py-2 text-sm disabled:opacity-50 hover:bg-accent/90 transition-colors"
>
{submitting ? 'Saving…' : 'Confirm'}
</button>
</div>
</div>
</div>
)
}
export interface EscalateModalProps {
onClose: () => void
onConfirm: (category: string, reason: string) => Promise<void>
}
const REASON_CATEGORIES = [
'Out of L1 scope',
'Customer demanding senior',
'Tree dead-ended',
'AI tree wrong',
'No KB available',
'Other',
] as const
export function EscalateModal({ onClose, onConfirm }: EscalateModalProps) {
const [category, setCategory] = useState<string>(REASON_CATEGORIES[0])
const [reason, setReason] = useState('')
const [submitting, setSubmitting] = useState(false)
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<div className="bg-card border border-default rounded-lg p-6 max-w-md w-full">
<h3 className="font-heading text-lg font-bold mb-4">Escalate to engineering</h3>
<label className="block text-xs uppercase tracking-wider text-muted-foreground mb-1">Reason</label>
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
className="w-full bg-page border border-default rounded-md px-3 py-2 text-sm mb-3 focus:outline-none focus:ring-2 focus:ring-accent/40"
>
{REASON_CATEGORIES.map((c) => (<option key={c}>{c}</option>))}
</select>
<textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
rows={3}
placeholder="Details (optional)…"
className="w-full bg-page border border-default rounded-md px-3 py-2 text-sm mb-4 focus:outline-none focus:ring-2 focus:ring-accent/40"
/>
<div className="flex justify-end gap-2">
<button
onClick={onClose}
className="rounded-md border border-default px-4 py-2 text-sm hover:bg-elevated transition-colors"
>
Cancel
</button>
<button
disabled={submitting}
onClick={async () => {
setSubmitting(true)
try { await onConfirm(category, reason) } finally { setSubmitting(false) }
}}
className="rounded-md bg-warning text-white px-4 py-2 text-sm disabled:opacity-50 hover:bg-warning/90 transition-colors"
>
{submitting ? 'Escalating…' : 'Confirm escalate'}
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,18 @@
import { Navigate } from 'react-router-dom'
import { usePermissions } from '@/hooks/usePermissions'
import { L1CoverageBanner } from '@/components/l1/L1CoverageBanner'
export function L1RouteGuard({ children }: { children: React.ReactNode }) {
const { canUseL1Surface } = usePermissions()
if (!canUseL1Surface) {
return <Navigate to="/" replace />
}
return (
<div className="flex flex-col h-full">
<L1CoverageBanner />
<div className="flex-1 min-h-0 flex flex-col">
{children}
</div>
</div>
)
}

View File

@@ -32,9 +32,10 @@ export function ProtectedRoute({ requiredRole, children }: ProtectedRouteProps)
if (requiredRole) {
const ROLE_HIERARCHY: Record<EffectiveRole, number> = {
super_admin: 4,
owner: 3,
engineer: 2,
super_admin: 5,
owner: 4,
engineer: 3,
l1_tech: 2,
viewer: 1,
}
if (ROLE_HIERARCHY[effectiveRole] < ROLE_HIERARCHY[requiredRole]) {
@@ -42,6 +43,12 @@ export function ProtectedRoute({ requiredRole, children }: ProtectedRouteProps)
}
}
// L1 users landing on / (e.g. post-login) get redirected to their workspace.
// Does not fire when already on /l1 or any other path, preventing loops.
if (effectiveRole === 'l1_tech' && location.pathname === '/') {
return <Navigate to="/l1" replace />
}
return <>{children}</>
}

View File

@@ -12,6 +12,7 @@ import { useUserPreferencesStore } from '@/store/userPreferencesStore'
import { sidebarApi } from '@/api'
import type { SidebarStatsResponse } from '@/api/sidebar'
import { prefetchForRoute } from '@/lib/routePrefetch'
import { usePermissions } from '@/hooks/usePermissions'
/* ── Types ──────────────────────────────────────────── */
@@ -37,6 +38,7 @@ export function Sidebar() {
const location = useLocation()
const sidebarPinned = useUserPreferencesStore(s => s.sidebarPinned)
const toggleSidebarPinned = useUserPreferencesStore(s => s.toggleSidebarPinned)
const { isL1Tech, canCoverL1 } = usePermissions()
const [stats, setStats] = useState<SidebarStatsResponse | null>(null)
// Phase 6: pending-drafts badge on the Scripts nav. Fetched independently
@@ -77,58 +79,74 @@ export function Sidebar() {
* and pinned modes. Pin/unpin is a width/label affordance, not an
* IA switch. A hairline divider separates the two groups; no labels. */
const workItems: NavEntry[] = [
{
href: '/', icon: LayoutGrid, label: 'Dashboard', shortLabel: 'Dash',
matchPaths: ['/'],
},
{
href: '/tickets', icon: Ticket, label: 'Tickets', shortLabel: 'Tickets',
matchPaths: ['/tickets'],
},
{
href: '/sessions', icon: Clock, label: 'Sessions', shortLabel: 'Sessions',
badge: stats?.active_count || undefined,
matchPaths: ['/sessions'],
},
{
href: '/escalations', icon: AlertTriangle, label: 'Escalations', shortLabel: 'Escal',
badge: stats?.escalation_count || undefined,
matchPaths: ['/escalations'],
},
]
// L1 users get a focused sidebar with only their surfaces.
// Engineers/owners get the full sidebar; those with canCoverL1 also get
// an appended "L1 Workspace" entry in the library group.
const workItems: NavEntry[] = isL1Tech
? [
{ href: '/l1', icon: LayoutGrid, label: 'Workspace', shortLabel: 'Work', matchPaths: ['/l1'] },
{ href: '/l1/tickets', icon: Ticket, label: 'Tickets', shortLabel: 'Tickets', matchPaths: ['/l1/tickets'] },
{ href: '/l1/drafts', icon: FileText, label: 'My Drafts', shortLabel: 'Drafts', matchPaths: ['/l1/drafts'] },
]
: [
{
href: '/', icon: LayoutGrid, label: 'Dashboard', shortLabel: 'Dash',
matchPaths: ['/'],
},
{
href: '/tickets', icon: Ticket, label: 'Tickets', shortLabel: 'Tickets',
matchPaths: ['/tickets'],
},
{
href: '/sessions', icon: Clock, label: 'Sessions', shortLabel: 'Sessions',
badge: stats?.active_count || undefined,
matchPaths: ['/sessions'],
},
{
href: '/escalations', icon: AlertTriangle, label: 'Escalations', shortLabel: 'Escal',
badge: stats?.escalation_count || undefined,
matchPaths: ['/escalations'],
},
]
const libraryItems: NavEntry[] = [
{
href: '/trees', icon: GitBranch, label: 'Flows', shortLabel: 'Flows',
badge: stats?.tree_counts.total || undefined,
matchPaths: ['/trees', '/flows', '/my-trees', '/step-library', '/network-diagrams'],
children: [
{ href: '/trees?type=procedural', label: 'Projects', count: stats?.tree_counts.procedural || undefined },
{ href: '/step-library', label: 'Solutions Library' },
{ href: '/network-diagrams', label: 'Network Maps' },
],
},
{
href: '/scripts', icon: FileText, label: 'Scripts', shortLabel: 'Scripts',
badge: pendingDraftCount || undefined,
matchPaths: ['/scripts', '/script-builder'],
children: [
{ href: '/script-builder', label: 'Script Builder' },
],
},
{
href: '/review-queue', icon: ListChecks, label: 'Review Queue', shortLabel: 'Review',
matchPaths: ['/review-queue'],
},
{
href: '/analytics', icon: BarChart3, label: 'Analytics', shortLabel: 'Stats',
matchPaths: ['/analytics', '/shares'],
children: [
{ href: '/shares', label: 'Exports' },
],
},
]
const libraryItems: NavEntry[] = isL1Tech
? []
: [
{
href: '/trees', icon: GitBranch, label: 'Flows', shortLabel: 'Flows',
badge: stats?.tree_counts.total || undefined,
matchPaths: ['/trees', '/flows', '/my-trees', '/step-library', '/network-diagrams'],
children: [
{ href: '/trees?type=procedural', label: 'Projects', count: stats?.tree_counts.procedural || undefined },
{ href: '/step-library', label: 'Solutions Library' },
{ href: '/network-diagrams', label: 'Network Maps' },
],
},
{
href: '/scripts', icon: FileText, label: 'Scripts', shortLabel: 'Scripts',
badge: pendingDraftCount || undefined,
matchPaths: ['/scripts', '/script-builder'],
children: [
{ href: '/script-builder', label: 'Script Builder' },
],
},
{
href: '/review-queue', icon: ListChecks, label: 'Review Queue', shortLabel: 'Review',
matchPaths: ['/review-queue'],
},
{
href: '/analytics', icon: BarChart3, label: 'Analytics', shortLabel: 'Stats',
matchPaths: ['/analytics', '/shares'],
children: [
{ href: '/shares', label: 'Exports' },
],
},
// Engineers/owners with L1 coverage access also get the L1 Workspace entry
...(canCoverL1 ? [{
href: '/l1', icon: LayoutGrid, label: 'L1 Workspace', shortLabel: 'L1',
matchPaths: ['/l1'],
}] : []),
]
const footerItems: NavEntry[] = [
{ href: '/guides', icon: BookOpen, label: 'Guides', shortLabel: 'Guides', matchPaths: ['/guides'] },

View File

@@ -1,19 +1,20 @@
/**
* Centralized permissions hook for ResolutionFlow.
*
* Role hierarchy: super_admin > owner > engineer > viewer
* Role hierarchy: super_admin > owner > engineer > l1_tech > viewer
*
* Mirrors backend logic in backend/app/core/permissions.py
*/
import { useAuthStore } from '@/store/authStore'
import type { User } from '@/types'
export type EffectiveRole = 'super_admin' | 'owner' | 'engineer' | 'viewer'
export type EffectiveRole = 'super_admin' | 'owner' | 'engineer' | 'l1_tech' | 'viewer'
const ROLE_HIERARCHY: Record<EffectiveRole, number> = {
super_admin: 4,
owner: 3,
engineer: 2,
super_admin: 5,
owner: 4,
engineer: 3,
l1_tech: 2,
viewer: 1,
}
@@ -21,7 +22,9 @@ function getEffectiveRole(user: User | null): EffectiveRole {
if (!user) return 'viewer'
if (user.is_super_admin) return 'super_admin'
if (user.account_role === 'owner') return 'owner'
return user.role as EffectiveRole
if (user.account_role === 'engineer') return 'engineer'
if (user.account_role === 'l1_tech') return 'l1_tech'
return 'viewer'
}
function hasMinimumRole(user: User | null, minimum: EffectiveRole): boolean {
@@ -39,8 +42,23 @@ export function usePermissions() {
isSuperAdmin: effectiveRole === 'super_admin',
isAccountOwner: effectiveRole === 'owner' || effectiveRole === 'super_admin',
isEngineer: hasMinimumRole(user, 'engineer'),
isL1Tech: effectiveRole === 'l1_tech',
isViewer: effectiveRole === 'viewer',
// L1 workspace permissions
canCoverL1: (
Boolean(user?.can_cover_l1) ||
effectiveRole === 'owner' ||
effectiveRole === 'super_admin'
),
canUseL1Surface: (
effectiveRole === 'l1_tech' ||
effectiveRole === 'owner' ||
effectiveRole === 'super_admin' ||
(user?.account_role === 'engineer' && Boolean(user?.can_cover_l1))
),
canUseEngineerSurface: hasMinimumRole(user, 'engineer'),
// Content creation permissions
canCreateTrees: hasMinimumRole(user, 'engineer'),
canCreateSteps: hasMinimumRole(user, 'engineer'),

View File

@@ -33,6 +33,7 @@ import { Spinner } from '@/components/common/Spinner'
import { cn } from '@/lib/utils'
import { usePermissions } from '@/hooks/usePermissions'
import { useSubscription } from '@/hooks/useSubscription'
import { SeatCounterWidget } from '@/components/admin/SeatCounterWidget'
import { useAuthStore } from '@/store/authStore'
import { CheckoutButton } from '@/components/subscription/CheckoutButton'
import { toast } from '@/lib/toast'
@@ -432,6 +433,8 @@ export function AccountSettingsPage() {
<section className="space-y-5 border-t border-border pt-8">
<SectionLabel>People</SectionLabel>
<SeatCounterWidget />
<form onSubmit={handleInvite} className="flex flex-wrap items-center gap-2">
<input
type="email"

View File

@@ -164,46 +164,74 @@ export default function LandingPage() {
</div>
</section>
{/* Problem — asymmetric: headline left, cards right */}
<section id="problem" className="landing-section landing-section-alt landing-reveal">
{/* Problem — editorial list, no cards */}
<section id="problem" className="landing-section landing-section-alt landing-reveal landing-section-tight">
<div className="landing-section-inner">
<div className="landing-problem-layout">
<div className="landing-problem-headline">
<div className="landing-section-label">The Problem</div>
<h2>Documentation is broken.<br />Everyone knows it.</h2>
<p>Engineers don&apos;t want to write it. Managers hate chasing it. Clients never see it. The same issues get solved from scratch &mdash; every time.</p>
</div>
<div className="landing-problem-grid">
<ProblemCard icon="&#9201;" color="red" title="15&ndash;25 min lost per ticket" description="More time documenting than resolving. After a complex issue, writing notes is the last thing anyone does." />
<ProblemCard icon="&#128203;" color="amber" title="Vague, useless notes" description={`"Fixed Outlook" tells no one anything. Notes under pressure are always too vague to help next time.`} />
<ProblemCard icon="&#128260;" color="slate" title="Knowledge walks out the door" description="When a senior engineer leaves, years of tribal knowledge vanish overnight." />
<ProblemCard icon="&#129504;" color="violet" title="Context switching kills speed" description="Jumping between the issue, docs, PSA tickets, and knowledge bases fragments focus." />
<p>Engineers don&apos;t want to write it. Managers hate chasing it. Clients never see it. The same issues get solved from scratch, every time.</p>
</div>
<ol className="landing-problem-list">
<li className="landing-problem-item">
<span className="landing-problem-num">01</span>
<div className="landing-problem-body">
<h3>15&ndash;25 min lost per ticket</h3>
<p>More time documenting than resolving. After a complex issue, writing notes is the last thing anyone does.</p>
</div>
</li>
<li className="landing-problem-item">
<span className="landing-problem-num">02</span>
<div className="landing-problem-body">
<h3>Vague, useless notes</h3>
<p>&ldquo;Fixed Outlook&rdquo; tells no one anything. Notes under pressure are always too vague to help next time.</p>
</div>
</li>
<li className="landing-problem-item">
<span className="landing-problem-num">03</span>
<div className="landing-problem-body">
<h3>Knowledge walks out the door</h3>
<p>When a senior engineer leaves, years of tribal knowledge vanish overnight.</p>
</div>
</li>
<li className="landing-problem-item">
<span className="landing-problem-num">04</span>
<div className="landing-problem-body">
<h3>Context switching kills speed</h3>
<p>Jumping between the issue, docs, PSA tickets, and knowledge bases fragments focus.</p>
</div>
</li>
</ol>
</div>
</div>
</section>
{/* Equation */}
{/* Equation — typographic moment */}
<div className="landing-equation-section landing-reveal">
<div className="landing-equation-inner">
<div className="landing-section-label">The Answer</div>
<div className="landing-brand-equation">
<span className="landing-eq-item">Resolution</span>
<span className="landing-eq-operator">+</span>
<span className="landing-eq-item">Documentation</span>
<span className="landing-eq-operator">&minus;</span>
<span className="landing-eq-item">Time</span>
<span className="landing-eq-operator">=</span>
<span className="landing-eq-result">ResolutionFlow</span>
<div className="landing-brand-equation" aria-label="Resolution plus documentation minus time equals ResolutionFlow">
<div className="landing-eq-lhs">
<span className="landing-eq-item">Resolution</span>
<span className="landing-eq-operator">+</span>
<span className="landing-eq-item">Documentation</span>
<span className="landing-eq-operator">&minus;</span>
<span className="landing-eq-item">Time</span>
</div>
<div className="landing-eq-equals">
<span className="landing-eq-operator-equals">=</span>
</div>
<div className="landing-eq-result">ResolutionFlow</div>
</div>
<p className="landing-equation-desc">
What if documentation was a <em>byproduct</em> of solving the issue &mdash; not a separate task?
What if documentation was a <em>byproduct</em> of solving the issue, not a separate task?
</p>
</div>
</div>
{/* How It Works — zigzag */}
<section id="how-it-works" className="landing-section landing-reveal">
<section id="how-it-works" className="landing-section landing-reveal landing-section-tight">
<div className="landing-section-inner">
<div className="landing-section-label">How It Works</div>
<h2 className="landing-section-title">Three steps. Zero note-writing.</h2>
@@ -268,54 +296,47 @@ export default function LandingPage() {
</div>
</section>
{/* Features */}
<section id="features" className="landing-section landing-section-alt landing-reveal">
{/* Features — editorial spec list */}
<section id="features" className="landing-section landing-section-alt landing-reveal landing-section-generous">
<div className="landing-section-inner">
<div className="landing-section-label">Features</div>
<h2 className="landing-section-title">Everything you need to troubleshoot faster.</h2>
<div className="landing-feature-highlight">
<div className="landing-feature-highlight-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3" /><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" /></svg>
</div>
<div className="landing-feature-highlight-marker" aria-hidden="true">FP</div>
<div className="landing-feature-highlight-content">
<h3>FlowPilot &mdash; Your AI Copilot</h3>
<p>Like having a senior engineer on every call. Describe the issue, get expert troubleshooting guidance, and documentation writes itself &mdash; as a byproduct of solving the problem.</p>
<h3>FlowPilot, your AI copilot</h3>
<p>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.</p>
</div>
</div>
<div className="landing-features-grid">
<FeatureCard
icon={<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" /><line x1="9" y1="3" x2="9" y2="21" /></svg>}
title="Guided Flows"
description="Build step-by-step troubleshooting paths your team can follow. Great for onboarding and consistency."
/>
<FeatureCard
icon={<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><polyline points="14 2 14 8 20 8" /><line x1="16" y1="13" x2="8" y2="13" /><line x1="16" y1="17" x2="8" y2="17" /></svg>}
title="Zero Empty Tickets"
description="Every session generates timestamped notes, formatted for your PSA. No more empty ticket closures."
/>
<FeatureCard
icon={<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" /><path d="M23 21v-2a4 4 0 0 0-3-3.87" /><path d="M16 3.13a4 4 0 0 1 0 7.75" /></svg>}
title="Team Knowledge"
description="Solutions are saved and surfaced when the next engineer hits a similar issue."
/>
<FeatureCard
icon={<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12" /></svg>}
title="Session Analytics"
description="Track resolution times, identify recurring issues, and measure team performance."
/>
<FeatureCard
icon={<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2" /><line x1="8" y1="21" x2="16" y2="21" /><line x1="12" y1="17" x2="12" y2="21" /></svg>}
title="PSA Integration"
description="Connect to ConnectWise, Atera, and Syncro. Push session docs straight to tickets."
/>
</div>
<dl className="landing-feature-spec">
<div className="landing-feature-row">
<dt>Guided Flows</dt>
<dd>Build step-by-step troubleshooting paths your team can follow. Great for onboarding and consistency.</dd>
</div>
<div className="landing-feature-row">
<dt>Zero Empty Tickets</dt>
<dd>Every session generates timestamped notes, formatted for your PSA. No more empty ticket closures.</dd>
</div>
<div className="landing-feature-row">
<dt>Team Knowledge</dt>
<dd>Solutions are saved and surfaced when the next engineer hits a similar issue.</dd>
</div>
<div className="landing-feature-row">
<dt>Session Analytics</dt>
<dd>Track resolution times, identify recurring issues, and measure team performance.</dd>
</div>
<div className="landing-feature-row">
<dt>PSA Integration</dt>
<dd>Connect to ConnectWise, Atera, and Syncro. Push session docs straight to tickets.</dd>
</div>
</dl>
</div>
</section>
{/* Pricing */}
<section id="pricing" className="landing-section landing-reveal">
<section id="pricing" className="landing-section landing-reveal landing-section-generous">
<div className="landing-section-inner">
<div className="landing-section-label">Pricing</div>
<h2 className="landing-section-title">Simple pricing. No surprises.</h2>
@@ -364,7 +385,7 @@ export default function LandingPage() {
</section>
{/* FAQ */}
<section id="faq" className="landing-section landing-section-alt landing-reveal">
<section id="faq" className="landing-section landing-section-alt landing-reveal landing-section-tight">
<div className="landing-section-inner">
<div className="landing-section-label">FAQ</div>
<h2 className="landing-section-title">Common questions</h2>
@@ -399,15 +420,16 @@ export default function LandingPage() {
</div>
</div>
{/* CTA */}
<section className="landing-cta-section landing-reveal">
{/* CTA — drenched */}
<section className="landing-cta-section landing-cta-drench landing-reveal">
<div className="landing-cta-inner">
<h2>Ready to stop writing ticket notes?</h2>
<p>Get early access. Troubleshoot your next ticket with FlowPilot.</p>
<div className="landing-cta-eyebrow">Stop writing ticket notes</div>
<h2>Troubleshoot your next ticket with FlowPilot.</h2>
<p>Get early access. Free to start, no credit card.</p>
<div className="landing-cta-actions">
<Link to="/register?from=beta" className="landing-btn-hero-primary">Get started</Link>
<Link to="/register?from=beta" className="landing-btn-cta-invert">Get started</Link>
<a href="#how-it-works" className="landing-btn-cta-ghost">See how it works</a>
</div>
<p className="landing-cta-fine-print">Free to start. No credit card required.</p>
</div>
</section>
@@ -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 (
<div className="landing-problem-card">
<div className={`landing-problem-icon ${color}`}>{icon}</div>
<h3>{title}</h3>
<p>{description}</p>
</div>
)
}
function FeatureCard({ icon, title, description }: {
icon: React.ReactNode; title: string; description: string
}) {
return (
<div className="landing-feature-card">
<div className="landing-feature-icon">{icon}</div>
<h3>{title}</h3>
<p>{description}</p>
</div>
)
}
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

View File

@@ -0,0 +1,162 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { PageMeta } from '@/components/common/PageMeta'
import { useAuthStore } from '@/store/authStore'
import { l1Api } from '@/api/l1'
import { EmptyStateCard } from '@/components/l1/EmptyStateCard'
import { ResumeInProgress } from '@/components/l1/ResumeInProgress'
import type { QueueRow } from '@/types/l1'
export default function L1Dashboard() {
const user = useAuthStore((s) => s.user)
const navigate = useNavigate()
const [problem, setProblem] = useState('')
const [customerName, setCustomerName] = useState('')
const [customerContact, setCustomerContact] = useState('')
const [submitting, setSubmitting] = useState(false)
const [queue, setQueue] = useState<QueueRow[]>([])
const [isEmpty, setIsEmpty] = useState(false)
useEffect(() => {
l1Api.queue('open').then(setQueue).catch(() => setQueue([]))
// Phase 1: emptiness detection is just "is the queue empty AND no resumable sessions" —
// we conservatively show the empty-state card on accounts with literally no L1 activity yet.
// (A stricter KB-empty detection arrives in Phase 2 when the kb_documents table exists.)
}, [])
useEffect(() => {
// Show empty-state ONLY for first-run state — no queue items and no active sessions
if (queue.length === 0) {
l1Api
.listActiveSessions()
.then((active) => setIsEmpty(active.length === 0))
.catch(() => setIsEmpty(false))
} else {
setIsEmpty(false)
}
}, [queue])
const handleStart = async () => {
if (!problem.trim()) return
setSubmitting(true)
try {
const response = await l1Api.intake({
problem_statement: problem.trim(),
customer_name: customerName.trim() || undefined,
customer_contact: customerContact.trim() || undefined,
})
navigate(`/l1/walk/${response.session_id}`)
} finally {
setSubmitting(false)
}
}
const now = new Date()
const greeting =
now.getHours() < 12 ? 'morning' : now.getHours() < 18 ? 'afternoon' : 'evening'
const firstName = user?.name?.split(' ')[0] || 'there'
return (
<div className="overflow-y-auto h-full">
<PageMeta title="L1 Workspace" />
<div className="max-w-4xl mx-auto px-6 pt-12 pb-12 space-y-8">
{/* Greeting */}
<div>
<p className="font-sans text-xs uppercase tracking-[0.12em] text-muted-foreground mb-1">
{now.toLocaleDateString('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric',
})}
</p>
<h1 className="font-heading text-3xl sm:text-4xl font-extrabold tracking-tight text-heading leading-tight">
Good {greeting}, {firstName}.
</h1>
</div>
{/* Empty state (first-run) */}
{isEmpty && <EmptyStateCard />}
{/* Describe the problem */}
<section>
<div className="flex items-center gap-3 mb-3">
<span className="w-1 h-4 bg-accent rounded-sm" />
<span className="font-sans text-[0.625rem] uppercase tracking-[0.12em] font-semibold text-muted-foreground">
Describe the problem
</span>
</div>
<div className="rounded-lg border border-default bg-card p-4 space-y-3">
<textarea
value={problem}
onChange={(e) => setProblem(e.target.value)}
placeholder="What's the user calling about?"
autoFocus
rows={3}
className="w-full bg-page border border-default rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent/40"
/>
<div className="grid grid-cols-2 gap-3">
<input
value={customerName}
onChange={(e) => setCustomerName(e.target.value)}
placeholder="Customer name (optional)"
className="bg-page border border-default rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent/40"
/>
<input
value={customerContact}
onChange={(e) => setCustomerContact(e.target.value)}
placeholder="Email or phone (optional)"
className="bg-page border border-default rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent/40"
/>
</div>
<div className="flex justify-end">
<button
type="button"
onClick={handleStart}
disabled={!problem.trim() || submitting}
className="rounded-md bg-accent text-white px-5 py-2 text-sm font-medium hover:bg-accent/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{submitting ? 'Starting…' : 'Start walk →'}
</button>
</div>
</div>
</section>
{/* Open tickets */}
{queue.length > 0 && (
<section>
<div className="flex items-center gap-3 mb-3">
<span className="font-sans text-[0.625rem] uppercase tracking-[0.12em] font-semibold text-muted-foreground">
Open tickets · {queue.length}
</span>
<div className="flex-1 h-px bg-border" />
</div>
<div className="rounded-lg border border-default bg-card overflow-hidden">
{queue.map((row) => (
/* Phase 1: display-only rows. Phase 2 makes them clickable to claim. */
<div
key={row.ticket_id}
className="px-4 py-3 border-b border-default last:border-b-0"
>
<div className="flex items-center justify-between">
<div>
<span className="font-mono text-xs text-muted-foreground mr-2">
#{row.ticket_id.slice(0, 8)}
</span>
<span className="text-sm">{row.problem_statement}</span>
</div>
<span className="text-xs px-2 py-0.5 rounded bg-elevated text-muted-foreground">
{row.ticket_kind === 'psa' ? 'PSA' : 'Internal'}
</span>
</div>
</div>
))}
</div>
</section>
)}
{/* Resume in progress */}
<ResumeInProgress />
</div>
</div>
)
}

View File

@@ -0,0 +1,15 @@
import { PageMeta } from '@/components/common/PageMeta'
export default function L1DraftsPage() {
return (
<div className="overflow-y-auto h-full">
<PageMeta title="My Drafts" />
<div className="max-w-4xl mx-auto px-6 pt-12 pb-12">
<h1 className="font-heading text-2xl font-bold mb-2">My AI drafts</h1>
<p className="text-muted-foreground">
AI-built drafts you've created will show here once AI build is enabled (Phase 2).
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,60 @@
import { useEffect, useState } from 'react'
import { PageMeta } from '@/components/common/PageMeta'
import { l1Api } from '@/api/l1'
import type { QueueRow } from '@/types/l1'
export default function L1TicketsPage() {
const [rows, setRows] = useState<QueueRow[]>([])
const [statusFilter, setStatusFilter] = useState<string>('')
useEffect(() => {
l1Api.queue(statusFilter || undefined).then(setRows).catch(() => setRows([]))
}, [statusFilter])
return (
<div className="overflow-y-auto h-full">
<PageMeta title="Tickets" />
<div className="max-w-5xl mx-auto px-6 pt-12 pb-12">
<div className="flex items-center justify-between mb-6">
<h1 className="font-heading text-2xl font-bold">Tickets</h1>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="bg-card border border-default rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent/40"
>
<option value="">All</option>
<option value="open">Open</option>
<option value="walking">Walking</option>
<option value="resolved">Resolved</option>
<option value="escalated">Escalated</option>
</select>
</div>
<div className="rounded-lg border border-default bg-card overflow-hidden">
{rows.map((r) => (
<div key={r.ticket_id} className="px-4 py-3 border-b border-default last:border-b-0">
<div className="flex items-center justify-between">
<div>
<span className="font-mono text-xs text-muted-foreground mr-2">
#{r.ticket_id.slice(0, 8)}
</span>
<span className="text-sm">{r.problem_statement}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs px-2 py-0.5 rounded bg-elevated text-muted-foreground">
{r.status}
</span>
<span className="text-xs text-muted-foreground">
{r.ticket_kind === 'psa' ? 'PSA' : 'Internal'}
</span>
</div>
</div>
</div>
))}
{rows.length === 0 && (
<p className="px-4 py-8 text-sm text-muted-foreground text-center">No tickets.</p>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,70 @@
import { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { PageMeta } from '@/components/common/PageMeta'
import { l1Api } from '@/api/l1'
import { L1WalkTreeVariant } from '@/components/l1/L1WalkTreeVariant'
import { L1WalkAdhocVariant } from '@/components/l1/L1WalkAdhocVariant'
import type { WalkSession } from '@/types/l1'
export default function L1WalkPage() {
const { sessionId } = useParams<{ sessionId: string }>()
const navigate = useNavigate()
const [session, setSession] = useState<WalkSession | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!sessionId) return
l1Api.getSession(sessionId)
.then(setSession)
.catch((err) => {
const msg = err?.response?.data?.detail || err?.message || 'Failed to load session'
setError(typeof msg === 'string' ? msg : 'Failed to load session')
})
}, [sessionId])
if (error) {
return (
<div className="overflow-y-auto h-full">
<PageMeta title="L1 Walk" />
<div className="max-w-4xl mx-auto px-6 pt-12 text-muted-foreground">
{error}
</div>
</div>
)
}
if (!session) {
return (
<div className="overflow-y-auto h-full">
<PageMeta title="L1 Walk" />
<div className="max-w-4xl mx-auto px-6 pt-12 text-muted-foreground">Loading</div>
</div>
)
}
const handleDone = () => navigate('/l1')
// Phase 1: adhoc variant handles session_kind='adhoc'. Tree variant handles flow/proposal.
if (session.session_kind === 'adhoc') {
return (
<>
<PageMeta title="L1 Walk" />
<L1WalkAdhocVariant
session={session}
onSessionUpdate={setSession}
onDone={handleDone}
/>
</>
)
}
return (
<>
<PageMeta title="L1 Walk" />
<L1WalkTreeVariant
session={session}
onSessionUpdate={setSession}
onDone={handleDone}
/>
</>
)
}

View File

@@ -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 { L1RouteGuard } from '@/components/layout/L1RouteGuard'
const sentryCreateBrowserRouter = Sentry.wrapCreateBrowserRouterV7(createBrowserRouter)
import {
@@ -95,6 +96,12 @@ const AdminSurveyInvitesPage = lazyWithRetry(() => import('@/pages/admin/SurveyI
const AdminSurveyResponsesPage = lazyWithRetry(() => import('@/pages/admin/SurveyResponsesPage'))
const AdminGalleryManagementPage = lazyWithRetry(() => import('@/pages/admin/GalleryManagementPage'))
// L1 workspace pages
const L1Dashboard = lazyWithRetry(() => import('@/pages/l1/L1Dashboard'))
const L1WalkPage = lazyWithRetry(() => import('@/pages/l1/L1WalkPage'))
const L1DraftsPage = lazyWithRetry(() => import('@/pages/l1/L1DraftsPage'))
const L1TicketsPage = lazyWithRetry(() => import('@/pages/l1/L1TicketsPage'))
// Account pages
const AccountLayout = lazyWithRetry(() => import('@/components/account/AccountLayout'))
const ProfileSettingsPage = lazyWithRetry(() => import('@/pages/account/ProfileSettingsPage'))
@@ -284,6 +291,11 @@ export const router = sentryCreateBrowserRouter([
{ 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: <L1RouteGuard>{page(L1Dashboard)}</L1RouteGuard> },
{ path: 'l1/walk/:sessionId', element: <L1RouteGuard>{page(L1WalkPage)}</L1RouteGuard> },
{ path: 'l1/drafts', element: <L1RouteGuard>{page(L1DraftsPage)}</L1RouteGuard> },
{ path: 'l1/tickets', element: <L1RouteGuard>{page(L1TicketsPage)}</L1RouteGuard> },
// Admin routes
{
path: 'admin',

View File

@@ -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 {

52
frontend/src/types/l1.ts Normal file
View File

@@ -0,0 +1,52 @@
export type SessionKind = 'flow' | 'proposal' | 'adhoc'
export type SessionStatus = 'active' | 'resolved' | 'escalated' | 'abandoned'
export type TicketKind = 'psa' | 'internal'
export interface WalkStep {
node_id: string
question: string
answer: string
l1_note: string | null
}
export interface AdhocNote {
timestamp: string
content: string
}
export interface WalkSession {
id: string
session_kind: SessionKind
flow_id: string | null
flow_proposal_id: string | null
current_node_id: string | null
walked_path: WalkStep[]
walk_notes: AdhocNote[]
status: SessionStatus
started_at: string
last_step_at: string
resolved_at: string | null
}
export interface QueueRow {
ticket_id: string
ticket_kind: TicketKind
problem_statement: string | null
customer_name: string | null
status: string
created_at: string | null
}
export interface IntakeRequest {
problem_statement: string
customer_name?: string
customer_contact?: string
flow_id?: string
}
export interface IntakeResponse {
session_id: string
session_kind: SessionKind
ticket_id: string
ticket_kind: TicketKind
}

View File

@@ -9,7 +9,8 @@ export interface User {
is_active: boolean
must_change_password: boolean
account_id: string | null
account_role: 'owner' | 'engineer' | 'viewer' | null
account_role: 'owner' | 'engineer' | 'l1_tech' | 'viewer' | null
can_cover_l1: boolean
team_id: string | null
created_at: string
last_login: string | null