Skip to content

Latest commit

 

History

History
140 lines (93 loc) · 6.12 KB

File metadata and controls

140 lines (93 loc) · 6.12 KB

Scaling Patterns

Patterns that emerged at 100+ features. None of these are needed at 10 features. All of them become necessary at 100+.

The template gives you a foundation. This doc covers what you'll build on top of it as you scale.


useToolSettings

The problem: Every feature with configurable behavior (sort order, display mode, decimal precision) needs persistent settings. Without standardization, each feature manually constructs a localStorage key and re-implements the merge pattern.

The solution: A thin hook over useLocalStorage with auto-namespacing.

const [settings, updateSetting, resetSettings] = useToolSettings('unit-converter', {
  decimalMode: false,
  showAdvanced: false,
});

// Partial update -- only changes the key you specify
updateSetting('decimalMode', true);

// Full reset to defaults
resetSettings();

Internally, useToolSettings stores everything under {app}:{featureId}:settings. The updateSetting function does a shallow merge, so updating one key doesn't clobber the others.

Why extract it: At 30+ features with settings, the alternative is 30 hand-rolled useLocalStorage calls with 30 hand-rolled merge functions. This hook makes the right pattern effortless.


useToolHistory

The problem: Many features show a "recent history" panel (password generator results, unit conversions, color picks). Each one needs timestamped entries, a cap to prevent unbounded growth, and individual removal.

The solution: A standardized hook for history management.

const [history, addEntry, clearHistory, removeEntry] = useToolHistory('password-generator', 50);

// Add an entry -- auto-stamped with id (Date.now) and ISO timestamp
addEntry({ password: 'xK9#mP2$', strength: 'strong' });

// Remove a single entry by its auto-assigned id
removeEntry(1709234567890);

// Clear everything
clearHistory();

History is newest-first, capped at maxItems (default 50). Entries older than the cap are silently dropped on the next addEntry.

Composable with HistoryPanel: The HistoryPanel shared component renders history entries with clear and per-item removal. Wire it up with one line:

<HistoryPanel items={history} renderItem={(entry) => <span>{entry.password}</span>} onClear={clearHistory} />

SEO Data Layer

The problem: At 178 features, each needs a unique page title, meta description, and FAQ section for search engines. Embedding this content in each feature page would scatter SEO copy across 178 files, making audits and updates painful.

The solution: Centralize all SEO content in a single data file.

// src/data/seoData.js
export const seoData = {
  'unit-converter': {
    seoTitle: 'Online Unit Converter',
    seoDescription: 'Convert between metric, imperial, and scientific units instantly.',
    faqs: [
      { q: 'How accurate are the conversions?', a: 'All conversions use IEEE 754...' },
      { q: 'Does this work offline?', a: 'Yes, all calculations run client-side...' },
    ],
  },
  // ... one entry per feature
};

Consumption pipeline:

  1. Runtime: FeatureLayout reads seoData[featureId] and renders the H1, description paragraph, and FAQ accordion. It also feeds <Helmet> for <title> and <meta name="description">.

  2. Build-time: A Vite plugin (inject-meta.mjs) bakes <title> and <meta> into static HTML chunks so crawlers see correct metadata even before JavaScript executes.

Why centralize: Updating SEO copy for all 178 features is a single-file operation. Auditing for missing or stale metadata is a single scan. Adding a new feature's SEO data is one object entry, not a scattered edit across component files.


GameShell

The problem: Game-type features (chess, blackjack, dice games, maze) share hard layout problems: aspect-ratio preservation, fullscreen mode, canvas feedback-loop prevention, and responsive touch controls. Solving these in every game page leads to duplicated, fragile code.

The solution: A shared layout wrapper that handles the hardest rendering concerns.

<GameShell
  aspectRatio={16/9}
  isFullscreen={isFs}
  onToggleFs={() => setFs(!isFs)}
  canvas={<canvas ref={canvasRef} />}
  stats={[{ label: 'Score', value: score }, { label: 'Level', value: level }]}
  controls={<TouchDPad onMove={handleMove} />}
  actions={<Button onClick={newGame}>New Game</Button>}
  footer="Arrow keys to move"
/>

Two rendering modes

  • Canvas mode (when aspectRatio is provided): Uses CSS container queries to compute the largest rectangle that fits the viewport while preserving the exact ratio. The canvas is absolutely positioned inside it to prevent resize feedback loops.

  • HTML mode (when aspectRatio is omitted): Content fills via flexbox. Suitable for card games and board games that use DOM elements instead of canvas.

The single-render-tree constraint

When toggling fullscreen, the entire component tree switches from normal layout to fixed inset-0 z-50 -- but the canvas element is never unmounted. This preserves refs, event listeners, and requestAnimationFrame loops. Without this guarantee, every fullscreen toggle would tear down and restart the game's animation loop.

Named slots

Prop What it renders Behavior in fullscreen
canvas The game area (canvas or HTML) Scales to fill
stats StatCard array Compact chip mode
sidePanel Adjacent panel (e.g., "next piece" preview) Preserved
controls Touch controls Visible (hidden in normal mode on desktop)
actions Game buttons Preserved
footer Instructions text Hidden

Context Growth

The template ships 2 contexts (Theme + Toast). At 178 features, Overtooled uses 6. See State Management for the full breakdown and the decision tree for when a new context is justified.

The short version: a context is justified when state genuinely needs to cross React tree branches that share no common parent below App. If the state is used by siblings in separate layout zones (Navbar + Home page, Sidebar + Content area), that's a context. If the state flows from parent to child, use props.