Skip to content

Shadcn UI consistency pass — implementation plan

Source: refined task breakdown from a review of handcrafted UI parts across the app (chat session, 2026-08-24).

Today, three floating map-control widgets (avesmaps-layer-control.tsx, image-overlay-control.tsx, map-object-visibility-control.tsx) each hand-roll their own useState(open) + always-rendered Card instead of a real overlay primitive — no click-outside dismiss, no Escape, no focus trap. Deleting a map-object type (map-object-type-manager.tsx) or a placed POI/line/area (edit-detail-panel.tsx) submits immediately with no confirmation. login.tsx uses raw <label> elements where every other form in the app uses Field/FieldLabel. The color/icon/image-icon swatch pickers in map-object-type-form.tsx are raw <button> grids with manual selected-state styling instead of a toggle-button primitive.

This plan adds shadcn's Popover, AlertDialog, and ToggleGroup components and uses them to replace those four handcrafted patterns, and brings login.tsx in line with the rest of the app's form styling. Pure UI consistency work — no data shape, route, or server-action changes.


Decisions

Decision Choice Why
Order Popover work (Steps 1–4) lands first, independently of the rest Biggest functional gap (used constantly in view mode); has no dependency on the other three changes
Popover content Each panel's existing Card (size="sm") becomes the direct child of PopoverContent, with PopoverContent's own padding/border stripped (e.g. p-0) so there's no doubled border/shadow Keeps each panel's already-shadcn'd internals (CardHeader/CardTitle/ScrollArea/Checkbox rows) untouched; only the show/hide/dismiss mechanism changes
Delete confirmation abstraction One new shared ConfirmDeleteButton component wraps AlertDialog + the existing fetcher.Form submit pattern, used at both call sites: the generic MapObjectTypeManager delete button (covering all 3 type kinds) and the generic EditDetailPanel delete button (covering all 3 instance kinds, now centralized here since the v0.0.6 map-editor extraction landed) Both are already kind-parameterized components each with 3 internal branches; a shared confirm-delete component keeps the dialog copy and behavior consistent across all 6 branches, which is the point of this pass
ConfirmDeleteButton location app/components/confirm-delete-button.tsx Used by both a map-objects feature component and a route file directly — not owned by one feature, same tier as map-topbar.tsx
AlertDialog copy Names the item and, for type deletes, its usage count via the existing countTypeUsage helper (map-object.ts) Reuses data already computed today (line/area branches already call this; POI branch currently doesn't display it — see Step 7)
ToggleGroup scope Covers all three swatch rows in MapObjectTypeForm: color, lucide icon, and image icon Confirmed with user — all three are single-select pickers with the same shape
login.tsx Swap the two raw <label> elements for Field/FieldLabel, no other visual changes Matches create.tsx, add-map.tsx, and project.tsx's settings form; smallest change that removes the inconsistency

Out of scope

  • Any change to server actions, projects.server.ts, or data/projects.json's shape — this pass is UI-only.
  • Listing which specific map objects use a type being deleted (only the count, which already exists).
  • Redesigning the Card-based panel internals (checkbox rows, scroll areas, headings) — only the outer show/hide mechanism changes.
  • The image-icon row's underlying icon set/rendering (POI_TYPE_IMAGE_ICONS, getPoiImageIconPath) — only its wrapper changes to ToggleGroup.
  • Any other handcrafted pattern not listed above (e.g. Skeleton/Alert for loading/error states) — separate future task.

Step 1 — Add the Popover component

Goal: shadcn's Popover primitive is available in app/components/ui/.

  • Run pnpm dlx shadcn@latest add popover.

Acceptance criteria - [ ] app/components/ui/popover.tsx exists, exporting Popover, PopoverTrigger, PopoverContent (and any portal/anchor pieces the generator adds), built on @base-ui/react, consistent with dropdown-menu.tsx's pattern. - [ ] pnpm typecheck passes.

Step 2 — AvesmapsLayerControl uses Popover

Goal: The open/setOpen state and always-rendered Card in avesmaps-layer-control.tsx (around the trigger Button and Card size="sm" block) are replaced by Popover/PopoverTrigger/PopoverContent, with the existing trigger Button as the PopoverTrigger and the existing Card (unchanged internals: CardHeader, CardTitle, ScrollArea, per-subtype Checkbox rows) as PopoverContent's child.

Acceptance criteria - [ ] No local open/setOpen state remains; Popover's own open state drives visibility. - [ ] The panel closes on outside click, on Escape, and when the trigger button is clicked again. - [ ] The panel's visual content (grouping, counts, checkboxes) is unchanged. - [ ] pnpm typecheck passes.

Step 3 — ImageOverlayControl uses Popover

Goal: Same change as Step 2, applied to image-overlay-control.tsx.

Acceptance criteria - [ ] Same three criteria as Step 2, adapted to this file. - [ ] pnpm typecheck passes.

Step 4 — MapObjectVisibilityControl uses Popover

Goal: Same change as Step 2, applied to map-object-visibility-control.tsx.

Acceptance criteria - [ ] Same three criteria as Step 2, adapted to this file. - [ ] pnpm typecheck passes.

Step 5 — Add the AlertDialog component

Goal: shadcn's AlertDialog primitive is available in app/components/ui/.

  • Run pnpm dlx shadcn@latest add alert-dialog.

Acceptance criteria - [ ] app/components/ui/alert-dialog.tsx exists, exporting AlertDialog, AlertDialogTrigger, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, AlertDialogAction, AlertDialogCancel. - [ ] pnpm typecheck passes.

Step 6 — Build the shared ConfirmDeleteButton

Goal: A new app/components/confirm-delete-button.tsx exports a ConfirmDeleteButton component that renders a destructive trigger Button, an AlertDialog with a title/description and Cancel/Delete actions, and submits an already-built fetcher.Form (the same useFetcher submit pattern used today) on confirm. Props cover: the fetcher, the hidden field values to submit (intent + the relevant id/slug), the trigger's visible content (icon-only vs "Delete" label, to match the two existing button styles), and the dialog's title/description text.

Acceptance criteria - [ ] ConfirmDeleteButton renders nothing extra when closed (trigger button only) and opens the AlertDialog on click instead of submitting immediately. - [ ] Confirming submits the same fetcher.Form (same intent/hidden fields) that the replaced inline buttons submitted. - [ ] Cancelling closes the dialog and submits nothing. - [ ] pnpm typecheck passes.

Step 7 — Wire ConfirmDeleteButton into MapObjectTypeManager

Goal: Both delete buttons in map-object-type-manager.tsx (the icon-only POI delete button and the "Delete" line/area button — one generic, kind-parameterized component covers all three kinds) use ConfirmDeleteButton in place of the bare deleteFetcher.Form + Button type="submit". The dialog description includes the type's name and its usage count via countTypeUsage(project, kind, type.slug) (already called for line/area today; the POI branch gains this same call to compute the count for its own dialog copy).

Acceptance criteria - [ ] Deleting a POI, line, or area type opens a confirmation dialog naming the type and its usage count before submitting delete-{poi,line,area}-type. - [ ] Cancelling leaves the type list unchanged and submits nothing. - [ ] pnpm typecheck passes.

Step 8 — Wire ConfirmDeleteButton into EditDetailPanel

Goal: The three delete blocks in edit-detail-panel.tsx (POI at L105, line at L146, area at L187 — each a removeFetcher.Form/removeLineFetcher.Form/removeAreaFetcher.Form with a Button type="submit" variant="destructive") use ConfirmDeleteButton in place of the bare form + submit button, naming the object being deleted (editingPoi.name/editingLine.name/editingArea.name) in the dialog.

Acceptance criteria - [ ] Deleting a placed POI/line/area opens a confirmation dialog naming the object before submitting remove/remove-line/remove-area. - [ ] Cancelling leaves the edit sidebar in its current editing state and submits nothing. - [ ] pnpm typecheck passes.

Step 9 — login.tsx uses Field/FieldLabel

Goal: The two raw <label> elements in login.tsx (L76 email, L89 password) are replaced with Field/FieldLabel, matching the pattern already used in create.tsx, add-map.tsx, and project.tsx's settings form.

Acceptance criteria - [ ] Email and password fields use Field/FieldLabel instead of raw <label>. - [ ] Visual spacing matches the form's existing gap-4 rhythm. - [ ] pnpm typecheck passes.

Step 10 — Add the ToggleGroup component

Goal: shadcn's ToggleGroup/ToggleGroupItem primitives are available in app/components/ui/.

  • Run pnpm dlx shadcn@latest add toggle-group.

Acceptance criteria - [ ] app/components/ui/toggle-group.tsx (and toggle.tsx if generated as a separate dependency) exists, exporting ToggleGroup/ToggleGroupItem. - [ ] pnpm typecheck passes.

Step 11 — MapObjectTypeForm's swatch rows use ToggleGroup

Goal: In map-object-type-form.tsx, the three raw <button> grids — POI_TYPE_COLORS, POI_TYPE_ICONS, and POI_TYPE_IMAGE_ICONS — each become a ToggleGroup type="single", with each swatch's existing visual (colored circle / lucide icon / image) as the content of a ToggleGroupItem. Selection still drives the same color/icon local state and hidden-input wiring as today.

Acceptance criteria - [ ] All three swatch rows render as ToggleGroup with exactly one selected item each. - [ ] Arrow-key navigation moves focus between swatches within a row; Enter/Space selects. - [ ] Selected-state visuals are equivalent to today's border-foreground/border-primary treatment. - [ ] Submitting the form still posts the same color/icon hidden-field values as today. - [ ] pnpm typecheck passes.

Step 12 — Verification pass

Goal: Confirm all four changes work together with no regressions.

  • Manually exercise each of the 3 map-control popovers: open, click outside to dismiss, open and press Escape, open and re-click the trigger.
  • Manually exercise all 6 delete flows (3 type kinds, 3 instance kinds): confirm the dialog text is correct, confirm Cancel does nothing, confirm Delete performs the same removal as before this plan.
  • Manually check the login form's visual spacing against another form (e.g. create.tsx).
  • Manually exercise the color/icon/image-icon pickers in the type form: keyboard navigation, selection, and form submission.

Acceptance criteria - [ ] pnpm typecheck passes. - [ ] pnpm format passes. - [ ] pnpm build passes. - [ ] Manual checks above pass with no console errors.