# Particle Effects / Atmosphere Panel Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Replace the single fog toggle in the campaign header with an atmosphere panel supporting fog (CSS, unchanged), fire, rain, and embers (tsParticles), each with an on/off toggle and intensity slider synced via socket.

**Architecture:** `AtmospherePanel` is a self-contained popover component that receives atmosphere state and an `onAtmosphereChange` callback from `CampaignView`. `CampaignView` owns socket emit and state. `ParticleOverlay` renders a tsParticles instance per active particle effect (fire/rain/embers). `FogOverlay` is unchanged — fog stays as CSS.

**Tech Stack:** React 18, TypeScript, `@tsparticles/react`, `@tsparticles/slim`, Socket.IO client, CSS Modules

---

## File Map

| File | Action |
|------|--------|
| `client/src/lib/atmosphereTypes.ts` | Create — shared types and default state |
| `client/src/lib/particleConfigs.ts` | Create — pure config functions per effect |
| `client/src/components/ParticleOverlay.tsx` | Create — tsParticles renderer |
| `client/src/components/AtmospherePanel.tsx` | Create — button + popover |
| `client/src/components/AtmospherePanel.module.css` | Create — panel styles |
| `client/src/pages/CampaignView.tsx` | Modify — state, socket, JSX |
| `server/src/socket.ts` | Modify — update TypeScript type |
| `client/package.json` | Modify — add tsParticles packages |

---

### Task 1: Install tsParticles and define shared types

**Files:**
- Modify: `client/package.json`
- Create: `client/src/lib/atmosphereTypes.ts`

- [ ] **Step 1: Install packages**

```bash
cd /path/to/shadowdark/client
npm install @tsparticles/react @tsparticles/slim
```

Expected output: packages added to node_modules, `package.json` updated with both deps.

- [ ] **Step 2: Verify installation**

```bash
ls node_modules/@tsparticles/
```

Expected: `engine  react  slim` directories visible.

- [ ] **Step 3: Create shared types file**

Create `client/src/lib/atmosphereTypes.ts`:

```typescript
export interface EffectState {
  active: boolean;
  intensity: number; // 0–100
}

export interface AtmosphereState {
  fog: EffectState;
  fire: EffectState;
  rain: EffectState;
  embers: EffectState;
}

export const defaultAtmosphere: AtmosphereState = {
  fog:    { active: false, intensity: 50 },
  fire:   { active: false, intensity: 50 },
  rain:   { active: false, intensity: 50 },
  embers: { active: false, intensity: 50 },
};
```

- [ ] **Step 4: Verify TypeScript compiles**

```bash
cd client
npx tsc --noEmit
```

Expected: no errors.

- [ ] **Step 5: Commit**

```bash
git add client/package.json client/package-lock.json client/src/lib/atmosphereTypes.ts
git commit -m "feat: install tsParticles and add atmosphere types"
```

---

### Task 2: Implement particle configs

**Files:**
- Create: `client/src/lib/particleConfigs.ts`

- [ ] **Step 1: Create particle configs file**

Create `client/src/lib/particleConfigs.ts`:

