feat(uat-batch-10): copy polish, TTL trim, and a11y discrete fixes

- Supplemental-info link TTL trimmed from 30 → 14 days (single
  constant in supplemental-forms.service).
- LinkedBerthsList toggle renamed "Mark in EOI bundle" →
  "Include in EOI"; tooltip aria-label updated to match.
- Icon-only row-action triggers on the interest / client / berth list
  tables gain aria-label (Row actions for <name>) so SR users hear
  the row context.
- Table / Board view toggle on interest list gains aria-label +
  aria-pressed on each variant; wrapper gets role="group".
- Upcoming-milestones disclosure on interest-tabs gains
  aria-expanded + aria-controls; recommender Hide/Add filters
  button matches.
- BrandedAuthShell logo alt no longer defaults to "Sign in" — uses
  the configured `appName` when known, empty string otherwise so
  screen readers don't announce "Sign in" on password-reset /
  set-password pages.

tsc clean. 1419/1419 vitest pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 18:01:17 +02:00
parent 5f937b4551
commit db511063df
9 changed files with 28 additions and 6 deletions

View File

@@ -176,6 +176,7 @@ function ActionsCell({ row }: { row: { original: BerthRow } }) {
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8" className="h-8 w-8"
aria-label={`Row actions for berth ${berth.mooringNumber ?? ''}`.trim()}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<MoreHorizontal className="h-4 w-4" aria-hidden /> <MoreHorizontal className="h-4 w-4" aria-hidden />

View File

@@ -302,6 +302,7 @@ export function getClientColumns({
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-7 w-7" className="h-7 w-7"
aria-label={`Row actions for ${row.original.fullName ?? 'client'}`}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<MoreHorizontal className="h-4 w-4" aria-hidden /> <MoreHorizontal className="h-4 w-4" aria-hidden />

View File

@@ -503,6 +503,8 @@ export function BerthRecommenderPanel({
variant="outline" variant="outline"
onClick={() => setFiltersOpen((v) => !v)} onClick={() => setFiltersOpen((v) => !v)}
disabled={!hasDimensions} disabled={!hasDimensions}
aria-expanded={filtersOpen}
aria-controls="recommender-filters-body"
> >
<Filter className="mr-1.5 size-3.5" aria-hidden /> <Filter className="mr-1.5 size-3.5" aria-hidden />
{filtersOpen ? 'Hide filters' : 'Add filters'} {filtersOpen ? 'Hide filters' : 'Add filters'}
@@ -542,7 +544,9 @@ export function BerthRecommenderPanel({
</div> </div>
</div> </div>
{!collapsed && filtersOpen && hasDimensions ? ( {!collapsed && filtersOpen && hasDimensions ? (
<AmenityFilterForm filters={amenityFilters} onChange={setAmenityFilters} /> <div id="recommender-filters-body">
<AmenityFilterForm filters={amenityFilters} onChange={setAmenityFilters} />
</div>
) : null} ) : null}
{!collapsed && hasDimensions && areaChips.length > 1 ? ( {!collapsed && hasDimensions && areaChips.length > 1 ? (
<div className="flex flex-wrap items-center gap-1.5 pt-1"> <div className="flex flex-wrap items-center gap-1.5 pt-1">

View File

@@ -297,6 +297,7 @@ export function getInterestColumns({
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-7 w-7" className="h-7 w-7"
aria-label={`Row actions for ${row.original.clientName ?? 'interest'}`}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<MoreHorizontal className="h-4 w-4" aria-hidden /> <MoreHorizontal className="h-4 w-4" aria-hidden />

View File

@@ -183,11 +183,17 @@ export function InterestList() {
{/* Kanban view is desktop-only — mobile drops the toggle and {/* Kanban view is desktop-only — mobile drops the toggle and
falls back to the list/cards view (the board's column falls back to the list/cards view (the board's column
horizontal-scroll model is unusable at phone widths). */} horizontal-scroll model is unusable at phone widths). */}
<div className="hidden sm:flex items-center border rounded-md overflow-hidden"> <div
className="hidden sm:flex items-center border rounded-md overflow-hidden"
role="group"
aria-label="View mode"
>
<Button <Button
size="sm" size="sm"
variant={viewMode === 'table' ? 'default' : 'ghost'} variant={viewMode === 'table' ? 'default' : 'ghost'}
className="rounded-none" className="rounded-none"
aria-label="Table view"
aria-pressed={viewMode === 'table'}
onClick={() => setViewMode('table')} onClick={() => setViewMode('table')}
> >
<LayoutList className="h-4 w-4" aria-hidden /> <LayoutList className="h-4 w-4" aria-hidden />
@@ -196,6 +202,8 @@ export function InterestList() {
size="sm" size="sm"
variant={viewMode === 'board' ? 'default' : 'ghost'} variant={viewMode === 'board' ? 'default' : 'ghost'}
className="rounded-none" className="rounded-none"
aria-label="Board view"
aria-pressed={viewMode === 'board'}
onClick={() => setViewMode('board')} onClick={() => setViewMode('board')}
> >
<Kanban className="h-4 w-4" aria-hidden /> <Kanban className="h-4 w-4" aria-hidden />

View File

@@ -555,6 +555,8 @@ function FutureMilestones({
<button <button
type="button" type="button"
onClick={() => setExpanded((v) => !v)} onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
aria-controls="future-milestones-body"
className="flex w-full items-center justify-between gap-2 px-4 py-2.5 text-sm text-muted-foreground hover:text-foreground hover:bg-muted/30 transition-colors" className="flex w-full items-center justify-between gap-2 px-4 py-2.5 text-sm text-muted-foreground hover:text-foreground hover:bg-muted/30 transition-colors"
> >
<span> <span>
@@ -565,6 +567,7 @@ function FutureMilestones({
</button> </button>
{expanded && ( {expanded && (
<div <div
id="future-milestones-body"
className={cn( className={cn(
'grid grid-cols-1 gap-4 p-4 pt-0', 'grid grid-cols-1 gap-4 p-4 pt-0',
milestones.length === 1 ? '' : 'lg:grid-cols-2', milestones.length === 1 ? '' : 'lg:grid-cols-2',

View File

@@ -374,14 +374,14 @@ function LinkedBerthRowItem({
htmlFor={`bundle-${row.berthId}`} htmlFor={`bundle-${row.berthId}`}
className="text-sm font-medium cursor-pointer" className="text-sm font-medium cursor-pointer"
> >
Mark in EOI bundle Include in EOI
</Label> </Label>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
type="button" type="button"
className="inline-flex h-5 w-5 items-center justify-center rounded-full text-muted-foreground hover:bg-muted/60 hover:text-foreground" className="inline-flex h-5 w-5 items-center justify-center rounded-full text-muted-foreground hover:bg-muted/60 hover:text-foreground"
aria-label="What does Mark in EOI bundle do?" aria-label="What does Include in EOI do?"
> >
<HelpCircle className="h-3.5 w-3.5" aria-hidden /> <HelpCircle className="h-3.5 w-3.5" aria-hidden />
</button> </button>

View File

@@ -29,7 +29,11 @@ export function BrandedAuthShell({ children, branding }: BrandedAuthShellProps)
const ctx = useAuthBranding(); const ctx = useAuthBranding();
const logoUrl = branding?.logoUrl ?? ctx?.logoUrl ?? null; const logoUrl = branding?.logoUrl ?? ctx?.logoUrl ?? null;
const backgroundUrl = branding?.backgroundUrl ?? ctx?.backgroundUrl ?? null; const backgroundUrl = branding?.backgroundUrl ?? ctx?.backgroundUrl ?? null;
const altText = branding?.appName ?? ctx?.appName ?? 'Sign in'; // When no port name is known, treat the logo as decorative — "Sign in"
// as alt text was being read on every auth page even when the page
// itself isn't a sign-in surface (e.g. password reset, set-password).
const appName = branding?.appName ?? ctx?.appName ?? null;
const altText = appName ?? '';
// fixed inset-0 anchors the auth surface to the viewport directly — // fixed inset-0 anchors the auth surface to the viewport directly —
// iOS Safari ignores overflow-hidden on inner divs for body-level // iOS Safari ignores overflow-hidden on inner divs for body-level
// scrolling, so a regular `h-dvh overflow-hidden` wrapper doesn't // scrolling, so a regular `h-dvh overflow-hidden` wrapper doesn't

View File

@@ -22,7 +22,7 @@ import {
} from '@/lib/db/schema'; } from '@/lib/db/schema';
import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors'; import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
const TOKEN_TTL_DAYS = 30; const TOKEN_TTL_DAYS = 14;
const TOKEN_BYTES = 32; // 256-bit → ~43 base64url chars; brute-force infeasible. const TOKEN_BYTES = 32; // 256-bit → ~43 base64url chars; brute-force infeasible.
function generateToken(): string { function generateToken(): string {