Skip to content

Admin SPA: extract shared components (Button, Spinner, Banner) and CSS design tokens #626

@waynesun09

Description

@waynesun09

Summary

Extract duplicated UI patterns from route-level Svelte files into shared components under web/admin/src/lib/components/, and convert hardcoded hex values into CSS custom properties in app.css. This eliminates copy-paste drift and gives upcoming features (#510#514) a consistent component foundation.

Prerequisite: PR #624 (DESIGN.md) and PR #625 (ESLint/Prettier/Stylelint + Husky) should merge first. DESIGN.md defines the token palette; the linter enforces component size limits.

Depends on: PR #617 (Barak's org list + OAuth worker) — the extraction targets App.svelte and OrgList.svelte as they exist on that branch.

Estimated effort: 14–16 hours (~2 days)

Problem

The admin SPA has zero shared components. Every file re-implements buttons, spinners, and banners with slight inconsistencies:

Pattern Duplicated in Drift
.btn base App.svelte, OrgList.svelte padding 0.35rem vs 0.4rem; border #aaa vs #888
.btn.primary App.svelte, OrgList.svelte bg #24292f (emphasis) vs #0969da (accent)
@keyframes spin App.svelte (spin), OrgList.svelte (org-spin) Duplicate animation definitions
Spinners 6 different sizes across 2 files 0.95rem, 1.1rem, 1.25rem, 2rem, 2.25rem, 2.75rem
.banner App.svelte (error + warning), OrgList.svelte (error only) Slight gap and border differences
.sr-only OrgList.svelte only Missing from App.svelte
Hardcoded hex values ~55 across both files No design tokens

OrgList.svelte is 1,155 lines. App.svelte is 473 lines. Both exceed the 150-line component limit from PR #625.

Phases

Phase 1: CSS custom properties token file

Expand app.css from 6 lines into the token foundation. Every color, spacing, typography, and radius value from DESIGN.md becomes a CSS custom property under :root. Includes .sr-only utility and prefers-reduced-motion media query.

~55 hardcoded hex values across App.svelte and OrgList.svelte map to tokens. GitHub sign-in button colors (#0d1117 etc.) stay local — they're GitHub brand colors, not part of the design system.

Phase 2: Shared component extraction

Create web/admin/src/lib/components/ with:

Component Props Replaces
Spinner.svelte size: 'sm'|'md'|'lg'|'xl', label: string 6 spinner implementations (normalized to 4 sizes)
Button.svelte variant: 'default'|'primary'|'accent'|'muted'|'link', children, leading (snippet slot for spinner) All .btn / .link-btn duplication
ButtonLink.svelte href, variant, children <a class="btn"> patterns in OrgList
Banner.svelte variant: 'error'|'warning'|'success'|'info', dismissible, children, actions (snippet slot) All .banner duplication
Card.svelte children, heading snippet Section containers
index.ts Barrel export

All components use Svelte 5 runes ($props()) and follow DESIGN.md accessibility requirements (focus rings, ARIA attributes, reduced motion).

Spinner sizes normalized:

Size Value Replaces
sm 1rem btn-refresh-spinner (0.95rem), App spinner (1.1rem)
md 1.25rem row-spinner-disc (1.25rem)
lg 2rem org-more-spinner (2rem), org-loading-spinner (2.25rem)
xl 2.75rem boot-spinner (2.75rem)

Button variants:

  • default — secondary actions (sign out, dismiss)
  • primary — emphasis/dark bg (--bg-emphasis)
  • accent — blue CTA for deploy/action (--fg-accent)
  • muted — subtle, less prominent
  • link — text-styled inline action (replaces .link-btn)

Phase 3: Migration

Replace inline implementations in App.svelte and OrgList.svelte with shared component imports. Delete duplicate CSS. Each route file should drop well below the 150-line limit after extraction.

Migration example (spinner):

<!-- Before -->
<span class="spinner" aria-hidden="true"></span>

<!-- After -->
<Spinner size="sm" label="Loading session" />

Migration example (button with spinner):

<!-- Before -->
<button type="button" class="btn btn-refresh" disabled={loading}>
  {#if loading}<span class="btn-refresh-spinner" aria-hidden="true"></span>{/if}
  Refresh
</button>

<!-- After -->
<Button disabled={loading} aria-busy={loading}
  onclick={() => void loadOrgs(true)}>
  {#snippet leading()}
    {#if loading}<Spinner size="sm" label="Refreshing" />{/if}
  {/snippet}
  Refresh
</Button>

Phase 4: Verification

  • All linters pass (npm run lint, npm run format:check, npm run stylelint)
  • svelte-check passes with zero errors
  • No hardcoded hex values remain in component files (verified by grep)
  • All components under 150 lines
  • Visual regression check in browser at 1280px, 768px, 375px

Relationship to other issues

Metadata

Metadata

Type

No type

Projects

Status

Todo

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions