pinpoint-ui

shadcn/ui patterns, progressive enhancement, Server Components, Client Components, form handling, Tailwind CSS v4, accessibility. Use when building UI, forms, components, or when user mentions UI/styl

SKILL.md

GitHub

PinPoint UI Guide

When to Use This Skill

Use this skill when:

  • Building or modifying UI components
  • Creating forms
  • Working with shadcn/ui components
  • Styling with Tailwind CSS v4
  • Implementing Server Action forms, or resetting a CREATE form after a successful submit
  • Any form that contains a Radix/shadcn Select (read Server Action Forms first — this is the codebase's biggest footgun)
  • Deciding between Server and Client Components
  • Authoring a new config-driven domain enum, or a badge component that renders several enum types
  • Writing a Server Action or a colocated data-access module
  • Writing a transactional service function that also notifies — the plan-in-transaction / dispatch-after-commit pattern (CORE-ARCH-011), or when you hit SideEffectInTransactionError
  • User mentions: "UI", "component", "form", "styling", "Tailwind", "shadcn", "button", "input", "select", "dropdown", "badge", "enum", "server action", "form reset", "db.transaction", "planNotification", "dispatchNotification", "SideEffectInTransactionError", "side effect in transaction"

Quick Reference

Critical UI Rules

  1. Server Components first: Default to Server Components, use "use client" only for interactivity
  2. Honest failure (CORE-ARCH-012): a control that cannot perform its action must not report that it did — never a success confirmation for input that could not have been collected. There is no no-JS requirement.
  3. shadcn/ui only: No MUI components
  4. Direct Server Action references (CORE-ARCH-005): No inline wrappers in forms
  5. Dropdown Server Actions (CORE-ARCH-006): Use onSelect, not forms
  6. Tailwind CSS v4 + semantic tokens: Use bg-primary, text-destructive-text, etc. — no raw palette classes (bg-cyan-500, text-red-500) and no hardcoded hex (enforced via Oxlint better-tailwindcss/no-restricted-classes)
  7. TooltipProvider is hoisted: <TooltipProvider> is mounted once in ClientProviders — don't add nested providers. See pinpoint-design-bible §12.
  8. Baseline Widely available is the floor (CORE-UI-005): use <dialog>, container queries, :has(), :user-invalid, inert, aspect-ratio, native form validation directly — no polyfills. Newly-available features (Popover API, View Transitions, anchor positioning) require a per-feature opt-in in pinpoint-design-bible §19; fetchpriority and text-wrap: balance are the two that have one. Never trust a cached Baseline date — look it up live (references/browser-support.md).
  9. Form correctness (CORE-FORM-001..006): right type, correct autocomplete token, :user-invalid styling, aria-invalid blur sync, visible required-field indicator, enterkeyhint on sequential mobile fields. Conventions are owned by pinpoint-design-bible §20; the code is in references/form-correctness.md.
  10. Accessibility floor (CORE-A11Y-001..006): skip link, motion-reduce: paired with animations, semantic <table> markup, real <button> (no <div role="button">), title is not a tooltip, inert background on modals. See Accessibility in references/accessibility.md.
  11. Early UI review gate (pre-E2E) (PP-4c4b): Present rendered screenshots at the three review viewports (desktop, large mobile 430px, small mobile 320px — pinpoint-design-bible §4 Review Viewports) to Tim for visual approval before authoring or updating Playwright/E2E specs or running preflight. Fast unit tests (pnpm run test) run early; heavy E2E suites wait until layout and visual hierarchy are approved.

Reference Files

Everything below lives one hop away in references/. Load the file you need.

File Covers
references/browser-support.md How to look up a feature's Baseline status live; pointers to design-bible §19 / §22
references/key-files.md Component basics, Issue Field Display Order, Key Files Registry, Label Standards
references/filter-conventions.md Filter-bar conventions: status groups, smart-badge grouping, quick-selects, mobile
references/enums-and-props.md Config-Driven Enums, discriminated-union props for multi-type components
references/form-correctness.md Form-correctness code (types, autocomplete, :user-invalid), native HTML primitives
references/styling-and-shadcn.md Tailwind CSS v4 styling, shadcn/ui component patterns, Button variants/sizes/loading
references/accessibility.md Accessibility floor (CORE-A11Y-001..006), Animation & Motion

Color System

  • Use semantic tokens (bg-primary, text-destructive-text, text-muted-foreground, border-success/40). Raw Tailwind palette classes and hardcoded hex are forbidden in component code, enforced by Oxlint (better-tailwindcss/no-restricted-classes). The token values live in the Tailwind v4 @theme block in src/app/globals.css; the rule and its two design-layer exceptions are pinpoint-design-bible §1.
  • Status / severity / priority / frequency colors come from the configs in src/lib/issues/status.ts — never freestyle a status color at a call site.
  • PinPoint is dark-only. dark: utility classes are dead code; remove them when you touch a file that still has them.
  • The secondary is teal, and purple is not in the palette — a purple/fuchsia secondary was removed deliberately (PR #1204) so primary and secondary read as one green-family pairing rather than two competing brands. Do not reintroduce it.
  • For the full visual identity (surface hierarchy, glow rules, accessibility constraints) see pinpoint-design-bible §1–§2.

Core UI Patterns

Server vs Client Components

Server Components are the default (CORE-ARCH-001). "use client" marks an interaction leaf — the smallest subtree that genuinely needs state, effects, or event handlers. Push it down: a page that renders a list of cards stays a Server Component even when one control inside a card is interactive.

The consequence that bites most often is that "use client" is viral downward — everything a client file imports is bundled for the client too. So don't co-locate a data query, a Node-only import, or ~/lib/logger (which pulls in fs) in a file that is or will become a client component.

Canonical page composition and the layout wrappers are in references/key-files.md § Layout.

Server Action Forms

The Radix Select carve-out: dispatch directly, never action={...}

This is the single biggest footgun in this codebase. It caused a P0 (PP-0fvr) and four more live data-loss bugs (PP-1ajq).

@radix-ui/react-select >= 2.3.3 attaches a form-reset listener that replays onValueChange with the Select's mount-time value. React 19 auto-resets a <form action={...}> once the action settles — on failure as well as success, since React only sees that the action returned. Put those together and every Radix Select in the form snaps back about a second after submit.

The remedy is to remove the native submission path: build FormData by hand and call the useActionState dispatch directly inside startTransition. No form submission ever completes, so React never fires the reset.

const dispatchForm = (): void => {
  if (!formRef.current) return;
  const fd = new FormData(formRef.current);
  startTransition(() => {
    formAction(fd);
  });
};

startTransition is not optional — outside a transition React 19 silently skips the Server Action. The reference implementation is the <form onSubmit={...}> in src/app/(app)/report/unified-report-form.tsx, which also guards e.target !== e.currentTarget so a submit bubbling up from a portalled descendant form doesn't get cancelled here (React propagates events through the React tree, not the DOM tree).

How it surfaced in each shape:

  • Auto-submitting from onValueChange (the four inline issue-metadata forms, PP-0fvr): the replay looked like a real user selection and fired a second write carrying the stale value — silently reverting every status/priority/severity/frequency change.
  • Submitting from a real button (edit-machine, create-machine, unified-report, delete-account — PP-1ajq): no bad write, but a failed save silently wiped unsaved edits while the form was still on screen. Worst case was delete-account, where a reverted machine-reassignment left the confirmation text intact and the destructive button still enabled — a destructive control armed against a target the user could no longer see.
  • A form with no Select at all (machine owner transfer, src/app/(app)/m/[initials]/(tabs)/edit/machine-owner-transfer.tsx — PP-o355.19): this section is named for the Radix Select because that is the common case, but Radix is only one symptom riding on React's reset. Here a native reset blanked the form's controlled hidden id/name inputs in the DOM without re-rendering React — the state behind them never changed, so React had no reason to re-sync — leaving a second attempt after a failed transfer to submit an empty machine id. If your form carries controlled hidden inputs, it has this bug whether or not a Select is anywhere near it.

Three things worth knowing before you try to fix this some other way:

  • Making the Select controlled does not help. Radix's replay calls onValueChange, so the stale value is written straight into your state — the controlled path is the very path the replay travels.
  • Mocking useActionState is why this stayed invisible to a green suite for five days. The regression guards (src/test/unit/components/{issues,machines,settings,report}/*revert*.test.tsx, plus the co-located src/app/(app)/m/[initials]/(tabs)/edit/machine-details-form.test.tsx for the machine Manage tab) deliberately mock neither react nor the Select wrappers. Keep it that way: a test that mocks useActionState or the Select wrapper cannot observe the reset, and will pass while the bug is live.
  • "No Select" is not a clean bill of health. See the owner-transfer case above — reach for this remedy whenever a form's values live anywhere React won't re-render on reset, not just when Radix is present.

This is the form-level surface of CORE-ARCH-012 (honest failure).

Silent stale-value submission: native <select> + a stale option ID (PP-lql)

Same failure family — a value the user never chose gets submitted without any visible error.

A controlled native <select value="X"> whose value does not match any <option> does not error or render visibly broken — browsers display the first non-disabled <option> and FormData.get(name) returns its value. This is HTML-spec behavior, not a React bug.

This combines disastrously with localStorage-backed drafts: if the saved ID references a record that has since been deleted (or that belongs to a different tenant after a session change), restoration silently puts the form into a state where the dropdown shows — and submits — the alphabetically-first record. The Zod uuid() schema and any "does this id exist?" server checks both pass, because the submitted UUID is a valid record. The user never sees an error, and an entity is created against the wrong target.

Mitigation (apply when restoring an option-ID from localStorage or any other side channel): validate the restored ID against the live options list before writing it into state, and add a defense-in-depth effect that clears the selection whenever the current value is absent from the list. Regression coverage lives at src/app/(app)/report/select-fallback.test.tsx (JSDOM unit test pinning the HTML behavior) and e2e/full/report-stale-machine.spec.ts (seeds a stale draft, asserts an empty placeholder and a refusal to submit).

Better long term: replace native <select> with the project's shadcn Select for any field whose options come from a list that can change between sessions — the command-palette pattern surfaces "no match" instead of silently picking the first option.

CREATE form reset

Applies to CREATE forms only. Edit forms typically navigate or close on success, so they don't need this. A CREATE form is "fully reset" when, on successful submit:

  1. The Server Action returns ok({ ..., redirectTo }) — it does not call redirect() server-side.
  2. A useEffect watching state?.ok (or state.success) runs formRef.current?.reset(), then explicit setState("") / setState(null) for every controlled field, plus a key bump on uncontrolled child editors (e.g. RichTextEditor) to remount them.
  3. Then window.location.assign(state.value.redirectTo).

The return-redirect requirement is the load-bearing piece. When a Server Action calls redirect() server-side, Next.js throws a redirect error that propagates before useActionState returns success to the client, so the cleanup useEffect never fires. Returning redirectTo shifts the redirect to the client and lets cleanup run first.

Ordering inside the effect matters (PP-1ajq): formRef.current.reset() must run before the explicit setState clears. reset() synchronously fires the form-reset event, and Radix answers it by replaying each Select's mount-time value through onValueChange. Everything in the effect is one React batch, so whichever update is queued last wins — reset first and the stale replay is superseded by your clears; clear first and the replay overwrites them. See the commented resetSingleForm in src/app/(app)/report/unified-report-form.tsx for the worked case.

Why both passes are needed:

Field type Cleared by formRef.reset()?
Native <input>, <textarea>, <select> (uncontrolled) ✅
Native fields rendered with value={...} (controlled) ❌ — React re-renders the value back
shadcn/ui Select ❌ — internal state is React, hidden input is React-driven
RichTextEditor (TipTap) content prop ❌ — internal editor state must be remounted via key
Image-upload arrays (useState<ImageMetadata[]>) ❌ — pure React state

Native reset clears the form's DOM state; explicit setState clears React's view of every controlled field.

Three valid reset strategies, pick per form:

  • Dual-pass in an effect — the default above. src/components/issues/AddCommentForm.tsx is the cleanest example: Result type, stays mounted on success, formRef.reset() + clears controlled state.
  • Remount on reopen — for dialogs, put the form (and its useActionState) in a child that mounts with the dialog content, so every open starts clean. src/components/users/InviteUserDialog.tsx does exactly this; its comment says so.
  • Navigate away — the redirect unmounts the form, so nothing needs clearing.

Optional Clear button: for long CREATE forms (≥3 fields), add one next to Submit, always gated behind an <AlertDialog> — the user just typed several fields. Reuse the same reset helper the success effect uses. Skip it for single-field forms and for dialogs that reset on close.

Edge cases: preserve URL-derived defaults across reset (?machine=MM → reset to defaultMachineId ?? "", not ""); clear localStorage drafts in the same effect and make the save-draft effect short-circuit on success so it doesn't race the reset; for a child that owns its own selection state (like OwnerSelect), bump a key to remount it.

E2E coverage: every CREATE form needs at least one assertion that, after a successful submit, every field reads back empty/placeholder. For forms that navigate away, submit → wait for redirect → navigate back → assert empty. See e2e/full/form-resets.spec.ts.

Unsaved changes navigation guard (PP-kny4)

Applies to deferred-save forms that buffer edits in client state until an explicit Save.

Draft or guard — pick one per surface:

  • A composer for something new (a comment, a timeline note, a report) keeps a draft in localStorage and gets no guard: leaving loses nothing, so a "changes will be lost" dialog would be false. Comment drafts (~/components/issues/comment-draft.ts) are the reference: keyed per person and per target, cleared after a successful post and on sign-out, synced across tabs.
  • An edit of something already saved, held until Save (a comment edit, an issue title on mobile, the machine and settings forms), gets the guard and no draft.
  • An inline field that already cancels when it loses focus (the desktop issue title) needs neither: leaving the field discards the edit by design.

Use the shared guard from ~/hooks/use-unsaved-changes-guard:

import { UnsavedChangesGuard } from "~/hooks/use-unsaved-changes-guard";

export function MyForm() {
  const [isDirty, setIsDirty] = useState(false);

  return (
    <form>
      <UnsavedChangesGuard isDirty={isDirty} />
      {/* ... fields ... */}
    </form>
  );
}

