diff --git a/app/components/RatingSystemEditor.tsx b/app/components/RatingSystemEditor.tsx index 22145ecc0..cd207f45c 100644 --- a/app/components/RatingSystemEditor.tsx +++ b/app/components/RatingSystemEditor.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from "react"; import { useFetcher } from "react-router"; -import { Modal, Button, Icon } from "@core/shared-ui"; +import { Modal, Button, IconButton, Icon } from "@core/shared-ui"; import type { Severity } from "~/lib/severity"; import { SEVERITIES, SEVERITY_LABEL, SEVERITY_DOT } from "~/lib/severity"; @@ -218,14 +218,15 @@ export function RatingSystemEditor({
Start from {PRESETS.map((p) => ( - + ))}
@@ -294,22 +295,53 @@ export function RatingSystemEditor({ {/* Reorder + remove */}
- - - + moveLevel(i, -1)} + disabled={i === 0} + title="Move up" + className="w-6 text-ih-fg-4 hover:text-ih-fg-1" + > + + + moveLevel(i, 1)} + disabled={i === levels.length - 1} + title="Move down" + className="w-6 text-ih-fg-4 hover:text-ih-fg-1" + > + + + setLevels((prev) => prev.filter((_, j) => j !== i))} + disabled={levels.length <= 2} + title="Remove level" + className="w-6 text-ih-fg-4 hover:text-ih-bad-fg" + > + +
))} {levels.length < 10 && ( - + )} diff --git a/app/components/editor-shared/ItemList.tsx b/app/components/editor-shared/ItemList.tsx index 9f712f449..43d2dbbc3 100644 --- a/app/components/editor-shared/ItemList.tsx +++ b/app/components/editor-shared/ItemList.tsx @@ -1,5 +1,6 @@ import { useState, useRef } from "react"; import { createPortal } from "react-dom"; +import { Button, IconButton, MenuItem } from "@core/shared-ui"; import type { EditorMode } from "./editor-mode"; import { useSortableReorder } from "./useSortableReorder"; import { InlineRename } from "./InlineRename"; @@ -206,15 +207,16 @@ export function ItemList({ {/* Reserved ⋯ slot — own column, never overlaps the rating dot. */} {structuralEditing && !batchMode && (
- + {menuItemId === item.id && menuAnchor && createPortal( <>
@@ -224,19 +226,19 @@ export function ItemList({ className="fixed -translate-x-full z-[61] w-36 py-1 bg-ih-bg-card border border-ih-border rounded-md shadow-ih-popover text-[12px]" > {onRenameItem && ( - + { e.stopPropagation(); closeItemMenu(); setEditingId(item.id); }}>Rename )} {onDuplicateItem && ( - + { e.stopPropagation(); closeItemMenu(); onDuplicateItem(item.id); }}>Duplicate )} {onMoveItem && fullIdx > 0 && ( - + { e.stopPropagation(); closeItemMenu(); onMoveItem(item.id, -1); }}>Move up )} {onMoveItem && fullIdx < items.length - 1 && ( - + { e.stopPropagation(); closeItemMenu(); onMoveItem(item.id, 1); }}>Move down )} {onDeleteItem && ( - + { e.stopPropagation(); closeItemMenu(); onDeleteItem(item.id); }}>Delete )}
, @@ -250,12 +252,14 @@ export function ItemList({
{onAddItem && (
- +
)} diff --git a/app/components/editor-shared/RatingSegment.test.tsx b/app/components/editor-shared/RatingSegment.test.tsx new file mode 100644 index 000000000..33ae90da1 --- /dev/null +++ b/app/components/editor-shared/RatingSegment.test.tsx @@ -0,0 +1,138 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { render, screen, cleanup, fireEvent } from "@testing-library/react"; +import { RatingSegment } from "./RatingSegment"; + +afterEach(cleanup); + +const ratings = [ + { value: "ok", label: "Serviceable", tone: "ok" as const }, + { value: "bad", label: "Deficient", tone: "bad" as const }, +]; + +describe("RatingSegment", () => { + it("selected tile is filled with its tone token", () => { + render( {}} />); + const sel = screen.getByRole("radio", { name: "Deficient" }); + expect(sel.getAttribute("aria-checked")).toBe("true"); + expect(sel.className).toContain("bg-ih-bad"); + }); + + it("clicking a tile calls onChange with its value", () => { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByRole("radio", { name: "Deficient" })); + expect(onChange).toHaveBeenCalledWith("bad"); + }); + + it("unselected tiles are outlined/muted, not filled", () => { + render( {}} />); + const idle = screen.getByRole("radio", { name: "Serviceable" }); + expect(idle.getAttribute("aria-checked")).toBe("false"); + expect(idle.className).not.toContain("bg-ih-ok text-ih-fg-inverse"); + expect(idle.className).toContain("bg-ih-ok-bg"); + }); + + it("maps the warn tone to the ih-watch token (no ih-warn token exists)", () => { + render( + {}} + /> + ); + expect(screen.getByRole("radio", { name: "Monitor" }).className).toContain("bg-ih-watch"); + }); + + it("maps the neutral tone to a solid inverse chip when selected (contrast fix)", () => { + render( + {}} + /> + ); + const tile = screen.getByRole("radio", { name: "N/A" }); + expect(tile.className).toContain("bg-ih-bg-inverse"); + expect(tile.className).not.toContain("bg-ih-bg-muted"); + }); + + it("only the selected tile is tab-focusable (roving tabindex)", () => { + render( {}} />); + expect(screen.getByRole("radio", { name: "Serviceable" }).getAttribute("tabIndex")).toBe("-1"); + expect(screen.getByRole("radio", { name: "Deficient" }).getAttribute("tabIndex")).toBe("0"); + }); + + it("ArrowRight moves selection to the next tile and calls onChange", () => { + const onChange = vi.fn(); + render(); + fireEvent.keyDown(screen.getByRole("radio", { name: "Serviceable" }), { key: "ArrowRight" }); + expect(onChange).toHaveBeenCalledWith("bad"); + }); + + it("Home / End jump to the first / last tile", () => { + const onChange = vi.fn(); + const three = [ + { value: "a", label: "A", tone: "ok" as const }, + { value: "b", label: "B", tone: "warn" as const }, + { value: "c", label: "C", tone: "bad" as const }, + ]; + render(); + fireEvent.keyDown(screen.getByRole("radio", { name: "B" }), { key: "End" }); + expect(onChange).toHaveBeenLastCalledWith("c"); + fireEvent.keyDown(screen.getByRole("radio", { name: "B" }), { key: "Home" }); + expect(onChange).toHaveBeenLastCalledWith("a"); + }); + + it("a per-option color override wins over the tone token, regardless of selection (BatchActionBar case)", () => { + render( + {}} + /> + ); + const tile = screen.getByRole("radio", { name: "1" }); + expect(tile.getAttribute("style")).toContain("background: #ff8800"); + expect(tile.className).not.toContain("bg-ih-bad"); + }); + + it("size=sm renders the shortLabel instead of the full label", () => { + render( + {}} + size="sm" + /> + ); + // Accessible name stays the full label even when the visible text is abbreviated. + const tile = screen.getByRole("radio", { name: "Serviceable" }); + expect(tile.textContent).toContain("OK"); + expect(tile.textContent).not.toContain("Serviceable"); + }); + + it("size=md renders a responsive short/full label swap, still findable by full-label accessible name", () => { + render( + {}} + size="md" + /> + ); + // Full label remains the accessible name at every viewport. + const tile = screen.getByRole("radio", { name: "Serviceable" }); + expect(tile.textContent).toContain("OK"); + expect(tile.textContent).toContain("Serviceable"); + + const shortSpan = Array.from(tile.querySelectorAll("span")).find( + (el) => el.textContent === "OK" + ); + const fullSpan = Array.from(tile.querySelectorAll("span")).find( + (el) => el.textContent === "Serviceable" + ); + expect(shortSpan?.className).toContain("sm:hidden"); + expect(fullSpan?.className).toContain("hidden"); + expect(fullSpan?.className).toContain("sm:inline"); + }); +}); diff --git a/app/components/editor-shared/RatingSegment.tsx b/app/components/editor-shared/RatingSegment.tsx new file mode 100644 index 000000000..85e40fc6e --- /dev/null +++ b/app/components/editor-shared/RatingSegment.tsx @@ -0,0 +1,187 @@ +import React from "react"; +import { cn } from "../../../packages/shared-ui/src/cn"; + +export type RatingTone = "ok" | "warn" | "bad" | "info" | "neutral"; + +export interface RatingOption { + value: string; + label: string; + tone: RatingTone; + /** Short form for compact layouts (e.g. an abbreviation, or a bare index + * for BatchActionBar's numbered tiles). Falls back to `label` when + * omitted — used at `size="sm"`. */ + shortLabel?: string; + /** Small hint rendered under the label (e.g. a keyboard-shortcut digit, + * mirroring RatingButtonRow's/SpeedMode's `idx + 1` hint). Also appended + * to the tile's `title` tooltip. */ + hint?: string; + /** Dynamic, data-driven color override (e.g. a tenant-configured hex from + * the rating system). Wins over the tone token classes when present — + * mirrors BatchActionBar's existing `getRatingColor` inline-style + * pattern. Applies regardless of selection state: data-driven callers + * (batch mode) render every tile permanently in its assigned color, not + * just the currently-selected one. */ + color?: string; +} + +export interface RatingSegmentProps { + ratings: RatingOption[]; + value: string | null | undefined; + onChange: (value: string) => void; + /** `sm` (compact/icon tiles) · `md` (default row, RatingButtonRow-like) · + * `lg` (large touch tiles, SpeedMode-like). Default `md`. */ + size?: "sm" | "md" | "lg"; + /** Accessible name for the radiogroup. */ + ariaLabel?: string; + className?: string; +} + +/* Single source of truth for severity-tone -> token mapping. `warn` uses the + * `ih-watch` token family — there is no `ih-warn` token in tailwind.css (the + * design system's "watch" naming predates this component); this is the + * closest real token and matches RatingButtonRow's existing `marginal` + * tier. */ +const TONE_FILLED: Record = { + ok: "bg-ih-ok text-ih-fg-inverse", + warn: "bg-ih-watch text-ih-fg-inverse", + bad: "bg-ih-bad text-ih-fg-inverse", + info: "bg-ih-info text-ih-fg-inverse", + // Solid inverse chip (not `bg-ih-bg-muted`) — muted-bg + inverse-fg pairs + // to ~1:1 contrast in both themes. This mirrors the pre-migration + // RatingButtonRow `minor` tier's `bg-ih-bg-inverse`, which flips with + // theme and correctly pairs with `ih-fg-inverse`. + neutral: "bg-ih-bg-inverse text-ih-fg-inverse", +}; + +const TONE_IDLE: Record = { + ok: "bg-ih-ok-bg text-ih-ok-fg border border-ih-ok/30 hover:bg-ih-ok/20", + warn: "bg-ih-watch-bg text-ih-watch-fg border border-ih-watch/30 hover:bg-ih-watch/20", + bad: "bg-ih-bad-bg text-ih-bad-fg border border-ih-bad/30 hover:bg-ih-bad/20", + info: "bg-ih-info-bg text-ih-info-fg border border-ih-info/30 hover:bg-ih-info/20", + neutral: "bg-transparent text-ih-fg-3 border border-ih-border hover:bg-ih-bg-muted", +}; + +const SIZE_CLASSES: Record, string> = { + sm: "h-8 min-w-8 px-2 rounded text-[11px] font-bold", + md: "h-11 min-w-0 flex-1 px-3 rounded-lg text-[13px] font-bold", + lg: "h-20 w-20 rounded-xl text-sm font-bold", +}; + +/** + * Domain rating-tile row. Consolidates the three hand-rolled rating-tile + * copies (RatingButtonRow, BatchActionBar, SpeedMode) into one component; + * lives in the app (not shared-ui) because the severity tone map is + * domain-coupled (inspection rating severities), not a generic UI concern. + * + * Accessibility follows the same WAI-ARIA radiogroup pattern as + * `SegmentedControl`: `role="radiogroup"` on the container, `role="radio"` + + * `aria-checked` on each tile, a roving tabindex (only the selected tile is + * tab-focusable), and Arrow/Home/End keys that move selection + focus. + */ +export function RatingSegment({ + ratings, + value, + onChange, + size = "md", + ariaLabel, + className = "", +}: RatingSegmentProps) { + const btnRefs = React.useRef<(HTMLButtonElement | null)[]>([]); + + const selectedIndex = ratings.findIndex((r) => r.value === value); + // If value matches nothing, keep the first tile tab-focusable so the + // group is always reachable by keyboard. + const focusIndex = selectedIndex < 0 ? 0 : selectedIndex; + + function select(index: number) { + const n = ratings.length; + if (n === 0) return; + const clamped = ((index % n) + n) % n; + onChange(ratings[clamped].value); + btnRefs.current[clamped]?.focus(); + } + + function onKeyDown(e: React.KeyboardEvent, index: number) { + switch (e.key) { + case "ArrowRight": + case "ArrowDown": + e.preventDefault(); + select(index + 1); + break; + case "ArrowLeft": + case "ArrowUp": + e.preventDefault(); + select(index - 1); + break; + case "Home": + e.preventDefault(); + select(0); + break; + case "End": + e.preventDefault(); + select(ratings.length - 1); + break; + } + } + + const sizeClass = SIZE_CLASSES[size]; + + return ( +
+ {ratings.map((r, i) => { + const selected = r.value === value; + const toneClass = selected ? TONE_FILLED[r.tone] : TONE_IDLE[r.tone]; + // A per-option `color` override wins over the tone token classes + // (data-driven, like BatchActionBar's existing pattern) and applies + // regardless of selection — see the RatingOption.color doc above. + const style = r.color ? { background: r.color, color: "#fff" } : undefined; + // At `size="sm"` (compact tiles, e.g. BatchActionBar) always show the + // short form. At md/lg, responsively swap: shortLabel below the `sm` + // breakpoint, full label at `sm` and up — mirrors the pre-migration + // RatingButtonRow behavior of abbreviating on narrow screens. When no + // `shortLabel` is provided, just render the full label everywhere. + const text = + size === "sm" ? ( + (r.shortLabel ?? r.label) + ) : r.shortLabel ? ( + <> + {r.shortLabel} + {r.label} + + ) : ( + r.label + ); + const title = r.hint ? `${r.label} (${r.hint})` : r.label; + return ( + + ); + })} +
+ ); +} diff --git a/app/components/editor-shared/SectionRail.tsx b/app/components/editor-shared/SectionRail.tsx index 5502376a5..c6558bb97 100644 --- a/app/components/editor-shared/SectionRail.tsx +++ b/app/components/editor-shared/SectionRail.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import { createPortal } from 'react-dom'; +import { Button, IconButton, MenuItem } from '@core/shared-ui'; import { SectionDonut } from '../editor/SectionDonut'; import { sectionIconFor } from '../editor/section-icons'; import type { EditorMode } from './editor-mode'; @@ -228,15 +229,16 @@ export function SectionRail({ {/* Reserved ⋯ slot — its own column, never overlaps the donut. */} {hasStructuralOps && (
- + {menuOpen && menuAnchor && createPortal( <>
@@ -246,51 +248,42 @@ export function SectionRail({ role="menu" > {onRenameSection && ( - + )} {onDuplicateSection && ( - + )} {onMoveSection && idx > 0 && ( - + )} {onMoveSection && idx < sections.length - 1 && ( - + )} {onDeleteSection && ( <>
- + )}
@@ -308,13 +301,15 @@ export function SectionRail({ {/* D8 — "+ Add section" CTA at the rail bottom (only when structural ops are wired) */} {onAddSection && (
- +
)} diff --git a/app/components/editor/AddItemTypeModal.tsx b/app/components/editor/AddItemTypeModal.tsx index 82e45e1bd..e7bdc06e1 100644 --- a/app/components/editor/AddItemTypeModal.tsx +++ b/app/components/editor/AddItemTypeModal.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useRef } from "react"; -import { Modal } from "@core/shared-ui"; +import { Modal, Button } from "@core/shared-ui"; import type { ItemType } from "~/lib/editor/structure-ops"; export interface AddItemTypeModalProps { @@ -51,19 +51,19 @@ export function AddItemTypeModal({ open, onConfirm, onCancel }: AddItemTypeModal initialFocusRef={labelRef} footer={ <> - - + } > diff --git a/app/components/editor/AddMediaChooser.tsx b/app/components/editor/AddMediaChooser.tsx index 94502f429..ca6d5ce8d 100644 --- a/app/components/editor/AddMediaChooser.tsx +++ b/app/components/editor/AddMediaChooser.tsx @@ -1,10 +1,13 @@ export interface AddMediaChooserProps { onClose: () => void; - onPickPhoto: () => void; + /** "Take photo" — opens the single-shot camera capture input. */ + onTakePhoto: () => void; + /** "Add from library" — opens the multi-select library input (Task 16). */ + onAddFromLibrary: () => void; onPickVideo: () => void; } -export function AddMediaChooser({ onClose, onPickPhoto, onPickVideo }: AddMediaChooserProps) { +export function AddMediaChooser({ onClose, onTakePhoto, onAddFromLibrary, onPickVideo }: AddMediaChooserProps) { return (
+ {(() => { const offline = typeof navigator !== "undefined" && navigator.onLine === false; @@ -31,7 +41,7 @@ export function AddMediaChooser({ onClose, onPickPhoto, onPickVideo }: AddMediaC disabled={offline} onClick={onPickVideo} title={offline ? "Video upload requires a connection" : undefined} - className="min-h-[44px] rounded-xl border border-ih-border bg-ih-bg-muted px-4 py-3 text-[14px] font-bold text-ih-fg-1 hover:border-ih-primary disabled:opacity-40" + className="min-h-[44px] rounded-xl border border-ih-border bg-ih-bg-muted px-2 py-3 text-center text-[13px] font-bold leading-tight text-ih-fg-1 hover:border-ih-primary disabled:opacity-40" > Video {offline && Requires a connection} diff --git a/app/components/editor/AddSectionPromptModal.tsx b/app/components/editor/AddSectionPromptModal.tsx index 5309862e3..abc008468 100644 --- a/app/components/editor/AddSectionPromptModal.tsx +++ b/app/components/editor/AddSectionPromptModal.tsx @@ -1,5 +1,5 @@ import { useRef } from "react"; -import { Modal } from "@core/shared-ui"; +import { Modal, Button } from "@core/shared-ui"; /** * D8 — "Add section" title prompt modal. @@ -33,18 +33,18 @@ export function AddSectionPromptModal({ initialFocusRef={inputRef} footer={ <> - - + } > diff --git a/app/components/editor/BatchActionBar.tsx b/app/components/editor/BatchActionBar.tsx index 1344e8b72..f1c54436e 100644 --- a/app/components/editor/BatchActionBar.tsx +++ b/app/components/editor/BatchActionBar.tsx @@ -1,4 +1,6 @@ import React from "react"; +import { Button } from "@core/shared-ui"; +import { RatingSegment, type RatingOption } from "../editor-shared/RatingSegment"; interface BatchActionBarProps { count: number; @@ -30,6 +32,16 @@ export function BatchActionBar({ onSetRating, onExit, }: BatchActionBarProps) { + // Numbered tiles, coloured per-tenant via getRatingColor — `color` wins over + // the (unused) tone token on every tile, unconditionally, mirroring the + // former always-on inline `style={{ background: getRatingColor(...) }}`. + const batchRatings: RatingOption[] = ratingLevels.map((level, idx) => ({ + value: level.id, + label: String(idx + 1), + tone: "neutral", + color: getRatingColor(level.id), + })); + return (
{/* Selected count */} @@ -43,47 +55,30 @@ export function BatchActionBar({ ); diff --git a/app/components/editor/CustomDefectForm.tsx b/app/components/editor/CustomDefectForm.tsx index 859fde0e4..04c31fcbf 100644 --- a/app/components/editor/CustomDefectForm.tsx +++ b/app/components/editor/CustomDefectForm.tsx @@ -1,3 +1,4 @@ +import { Button } from "@core/shared-ui"; import type { CustomDefectCategory } from "../../lib/custom-defects"; export interface CustomDefectFormProps { @@ -70,21 +71,21 @@ export function CustomDefectForm({ )} - - +
); diff --git a/app/components/editor/EditorHeader.tsx b/app/components/editor/EditorHeader.tsx index 8e230f6b6..1908cac66 100644 --- a/app/components/editor/EditorHeader.tsx +++ b/app/components/editor/EditorHeader.tsx @@ -1,6 +1,6 @@ import type { ReactNode } from "react"; import type { useInspectionState } from "~/hooks/useInspection"; -import { Icon } from "@core/shared-ui"; +import { Button, IconButton, Icon } from "@core/shared-ui"; import type { ColorScheme } from "~/lib/ui-prefs"; import { ProgressStripText } from "~/components/editor/ProgressStripText"; import { TemplateMenu } from "~/components/editor/TemplateMenu"; @@ -191,24 +191,25 @@ export function EditorHeader({ {/* #181 — Version history (only when collab editing is enabled) */} {collabEditing && ( - + )}
{/* Right zone: theme + settings + preview + preview PDF + sign now + publish */}
{/* Theme cycle: light → dark → field → auto */} - + {/* Settings button */} - + {/* Template menu [config] — global template actions consolidated here (swap template · save as new · update source), replacing the buttons @@ -262,63 +263,75 @@ export function EditorHeader({ {/* Preview full report — opens the whole report (all sections) in a new tab. Owner preview works on drafts (tokenless via the report-view loader). */} {tenantSlug && ( - + )} {/* Preview PDF — opens the real server-rendered PDF deliverable (the exact client deliverable) in a new tab. Owner on-demand render works pre-publish on drafts via the owner/JWT-authed /api/inspections/:id/pdf endpoint. */} - + {/* Sign now button */} - + {/* Publish button */} - +
diff --git a/app/components/editor/FooterBar.tsx b/app/components/editor/FooterBar.tsx index 70dc77061..701ed5748 100644 --- a/app/components/editor/FooterBar.tsx +++ b/app/components/editor/FooterBar.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { Button } from "@core/shared-ui"; import type { PresenceUser, PresenceStatus } from "~/hooks/usePresence"; interface FooterBarProps { @@ -30,13 +31,14 @@ export function FooterBar({ connected = false, status, roster = [] }: FooterBarP return (
- + {shortcutsOpen && (
diff --git a/app/components/editor/FullscreenToggle.tsx b/app/components/editor/FullscreenToggle.tsx index d86ce9099..4329e0376 100644 --- a/app/components/editor/FullscreenToggle.tsx +++ b/app/components/editor/FullscreenToggle.tsx @@ -1,3 +1,5 @@ +import { IconButton } from "@core/shared-ui"; + export interface FullscreenToggleProps { active: boolean; onToggle: () => void; @@ -10,17 +12,11 @@ export interface FullscreenToggleProps { */ export function FullscreenToggle({ active, onToggle }: FullscreenToggleProps) { return ( - + ); } diff --git a/app/components/editor/InspectionSettingsSheet.tsx b/app/components/editor/InspectionSettingsSheet.tsx index 111537833..4aeb04bbf 100644 --- a/app/components/editor/InspectionSettingsSheet.tsx +++ b/app/components/editor/InspectionSettingsSheet.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef } from "react"; import { useFetcher } from "react-router"; -import { Drawer } from "@core/shared-ui"; +import { Drawer, Button } from "@core/shared-ui"; import { TemplateCombobox } from "~/components/TemplateCombobox"; import { CoverCropper } from "~/components/media-studio/CoverCropper"; import { fullResUrl } from "~/components/media-studio/cropImage"; @@ -244,9 +244,9 @@ export function InspectionSettingsSheet({ open, onClose, inspectionId, referralS {saveState === "saving" && Saving...} {saveState === "saved" && Saved} {saveState === "error" && Error -- try again} - + )} > @@ -383,14 +383,9 @@ export function InspectionSettingsSheet({ open, onClose, inspectionId, referralS
)}
- + Shown on the report cover page. Click the selected photo to clear it.
diff --git a/app/components/editor/InspectorToolsDock.tsx b/app/components/editor/InspectorToolsDock.tsx index 7aaf80e7c..ade7c8b46 100644 --- a/app/components/editor/InspectorToolsDock.tsx +++ b/app/components/editor/InspectorToolsDock.tsx @@ -1,4 +1,5 @@ import { useState, useEffect } from "react"; +import { MenuItem } from "@core/shared-ui"; interface DockTile { id: string; @@ -61,22 +62,23 @@ export function InspectorToolsDock({ {dockOpen && (
{TILES.map((t) => ( - + ))}
)} + {/* DS exception: intentional gradient FAB */} + ) : null; const submitCustomDefect = () => { @@ -431,13 +427,13 @@ export function ItemEditor({ {contradictions.map((hit) => (
  • “{hit.title}” still says all-clear - +
  • ))} @@ -491,13 +487,13 @@ export function ItemEditor({ placeholder="Add notes — type to see recommended comments, / for library" className="w-full h-28 px-3 py-2 rounded-lg border border-ih-border bg-ih-bg-card text-[13px] resize-none focus:shadow-ih-focus focus:border-ih-primary outline-none" /> - + void }) { return (
    - {/* ds-allow: intentional heavier fixed-dark dim (0.85) for the full-screen keyboard-shortcuts overlay */} -
    +

    Keyboard shortcuts

    Press ? to toggle, Esc to close

    - + ×
    {COLUMNS.map((col) => ( diff --git a/app/components/editor/MobileAppBar.tsx b/app/components/editor/MobileAppBar.tsx index 8bb526e29..eae4185a6 100644 --- a/app/components/editor/MobileAppBar.tsx +++ b/app/components/editor/MobileAppBar.tsx @@ -1,4 +1,4 @@ -import { Icon } from "@core/shared-ui"; +import { Icon, IconButton } from "@core/shared-ui"; export interface MobileAppBarProps { sectionTitle: string; @@ -15,20 +15,20 @@ export interface MobileAppBarProps { export function MobileAppBar({ sectionTitle, itemLabel, onBack, onMore }: MobileAppBarProps) { return (
    - + >
    {sectionTitle}
    {itemLabel}
    - + >⋮
    ); } diff --git a/app/components/editor/PublishGateModal.tsx b/app/components/editor/PublishGateModal.tsx index cdedff5c8..93fbfd44e 100644 --- a/app/components/editor/PublishGateModal.tsx +++ b/app/components/editor/PublishGateModal.tsx @@ -1,4 +1,4 @@ -import { Icon } from '@core/shared-ui'; +import { Icon, Button, IconButton } from '@core/shared-ui'; import type { PublishReadiness, PublishBlockingDefect } from '../../lib/types'; export interface PublishGateModalProps { @@ -36,12 +36,13 @@ function DefectList({ entries, onJump, tone }: { )}
    - + ))} @@ -65,13 +66,13 @@ export function PublishGateModal({ open, readiness, onClose, onJump, onProceed } ? <>Publish with warnings? — {warnings.length} defect{warnings.length === 1 ? '' : 's'} incomplete : <>Cannot publish — {blocking.length} defect{blocking.length === 1 ? '' : 's'} need attention} - +
    {blocking.length > 0 && } @@ -85,17 +86,18 @@ export function PublishGateModal({ open, readiness, onClose, onJump, onProceed } )}
    - + {warningOnly && onProceed && ( - + )}
    diff --git a/app/components/editor/PublishModal.tsx b/app/components/editor/PublishModal.tsx index 4acaffd14..629b665fa 100644 --- a/app/components/editor/PublishModal.tsx +++ b/app/components/editor/PublishModal.tsx @@ -1,4 +1,4 @@ -import { Modal } from "@core/shared-ui"; +import { Modal, Button } from "@core/shared-ui"; export interface PublishModalProps { open: boolean; @@ -22,12 +22,12 @@ export function PublishModal({ open, progress, status, publishError, isSubmittin title="Publish Report" footer={ <> - - + + >{isSubmitting ? "Publishing…" : "Publish Now"} } > diff --git a/app/components/editor/RatingButtonRow.tsx b/app/components/editor/RatingButtonRow.tsx index 7f3d28477..7940d3978 100644 --- a/app/components/editor/RatingButtonRow.tsx +++ b/app/components/editor/RatingButtonRow.tsx @@ -1,61 +1,58 @@ import type { EditorRatingLevel } from "../../lib/rating-levels"; +import { RatingSegment, type RatingOption, type RatingTone } from "../editor-shared/RatingSegment"; -/* Solid active fills — readable in direct sunlight (field eval FE/C-14a). - * DS tokens flip in dark mode automatically; the `minor` active fill uses the - * inverse pair so its solid chip stays high-contrast in both themes. */ -const SEVERITY_STYLES: Record = { - good: { - active: "bg-ih-ok text-white ring-2 ring-ih-ok/40", - idle: "bg-ih-ok-bg text-ih-ok-fg hover:bg-ih-ok/20", - }, - marginal: { - active: "bg-ih-watch text-white ring-2 ring-ih-watch/40", - idle: "bg-ih-watch-bg text-ih-watch-fg hover:bg-ih-watch/20", - }, - significant: { - active: "bg-ih-bad text-white ring-2 ring-ih-bad/40", - idle: "bg-ih-bad-bg text-ih-bad-fg hover:bg-ih-bad/20", - }, - minor: { - active: "bg-ih-bg-inverse text-ih-fg-inverse ring-2 ring-ih-border-strong", - idle: "bg-ih-bg-muted text-ih-fg-2 hover:bg-ih-border", - }, +/* Severity → RatingSegment tone. `good`/`marginal`/`significant` map to the + * ok/warn/bad tone tokens (readable in direct sunlight per field eval + * FE/C-14a); any other severity — including the pre-migration `minor` tier's + * inverse-fill treatment, or an absent severity — falls back to `neutral`, + * mirroring the old SEVERITY_STYLES[r.severity ?? "minor"] ?? .minor default. */ +const SEVERITY_TONE: Record = { + good: "ok", + marginal: "warn", + significant: "bad", }; +function toneFor(severity: string | undefined): RatingTone { + return SEVERITY_TONE[severity ?? ""] ?? "neutral"; +} + export interface RatingButtonRowProps { levels: EditorRatingLevel[]; activeLevel: EditorRatingLevel | null | undefined; onRating: (rating: string) => void; } -/* Rating buttons — driven by the rating system's levels (C-14a): - full words on ≥sm, abbreviation on narrow, always-on semantic colour. */ +/* Rating buttons — driven by the rating system's levels (C-14a). Delegates to + the shared RatingSegment radiogroup (consolidates the hand-rolled tile + copies across RatingButtonRow/BatchActionBar/SpeedMode). Same rating + `value`s (level `id`) and `onRating` callback as before; now carries + radiogroup a11y semantics (role="radio"/aria-checked + roving tabindex + + Arrow/Home/End key support) in place of the former plain-button + + aria-pressed markup. */ export function RatingButtonRow({ levels, activeLevel, onRating }: RatingButtonRowProps) { + const ratings: RatingOption[] = levels.map((r, idx) => { + const full = r.label ?? r.name ?? r.id; + return { + value: r.id, + label: full, + shortLabel: r.abbreviation ?? full, + tone: toneFor(r.severity), + hint: String(idx + 1), + }; + }); + return ( // FE-4 — gap-3 (12px) between rating buttons: adjacent mis-taps were the - // top field complaint; buttons themselves are already 52px tall. -
    - {levels.map((r, idx) => { - const sev = SEVERITY_STYLES[r.severity ?? "minor"] ?? SEVERITY_STYLES.minor; - const isActive = activeLevel?.id === r.id; - const full = r.label ?? r.name ?? r.id; - const abbr = r.abbreviation ?? full; - return ( - - ); - })} + // top field complaint. Single row (no wrap), same as the pre-migration + // flex-1 tile row. +
    +
    ); } diff --git a/app/components/editor/RecropWarningModal.tsx b/app/components/editor/RecropWarningModal.tsx index 914e1c898..97ec08901 100644 --- a/app/components/editor/RecropWarningModal.tsx +++ b/app/components/editor/RecropWarningModal.tsx @@ -1,4 +1,4 @@ -import { Modal } from "@core/shared-ui"; +import { Modal, Button } from "@core/shared-ui"; export interface RecropWarningModalProps { open: boolean; @@ -15,8 +15,8 @@ export function RecropWarningModal({ open, onCancel, onConfirm }: RecropWarningM size="sm" footer={ <> - - + + } > diff --git a/app/components/editor/RepairItemsPanel.tsx b/app/components/editor/RepairItemsPanel.tsx index cc957cf07..fd61473ce 100644 --- a/app/components/editor/RepairItemsPanel.tsx +++ b/app/components/editor/RepairItemsPanel.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { Button } from "@core/shared-ui"; import type { AttachedRepairItem } from "~/hooks/useFindings"; interface RepairItemOption { @@ -54,7 +55,15 @@ export function RepairItemsPanel({
    Repair items - +
    {attached.length > 0 && ( @@ -72,7 +81,15 @@ export function RepairItemsPanel({ {a.contractorTypeSnapshot && {a.contractorTypeSnapshot}}
    - + ); })} @@ -83,12 +100,12 @@ export function RepairItemsPanel({
    setQ(e.target.value)} placeholder="Search repair items…" aria-label="Search repair items" autoFocus className="w-full px-2 py-1.5 rounded border border-ih-border bg-ih-bg-card text-[12px] text-ih-fg-1 focus:border-ih-primary outline-none" /> -
      +
        {filtered.length === 0 ? (
      • No matching repair items. Add some under Library → Repair Items.
      • ) : filtered.map((o) => ( -
      • - diff --git a/app/components/editor/SaveTemplateModal.tsx b/app/components/editor/SaveTemplateModal.tsx index 346e0d696..5b15ad1a1 100644 --- a/app/components/editor/SaveTemplateModal.tsx +++ b/app/components/editor/SaveTemplateModal.tsx @@ -1,5 +1,5 @@ import { useRef } from "react"; -import { Modal } from "@core/shared-ui"; +import { Modal, Button } from "@core/shared-ui"; export interface SaveTemplateModalProps { /** Non-null while open: 'back' updates the source template, 'new' forks a copy. */ @@ -32,19 +32,19 @@ export function SaveTemplateModal({ mode, name, onChangeName, onConfirm, onCance initialFocusRef={nameRef} footer={ <> - - + } > diff --git a/app/components/editor/SideRail.tsx b/app/components/editor/SideRail.tsx index 83b4d2205..b9f4ce2e6 100644 --- a/app/components/editor/SideRail.tsx +++ b/app/components/editor/SideRail.tsx @@ -1,4 +1,5 @@ import { useState, useEffect } from "react"; +import { IconButton, Icon } from "@core/shared-ui"; import { renderTemplate } from "../../lib/mustache"; import { DEFECT_TRADE_LABELS, DEFECT_DEADLINE_LABELS, DEFECT_TIMEFRAME_LABELS } from "../../lib/defect-fields"; import { photoDisplayName, withDownload } from "../../lib/photo-name"; @@ -90,7 +91,9 @@ export function SideRail({ mode, activeItem, activeResult, getRatingColor, getRa
        {effectiveTab} - + + +
        {effectiveTab === "preview" && ( diff --git a/app/components/editor/SpeedMode.tsx b/app/components/editor/SpeedMode.tsx index ffd7c6096..097d55907 100644 --- a/app/components/editor/SpeedMode.tsx +++ b/app/components/editor/SpeedMode.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback } from 'react'; -import { Icon } from '@core/shared-ui'; +import { Icon, Button } from '@core/shared-ui'; import { usePointerGesture } from '../../hooks/usePointerGesture'; import { MobileBottomDrawer } from '../MobileBottomDrawer'; import { SpeedModeUndoToast } from './SpeedModeUndoToast'; @@ -45,15 +45,13 @@ const SPEED_FALLBACK_LEVELS: Array<{ id: string; label?: string; name?: string; function ratingButtonClass(severity: string | undefined, isActive: boolean): string { if (!isActive) { - // ds-allow: fixed-dark surface - return "bg-slate-800 text-slate-300 hover:bg-slate-700 border border-slate-700"; + return "bg-ih-bg-muted text-ih-fg-2 hover:bg-ih-border border border-ih-border"; } switch (severity) { - case "good": return "bg-ih-ok text-white ring-4 ring-ih-ok/50"; - case "marginal": return "bg-ih-watch text-white ring-4 ring-ih-watch/50"; - case "significant": return "bg-ih-bad text-white ring-4 ring-ih-bad/50"; - // ds-allow: fixed-dark surface - default: return "bg-slate-600 text-white ring-4 ring-slate-400/50"; + case "good": return "bg-ih-ok text-ih-fg-inverse ring-4 ring-ih-ok/50"; + case "marginal": return "bg-ih-watch text-ih-fg-inverse ring-4 ring-ih-watch/50"; + case "significant": return "bg-ih-bad text-ih-fg-inverse ring-4 ring-ih-bad/50"; + default: return "bg-ih-bg-inverse text-ih-fg-inverse ring-4 ring-ih-fg-4/30"; } } @@ -128,19 +126,17 @@ export function SpeedMode({ }; return ( - /* ds-allow: fixed-dark surface */
        {/* Top bar */} - {/* ds-allow: fixed-dark surface */} -
        - {sectionTitle} +
        + {sectionTitle} {currentIndex + 1} / {totalCount} - +
        {/* Item — gesture surface wraps the title/status area, NOT the rating buttons */} @@ -149,9 +145,8 @@ export function SpeedMode({ className="touch-none select-none w-full flex flex-col items-center pb-6" {...gesture} > -

        {item.label}

        - {/* ds-allow: fixed-dark surface */} -

        +

        {item.label}

        +

        Swipe to navigate · Long-press to jump

        @@ -174,36 +169,31 @@ export function SpeedMode({
        {/* Nav */} - {/* ds-allow: fixed-dark surface */}
        - - + +
        {/* Footer */} - {/* ds-allow: fixed-dark surface */} -
        - Press Z or Esc to exit +
        + Press Z or Esc to exit
        {/* IA-17 — first-run coach mark: tap anywhere (or press any key) to dismiss */} {showCoach && ( - // ds-allow: fixed-dark surface
        - {/* ds-allow: fixed-dark surface */} -
        -

        Speed Mode

        +
        +

        Speed Mode

        • - {/* ds-allow: fixed-dark surface */} - 1–5 + 1–5 Rate the current item
        • @@ -215,8 +205,7 @@ export function SpeedMode({ Long-press to jump to a section
        - {/* ds-allow: fixed-dark surface */} -

        Tap anywhere to start

        +

        Tap anywhere to start

        )} diff --git a/app/components/editor/SpeedModeUndoToast.tsx b/app/components/editor/SpeedModeUndoToast.tsx index 0e8a77fc6..b50397923 100644 --- a/app/components/editor/SpeedModeUndoToast.tsx +++ b/app/components/editor/SpeedModeUndoToast.tsx @@ -1,4 +1,5 @@ import { useEffect } from 'react'; +import { Button } from '@core/shared-ui'; export interface SpeedModeUndoToastProps { /** Null = hide */ @@ -26,12 +27,14 @@ export function SpeedModeUndoToast({ pending, onDismiss, durationMs = 3000 }: Sp return (
        {pending.message} - +
        ); } diff --git a/app/components/editor/StructureDeleteModal.tsx b/app/components/editor/StructureDeleteModal.tsx index 74e21fcdf..66b8b2f5b 100644 --- a/app/components/editor/StructureDeleteModal.tsx +++ b/app/components/editor/StructureDeleteModal.tsx @@ -1,4 +1,4 @@ -import { Modal } from "@core/shared-ui"; +import { Modal, Button } from "@core/shared-ui"; export interface StructureDeleteModalProps { open: boolean; @@ -42,19 +42,19 @@ export function StructureDeleteModal({ open, title, noun = 'section', impact, on size="sm" footer={ <> - - + } > diff --git a/app/components/editor/TagChipRow.tsx b/app/components/editor/TagChipRow.tsx index 854fe98c3..0e69f25c0 100644 --- a/app/components/editor/TagChipRow.tsx +++ b/app/components/editor/TagChipRow.tsx @@ -1,4 +1,5 @@ import { useMemo } from 'react'; +import { Button } from '@core/shared-ui'; export interface TagPin { id: string; @@ -44,13 +45,15 @@ export function TagChipRow({ pinnedTags, activeTagIds, onToggle, onOpenLibrary } ); })} {onOpenLibrary && ( - + )}
        ); diff --git a/app/components/editor/TemplateMenu.tsx b/app/components/editor/TemplateMenu.tsx index 0a818b29c..01a2763c1 100644 --- a/app/components/editor/TemplateMenu.tsx +++ b/app/components/editor/TemplateMenu.tsx @@ -1,5 +1,5 @@ import { useRef, useState } from "react"; -import { Popover } from "@core/shared-ui"; +import { Button, MenuItem, Popover } from "@core/shared-ui"; export interface TemplateMenuProps { /** Open the template picker (the inspection's applied template can be swapped). */ @@ -34,14 +34,15 @@ export function TemplateMenu({ return ( <> - + setOpen(false)} anchorRef={anchorRef} align="right">
        • - +
        • - +
        • {canUpdateSource && (
        • - +
        • )}
        diff --git a/app/components/editor/UnitProgress.tsx b/app/components/editor/UnitProgress.tsx index 19df66b0e..94ebacf33 100644 --- a/app/components/editor/UnitProgress.tsx +++ b/app/components/editor/UnitProgress.tsx @@ -8,6 +8,8 @@ * `{ id, label }[]` + `completedUnitIds` shape below. */ +import { IconButton } from "@core/shared-ui"; + interface UnitProgressUnit { id: string; label: string; @@ -48,16 +50,16 @@ export function UnitProgress({ units, completedUnitIds, onSelectUnit, activeUnit const title = `${u.label} — ${isDone ? "complete" : "in progress"}`; return onSelectUnit ? ( - + ) : ( diff --git a/app/components/editor/UnitsManager.test.tsx b/app/components/editor/UnitsManager.test.tsx index 4e9e0c267..45028bca3 100644 --- a/app/components/editor/UnitsManager.test.tsx +++ b/app/components/editor/UnitsManager.test.tsx @@ -64,7 +64,7 @@ test("bulk floors × stacks form submits a floors_stacks payload", () => { test("CSV tab submits a csv payload", () => { const { submit } = renderManager(); - fireEvent.click(screen.getByRole("button", { name: "CSV paste" })); + fireEvent.click(screen.getByRole("radio", { name: "CSV paste" })); fireEvent.change(screen.getByLabelText("CSV units"), { target: { value: "Lobby,\nGarage,B1" } }); fireEvent.click(screen.getByRole("button", { name: "Create units" })); diff --git a/app/components/editor/UnitsManager.tsx b/app/components/editor/UnitsManager.tsx index f1eba0f87..3ff4cd7e1 100644 --- a/app/components/editor/UnitsManager.tsx +++ b/app/components/editor/UnitsManager.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import type { useFetcher } from "react-router"; -import { Drawer, Modal, Button, Input } from "@core/shared-ui"; +import { Drawer, Modal, Button, Input, IconButton, SegmentedControl } from "@core/shared-ui"; import type { UnitScopeRow } from "./BreadcrumbDropdown"; type UnitsFetcher = ReturnType; @@ -120,19 +120,16 @@ export function UnitsManager({ open, onClose, inspectionId, units, mode, fetcher size="sm" footer={ <> - - + } > @@ -188,30 +185,30 @@ function UnitRow({ maxLength={80} className="flex-1 h-8 px-2.5 rounded-ih-input border border-ih-border bg-ih-bg-app text-[13px] text-ih-fg-1" /> - - +
      • ); } @@ -288,20 +285,15 @@ function BulkCreateForm({ busy, onSubmit }: { busy: boolean; onSubmit: (fields: return (
        -
        - {(["grid", "csv"] as const).map((t) => ( - - ))} -
        + setTab(v as "grid" | "csv")} + ariaLabel="Bulk create method" + /> {tab === "grid" ? (
        diff --git a/app/components/editor/UnsavedChangesBlocker.tsx b/app/components/editor/UnsavedChangesBlocker.tsx index 129f24e79..ba9fa603d 100644 --- a/app/components/editor/UnsavedChangesBlocker.tsx +++ b/app/components/editor/UnsavedChangesBlocker.tsx @@ -1,4 +1,4 @@ -import { Modal } from "@core/shared-ui"; +import { Modal, Button } from "@core/shared-ui"; export interface UnsavedChangesBlockerProps { open: boolean; @@ -21,18 +21,18 @@ export function UnsavedChangesBlocker({ open, onStay, onLeave }: UnsavedChangesB size="sm" footer={ <> - - + } > diff --git a/app/components/editor/batch-action-bar.test.ts b/app/components/editor/batch-action-bar.test.ts index 1971f3fa8..a4c717c84 100644 --- a/app/components/editor/batch-action-bar.test.ts +++ b/app/components/editor/batch-action-bar.test.ts @@ -52,8 +52,10 @@ describe('BatchActionBar', () => { it('renders one rating button per level', () => { const html = render(baseProps); - // Two rating levels → two data-rating-id attributes - const matches = [...html.matchAll(/data-rating-id="[^"]+"/g)]; + // Two rating levels → two radiogroup tiles (RatingSegment renders + // role="radio" per option; the former data-rating-id attribute was an + // implementation detail of the pre-migration hand-rolled tiles). + const matches = [...html.matchAll(/role="radio"/g)]; expect(matches).toHaveLength(2); }); diff --git a/app/components/inspection-edit/CompliancePanel.tsx b/app/components/inspection-edit/CompliancePanel.tsx index ca7cba481..a840cc5d8 100644 --- a/app/components/inspection-edit/CompliancePanel.tsx +++ b/app/components/inspection-edit/CompliancePanel.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from "react"; import { useFetcher, useRevalidator } from "react-router"; -import { Modal } from "@core/shared-ui"; +import { Button, Modal, SegmentedControl } from "@core/shared-ui"; import { ConfirmDialog } from "~/components/ConfirmDialog"; /** @@ -198,14 +198,9 @@ function SignoffRoleCard({ role, existing }: { role: SignoffRole; existing: Repo {existing.signedAt ? (
        Signed {new Date(existing.signedAt).toLocaleString()}
        ) : null} - +
        ) : (
        @@ -239,14 +234,15 @@ function SignoffRoleCard({ role, existing }: { role: SignoffRole; existing: Repo /> Dual role (same person signs both) - +
        )} @@ -315,22 +311,18 @@ function PsqPanel({ psq }: { psq: PsqView | null }) {
        Status - {PSQ_STATUSES.map((s) => ( - - ))} +
        + { + const next = v as "sent" | "received" | "declined"; + if (next === "declined") setDeclineOpen(true); + else applyStatus(next); + }} + options={PSQ_STATUSES.map((s) => ({ value: s.value, label: s.label }))} + /> +
        @@ -356,20 +348,12 @@ function PsqPanel({ psq }: { psq: PsqView | null }) { size="sm" footer={ <> - - + + } > @@ -495,14 +479,14 @@ function DocReviewSection({ items }: { items: DocumentReviewItemView[] }) { {items.length === 0 ? (

        No documents tracked yet.

        - +
        ) : (
          diff --git a/app/components/media-studio/ItemPhotoStrip.tsx b/app/components/media-studio/ItemPhotoStrip.tsx index 319a706f8..af45b01c4 100644 --- a/app/components/media-studio/ItemPhotoStrip.tsx +++ b/app/components/media-studio/ItemPhotoStrip.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from "react"; import Sortable from "sortablejs"; +import { Button } from "@core/shared-ui"; import { resolvePhotoDisplayKey } from "./photo-display-key"; export interface StripPhoto { @@ -170,13 +171,9 @@ export function ItemPhotoStrip({
          {selecting ? ( <> - +
          {onBulkMove && moveTargets && moveTargets.length > 0 && ( )} {onBulkDetach && ( - + )}
          ) : ( - + )}
          )} diff --git a/app/components/portal/sections/ReportView.tsx b/app/components/portal/sections/ReportView.tsx index f871f3824..083674721 100644 --- a/app/components/portal/sections/ReportView.tsx +++ b/app/components/portal/sections/ReportView.tsx @@ -30,7 +30,6 @@ import { ReportRepairPanel } from "./report/ReportRepairPanel"; import { BuildingProfile } from "./report/BuildingProfile"; import { PcaSkeleton } from "./report/PcaSkeleton"; import { ReportToc } from "./report/ReportToc"; -import { PagedTocInjection } from "./report/PagedTocInjection"; import { PerUnitReportBlock } from "./report/PerUnitReportBlock"; import { CostTables } from "./report/CostTables"; import { WordExportButton } from "./report/WordExportButton"; @@ -131,7 +130,7 @@ export function reportViewProps( reportTheme: data.reportTheme, initialFilter: data.initialFilter ?? "all", printMode: data.printMode ?? false, - pagedToc: data.pagedToc ?? false, + tocPages: data.tocPages, isPublished: data.isPublished ?? false, signature: data.signature ?? null, verification: data.verification ?? null, @@ -202,10 +201,6 @@ function mediaTileKey(photo: ReportPhoto, idx: number): string { } } -/* ------------------------------------------------------------------ */ -/* Gated Paged.js TOC page-number injection (Commercial PCA Phase O 7-9) */ -/* ------------------------------------------------------------------ */ - /* ------------------------------------------------------------------ */ /* Component */ /* ------------------------------------------------------------------ */ @@ -517,18 +512,11 @@ export function ReportView(props: ReportViewProps) { {/* Commercial PCA Phase O — reserved TOC slot: after the cover/header front matter, before the PcaSkeleton body. `?? []` guards the inline-Hub mount that may pass a partial payload during - transition; ReportToc itself renders nothing when empty. */} - - {/* Commercial PCA Phase O Tasks 7–9 (GATED, opt-in). Injected ONLY when - the render URL carried `?print=1&pagedtoc=1`. When the gate is off — - the DEFAULT, incl. the current production generatePdfFromUrl which - does NOT send the param — none of this renders and the report is - byte-for-byte unchanged (no Paged.js script, no target-counter CSS, - no re-pagination). Client layer only: this proves Paged.js fills the - TOC page numbers via `target-counter` in a real browser. The CF-side - readiness wait + @page/pdfOptions reconciliation are still unverified; - see scripts/spike/pagedjs-cf-spike.md. */} - {data.pagedToc === true && } + transition; ReportToc itself renders nothing when empty. + Task 19a — `tocPages` (undefined on the web + PDF pass 1) fills the + reserved page-ref slot with real page numbers on pass 2, resolved + server-side by extractAnchorPages against the pass-1 render. */} + caller). - * When the gate is off nothing here renders and the report is byte-identical to - * the pre-Paged.js output — so the current production PDF (which never sends the - * param) is completely untouched. - * - * Readiness signal: the vendored `paged.polyfill.js` auto-runs on document-ready - * and reads `window.PagedConfig`. We set `PagedConfig.after(done)` — Paged.js - * invokes it AFTER the preview flow finishes re-paginating — to raise - * `window.__pagedReady = true` and stamp `data-paged-done="1"` on . The CF - * follow-up will wait on that signal before capturing the PDF (the spike's - * Step-3 risk: `quickAction('pdf')` may not expose an in-page wait hook — see - * scripts/spike/pagedjs-cf-spike.md). The inline PagedConfig script MUST - * precede the polyfill - - -
          -

          Table of Contents

          - - - -
          -
          Front matter / TOC page filler
          -

          Section A — Roofing

          -
          Section A body
          -

          Section B — Electrical

          -
          Section B body
          -

          Section C — Plumbing

          -
          Section C body
          - -`; - -test('Paged.js fills TOC page numbers via target-counter, increasing down the list', async ({ page }, testInfo) => { - await page.setContent(FIXTURE, { waitUntil: 'load' }); - // Inject the vendored polyfill bundle. It auto-runs on document-ready, reads - // window.PagedConfig (already set by the fixture's inline script), paginates, - // then fires PagedConfig.after → data-paged-done. - await page.addScriptTag({ content: POLYFILL }); - - // Wait on the SAME readiness signal the CF follow-up will wait on. - await page.waitForFunction(() => (window as unknown as { __pagedReady?: boolean }).__pagedReady === true, null, { - timeout: 20000, - }); - await expect(page.locator('html[data-paged-done="1"]')).toHaveCount(1); - - // Read, for each TOC page-ref, (a) its resolved ::after content and (b) the - // real page number the linked heading landed on. - // - // Paged.js resolves `target-counter()` by rewriting each ::after into a - // page-scoped NAMED counter — `content: counter(target-counter-)` — - // and RESET-ing that counter to the target page's number on the page where - // the target lands. So getComputedStyle returns the `counter(...)` expression - // (proof the polyfill processed target-counter — native headless Chrome, with - // no target-counter support, would compute `normal` / empty here), while the - // VISIBLE integer equals the target heading's `.pagedjs_page[data-page-number]`. - const rows = await page.evaluate(() => { - const pageNumberOf = (id: string): number => { - const target = document.getElementById(id); - const pageEl = target?.closest('.pagedjs_page'); - return pageEl ? Number(pageEl.getAttribute('data-page-number')) : NaN; - }; - const anchors = Array.from( - document.querySelectorAll('.pagedjs_pages .toc-pageref'), - ); - return anchors.map((el) => { - const content = getComputedStyle(el, '::after').content ?? ''; - const href = (el.getAttribute('href') ?? '').replace('#', ''); - return { content, href, page: pageNumberOf(href) }; - }); - }); - - // Three TOC entries → three page-refs, in document order. - expect(rows).toHaveLength(3); - for (const r of rows) { - // The polyfill rewrote target-counter into a scoped counter — this is the - // client-layer proof that `target-counter(attr(href url), page)` was - // processed (a native render leaves `normal`/empty here). - expect(r.content, `::after should be a polyfilled counter() (got ${JSON.stringify(r.content)})`).toContain('counter('); - // The linked heading resolved to a real, positive page number. - expect(Number.isFinite(r.page), `resolved page should be numeric (href=${r.href})`).toBe(true); - expect(r.page).toBeGreaterThan(0); - } - // The rendered TOC page numbers strictly increase down the list (each section - // lands on a later page than the previous, given the tall filler). - expect(rows[1].page).toBeGreaterThan(rows[0].page); - expect(rows[2].page).toBeGreaterThan(rows[1].page); - - // Capture proof for human signoff: the paginated fixture with visible numbers. - // Write into Playwright's per-test output dir (cross-platform; a hardcoded - // Windows path fails on the Linux CI runner) and attach it to the report. - await page.screenshot({ path: testInfo.outputPath('paged-toc-proof.png'), fullPage: true }); -}); diff --git a/tests/fixtures/toc-fixture.pdf b/tests/fixtures/toc-fixture.pdf new file mode 100644 index 000000000..fd0e28320 Binary files /dev/null and b/tests/fixtures/toc-fixture.pdf differ diff --git a/tests/unit/messaging/telnyx-rest-client.spec.ts b/tests/unit/messaging/telnyx-rest-client.spec.ts new file mode 100644 index 000000000..840b60435 --- /dev/null +++ b/tests/unit/messaging/telnyx-rest-client.spec.ts @@ -0,0 +1,230 @@ +// tests/unit/messaging/telnyx-rest-client.spec.ts +// +// Per-operation contract tests for the fetch-based Telnyx REST client that +// replaces the `telnyx` SDK behind TelnyxComplianceClient. Mocks global fetch +// and asserts exact METHOD, URL, auth header, and JSON body for every +// operation the compliance provider drives, plus response (un)wrapping and a +// non-2xx error path. +// +// Telnyx wraps most v2 responses in `{ data: {...} }`. Per the pinned SDK +// shapes documented in telnyx-compliance.ts, brand/campaign/phoneNumberCampaigns/ +// tollfree-verification-request responses are FLAT (the client unwraps +// `.data`), while messagingProfiles.create / availablePhoneNumbers.list / +// numberOrders.create stay WRAPPED (the client returns the envelope as-is). +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { createTelnyxRestClient } from '../../../server/lib/messaging/telnyx-rest-client'; + +const API_KEY = 'KEY0123456789'; +const BEARER = `Bearer ${API_KEY}`; + +function mockFetchOnce(status: number, body: unknown) { + const fetchMock = vi.fn(async () => new Response(JSON.stringify(body), { status })); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +} + +describe('createTelnyxRestClient — messaging10dlc.brand', () => { + afterEach(() => vi.restoreAllMocks()); + + it('create() POSTs JSON to /v2/10dlc/brand with Bearer auth and unwraps .data (flat)', async () => { + const fetchMock = mockFetchOnce(201, { data: { brandId: 'BR1', identityStatus: 'UNVERIFIED' } }); + const client = createTelnyxRestClient(API_KEY); + const out = await client.messaging10dlc.brand.create({ country: 'US', displayName: 'Acme' }); + expect(out).toEqual({ brandId: 'BR1', identityStatus: 'UNVERIFIED' }); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('https://api.telnyx.com/v2/10dlc/brand'); + expect(String(init.method).toUpperCase()).toBe('POST'); + expect((init.headers as Record).Authorization).toBe(BEARER); + expect((init.headers as Record)['Content-Type']).toBe('application/json'); + expect(JSON.parse(init.body as string)).toEqual({ country: 'US', displayName: 'Acme' }); + }); + + it('create() throws with the Telnyx error detail on a non-2xx response', async () => { + mockFetchOnce(422, { errors: [{ detail: 'Missing EIN' }] }); + const client = createTelnyxRestClient(API_KEY); + await expect(client.messaging10dlc.brand.create({ country: 'US' })).rejects.toThrow('Missing EIN'); + }); + + it('retrieve() GETs /v2/10dlc/brand/{id} and unwraps .data', async () => { + const fetchMock = mockFetchOnce(200, { data: { brandId: 'BR1', identityStatus: 'VERIFIED', failureReasons: null } }); + const client = createTelnyxRestClient(API_KEY); + const out = await client.messaging10dlc.brand.retrieve('BR1'); + expect(out).toEqual({ brandId: 'BR1', identityStatus: 'VERIFIED', failureReasons: null }); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('https://api.telnyx.com/v2/10dlc/brand/BR1'); + expect(String(init.method).toUpperCase()).toBe('GET'); + }); + + it('externalVetting.order() POSTs to /v2/10dlc/brand/{id}/externalVetting and unwraps .data', async () => { + const fetchMock = mockFetchOnce(201, { data: { vettingId: 'VET1' } }); + const client = createTelnyxRestClient(API_KEY); + const out = await client.messaging10dlc.brand.externalVetting.order('BR1', { evpId: 'AEGIS', vettingClass: 'STANDARD' }); + expect(out).toEqual({ vettingId: 'VET1' }); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('https://api.telnyx.com/v2/10dlc/brand/BR1/externalVetting'); + expect(JSON.parse(init.body as string)).toEqual({ evpId: 'AEGIS', vettingClass: 'STANDARD' }); + }); +}); + +describe('createTelnyxRestClient — messaging10dlc.campaign / campaignBuilder', () => { + afterEach(() => vi.restoreAllMocks()); + + it('campaignBuilder.submit() POSTs to /v2/10dlc/campaignBuilder and unwraps .data', async () => { + const fetchMock = mockFetchOnce(201, { data: { campaignId: 'CAMP1', campaignStatus: 'TCR_PENDING' } }); + const client = createTelnyxRestClient(API_KEY); + const out = await client.messaging10dlc.campaignBuilder.submit({ brandId: 'BR1', usecase: 'AGENTS_FRANCHISES' }); + expect(out).toEqual({ campaignId: 'CAMP1', campaignStatus: 'TCR_PENDING' }); + const [url] = fetchMock.mock.calls[0]; + expect(url).toBe('https://api.telnyx.com/v2/10dlc/campaignBuilder'); + }); + + it('campaignBuilder.submit() throws on non-2xx', async () => { + mockFetchOnce(400, { errors: [{ detail: 'Brand not approved' }] }); + const client = createTelnyxRestClient(API_KEY); + await expect(client.messaging10dlc.campaignBuilder.submit({ brandId: 'BR_BAD' })).rejects.toThrow('Brand not approved'); + }); + + it('campaign.retrieve() GETs /v2/10dlc/campaign/{id} and unwraps .data', async () => { + const fetchMock = mockFetchOnce(200, { data: { campaignId: 'CAMP1', campaignStatus: 'MNO_PROVISIONED', failureReasons: null } }); + const client = createTelnyxRestClient(API_KEY); + const out = await client.messaging10dlc.campaign.retrieve('CAMP1'); + expect(out).toEqual({ campaignId: 'CAMP1', campaignStatus: 'MNO_PROVISIONED', failureReasons: null }); + const [url] = fetchMock.mock.calls[0]; + expect(url).toBe('https://api.telnyx.com/v2/10dlc/campaign/CAMP1'); + }); +}); + +describe('createTelnyxRestClient — messaging10dlc.phoneNumberCampaigns', () => { + afterEach(() => vi.restoreAllMocks()); + + it('create() POSTs to /v2/10dlc/phoneNumberCampaigns and unwraps .data', async () => { + const fetchMock = mockFetchOnce(201, { data: { phoneNumber: '+15551110000', assignmentStatus: 'PENDING_ASSIGNMENT' } }); + const client = createTelnyxRestClient(API_KEY); + const out = await client.messaging10dlc.phoneNumberCampaigns.create({ campaignId: 'CAMP1', phoneNumber: '+15551110000' }); + expect(out).toEqual({ phoneNumber: '+15551110000', assignmentStatus: 'PENDING_ASSIGNMENT' }); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('https://api.telnyx.com/v2/10dlc/phoneNumberCampaigns'); + expect(JSON.parse(init.body as string)).toEqual({ campaignId: 'CAMP1', phoneNumber: '+15551110000' }); + }); + + it('create() throws on non-2xx', async () => { + mockFetchOnce(400, { errors: [{ detail: 'assign failed' }] }); + const client = createTelnyxRestClient(API_KEY); + await expect( + client.messaging10dlc.phoneNumberCampaigns.create({ campaignId: 'CAMP1', phoneNumber: '+1' }), + ).rejects.toThrow('assign failed'); + }); +}); + +describe('createTelnyxRestClient — messagingProfiles (data-wrapped)', () => { + afterEach(() => vi.restoreAllMocks()); + + it('create() POSTs to /v2/messaging_profiles and returns the envelope with .data intact', async () => { + const fetchMock = mockFetchOnce(201, { data: { id: 'MP1' } }); + const client = createTelnyxRestClient(API_KEY); + const out = await client.messagingProfiles.create({ name: 'Acme', whitelisted_destinations: ['US'] }); + expect(out).toEqual({ data: { id: 'MP1' } }); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('https://api.telnyx.com/v2/messaging_profiles'); + expect(JSON.parse(init.body as string)).toEqual({ name: 'Acme', whitelisted_destinations: ['US'] }); + }); +}); + +describe('createTelnyxRestClient — availablePhoneNumbers (data-wrapped, bracket filter query)', () => { + afterEach(() => vi.restoreAllMocks()); + + it('list() GETs /v2/available_phone_numbers with bracket-notation filter params', async () => { + const fetchMock = mockFetchOnce(200, { data: [{ phone_number: '+15551110000' }] }); + const client = createTelnyxRestClient(API_KEY); + const out = await client.availablePhoneNumbers.list({ + filter: { country_code: 'US', features: ['SMS'], limit: 1, national_destination_code: '512' }, + }); + expect(out).toEqual({ data: [{ phone_number: '+15551110000' }] }); + const [url, init] = fetchMock.mock.calls[0]; + expect(String(init.method).toUpperCase()).toBe('GET'); + expect(url).toContain('https://api.telnyx.com/v2/available_phone_numbers?'); + expect(url).toContain('filter%5Bcountry_code%5D=US'); + expect(url).toContain('filter%5Bfeatures%5D%5B%5D=SMS'); + expect(url).toContain('filter%5Blimit%5D=1'); + expect(url).toContain('filter%5Bnational_destination_code%5D=512'); + }); + + it('list() omits keys that are not provided in the filter', async () => { + const fetchMock = mockFetchOnce(200, { data: [] }); + const client = createTelnyxRestClient(API_KEY); + await client.availablePhoneNumbers.list({ filter: { country_code: 'US', phone_number_type: 'toll_free', features: ['SMS'], limit: 1 } }); + const [url] = fetchMock.mock.calls[0]; + expect(url).not.toContain('national_destination_code'); + expect(url).toContain('filter%5Bphone_number_type%5D=toll_free'); + }); +}); + +describe('createTelnyxRestClient — numberOrders (data-wrapped)', () => { + afterEach(() => vi.restoreAllMocks()); + + it('create() POSTs to /v2/number_orders and returns the envelope with .data intact', async () => { + const fetchMock = mockFetchOnce(201, { + data: { id: 'ORD1', phone_numbers: [{ id: 'PNUM1', phone_number: '+15551110000' }] }, + }); + const client = createTelnyxRestClient(API_KEY); + const out = await client.numberOrders.create({ + phone_numbers: [{ phone_number: '+15551110000' }], + messaging_profile_id: 'MP1', + }); + expect(out).toEqual({ data: { id: 'ORD1', phone_numbers: [{ id: 'PNUM1', phone_number: '+15551110000' }] } }); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('https://api.telnyx.com/v2/number_orders'); + expect(JSON.parse(init.body as string)).toEqual({ + phone_numbers: [{ phone_number: '+15551110000' }], + messaging_profile_id: 'MP1', + }); + }); + + it('create() throws on non-2xx', async () => { + mockFetchOnce(400, { errors: [{ detail: 'No inventory' }] }); + const client = createTelnyxRestClient(API_KEY); + await expect(client.numberOrders.create({ phone_numbers: [{ phone_number: '+1' }] })).rejects.toThrow('No inventory'); + }); +}); + +describe('createTelnyxRestClient — messagingTollfree.verification.requests (flat)', () => { + afterEach(() => vi.restoreAllMocks()); + + it('create() POSTs to /v2/messaging_tollfree/verification/requests and unwraps .data', async () => { + const fetchMock = mockFetchOnce(201, { + data: { id: 'TFV_DB_ID', verificationRequestId: 'TFV_REQ1', verificationStatus: 'In Progress' }, + }); + const client = createTelnyxRestClient(API_KEY); + const out = await client.messagingTollfree.verification.requests.create({ businessName: 'Acme' }); + expect(out).toEqual({ id: 'TFV_DB_ID', verificationRequestId: 'TFV_REQ1', verificationStatus: 'In Progress' }); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('https://api.telnyx.com/v2/messaging_tollfree/verification/requests'); + expect(String(init.method).toUpperCase()).toBe('POST'); + }); + + it('create() throws on non-2xx', async () => { + mockFetchOnce(422, { errors: [{ detail: 'Invalid phone number' }] }); + const client = createTelnyxRestClient(API_KEY); + await expect(client.messagingTollfree.verification.requests.create({ businessName: 'x' })).rejects.toThrow('Invalid phone number'); + }); + + it('retrieve() GETs /v2/messaging_tollfree/verification/requests/{id} and unwraps .data', async () => { + const fetchMock = mockFetchOnce(200, { data: { id: 'TFV_DB_ID', verificationStatus: 'Verified', reason: null } }); + const client = createTelnyxRestClient(API_KEY); + const out = await client.messagingTollfree.verification.requests.retrieve('TFV_DB_ID'); + expect(out).toEqual({ id: 'TFV_DB_ID', verificationStatus: 'Verified', reason: null }); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('https://api.telnyx.com/v2/messaging_tollfree/verification/requests/TFV_DB_ID'); + expect(String(init.method).toUpperCase()).toBe('GET'); + }); +}); + +describe('createTelnyxRestClient — error path without a JSON error body', () => { + afterEach(() => vi.restoreAllMocks()); + + it('falls back to a generic "Telnyx {status}" message when the error body has no detail', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response('', { status: 500 }))); + const client = createTelnyxRestClient(API_KEY); + await expect(client.messaging10dlc.brand.create({ country: 'US' })).rejects.toThrow('Telnyx 500'); + }); +}); diff --git a/tests/unit/messaging/twilio-rest-client.spec.ts b/tests/unit/messaging/twilio-rest-client.spec.ts new file mode 100644 index 000000000..88dfc7344 --- /dev/null +++ b/tests/unit/messaging/twilio-rest-client.spec.ts @@ -0,0 +1,187 @@ +// tests/unit/messaging/twilio-rest-client.spec.ts +// +// Per-operation contract tests for the fetch-based Twilio REST client that +// replaces the twilio-node SDK behind TwilioComplianceClient. Mocks global +// fetch and asserts exact METHOD, URL, auth header, and body encoding for +// every operation the compliance provider drives, plus response parsing and +// a non-2xx error path. +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { createTwilioRestClient } from '../../../server/lib/messaging/twilio-rest-client'; + +const CREDS = { accountSid: 'ACmain', apiKeySid: 'SKkey', apiKeySecret: 'secret123' }; +const BASIC_AUTH = `Basic ${btoa('SKkey:secret123')}`; + +function mockFetchOnce(status: number, body: unknown, headers?: Record) { + const fetchMock = vi.fn(async () => new Response(JSON.stringify(body), { status, headers })); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +} + +describe('createTwilioRestClient — generic request()', () => { + afterEach(() => vi.restoreAllMocks()); + + it('POSTs to an absolute URI with Basic auth + form-encoded body, and does NOT throw on non-2xx', async () => { + const fetchMock = mockFetchOnce(400, { message: 'bad request' }); + const client = createTwilioRestClient(CREDS); + const resp = await client.request({ + method: 'post', + uri: 'https://trusthub.twilio.com/v1/CustomerProfiles', + data: { FriendlyName: 'Acme', Email: 'a@b.com' }, + }); + expect(resp).toEqual({ statusCode: 400, body: { message: 'bad request' } }); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('https://trusthub.twilio.com/v1/CustomerProfiles'); + expect(String(init.method).toUpperCase()).toBe('POST'); + expect((init.headers as Record).Authorization).toBe(BASIC_AUTH); + expect((init.headers as Record)['Content-Type']).toBe('application/x-www-form-urlencoded'); + expect(init.body).toBe('FriendlyName=Acme&Email=a%40b.com'); + }); + + it('passes through a 2xx body unchanged', async () => { + mockFetchOnce(201, { sid: 'BUx', status: 'PENDING' }); + const client = createTwilioRestClient(CREDS); + const resp = await client.request({ + method: 'post', + uri: 'https://messaging.twilio.com/v1/Tollfree/Verifications', + data: { TollfreePhoneNumberSid: 'PN1' }, + }); + expect(resp).toEqual({ statusCode: 201, body: { sid: 'BUx', status: 'PENDING' } }); + }); +}); + +describe('createTwilioRestClient — messaging.v1.brandRegistrations', () => { + afterEach(() => vi.restoreAllMocks()); + + it('create() POSTs to /v1/a2p/BrandRegistrations with mapped form fields and returns sid+status', async () => { + const fetchMock = mockFetchOnce(201, { sid: 'BNx', status: 'PENDING' }); + const client = createTwilioRestClient(CREDS); + const out = await client.messaging.v1.brandRegistrations.create({ + customerProfileBundleSid: 'BUx', + a2PProfileBundleSid: 'BUy', + brandType: 'SOLE_PROPRIETOR', + }); + expect(out).toEqual({ sid: 'BNx', status: 'PENDING' }); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('https://messaging.twilio.com/v1/a2p/BrandRegistrations'); + expect(String(init.method).toUpperCase()).toBe('POST'); + expect((init.headers as Record).Authorization).toBe(BASIC_AUTH); + expect(init.body).toContain('CustomerProfileBundleSid=BUx'); + expect(init.body).toContain('A2PProfileBundleSid=BUy'); + expect(init.body).toContain('BrandType=SOLE_PROPRIETOR'); + }); + + it('create() throws with the Twilio error message on a non-2xx response', async () => { + mockFetchOnce(400, { message: 'Bundle not approved' }); + const client = createTwilioRestClient(CREDS); + await expect( + client.messaging.v1.brandRegistrations.create({ + customerProfileBundleSid: 'BUx', a2PProfileBundleSid: 'BUy', brandType: 'SOLE_PROPRIETOR', + }), + ).rejects.toThrow('Bundle not approved'); + }); + + it('list() GETs /v1/a2p/BrandRegistrations and maps the data array to sid+status', async () => { + const fetchMock = mockFetchOnce(200, { data: [{ sid: 'BN1', status: 'APPROVED' }] }); + const client = createTwilioRestClient(CREDS); + const out = await client.messaging.v1.brandRegistrations.list(); + expect(out).toEqual([{ sid: 'BN1', status: 'APPROVED' }]); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('https://messaging.twilio.com/v1/a2p/BrandRegistrations'); + expect(String(init.method).toUpperCase()).toBe('GET'); + }); +}); + +describe('createTwilioRestClient — messaging.v1.services', () => { + afterEach(() => vi.restoreAllMocks()); + + it('create() POSTs to /v1/Services with FriendlyName and returns sid', async () => { + const fetchMock = mockFetchOnce(201, { sid: 'MGx' }); + const client = createTwilioRestClient(CREDS); + const out = await client.messaging.v1.services.create({ friendlyName: 'Acme Inspections' }); + expect(out).toEqual({ sid: 'MGx' }); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('https://messaging.twilio.com/v1/Services'); + expect(init.body).toContain('FriendlyName=Acme+Inspections'); + }); + + it('create() throws on non-2xx', async () => { + mockFetchOnce(400, { message: 'FriendlyName is required' }); + const client = createTwilioRestClient(CREDS); + await expect(client.messaging.v1.services.create({ friendlyName: '' })).rejects.toThrow('FriendlyName is required'); + }); + + it('(sid).phoneNumbers.create() POSTs PhoneNumberSid to /v1/Services/{sid}/PhoneNumbers', async () => { + const fetchMock = mockFetchOnce(201, { sid: 'ASx' }); + const client = createTwilioRestClient(CREDS); + const out = await client.messaging.v1.services('MG3').phoneNumbers.create({ phoneNumberSid: 'PN5' }); + expect(out).toEqual({ sid: 'ASx' }); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('https://messaging.twilio.com/v1/Services/MG3/PhoneNumbers'); + expect(init.body).toContain('PhoneNumberSid=PN5'); + }); + + it('(sid).phoneNumbers.create() throws on non-2xx', async () => { + mockFetchOnce(404, { message: 'Phone number not found' }); + const client = createTwilioRestClient(CREDS); + await expect( + client.messaging.v1.services('MG3').phoneNumbers.create({ phoneNumberSid: 'PN_BAD' }), + ).rejects.toThrow('Phone number not found'); + }); +}); + +describe('createTwilioRestClient — messaging.v1.tollfreeVerifications', () => { + afterEach(() => vi.restoreAllMocks()); + + it('list() GETs /v1/Tollfree/Verifications and maps the verifications array', async () => { + const fetchMock = mockFetchOnce(200, { verifications: [{ sid: 'HH1', status: 'TWILIO_APPROVED' }] }); + const client = createTwilioRestClient(CREDS); + const out = await client.messaging.v1.tollfreeVerifications.list(); + expect(out).toEqual([{ sid: 'HH1', status: 'TWILIO_APPROVED' }]); + const [url] = fetchMock.mock.calls[0]; + expect(url).toBe('https://messaging.twilio.com/v1/Tollfree/Verifications'); + }); +}); + +describe('createTwilioRestClient — availablePhoneNumbers', () => { + afterEach(() => vi.restoreAllMocks()); + + it('local.list() GETs AvailablePhoneNumbers/{country}/Local.json with AreaCode', async () => { + const fetchMock = mockFetchOnce(200, { available_phone_numbers: [{ phone_number: '+15125550001' }] }); + const client = createTwilioRestClient(CREDS); + const out = await client.availablePhoneNumbers('US').local.list({ areaCode: '512' }); + expect(out).toEqual([{ phoneNumber: '+15125550001' }]); + const [url] = fetchMock.mock.calls[0]; + expect(url).toContain('api.twilio.com/2010-04-01/Accounts/ACmain/AvailablePhoneNumbers/US/Local.json'); + expect(url).toContain('AreaCode=512'); + }); + + it('tollFree.list() GETs AvailablePhoneNumbers/{country}/TollFree.json', async () => { + const fetchMock = mockFetchOnce(200, { available_phone_numbers: [{ phone_number: '+18005550001' }] }); + const client = createTwilioRestClient(CREDS); + const out = await client.availablePhoneNumbers('US').tollFree.list(); + expect(out).toEqual([{ phoneNumber: '+18005550001' }]); + const [url] = fetchMock.mock.calls[0]; + expect(url).toContain('AvailablePhoneNumbers/US/TollFree.json'); + expect(url).not.toContain('AreaCode'); + }); +}); + +describe('createTwilioRestClient — incomingPhoneNumbers', () => { + afterEach(() => vi.restoreAllMocks()); + + it('create() POSTs PhoneNumber to IncomingPhoneNumbers.json and returns sid+phoneNumber', async () => { + const fetchMock = mockFetchOnce(201, { sid: 'PN9', phone_number: '+18005550001' }); + const client = createTwilioRestClient(CREDS); + const out = await client.incomingPhoneNumbers.create({ phoneNumber: '+18005550001' }); + expect(out).toEqual({ sid: 'PN9', phoneNumber: '+18005550001' }); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('https://api.twilio.com/2010-04-01/Accounts/ACmain/IncomingPhoneNumbers.json'); + expect(init.body).toContain('PhoneNumber=%2B18005550001'); + }); + + it('create() throws on non-2xx', async () => { + mockFetchOnce(400, { message: 'Phone number unavailable' }); + const client = createTwilioRestClient(CREDS); + await expect(client.incomingPhoneNumbers.create({ phoneNumber: '+18005550001' })).rejects.toThrow('Phone number unavailable'); + }); +}); diff --git a/tests/unit/reports/pdf.spec.ts b/tests/unit/reports/pdf.spec.ts index e5df9a0ae..94b3d003a 100644 --- a/tests/unit/reports/pdf.spec.ts +++ b/tests/unit/reports/pdf.spec.ts @@ -1,7 +1,15 @@ import { describe, it, expect, vi } from 'vitest'; -import { generatePdfFromUrl } from '../../../server/lib/pdf'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { generatePdfFromUrl, generatePdfWithTocPages } from '../../../server/lib/pdf'; import type { BrowserRun } from '../../../server/types/hono'; +const __dirname = dirname(fileURLToPath(import.meta.url)); +// Same fixture as tests/unit/reports/toc-pages.spec.ts — a real Chrome-rendered +// PDF whose TOC anchors resolve to named destinations. +const TOC_FIXTURE = readFileSync(join(__dirname, '../../fixtures/toc-fixture.pdf')); + describe('generatePdfFromUrl', () => { it('returns ArrayBuffer when BROWSER binding renders successfully', async () => { const fakePdfBytes = new Uint8Array([0x25, 0x50, 0x44, 0x46]).buffer; // "%PDF" magic @@ -94,3 +102,52 @@ describe('generatePdfFromUrl', () => { .rejects.toThrow(/PDF rendering failed/i); }); }); + +describe('generatePdfWithTocPages', () => { + it('renders twice and appends ?tocpages= to the second pass when pass 1 has a TOC', async () => { + const browser = { + quickAction: vi.fn() + .mockResolvedValueOnce({ ok: true, arrayBuffer: () => Promise.resolve(TOC_FIXTURE.buffer.slice(TOC_FIXTURE.byteOffset, TOC_FIXTURE.byteOffset + TOC_FIXTURE.byteLength)) }) + .mockResolvedValueOnce({ ok: true, arrayBuffer: () => Promise.resolve(new ArrayBuffer(4)) }), + }; + const result = await generatePdfWithTocPages(browser as unknown as BrowserRun, 'https://example.com/report/abc'); + + expect(browser.quickAction).toHaveBeenCalledTimes(2); + expect(result).toBeInstanceOf(ArrayBuffer); + + const [, pass2Opts] = browser.quickAction.mock.calls[1]; + const pass2Url: string = pass2Opts.url; + expect(pass2Url).toMatch(/[?&]tocpages=/); + const tocParam = new URL(pass2Url).searchParams.get('tocpages'); + expect(tocParam).toBeTruthy(); + const decoded = JSON.parse(atob(decodeURIComponent(tocParam as string))); + expect(decoded).toEqual({ roof: 3, structural: 5, mechanical: 7, electrical: 9, plumbing: 11 }); + }); + + it('skips the second pass and returns pass 1 unchanged when there is no TOC', async () => { + const fakePdfBytes = new Uint8Array([0x25, 0x50, 0x44, 0x46]).buffer; // "%PDF" magic, no named dests + const browser = { + quickAction: vi.fn().mockResolvedValue({ ok: true, arrayBuffer: () => Promise.resolve(fakePdfBytes) }), + }; + const result = await generatePdfWithTocPages(browser as unknown as BrowserRun, 'https://example.com/report/abc'); + + expect(browser.quickAction).toHaveBeenCalledTimes(1); + expect(result).toBe(fakePdfBytes); + }); + + it('preserves footer/settings opts across both passes', async () => { + const browser = { + quickAction: vi.fn() + .mockResolvedValueOnce({ ok: true, arrayBuffer: () => Promise.resolve(TOC_FIXTURE.buffer.slice(TOC_FIXTURE.byteOffset, TOC_FIXTURE.byteOffset + TOC_FIXTURE.byteLength)) }) + .mockResolvedValueOnce({ ok: true, arrayBuffer: () => Promise.resolve(new ArrayBuffer(4)) }), + }; + await generatePdfWithTocPages(browser as unknown as BrowserRun, 'https://example.com/report/abc', { + address: '123 Main St', + settings: { showFooter: true, showPageNumbers: true, showLicense: true, companyAddress: null }, + }); + const [, pass1Opts] = browser.quickAction.mock.calls[0]; + const [, pass2Opts] = browser.quickAction.mock.calls[1]; + expect(pass1Opts.pdfOptions.footerTemplate).toContain('123 Main St'); + expect(pass2Opts.pdfOptions.footerTemplate).toContain('123 Main St'); + }); +}); diff --git a/tests/unit/reports/report-pdf.service.spec.ts b/tests/unit/reports/report-pdf.service.spec.ts index f6bbebf71..0e059845b 100644 --- a/tests/unit/reports/report-pdf.service.spec.ts +++ b/tests/unit/reports/report-pdf.service.spec.ts @@ -9,8 +9,9 @@ import { drizzle as mockDrizzle } from 'drizzle-orm/d1'; vi.mock('../../../server/lib/pdf', () => ({ generatePdfFromUrl: vi.fn(async () => new ArrayBuffer(1024)), + generatePdfWithTocPages: vi.fn(async () => new ArrayBuffer(4096)), })); -import { generatePdfFromUrl } from '../../../server/lib/pdf'; +import { generatePdfFromUrl, generatePdfWithTocPages } from '../../../server/lib/pdf'; const TENANT_A = '00000000-0000-0000-0000-000000000001'; const INSP_1 = '00000000-0000-0000-0000-0000000000b1'; @@ -38,8 +39,10 @@ describe('ReportPdfService', () => { await seed(testDb); svc = new ReportPdfService({} as D1Database, mockBrowser, mockR2); vi.clearAllMocks(); + // The service always renders through generatePdfWithTocPages (it + // self-short-circuits to a single pass when there are no anchors). // eslint-disable-next-line @typescript-eslint/no-explicit-any - (generatePdfFromUrl as any).mockResolvedValue(new ArrayBuffer(2048)); + (generatePdfWithTocPages as any).mockResolvedValue(new ArrayBuffer(2048)); }); it('returns null when no PDF record exists', async () => { @@ -67,7 +70,7 @@ describe('ReportPdfService', () => { sourceVersion: 1, }); // No footer passed in this path → third arg is undefined (footer is optional). - expect(generatePdfFromUrl).toHaveBeenCalledWith( + expect(generatePdfWithTocPages).toHaveBeenCalledWith( mockBrowser, 'https://example.com/report/insp-1?summary=1', undefined, @@ -102,6 +105,18 @@ describe('ReportPdfService', () => { ).rejects.toThrow(/storage bucket binding not configured/); }); + it('Task 19a — renderAndStore always renders through the two-pass TOC path', async () => { + // generatePdfWithTocPages self-short-circuits to a single pass when the + // report has no intra-doc anchors, so the service calls it unconditionally + // and never calls generatePdfFromUrl directly. + await svc.renderAndStore(INSP_1, TENANT_A, 'full', { + reportUrl: 'https://example.com/report/insp-1', + sourceVersion: 1, + }); + expect(generatePdfWithTocPages).toHaveBeenCalledWith(mockBrowser, 'https://example.com/report/insp-1', undefined); + expect(generatePdfFromUrl).not.toHaveBeenCalled(); + }); + it('markQueued creates placeholder when no record exists', async () => { await svc.markQueued(INSP_1, TENANT_A, 'full'); const row = await svc.getPdfRecord(INSP_1, TENANT_A, 'full'); @@ -150,7 +165,7 @@ describe('ReportPdfService', () => { }); vi.clearAllMocks(); // eslint-disable-next-line @typescript-eslint/no-explicit-any - (generatePdfFromUrl as any).mockResolvedValue(new ArrayBuffer(2048)); + (generatePdfWithTocPages as any).mockResolvedValue(new ArrayBuffer(2048)); (mockR2 as any).put = vi.fn(async () => undefined); const rec = await svc.getOrRender(INSP_1, TENANT_A, 'full', { @@ -159,7 +174,7 @@ describe('ReportPdfService', () => { versionNumber: 1, }); - expect(generatePdfFromUrl).toHaveBeenCalledTimes(0); + expect(generatePdfWithTocPages).toHaveBeenCalledTimes(0); expect(rec.status).toBe('ready'); expect(rec.contentHash).toBe(HASH_H1); }); @@ -174,7 +189,7 @@ describe('ReportPdfService', () => { }); vi.clearAllMocks(); // eslint-disable-next-line @typescript-eslint/no-explicit-any - (generatePdfFromUrl as any).mockResolvedValue(new ArrayBuffer(2048)); + (generatePdfWithTocPages as any).mockResolvedValue(new ArrayBuffer(2048)); (mockR2 as any).put = vi.fn(async () => undefined); // Call with different hash H2 → must render. @@ -184,7 +199,7 @@ describe('ReportPdfService', () => { versionNumber: 2, }); - expect(generatePdfFromUrl).toHaveBeenCalledTimes(1); + expect(generatePdfWithTocPages).toHaveBeenCalledTimes(1); expect(rec.status).toBe('ready'); expect(rec.contentHash).toBe(HASH_H2); // Content-addressed R2 key must incorporate the hash. @@ -199,7 +214,7 @@ describe('ReportPdfService', () => { versionNumber: null, }); - expect(generatePdfFromUrl).toHaveBeenCalledTimes(1); + expect(generatePdfWithTocPages).toHaveBeenCalledTimes(1); expect(rec.status).toBe('ready'); expect(rec.contentHash).toBe(HASH_H1); }); diff --git a/tests/unit/reports/toc-pages.spec.ts b/tests/unit/reports/toc-pages.spec.ts new file mode 100644 index 000000000..48a8b736e --- /dev/null +++ b/tests/unit/reports/toc-pages.spec.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { extractAnchorPages } from '../../../server/lib/toc-pages'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// Deterministic fixture produced by the validated spike +// (scripts/spike/toc-pdflib-spike.mjs) — a TOC of 5 entries +// followed by tall sections, rendered via Chromium's page.pdf(). Known-correct +// pages per scripts/spike/toc-pdflib-spike.md. +const FIXTURE_PATH = join(__dirname, '../../fixtures/toc-fixture.pdf'); + +describe('extractAnchorPages', () => { + it('resolves each named TOC anchor to its 1-based target page', async () => { + const bytes = readFileSync(FIXTURE_PATH); + const result = await extractAnchorPages(bytes); + expect(result).toEqual({ + roof: 3, + structural: 5, + mechanical: 7, + electrical: 9, + plumbing: 11, + }); + }); + + it('returns an empty map for a PDF with no named destinations', async () => { + // Minimal single-page PDF with no /Names /Dests tree and no link + // annotations — extractAnchorPages must resolve zero entries, not throw. + const { PDFDocument } = await import('pdf-lib'); + const doc = await PDFDocument.create(); + doc.addPage(); + const bytes = await doc.save(); + const result = await extractAnchorPages(bytes); + expect(result).toEqual({}); + }); + + it('never throws on malformed/garbage input — returns an empty map', async () => { + const garbage = new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x00, 0x01, 0x02]); // "%PDF" + junk + await expect(extractAnchorPages(garbage)).resolves.toEqual({}); + }); +}); diff --git a/tests/workers/twilio-sdk-fetch.spec.ts b/tests/workers/twilio-sdk-fetch.spec.ts deleted file mode 100644 index 63ca9849a..000000000 --- a/tests/workers/twilio-sdk-fetch.spec.ts +++ /dev/null @@ -1,44 +0,0 @@ -// tests/workers/twilio-sdk-fetch.spec.ts -import { afterEach, describe, expect, it, vi } from 'vitest'; -import twilio from 'twilio'; -import { createFetchHttpClient } from '../../server/lib/messaging/twilio-http-client'; - -describe('twilio-node v6 workerd smoke (fetch httpClient)', () => { - afterEach(() => { vi.unstubAllGlobals(); }); - - it('imports + constructs a Twilio client inside workerd without throwing', () => { - expect(() => { - const client = new twilio.Twilio('ACsmoke', 'smoketoken', { - httpClient: createFetchHttpClient() as never, - }); - expect(client.messaging.v1.services).toBeDefined(); - }).not.toThrow(); - }); - - it('routes a real resource call through the injected fetch httpClient', async () => { - let captured: { url: string; init: RequestInit } | undefined; - vi.stubGlobal('fetch', vi.fn(async (url: string, init: RequestInit) => { - captured = { url, init }; - return new Response( - JSON.stringify({ sid: 'MGsmoke0000000000000000000000000000', account_sid: 'ACsmoke', friendly_name: 'smoke-test' }), - { status: 201, headers: { 'Content-Type': 'application/json' } }, - ); - })); - - const client = new twilio.Twilio('ACsmoke', 'smoketoken', { - httpClient: createFetchHttpClient() as never, - }); - const service = await client.messaging.v1.services.create({ friendlyName: 'smoke-test' }); - - // (1) the SDK parsed the canned response into a resource instance - expect(service.sid).toBe('MGsmoke0000000000000000000000000000'); - expect(service.friendlyName).toBe('smoke-test'); - // (2) the SDK drove our injected fetch with the right shape - expect(captured).toBeDefined(); - expect(captured!.url).toContain('https://messaging.twilio.com/v1/Services'); - expect(String(captured!.init.method).toUpperCase()).toBe('POST'); // method arrives lowercase - const auth = (captured!.init.headers as Record).Authorization; - expect(auth).toBe('Basic ' + btoa('ACsmoke:smoketoken')); - expect(String(captured!.init.body)).toContain('FriendlyName=smoke-test'); - }); -}); diff --git a/vitest.workers.config.ts b/vitest.workers.config.ts index d15fa0166..ca9d82c2a 100644 --- a/vitest.workers.config.ts +++ b/vitest.workers.config.ts @@ -28,7 +28,7 @@ export default defineConfig({ // so twilio-node's module-load `require('os')` resolves under // nodejs_compat (node:os is compat-date-gated, not force-injected // by the pool). Prod runs 2026-05-22 on CF's newer binary; keep - // this at the local cap. See twilio-sdk-fetch.spec.ts. + // this at the local cap. compatibilityDate: '2026-04-12', compatibilityFlags: ['nodejs_compat'], d1Databases: { DB: 'test-sync-db' },