From 86119a1717d0a3e5c284d01c2ac429217dabd272 Mon Sep 17 00:00:00 2001 From: important-new Date: Sun, 12 Jul 2026 21:50:16 +0800 Subject: [PATCH 01/32] chore: add pdf-lib for two-pass TOC page-number extraction Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_017iM1JG433tMu7MGJjEC49X --- package-lock.json | 37 +++++++++++++++++++++++++++++++++++++ package.json | 1 + 2 files changed, 38 insertions(+) diff --git a/package-lock.json b/package-lock.json index 236d364e3..c2b25632f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "nanoid": "^5.1.5", "node-diff3": "^3.1.2", "otpauth": "^9.5.1", + "pdf-lib": "^1.17.1", "qrcode": "^1.5.4", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -3471,6 +3472,24 @@ "win32" ] }, + "node_modules/@pdf-lib/standard-fonts": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz", + "integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.6" + } + }, + "node_modules/@pdf-lib/upng": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@pdf-lib/upng/-/upng-1.0.1.tgz", + "integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.10" + } + }, "node_modules/@playwright/test": { "version": "1.58.2", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", @@ -9714,6 +9733,24 @@ "dev": true, "license": "MIT" }, + "node_modules/pdf-lib": { + "version": "1.17.1", + "resolved": "https://registry.npmmirror.com/pdf-lib/-/pdf-lib-1.17.1.tgz", + "integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==", + "license": "MIT", + "dependencies": { + "@pdf-lib/standard-fonts": "^1.0.0", + "@pdf-lib/upng": "^1.0.1", + "pako": "^1.0.11", + "tslib": "^1.11.1" + } + }, + "node_modules/pdf-lib/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", diff --git a/package.json b/package.json index 6042e6bfd..cef6070e3 100644 --- a/package.json +++ b/package.json @@ -96,6 +96,7 @@ "nanoid": "^5.1.5", "node-diff3": "^3.1.2", "otpauth": "^9.5.1", + "pdf-lib": "^1.17.1", "qrcode": "^1.5.4", "react": "^18.3.1", "react-dom": "^18.3.1", From 0264c6fc93c86a72e754677cf200db5b0e4294ec Mon Sep 17 00:00:00 2001 From: important-new Date: Sun, 12 Jul 2026 21:57:00 +0800 Subject: [PATCH 02/32] feat(shared-ui): add Button link + danger-link variants Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_017iM1JG433tMu7MGJjEC49X --- packages/shared-ui/src/Button.test.tsx | 18 ++++++++++++++++++ packages/shared-ui/src/Button.tsx | 4 +++- 2 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 packages/shared-ui/src/Button.test.tsx diff --git a/packages/shared-ui/src/Button.test.tsx b/packages/shared-ui/src/Button.test.tsx new file mode 100644 index 000000000..f51943517 --- /dev/null +++ b/packages/shared-ui/src/Button.test.tsx @@ -0,0 +1,18 @@ +import { render, screen } from "@testing-library/react"; +import { Button } from "./Button"; + +test("link variant renders borderless text action", () => { + render(); + const btn = screen.getByRole("button", { name: "Browse library" }); + expect(btn.className).toContain("text-ih-primary"); + expect(btn.className).toContain("hover:underline"); + expect(btn.className).not.toContain("bg-ih-primary"); +}); + +test("danger-link variant renders borderless destructive text", () => { + render(); + const btn = screen.getByRole("button", { name: "Remove" }); + expect(btn.className).toContain("text-ih-bad-fg"); + expect(btn.className).toContain("hover:underline"); + expect(btn.className).not.toContain("bg-ih-bad"); +}); diff --git a/packages/shared-ui/src/Button.tsx b/packages/shared-ui/src/Button.tsx index 20634a5c1..714e233eb 100644 --- a/packages/shared-ui/src/Button.tsx +++ b/packages/shared-ui/src/Button.tsx @@ -1,6 +1,6 @@ import React from "react"; -type ButtonVariant = "primary" | "secondary" | "ghost" | "danger"; +type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "link" | "danger-link"; type ButtonSize = "sm" | "md" | "lg"; interface ButtonProps extends React.ButtonHTMLAttributes { @@ -14,6 +14,8 @@ const variantClasses: Record = { secondary: "bg-ih-bg-card border border-ih-border text-ih-fg-2 hover:bg-ih-bg-muted", ghost: "text-ih-fg-2 hover:bg-ih-bg-muted", danger: "bg-ih-bad text-ih-fg-inverse hover:opacity-90", + link: "text-ih-primary hover:underline bg-transparent", + "danger-link": "text-ih-bad-fg hover:underline bg-transparent", }; const sizeClasses: Record = { From f0257924dda37d672bc0c0483356de81e82b3d2a Mon Sep 17 00:00:00 2001 From: important-new Date: Sun, 12 Jul 2026 22:06:24 +0800 Subject: [PATCH 03/32] feat(shared-ui): add Button selected (aria-pressed) toggle state Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_017iM1JG433tMu7MGJjEC49X --- packages/shared-ui/src/Button.test.tsx | 23 ++++++++++++++++++++++- packages/shared-ui/src/Button.tsx | 7 +++++-- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/packages/shared-ui/src/Button.test.tsx b/packages/shared-ui/src/Button.test.tsx index f51943517..33dea46a7 100644 --- a/packages/shared-ui/src/Button.test.tsx +++ b/packages/shared-ui/src/Button.test.tsx @@ -1,6 +1,9 @@ -import { render, screen } from "@testing-library/react"; +import { describe, it, expect, afterEach } from "vitest"; +import { render, cleanup, screen } from "@testing-library/react"; import { Button } from "./Button"; +afterEach(cleanup); + test("link variant renders borderless text action", () => { render(); const btn = screen.getByRole("button", { name: "Browse library" }); @@ -16,3 +19,21 @@ test("danger-link variant renders borderless destructive text", () => { expect(btn.className).toContain("hover:underline"); expect(btn.className).not.toContain("bg-ih-bad"); }); + +describe("Button selected state", () => { + it("selected sets aria-pressed and pressed style", () => { + render( + , + ); + const btn = screen.getByRole("button", { name: "Tag" }); + expect(btn.getAttribute("aria-pressed")).toBe("true"); + expect(btn.className).toContain("ring-ih-primary"); + }); + + it("unselected omits aria-pressed", () => { + render(); + expect(screen.getByRole("button", { name: "Tag" }).hasAttribute("aria-pressed")).toBe(false); + }); +}); diff --git a/packages/shared-ui/src/Button.tsx b/packages/shared-ui/src/Button.tsx index 714e233eb..d321b6144 100644 --- a/packages/shared-ui/src/Button.tsx +++ b/packages/shared-ui/src/Button.tsx @@ -7,6 +7,7 @@ interface ButtonProps extends React.ButtonHTMLAttributes { variant?: ButtonVariant; size?: ButtonSize; icon?: React.ReactNode; + selected?: boolean; } const variantClasses: Record = { @@ -25,13 +26,15 @@ const sizeClasses: Record = { }; export const Button = React.forwardRef(function Button( - { variant = "secondary", size = "md", icon, children, className = "", ...props }, + { variant = "secondary", size = "md", icon, children, className = "", selected, ...props }, ref, ) { + const selectedClass = selected ? "ring-2 ring-ih-primary ring-inset" : ""; return ( + ); + }, +); diff --git a/packages/shared-ui/src/index.ts b/packages/shared-ui/src/index.ts index 3ce334b7c..432c06075 100644 --- a/packages/shared-ui/src/index.ts +++ b/packages/shared-ui/src/index.ts @@ -1,4 +1,5 @@ export { Button } from "./Button"; +export { IconButton } from "./IconButton"; export { Pill, type PillTone } from "./Pill"; export { StatCard } from "./StatCard"; export { Icon, type IconProps } from "./Icon"; From 6b9c60018b31256ea7fc07ec3ef7886fb192672f Mon Sep 17 00:00:00 2001 From: important-new Date: Sun, 12 Jul 2026 22:17:34 +0800 Subject: [PATCH 05/32] chore: defer pdf-lib dep to Phase 4 (unused-until-imported fails deadcode gate) Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_017iM1JG433tMu7MGJjEC49X --- package-lock.json | 37 ------------------------------------- package.json | 1 - 2 files changed, 38 deletions(-) diff --git a/package-lock.json b/package-lock.json index c2b25632f..236d364e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,6 @@ "nanoid": "^5.1.5", "node-diff3": "^3.1.2", "otpauth": "^9.5.1", - "pdf-lib": "^1.17.1", "qrcode": "^1.5.4", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -3472,24 +3471,6 @@ "win32" ] }, - "node_modules/@pdf-lib/standard-fonts": { - "version": "1.0.0", - "resolved": "https://registry.npmmirror.com/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz", - "integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==", - "license": "MIT", - "dependencies": { - "pako": "^1.0.6" - } - }, - "node_modules/@pdf-lib/upng": { - "version": "1.0.1", - "resolved": "https://registry.npmmirror.com/@pdf-lib/upng/-/upng-1.0.1.tgz", - "integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==", - "license": "MIT", - "dependencies": { - "pako": "^1.0.10" - } - }, "node_modules/@playwright/test": { "version": "1.58.2", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", @@ -9733,24 +9714,6 @@ "dev": true, "license": "MIT" }, - "node_modules/pdf-lib": { - "version": "1.17.1", - "resolved": "https://registry.npmmirror.com/pdf-lib/-/pdf-lib-1.17.1.tgz", - "integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==", - "license": "MIT", - "dependencies": { - "@pdf-lib/standard-fonts": "^1.0.0", - "@pdf-lib/upng": "^1.0.1", - "pako": "^1.0.11", - "tslib": "^1.11.1" - } - }, - "node_modules/pdf-lib/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmmirror.com/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "license": "0BSD" - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", diff --git a/package.json b/package.json index cef6070e3..6042e6bfd 100644 --- a/package.json +++ b/package.json @@ -96,7 +96,6 @@ "nanoid": "^5.1.5", "node-diff3": "^3.1.2", "otpauth": "^9.5.1", - "pdf-lib": "^1.17.1", "qrcode": "^1.5.4", "react": "^18.3.1", "react-dom": "^18.3.1", From 0b24dc19174bd555129169a56d1230cbdb44ebcd Mon Sep 17 00:00:00 2001 From: important-new Date: Sun, 12 Jul 2026 22:20:16 +0800 Subject: [PATCH 06/32] feat(shared-ui): add MenuItem dropdown-row primitive Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_017iM1JG433tMu7MGJjEC49X --- packages/shared-ui/src/MenuItem.test.tsx | 19 +++++++++++++++++++ packages/shared-ui/src/MenuItem.tsx | 24 ++++++++++++++++++++++++ packages/shared-ui/src/index.ts | 1 + 3 files changed, 44 insertions(+) create mode 100644 packages/shared-ui/src/MenuItem.test.tsx create mode 100644 packages/shared-ui/src/MenuItem.tsx diff --git a/packages/shared-ui/src/MenuItem.test.tsx b/packages/shared-ui/src/MenuItem.test.tsx new file mode 100644 index 000000000..c4a3194f0 --- /dev/null +++ b/packages/shared-ui/src/MenuItem.test.tsx @@ -0,0 +1,19 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { render, cleanup, screen } from "@testing-library/react"; +import { MenuItem } from "./MenuItem"; + +afterEach(cleanup); + +describe("MenuItem", () => { + it("renders a full-width left-aligned menuitem", () => { + render(Duplicate); + const item = screen.getByRole("menuitem", { name: "Duplicate" }); + expect(item.className).toContain("w-full"); + expect(item.className).toContain("text-left"); + }); + + it("danger tone uses bad-fg token", () => { + render(Delete); + expect(screen.getByRole("menuitem", { name: "Delete" }).className).toContain("text-ih-bad-fg"); + }); +}); diff --git a/packages/shared-ui/src/MenuItem.tsx b/packages/shared-ui/src/MenuItem.tsx new file mode 100644 index 000000000..ab6c8da18 --- /dev/null +++ b/packages/shared-ui/src/MenuItem.tsx @@ -0,0 +1,24 @@ +import React from "react"; + +interface MenuItemProps extends React.ButtonHTMLAttributes { + icon?: React.ReactNode; + tone?: "default" | "danger"; +} + +export const MenuItem = React.forwardRef( + function MenuItem({ icon, tone = "default", className = "", children, ...props }, ref) { + const toneClass = tone === "danger" ? "text-ih-bad-fg" : "text-ih-fg-2"; + return ( + + ); + }, +); diff --git a/packages/shared-ui/src/index.ts b/packages/shared-ui/src/index.ts index 432c06075..d288b1b2c 100644 --- a/packages/shared-ui/src/index.ts +++ b/packages/shared-ui/src/index.ts @@ -26,3 +26,4 @@ export { Table } from "./Table"; export type { TableProps, TableColumn, TableAlign } from "./Table"; export { SegmentedControl, type SegmentedControlOption } from "./SegmentedControl"; export { Avatar, avatarInitials, type AvatarSize, type AvatarVariant, type AvatarStatus } from "./Avatar"; +export { MenuItem } from "./MenuItem"; From 6ab91aa65cc2473823d4a2a244e068c2de86c2dc Mon Sep 17 00:00:00 2001 From: important-new Date: Sun, 12 Jul 2026 22:25:43 +0800 Subject: [PATCH 07/32] feat(shared-ui): add TabStrip vertical orientation Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_017iM1JG433tMu7MGJjEC49X --- packages/shared-ui/src/TabStrip.test.tsx | 28 ++++++++++++++++++++++ packages/shared-ui/src/TabStrip.tsx | 30 ++++++++++++++++++------ 2 files changed, 51 insertions(+), 7 deletions(-) create mode 100644 packages/shared-ui/src/TabStrip.test.tsx diff --git a/packages/shared-ui/src/TabStrip.test.tsx b/packages/shared-ui/src/TabStrip.test.tsx new file mode 100644 index 000000000..3277a9256 --- /dev/null +++ b/packages/shared-ui/src/TabStrip.test.tsx @@ -0,0 +1,28 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { render, cleanup, screen } from "@testing-library/react"; +import { TabStrip } from "./TabStrip"; + +afterEach(cleanup); + +const TABS = [ + { id: "a", label: "Alpha" }, + { id: "b", label: "Beta" }, +]; + +describe("TabStrip", () => { + it("horizontal (default) keeps bottom-border underline tabs", () => { + const { container } = render( + {}} />, + ); + expect((container.firstChild as HTMLElement).className).toContain("border-b"); + }); + + it("vertical orientation stacks with left-border accent", () => { + const { container } = render( + {}} orientation="vertical" />, + ); + expect((container.firstChild as HTMLElement).className).toContain("flex-col"); + const active = screen.getByRole("button", { name: "Alpha" }); + expect(active.className).toContain("border-l-2"); + }); +}); diff --git a/packages/shared-ui/src/TabStrip.tsx b/packages/shared-ui/src/TabStrip.tsx index f0a358c8a..75d865b3a 100644 --- a/packages/shared-ui/src/TabStrip.tsx +++ b/packages/shared-ui/src/TabStrip.tsx @@ -10,20 +10,36 @@ interface TabStripProps { tabs: Tab[]; activeId: string; onChange: (id: string) => void; + orientation?: "horizontal" | "vertical"; } -export function TabStrip({ tabs, activeId, onChange }: TabStripProps) { +export function TabStrip({ tabs, activeId, onChange, orientation = "horizontal" }: TabStripProps) { + const vertical = orientation === "vertical"; return ( -
+
{tabs.map((tab) => ( + ); + })} +
+ ); +} diff --git a/scripts/knip-baseline.json b/scripts/knip-baseline.json index 6d6096ff8..54e58db01 100644 --- a/scripts/knip-baseline.json +++ b/scripts/knip-baseline.json @@ -411,6 +411,8 @@ "types:app/components/SettingsCrumb.tsx:CrumbItem", "types:app/components/agreements/SendAgreementModal.tsx:SignerRole", "types:app/components/agreements/SignerList.tsx:SignerStatus", + "types:app/components/editor-shared/RatingSegment.tsx:RatingOption", + "types:app/components/editor-shared/RatingSegment.tsx:RatingTone", "types:app/components/form/FormField.tsx:CannedComment", "types:app/components/form/FormField.tsx:ItemResult", "types:app/components/inspection-edit/CompliancePanel.tsx:DocumentReviewItemView", From 983c7d9a2244c984e9c545e9464af566a8e5e9ba Mon Sep 17 00:00:00 2001 From: important-new Date: Sun, 12 Jul 2026 22:41:35 +0800 Subject: [PATCH 09/32] docs: document new shared-ui affordances (Button link/selected, IconButton, MenuItem, vertical TabStrip) Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_017iM1JG433tMu7MGJjEC49X --- docs/developers/11_design_system.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/developers/11_design_system.md b/docs/developers/11_design_system.md index 224aa8a4a..f3a864525 100644 --- a/docs/developers/11_design_system.md +++ b/docs/developers/11_design_system.md @@ -173,7 +173,9 @@ route files. | Component | Purpose | Key props / variants | |---|---|---| -| `Button` | Primary interactive control | `variant`: `primary` \| `secondary` \| `ghost` \| `danger`; `size`: `sm` \| `md` \| `lg`; `icon` | +| `Button` | Primary interactive control | `variant`: `primary` \| `secondary` \| `ghost` \| `danger` \| `link` \| `danger-link`; `size`: `sm` \| `md` \| `lg`; `icon`; `selected` (sets `aria-pressed` + a pressed ring — for toggle affordances). `link`/`danger-link` are borderless text actions (no fill/border) | +| `IconButton` | Square icon-only button — same variants as `Button`, no text padding; `aria-label` is required (TS-enforced) | `aria-label` (**required**), `variant`: `primary` \| `secondary` \| `ghost` (default) \| `danger`; `size`: `sm` (28px) \| `md` (36px) \| `lg` (44px); `selected` (`aria-pressed` toggle). Use for toolbar/close/settings glyph buttons instead of a hand-rolled `w-9 h-9 flex` `