Repository navigation
Reusable skeleton/empty/error system + mobile/responsive pass across hosted views #24
Description
Context
Hosted views use plain-text loaders ("Loading namespaces…" in NamespacesSimplePage, apps/web/src/main.tsx:5938; "Loading directory…" in ShowcasePage, main.tsx:2110) and ad-hoc red error text, with no shared skeleton/empty/error components. Styling is a single ~184KB apps/web/src/styles.css (--cm-* tokens, no Tailwind/shadcn), and mobile/responsive has not been audited. FailureExplainer already exists (used at main.tsx:2115) but isn't a reusable card.
Goal / user story
As a user on any device, I see polished skeletons while loading, helpful empty states with a next-step CTA, and recoverable error cards with Retry — on a layout that works down to phone width — instead of raw text that shifts or dead-ends.
Acceptance criteria
- A reusable
<Skeleton>(tokenized shimmer using--cm-*) is applied to hosted extraction, facts/graph, chat "thinking", the namespaces list, and the share page. - One
<EmptyState>component (icon + title + CTA) replaces inline strings like "No namespaces yet" (main.tsx:5938). - A structured
<ErrorCard>with a Retry action replaces raw red error text (e.g.NamespacesAppPageerroratmain.tsx:5967,ShowcasePageatmain.tsx:2112);FailureExplaineris folded into it. - Retry re-invokes the relevant fetch (e.g.
NamespacesAppPage.refresh(),main.tsx:5969) without a full reload. - Responsive pass: nav (
appNavItems,main.tsx:1614), the Build console, and the constellation are usable at 360–768px with no horizontal scroll. - No new off-palette hex is introduced (extend
--cm-*;namespace-memory.cssalready drifts — don't add to it).
Implementation notes
- Add components under
apps/web/src/components/(e.g.Skeleton.tsx,EmptyState.tsx,ErrorCard.tsx) plus tokens instyles.css; replace the scatteredsubEmptyplain-text usages. - Wire Retry handlers to existing refresh functions; keep the light-app / dark-graph brand split intact.
- Mobile: make
AppShellnav collapsible; pair with the constellation mobile work. - Parts of this (EmptyState/ErrorCard extraction) are good-first-issue sized.
Sui Overflow angle
The Harbor bucket handshake (RESERVE→SIGN→FINALIZE plus the ~3s mirror_missing_grant window) and SEAL decrypt are inherently async with retry-able failures. This exact skeleton + structured-error system is what lets the demo show "creating on-chain bucket…" and graceful retry states instead of raw errors during the Sui-native storage flow.
Dependencies
Standalone and shippable. Enhances the routed namespace console issue and the onboarding-flow issue.
Part of the ContextMEM roadmap (#4) • Sui Overflow build.
Activity
harrymove-ctrl commentedon Jun 19, 2026
Implementation Plan
Approach
Build three tiny, dependency-free presentational components —
Skeleton,EmptyState,ErrorCard— underapps/web/src/components/ui/, backed by a handful of new tokenized CSS classes + error/danger--cm-*tokens instyles.css.ErrorCardabsorbs the existingfailureHintFor()/FailureExplainerlogic (main.tsx:8002 / :8029) and adds aonRetryprop wired to each page's existing fetch (e.g.NamespacesAppPage.refresh()at main.tsx:5969) so recovery happens in-place, no reload. The responsive pass converts the already-half-responsiveAppShellnav (currently a horizontal-scroll strip at the 920px breakpoint, styles.css:7881) into a real collapsible drawer + bottom-bar, and kills horizontal overflow on the Build console and the constellation down to 360px. Key decision: keep all pure logic (error classification, skeleton variant→class) in sibling.tsfiles so they're unit-testable under the existing node-env vitest (no RTL/jsdom is installed).Prerequisites & dependencies
lucide-react(already a dep) supplies icons forEmptyState/ErrorCard. Do not add@testing-library/react/jsdom— vitest runsenvironment: "node"andinclude: ["apps/**/*.test.ts"](vitest.config.ts), so component render tests would force new infra; keep tests on pure.tshelpers.NamespacesAppPage" P0 task (the route swap at main.tsx:1575) and the constellation reduced-motion task. This issue touchesNamespacesAppPage's error/empty rendering even though it is still unrouted today.#a8d946; the light-app / dark-graph split must stay intact; do not add hex tocomponents/namespace-memory/namespace-memory.css(it already drifts).Step-by-step
Add tokens + shimmer + error/empty CSS to
styles.css. In the brand:rootblock (styles.css:2337–2351, the one with--cm-accent), add danger/empty tokens so nothing introduces off-palette hex per AC Build HarborClient: reserve→sign→finalize bucket, multipart upload, status poll, download #6:--cm-danger: #b42318; --cm-danger-strong: #912018; --cm-danger-bg: #fef3f2; --cm-danger-border: #fda29b; --cm-skeleton-base: rgba(17,20,22,0.06); --cm-skeleton-sheen: rgba(17,20,22,0.12);Then append (after the existing keyframes near styles.css:7815):
@keyframes cm-shimmer { 0%{background-position:-468px 0} 100%{background-position:468px 0} }and classes.cmSkeleton(gradient using the two skeleton tokens +animation: cm-shimmer 1200ms linear infinite,border-radius: var(--cm-radius, 8px)),.cmSkeleton--text/--line/--card/--circlesize variants,.cmEmptyState,.cmErrorCard(replacing.failurePanelcolors at styles.css:6395 with the new--cm-danger-*tokens), and.cmErrorCard__retry. Inside@media (prefers-reduced-motion: reduce)(one already exists at styles.css:8365 / 9967) set.cmSkeleton { animation: none }.Create
apps/web/src/components/ui/Skeleton.tsx. Props{ variant?: "text"|"line"|"card"|"circle"; width?; height?; count?; className? }. Renders one or more<span className="cmSkeleton cmSkeleton--{variant}">with inline width/height. Put the variant→className map in a siblingskeleton.ts(skeletonClass(variant)) for unit testing. Addrole="status" aria-busy="true" aria-label="Loading"on the wrapper.Create
apps/web/src/components/ui/EmptyState.tsx. Props{ icon?: React.ComponentType<{size?:number}>; title: string; description?: string; action?: { label: string; onClick?: ()=>void; to?: string } }. Renders icon + title + optional description + optional CTA (button or react-routerLink). Reuse.subEmpty/.panelspacing conventions so it slots into existing<section className="panel">wrappers.Create
apps/web/src/components/ui/ErrorCard.tsx+error-card.ts. MovefailureHintFor()(main.tsx:8002–8027) intoerror-card.tsasclassifyError(message): { code?; hint?; title }(default title "Something went wrong").ErrorCardprops{ error: string|Error|null; title?; onRetry?: ()=>void|Promise<void>; retrying?: boolean }; renders the message, the classified hint/code (folding inFailureExplainer), and a Retry button (lucideRefreshCw) whenonRetryis set. Keep the oldFailureExplainer(main.tsx:8029) as a 3-line wrapper that renders<ErrorCard>for back-compat at its other call sites, or delete after replacing all usages.Apply to
ShowcasePage(main.tsx:2057, render block 2109–2118): replace<p>Loading directory…</p>with<Skeleton variant="card" count={6}>in a.showcaseGrid-shaped wrapper; replace theerrorStateblock (2112–2116, which already callsFailureExplainer) with<ErrorCard error={error} title="Directory unavailable" onRetry={loadDirectory}>; replace the "No public namespaces…" empty (2118) with<EmptyState>. Extract the existinguseEffectfetch into a namedloadDirectory()so Retry can re-invoke it.Apply to
NamespacesSimplePage(main.tsx:5893): the inline loading/empty at main.tsx:5938 becomesbusy ? <Skeleton variant="card" count={4}/> : !namespaces.length ? <EmptyState icon={Database} title="No namespaces yet" description="Build a context package to create one." action={{label:"Go to Build", to:"/app"}}/> : null. Pull theuseEffectbody intoloadNamespaces()and add anErrorCardpath (currently errors are swallowed in thecatchat :5905 — add anerrorstate).Apply to
NamespacesAppPage(main.tsx:5943): replace{error ? <div className="error">{error}</div> : null}(main.tsx:6182) with<ErrorCard error={error} onRetry={() => refresh().catch(...)} retrying={!!busy}>;refresh()already exists at main.tsx:5969 and clears error at :5970 — satisfies AC docs: add product roadmap (Harbor storage + Talus on-chain direction) #4 (no reload). Replace the sub-empties at main.tsx:6196/6397/6417 with<EmptyState>. Add<Skeleton>while the initialrefresh()(main.tsx:5988) is in flight (introduce aloadingboolean around the first load).Apply to
SharePage(main.tsx:1634): replace the "Loading shared context" panel (main.tsx:1712) with skeleton blocks shaped like the share hero/artifact list, and the:1706) witherrorStatepanel (<ErrorCard error={error} title="Share unavailable" onRetry={loadShare}>(loadSharealready exists at main.tsx:1668).Apply to hosted extraction + facts/graph + chat "thinking":
demoRunPanel(styles.css:5028). Add<Skeleton>placeholders for the results area while a job isqueued/running(the result tabs region,buildTabsmain.tsx:1623) so the layout doesn't shift when results arrive.<Skeleton variant="card">instead of bare text.aiChatTypingspinner line at main.tsx:3529 ("ContextMeM is thinking…") with 2–3 shimmering<Skeleton variant="line">chat bubbles.Responsive pass — nav (AC Spike: run @mysten/seal encrypt+decrypt inside a Cloudflare Worker #5). In
AppShell(main.tsx:2196, nav at :2230) add a hamburger toggle button +useState(open)and anaria-controls/aria-expandedpair; render the existingappNavItems(main.tsx:1614) inside a drawer/bottom-bar. Instyles.css, replace the current 920px behavior (styles.css:7881–7942, which makes.appSideNavanoverflow-x:autostrip) with: at ≤768px collapse.appSidebarto a top bar + slide-in drawer (or fixed bottom nav using the 6 icons),.appShell { grid-template-columns: 1fr }, and ensurebody/.appMain { overflow-x: hidden; min-width: 0 }so there is no horizontal page scroll at 360–768px.Responsive pass — Build console + constellation (AC Spike: run @mysten/seal encrypt+decrypt inside a Cloudflare Worker #5). Build console:
.appWorkspace(styles.css:5217) and thebuildTabsrow — make tabs wrap (flex-wrap) instead of overflowing, single-column.appWorkspacebelow 768px (partly done at styles.css:7940). Constellation: inNamespaceMemoryConstellation.tsx+ itsnamespace-memory.css, cap the canvas/container towidth:100%; max-width:100vwand make the detail panel a full-width bottom sheet under 768px — without adding new hex tonamespace-memory.css(use existing classes /--cm-*only). Verify the 3D canvas doesn't force>100vw.Unit tests (see Test plan): add
skeleton.test.tsanderror-card.test.tsnext to the helpers.New / changed files
apps/web/src/components/ui/Skeleton.tsxapps/web/src/components/ui/skeleton.ts(variant→class helper)apps/web/src/components/ui/skeleton.test.tsapps/web/src/components/ui/EmptyState.tsxapps/web/src/components/ui/ErrorCard.tsxapps/web/src/components/ui/error-card.ts(classifyError, moved fromfailureHintFor)apps/web/src/components/ui/error-card.test.tsapps/web/src/main.tsx—ShowcasePage(2057/2109–2118),SharePage(1668/1706–1712),NamespacesSimplePage(5893/5938),NamespacesAppPage(5969/6182/6196…), chataiChatTyping(3529), Build results region (~1623),AppShellnav (2196–2244), and refactorfailureHintFor/FailureExplainer(8002–8040) to delegate toErrorCard.apps/web/src/styles.css— new--cm-danger*/--cm-skeleton-*tokens (in:rootat 2337),@keyframes cm-shimmer+.cmSkeleton*/.cmEmptyState/.cmErrorCard*(near 6395/7815), responsive overrides (replace 7881–7942), reduced-motion guard (8365/9967).apps/web/src/components/namespace-memory/NamespaceMemoryConstellation.tsx+namespace-memory.css— mobile width/bottom-sheet (no new hex).Test plan
bun run test→ vitest):error-card.test.ts: assertclassifyErrorreturns the rightcode/hintfor each branch ("[demo_limit_exceeded]" →DEMO_LIMIT_EXCEEDED, walrus aggregator timeout →WALRUS_UNREACHABLE, memwal 401 →MEMWAL_AUTH, openai 429 →OPENAI_RATE_LIMIT, namespace not found →NAMESPACE_MISSING, read-token →NAMESPACE_TOKEN, unknown → default title, empty → null hint). This is a behavior-preserving port offailureHintFor.skeleton.test.ts: assertskeletonClass(variant)mapping + default..test.ts(vitestincludeexcludes.tsx).bun run typecheckandbun run build(apps/webvite build) — proves the new.tsxcomponents compile and bundle (bun-check.yml runsbun run check+bun run build).bun run --filter ./apps/web dev, then drive with chrome-devtoolsresize_pageat 360, 414, 768 and assert viaevaluate_scriptthatdocument.documentElement.scrollWidth <= window.innerWidth(no horizontal scroll) on/app/namespaces,/app(Build),/showcase, and a constellation view. Throttle network (slow 3G) to confirm skeletons render before data; kill the API (/app/namespaceswith badAPI_BASE) to confirmErrorCardshows + Retry re-fetches without a full reload (watch network panel: a new XHR fires, no document navigation). Confirmprefers-reduced-motionstops the shimmer (emulate in devtools).Risks & gotchas
NamespacesAppPageis unrouted (route at main.tsx:1575 still usesNamespacesSimplePage). Editing its error/empty rendering (AC Memory knowledge graph: 3D neural constellation + grounded entity panel #2/fix(ci): force bun + pin wrangler in Deploy workflow #3) won't be visible in-app until the separate "wire up" task lands — verify those changes via typecheck/build, not the running app. Coordinate so you don't conflict with that task's edit to the same component.failurePanelis reused at other call sites (e.g. ShowcasePage at main.tsx:2115); if you deleteFailureExplainerinstead of shimming it, grep every usage first and replace, or CI typecheck breaks..failurePanelred hex (#fef2f2/#fecaca/#7f1d1d/#991b1b at styles.css:6395) must be tokenized, not copied; do not introduce hex intonamespace-memory.css.:rootblocks (styles.css:1 and :2337) — add tokens to the brand block at :2337 (the one defining--cm-accent), not the pre-brand block at :1.100%/max-width:100vwand re-measure on resize, else AC Spike: run @mysten/seal encrypt+decrypt inside a Cloudflare Worker #5 "no horizontal scroll" fails.ErrorCard.onRetrysystem is the substrate for showing "creating on-chain bucket…" and retrying the ~3smirror_missing_grantwindow during the Harbor flow — design the skeleton copy/retry to be reusable for that, but no Harbor code in this issue.Acceptance-criteria mapping
<Skeleton>on hosted extraction, facts/graph, chat thinking, namespaces list, share page → Steps 1–2, 5, 6, 8, 9.<EmptyState>replaces inline strings like "No namespaces yet" (5938) → Steps 3, 5 (showcase empty), 6 (main.tsx:5938), 7 (6196/6397/6417).<ErrorCard>with Retry replaces raw red text (NamespacesAppPage 5967, ShowcasePage 2112); FailureExplainer folded in → Steps 4, 5, 7 (replaces<div className="error">at main.tsx:6182), 8.onRetry={() => refresh()}), 5/6/8 (loadDirectory/loadNamespaces/loadShare); verified in Test plan manual section.--cm-*; don't touch namespace-memory.css) → Step 1 (tokens) + Step 11 constraint; no hex added tonamespace-memory.css.Effort
M, ~2.5–3.5 person-days. Components + tokens + tests ≈ 1 day; wiring across the 5+ call sites in the 8.2k-line
main.tsx≈ 1 day; responsive nav/Build/constellation pass + cross-device verification ≈ 0.5–1.5 days. (EmptyState/ErrorCard extraction alone is good-first-issue sized per the issue's note.)Implementation plan generated for execution (planning phase). Part of the roadmap (#4).