```typescript
import type { ISourceOptions } from "@tsparticles/engine";

/** Intensity 0–100 → fire particle count 20–200, speed 2–8 */
export function getFireConfig(intensity: number): ISourceOptions {
  const count = Math.round(20 + (intensity / 100) * 180);
  const speed = 2 + (intensity / 100) * 6;
  return {
    fullScreen: { enable: false },
    background: { opacity: 0 },
    particles: {
      number: { value: count, density: { enable: false } },
      color: { value: ["#ff4400", "#ff8800", "#ffcc00", "#ff2200"] },
      shape: { type: "circle" },
      opacity: {
        value: { min: 0.1, max: 0.8 },
        animation: { enable: true, speed: 2, startValue: "max", destroy: "min" },
      },
      size: { value: { min: 1, max: 4 } },
      move: {
        enable: true,
        speed: { min: speed * 0.5, max: speed },
        direction: "top",
        random: true,
        straight: false,
        outModes: { default: "destroy", top: "destroy" },
      },
      life: { duration: { sync: false, value: 3 }, count: 1 },
    },
    emitters: {
      direction: "top",
      life: { count: 0, duration: 0.1, delay: 0.1 },
      rate: { delay: 0.05, quantity: Math.max(1, Math.round(count / 20)) },
      size: { width: 100, height: 0 },
      position: { x: 50, y: 100 },
    },
  };
}

/** Intensity 0–100 → rain particle count 50–500, speed 5–20 */
export function getRainConfig(intensity: number): ISourceOptions {
  const count = Math.round(50 + (intensity / 100) * 450);
  const speed = 5 + (intensity / 100) * 15;
  return {
    fullScreen: { enable: false },
    background: { opacity: 0 },
    particles: {
      number: { value: count, density: { enable: false } },
      color: { value: "#7aaad8" },
      shape: { type: "circle" },
      opacity: { value: { min: 0.2, max: 0.45 } },
      size: { value: { min: 0.5, max: 1.5 } },
      move: {
        enable: true,
        speed: speed,
        direction: "bottom",
        straight: true,
        angle: { value: 15, offset: 0 },
        outModes: { default: "out" },
      },
    },
  };
}

/** Intensity 0–100 → embers particle count 5–80, speed 0.2–1.5 */
export function getEmbersConfig(intensity: number): ISourceOptions {
  const count = Math.round(5 + (intensity / 100) * 75);
  const speed = 0.2 + (intensity / 100) * 1.3;
  return {
    fullScreen: { enable: false },
    background: { opacity: 0 },
    particles: {
      number: { value: count, density: { enable: false } },
      color: { value: ["#ff8800", "#ffaa00", "#ffcc44", "#ff6600"] },
      shape: { type: "circle" },
      opacity: {
        value: { min: 0.3, max: 0.9 },
        animation: { enable: true, speed: 0.5, sync: false },
      },
      size: { value: { min: 1, max: 3 } },
      move: {
        enable: true,
        speed: speed,
        direction: "top",
        random: true,
        straight: false,
        outModes: { default: "out" },
      },
    },
  };
}
```

- [ ] **Step 2: Verify TypeScript compiles**

```bash
cd client && npx tsc --noEmit
```

Expected: no errors. If `ISourceOptions` import fails, check `@tsparticles/engine` is a transitive dep of `@tsparticles/slim` — it should be. If not, run `npm install @tsparticles/engine`.

- [ ] **Step 3: Commit**

```bash
git add client/src/lib/particleConfigs.ts
git commit -m "feat: add tsParticles configs for fire, rain, embers"
```

---

### Task 3: Implement ParticleOverlay

**Files:**
- Create: `client/src/components/ParticleOverlay.tsx`

- [ ] **Step 1: Create ParticleOverlay component**

Create `client/src/components/ParticleOverlay.tsx`:

```typescript
import { useEffect, useState } from "react";
import Particles, { initParticlesEngine } from "@tsparticles/react";
import { loadSlim } from "@tsparticles/slim";
import type { AtmosphereState } from "../lib/atmosphereTypes";
import { getFireConfig, getRainConfig, getEmbersConfig } from "../lib/particleConfigs";

// Module-level singleton so the engine is only initialised once
let enginePromise: Promise<void> | null = null;
function ensureEngine(): Promise<void> {
  if (!enginePromise) {
    enginePromise = initParticlesEngine(async (engine) => {
      await loadSlim(engine);
    });
  }
  return enginePromise;
}

const overlayStyle: React.CSSProperties = {
  position: "fixed",
  inset: 0,
  zIndex: 9997,
  pointerEvents: "none",
};

const instanceStyle: React.CSSProperties = {
  position: "absolute",
  inset: 0,
};

interface ParticleOverlayProps {
  atmosphere: AtmosphereState;
}

export default function ParticleOverlay({ atmosphere }: ParticleOverlayProps) {
  const [ready, setReady] = useState(false);

  useEffect(() => {
    ensureEngine().then(() => setReady(true));
  }, []);

  if (!ready) return null;

  const { fire, rain, embers } = atmosphere;
  const anyActive = fire.active || rain.active || embers.active;
  if (!anyActive) return null;

  return (
    <div style={overlayStyle}>
      {fire.active && (
        <Particles
          key={`fire-${fire.intensity}`}
          id="particles-fire"
          options={getFireConfig(fire.intensity)}
          style={instanceStyle}
        />
      )}
      {rain.active && (
        <Particles
          key={`rain-${rain.intensity}`}
          id="particles-rain"
          options={getRainConfig(rain.intensity)}
          style={instanceStyle}
        />
      )}
      {embers.active && (
        <Particles
          key={`embers-${embers.intensity}`}
          id="particles-embers"
          options={getEmbersConfig(embers.intensity)}
          style={instanceStyle}
        />
      )}
    </div>
  );
}
```