Or call useUnsavedChangesGuard({ isDirty, onDiscard, description, ... }) when you need programmatic dialog control (e.g. triggering the same prompt from an in-form Cancel button via openConfirm(null)), custom dialog copy, or cleanup before navigation.

Key rules for navigation guards:

  1. Never call event.stopPropagation() in click guards. The guard uses a capturing listener on document to call event.preventDefault() (which halts Next.js <Link> routing), but MUST allow the click event to propagate to React's root. Calling stopPropagation() prevents React synthetic event handlers from firing, breaking drawer dismissals (e.g. mobile BottomTabBar "More" drawer), Radix dropdown closures, and active popovers.
  2. Focus restoration on stay: When the user cancels ("Stay on page"), the dialog's onCloseAutoFocus restores keyboard focus to the clicked link.
  3. App Router boundary contracts:
    • popstate (browser Back): in-app Back cannot be blocked synchronously without sentinel history traps that break browser navigation. Forms with auto-save semantics can pass onPopState: flushUnsaved for fire-and-forget saving on Back; discard-only forms accept silent discard.
    • router.push: programmatic navigation does not emit DOM click events. Callers performing programmatic navigation must inspect their own dirty state first.

Server & Data Conventions

Server Actions

Build every exported Server Action with createProtectedAction from ~/lib/actions (CORE-ARCH-013). createPublicAction is the same pipeline for an action signed-out visitors may call, such as public issue reporting: context.user is null for them and their access level is "unauthenticated". The pipeline authenticates, validates, loads, checks the permission, runs the handler, rethrows redirect(), and reports anything thrown as SERVER through serverActionError(), in that order; the handler holds only the action's own work.

Worked example: src/app/(app)/settings/reporting/actions.ts, which maps FormData into a schema, checks a static permission, and keeps one extra rule in the handler. src/lib/actions/pipeline.test.ts exercises every option.

The options, in pipeline order:

  • schema validates the input; a failure returns VALIDATION with the first zod issue's message. Without mapInput the action takes the schema's input type.
  • mapInput turns the raw argument into the value the schema parses. For a form, formFields(formData, ["fieldA", "fieldB"]) reads the named string fields, with a missing field or a file as null, so the schema owns every message.
  • load fetches the resource the permission check needs and returns err("NOT_FOUND", …) when it is missing. The permission callback and the handler receive it as context.resource, so the action queries it once.
  • permission is a permission id, or a callback returning { permission, ownershipContext } built from context.resource for ownership rules. It runs through checkPermission() (CORE-ARCH-008); a role comparison anywhere else is a bug, and pinpoint-security covers the non-gating exceptions. A denial returns FORBIDDEN with forbiddenMessage.
  • serverErrorMessage replaces the generic SERVER message; actionName names the operation in the Sentry report.

The result is Result<T, C> from ~/lib/result (ok(...) / err(...)), never a thrown exception, so useActionState can render the failure. Its codes are UNAUTHORIZED (signed out), VALIDATION, FORBIDDEN, and SERVER, plus whatever the handler and load return; type it as ProtectedActionResult<T, ExtraCodes>.

