mcpField
S
spencer-approved-frontends

spencer-approved-frontends

github.com/schneidenbachDesign & UI20100 installsUpdated 3w ago
Repository

About spencer-approved-frontends

A frontend design skill for projects built for Spencer, defining a dense, neutral visual style and practical React and TypeScript conventions. It helps coding agents create internal-tool interfaces without decorative landing-page patterns.

Key features

  1. 01Dense, neutral interface styling
  2. 02React 18 and Vite project defaults
  3. 03Strict TypeScript conventions
  4. 04Hand-built UI component patterns
  5. 05Guidance for forms and UI states

Use cases

  1. 01Starting a new internal-tool frontend
  2. 02Applying Spencer's visual style to an interface
  3. 03Building forms without component libraries
  4. 04Handling loading, empty, and error states

Install

Copy the command and run it in a terminal.

$npx skills add https://github.com/schneidenbach/skills --skill spencer-approved-frontends

After installing, type/spencer-approved-frontendsin Claude Code to confirm it loaded.

Bundled files

References and scripts installed along with SKILL.md.

agents/
openai.yaml
references/
anti-patterns.md
full-index-css.md
full-tailwind-config.md
starter-app.md
textarea-autoresize.md

Details

From the project README

Spencer-Approved Frontends

The default "AI-generated frontend" look is wrong for Spencer. Glassy gradients, oversized hero text, generous padding, drop shadows on every card, blue-to-purple linear gradients, rounded-2xl everything, lucide icons everywhere — none of that. Spencer ships dense, neutral, system-font UIs that read like serious internal tooling. Think Linear, Notion's admin views, the macOS settings panel — not a SaaS landing page.

When in doubt, fewer pixels of decoration, smaller text, tighter spacing, and one neutral color ramp.

When this overrides other skills

If frontend-design (or anything similar) is also active, this skill wins on style decisions. The other skill's process is fine; the visual defaults are not. Spencer's frontends look like the due-diligence and security-review repos in ~/Repos/. If you have access to those repos and the user is starting something new, you may peek at them as ground truth. Otherwise, follow the patterns in this file.

Stack defaults for new projects

