diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 0463620..1127b46 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -640,3 +640,47 @@ scenarios, demo manifest, About page). Gap audit: `docs/demo-release/current-dem the server demo-ready. - Exact next action: layer plain-language Dutch explanation onto the return flow, the 5 data-quality panels, and fix the knowledge assistant's RAGcore-naming bug — task #33. + +### Batch 4 — return/data-quality/knowledge demo legibility (complete) + +- **Fixed a real honesty bug**: `Knowledge.tsx` named "RAGcore" in the body copy and the + retrieval-flow diagram even though the active provider is the demo TF-IDF one (the + small badge below was already honest, contradicting the prose one line above). Now + derives a `providerLabel` ("Demo knowledge base" vs "RAGcore") from the real health + check and uses it everywhere; added an explicit disclosure note when not RAGcore. + Added 4 suggested-question chips. **Discovered and fixed a second real bug in the + process**: the brief's suggested Dutch questions (and my own Demo Guide step 6 wording) + would have returned "insufficient evidence" against the demo provider, because the + indexed procedures are English-only — verified empirically (Dutch question → + `insufficient`, its English equivalent → `grounded`). Fixed by keeping suggested + questions in English (matching the indexed content) and rewording the Guide step to + explain the knowledge base is English, rather than mistranslating the demo's + centerpiece feature into silently returning wrong answers. +- Return flow: `BookingDetail.tsx` now detects the one named return-anomaly scenario + booking (via the manifest, not a hardcoded ref) and fetches that vehicle's real + canonical odometer to pre-fill `ReturnForm`'s "End odometer" field with a suspicious + value below it, plus a callout explaining why — the brief explicitly requires the demo + not ask a visitor to invent a suspicious number themselves. Scoped narrowly to that one + scenario booking; ordinary returns are unaffected. `ReturnResultPanel` now links to + Automation and Audit trail (previously only the vehicle), and shows a "Ga verder met de + demo" button when the Demo Guide is open (advances the guide and navigates to the next + step). **Fixed a real regression caught by the existing return-review e2e test**: the + async pre-fill could silently overwrite odometer text a visitor had already started + typing, if the vehicle-detail fetch resolved after they began typing — fixed with an + `odometerEditedByUser` ref guard. +- Data quality: added a shared `RuleExplainer` (what's wrong / why it matters, in plain + language) for all 5 rule types on `DataQualityIssueDetail.tsx`; added a generic + post-resolution confirmation (audit-trail link, vehicle link, "Ga verder met de demo") + for the 4 rule types that previously just silently flipped their status badge with no + explicit confirmation, and extended `VehicleStatusConflictPanel`'s existing confirmation + with the same links rather than duplicating it. Added a "Demo scenario's only" checkbox + filter on `DataQuality.tsx` (client-side `public_ref.startsWith("DQ-DEMO-")`, no new + business logic) so the curated issues are easy to find among the full queue. +- Evidence: frontend `tsc -b` clean, `npm run build` clean; full Playwright suite + **51 passed** (47 existing + 4 new `demo-legibility.spec.ts`: return pre-fill + why- + suspicious explanation + result links, rule explainer visible, demo-scenario filter + narrows correctly, knowledge suggested question returns grounded evidence with the + correct provider label). Backend untouched this batch. +- Exact next action: plain-language integration-status labels, richer audit narration, + the full "Over deze demo" page content (currently a first pass from Batch 2), and + wiring reset into the guide/About/OM menu narrative — task #34. diff --git a/frontend/e2e/demo-legibility.spec.ts b/frontend/e2e/demo-legibility.spec.ts new file mode 100644 index 0000000..fb9f16d --- /dev/null +++ b/frontend/e2e/demo-legibility.spec.ts @@ -0,0 +1,77 @@ +import { expect, test, type APIRequestContext } from "@playwright/test"; + +async function resetDemoData(request: APIRequestContext) { + const login = await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } }); + expect(login.ok()).toBeTruthy(); + const reset = await request.post("/api/v1/demo/reset"); + expect(reset.ok()).toBeTruthy(); +} + +test.describe.configure({ mode: "serial" }); + +test("return flow pre-fills the suspicious odometer reading and explains why", async ({ page, request }) => { + await resetDemoData(request); + await page.goto("/login"); + await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); + await expect(page).toHaveURL(/\/dashboard$/); + await page.goto("/bookings/BK-DEMO-RETURN"); + + await expect(page.getByText("Demonstratiescenario: afwijkende kilometerstand")).toBeVisible(); + const odometerInput = page.getByLabel("End odometer (km)"); + await expect(odometerInput).not.toHaveValue(""); + const prefilled = Number(await odometerInput.inputValue()); + expect(prefilled).toBeGreaterThan(0); + + await page.getByRole("button", { name: "Review return" }).click(); + await expect(page.getByText(/below.*canonical reading/)).toBeVisible(); + await page.getByRole("button", { name: "Confirm return" }).click(); + await expect(page.getByRole("heading", { name: "Return registered" })).toBeVisible(); + await expect(page.getByRole("link", { name: "View automation status" })).toBeVisible(); + await expect(page.getByRole("link", { name: "View audit trail" })).toBeVisible(); +}); + +test("data quality issue detail explains what's wrong and why it matters", async ({ page }) => { + await page.goto("/login"); + await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); + await expect(page).toHaveURL(/\/dashboard$/); + await page.goto("/data-quality/DQ-DEMO-DUPLICATE"); + + await expect(page.getByText("What's wrong")).toBeVisible(); + await expect(page.getByText("Why it matters")).toBeVisible(); + await expect(page.getByText(/likely the same person/)).toBeVisible(); +}); + +test("data quality list can filter to demo scenarios only", async ({ page }) => { + await page.goto("/login"); + await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); + await expect(page).toHaveURL(/\/dashboard$/); + await page.goto("/data-quality"); + + await expect(page.locator(".data-table tbody tr").first()).toBeVisible(); + const allRows = await page.locator(".data-table tbody tr").count(); + await page.getByRole("checkbox", { name: "Demo scenario's only" }).check(); + const filteredRows = await page.locator(".data-table tbody tr").count(); + expect(filteredRows).toBeGreaterThan(0); + expect(filteredRows).toBeLessThanOrEqual(allRows); + const refs = await page.locator(".data-table tbody tr th a").allTextContents(); + for (const ref of refs) { + expect(ref.startsWith("DQ-DEMO-")).toBeTruthy(); + } +}); + +test("knowledge page suggested question returns a grounded, honestly-labelled answer", async ({ page }) => { + await page.goto("/login"); + await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); + await expect(page).toHaveURL(/\/dashboard$/); + await page.goto("/knowledge"); + + // The status badge and retrieval-flow diagram must name the actual active provider + // honestly, not the not-yet-connected "RAGcore" -- the honest disclosure note below is + // allowed to mention RAGcore by name when explaining it isn't live yet. + await expect(page.locator(".knowledge-status strong")).toHaveText("Demo knowledge base"); + await expect(page.locator(".retrieval-flow")).toContainText("Demo knowledge base"); + await expect(page.locator(".knowledge-status")).not.toContainText("RAGcore"); + + await page.getByRole("button", { name: "Who reviews an unusual odometer reading?" }).click(); + await expect(page.getByText("Grounded in cited procedures")).toBeVisible(); +}); diff --git a/frontend/src/components/ReturnForm.tsx b/frontend/src/components/ReturnForm.tsx index 1ef68bc..3694691 100644 --- a/frontend/src/components/ReturnForm.tsx +++ b/frontend/src/components/ReturnForm.tsx @@ -1,8 +1,11 @@ -import { useState, type FormEvent } from "react"; -import { Link } from "react-router-dom"; +import { useEffect, useRef, useState, type FormEvent } from "react"; +import { Link, useNavigate } from "react-router-dom"; import { api, ApiError } from "../api/client"; import type { RegisterReturnRequest, RegisterReturnResult, ReturnPreviewResult } from "../api/types"; import { useAuth } from "../context/AuthContext"; +import { useDemoGuide } from "../context/DemoGuideContext"; +import { useDemoManifest } from "../context/DemoManifestContext"; +import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps"; import { Icon } from "./Icons"; import { StatusBadge } from "./Badge"; @@ -14,7 +17,17 @@ function newIdempotencyKey(): string { export function ReturnResultPanel({ result }: { result: RegisterReturnResult }) { const { user } = useAuth(); + const navigate = useNavigate(); + const { manifest } = useDemoManifest(); + const { open: guideOpen, currentIndex, completeAndAdvance } = useDemoGuide(); const canSeeQualityIssue = user?.role === "operations_manager"; + + function continueDemo() { + completeAndAdvance(); + const nextIndex = Math.min(currentIndex + 1, DEMO_GUIDE_STEPS.length - 1); + navigate(DEMO_GUIDE_STEPS[nextIndex].route(manifest)); + } + return (

Committed locally

Return registered

@@ -58,9 +71,16 @@ export function ReturnResultPanel({ result }: { result: RegisterReturnResult }) opened for review.

)} -

+

View vehicle {result.vehicle_ref} -

+ View automation status + View audit trail + {guideOpen && ( + + )} +
); } @@ -68,11 +88,14 @@ export function ReturnResultPanel({ result }: { result: RegisterReturnResult }) export function ReturnForm({ bookingRef, onRegistered, + suggestedOdometerKm, }: { bookingRef: string; onRegistered: (result: RegisterReturnResult) => void; + suggestedOdometerKm?: number; }) { - const [odometer, setOdometer] = useState(""); + const [odometer, setOdometer] = useState(suggestedOdometerKm !== undefined ? String(suggestedOdometerKm) : ""); + const odometerEditedByUser = useRef(false); const [fuel, setFuel] = useState("50"); const [cleanlinessOk, setCleanlinessOk] = useState(true); const [damageReported, setDamageReported] = useState(false); @@ -85,6 +108,16 @@ export function ReturnForm({ const [step, setStep] = useState<"capture" | "review">("capture"); const [preview, setPreview] = useState(null); + useEffect(() => { + // suggestedOdometerKm arrives asynchronously (BookingDetail fetches the vehicle's + // canonical odometer after this form has already mounted), so the initial useState + // value alone won't catch it -- sync it once it resolves. Never overwrite a value the + // visitor has already started typing. + if (suggestedOdometerKm !== undefined && !odometerEditedByUser.current) { + setOdometer(String(suggestedOdometerKm)); + } + }, [suggestedOdometerKm]); + function currentBody(): RegisterReturnRequest { return { end_odometer_km: Number(odometer), @@ -151,7 +184,10 @@ export function ReturnForm({ required min={0} value={odometer} - onChange={(e) => setOdometer(e.target.value)} + onChange={(e) => { + odometerEditedByUser.current = true; + setOdometer(e.target.value); + }} /> diff --git a/frontend/src/data/demoGuideSteps.ts b/frontend/src/data/demoGuideSteps.ts index 22f7f45..6ba4664 100644 --- a/frontend/src/data/demoGuideSteps.ts +++ b/frontend/src/data/demoGuideSteps.ts @@ -66,7 +66,10 @@ export const DEMO_GUIDE_STEPS: DemoGuideStep[] = [ title: "6. Stel een vraag aan de procedureassistent", whatYouWillSee: "Een antwoord met bronvermelding uit de afgebakende demokennisbank.", whyItMatters: "Medewerkers moeten snel een onderbouwd antwoord krijgen over procedures, zonder te gokken.", - startAction: 'Stel de voorbeeldvraag "Wat moet ik doen wanneer een voertuig beschadigd terugkomt?".', + startAction: + 'Klik op één van de voorbeeldvragen (bv. "What must I do when a vehicle returns ' + + 'with damage?"). De geïndexeerde procedures zijn Engelstalig, dus gebruik de ' + + "voorgestelde vragen of stel je eigen vraag in het Engels.", expectedOutcome: "Je ziet het antwoord, de gebruikte procedure en de brontekst — of een eerlijk 'onvoldoende informatie' als dat niet aanwezig is.", route: () => "/knowledge", }, diff --git a/frontend/src/pages/BookingDetail.tsx b/frontend/src/pages/BookingDetail.tsx index 4303610..866c8e2 100644 --- a/frontend/src/pages/BookingDetail.tsx +++ b/frontend/src/pages/BookingDetail.tsx @@ -1,7 +1,8 @@ import { useCallback, useEffect, useState } from "react"; import { Link, useParams } from "react-router-dom"; import { api } from "../api/client"; -import type { Booking, RegisterReturnResult } from "../api/types"; +import type { Booking, RegisterReturnResult, VehicleDetail } from "../api/types"; +import { useDemoManifest } from "../context/DemoManifestContext"; import { StatusBadge } from "../components/Badge"; import { ReturnForm, ReturnResultPanel } from "../components/ReturnForm"; import { Icon } from "../components/Icons"; @@ -9,9 +10,11 @@ import { ErrorState, LoadingState, PageHeader } from "../components/PageChrome"; export function BookingDetail() { const { publicRef } = useParams<{ publicRef: string }>(); + const { manifest } = useDemoManifest(); const [booking, setBooking] = useState(null); const [error, setError] = useState(null); const [returnResult, setReturnResult] = useState(null); + const [canonicalOdometerKm, setCanonicalOdometerKm] = useState(null); const load = useCallback(() => { if (!publicRef) return; @@ -28,6 +31,21 @@ export function BookingDetail() { load(); }, [load]); + // The odometer-regression demo scenario supplies its own suspicious reading (per the + // brief: never ask a demo visitor to invent one) -- only fetched for that one known + // scenario booking, not for every return. + const isReturnAnomalyScenario = + manifest?.scenarios.find((s) => s.id === "return-anomaly")?.start_path === `/bookings/${publicRef}`; + + useEffect(() => { + setCanonicalOdometerKm(null); + if (!isReturnAnomalyScenario || !booking) return; + api + .get(`/api/v1/vehicles/${booking.vehicle_ref}`) + .then((vehicle) => setCanonicalOdometerKm(vehicle.odometer_km)) + .catch(() => setCanonicalOdometerKm(null)); + }, [isReturnAnomalyScenario, booking]); + function handleRegistered(result: RegisterReturnResult) { setReturnResult(result); load(); @@ -50,9 +68,28 @@ export function BookingDetail() {
Requirements complete
{booking.requirements_complete ? "Yes" : "No"}
+ {isReturnAnomalyScenario && !returnResult && canonicalOdometerKm !== null && ( +
+ +
+ Demonstratiescenario: afwijkende kilometerstand +

+ Dit voertuig staat momenteel op {canonicalOdometerKm.toLocaleString("en-GB")} km. + Het onderstaande formulier is vooraf ingevuld met een retourstand die daaronder + ligt — een teken van een foutieve invoer of een verwisseld voertuig. Bevestig de + retour om te zien hoe MobilityOps dit detecteert en afhandelt. +

+
+
+ )} + {returnResult && } {!returnResult && booking.status === "active" && ( - + )} ); diff --git a/frontend/src/pages/DataQuality.tsx b/frontend/src/pages/DataQuality.tsx index d85c826..89f8c3e 100644 --- a/frontend/src/pages/DataQuality.tsx +++ b/frontend/src/pages/DataQuality.tsx @@ -24,6 +24,7 @@ export function DataQuality() { const [scanError, setScanError] = useState(null); const [scanResult, setScanResult] = useState(null); const [confirmingScan, setConfirmingScan] = useState(false); + const [demoScenariosOnly, setDemoScenariosOnly] = useState(false); const load = useCallback(() => { if (user?.role !== "operations_manager") return; @@ -67,6 +68,11 @@ export function DataQuality() { } const scanTotal = scanResult ? Object.values(scanResult.created).reduce((a, b) => a + b, 0) : 0; + const visibleIssues = issues + ? demoScenariosOnly + ? issues.filter((i) => i.public_ref.startsWith("DQ-DEMO-")) + : issues + : []; return (
@@ -126,14 +132,25 @@ export function DataQuality() { ))} + {error && } {!error && !issues && } {issues && issues.length === 0 && } + {issues && issues.length > 0 && visibleIssues.length === 0 && ( + + )} - {issues && issues.length > 0 && ( -
{issues.length} issuesEvidence-backed detection
+ {visibleIssues.length > 0 && ( +
{visibleIssues.length} issuesEvidence-backed detection
@@ -145,7 +162,7 @@ export function DataQuality() { - {issues.map((i) => ( + {visibleIssues.map((i) => (
Data-quality issues
{i.public_ref} diff --git a/frontend/src/pages/DataQualityIssueDetail.tsx b/frontend/src/pages/DataQualityIssueDetail.tsx index 5d017d6..d673873 100644 --- a/frontend/src/pages/DataQualityIssueDetail.tsx +++ b/frontend/src/pages/DataQualityIssueDetail.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState, type FormEvent } from "react"; -import { Link, useParams } from "react-router-dom"; +import { Link, useNavigate, useParams } from "react-router-dom"; import { api, ApiError } from "../api/client"; import type { ApplyRecommendedStatusResult, @@ -8,11 +8,62 @@ import type { } from "../api/types"; import { SeverityBadge, StatusBadge } from "../components/Badge"; import { useAuth } from "../context/AuthContext"; +import { useDemoGuide } from "../context/DemoGuideContext"; +import { useDemoManifest } from "../context/DemoManifestContext"; +import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps"; import { Icon } from "../components/Icons"; import { ErrorState, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome"; const MERGE_FIELDS = ["first_name", "last_name", "email", "phone", "postal_code", "city"]; +const RULE_EXPLAINERS: Record = { + possible_duplicate_customer: { + whatIsWrong: + "Two customer profiles share identifying details (email, phone or a very similar name) strongly enough that they are likely the same person, registered twice.", + whyItMatters: + "Duplicate customers split booking history across two records, risk duplicate billing, and confuse support conversations.", + }, + missing_required_field: { + whatIsWrong: + "This record is missing information that's required for normal operation (for example, a customer with neither an email nor a phone number on file).", + whyItMatters: + "Without this data, the business can't reach the customer, or can't reliably identify the vehicle for compliance and hand-off checks.", + }, + odometer_regression: { + whatIsWrong: "A submitted odometer reading is lower than the vehicle's last known (canonical) reading.", + whyItMatters: + "A falling odometer usually means a data-entry mistake or that readings were recorded against the wrong vehicle. Letting it through silently would corrupt maintenance scheduling and resale mileage history.", + }, + booking_overlap: { + whatIsWrong: "The same vehicle is committed to two bookings whose date ranges overlap.", + whyItMatters: + "Only one of these bookings can actually be honoured. Left unresolved, a customer would arrive to find their vehicle already out with someone else.", + }, + vehicle_status_conflict: { + whatIsWrong: + "This vehicle's stored operational status doesn't match what its own booking and inspection history implies it should be.", + whyItMatters: + "An incorrect status can make an unavailable vehicle appear bookable, or keep an available vehicle hidden from the fleet.", + }, +}; + +function RuleExplainer({ ruleType }: { ruleType: string }) { + const explainer = RULE_EXPLAINERS[ruleType]; + if (!explainer) return null; + return ( +
+
+ What's wrong +

{explainer.whatIsWrong}

+
+
+ Why it matters +

{explainer.whyItMatters}

+
+
+ ); +} + function EvidenceDisclosure({ issue }: { issue: IssueDetail }) { return (
@@ -433,11 +484,20 @@ function BookingOverlapPanel({ issue, onResolved }: { issue: IssueDetail; onReso } function VehicleStatusConflictPanel({ issue, onResolved }: { issue: IssueDetail; onResolved: () => void }) { + const navigate = useNavigate(); + const { manifest } = useDemoManifest(); + const { open: guideOpen, currentIndex, completeAndAdvance } = useDemoGuide(); const [error, setError] = useState(null); const [result, setResult] = useState(null); const [submitting, setSubmitting] = useState(false); const [confirming, setConfirming] = useState(false); + function continueDemo() { + completeAndAdvance(); + const nextIndex = Math.min(currentIndex + 1, DEMO_GUIDE_STEPS.length - 1); + navigate(DEMO_GUIDE_STEPS[nextIndex].route(manifest)); + } + // Once resolved through this panel, keep showing the "Applied" confirmation even // after the parent's issue.status flips away from "open" -- reloading on success // updates the page's own status badge immediately, but this panel controls its own @@ -472,9 +532,20 @@ function VehicleStatusConflictPanel({ issue, onResolved }: { issue: IssueDetail;
Current status
{result ? ( -

- Applied — {result.reason} -

+ <> +

+ Applied — {result.reason} +

+
+ View audit trail + View vehicle + {guideOpen && ( + + )} +
+ ) : !confirming ? ( + )} + + + )} + {issue.status === "open" && issue.rule_type === "possible_duplicate_customer" && ( - + )} {issue.status === "open" && issue.rule_type === "missing_required_field" && ( - + )} {issue.status === "open" && issue.rule_type === "odometer_regression" && ( - + )} {issue.status === "open" && issue.rule_type === "booking_overlap" && ( - + )} {issue.rule_type === "vehicle_status_conflict" && ( - + )} {issue.status === "open" && ( diff --git a/frontend/src/pages/Knowledge.tsx b/frontend/src/pages/Knowledge.tsx index e3dcfc3..a4a6d82 100644 --- a/frontend/src/pages/Knowledge.tsx +++ b/frontend/src/pages/Knowledge.tsx @@ -15,6 +15,13 @@ const EVIDENCE_LABEL: Record = { unavailable: "Knowledge service unavailable", }; +const SUGGESTED_QUESTIONS = [ + "What must I do when a vehicle returns with damage?", + "When may a vehicle be made available again?", + "Who reviews an unusual odometer reading?", + "Which checks are required before departure?", +]; + export function Knowledge() { const [status, setStatus] = useState(null); const [question, setQuestion] = useState(""); @@ -29,14 +36,12 @@ export function Knowledge() { .catch(() => setStatus(null)); }, []); - async function handleSubmit(e: FormEvent) { - e.preventDefault(); - if (!question.trim()) return; + async function ask(questionText: string) { setError(null); setSubmitting(true); try { - const answer = await api.post("/api/v1/knowledge/questions", { question }); - setExchanges((prev) => [{ question, answer }, ...prev]); + const answer = await api.post("/api/v1/knowledge/questions", { question: questionText }); + setExchanges((prev) => [{ question: questionText, answer }, ...prev]); setQuestion(""); } catch (err) { setError(err instanceof ApiError ? err.message : "Could not reach the knowledge service."); @@ -45,11 +50,26 @@ export function Knowledge() { } } + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + if (!question.trim()) return; + await ask(question); + } + + const providerLabel = status?.provider === "ragcore" ? "RAGcore" : "Demo knowledge base"; + return (
- + {status && ( -
{status.provider}{status.available ? "Available" : "Unavailable"} · {status.document_count} procedures indexed
{status.collection}
+
{providerLabel}{status.available ? "Available" : "Unavailable"} · {status.document_count} procedures indexed
{status.collection}
+ )} + {status?.provider !== "ragcore" && ( +

+ This demo answers from a small, fixed set of indexed + procedures — not a live RAGcore connection. A live RAGcore backend will later + take over the same interface without changing how this page works. +

)}
@@ -74,10 +94,18 @@ export function Knowledge() {
{error &&

{error}

} +
+ Try one: + {SUGGESTED_QUESTIONS.map((q) => ( + + ))} +
{exchanges.length === 0 && !error && ( -

Evidence before answers

Ask about returns, damage, inspections or another indexed procedure. MobilityOps will not invent an answer when evidence is missing.

+

Evidence before answers

Ask about returns, damage, inspections or another indexed procedure. MobilityOps will not invent an answer when evidence is missing.

)}
    diff --git a/frontend/src/styles.css b/frontend/src/styles.css index b90b5a2..9ef3c3a 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -248,6 +248,14 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details .impact-preview { display: flex; align-items: flex-start; gap: 11px; padding: 15px; border: 1px solid; border-radius: var(--radius); }.impact-preview > svg { width: 20px; flex: 0 0 auto; }.impact-preview strong { font-size: .76rem; }.impact-preview p { margin: 4px 0 0; font-size: .72rem; line-height: 1.45; }.impact-ready { color: #185d41; background: var(--success-pale); border-color: #bfe3d0; }.impact-warning { color: #844909; background: var(--warning-pale); border-color: #eed4aa; } .commit-list { list-style: none; display: grid; gap: 7px; margin: 16px 0 0; padding: 0; color: var(--muted); font-size: .7rem; }.commit-list li { display: flex; gap: 7px; }.commit-list svg { width: 14px; color: var(--teal-dark); } .success-panel { padding: 22px; }.result-heading { display: flex; align-items: center; gap: 12px; margin-bottom: 18px; }.result-heading > span { width: 40px; height: 40px; display: grid; place-items: center; color: white; background: var(--success); border-radius: 50%; }.result-heading svg { width: 20px; }.result-heading h2 { margin: 0; font-size: 1.2rem; } +.result-links { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; } +.rule-explainer { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px; margin-bottom: 18px; padding: 16px 18px; background: var(--info-pale); border: 1px solid #cfe3ee; border-radius: var(--radius); } +.rule-explainer strong { display: block; margin-bottom: 4px; color: var(--ink); font-size: .72rem; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; } +.rule-explainer p { margin: 0; color: var(--ink-soft); font-size: .78rem; line-height: 1.55; } +.scenario-callout { display: flex; align-items: flex-start; gap: 12px; margin-bottom: 18px; padding: 16px 18px; background: var(--teal-pale); border: 1px solid #bfe6df; } +.scenario-callout svg { width: 18px; height: 18px; color: var(--teal-dark); flex-shrink: 0; margin-top: 2px; } +.scenario-callout strong { display: block; margin-bottom: 4px; color: var(--ink); font-size: .82rem; } +.scenario-callout p { margin: 0; color: var(--ink-soft); font-size: .78rem; line-height: 1.55; } .duplicate-compare { margin-top: 18px; }.duplicate-compare fieldset { margin: 15px 0; padding: 13px; border: 1px solid var(--line); }.duplicate-compare legend { padding: 0 5px; color: var(--ink-soft); font-size: .69rem; font-weight: 700; }.duplicate-compare fieldset label { display: inline-flex !important; margin-right: 18px; } .compare-table th, .compare-table td { vertical-align: top; }.compare-table label { display: inline-flex; flex-direction: row; align-items: center; gap: 6px; }.difference-mark, .match-mark { display: block; width: max-content; margin-top: 4px; padding: 2px 5px; font-size: .52rem; border-radius: 2px; }.difference-mark { color: var(--warning); background: var(--warning-pale); }.match-mark { color: var(--success); background: var(--success-pale); } @@ -293,6 +301,14 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details .app-shell { grid-template-columns: 190px minmax(0, 1fr); }.sidebar { width: 190px; }.brand-lockup { padding-inline: 14px; }.readiness-band { grid-template-columns: 160px 1fr; }.readiness-band .inline-action { display: none; }.operations-grid { grid-template-columns: 1fr 1fr; }.timezone { display: none; }.review-facts { grid-template-columns: repeat(3, 1fr); } } +.knowledge-provider-note { display: flex; align-items: flex-start; gap: 8px; margin: -6px 0 18px; padding: 10px 14px; color: var(--ink-soft); background: var(--info-pale); border: 1px solid #cfe3ee; border-radius: var(--radius); font-size: .72rem; line-height: 1.5; } +.knowledge-provider-note svg { width: 15px; flex-shrink: 0; margin-top: 1px; color: var(--info); } +.knowledge-suggestions { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; margin-top: 12px; } +.knowledge-suggestions > span { color: var(--muted); font-size: .68rem; font-weight: 700; } +.suggestion-chip { padding: 6px 11px; color: var(--teal-dark); background: var(--teal-pale); border: 1px solid #bfe6df; border-radius: 999px; font-size: .68rem; font-weight: 600; cursor: pointer; } +.suggestion-chip:hover { background: #d9f0ea; } +.suggestion-chip:disabled { opacity: .6; cursor: not-allowed; } + .demo-start-panel { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; margin-bottom: 22px; padding: 16px 20px; background: var(--teal-pale); border: 1px solid #bfe6df; border-radius: var(--radius); } .demo-start-panel > div:first-child { display: flex; align-items: center; gap: 12px; } .demo-start-panel > div:first-child > svg { width: 22px; height: 22px; color: var(--teal-dark); flex-shrink: 0; }