A "use server" file exports only async functions, so keep the pipeline in a module-level const and export a named async function that calls it, with the (prevState, formData) signature when useActionState drives it. Zod schemas an action shares with client code live in a schemas.ts beside it for the same reason.

Two helpers sit beside the pipeline in ~/lib/actions: revalidateMachine(initials, tabs) revalidates /m/<initials> and each named tab beneath it, and rethrowIfRedirect(error) is the redirect passthrough for a catch outside the pipeline.

When several hosts need the same set of actions, a pipeline factory can build them once: createSavedViewActionHandlers in src/lib/list-view/saved-view-actions.ts builds the Saved View handlers that src/app/(app)/issues/saved-view-actions.ts and src/app/(app)/m/saved-view-actions.ts export. A factory must build every handler it returns with createProtectedAction or createPublicAction, and each "use server" export is one statement, return handlers.create(input), on a module-level const holding the factory's result. List a new factory in PIPELINE_FACTORIES in the ratchet test; the test checks every handler the factory returns, so a factory with one hand-rolled handler fails.

The exceptions (signed-out auth flows, redirect-only OAuth and consent actions) and the backlog of hand-rolled actions are the allowlists in src/test/lint/protected-action-ratchet.test.ts. Migrating an action means deleting its line there; the test fails until you do.