Note: `key={`effect-${intensity}`}` causes the Particles component to remount when intensity changes (committed via slider release), giving clean re-initialization. This avoids stale container state.

- [ ] **Step 2: Verify TypeScript compiles**

```bash
cd client && npx tsc --noEmit
```

Expected: no errors.

- [ ] **Step 3: Commit**

```bash
git add client/src/components/ParticleOverlay.tsx
git commit -m "feat: add ParticleOverlay component for tsParticles effects"
```

---

### Task 4: Style AtmospherePanel

**Files:**
- Create: `client/src/components/AtmospherePanel.module.css`

- [ ] **Step 1: Create CSS module**

Create `client/src/components/AtmospherePanel.module.css`:

```css
/* Container holds trigger + popover — position relative so popover anchors here */
.container {
  position: relative;
}

/* Trigger button — matches fogBtn style from CampaignView.module.css */
.trigger {
  padding: 0.4rem 0.5rem;
  background: none;
  border: 1px solid rgba(var(--gold-rgb), 0.2);
  border-radius: 4px;
  cursor: pointer;
  font-size: 1rem;
  line-height: 1;
  color: var(--text-primary);
  opacity: 0.5;
  transition:
    opacity 0.15s,
    border-color 0.15s;
  white-space: nowrap;
}

.trigger:hover {
  opacity: 0.8;
  border-color: rgba(var(--gold-rgb), 0.4);
}

/* When any effect is active */
.triggerActive {
  opacity: 1;
  border-color: rgba(var(--gold-rgb), 0.5);
  background: rgba(var(--gold-rgb), 0.1);
}

/* Popover panel */
.panel {
  position: absolute;
  top: calc(100% + 0.4rem);
  right: 0;
  background-color: var(--bg-modal);
  background-image: var(--texture-surface);
  background-size: 256px 256px;
  background-repeat: repeat;
  border: 1px solid rgba(var(--gold-rgb), 0.3);
  border-radius: 4px;
  padding: 0.5rem 0.6rem;
  min-width: 180px;
  z-index: 9999;
  box-shadow:
    0 4px 16px rgba(var(--shadow-rgb), 0.5),
    inset 0 1px 0 rgba(var(--gold-rgb), 0.06);
}

.panelTitle {
  font-family: "Cinzel", Georgia, serif;
  font-size: 0.7rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: rgba(var(--gold-rgb), 0.6);
  border-bottom: 1px solid rgba(var(--gold-rgb), 0.15);
  padding-bottom: 0.35rem;
  margin-bottom: 0.4rem;
}

/* One row per effect */
.effectRow {
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
  padding: 0.3rem 0;
  border-bottom: 1px solid rgba(var(--gold-rgb), 0.08);
}

.effectRow:last-child {
  border-bottom: none;
}

.effectTop {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.5rem;
}

.effectLabel {
  font-family: "Alegreya", Georgia, serif;
  font-size: 0.85rem;
  color: var(--text-primary);
}

/* Toggle switch */
.toggle {
  position: relative;
  width: 32px;
  height: 17px;
  flex-shrink: 0;
  border-radius: 9px;
  background: rgba(var(--gold-rgb), 0.1);
  border: 1px solid rgba(var(--gold-rgb), 0.2);
  cursor: pointer;
  transition: background 0.2s, border-color 0.2s;
}

.toggle::after {
  content: "";
  position: absolute;
  top: 2px;
  left: 2px;
  width: 11px;
  height: 11px;
  border-radius: 50%;
  background: rgba(var(--gold-rgb), 0.35);
  transition: transform 0.2s, background 0.2s;
}

.toggleOn {
  background: rgba(var(--gold-rgb), 0.25);
  border-color: rgba(var(--gold-rgb), 0.5);
}

.toggleOn::after {
  transform: translateX(15px);
  background: var(--gold);
}

/* Intensity row — dimmed when effect is off */
.intensityRow {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  opacity: 0.3;
  transition: opacity 0.15s;
}

.intensityActive {
  opacity: 1;
}

.intensityLabel {
  font-family: "Alegreya", Georgia, serif;
  font-size: 0.75rem;
  color: var(--text-secondary);
  width: 2.2rem;
  text-align: right;
  flex-shrink: 0;
}

.slider {
  flex: 1;
  -webkit-appearance: none;
  appearance: none;
  height: 3px;
  border-radius: 2px;
  background: rgba(var(--gold-rgb), 0.15);
  outline: none;
  cursor: pointer;
}

.slider::-webkit-slider-thumb {
  -webkit-appearance: none;
  width: 11px;
  height: 11px;
  border-radius: 50%;
  background: rgba(var(--gold-rgb), 0.4);
  cursor: pointer;
  transition: background 0.15s;
}

.slider:not(:disabled)::-webkit-slider-thumb {
  background: var(--gold);
}

.slider::-moz-range-thumb {
  width: 11px;
  height: 11px;
  border-radius: 50%;
  border: none;
  background: var(--gold);
  cursor: pointer;
}
```

