Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
86119a1
chore: add pdf-lib for two-pass TOC page-number extraction
important-new Jul 12, 2026
0264c6f
feat(shared-ui): add Button link + danger-link variants
important-new Jul 12, 2026
f025792
feat(shared-ui): add Button selected (aria-pressed) toggle state
important-new Jul 12, 2026
35a710c
feat(shared-ui): add IconButton square icon-only primitive
important-new Jul 12, 2026
6b9c600
chore: defer pdf-lib dep to Phase 4 (unused-until-imported fails dead…
important-new Jul 12, 2026
0b24dc1
feat(shared-ui): add MenuItem dropdown-row primitive
important-new Jul 12, 2026
6ab91aa
feat(shared-ui): add TabStrip vertical orientation
important-new Jul 12, 2026
5a9a740
feat(editor): add RatingSegment shared rating-tile component
important-new Jul 12, 2026
983c7d9
docs: document new shared-ui affordances (Button link/selected, IconB…
important-new Jul 12, 2026
3c33885
refactor(editor): migrate inspection-edit route + header/nav to share…
important-new Jul 12, 2026
6de690a
fix(shared-ui): compose classes with tailwind-merge so consumer class…
important-new Jul 12, 2026
fb1529c
refactor(editor): migrate cost/repair/compliance/report panels to sha…
important-new Jul 12, 2026
c88bdfd
refactor(editor): migrate section/item rails + dropdown menus to shar…
important-new Jul 12, 2026
45e4baa
fix(editor): Group C review — listbox option ARIA, TemplateMenu heigh…
important-new Jul 12, 2026
e239157
refactor(editor): migrate photo-strip + rating rows to shared-ui prim…
important-new Jul 12, 2026
6901c51
fix(editor): RatingSegment neutral-tone contrast + responsive short l…
important-new Jul 12, 2026
baf4597
refactor(editor): make SpeedMode theme-aware; migrate BurstCamera con…
important-new Jul 12, 2026
ff7f66e
fix(editor): SpeedMode coach scrim uses ih-backdrop (was invisible in…
important-new Jul 12, 2026
34e7202
refactor(editor): migrate confirm/prompt modals to shared-ui Button/I…
important-new Jul 12, 2026
9f71120
refactor(editor): migrate drawers, item editor + misc controls to sha…
important-new Jul 12, 2026
2db81d8
fix(editor): transparent dashed +Add CTAs (ghost) + MenuItem rich-con…
important-new Jul 12, 2026
c73b4cc
refactor(template-edit): migrate template editor to shared-ui primitives
important-new Jul 12, 2026
57c4864
chore: tighten deadcode baseline (RatingSegment/RatingOption now cons…
important-new Jul 12, 2026
8a7db39
feat(upload): batch photo fan-out with bounded concurrency + per-file…
important-new Jul 13, 2026
66c4511
feat(upload): multi-select + library/camera split for batch photo upload
important-new Jul 13, 2026
c297635
docs(spike): TOC page numbers via Chrome named-dests + pdf-lib — PASS
important-new Jul 13, 2026
c5a6d09
feat(report): TOC page numbers via two-pass pdf-lib extraction (repla…
important-new Jul 13, 2026
bd70157
perf(messaging): replace twilio/telnyx SDKs with fetch REST clients (…
important-new Jul 13, 2026
b235cfc
docs: refresh stale comments after SDK diet + TOC two-pass
important-new Jul 13, 2026
73cc0aa
fix(editor): correct menu/listbox ARIA roles (final review)
important-new Jul 13, 2026
64f7dc1
test(e2e): fix rating radiogroup assertion + TOC-e2e CSRF login (fork…
important-new Jul 13, 2026
f178f40
test(e2e): TOC-e2e wizard 2xx + data.id path + broaden no-render skip
important-new Jul 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 44 additions & 12 deletions app/components/RatingSystemEditor.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -218,14 +218,15 @@ export function RatingSystemEditor({
<div className="flex items-center gap-1.5">
<span className="text-[11px] text-ih-fg-4 mr-1">Start from</span>
{PRESETS.map((p) => (
<button
<Button
key={p.name}
type="button"
variant="secondary"
size="sm"
onClick={() => setLevels(p.levels.map((l) => ({ ...l })))}
className="h-6 px-2 rounded text-[11px] font-bold text-ih-primary border border-ih-border hover:bg-ih-primary-tint transition-colors"
className="h-6 px-2 text-[11px] text-ih-primary hover:bg-ih-primary-tint"
>
{p.name}
</button>
</Button>
))}
</div>
</div>
Expand Down Expand Up @@ -294,22 +295,53 @@ export function RatingSystemEditor({

{/* Reorder + remove */}
<div className="flex items-center shrink-0">
<button type="button" onClick={() => moveLevel(i, -1)} disabled={i === 0} className="w-6 h-7 text-ih-fg-4 hover:text-ih-fg-1 disabled:opacity-30 disabled:hover:text-ih-fg-4" title="Move up"><Icon name="chevU" size={14} /></button>
<button type="button" onClick={() => moveLevel(i, 1)} disabled={i === levels.length - 1} className="w-6 h-7 text-ih-fg-4 hover:text-ih-fg-1 disabled:opacity-30 disabled:hover:text-ih-fg-4" title="Move down"><Icon name="chevD" size={14} /></button>
<button type="button" onClick={() => setLevels((prev) => prev.filter((_, j) => j !== i))} disabled={levels.length <= 2} className="w-6 h-7 text-ih-fg-4 hover:text-ih-bad-fg disabled:opacity-30 disabled:hover:text-ih-fg-4" title="Remove level">✕</button>
<IconButton
aria-label="Move up"
variant="ghost"
size="sm"
onClick={() => moveLevel(i, -1)}
disabled={i === 0}
title="Move up"
className="w-6 text-ih-fg-4 hover:text-ih-fg-1"
>
<Icon name="chevU" size={14} />
</IconButton>
<IconButton
aria-label="Move down"
variant="ghost"
size="sm"
onClick={() => moveLevel(i, 1)}
disabled={i === levels.length - 1}
title="Move down"
className="w-6 text-ih-fg-4 hover:text-ih-fg-1"
>
<Icon name="chevD" size={14} />
</IconButton>
<IconButton
aria-label="Remove level"
variant="ghost"
size="sm"
onClick={() => 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"
>
<Icon name="x" size={14} />
</IconButton>
</div>
</div>
))}
</div>

{levels.length < 10 && (
<button
type="button"
<Button
variant="secondary"
size="sm"
onClick={() => setLevels((prev) => [...prev, blankLevel()])}
className="mt-2 w-full h-8 rounded-lg border border-dashed border-ih-border text-[12px] font-bold text-ih-fg-3 hover:text-ih-primary hover:border-ih-primary transition-colors"
className="mt-2 w-full h-8 rounded-lg border-dashed text-[12px] text-ih-fg-3 hover:text-ih-primary hover:border-ih-primary"
>
+ Add level
</button>
</Button>
)}
</div>

Expand Down
26 changes: 15 additions & 11 deletions app/components/editor-shared/ItemList.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -206,15 +207,16 @@ export function ItemList({
{/* Reserved ⋯ slot — own column, never overlaps the rating dot. */}
{structuralEditing && !batchMode && (
<div className={`shrink-0 w-6 flex items-center justify-center ${REVEAL}`}>
<button
<IconButton
onClick={(e) => { e.stopPropagation(); openItemMenu(item.id, e.currentTarget); }}
className="w-6 h-6 rounded-md flex items-center justify-center text-ih-fg-4 hover:text-ih-fg-2 hover:bg-ih-bg-muted focus-visible:outline focus-visible:outline-2 focus-visible:outline-ih-primary"
size="sm"
className="w-6 h-6 text-ih-fg-4 hover:text-ih-fg-2"
aria-label={`Edit ${item.label}`}
aria-haspopup="true"
aria-expanded={menuItemId === item.id}
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><circle cx="5" cy="12" r="1.6" /><circle cx="12" cy="12" r="1.6" /><circle cx="19" cy="12" r="1.6" /></svg>
</button>
</IconButton>
{menuItemId === item.id && menuAnchor && createPortal(
<>
<div className="fixed inset-0 z-[60]" onClick={closeItemMenu} />
Expand All @@ -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 && (
<button role="menuitem" onClick={(e) => { e.stopPropagation(); closeItemMenu(); setEditingId(item.id); }} className="w-full text-left px-3 py-1.5 text-ih-fg-2 hover:bg-ih-bg-muted">Rename</button>
<MenuItem onClick={(e) => { e.stopPropagation(); closeItemMenu(); setEditingId(item.id); }}>Rename</MenuItem>
)}
{onDuplicateItem && (
<button role="menuitem" onClick={(e) => { e.stopPropagation(); closeItemMenu(); onDuplicateItem(item.id); }} className="w-full text-left px-3 py-1.5 text-ih-fg-2 hover:bg-ih-bg-muted">Duplicate</button>
<MenuItem onClick={(e) => { e.stopPropagation(); closeItemMenu(); onDuplicateItem(item.id); }}>Duplicate</MenuItem>
)}
{onMoveItem && fullIdx > 0 && (
<button role="menuitem" onClick={(e) => { e.stopPropagation(); closeItemMenu(); onMoveItem(item.id, -1); }} className="w-full text-left px-3 py-1.5 text-ih-fg-2 hover:bg-ih-bg-muted">Move up</button>
<MenuItem onClick={(e) => { e.stopPropagation(); closeItemMenu(); onMoveItem(item.id, -1); }}>Move up</MenuItem>
)}
{onMoveItem && fullIdx < items.length - 1 && (
<button role="menuitem" onClick={(e) => { e.stopPropagation(); closeItemMenu(); onMoveItem(item.id, 1); }} className="w-full text-left px-3 py-1.5 text-ih-fg-2 hover:bg-ih-bg-muted">Move down</button>
<MenuItem onClick={(e) => { e.stopPropagation(); closeItemMenu(); onMoveItem(item.id, 1); }}>Move down</MenuItem>
)}
{onDeleteItem && (
<button role="menuitem" onClick={(e) => { e.stopPropagation(); closeItemMenu(); onDeleteItem(item.id); }} className="w-full text-left px-3 py-1.5 text-ih-bad hover:bg-ih-bg-muted">Delete</button>
<MenuItem tone="danger" onClick={(e) => { e.stopPropagation(); closeItemMenu(); onDeleteItem(item.id); }}>Delete</MenuItem>
)}
</div>
</>,
Expand All @@ -250,12 +252,14 @@ export function ItemList({
</div>
{onAddItem && (
<div className="p-2 border-t border-ih-border">
<button
<Button
variant="ghost"
size="sm"
onClick={onAddItem}
className="w-full py-2 rounded-md border border-dashed border-ih-border-strong text-[12px] font-bold text-ih-fg-3 hover:text-ih-primary hover:border-ih-primary"
className="w-full h-auto py-2 border border-dashed border-ih-border-strong font-bold text-ih-fg-3 hover:bg-transparent hover:text-ih-primary hover:border-ih-primary"
>
+ Add item
</button>
</Button>
</div>
)}
</div>
Expand Down
138 changes: 138 additions & 0 deletions app/components/editor-shared/RatingSegment.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<RatingSegment ratings={ratings} value="bad" onChange={() => {}} />);
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(<RatingSegment ratings={ratings} value="ok" onChange={onChange} />);
fireEvent.click(screen.getByRole("radio", { name: "Deficient" }));
expect(onChange).toHaveBeenCalledWith("bad");
});

it("unselected tiles are outlined/muted, not filled", () => {
render(<RatingSegment ratings={ratings} value="bad" onChange={() => {}} />);
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(
<RatingSegment
ratings={[{ value: "w", label: "Monitor", tone: "warn" as const }]}
value="w"
onChange={() => {}}
/>
);
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(
<RatingSegment
ratings={[{ value: "n", label: "N/A", tone: "neutral" as const }]}
value="n"
onChange={() => {}}
/>
);
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(<RatingSegment ratings={ratings} value="bad" onChange={() => {}} />);
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(<RatingSegment ratings={ratings} value="ok" onChange={onChange} />);
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(<RatingSegment ratings={three} value="b" onChange={onChange} />);
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(
<RatingSegment
ratings={[{ value: "x", label: "1", tone: "bad" as const, color: "#ff8800" }]}
value=""
onChange={() => {}}
/>
);
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(
<RatingSegment
ratings={[{ value: "ok", label: "Serviceable", shortLabel: "OK", tone: "ok" as const }]}
value="ok"
onChange={() => {}}
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(
<RatingSegment
ratings={[{ value: "ok", label: "Serviceable", shortLabel: "OK", tone: "ok" as const }]}
value="ok"
onChange={() => {}}
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");
});
});
Loading
Loading