Exported actions are suffixed Action — createMachineAction, markAsReadAction. Older actions predate the convention: name new ones with the suffix, leave existing names alone (a rename risks missing a call site), and treat an unsuffixed export as a possible Server Action, since several are wired straight into useActionState.

Transactional service functions (side effects after commit — CORE-ARCH-011)

External side effects — email, Discord, blob storage, the Discord-config Vault RPC, notification dispatch, raw HTTP — must never run inside a db.transaction(...) callback. A send that "succeeds" just before a rolled-back transaction ships an effect for data that was never persisted. That is not theoretical: in the Doodle Bug (PP-2053) a member reported an issue and got a confirmation email with a working link, but the issue was never persisted and no alert fired — the Resend call ran inside the issue-creation transaction, the request was killed mid-flight, and the rollback took the row while the email had already gone out. A transaction can roll back at any point, so anything irreversible done before COMMIT can outlive a write that never lands. Fetch any inputs the effect needs before the transaction opens, and deliver the effect after it commits.

The worked shape lives in createIssue (src/services/issues.ts) and repeats across updateIssueStatus, addIssueComment, assignIssue, and machines.ts — read one rather than a prose copy:

  • Inside db.transaction(async (tx) => …): do the DB writes, then call planNotification with the transaction and the notification channels resolved up front — pass tx so the in-app rows and watcher backfill are written through it, and pass the channels rather than letting planNotification resolve them itself, which would reach the Vault (the one way planning does external I/O). Planning is otherwise transactional and does no I/O. It returns a DeliveryPlan; gather its deliveries and hand the plan back from the service (return { …, deliveryPlan }). Wrap the planning in try/catch with reportError(err, { …, bestEffort: true }) so a planning hiccup cannot roll back the row the transaction just committed.
  • After commit: the calling Server Action runs after(() => dispatchNotification(deliveryPlan)) (after from next/server). dispatchNotification is the only half that talks to email/Discord, and running it in after() keeps a slow or failed external send off the request path, where it can neither roll back nor delay the write.

Do not pass tx to a notification-creating call. The retired anti-pattern was createNotification({…}, tx) inside the transaction's try — the Doodle Bug verbatim; that tx parameter no longer exists. Plan in, dispatch out.

Three guardrails enforce this, so a violation fails loudly instead of silently shipping:

  • Runtime tripwire — src/server/db/transaction-context.ts runs every db.transaction callback inside an AsyncLocalStorage flag; side-effect client wrappers call assertNotInTransaction(name) and throw SideEffectInTransactionError when reached mid-transaction. Hitting that error means you called an external client (sendEmail, the Discord-config Vault RPC, …) inside a transaction — hoist the call out and dispatch after commit.
  • Static rule — pinpoint/no-side-effects-in-transaction (eslint-rules/no-side-effects-in-transaction.mjs), "error" on every *.ts(x), anchored on the db.transaction(...) callback argument.
  • Tests — src/test/integration/transaction-tripwire.test.ts (the runtime tripwire) and src/test/lint/oxlint-fixtures.test.ts (the static rule fixture tests).

