Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
6aecf98
feat(notifications): one class vocabulary, and 8 templates a tenant c…
important-new Jul 31, 2026
4ca4b31
feat(notifications): the send boundary now knows what it is sending
important-new Jul 31, 2026
e4f0770
feat(notifications): the three hand-built sends become templates
important-new Jul 31, 2026
fe60d47
refactor(sms): one gate chain, and the two test sends now run it
important-new Jul 31, 2026
459f444
feat(notifications): a gate, because auditing found what auditing cou…
important-new Jul 31, 2026
c6051ff
perf(gates): cache a gate on what it read, so running it twice costs …
important-new Jul 31, 2026
5bfaa7e
feat(notifications): the preferences table, shaped by the constraint …
important-new Jul 31, 2026
cc4adc8
feat(notifications): preferences enforced where the send happens
important-new Jul 31, 2026
9eda941
refactor(notifications): withdraw the fourth category, add recipientF…
important-new Jul 31, 2026
7d9d04f
feat(notifications): every seeded automation rule now says what it is
important-new Jul 31, 2026
92f51fb
test(notifications): pin the two consequences D3 and D5 accepted
important-new Jul 31, 2026
124d84b
feat(notifications): in-app honours the same preference decision
important-new Jul 31, 2026
6d9d410
feat(notifications): §2's "Who" column, and the model the screen renders
important-new Jul 31, 2026
5c6e6d3
feat(notifications): the preferences screen component, and two test p…
important-new Jul 31, 2026
49e954e
feat(notifications): the preferences API, and a refusal the tests found
important-new Jul 31, 2026
951674d
feat(notifications): per-recipient preference screens for staff, agen…
important-new Jul 31, 2026
cb896c1
feat(notifications): bulk row/column actions, and honour preferences …
important-new Jul 31, 2026
91b1630
feat(notifications): the screen offers every channel, always
important-new Jul 31, 2026
9ff5812
feat(notifications): SMS consent block, and spec §4.2 for what a chan…
important-new Jul 31, 2026
be1c4d5
feat(notifications): consent is the gate — inline grant, and a locked…
important-new Jul 31, 2026
be6efd4
feat(sms): staff can STOP — a consent ledger keyed on a subject, not …
important-new Jul 31, 2026
828dfc8
feat(sms): staff and agents can turn texts back on — one surface, one…
important-new Jul 31, 2026
44bfaa4
fix(profile): credentials are visible when added, and reach the signa…
important-new Jul 31, 2026
c741992
fix(agent): a session ends at the door it was opened at
important-new Jul 31, 2026
228edd3
feat(notifications): a page to link to, for a reader who is not signe…
important-new Jul 31, 2026
1671b9a
feat(legal): a tenant's Privacy and Terms now have a history
important-new Jul 31, 2026
5f84d7f
fix(credentials): the badges now reach the recipient, not just the pr…
important-new Jul 31, 2026
7331a46
refactor(settings): a button means submit; its absence means it saved
important-new Jul 31, 2026
20e1ae8
feat(reports): a published version renders what it froze
important-new Jul 31, 2026
fa310eb
feat(credentials): the state license becomes a credential row
important-new Jul 31, 2026
47b7b05
feat(credentials): retire users.license_number; the section is now Li…
important-new Jul 31, 2026
fdccf68
chore: drop exports nothing imports
important-new Jul 31, 2026
202061e
chore(deps): drop @testing-library/user-event — nothing imports it
important-new Jul 31, 2026
9321deb
fix(schema): notification_preferences.enabled -> is_enabled
important-new Jul 31, 2026
4499026
fix(reports): one person, one source — the pinned name and licence too
important-new Jul 31, 2026
bf35b7c
feat(gates): enforce the agent- route prefix that loginPathFor depend…
important-new Jul 31, 2026
fe55a32
fix(ui): three things only a browser could show
important-new Aug 1, 2026
bcef1ea
perf(credentials): serve badges at the size they are actually drawn
important-new Aug 1, 2026
6ad893a
fix(settings): the signature card, and two controls that looked broken
important-new Aug 1, 2026
f0e6e80
feat(settings): one cropper for every image on the profile page
important-new Aug 1, 2026
b948a46
fix(editor): auto-sign now applies to the publish it was ticked for
important-new Aug 1, 2026
41d736b
feat(reports): a delivered report renders what it froze (option A)
important-new Aug 1, 2026
94f8aed
feat(editor): the rehearsal must be as reachable as the performance
important-new Aug 1, 2026
5c261fa
fix(editor): drop the unused HeaderOverflowKey export
important-new Aug 1, 2026
2201c7e
feat(credentials): drop the licence column, name the badge rule, fini…
important-new Aug 1, 2026
d6781aa
fix(migrations): repair the drizzle meta chain, and gate it
important-new Aug 1, 2026
a78e0cb
build: minify the worker — 100.8% of the Free script limit down to 57.9%
important-new Aug 1, 2026
ee5bfbc
fix(tests): three suites that only CI could fail, and one that pinned…
important-new Aug 1, 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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,4 @@ scripts/backfill-zod-descriptions.report.md