For a new project from scratch, start here unless Spencer says otherwise:

  • React 18 with Vite 5+ (not Next.js, not CRA)
  • TypeScript strict mode + noUncheckedIndexedAccess + noImplicitOverride
  • Tailwind CSS 3.4 (NOT Tailwind 4 by default — Spencer's repos pin to 3.4)
  • npm (not pnpm/yarn/bun)
  • No component library. No shadcn, no Radix, no MUI, no Mantine, no Chakra, no Headless UI. Build the few primitives you need by hand (Modal, Badge, Button, InlinePicker).
  • No icon library by default. Use small SVG inline or text glyphs (:: for a drag handle, × for close). If Spencer asks for icons, ask which library before adding one.
  • No form library. useState + a plain <form onSubmit> is enough.
  • No state library. useState / useReducer / useContext is enough until proven otherwise. No Redux, Zustand, Jotai, Recoil.
  • No data fetching library. Plain fetch wrapped in a small lib/api.ts module. No React Query, SWR, axios.
  • No animation library. No framer-motion, no react-spring. CSS transition on hover/focus is the whole animation budget.
  • No router library for small apps — manual window.location.pathname parsing in App.tsx. If the app grows past ~3 routes, ask Spencer if it's time for a router.

If Spencer asks for a different stack, follow Spencer. But propose this stack first.

Project layout

src/
  App.tsx               root, manual routing
  main.tsx              entry
  index.css             global tokens + utilities
  components/           one .tsx per component, PascalCase
  lib/                  cx.ts, format.ts, api.ts, hooks
shared/                 types shared with backend, aliased as @shared/*

No barrel exports. No pages/ directory for small apps. No co-located CSS files — Tailwind utilities only.

The visual contract

Every Spencer UI obeys these defaults. Memorize them.

Color tokens

Define these in tailwind.config.ts and index.css as CSS variables. Do not skip this step — Tailwind's default neutral palette is too cool and too saturated.

// tailwind.config.ts (excerpt)
colors: {
  surface: {
    DEFAULT: "#ffffff",
    soft:    "#fafafa",
    deep:    "#f5f5f4",
    hover:   "#f4f4f4",
  },
  ink: {
    DEFAULT: "#0a0a0a",
    soft:    "#404040",
    muted:   "#737373",
    faint:   "#a3a3a3",
  },
  line: {
    DEFAULT: "#eaeaea",
    strong:  "#d4d4d4",
  },
  // domain-specific severity ramp — rename per project
  // e.g. "risk" for due-diligence, "sev" for security-review,
  // "status" or "tier" for whatever the app is about
  risk: {
    critical: "#7f1d1d",
    high:     "#b91c1c",
    medium:   "#b45309",
    low:      "#1d4ed8",
  },
},

Pair each semantic color with a soft background variant via CSS variable, e.g. --risk-high-soft, used as bg-[var(--risk-high-soft)]. Soft backgrounds + saturated text = the badge look.

Typography
fontFamily: {
  sans: ["-apple-system", "BlinkMacSystemFont", "'SF Pro Text'", "'Segoe UI'", "Roboto", "Helvetica", "Arial", "sans-serif"],
  mono: ["'SFMono-Regular'", "'SF Mono'", "Menlo", "Consolas", "monospace"],
},

System fonts only. No Google Fonts, no Inter, no Geist, no font imports.

Allowed sizes (use text-[Npx] arbitrary values where Tailwind defaults don't fit):

  • 11px — tracked labels, badges, dense buttons
  • 12px — primary buttons, dense controls
  • 12.5px — secondary buttons
  • 13px — body text (this is the base — the <body> itself is font-size: 14px with line-height: 1.5, but most app chrome is 13px)
  • 15px — modal titles, section headings

That is the whole scale. No text-base, no text-lg, no text-2xl, no hero text. If you need emphasis, use font-semibold tracking-tight at 15px, not bigger type.

For form/section labels, use this utility (defined in index.css):

.tracked-label {
  font-size: 11px;
  font-weight: 500;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-muted);
}
Spacing & density

Spencer's UIs are dense. Allowed spacing values, in order of frequency:

  • gap-1, gap-1.5, gap-2, gap-3
  • px-2, px-2.5, px-3
  • py-0.5 (badges/pills), py-1, py-1.5, py-2

Do NOT default to p-4, p-6, p-8, gap-4, gap-6. Those exist for a different design system.

Buttons end up ~28–32px tall. Badges end up ~18–20px tall. That's correct.

Borders, radii, shadows
  • 1px borders everywhere there's a divider: border border-line or border-b border-line.
  • Radii: rounded-md for inputs, rounded-lg for buttons and cards, rounded-xl for modals, rounded-full for pills/badges. No rounded-2xl.
  • No shadows on regular elements. Spencer's index.css includes .clean-app * { box-shadow: none; }. The single exception is the modal: shadow-[0_20px_60px_-24px_rgba(0,0,0,0.25)].
  • No gradients. Not on backgrounds, not on buttons, not on text. Flat fills only.
Animation

Hover and focus transitions only, via Tailwind's default transition class. No animate-pulse, no animate-spin (unless it's a real loading spinner inside an SVG), no entrance/exit animations, no framer-motion.

The four primitives you actually need

Ship these four hand-rolled components and you have 80% of any app. Copy these patterns; do not import from a library.

lib/cx.ts
export function cx(...parts: Array<string | false | null | undefined>): string {
  return parts.filter(Boolean).join(" ");
}

This replaces clsx and classnames. Do not install either. cx is what every component uses for conditional classes.

Button (extracted const, not a component)

In most files you don't even build a <Button /> component. You hoist the class string to a const at the top of the file and use it on a <button>:

const primaryButtonClass =
  "inline-flex items-center justify-center rounded-lg bg-ink px-2.5 py-1.5 text-[12px] font-medium text-white transition hover:bg-ink-soft disabled:cursor-not-allowed disabled:opacity-50";

const secondaryButtonClass =
  "inline-flex items-center justify-center rounded-lg border border-line bg-surface px-3 py-1.5 text-[12.5px] font-medium text-ink-soft transition hover:border-line-strong hover:text-ink disabled:cursor-not-allowed disabled:opacity-40";

const dangerButtonClass =
  "inline-flex items-center justify-center rounded-md border border-line bg-surface px-2 py-1 text-[11px] font-medium text-risk-high transition hover:border-risk-high";

Use these strings directly. If three files end up using the same one, then extract to a shared module — not before.

Input
const inputClass =
  "w-full rounded-lg border border-line bg-surface px-2.5 py-1.5 text-[12px] text-ink outline-none transition placeholder:text-ink-faint focus:border-ink";

Same pattern: hoist to const, use on plain <input> or <textarea>.

Badge / pill
const pill = "inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-[11px] font-medium";

const riskTone: Record<Risk, { text: string; bg: string; dot: string }> = {
  critical: { text: "text-risk-critical", bg: "bg-[var(--risk-critical-soft)]", dot: "bg-risk-critical" },
  high:     { text: "text-risk-high",     bg: "bg-[var(--risk-high-soft)]",     dot: "bg-risk-high" },
  medium:   { text: "text-risk-medium",   bg: "bg-[var(--risk-medium-soft)]",   dot: "bg-risk-medium" },
  low:      { text: "text-risk-low",      bg: "bg-[var(--risk-low-soft)]",      dot: "bg-risk-low" },
};

export function RiskBadge({ value }: { value: Risk }) {
  const tone = riskTone[value];
  return (
    <span className={cx(pill, tone.text, tone.bg)}>
      <span className={cx("h-1.5 w-1.5 rounded-full", tone.dot)} aria-hidden />
      {value}
    </span>
  );
}

The 1.5px dot + soft background + saturated text + capitalized value is the Spencer badge.

Modal
export function Modal({
  open, title, children, onClose, width = "max-w-3xl",
}: {
  open: boolean;
  title: string;
  children: ReactNode;
  onClose: () => void;
  width?: string;
}) {
  if (!open) return null;
  return (
    <div
      className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/30 px-4 py-10"
      onClick={onClose}
    >
      <div
        className={`w-full ${width} overflow-hidden rounded-xl border border-line bg-surface shadow-[0_20px_60px_-24px_rgba(0,0,0,0.25)]`}
        onClick={(e) => e.stopPropagation()}
      >
        <div className="flex items-center justify-between border-b border-line px-6 py-4">
          <h2 className="text-[15px] font-semibold tracking-tight text-ink">{title}</h2>
          <button type="button" onClick={onClose} className="text-sm text-ink-muted transition hover:text-ink">
            Close
          </button>
        </div>
        <div className="max-h-[78vh] overflow-y-auto scrollbar-thin">{children}</div>
      </div>
    </div>
  );
}

That's the only modal you need.

App shell
export function App() {
  return (
    <div className="clean-app flex h-screen flex-col bg-surface pt-7 text-[13px] text-ink">
      {bannerError && <ErrorBanner message={bannerError} onDismiss={() => setBannerError(null)} />}
      <div className="min-h-0 flex-1">
        {/* routed content */}
      </div>
    </div>
  );
}