Data access

Data access lives in colocated _data.ts / queries.ts files next to the route that uses it, wrapped in cache() from React so a layout and its page don't double-hit the DB in one render pass. There is no src/server/data-access/ directory — don't create one.

The current viewer is the one shared exception: pages, layouts and server components read it from getViewer() (~/lib/auth/viewer), a request-scoped cache() that runs auth.getUser() and the profile read (id, role, name, report modes) once per render. It returns nulls for a signed-out viewer or a missing profile row; the caller keeps its own redirect, 404 or Forbidden. Server Actions keep their own auth check (createProtectedAction).

Revalidate with revalidatePath — that's the convention throughout. revalidateTag has zero usages in src/; if you think you need it, you're introducing a second caching convention.

Stored rich text

A prose column a person writes in the rich text editor (machine description, owner's requirements, Collection description) is validated with validateProseMirrorDoc (~/lib/tiptap/validate) before it is saved. It checks the doc's shape, applies the 10k plain-text / 100k JSON size caps, and reports a whitespace-only doc as "empty" so the column stores NULL. Map its result to your action's own error copy; don't write another size check.

Machine status is derived, never stored

There is no status column on the machines table — a machine's operational status is computed at read time from its open issues. src/lib/machines/status.ts is the source of truth for the algorithm and the label/style helpers. Read it rather than reimplementing or restating the rules; an earlier prose copy of this algorithm drifted until it was behaviorally wrong. Query only the columns the derivation needs (status, severity) when loading issues for it.

Logging

src/lib/logger.ts is canonical and short — read it rather than a prose copy. It is the pino config plus the log.info / log.warn / log.error wrappers. Two conventions are worth knowing before you write a call site:

  • Never log PII — log user IDs instead. The redact list is a backstop for a fixed set of key names, not a filter. EMAIL_LOG_KEYS (email, reporterEmail, userEmail, contactEmail, submittedEmail) is crossed with wildcard depths 0–3, so those five names are caught from the top level of the payload down to four levels in, array indices included. Anything else — a differently-named key, or one of these nested deeper still — is logged raw, to logs/<session>/app.log locally and to stdout on Vercel. Pino has no recursive wildcard, so the depth is a bounded choice rather than a guarantee; mask at the call site with maskEmail (~/lib/logging/mask) and treat the list as the safety net. (PP-tg9y widened it from the original two keys at one level.)
  • An error goes under the err key. pino.stdSerializers.err is wired to that key alone, and the comment records that every call site was standardised on it so the single mapping suffices.

One UI-specific fact that lives here: ~/lib/logger imports mkdirSync from "fs", so it cannot be imported into a Client Component. console.* is therefore correct and expected inside "use client" files. Don't "fix" a console call in a client component by swapping in ~/lib/logger; that breaks the build.

UI Checklist

Before committing UI code:

  • Server Components by default (only "use client" when needed)
  • No control reports success for an action it could not perform (CORE-ARCH-012)
  • Direct Server Action references (no inline wrappers)
  • Dropdowns use onSelect for Server Actions
  • CSS variables, no hardcoded colors
  • cn() used for className merging
  • Semantic HTML (nav, main, article, etc.)
  • ARIA labels for icon-only buttons
  • Responsive design (mobile-first)
  • shadcn/ui components only (no MUI)
  • Input fields carry the correct type and autocomplete token (CORE-FORM-001, 002)
  • Multi-field forms set enterkeyhint per field (CORE-FORM-006)
  • Required fields have a visible * indicator (CORE-FORM-005)
  • Every animate-* or non-essential transition-* pairs with motion-reduce: (CORE-A11Y-002)
  • No <div role="button"> introduced (CORE-A11Y-004)
  • No title="..." as a tooltip (CORE-A11Y-005)
  • If you added a sortable table: <th scope="col">, aria-sort, accessible name (CORE-A11Y-003)
  • If you added an image with priority: it is the LCP candidate for the page's dominant viewport, and sizes is set (CORE-PERF-003)
  • If you added an animation, dialog, or layout pattern: searched modern-web-guidance for the Widely-available primitive first (CORE-UI-006)