- [ ] **Step 2: Commit**

```bash
git add client/src/components/AtmospherePanel.module.css
git commit -m "feat: add AtmospherePanel CSS module"
```

---

### Task 5: Implement AtmospherePanel component

**Files:**
- Create: `client/src/components/AtmospherePanel.tsx`

- [ ] **Step 1: Create AtmospherePanel component**

Create `client/src/components/AtmospherePanel.tsx`:

```typescript
import { useEffect, useRef, useState } from "react";
import type { AtmosphereState } from "../lib/atmosphereTypes";
import styles from "./AtmospherePanel.module.css";

type EffectKey = keyof AtmosphereState;

const EFFECTS: { key: EffectKey; emoji: string; label: string }[] = [
  { key: "fog",    emoji: "🌫",  label: "Fog"    },
  { key: "fire",   emoji: "🔥",  label: "Fire"   },
  { key: "rain",   emoji: "🌧",  label: "Rain"   },
  { key: "embers", emoji: "✨", label: "Embers" },
];

interface AtmospherePanelProps {
  atmosphere: AtmosphereState;
  onAtmosphereChange: (next: AtmosphereState) => void;
}

export default function AtmospherePanel({
  atmosphere,
  onAtmosphereChange,
}: AtmospherePanelProps) {
  const [open, setOpen] = useState(false);
  // Local slider values so the label updates while dragging without triggering remounts
  const [localIntensity, setLocalIntensity] = useState({
    fog:    atmosphere.fog.intensity,
    fire:   atmosphere.fire.intensity,
    rain:   atmosphere.rain.intensity,
    embers: atmosphere.embers.intensity,
  });
  const containerRef = useRef<HTMLDivElement>(null);

  // Close panel on outside click
  useEffect(() => {
    if (!open) return;
    function onMouseDown(e: MouseEvent) {
      if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
        setOpen(false);
      }
    }
    document.addEventListener("mousedown", onMouseDown);
    return () => document.removeEventListener("mousedown", onMouseDown);
  }, [open]);

  // Sync local intensity when atmosphere changes from socket
  useEffect(() => {
    setLocalIntensity({
      fog:    atmosphere.fog.intensity,
      fire:   atmosphere.fire.intensity,
      rain:   atmosphere.rain.intensity,
      embers: atmosphere.embers.intensity,
    });
  }, [atmosphere]);

  const activeEmojis = EFFECTS
    .filter(({ key }) => atmosphere[key].active)
    .map(({ emoji }) => emoji)
    .join("");

  function toggleEffect(key: EffectKey) {
    onAtmosphereChange({
      ...atmosphere,
      [key]: { ...atmosphere[key], active: !atmosphere[key].active },
    });
  }

  function handleSliderChange(key: EffectKey, value: number) {
    // Update label only — no socket emit yet
    setLocalIntensity((prev) => ({ ...prev, [key]: value }));
  }

  function handleSliderCommit(key: EffectKey, value: number) {
    // Emit on mouse/touch release
    onAtmosphereChange({
      ...atmosphere,
      [key]: { ...atmosphere[key], intensity: value },
    });
  }

  return (
    <div className={styles.container} ref={containerRef}>
      <button
        className={`${styles.trigger} ${activeEmojis ? styles.triggerActive : ""}`}
        onClick={() => setOpen((o) => !o)}
        title="Atmosphere effects"
      >
        {activeEmojis || "🌫"} ▾
      </button>

      {open && (
        <div className={styles.panel}>
          <div className={styles.panelTitle}>Atmosphere</div>
          {EFFECTS.map(({ key, emoji, label }) => (
            <div key={key} className={styles.effectRow}>
              <div className={styles.effectTop}>
                <span className={styles.effectLabel}>{emoji} {label}</span>
                <button
                  className={`${styles.toggle} ${atmosphere[key].active ? styles.toggleOn : ""}`}
                  onClick={() => toggleEffect(key)}
                  aria-label={`Toggle ${label}`}
                />
              </div>
              <div
                className={`${styles.intensityRow} ${
                  atmosphere[key].active ? styles.intensityActive : ""
                }`}
              >
                <span className={styles.intensityLabel}>
                  {localIntensity[key]}%
                </span>
                <input
                  type="range"
                  min={0}
                  max={100}
                  value={localIntensity[key]}
                  disabled={!atmosphere[key].active}
                  className={styles.slider}
                  onChange={(e) =>
                    handleSliderChange(key, Number(e.target.value))
                  }
                  onMouseUp={(e) =>
                    handleSliderCommit(
                      key,
                      Number((e.target as HTMLInputElement).value),
                    )
                  }
                  onTouchEnd={(e) =>
                    handleSliderCommit(
                      key,
                      Number((e.currentTarget as HTMLInputElement).value),
                    )
                  }
                />
              </div>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}
```