# SDD scratch (briefs, reports, diffs, progress ledger)
.superpowers/
.gate-cache/
3 changes: 3 additions & 0 deletions app/components/agent/AgentNoticeBell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ export function AgentNoticeBell({ notices, unread }: { notices: NoticeRowData[];

return (
<NoticeBell
// Shortcut only; the agent screen is per-company and needs the company
// selector, so it stays a page rather than moving into this panel.
settingsHref="/agent-settings/profile"
notices={notices}
unread={unread}
emailComposer={false}
Expand Down
89 changes: 47 additions & 42 deletions app/components/editor/EditorHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import { usePdfExport, pdfActionLabel } from "~/hooks/usePdfExport";
import { ThemeSegmentControl } from "~/components/sidebar/ThemeSegmentControl";
import { ProgressStripText } from "~/components/editor/ProgressStripText";
import { TemplateMenu } from "~/components/editor/TemplateMenu";
import { PreviewMenu } from "~/components/editor/PreviewMenu";
import { HEADER_OVERFLOW } from "~/components/editor/header-visibility";
import { HeaderOverflowMenu } from "~/components/editor/HeaderOverflowMenu";
import { m } from "~/paraglide/messages";

type EditorState = ReturnType<typeof useInspectionState>;
Expand Down Expand Up @@ -178,8 +181,11 @@ export function EditorHeader({
</div>
)}

{/* Center zone: report search + version history [tools] */}
<div className="hidden xl:flex items-center gap-2">
{/* Center zone: report search + version history [tools]. Both hide together
at the same breakpoint; only version history reappears in the More menu.
Search does not: typing into a popover is worse than not offering it, and
an inspector on a tablet navigates by section rail, not by search. */}
<div className={`${HEADER_OVERFLOW.versionHistory.inline} items-center gap-2`}>
{/* Search */}
<input
type="text"
Expand Down Expand Up @@ -212,7 +218,7 @@ export function EditorHeader({
{/* Theme — the shared 4-segment control (auto/light/dark/field), same as the
tenant sidebar. Shown from xl up where the header has room; narrower
widths reach it through the mobile Theme drawer. */}
<ThemeSegmentControl className="hidden xl:flex" />
<ThemeSegmentControl className={HEADER_OVERFLOW.theme.inline} />

{/* Report settings — a labelled button, not a bare gear.
IA-87 ③ / IA-88 ⑤: this was the only unlabelled icon in a row of text
Expand Down Expand Up @@ -262,51 +268,33 @@ export function EditorHeader({
canUpdateSource={canUpdateSourceTemplate}
/>

{/* 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 && (
<Button
variant="secondary"
size="md"
onClick={() => window.open(`/report-view/${tenantSlug}/${state.inspection.id}`, "_blank", "noopener")}
className="hidden 2xl:inline-flex"
title={m.editor_header_preview_full_title()}
icon={
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
{/* Preview [CHECK tier] — the report's two fidelities behind one control.
Never hides: it is the rehearsal for Publish, and Publish never hides.
The web report is the owner preview (works on drafts, tokenless via the
report-view loader); the PDF is the real server-rendered deliverable
(owner on-demand pre-publish via the JWT-authed endpoint). */}
<PreviewMenu
onPreviewReport={
tenantSlug
? () => window.open(`/report-view/${tenantSlug}/${state.inspection.id}`, "_blank", "noopener")
: null
}
>
{m.editor_header_preview()}
</Button>
)}

{/* 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. */}
<Button
variant="secondary"
size="md"
onClick={() => pdf.exportPdf(`/api/inspections/${state.inspection.id}/pdf?type=full`, { mode: "view", filename: `report-${state.inspection.id}.pdf` })}
disabled={pdf.busy}
className="hidden xl:inline-flex"
title={pdf.error ?? m.editor_header_preview_pdf_title()}
icon={
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
}
>
{pdfActionLabel(pdf, m.editor_header_preview_pdf())}
</Button>
onPreviewPdf={() => pdf.exportPdf(`/api/inspections/${state.inspection.id}/pdf?type=full`, { mode: "view", filename: `report-${state.inspection.id}.pdf` })}
pdfBusy={pdf.busy}
pdfError={pdf.error}
/* Inside a menu already labelled "Preview", the rows name destinations,
not actions — "Web report" / "PDF", not "Web report" / "Preview PDF".
The busy and cooldown states replace the label outright, so the short
form loses nothing. */
pdfLabel={pdfActionLabel(pdf, m.editor_header_preview_pdf_short())}
/>

{/* Sign now button */}
<Button
variant="secondary"
size="md"
onClick={() => setSignModalOpen(true)}
className="hidden xl:inline-flex"
className={HEADER_OVERFLOW.sign.inline}
title={m.editor_header_sign_title()}
icon={<Icon name="edit" className="w-3.5 h-3.5" />}
>
Expand All @@ -322,13 +310,30 @@ export function EditorHeader({
size="md"
onClick={handleFinishFieldwork}
disabled={finishingFieldwork}
className="hidden lg:inline-flex"
className={HEADER_OVERFLOW.finishFieldwork.inline}
icon={<Icon name="check" className="w-3.5 h-3.5" />}
>
{finishingFieldwork ? m.editor_finish_fieldwork_pending() : m.editor_finish_fieldwork()}
</Button>
)}

{/* More [COMPOSE tier overflow] — holds exactly what this width dropped.
Sits immediately left of Publish so the controls that vanished from the
row are one tap from where they used to be. Hides itself at xl, where
everything it holds is inline again. */}
<HeaderOverflowMenu
onSign={() => setSignModalOpen(true)}
onFinishFieldwork={
(state.inspection.status as string) !== "completed" ? handleFinishFieldwork : null
}
finishingFieldwork={finishingFieldwork}
onOpenVersionHistory={collabEditing && onOpenVersionHistory ? onOpenVersionHistory : null}
/* Full width in the menu: at its intrinsic size the four segments sit in
a short pill against a much wider panel, reading as a stray chip rather
than a control. */
themeControl={<ThemeSegmentControl className="w-full" />}
/>

{/* Publish button */}
<Button
variant="primary"
Expand Down
117 changes: 117 additions & 0 deletions app/components/editor/HeaderOverflowMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { useRef, useState, type ReactNode } from "react";
import { Button, Icon, MenuItem, Popover } from "@core/shared-ui";
import { HEADER_OVERFLOW } from "~/components/editor/header-visibility";
import { m } from "~/paraglide/messages";

export interface HeaderOverflowMenuProps {
/** Opens the manual sign modal. Mirrors the `xl` Sign button. */
onSign: () => void;
/** Advisory lifecycle move; null once the fieldwork is already complete. */
onFinishFieldwork: (() => void) | null;
/** Whether the finish-fieldwork request is in flight. */
finishingFieldwork: boolean;
/** #181 — collab-only; null when version history is unavailable. */
onOpenVersionHistory: (() => void) | null;
/** The theme segment control, rendered as the menu's last row. */
themeControl: ReactNode;
}

/**
* "More" — the header's overflow, holding exactly the controls the current
* viewport width has dropped.
*
* Membership is decided in CSS, not by measurement: every row carries the
* inverse of its button's visibility class (`xl:hidden` mirrors
* `hidden xl:inline-flex`), so a control is in exactly one place at every
* width and neither list can drift from the other. A ResizeObserver-driven
* "priority+" bar would be the textbook answer, but it renders nothing useful
* during SSR and costs bundle this Worker does not have.
*
* The trigger itself is `xl:hidden`: Sign, version history and the theme
* control all reappear at `xl`, so at that width the menu would be empty.
*/
export function HeaderOverflowMenu({
onSign,
onFinishFieldwork,
finishingFieldwork,
onOpenVersionHistory,
themeControl,
}: HeaderOverflowMenuProps) {
const [open, setOpen] = useState(false);
const anchorRef = useRef<HTMLButtonElement>(null);

const run = (fn: () => void) => {
setOpen(false);
fn();
};

return (
<>
<Button
ref={anchorRef}
variant="secondary"
size="md"
onClick={() => setOpen((v) => !v)}
aria-haspopup="menu"
aria-expanded={open}
aria-label={m.editor_header_more()}
title={m.editor_header_more()}
className="xl:hidden"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h.01M12 12h.01M19 12h.01" />
</svg>
</Button>

<Popover open={open} onClose={() => setOpen(false)} anchorRef={anchorRef} align="right">
{/* Wide enough that the four-segment theme control in the last row
breathes; the action rows above are far shorter than this. */}
<ul role="menu" aria-label={m.editor_header_more()} className="py-1 min-w-[260px]">
<li role="none">
<MenuItem
data-testid="overflow-sign-btn"
className={HEADER_OVERFLOW.sign.row}
icon={<Icon name="edit" className="w-3.5 h-3.5" />}
onClick={() => run(onSign)}
>
{m.editor_header_sign()}
</MenuItem>
</li>
{onFinishFieldwork && (
<li role="none">
<MenuItem
data-testid="overflow-finish-fieldwork-btn"
className={HEADER_OVERFLOW.finishFieldwork.row}
icon={<Icon name="check" className="w-3.5 h-3.5" />}
disabled={finishingFieldwork}
onClick={() => run(onFinishFieldwork)}
>
{finishingFieldwork ? m.editor_finish_fieldwork_pending() : m.editor_finish_fieldwork()}
</MenuItem>
</li>
)}
{onOpenVersionHistory && (
<li role="none">
<MenuItem
data-testid="overflow-version-history-btn"
className={HEADER_OVERFLOW.versionHistory.row}
icon={
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
}
onClick={() => run(onOpenVersionHistory)}
>
{m.editor_header_version_history()}
</MenuItem>
</li>
)}
<li role="none" className={`${HEADER_OVERFLOW.theme.row} mt-1 border-t border-ih-border px-3 pt-2 pb-1`}>
<div className="text-[11px] uppercase tracking-[0.1em] text-ih-fg-4 mb-1.5">{m.nav_theme_label()}</div>
{themeControl}
</li>
</ul>
</Popover>
</>
);
}
10 changes: 8 additions & 2 deletions app/components/editor/MobileDrawerTriggers.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { m } from "~/paraglide/messages";

export type MobileDrawerId = 'sections' | 'items' | 'preview' | 'theme';
/**
* `actions` has no trigger in this nav — it opens from the app bar's own
* "More actions" button, which is where a phone user already looks for the
* things that end a job (publish, sign, preview). Adding a fifth tab here
* would put the terminal action in the same row as the navigation.
*/
export type MobileDrawerId = 'sections' | 'items' | 'preview' | 'theme' | 'actions';

export interface MobileDrawerTriggersProps {
onOpen: (id: MobileDrawerId) => void;
Expand All @@ -17,7 +23,7 @@ export function MobileDrawerTriggers({ onOpen }: MobileDrawerTriggersProps) {
{([
{ id: 'sections', label: m.editor_mobile_drawer_sections(), icon: '☰' },
{ id: 'items', label: m.editor_mobile_drawer_items(), icon: '≣' },
{ id: 'preview', label: m.editor_header_preview(), icon: '👁' },
{ id: 'preview', label: m.editor_route_drawer_preview(), icon: '👁' },
{ id: 'theme', label: m.nav_theme_label(), icon: '◐' },
] as const).map(t => (
<button
Expand Down
87 changes: 87 additions & 0 deletions app/components/editor/MobileFinishDrawer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { Button } from "@core/shared-ui";
import { MobileBottomDrawer } from "~/components/MobileBottomDrawer";
import { m } from "~/paraglide/messages";

export interface MobileFinishDrawerProps {
open: boolean;
onClose: () => void;
/** Runs the publish readiness pre-flight, then opens the publish modal. */
onPublish: () => void;
/** Opens the manual signature modal. */
onSign: () => void;
/** Opens the per-inspection report settings sheet. */
onOpenSettings: () => void;
/** Advisory lifecycle move; null once the fieldwork is already complete. */
onFinishFieldwork: (() => void) | null;
/** Whether the finish-fieldwork request is in flight. */
finishingFieldwork: boolean;
/** Opens the full web report in a new tab; null when the slug is unknown. */
onPreviewReport: (() => void) | null;
}

/**
* The ending the mobile walkthrough was missing.
*
* Below 768px the editor renders its own tree — app bar, section/item drawers,
* item editor — built for walking a property on the device you carry. It had no
* Publish, no Sign, no Preview and no report settings, and the app bar's own
* "More actions" button was a comment reading `future: open more menu`. An
* inspector could complete an entire inspection on a phone and then have no way
* to finish it.
*
* Publish leads because it is what someone opens this for; everything else
* supports it. Deliberately NOT a port of the desktop action row — search,
* version history and the template menu are back-office work, and putting them
* on a phone would bury the one action that matters. For the same reason this
* opens from the app bar rather than gaining a fifth tab in the bottom nav: a
* terminal action does not belong in a row of navigation.
*/
export function MobileFinishDrawer({
open,
onClose,
onPublish,
onSign,
onOpenSettings,
onFinishFieldwork,
finishingFieldwork,
onPreviewReport,
}: MobileFinishDrawerProps) {
const run = (fn: () => void) => {
onClose();
fn();
};
// Short, unlike the section/item drawers this shares a component with: those
// are scrollable lists that want the height, this is five buttons. At the 0.7
// default the sheet covered most of the screen with dead space below the last
// button, which reads as a list that failed to load.
return (
<MobileBottomDrawer open={open} onClose={onClose} title={m.editor_mobile_more()} heightFraction={0.42}>
<div className="p-4 space-y-2">
<Button variant="primary" className="w-full" onClick={() => run(onPublish)}>
{m.editor_header_publish()}
</Button>
<Button variant="secondary" className="w-full" onClick={() => run(onSign)}>
{m.editor_header_sign()}
</Button>
{onPreviewReport && (
<Button variant="secondary" className="w-full" onClick={() => run(onPreviewReport)}>
{m.editor_header_preview_report()}
</Button>
)}
<Button variant="secondary" className="w-full" onClick={() => run(onOpenSettings)}>
{m.editor_header_settings()}
</Button>
{onFinishFieldwork && (
<Button
variant="secondary"
className="w-full"
disabled={finishingFieldwork}
onClick={() => run(onFinishFieldwork)}
>
{finishingFieldwork ? m.editor_finish_fieldwork_pending() : m.editor_finish_fieldwork()}
</Button>
)}
</div>
</MobileBottomDrawer>
);
}
Loading
Loading