Skeletons, spinners, pending feedback, empty states, and in-page errors. Kit sources: packages/ui/src/spinner, page-states, empty-state, page-info. Evidence registers under docs/evidence/20260807-0230-speedway-design-language/ in this repo.
Audience: an agent (or person) building a loading, empty, or error state.
Travels with: 06 (the table whose rows are loading), 05 (the card that keeps its box), 09 (the archetype that sets the frame), 10 (states inside an overlay match the page's). Request-shaped bundles: AP-10, AP-11.
§A Principles and mental models#
1. The house rule: skeletons for content, spinners for processes.
Stated verbatim in three places: "content placeholders are skeletons, not spinners with a word." A spinner is legitimate only for a transient micro-process paired with a short verb phrase ("Matching loadsheet", "Checking columns", "Importing files"). It is also legitimate for a point load inside a control (a button's own loading). Content that has a shape gets a skeleton shaped like that content.
2. Skeletons preserve the box.
Every kit component's skeleton prop occupies exactly the space the real content will. Card keeps its border/shadow/padding ("collapsing makes the page jump, which is the whole thing a skeleton exists to prevent") and deliberately does not animate in ("a placeholder should be there, not arrive"). PageTitle's skeleton is slot-aware, drawing bars only for the slots the page actually uses.
3. Never blank what you already know. Static, known-ahead-of-time text renders immediately: "'Accounts' is static, say it; skeleton mode blanked it for no reason" (loading-states audit 2026-07-24). Suspense fallbacks render the REAL header. The title is derived from the URL when possible ("the title is in the URL before any data is: name it instead of an anonymous bar"). The skeleton is for genuinely unknown data only.
4. Skeleton granularity matches load granularity. Page-level (WorkspaceSkeleton, parameterized by layout/rows/cards/headroom) → section-level (SkeletonGroup presets: lines/media/card/grid) → cell-level (width-matched skeleton divs inside a live preview table). Optional sections are never placeholdered: "a placeholder that appears and then vanishes is a worse jump than one that was never drawn." A promised number that may never arrive gets NO skeleton (nav badges render nothing until real).
5. Stagger, bounded.
skeletonStaggerStyle offsets entrance delays via a fractional modulus so long lists neither pulse in lockstep nor wait seconds for their tail. Reduced-motion media queries disable skeleton and toast animation.
6. Pending navigation is layered and delayed.
Sweep bar at the top (90ms delay, trickle to 88%, snap on completion; speedway root.tsx:207-241) plus a spinner on the clicked nav link (120ms delay, delay-gated with aria-label="Loading page", WorkspaceNav.tsx:113-130). Content is never dimmed. The arriving route's skeleton says what is coming. Delays exist so instant transitions never flash feedback.
7. Empty states are content, and they branch.
The PageInfo family: EmptyState (nothing-here-yet), PageLoading (spinner-based page state, the one sanctioned full-page spinner), PageError (error tone + icon circle, "errors deserve weight"). Real pages branch three ways: true-empty (explain + a way forward), filtered-empty ("No parts match "query"" + Clear search), and in-progress-empty (IconCycle + "Ingestion running"). The richest zero state offers navigable job cards, "somewhere to go," not just a CTA. Not-found-within-a-page uses EmptyState + a back action rather than a route 404.
8. Empty states can act. A gated-workflow queue that is empty because the job waits on Proceed auto-navigates the user to the Proceed affordance instead of showing emptiness (guarded to fire once).
9. An empty state's tone carries meaning, not decoration.
§A7 says empty states branch on cause. This says the branch is visible in the tone. tone="muted" when the absence is neutral (no images on this part yet). tone="success" when the absence IS the good outcome. The reason is recorded at the call site: "No errors is good news here, so it reads as an achievement, not an absence." A surface reporting "no errors" in the same grey as "no images" tells the user two different things in one voice. The rule is checkable: name what the absence MEANS before picking the tone, and if the answer is "the user won", it is not muted. Spreading on its own is the evidence it is real: explicit tone usages inside walmart's preview modal went from two to four.
10. The boot skeleton is the page's own layout, routed.
An auth-boot frame that does not yet know which page it is landing on still must not show a generic placeholder. The pattern: a per-route skeleton module (routeSkeletons.tsx) mapping pathname to a skeleton shaped like the destination. Share the page's own skeleton where you can (walmart's HomeSkeleton is exported from the page and consumed by both), or its column definitions at minimum. Where the boot frame re-authors headers by hand instead, that pair CAN drift, and the cost is real. Two of walmart's routes carry hand-written header strings while the pages render from their live columns. Know which kind each route is. It composes with §A3. Inside the skeleton, everything static is real (tile labels, card titles, an action button whose label and destination are known at build time), and only the values bone.
11. A failure looks like a failure, and says what failed.
Added 2026-08-13 from the correction corpus, where four entries across both apps collapse into one rule. Three failure modes, in rising order of harm. A caught fetch error that renders the empty state is the worst, because "nothing here yet" and "we could not load this" are opposite facts and the user cannot tell them apart, so an outage reads as an empty account. An error surface that shows the transport's raw string is next: Internal Server Error and a stack fragment are not messages, they are the absence of one. Third, and the subtlest, is a handler that flattens a caught exception into its own generic wording and discards what the exception actually said, which destroys the only evidence anyone had. So: distinguish empty from failed at the render, always. Prefer the server's own description of the failure when it has one, and when it does not, give the reader something to quote, an id that matches a log line. The worked case is walmart's expired cloud credential, which surfaced for a day as a polished generic sentence advising the user to retry, while retrying could never work: three layers each replaced a specific fact with a vaguer one, and each replacement looked like good defensive practice on its own. Commit 9f6fb94 is the repair. Diagnostic note for reviewers: a try/except that AUTHORS a user-facing string is a different animal from one that suppresses a non-fatal side effect, and only the first kind destroys evidence. Speedway carries a second, fuller worked case (sweep 2026-08-14): its root ErrorBoundary at root.tsx:544-590 isolates the one credential failure with no structured field behind a documented single string-match exception (isGcpReauthError), explains it in plain language with dev-only remediation commands, and its generic branch prefers error.message/error.data over canned wording, falling back to "Something went wrong" only when neither exists.
§B Presets#
| Situation | Preset |
|---|---|
| Route loading | Suspense + WorkspaceSkeleton with the real header |
| Card loading | Card skeleton (box-preserving) |
| Peek/panel loading | skeletons shaped like the destination rows |
| Table cell in-flight | width-matched skeleton divs |
| Micro process | Spinner xs/sm + verb phrase |
| Full-page transient | PageLoading; errors get PageError |
| List empty | EmptyState: Icon + title + why + action; compact in dense contexts |
| Active-process empty | IconCycle icon + present-tense title |
| Search empty | compact, quotes the query, Clear action |
| Empty because the user won | PageInfo tone="success", not muted |
| Empty and unremarkable | PageInfo tone="muted" |
| Auth boot, route unknown | per-route skeleton module shared with the page |
§C Use cases through Speedway#
- ProductItem/PeekPanel/PreviewModal skeletons shaped as label+value pairs. DebugSkeleton draws only the two always-present cards. CodeBlock has its own skeleton mode.
- Jobs/parts/run pages all use WorkspaceSkeleton with real headers. Part-debug shows the SKU "on screen before the part arrives."
- Empty-state copy is instructional: "Create a job from your files. It builds its own catalog and the pipeline takes it from there." Runs/parts empties branch on ingesting vs settled and on orchestrator on/off.
- Admin divergence flagged for repair: admin/account lists use bare
<p>empty text while customer surfaces use kit EmptyState. Contentgen setup mixes spinner-with-word and cell skeletons on one page. Neither is marked deliberate. - Missing backend index renders as an EmptyState with setup instructions, treated as a content state, not a page error.