- [ ] **Step 2: Verify TypeScript compiles**

```bash
cd client && npx tsc --noEmit
```

Expected: no errors.

- [ ] **Step 3: Commit**

```bash
git add client/src/components/AtmospherePanel.tsx
git commit -m "feat: add AtmospherePanel component with toggles and intensity sliders"
```

---

### Task 6: Update CampaignView

**Files:**
- Modify: `client/src/pages/CampaignView.tsx`

- [ ] **Step 1: Update imports**

In `CampaignView.tsx`, replace the existing FogOverlay import block with:

```typescript
import FogOverlay from "../components/FogOverlay";
import AtmospherePanel from "../components/AtmospherePanel";
import ParticleOverlay from "../components/ParticleOverlay";
import type { AtmosphereState } from "../lib/atmosphereTypes";
import { defaultAtmosphere } from "../lib/atmosphereTypes";
```

- [ ] **Step 2: Replace fogActive state**

Find and remove:
```typescript
const [fogActive, setFogActive] = useState(false);
```

Add in its place:
```typescript
const [atmosphere, setAtmosphere] = useState<AtmosphereState>(defaultAtmosphere);
```

- [ ] **Step 3: Add atmosphere change handler**

After the state declarations, add:

```typescript
function handleAtmosphereChange(next: AtmosphereState) {
  setAtmosphere(next);
  socket.emit("atmosphere:update", { campaignId, ...next });
}
```

- [ ] **Step 4: Update socket listener**

Find the existing `atmosphere:update` listener:

```typescript
socket.on("atmosphere:update", (data: { fog: boolean }) => {
  setFogActive(data.fog);
});
```

Replace with:

```typescript
socket.on("atmosphere:update", (data: AtmosphereState) => {
  setAtmosphere(data);
});
```

Also update the `socket.off` call:

```typescript
socket.off("atmosphere:update");
```

