Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: CI

on:
pull_request:
push:
branches: [main]

jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
# Match the local dev / lockfile npm version — node 22's older npm
# resolves the SWC plugin's transitive @swc/helpers differently and
# rejects the lockfile under `npm ci`.
node-version: 24
cache: npm
- run: npm ci

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Regenerate the lockfile before running npm ci

In the new CI workflow, this step fails on a clean checkout: npm ci is npm's clean-install command, and running it in this repo exits with EUSAGE because package-lock.json is not in sync (Missing: @swc/helpers@ from lock file, caused by the new @vitejs/plugin-react-swc/@swc/core peer). As written, every PR/push hits this line and stops before lint/typecheck/tests; please regenerate and commit the lockfile so npm ci can install.

Useful? React with 👍 / 👎.

- run: npm run lint
- run: npm run typecheck
- run: npm test
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ tsconfig.tsbuildinfo
# debug
npm-debug.log*

# test artifacts
coverage/
playwright-report/
test-results/
.playwright/

# vercel
.vercel
node_modules
Expand Down
12 changes: 12 additions & 0 deletions components/ui/badge.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Steel-Tech / StructuPath
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { Badge } from "@/components/ui/badge";

describe("Badge", () => {
it("renders its children", () => {
render(<Badge>Active</Badge>);
expect(screen.getByText("Active")).toBeInTheDocument();
});
});
29 changes: 29 additions & 0 deletions components/wizard/legal-disclaimer.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Steel-Tech / StructuPath
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { LegalDisclaimer } from "@/components/wizard/legal-disclaimer";

describe("LegalDisclaimer", () => {
it("always shows the not-legal-advice notice", () => {
render(<LegalDisclaimer stateCode="WA" />);
expect(screen.getByRole("note")).toHaveTextContent(/not legal advice/i);
});

it("shows the last-verified date for a verified state", () => {
render(<LegalDisclaimer stateCode="WA" />);
expect(screen.getByRole("note")).toHaveTextContent(/last verified/i);
});

it("flags a machine-generated state as not independently verified", () => {
render(<LegalDisclaimer stateCode="AL" />);
const note = screen.getByRole("note");
expect(note).toHaveTextContent(/not independently verified/i);
expect(note).not.toHaveTextContent(/last verified/i);
});

it("renders a generic notice when no state is known", () => {
render(<LegalDisclaimer stateCode={null} />);
expect(screen.getByRole("note")).toHaveTextContent(/not legal advice/i);
});
});
86 changes: 86 additions & 0 deletions components/wizard/legal-disclaimer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"use client";

// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Steel-Tech / StructuPath
import { ShieldAlert } from "lucide-react";
import { getStateVerification } from "@/content/state-registry";

interface LegalDisclaimerProps {
/** Two-letter state code from the user's profile, if known. */
stateCode?: string | null;
}

/** Format an ISO date (YYYY-MM-DD) without UTC timezone drift. */
function formatVerifiedDate(iso?: string): string {
if (!iso) return "";
const [y, m, d] = iso.split("-").map(Number);
if (!y || !m || !d) return iso;
return new Date(y, m - 1, d).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
}