min-h-0 flex-1 on the scroll container is non-negotiable — without it nested flex columns won't scroll.

Component-writing conventions

  • One component per file, PascalCase filename. No barrels, no index.ts re-exports.

  • Functional components only. No React.FC. No forwardRef unless you actually need a ref.

  • Inline object types for props, not a separate interface:

    // do
    export function Badge({ value }: { value: Risk }) { ... }
    // not
    interface BadgeProps { value: Risk }
    export function Badge({ value }: BadgeProps) { ... }
    

    Exception: when props are reused or the type is generic (like the InlinePicker<T> pattern below), name the interface.

  • Use cx() for any conditional class. Never template-literal-concat classes.

  • Never use any. Spencer's TS settings are strict and noUncheckedIndexedAccess is on, so handle T | undefined from array indexing.

  • Imports order: React → @shared/* types → lib/ utilities → relative components.

  • Comments: explain why, not what. No JSDoc. No "// TODO: refactor" without a real reason.

The generic InlinePicker pattern

When you have a select-like control with semantic colors (status, severity, risk, tier), use this generic. Don't write a one-off <select> for each.

interface InlinePickerProps<T extends string> {
  value: T;
  disabled?: boolean;
  saving?: boolean;
  onChange: (next: T) => void;
  options: readonly T[];
  toneClass: (value: T) => string;
  ariaLabel: string;
  label?: (value: T) => string;
}

function InlinePicker<T extends string>({
  value, disabled, saving, onChange, options, toneClass, ariaLabel, label = (v) => v,
}: InlinePickerProps<T>) {
  return (
    <label className="relative inline-flex">
      <span className="sr-only">{ariaLabel}</span>
      <select
        value={value}
        disabled={disabled || saving}
        onChange={(e) => onChange(e.target.value as T)}
        className={cx(
          "cursor-pointer appearance-none rounded-full border border-transparent bg-no-repeat py-0.5 pl-2.5 pr-6 text-[11px] font-medium capitalize",
          toneClass(value),
          saving && "opacity-60",
          disabled && "cursor-not-allowed opacity-50",
        )}
      >
        {options.map((o) => <option key={o} value={o}>{label(o)}</option>)}
      </select>
    </label>
  );
}

Then specialize:

export function RiskPicker(props: Omit<InlinePickerProps<Risk>, "options" | "toneClass" | "ariaLabel">) {
  return <InlinePicker {...props} options={risks} toneClass={(v) => `${riskTone[v].text} ${riskTone[v].bg}`} ariaLabel="Risk" />;
}

Forms

  • Plain <form onSubmit={...}>. Call event.preventDefault(). No react-hook-form, no formik.
  • One saving boolean per form. Disable the submit button while it's true.
  • Errors surface via an ErrorBanner at the top of the screen or via a setError callback into a parent. No toast library.
  • Validation: do it inline in the submit handler. If it grows, extract to a validate() function in the same file. Don't reach for zod unless Spencer asks for it.
  • Auto-resize textareas with the useEffect-on-scrollHeight trick (see references/textarea-autoresize.md).

Loading, empty, error states

Three rules:

  1. Loading is a small inline marker, not a full-screen spinner. Disabled form + Saving… text on the button. For lists, render skeleton rows that match the row height (1px border, same padding) — not pulsing gradient blobs.
  2. Empty states are one line of text-ink-muted text and maybe a single secondary button. No illustrations, no clip art, no big "Get started" cards.
  3. Error states use bg-[var(--risk-high-soft)] text-risk-high with the ErrorBanner pattern. Always include a dismiss button.

What to never put in a Spencer UI

If you find yourself reaching for any of these, stop:

  • Gradient backgrounds, gradient text, gradient buttons.
  • Glass / blur / backdrop-filter effects.
  • Drop shadows on cards, buttons, inputs (modal is the one exception).
  • rounded-2xl or larger. Anything bigger than rounded-xl looks like a marketing site.
  • Hero sections, big centered headlines, "Welcome to ___" splash screens.
  • Stock illustrations, undraw-style SVGs, mascots.
  • Lucide icons sprinkled on every label "for visual interest."
  • Animated entrances, page transitions, scroll-triggered animations.
  • Color anywhere except: ink ramp, surface ramp, line ramp, and the one semantic ramp (risk/sev/status).
  • Tailwind's gray-*, slate-*, zinc-*, neutral-* classes — use the custom ink and line tokens instead so the palette stays consistent.
  • Tailwind's blue-500, indigo-600, purple-*, pink-* accents.
  • "Modern SaaS" font choices: Inter, Geist, Plus Jakarta Sans, Manrope. System fonts only.
  • dark: variants unless Spencer explicitly asks for dark mode.

Working with an existing repo

If Spencer is editing a repo that already has a different stack — Next.js with shadcn, a Mantine app, whatever — do NOT rewrite it to match this skill. Follow the repo's conventions. But still apply the taste defaults from this skill: smaller type, denser spacing, neutral palette, no gratuitous gradients or shadows. Push the existing components in that direction rather than around it.

Reference files

When you need more detail than this file holds, read:

  • references/full-tailwind-config.md — complete tailwind.config.ts with CSS variables.
  • references/full-index-css.md — the global CSS including .clean-app, .tracked-label, .scrollbar-thin, .select-ink.
  • references/starter-app.md — minimal App.tsx, main.tsx, index.html for a brand-new project.
  • references/anti-patterns.md — extended "do not do this" with concrete bad-vs-good code pairs, useful when reviewing or refactoring an existing AI-generated frontend toward Spencer's style.

Comments

Markdown supported