(No change needed — it's already correct.)

- [ ] **Step 5: Replace fog button in JSX with AtmospherePanel**

Find in JSX:

```tsx
<button
  className={`${styles.fogBtn} ${fogActive ? styles.fogBtnActive : ""}`}
  onClick={() => {
    const next = !fogActive;
    setFogActive(next);
    socket.emit("atmosphere:update", {
      campaignId,
      fog: next,
    });
  }}
  title={fogActive ? "Clear fog" : "Summon fog"}
>
  🌫
</button>
```

Replace with:

```tsx
<AtmospherePanel
  atmosphere={atmosphere}
  onAtmosphereChange={handleAtmosphereChange}
/>
```

- [ ] **Step 6: Update FogOverlay and add ParticleOverlay**

Find near the bottom of the JSX:

```tsx
<FogOverlay active={fogActive} />
```

Replace with:

```tsx
<FogOverlay active={atmosphere.fog.active} />
<ParticleOverlay atmosphere={atmosphere} />
```

- [ ] **Step 7: Verify TypeScript compiles**

```bash
cd client && npx tsc --noEmit
```

Expected: no errors. Common issue: if `fogBtn`/`fogBtnActive` CSS classes are now unused in the CSS module but referenced nowhere, TypeScript won't complain — leave them in the CSS for now or remove if desired.

- [ ] **Step 8: Commit**

```bash
git add client/src/pages/CampaignView.tsx
git commit -m "feat: wire AtmospherePanel and ParticleOverlay into CampaignView"
```

---

### Task 7: Update server socket type

**Files:**
- Modify: `server/src/socket.ts`

- [ ] **Step 1: Update the atmosphere:update handler type**

In `server/src/socket.ts`, find:

```typescript
socket.on(
  "atmosphere:update",
  (data: { campaignId: number; fog: boolean }) => {
    io.to(`campaign:${data.campaignId}`).emit("atmosphere:update", {
      fog: data.fog,
    });
  },
);
```

Replace with:

```typescript
interface EffectState {
  active: boolean;
  intensity: number;
}

interface AtmosphereUpdateData {
  campaignId: number;
  fog: EffectState;
  fire: EffectState;
  rain: EffectState;
  embers: EffectState;
}

socket.on("atmosphere:update", (data: AtmosphereUpdateData) => {
  const { campaignId, ...atmosphere } = data;
  io.to(`campaign:${campaignId}`).emit("atmosphere:update", atmosphere);
});
```

Place the `EffectState` and `AtmosphereUpdateData` interfaces at the top of the `socket.ts` file (or just above the handler — either works since this file has no other shared types).

- [ ] **Step 2: Verify server TypeScript compiles**

```bash
cd server && npx tsc --noEmit
```

Expected: no errors.

- [ ] **Step 3: Commit**

```bash
git add server/src/socket.ts
git commit -m "feat: update atmosphere:update socket type for full effect state"
```

---

### Task 8: Visual verification

**Files:** none

- [ ] **Step 1: Start the server and client**

In two terminals:

```bash
# Terminal 1
cd server && npm run dev

# Terminal 2
cd client && npm run dev
```

- [ ] **Step 2: Open the app and navigate to a campaign**

Open `http://localhost:5173` (or whatever Vite port). Open or create a campaign.

- [ ] **Step 3: Verify the fog button is replaced**

The header should show a single `🌫 ▾` button where the fog button was. Clicking it should open a popover with 4 effect rows (Fog, Fire, Rain, Embers), each with a toggle and a dimmed slider.

- [ ] **Step 4: Test fog (CSS)**

Toggle Fog on. The CSS fog overlay should drift across the screen (unchanged from before). Toggle it off — fog clears.

- [ ] **Step 5: Test fire**

Toggle Fire on. Particles should rise from the bottom — orange/red/yellow sparks. Adjust the slider and release — particle density should visibly change. Toggle off — particles stop.

- [ ] **Step 6: Test rain**

Toggle Rain on. Blue-ish particles should fall from the top. High intensity = heavy rain. Toggle off.

- [ ] **Step 7: Test embers**

Toggle Embers on. Slow-drifting amber dots should float upward. Toggle off.

- [ ] **Step 8: Test stacking**

Enable Fog + Embers simultaneously. Both should render correctly — fog CSS overlay above the ember particles (z-index 9998 vs 9997).

- [ ] **Step 9: Test socket sync**

Open a second browser tab on the same campaign. Toggle fire on in one tab — verify it appears in the other tab. Adjust intensity and release slider — verify it syncs to the other tab.

- [ ] **Step 10: Commit if any tweaks were made**

```bash
git add -p   # stage only intentional changes
git commit -m "fix: tweak particle configs after visual verification"
```

If particle effects look poor out of the box, adjust the count/speed/color values in `particleConfigs.ts`. The tsParticles config reference is at https://particles.js.org/docs/