/**
* Persistent, non-dismissable legal notice shown on state-facing content.
* Surfaces whether the state's regulatory data was human-verified (with the
* date) or is machine-generated and unverified.
*/
export function LegalDisclaimer({ stateCode }: LegalDisclaimerProps) {
const v = stateCode ? getStateVerification(stateCode) : null;
const agency = v?.stateName ?? "your state";

return (
<div
role="note"
aria-label="Legal disclaimer"
className="flex items-start gap-2.5 rounded-lg border border-neon-amber/25 bg-neon-amber/5 px-3.5 py-2.5 text-xs leading-relaxed"
>
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0 text-neon-amber" />
<div className="space-y-1 text-text-secondary">
<p>
<span className="font-semibold text-neon-amber">Not legal advice.</span>{" "}
Fees, rules, and links change — verify with the official {agency}{" "}
agency before acting.
</p>
{v &&
(v.verified ? (
<p className="text-text-muted">
Last verified {formatVerifiedDate(v.lastVerified)}.
{v.sourceUrl && (
<>
{" "}
<a
href={v.sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="text-neon-blue hover:underline"
>
Source
</a>
</>
)}
</p>
) : (
<p className="text-text-muted">
Auto-generated for {v.stateName} — not independently verified.
{v.sourceUrl && (
<>
{" "}
<a
href={v.sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="text-neon-blue hover:underline"
>
Confirm with the state agency
</a>
</>
)}
</p>
))}
</div>
</div>
);
}
2 changes: 2 additions & 0 deletions components/wizard/step-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Checklist } from "./checklist";
import { CostCard } from "./cost-card";
import { ResourceLink } from "./resource-link";
import { CoachTip } from "./coach-tip";
import { LegalDisclaimer } from "./legal-disclaimer";
import {
ChevronLeft,
ChevronRight,
Expand Down Expand Up @@ -55,6 +56,7 @@ export function StepContent({
<div className="flex-1 overflow-y-auto scrollbar-thin bg-cyber-black">
<div className="max-w-3xl mx-auto p-6 md:p-8 space-y-8">
{headerExtra}
<LegalDisclaimer stateCode={profile?.state} />
{/* Header */}
<div className="space-y-3 animate-fade-in-up">
<div className="text-xs text-neon-cyan font-mono tracking-widest uppercase">
Expand Down
133 changes: 133 additions & 0 deletions content/phases.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Steel-Tech / StructuPath
import { describe, it, expect } from "vitest";
import {
getPhaseContent,
getStepByIds,
getNextStep,
getPrevStep,
PHASE_DEFINITIONS,
type StateCode,
} from "@/content/phases";
import { STATE_REGISTRY } from "@/content/state-registry";
import type { Phase, Step } from "@/lib/types/content";

const PHASE_IDS = PHASE_DEFINITIONS.map((p) => p.id);
const STATE_CODES = Object.keys(STATE_REGISTRY) as StateCode[];

function assertValidStep(step: Step) {
expect(typeof step.id).toBe("string");
expect(step.id.length).toBeGreaterThan(0);
expect(typeof step.title).toBe("string");
expect(step.title.length).toBeGreaterThan(0);
expect(Array.isArray(step.checklist)).toBe(true);
expect(Array.isArray(step.resources)).toBe(true);
expect(Array.isArray(step.tips)).toBe(true);
expect(Array.isArray(step.warnings)).toBe(true);
expect(step.estimatedCost).toBeTypeOf("object");
expect(step.estimatedCost.min).toBeTypeOf("number");
expect(step.estimatedCost.max).toBeTypeOf("number");
expect(step.aiContext).toBeTypeOf("string");
}

function assertValidPhase(phase: Phase) {
expect(phase.id).toBeTypeOf("string");
expect(phase.title.length).toBeGreaterThan(0);
expect(Array.isArray(phase.steps)).toBe(true);
expect(phase.steps.length).toBeGreaterThan(0);
for (const step of phase.steps) assertValidStep(step);
}

describe("getPhaseContent — content contract", () => {
it("covers exactly the 50 states", () => {
expect(STATE_CODES.length).toBe(50);
});

it("resolves valid, non-empty content for every state × phase", () => {
for (const state of STATE_CODES) {
for (const phaseId of PHASE_IDS) {
assertValidPhase(getPhaseContent(phaseId, state));
}
}
});

it("serves generated (not Washington) content for a non-WA state", () => {
const wa = getPhaseContent("business-formation", "WA");
const tx = getPhaseContent("business-formation", "TX");
expect(JSON.stringify(tx)).not.toBe(JSON.stringify(wa));
});

it("returns a labeled 'unavailable' phase — never Washington's — for an unknown code", () => {
const unknown = getPhaseContent("business-formation", "ZZ" as StateCode);
const wa = getPhaseContent("business-formation", "WA");
expect(unknown.steps[0].id).toBe("data-unavailable");
expect(unknown.steps[0].title).toContain("ZZ");
expect(JSON.stringify(unknown)).not.toBe(JSON.stringify(wa));
});

it("returns a well-formed (non-empty, valid) phase even for an unknown code", () => {
assertValidPhase(getPhaseContent("contractor-licensing", "ZZ" as StateCode));
});

it("lets the chat-route step lookup find a phase's first step (parity)", () => {
const state: StateCode = "CO";
const phase = getPhaseContent("contractor-licensing", state);
const firstId = phase.steps[0].id;
const found = getStepByIds("contractor-licensing", firstId, state);
expect(found).not.toBeNull();
expect(found?.id).toBe(firstId);
});
});

describe("getPhaseContent — unavailable + static phases", () => {
it("returns a valid merged phase carrying the unavailable step for an unknown code", () => {
const phase = getPhaseContent("surety-bonding", "ZZ" as StateCode);
assertValidPhase(phase);
expect(phase.steps.some((s) => s.id === "data-unavailable")).toBe(true);
});

it("returns non-empty legal-federal content for any state", () => {
assertValidPhase(getPhaseContent("legal-federal", "TX"));
});

it("returns a defined, empty-step phase for an unknown phase id (no throw)", () => {
const phase = getPhaseContent("does-not-exist", "TX");
expect(phase).toBeDefined();
expect(phase.steps).toEqual([]);
});
});

describe("wizard navigation (getNextStep / getPrevStep)", () => {
const state: StateCode = "TX";

it("advances to the next step within a phase", () => {
const firstPhase = PHASE_DEFINITIONS[0].id;
const steps = getPhaseContent(firstPhase, state).steps;
expect(steps.length).toBeGreaterThanOrEqual(2);
expect(getNextStep(firstPhase, steps[0].id, state)).toEqual({
phaseId: firstPhase,
stepId: steps[1].id,
});
});

it("crosses to the next phase from the last step of a phase", () => {
const firstPhase = PHASE_DEFINITIONS[0].id;
const secondPhase = PHASE_DEFINITIONS[1].id;
const steps = getPhaseContent(firstPhase, state).steps;
const next = getNextStep(firstPhase, steps[steps.length - 1].id, state);
expect(next?.phaseId).toBe(secondPhase);
expect(next?.stepId).toBe(getPhaseContent(secondPhase, state).steps[0].id);
});

it("returns null past the last step of the last phase", () => {
const lastPhase = PHASE_DEFINITIONS[PHASE_DEFINITIONS.length - 1].id;
const steps = getPhaseContent(lastPhase, state).steps;
expect(getNextStep(lastPhase, steps[steps.length - 1].id, state)).toBeNull();
});

it("returns null before the first step of the first phase", () => {
const firstPhase = PHASE_DEFINITIONS[0].id;
const firstId = getPhaseContent(firstPhase, state).steps[0].id;
expect(getPrevStep(firstPhase, firstId, state)).toBeNull();
});
});
Loading
Loading