Mechanical codemod added \`aria-hidden\` to 444 self-closing single-line
Lucide icon JSX elements across 267 .tsx files in:
- shared/, layout/, dashboard/
- admin/ (all sections)
- clients/, berths/, yachts/, companies/, interests/, documents/
- reminders/, reservations/, residential/, expenses/, email/
The regex targeted only the safe pattern \`<IconName className="..." />\`
(no other props, self-closing, capitalized component name). Every match
inspected is a decorative companion to visible text or sits inside a
button whose accessible name comes from \`aria-label\` / sr-only text
— the icon itself should not be announced.
Screen readers no longer double-read the icon + the adjacent label
text (e.g. "Pencil Pencil Edit" → just "Edit"). The existing
@axe-core/playwright smoke test (\`20-accessibility.spec.ts\`) continues
to pass.
Test suite stays at 1315/1315 vitest. typescript clean.
Closes task #69 (aria-hidden sweep) from the AUDIT-2026-05-12 follow-ups
backlog.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ran the official @tailwindcss/upgrade tool:
- tailwind.config.ts → @theme directive in globals.css
- @tailwind base/components/utilities → @import 'tailwindcss'
- postcss.config switched from tailwindcss + autoprefixer to
@tailwindcss/postcss (autoprefixer baked in)
- focus-visible:outline-none → focus-visible:outline-hidden (the v3
utility was a footgun — outline still showed in forced-colors mode)
Reverted the migration tool's over-zealous variant="outline" →
variant="outline-solid" rename on CVA prop values; that rename was
meant for the Tailwind `outline:` utility, not our Button/Badge
component variants.
Swapped tailwindcss-animate (v3-style JS plugin) for tw-animate-css
(v4-native @import). Same utility surface (animate-spin, animate-in,
etc.), one fewer JS plugin in the bundle.
Fixed the upgrade tool's malformed dark variant
(@custom-variant dark (&:is(class *)) — `class` was being parsed as
a tag) to canonical &:where(.dark, .dark *).
Verified: tsc 0 errors, eslint 0 errors (16 pre-existing warnings),
vitest 1315/1315, next build clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mobile + responsive
- berth-form full-width on phones (was 480px fixed → overflowed iPhone)
- currency-input switched to inputMode=decimal with live thousands separator
- client-form Country/Timezone/Source/Preferred-Contact full-width <sm
- contacts row restructured so Primary toggle + Remove get their own strip
- customize-dashboard footer stacks vertically on mobile; Done full-width
- interest-form client/berth pickers no longer cmdk-filter on UUID (typing
"Carlos" now returns Carlos Vega instead of "No clients found")
Data + consistency
- SOURCES + SOURCE_LABELS + formatSource() in lib/constants; 9 surfaces
now resolve interest/client source from one place
- INTEREST_OUTCOMES adds lost_other (picker, badge, timeline)
- Berth options natural-sort A1 → A2 → … → A10 via lib/utils/mooring-sort
- archiver downgraded ^8 → ^7.0.1 so the GDPR export route compiles
- TableBody last-row uses border-b-0 (not border-0); colored left-accent
on the bottom berth row now renders
- Hide Invite-to-Portal until port setting === true (was !== false default-show)
- OwnerPicker primer query resolves entity name on first paint (no more
UUID flash before the popover opens)
Terminology
- Replaced user-facing "Documenso" with "signing service" / "Generated EOI" /
"Manual EOI" in 8 components (admin/internal references kept)
- Plainer status-change copy on berth-detail-header
Forms + editing
- InlineEditableField gained a `date` variant (native picker); applied to
company incorporation date and ready for other YYYY-MM-DD plaintext fields
- Inline source picker on interest-tabs detail (was free text)
- TagPicker self-hides when port has no tags AND nothing is selected
- New ReminderDaysInput with preset chips (1d / 3d / 1wk / 2wk / 1mo / custom)
- Compose dialog follow-up is now a toggle that reveals datetime picker
Pipeline milestones
- changeStageSchema accepts optional milestoneDate; service stamps it on the
matching date column instead of always using now
- MilestoneAdvanceButton popover collects a back-date before stage advance
- Applied to every "Mark X manually" surface on the interest overview
EOI / linked-berths polish
- Add-bypass row aligned inline with toggle descriptions
- Tooltips on "Specifically pitching" / "Mark in EOI bundle" explain their
legal vs. public-map consequences
Surfaces
- Companies list now has the column picker + persisted hidden-column prefs
- NotesList aggregate flag enabled on clients, companies, residential_clients
(yachts already aggregated)
ft/m unit toggle (interim, before drift fix)
- "Berth size desired" gets a section-level ft/m toggle; per-field hint shows
the converted value. Storage stays canonical-ft for now; the drift-safe
persistence migration is the next step.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two mechanical sweeps closing the audit's HIGH §16 + MED §11 findings:
* 38 client components / 56 toast.error sites converted to
toastError(err) so the new admin error inspector becomes usable from
user-reported issues — every failed inline-edit, save, send, archive,
upload, etc. now carries the request-id + error-code (Copy ID action).
* 26 service files / 62 bare-Error throws converted to CodedError or
the existing AppError subclasses. Adds new error codes:
DOCUMENSO_UPSTREAM_ERROR (502), DOCUMENSO_AUTH_FAILURE (502),
DOCUMENSO_TIMEOUT (504), OCR_UPSTREAM_ERROR (502),
IMAP_UPSTREAM_ERROR (502), UMAMI_UPSTREAM_ERROR (502),
UMAMI_NOT_CONFIGURED (409), and INSERT_RETURNING_EMPTY (500) for
post-insert returning-empty guards.
* Five vitest assertions updated to match the new user-facing wording
(client-merge "already been merged", expense/interest "couldn't find
that …", documenso "signing service didn't respond").
Test status: 1168/1168 vitest, tsc clean.
Refs: docs/audit-comprehensive-2026-05-05.md HIGH §16 (auditor-H Issue 1)
+ MED §11 (auditor-G Issue 1).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces every em-dash and en-dash with regular ASCII hyphens
across comments, JSX strings, and dev-facing logs. Mostly cosmetic
but stops the inconsistent mix that crept in over the last few
months (some files used em-dashes in comments, others didn't,
some used both).
Bundles two small dashboard-layout tweaks that touch a couple of
already-modified files:
- (dashboard)/layout.tsx main padding goes from p-6 to pt-3 px-6
pb-6 so page content sits closer to the topbar.
- Sidebar now receives the ports list it needs for the footer
port switcher.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
InlinePhoneField now lays the country picker + number on top, with Save +
Cancel buttons on a second line — the previous single-line cluster was
cramped at every viewport size and broke entirely below ~480px.
A new onEditingChange callback notifies the parent when the field enters
edit mode, so contact rows can react. ContactsEditor uses it to "dilate"
the row visually: lift out of the muted baseline with a soft primary
ring + slightly brighter surface + bumped padding. Single visual signal
replaces the need for any "now editing" label, and the dilation also
hides the noisy chip cluster (label / star / trash) that would otherwise
fight the editor for space.
Mobile improvements applied at the same time:
- Each row stacks value editor on top, action cluster below at <sm
- Action cluster ("Add tag" + Make-primary star + trash) uses
justify-end on the new row so it doesn't collide with the picker
- Trash icon stays opacity-0/group-hover on desktop but is always
visible on touch (no hover state on touch) — sm:opacity-0 +
sm:group-hover:opacity-100 instead of the prior unconditional fade
- NewContactForm wraps onto multiple lines below sm (basis-full on
the value field) so the channel picker, value, label, and buttons
each get usable width
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cross-cutting i18n polish for forms across the marina + residential + company
domains. Introduces a single source of truth for country/phone/timezone/
subdivision data and replaces every nationality-as-free-text and timezone-
as-string Input with a dedicated combobox.
PR1 Countries — ALL_COUNTRY_CODES (~250 ISO-3166-1 alpha-2), Intl.DisplayNames
for localized labels, detectDefaultCountry() with navigator-region
fallback to US, CountryCombobox with regional-indicator flag glyphs +
compact mode for inline use.
PR2 Phone — libphonenumber-js wrapper (parsePhone / formatAsYouType /
callingCodeFor), PhoneInput with flag dropdown + national-format
AsYouType + paste-detect that flips the country dropdown for pasted
international strings.
PR3 Timezones — country->IANA map (250 entries, multi-zone for AU/BR/CA/CD/
ID/KZ/MN/MX/RU/US), formatTimezoneLabel ("Europe/London (UTC+1)"),
TimezoneCombobox with Suggested/All grouping driven by countryHint.
PR4 Subdivisions — wraps the iso-3166-2 npm package (~5000 ISO 3166-2
codes for every country), per-country cache, SubdivisionCombobox with
"Pick a country first" / "No regions available" empty states.
PR5 Schema deltas (migration 0015) — clients.nationality_iso, clientContacts
{value_e164, value_country}, clientAddresses {country_iso, subdivision_iso},
residentialClients {phone_e164, phone_country, nationality_iso, timezone,
place_of_residence_country_iso, subdivision_iso}, companies {incorporation_
country_iso, incorporation_subdivision_iso}, companyAddresses {country_iso,
subdivision_iso}. Plus shared zod validators (validators/i18n.ts) used
by every entity validator + route handler.
PR6 ClientForm + ClientDetail — CountryCombobox replaces nationality Input,
TimezoneCombobox replaces timezone Input (driven by nationalityIso hint),
PhoneInput conditionally rendered for phone/whatsapp contacts. Inline
editors (InlineCountryField / InlineTimezoneField / InlinePhoneField)
for the detail-page overview rows + ContactsEditor.
PR7 Residential client form + detail — phone -> PhoneInput, nationality/
timezone/place-of-residence-country/subdivision rows in both create
sheet and inline-editable detail view. Subdivision wipes when country
flips since codes are country-scoped.
PR8 Company form + detail — incorporation country -> CountryCombobox,
incorporation region -> SubdivisionCombobox in both modes.
PR9 Public inquiry endpoint — accepts pre-normalized phoneE164/phoneCountry
and i18n fields from newer website builds, server-side parsePhone()
fallback for legacy raw-international submissions. Old Nuxt builds
keep working unchanged.
Tests: 4 unit suites for the primitives (25 tests), 1 integration spec for
the public phone-normalization path (3 tests), 1 smoke spec asserting the
combobox triggers render in all three create sheets.
Test totals: vitest 713 -> 741 (+28).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>