name: pinpoint-design-bible description: Design system rules, page archetypes, spacing rhythm, surface hierarchy, responsive strategy. Use when building any new UI, page, or component to ensure visual consistency.
PinPoint Design Bible
When to Use This Skill
Use this skill when:
- Building a new page or view
- Creating or modifying a component
- Making layout or spacing decisions
- Choosing colors, surfaces, or border treatments
- Working on responsive behavior
- Someone asks about the design system, visual style, or UI patterns
1. Visual Identity
PinPoint uses a dark neon aesthetic -- deep charcoal backgrounds with neon green and teal accents.
| Token | Value | Usage |
|---|---|---|
| Primary | #4ade80 |
Actions, active states, CTAs, links |
| Secondary | #2dd4bf |
Accents, decorative highlights |
| Background | #0f0f11 |
Page background |
| Surface | #0f0f11 |
Content areas, full-width sections |
| Card | #18151b |
Elevated containers |
| Surface-variant/30 | -- | Dimmed/closed items |
Purple is not in the palette. It was removed in favor of teal so the
primary and secondary read as one green-family pairing rather than two
competing brands. Do not reintroduce a purple/magenta/fuchsia secondary, or
raw purple-* / fuchsia-* / magenta-* classes, in new code. The one
exception is the legacy raw-Tailwind purple used for a handful of entries in
STATUS_CONFIG and PRIORITY_CONFIG (both in
src/lib/issues/status.ts) -- those are
tracked for conversion to semantic tokens and should be migrated
opportunistically, not extended.
Rules:
- All color references in component code must use semantic tokens. Never write raw Tailwind palette classes (
text-purple-400,bg-amber-500/20,border-fuchsia-500) or hardcoded hex (#d946ef,bg-[#abcdef]) anywhere undersrc/app/**,src/components/**, or any.tsx/.tsfile that renders or styles UI. Usetext-primary,bg-destructive,text-muted-foreground,border-success/40, etc. dark:utility classes are forbidden. PinPoint is dark-only;dark:classes are dead code. Remove them when you touch a file that still contains them.- Design-layer config is the only exception. The four color tables in
src/lib/issues/status.ts(STATUS_CONFIG,SEVERITY_CONFIG,PRIORITY_CONFIG,FREQUENCY_CONFIG) and the equivalent mapping insrc/lib/machines/presence.tsmay use raw Tailwind palette classes — because the raw palette is the design decision being expressed. Component code consumes the resulting class strings viaSTATUS_CONFIG[status].styles; never replicate those class strings at call sites. - Status colors come from
STATUS_CONFIG/SEVERITY_CONFIG/PRIORITY_CONFIG/FREQUENCY_CONFIG-- never freestyle status colors in components. - Glow effects (
glow-primary,glow-secondary) are for interactive hover states only, never static decoration. Applyhover:glow-primaryto navigable card surfaces: machine cards (list and dashboard panels), issue cards, and interactive stat cards. Applyhover:glow-successto "recently fixed" machine cards where the success color already conveys status semantically. Do not apply any glow to form controls, buttons, modals, destructive actions, nav links, input fields, or dropdown triggers. - Frosted glass (bg-card with opacity +
backdrop-blur-sm) is reserved for navigation chrome. - Never rely on color alone to convey semantics. Destructive, warning, success, and status cues must ship with an accessible text label — either visible, or via
aria-label/sr-only. Decorative icons that accompany the color cue should be markedaria-hidden="true"so screen readers receive the label, not the icon. Under deuteranopia / protanopia (combined ~8% of men), destructive-red and warning-amber collapse to similar mustard shades and are not distinguishable by hue. Concretely:<Alert variant="destructive">and<Alert variant="warning">include a leadingAlertOctagon/AlertTriangle(or equivalent) asaria-hiddendecoration plus body text that names the condition; destructive buttons carry a verb label like "Delete" (with any iconaria-hidden); status / severity / priority badges expose.labelalongside their icon.
2. Surface Hierarchy
Pick the surface level based on the element's role:
| When building... | Use |
|---|---|
| Page background | bg-background (#0f0f11) |
| Full-width content section | bg-surface (#0f0f11) |
| Card, popover, elevated container | bg-card (#18151b, fully opaque) |
| Header, app header, tab bar (nav chrome) | bg-card/85 backdrop-blur-sm |
| Closed/archived/dimmed item | bg-surface-variant/30 |
Key distinction: Navigation chrome gets the frosted glass treatment (opacity + blur). Content cards are always fully opaque bg-card.
3. Shell Contract
These values are fixed. Do not deviate.
| Element | Value |
|---|---|
| App header | 56px (h-14), sticky, z-20, frosted glass |
| Bottom tab bar | 56px min-height, fixed, z-50, md:hidden |
| Tab bar safe | env(safe-area-inset-bottom) padding |
| Content bottom | pb-[calc(88px+env(safe-area-inset-bottom))] md:pb-0 |
| Scroll padding | scroll-pt-14 md:scroll-pt-14 |
| Mobile/desktop | md: (768px) is THE breakpoint (standard viewport, no hacks) |
If you add a new page: it MUST include the content bottom padding or content will be hidden behind the tab bar on mobile.
4. Responsive Strategy
PinPoint uses a two-layer responsive framework. Each layer has a distinct job. Never use both layers to solve the same layout problem.
Layer 1 — Viewport Breakpoints (page structure)
Use viewport breakpoints when the decision depends on the browser window size — showing or hiding entire sections, switching page-level grid columns, top-level padding.
| Breakpoint | Viewport | Role | Example |
|---|---|---|---|
md: |
768px | Primary layout pivot | Single column → multi-column, show nav icons |
lg: |
1024px | Element enrichment | Icon-only nav → icon+text, APC logo appears |
sm: |
640px | Padding/spacing only | sm:px-8, sm:gap-4 — no structural changes |
sm: is padding only. Never use sm:grid-cols-2, sm:flex-row, or hidden sm:block.
Layer 2 — Container Queries (component internals)
Use container queries when the decision depends on the component's available width — not the viewport. A component inside the issue detail content column has less space than the same component full-width, regardless of screen size.
These are NOT viewport sizes. They are the width of the nearest @container ancestor.
| Query | Container width | Typical use |
|---|---|---|
@lg: |
512px | First internal layout shift (e.g., stack → row) |
@xl: |
576px | Expanded row layout, additional columns |
@2xl: |
672px | Further enrichment, multi-column grids |
@3xl: |
768px | Full-featured component layout |
Decision Tree
"Show/hide entire section?" → Viewport (md: / lg:)
"Component internal layout?" → Container query (@lg: / @xl:) if variable-width parent, else viewport
z-index Hierarchy
| Element | Value |
|---|---|
| App header | z-20 |
| Bottom tab bar | z-50 |
| Modals (Radix) | z-50+ |
Rules
- Mobile-first: write the mobile layout, then add
md:/@lg:overrides. md:shows/hides sections and sets page structure.lg:enriches elements (icon → icon+text).- AppHeader uses a two-tier pattern: nav items appear at
md:(icon-only), text labels expand atlg:. See Section 8. - No JavaScript viewport detection. No
window.innerWidth,useMediaQuery, ormatchMedia— use CSS. These cause hydration mismatches and duplicate CSS's job. @containerpropagation: Adding@containerto a parent changes how all descendant container queries resolve. Audit children before adding it to an existing element.- Overflow testing: Every page must pass
assertNoHorizontalOverflow()in its smoke test —document.scrollWidth <= document.clientWidthat both mobile (375px) and desktop (1024px) viewports. Add new pages toe2e/smoke/responsive-overflow.spec.ts. - Documented exception:
use-table-responsive-columnsfor IssueList (PP-rs9) uses a JS hook — this is the sole approved exception.
5. Page Archetypes
When building a new page, pick the closest archetype and follow its pattern.
Dashboard
max-w-6xl -- Stats in grid-cols-1 md:grid-cols-3, lists in md:grid-cols-2.
List Page (issues, machines)
max-w-7xl -- Filters + card grid md:grid-cols-2 lg:grid-cols-3.
Detail Page with Sidebar (machine detail)
grid md:grid-cols-[minmax(0,1fr)_320px] -- Sidebar hidden md:block, collapses to inline strips on mobile.
Note: Issue detail migrated off this archetype (see "Detail Page with Inline Metadata" below). Machine and Location detail still use this pattern.
Detail Page with Inline Metadata (issue detail)
max-w-3xl (PageContainer size="narrow") single-column main flow. Metadata uses IssueMetadata (container query reflows 1-col → 2-col at @xl:). Mobile sticky comment composer opens a Sheet. Reading-content-shaped pages prefer narrow over standard — issue detail is text + form rows, not a dashboard.
Note: Replaces "Detail Page with Sidebar" for issue detail; eliminates desktop/mobile divergence. Use for new detail pages; migrate existing sidebar pages opportunistically.
Tabbed Detail Page (machine detail, multi-tab)
PageContainer size="standard" wrapping a persistent header zone + URL-driven tab strip + tab content. Each tab is a real route, not client state — deep-linkable and back-button-friendly. Reference implementation: src/app/(app)/m/[initials]/ (layout.tsx renders header + MachineTabStrip; sibling page.tsx and {slug}/page.tsx files render per-tab content).
- Persistent header: identity-only —
[initials chip] [game name (truncates)]. No status badge, no presence badge, no owner display, no primary action button. Not sticky on scroll. The rationale: identity stays in one place across tab navigation; everything else (status, owner, actions) moves into the tab content where it belongs to that tab's context. - Per-tab status badge: open-issue count + machine-status color render as a small colored pill appended to the relevant tab label (e.g.,
Service [3]in amber forneeds_service). Hidden when count is 0. This single element carries both the urgency (color, from status) and the scale (number, from open-issue count) — replaces the persistent header's status display. - Tab strip: horizontal
flexrow insideoverflow-x-auto. Active tab usesborder-b-2 border-primary text-primary. A right-edge fade gradient (pointer-events-none absolute bg-gradient-to-l from-background) is rendered only when the strip can scroll further right — tracked via ascrolllistener +ResizeObserverso the fade hides cleanly when all tabs already fit or when the user has scrolled to the end. - Mobile: same strip — scrolls horizontally when tabs don't fit. Client-side
scrollIntoView({ inline: 'center' })on mount centers the active tab on deep-link. - Desktop (≥ md): typically fits all tabs in one row with no scroll; fade stays hidden.
- Data sharing: layout + tab content share a
cache()-wrapped query (e.g.,getMachineForLayoutin_data.ts) — both call the same function within one request and the second call returns the cached result. Layout callsnotFound()for missing entities so children can assume existence. - No shadcn
<Tabs>: that primitive is state-driven (client-only). URL-driven tabs are a navigation strip, not a tabs widget — build with<Link>+usePathname().
Form Page (report, create machine)
max-w-2xl -- Form inside a card, back button + title in header.
Settings Page
max-w-3xl -- Vertical sections separated by Separator components.
Admin Table
max-w-6xl -- Full-width Table with fixed column widths.
Auth Page
max-w-md -- Centered card, no MainLayout wrapper.
Content Page (about, privacy, changelog)
max-w-3xl -- Prose content inside a card.
Help Hub
max-w-3xl -- Card grid sm:grid-cols-2.
6. Spacing Rhythm
Page-Level Vertical Padding
| Context | Value |
|---|---|
| Standard pages | py-10 |
| Settings / forms | py-6 |
| Detail pages (mobile-adj) | py-4 sm:py-10 |
Horizontal Padding
MainLayout provides px-4 sm:px-8 lg:px-10. Pages use max-w-* mx-auto only -- do NOT add their own px-*.
Section & Content Gaps
| Context | Value |
|---|---|
| Standard section gaps | space-y-6 |
| Major detail sections | space-y-8 |
| Card grids | gap-6 |
| Main + sidebar layouts | gap-8 |
Card Padding
Use shadcn defaults: CardHeader (px-6 pt-6 pb-3), CardContent (px-6 pb-6). Override only with a documented reason. Compact cards use p-3.
7. Card & List Patterns
- Cards are full-width tappable links on mobile, grid layout on desktop.
- Open items:
bg-card+hover:glow-primary+border-outline-variant hover:border-primary/50. (Earlier drafts called forbg-surfacehere, but the cards are content surfaces, not full-width sections — see §2 Surface Hierarchy.) - Closed items:
bg-surface-variant/30, no glow. IssueCardhasnormalandcompactvariants -- usecompactfor secondary/nested lists.IssueBadgeGridhasvariant="normal"(grid layout) andvariant="strip"(inline flex).
8. Navigation Patterns
AppHeader (always rendered, two-tier responsive)
- Wide desktop (>= lg): Logo, APC logo, nav links (Dashboard, Machines, Issues — icon+text), spacer, Report Issue button (secondary variant, icon+text), HelpMenu, NotificationList, UserMenu.
- Tablet (md–lg): Logo, nav links (Dashboard, Machines, Issues — icon-only), spacer, Report button (secondary variant, icon + "Report" label), HelpMenu, NotificationList, UserMenu. APC logo hidden.
- Mobile (< md): Logo, spacer, NotificationList, UserMenu. Nav links and Report Issue use
hidden md:flex. - Unauthenticated: NotificationList + UserMenu replaced by Sign In / Sign Up buttons.
- Admin link: Inside UserMenu dropdown (role-gated, not a top-level nav item).
- Icon-only pattern: Nav text uses
hidden lg:inlineon<span>. Icons always visible withtitlefor tooltip/a11y.
HelpMenu (desktop only, in AppHeader)
- Trigger:
HelpCircleicon button with badge dot when unread changelog entries exist. - Items: Feedback (Sentry widget), What's New (
/whats-new), Help (/help), About (/about).
BottomTabBar (mobile only, md:hidden)
- Primary tabs: Dashboard, Machines, Issues, Report Issue. Order matches the desktop
AppHeaderbecauseBottomTabBarspreads the sameNAV_ITEMSarray. - More tab: Opens
Sheet(bottom drawer) with Feedback, What's New, Help, About, Admin (role-gated).
Shared rules
- Active state:
text-primary. - Inactive state:
text-muted-foreground hover:text-primary. - Active detection uses
isNavItemActive()fromnav-utils.tswith pathname matching and special cases (e.g., issue detail highlights the Issues tab).
Testing Responsive Behavior
- Overflow assertions:
assertNoHorizontalOverflow(page)ine2e/support/actions.tschecksdocument.scrollWidth <= document.clientWidth. Every new page must be added toe2e/smoke/responsive-overflow.spec.ts. - Container query testing: Playwright can force a container width:
await page.evaluate(() => { document.querySelector('[data-testid="content-wrapper"]')!.style.width = '576px'; })— triggers@xl:breakpoints independently of viewport. - Chrome DevTools: Container query overlays available in Elements panel → "container" badge on
@containerelements.
9. Typography Scale
| Element | Classes |
|---|---|
| Page title (desktop) | text-3xl font-bold |
| Page title (detail page) | text-3xl font-bold |
| Section heading | text-xl font-semibold |
| Card title (normal) | text-base |
| Card title (compact) | text-sm |
| Metadata / labels | text-xs text-muted-foreground |
| Issue IDs | font-mono (e.g., AFM-3) |
| Machine name in cards | text-xs font-medium underline decoration-primary/30 underline-offset-2 |
Text wrapping (text-balance / text-pretty):
text-balanceon headings that may wrap onto multiple lines (page titles, card titles, dialog titles, hero copy, section headings).text-prettyon multi-line body copy that may wrap (card descriptions, alert/dialog descriptions, PageHeader subtitles, auth intro copy, feature descriptions).- Skip both for short definitely-single-line labels, table cells, badge/chip labels, and legends.
The shared primitives (CardTitle/CardDescription, AlertTitle/AlertDescription, DialogTitle/DialogDescription, AlertDialogTitle/AlertDialogDescription, EmptyState, PageHeader) bake these in by default — call sites rarely need to add them manually.
10. Border & Divider Rules
| Context | Treatment |
|---|---|
| Navigation chrome | border-primary/50 |
| Content cards | border-outline-variant |
| Form sections | Separator component |
| Page header bottom | border-b border-outline-variant pb-6 |
11. Transition Durations
Two canonical durations standardize all animated feedback:
| Intent | Duration | Class | Typical use |
|---|---|---|---|
| Hover feedback, color shifts | 150ms | duration-150 |
Button hover, text color, icon state |
| Layout changes, structural animations | 300ms | duration-300 |
Panel slides, accordion expand, drawer open |
Property selection: Prefer specific transitions over transition-all — list the properties that actually animate. This improves performance and clarity.
transition-colors duration-150for button hovers, link colors, icon fillstransition-opacity duration-150for opacity-only reveals (badges, icons)transition-transform duration-150for small rotations (chevrons, badges)- When a single element animates multiple properties (e.g. colors + a focus ring's box-shadow, or colors + a pressed-state transform), use the bracket syntax:
transition-[color,background-color,border-color,box-shadow] duration-150 - For layout shifts (drawers, accordions, height/width transitions), prefer the specific property list with
duration-300:transition-[height,width] duration-300,transition-[grid-template-rows] duration-300, etc. Reservetransition-all duration-300for cases where the set of animating properties genuinely isn't enumerable.
Rule: Never introduce a duration other than 150 or 300 unless the canonical two genuinely don't fit. If you find an edge case, add a new row to this table first, document the use case, then use it consistently across similar elements.
Motion sensitivity: Every animate-* and non-essential transition-* utility pairs with motion-reduce:animate-none / motion-reduce:transition-none (CORE-A11Y-002). Loading spinners use animate-spin motion-reduce:animate-none — the static icon still communicates "loading." Essential motion (e.g., a Sheet sliding into view — the slide is what conveys "this came from the side") can omit the variant; document the choice in a one-line comment so reviewers know it was deliberate.
12. Component Inventory
Before building something new, check if one of these already exists:
| Component | Purpose |
|---|---|
AppHeader |
Two-tier responsive header. Icon-only nav at md:, icon+text at lg:. APC logo at lg: only. |
HelpMenu |
Dropdown with Feedback, What's New, Help, About. Badge dot for unread changelog. |
BottomTabBar |
Mobile tab bar (md:hidden). Dashboard, Machines, Issues, Report, More. |
IssueBadgeGrid |
Status/severity/priority/frequency display |
IssueBadge |
Individual status badge with color |
IssueCard |
Issue summary card (normal/compact) |
IssueRow |
Table row variant of issue display |
SidebarActions |
Issue metadata editing (compact/full, rowLayout) |
SaveCancelButtons |
Form action buttons |
Card / CardHeader / CardContent |
shadcn/ui card |
Sheet |
Bottom drawer (mobile "More" menu) |
NotificationList |
Notification bell + dropdown |
UserMenu |
Avatar + dropdown menu (includes Admin link for admin role) |
BackToIssuesLink |
Breadcrumb back navigation |
EmptyState |
Icon + title + optional body + optional action. variant="card" (default) or variant="bare". |
Alert (shadcn) |
Inline message. variant="destructive" for errors. Never hand-roll <div role="alert">. |
Skeleton (shadcn) |
Loading placeholder. Shape it like the content that will arrive. |
13. Cross-Cutting UI States
Every page will eventually need one of these three states: empty, loading, error. Each has a canonical pattern. Reach for the pattern first; don't invent a variant.
Empty State
Canonical component. Use
<EmptyState>from~/components/ui/empty-statewhenever a list, collection, or section has zero items to display. Prefer the shared component over hand-rolled inline empty-state layouts so copy, spacing, and visual hierarchy stay consistent across the app.
Use <EmptyState> whenever a list, collection, or section has zero items to display.
| Prop | Purpose |
|---|---|
icon |
A lucide-react icon. Rendered at size-12 in a muted circle. |
title |
Short heading (e.g., "No machines yet", "No issues found"). |
description |
Optional body text. Explain what would populate this section. |
action |
Optional CTA — typically a <Button> or <Link> styled as such. |
variant |
"card" (default, wraps in <Card>) or "bare" (plain container). |
When to use each variant:
variant="card"— the empty state IS the content of the section. Dashboard widgets, standalone "no results" pages.variant="bare"— the empty state is rendered inside a list that's already wrapped in aCardor container. No double-border effect.
Rules:
- Never hand-roll an empty state with a raw
<div>+ icon + heading. Always use<EmptyState>. - Icon should be a single lucide icon — not a composition or custom SVG.
- Keep the title under 40 characters. If you need more, use
description. - Provide an
actiononly if the user can do something productive from here (e.g., "Report the first issue"). Don't provide dead-end CTAs. - For filtered-result empty states ("no matches for your filter"), the action should be "Clear filters" or similar.
Loading State
Prefer <Skeleton> rectangles shaped like the content that will appear. Skeletons reduce layout shift and communicate progress better than spinners.
| Situation | Use |
|---|---|
| Async data not yet available (lists, tables) | <Skeleton> rectangles matching the shape of incoming rows |
| In-flight form submission | <Button loading> — handles spinner + disabled state |
| Long-running background work | Toast with an inline spinner / progress indicator |
| Optimistic UI (actions with predictable end) | Update immediately, revert on error |
Rules:
- No custom spinners outside Button. Don't import
<Loader2>or similar directly into components. If you need one, use Button'sloadingprop. - Skeletons match shape, not count. Render 3-5 skeleton rows at most; real data decides the true count.
- No
loading.tsxfiles unless a route takes >500ms to stream initial HTML. Most pages render fast enough that a skeleton-shaped flicker is worse than the brief "empty for a moment" state. - In-place updates stay silent. A button that toggles a flag doesn't need a skeleton — just update the UI.
Error State
Three tiers based on scope. Pick the narrowest one that fits.
| Scope | Pattern |
|---|---|
| Form-level (submission failed) | <Alert variant="destructive"><AlertDescription> at top of form |
| Field-level (one field invalid) | <FormMessage> (react-hook-form) or inline text-sm text-destructive |
| Inline list edit (cell update) | toast.error("Failed to update X") |
| Entire route crashed | error.tsx boundary (already implemented) |
| Route not found | not-found.tsx boundary (already implemented) |
Rules:
- Never hand-roll
<div role="alert" className="rounded-md border border-red-900/50...">. Use<Alert variant="destructive">— it already exists in~/components/ui/alert. - Form-level errors should be announced at the top of the form, not buried near the submit button. Screen reader users need the error to appear above the inputs.
- Provide a recovery path. "Try again" button, a link to contact support, or instructions on what to fix.
- Don't use toast for form-level errors. Toasts dismiss themselves and are easy to miss. Use them only for transient async events.
14. Feedback Decision Tree
When something happens in response to user action, where should they see feedback?
| What happened | Where to show feedback |
|---|---|
| Form submit success → redirect | Server Action does the write, then redirect(...); if needed, show success on the destination page |
| Form submit success → stay on page (settings) | Return success state from the Server Action; <SaveCancelButtons> green flash (3s "Saved!") |
| Form submit error | <Alert variant="destructive"> at top + <FormMessage> under fields |
| Field validation error (Zod) | Inline <FormMessage> |
| Inline list edit (status change, priority change) | toast for both success and error |
| optimistic action (toggle, bookmark) | Immediate UI update; toast.error() on failure |
| Long-running background work (uploads) | Toast with progress indicator |
| Short in-place work (counter increment) | Immediate UI update, no notification |
Why server-side redirect instead of toast.success() + router.push()? The project's progressive-enhancement rule (AGENTS.md §2.1 "Progressive Enhancement") requires forms to work without JavaScript. <form action={serverAction}> + server-side redirect(...) works with JS off; a toast.success() + router.push() pattern only fires after hydration. If a success toast is genuinely needed on the destination page, persist a one-time success state (e.g., via a search param or short-lived cookie read in the destination route) and render it there.
Rule of thumb: If the user initiated it and waited → feedback. If it was instant or invisible → no feedback.
Why not toast for everything? Toasts are ephemeral and noisy. Use them for transient events (row updated, file uploaded). Use inline alerts for persistent state (form has errors, save failed, retry needed).
Why <SaveCancelButtons> has its own success flash instead of a toast? Settings pages don't redirect, so a toast would disappear while the user is still looking at the form. A button that briefly turns green keeps the feedback anchored to the action.
15. Date Formatting Vocabulary
Status — implemented. Three canonical helpers live in
src/lib/dates.ts. Use them; never callformatDistanceToNowortoLocaleDateStringdirectly from a component.
| Helper | Output | When to use |
|---|---|---|
formatRelative(date) |
"3 days ago", "in 2 hours" |
Activity timestamps — comments, issue updates, notifications |
formatDate(date) |
"Apr 17, 2026" |
Absolute dates in detail views, created-at fields |
formatDateTime(date) |
"Apr 17, 2026, 9:30 PM" |
Admin audit logs, precise timestamps, debug info |
All three accept Date | string | number. For null / undefined dates, null-guard at the call site and choose a context-appropriate placeholder (e.g., hiding the element, rendering "—", or using a semantic placeholder like "never").
Why a vocabulary instead of raw calls?
- Consistency. "2 days ago" and "Apr 17" look the same everywhere.
- Locale safety.
toLocaleDateString()renders differently per locale, which breaks visual regression tests. - Refactor leverage. If we ever switch from
date-fnstoTemporalor add tooltips showing absolute dates on hover, we change one file.
Don't: build custom formatting helpers per feature. If formatRelative / formatDate / formatDateTime don't cover a case, expand the vocabulary rather than inlining a new variant.
16. Icon Library
lucide-react is the only icon library for new work. Do not introduce new inline SVGs, and do not import icons from other libraries. Some existing inline <svg> usage is legacy (signup confirmation state, AssigneePicker chevron, NotificationList dismiss icon); when you touch those areas, prefer migrating them to lucide-react opportunistically where doing so does not change behavior.
Sizing
| Class | Usage |
|---|---|
size-4 (1rem) |
Default inline, buttons, nav links, table cells |
size-5 |
Heading emphasis (CardTitle/DialogTitle with leading icon) |
size-6 |
Callouts, prominent indicators |
size-8 |
Section decorative |
size-10+ |
EmptyState icons (rendered at size-12 in a muted circle), hero uses |
Critical rule: Use size-*, never h-* w-*. The size-* utility is Tailwind v4 canon; h-4 w-4 is legacy and creates two classes where one would do.
Buttons auto-size icons. <Button> has [&_svg:not([class*='size-'])]:size-4 built in, so you don't need to specify size-4 on an icon child. Only add an explicit size if you're overriding.
Color: Icons inherit from parent text color. Add text-* to the parent or the icon itself; don't use fill= or stroke= overrides.
Accessibility: Icon-only buttons must have aria-label or a visible <span className="sr-only">. Nav icons that are part of a labeled nav item (<Link> with text that may be hidden at some breakpoints) should have title as a tooltip fallback.
17. Modal Archetypes
Two canonical modal patterns. Use shadcn primitives directly; don't extract a composite unless duplication exceeds rule-of-three.
FormDialog pattern (create/edit in a modal)
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline">Edit</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Edit machine</DialogTitle>
<DialogDescription>Update the name and location.</DialogDescription>
</DialogHeader>
<form action={updateMachine} className="space-y-4">
<!-- fields -->
<DialogFooter>
<Button variant="outline" type="button" onClick={() => setOpen(false)}>Cancel</Button>
<Button type="submit">Save</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
ConfirmDialog pattern (destructive confirmations)
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive">Delete</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete machine?</AlertDialogTitle>
<AlertDialogDescription>This cannot be undone.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={deleteMachine}>Delete</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
Sizing
| Size | Class | Use case |
|---|---|---|
| Default | (no override) | Short confirmation prompts |
| Medium | sm:max-w-lg |
Most forms (2-6 fields) |
| Large | sm:max-w-xl / 2xl |
Forms with rich content (editors, previews) |
Footer layout
DialogFooter uses flex flex-col-reverse gap-2 sm:flex-row sm:justify-end. Write the buttons in source order as [Cancel, Save] (or [Cancel, Delete] for AlertDialog). That renders:
- Mobile (
flex-col-reverse): primary action (Save/Delete) on top, Cancel below. The reversal intentionally puts the primary action above the fold / closer to the focus point for small-screen readers. - Desktop (
sm:flex-row sm:justify-end): horizontal row on the bottom-right, Cancel left, primary action rightmost — matching the standard "primary action anchors the right edge" convention.
Do not reorder the buttons to try to "fix" the mobile stack — the reversal is by design.
Rules
- Never build a custom
ModalorDrawercomponent — Dialog / AlertDialog / Sheet cover every case. - Never put a
<form>inside aDropdownMenuItem— Radix closes the dropdown before the form submits. UseonSelect={() => serverAction()}instead. - Never wrap a Server Action in an inline async function:
action={async () => await serverAction()}breaks progressive enhancement. Pass the Server Action directly:action={serverAction}. - For destructive confirmations, use
AlertDialog— it has semantics (role="alertdialog") that screen readers announce more urgently. - When opening any modal, set
inerton the page-root container (CORE-A11Y-006). Radix usesaria-hidden+ pointer-events on the background;inertis the platform primitive that also removes the background from tab order. - Native
<dialog>.showModal()(Baseline Widely available; Baseline since Mar 2023 per §19) is not the default for product UI — shadcn<Dialog>/<AlertDialog>/<Sheet>are. Reach for<dialog>only for one-off, self-contained, single-purpose surfaces that don't earn a place in the shadcn variant system (e.g., a debug-only inspector, a tightly-scoped help blurb). Seepinpoint-uiskill § "Native HTML primitives alongside shadcn/Radix".
Composer surfaces (mobile bottom sheet + quick→full editor)
Any surface whose primary job is composing free-form rich text (timeline notes, issue comments, and future equivalents) follows two rules. Length-limited single-line fields — titles, names, search boxes — are explicitly exempt; these rules are about multi-line writing surfaces only.
Bottom sheet on mobile. On small screens a composer SHOULD open in a bottom
<Sheet side="bottom">, not an inline block buried in the page. The sheet owns the keyboard-adjusted viewport so the editor and its Post button stay visible while typing, and the capture affordance is reachable without scrolling past existing content. On desktop the composer may render inline (or in a side sheet / dialog when triggered from a header action). One composer component, viewport-aware presentation — do not fork into two components.Quick → full editor transition. The editor opens in quick mode: the formatting toolbar is hidden so it reads as a fast jot, not a document. A single toggle (a "format"/
Aabutton beside the primary controls) reveals the full toolbar; the toggle is two-way (content is always stored as the same rich-text document regardless of mode, so flipping back is lossless). Default to the lightest classification (e.g. the timeline composer defaults its tag tonote) and never block submit on a classification the author has not been asked to make. SupportCmd/Ctrl+Enterto submit.
The timeline composer (PP-0x98) establishes the basic pattern; generalizing it across every mobile editor and extracting a shared primitive is tracked separately (do it on the third use site, per rule-of-three — §17 intro).
18. Token Canonical Form
globals.css defines two parallel token vocabularies. The MD-era tokens predate Tailwind v4's semantic token naming and are kept in CSS for backward compatibility, but new code must use the canonical Tailwind semantic tokens.
| MD-era (deprecated in code) | Canonical Tailwind semantic |
|---|---|
text-on-surface |
text-foreground |
text-on-surface-variant |
text-muted-foreground |
bg-error-container |
bg-destructive/10 |
text-on-error-container |
text-destructive |
Rules:
- New code uses the canonical tokens. No exceptions.
- When editing a file that uses deprecated tokens, migrate them as part of the change. Opportunistic cleanup — don't go out of your way, but don't leave deprecated tokens next to your edits.
- CSS variable definitions stay. The deprecated tokens are still defined in
globals.css; existing code keeps working during migration. Eventually the MD-era tokens will be removed, but not in a single sweep. - Exception:
bg-surface-variant/30. The dimmed/closed item surface (Section 2) has no Tailwind-semantic equivalent and is intentional design. Keep it.
Quick cheatsheet
| Need | Use |
|---|---|
| Body text | text-foreground |
| Secondary / helper text | text-muted-foreground |
| Primary accent (links/CTAs) | text-primary |
| Error text | text-destructive |
| Primary CTA background | bg-primary |
| Subtle background | bg-muted |
| Destructive CTA background | bg-destructive |
| Destructive container bg | bg-destructive/10 |
| Card background | bg-card |
| Dimmed/closed item | bg-surface-variant/30 |
19. Browser Support Policy
PinPoint's UI is built on Baseline Widely available (CORE-UI-005) — features that have been cross-browser stable for ~2.5 years. This is the support floor for every new component, layout, animation, and form pattern.
shadcn/ui and Radix remain the design system. The Baseline floor is the platform layer underneath — what we trust to "just work" in our users' browsers. We don't migrate components off Radix to chase native primitives; we layer Widely-available web platform features (:user-invalid, inert, container queries, :has(), fetchpriority, motion-reduce:, aspect-ratio, enterkeyhint, autocomplete tokens, semantic <table> markup, native required/pattern validation, etc.) onto our shadcn-based components so they get the full benefit of the platform.
What is in-scope today
| Capability | Where it shows up in PinPoint | Baseline since |
|---|---|---|
Container queries (@container) |
IssueMetadata, IssueTimeline, AddCommentForm, ImageGallery | Feb 2023 |
:has() |
DOM-state-driven styling, removes JS mirroring | Dec 2023 |
:user-valid / :user-invalid |
Shared Input/Textarea primitives (CORE-FORM-003) | Nov 2023 |
inert attribute |
Background regions when modals open (CORE-A11Y-006) | Mar 2022 |
aspect-ratio |
ImageGallery, calendar day cells | Mar 2021 |
accent-color |
Checkbox/radio/range accent matching | May 2022 |
fetchpriority (img/script/link) |
LCP candidate images (Next/Image priority) |
Sep 2023 |
| CSS subgrid | Multi-column form alignment | Sep 2023 |
gap on flexbox |
Standard spacing everywhere | Apr 2021 |
prefers-reduced-motion (motion-reduce:) |
Every animation utility (CORE-A11Y-002) | Jul 2020 |
focus-visible |
All interactive primitives in src/components/ui/ |
Mar 2022 |
Native form validation (required …) |
Every form | (pre-Baseline) |
enterkeyhint |
Multi-field forms (CORE-FORM-006) | Dec 2021 |
Logical properties (inline-start) |
RTL-ready text alignment | Mar 2023 |
Native <dialog> |
Narrow one-off cases — see §17 | Mar 2023 |
Native <details> / <summary> |
Trivial disclosure where Accordion would be overkill | (pre-Baseline) |
What is deferred (Baseline Newly available)
These are not in PinPoint today. They require a per-feature opt-in here in §19 before adoption.
- Popover API (
popover="auto"/popover="hint") — Radix Popover/DropdownMenu/Tooltip already cover the use cases. - View Transitions (same-document and cross-document) — interesting for navigation polish; defer.
- CSS anchor positioning — would simplify some popover/tooltip placement; Radix's JS-driven positioning already works.
- Scroll-driven animations —
animation-timeline: scroll()and friends. Defer. text-wrap: balance— partially adopted (we usetext-balanceselectively per §9), but treat as Newly available and check support per use.interestforattribute for tooltips — Chrome-only as of late 2025; defer.closedbyattribute on<dialog>— Limited availability (no Safari).
How to verify a feature's Baseline status
The Google Chrome modern-web-guidance catalog tags each guide with its Baseline status. Search it before adopting any pattern:
npx -y modern-web-guidance@latest search "<query>"
npx -y modern-web-guidance@latest retrieve "<id>"
If the guide says "Baseline Widely available" — use directly. If "Baseline Newly available" — follow the guide's documented fallback, or skip the recommendation and add it to the deferred list above.
Opting in to a Newly-available feature
If a Newly-available feature becomes load-bearing for a planned design:
- Open a PR that adds a row to the "in-scope" table above (or moves one from "deferred").
- Document the fallback strategy in the row (e.g., "Use
@supportsto feature-detect; fall back to existing pattern X"). - Link the spec/explainer + the MWG guide id.
- Add the feature to
pinpoint-uiskill's relevant section.
Don't sneak a Newly-available feature in without updating this section — it's the single source of truth for what the project considers safe.
20. Form Correctness Conventions
Forms are the highest-leverage place to lean on the Widely-available web platform. The browser does post-interaction validation, autofill, mobile-keyboard hints, password-manager integration, and screen-reader announcement — opt in correctly and most "form polish" tickets disappear. Concrete rules and code in pinpoint-ui skill § "Form Correctness"; canonical rules in CORE-FORM-001..006.
Required attributes on every form input
| Attribute | When |
|---|---|
type |
Always — email, tel, url, password, text per CORE-FORM-001 |
autocomplete |
Every credential/identity input — per the token table below |
required |
Every field the form will refuse to submit without |
enterkeyhint |
Every field in a multi-field form (CORE-FORM-006) |
inputmode |
Numeric/decimal/tel where type alone isn't enough |
pattern / minlength |
When the validation can be expressed declaratively |
Autocomplete token quick reference
| Form / field | Token |
|---|---|
| Sign-in email | username |
| Sign-in password | current-password (id="current-password" too) |
| Sign-up email | username |
| Sign-up new password | new-password |
| Sign-up confirm password | off ← critical; do not autofill the confirm |
| Reset password (new) | new-password |
| Reset password (confirm) | off |
| First name | given-name |
| Last name | family-name |
| Email (general identity) | email |
| Phone | tel |
| Domain-specific picker | off (explicit — prevents browser from guessing/filling) |
Required-field indicators
Append <span aria-hidden="true">*</span> to the <Label> of every required field. For forms with many required fields, include a <p className="text-sm text-muted-foreground">* required</p> legend once near the top. Don't rely on the post-submit error to teach the user which fields are required.
Validation feedback timing
- Visual:
:user-invalidstyling on the shared<Input>primitive — fires only after the user has interacted (CORE-FORM-003). - AT:
aria-invalid="true"synced on blur whencheckValidity()fails (CORE-FORM-004). Implement once in the shared primitive, not per form. - Form-level errors:
<Alert variant="destructive">at the top of the form (per §13 Error State). - Field-level errors:
<FormMessage>(react-hook-form) under the field, or inline<p className="text-sm text-destructive">.
Submit-button enabled state
Disable a submit button after the user has attempted submission (to prevent double-posts), not preemptively while a CAPTCHA hasn't resolved or a field isn't yet filled. Preemptive disabling gives users no feedback about why the button is greyed out. The exception is the shadcn <Button loading> state during an in-flight submission.
21. Image Loading Discipline
Image loading sits at the intersection of LCP, layout stability, and bandwidth. Next.js <Image> handles the heavy lifting (srcset, WebP/AVIF negotiation, lazy loading); PinPoint must opt in correctly on a per-image basis.
priority is for the LCP candidate only (CORE-PERF-003)
priority emits fetchpriority="high" (Baseline Widely available since Sep 2025) plus eager loading. The browser interprets this as "this image is critical to first paint." Every prioritized image deprioritizes every other resource — so adding priority to a non-LCP image actively hurts LCP.
| Place | Should priority? |
|---|---|
| The largest above-the-fold image | Yes — that's the LCP candidate |
| 32px header logo | No — too small to be the LCP, downloads fast |
| Sidebar logo on a wide-only column | No — never the LCP, often below the fold |
| Image inside a closed dialog/modal | No — not in the viewport at first paint |
| Avatars in a list | No — many, all small, none the LCP |
| Thumbnail in a gallery | No — lazy is correct |
sizes accompanies every responsive image
Without sizes, the browser assumes 100vw and downloads the desktop-width variant on mobile. Always provide sizes for images that don't render at full viewport width:
<Image
src="/apc-logo.png"
alt="APC logo"
width={200}
height={149}
priority
sizes="(max-width: 768px) 80vw, 200px"
/>
Preconnect known image origins
Add <link rel="preconnect"> in the root layout for any third-party image origin used during initial render (e.g., the Vercel Blob bucket subdomain). This eliminates DNS + TLS handshake on the first user-uploaded image.
// src/app/layout.tsx <head>
<link
rel="preconnect"
href="https://<project>.public.blob.vercel-storage.com"
/>
Don't priority images that render inside closed surfaces
A Client-Component <Dialog> is mounted before it opens; an <Image priority> inside that mount is fetched eagerly even though the dialog hasn't been opened yet. Use priority on the gallery thumbnail's LCP candidate if there is one, not on the modal's full-size image.
22. Modern Web Guidance Reference
The Google Chrome modern-web-guidance plugin is PinPoint's canonical lookup tool for "is there a Widely-available primitive for this?" Each guide is a prescriptive document with DOs/DON'Ts and a Baseline-status note. Use it at the start of any non-trivial UI work (CORE-UI-006).
Three commands
npx -y modern-web-guidance@latest search "<query>" # find guides by intent
npx -y modern-web-guidance@latest retrieve "<id>,<id2>" # fetch full guide(s)
npx -y modern-web-guidance@latest list # browse the catalog
Guide map by PinPoint use case
| Building... | MWG search/retrieve |
|---|---|
| Sign-in / sign-up form | forms, autofill-sign-in-form, autofill-sign-up-form |
| Address or anonymous reporter form | autofill-address-form, forms |
| Post-interaction validation | validate-input-after-interaction, required-field-feedback |
| Accessible error announcement | accessible-error-announcement |
| Modal / dialog / confirmation | html §4, light-dismiss-a-dialog, platform-controls-dismiss-dialog |
| Mobile drawer / slide-in | navigation-drawer |
| Tooltips on touch | interest-triggered-tooltips (most variants are Newly available) |
| Image priority / LCP | optimize-image-priority, optimize-preload-priority |
| Long-task / INP | break-up-long-tasks, identify-inp-causes |
| Container-internal layout | css-layout, size-aware-styling |
| Conditional styling via DOM state | style-parent-with-has |
| Hidden-but-findable content | search-hidden-content |
| Reduced-motion / animation | accessibility § Motion |
| Skip-link / landmarks / focus | accessibility, html §3 |
| Tables | accessibility § Tables |
Don't memorize the map — re-search per task. Plugin catalog updates more often than this document does.
23. Presenting Mockups for Review
When you build a mockup or prototype of a change for review, show enough of the surrounding page that the reviewer can judge how the change fits — not just the changed element in isolation.
- Include the context, not only the delta. Render the change inside its containing page archetype (§5) — neighboring sections, the header/nav chrome, the elements above and below it — so proportion, spacing, and placement among existing content are visible. A cropped fragment can only be evaluated for its own internal look, not for fit.
- Err toward more context. A too-wide mockup costs a few extra elements; a too-narrow one costs a review round because the reviewer can't tell whether it belongs. When unsure how much page to include, include more.
- Label what's changing vs. unchanged. Make it obvious which parts are the actual proposal and which are existing context shown for placement (a caption, a highlight, or a short "everything outside the highlighted area is current UI, shown for context" note). This keeps the surrounding context from reading as new design decisions up for debate.
This is a presentation rule for design exploration, not a constraint on the product UI itself.