---
name: frontend-observability
description: A portable, framework-agnostic field-side observability system for any React or React Native app. Establishes one typed event taxonomy (canonical event-name constants, never inline strings), a best-effort non-blocking provider fan-out so a failing or absent analytics provider can never...
risk: critical
source: https://github.com/stareezy-1/frontend-architecture-skill/tree/main/skills/frontend-observability
source_repo: stareezy-1/frontend-architecture-skill
source_type: community
date_added: 2026-07-01
license: MIT
license_source: https://github.com/stareezy-1/frontend-architecture-skill/blob/main/LICENSE
---

# Frontend Observability (the field side)
## When to Use

Use this skill when you need a portable, framework-agnostic field-side observability system for any React or React Native app. Establishes one typed event taxonomy (canonical event-name constants, never inline strings), a best-effort non-blocking provider fan-out so a failing or absent analytics provider can never...

> Portable skill — readable by Claude Code, OpenCode, Codex, Cursor, Windsurf, and others.
> This skill describes a **field-side observability system** — event taxonomy, provider fan-out,
> real-user vitals, error reporting, consent — not a dashboard or a specific vendor. It is the
> **field complement to the frontend-lighthouse skill**: Lighthouse is the _lab_ gate (synthetic,
> pre-merge); this is the _field_ (what real users actually experience). It lives in a
> `services/analytics/` module per the **frontend-architecture** skill.

The goal: you can answer "what are real users doing, and what are they experiencing?" — with a
**typed event vocabulary** (no stringly-typed `track("clicked_thing")` scattered everywhere), a
fan-out that is **best-effort** (a broken provider never breaks the app), real **Core Web Vitals
from the field**, and **consent** respected before anything fires.

---

## 0. The five core ideas

1. **Events are a typed vocabulary.** Event names are canonical constants with a union type — never inline string literals. The taxonomy is reviewable in one file and the compiler rejects typos.
2. **Fan-out is best-effort and non-blocking.** `track()` dispatches to every provider, each in its own try/catch. A missing global, a thrown provider, an unloaded script — none can throw into the caller or stop the other providers.
3. **One entry point, SSR-safe.** A single `track(event, props)` is the only way to record. It's reached through a context hook that no-ops outside a provider and on the server, so instrumented components render safely anywhere.
4. **Field vitals complement lab budgets.** Real-user LCP/INP/CLS are reported to the same fan-out. Lighthouse proves the build _can_ be fast; field vitals prove it _is_ — together they close the loop.
5. **Consent gates everything.** No telemetry (events, vitals, error reports with PII) fires before opt-in. Consent state is checked at the fan-out boundary, not sprinkled through call sites.

---

## 1. Directory layout

The system is one service module plus its constants (per frontend-architecture).

```
src/
├── constants/
│   └── analytics.ts           ← canonical event names + AnalyticsEvent union
├── services/analytics/
│   ├── index.ts               ← barrel: track, adapters, types
│   ├── track.ts               ← the best-effort fan-out (single entry point)
│   ├── adapters.ts            ← one (event, props) => void per provider, window-guarded
│   ├── web-vitals.ts          ← report real-user LCP/INP/CLS into track()
│   └── consent.ts             ← consent gate read by the fan-out
├── providers/
│   └── AnalyticsProvider.tsx  ← 'use client' context exposing useAnalytics().track
└── error/
    └── ErrorBoundary.tsx      ← reports caught render errors via the fan-out
```

---

## 2. The event taxonomy (typed, never inline)

One file owns every event name. Components reference constants; the union type makes typos a compile
error and the catalog a single source of truth.

```ts
// constants/analytics.ts
export const ANALYTICS_EVENTS = {
  PROJECT_CLICK: "project_click",
  GITHUB_CLICK: "github_click",
  RESUME_DOWNLOAD: "resume_download",
  CONTACT_SUBMISSION: "contact_submission",
} as const;

export type AnalyticsEvent =
  (typeof ANALYTICS_EVENTS)[keyof typeof ANALYTICS_EVENTS];
```

```ts
// CORRECT — typed constant, autocompletes, can't typo
track(ANALYTICS_EVENTS.GITHUB_CLICK, { url });

// WRONG — stringly-typed, drifts, no compile check
track("github-click"); // ❌ silently a different event from "github_click"
```

**Hard rules:**

- No inline event-name strings anywhere; only `ANALYTICS_EVENTS.*`.
- Event names are snake_case and stable — renaming one breaks historical dashboards, so treat the catalog as a contract.
- Keep `props` shapes small and PII-light (see §6); prefer ids over names, never raw emails.

---

## 3. Best-effort, non-blocking fan-out

`track()` is the single entry point. It iterates the adapter registry, guarding **each** call so one
provider can't affect the caller or the others.

```ts
// services/analytics/track.ts
import type { AnalyticsEvent } from "@/constants/analytics";
import { analyticsAdapters } from "./adapters";
import { hasConsent } from "./consent";

export function track(
  event: AnalyticsEvent,
  props?: Record<string, unknown>,
): void {
  if (!hasConsent()) return; // §6 — nothing fires before opt-in
  for (const adapter of analyticsAdapters) {
    try {
      adapter(event, props);
    } catch {
      /* best-effort: a failing/absent provider must never throw into the
         caller or block dispatch to the remaining providers. */
    }
  }
}
```

Each adapter is a tiny `(event, props) => void` that **guards its provider global** — it no-ops on
the server (no `window`) and when the provider script is absent, so a missing or unloaded provider
never throws.

```ts
// services/analytics/adapters.ts
export type AnalyticsAdapter = (
  event: AnalyticsEvent,
  props?: Record<string, unknown>,
) => void;

export const googleAnalyticsAdapter: AnalyticsAdapter = (event, props) => {
  const w =
    typeof window !== "undefined" ? (window as AnalyticsGlobals) : undefined;
  if (!w || typeof w.gtag !== "function") return; // SSR-safe + absent-safe
  w.gtag("event", event, props ?? {});
};

export const clarityAdapter: AnalyticsAdapter = (event) => {
  const w =
    typeof window !== "undefined" ? (window as AnalyticsGlobals) : undefined;
  if (!w || typeof w.clarity !== "function") return;
  w.clarity("event", event);
};

// The registry track() fans out across. Exported + mutable so tests can swap
// in a recording sink to assert dispatch.
export const analyticsAdapters: AnalyticsAdapter[] = [
  googleAnalyticsAdapter,
  clarityAdapter,
  firebaseAdapter,
  // posthogAdapter, openPanelAdapter, …
];
```

### 3.1 Firebase Analytics — one adapter, two platforms

Firebase Analytics ships two SDKs that share the **same `logEvent(name, params)` contract**, so a
single conceptual adapter covers both web and React Native — only the import and the "is it
available?" guard differ. On web the adapter never imports the SDK at module top level (it's
browser-only and async), so it stays SSR-safe.

```ts
// services/analytics/adapters.firebase.web.ts — Firebase JS SDK (web)
import type { Analytics } from "firebase/analytics";
import type { AnalyticsAdapter } from "./adapters";

// Held after a lazy, browser-only init (below) so the adapter stays synchronous + SSR-safe.
let analytics: Analytics | undefined;
export function setFirebaseAnalytics(instance: Analytics): void {
  analytics = instance;
}

export const firebaseAdapter: AnalyticsAdapter = (event, props) => {
  if (typeof window === "undefined" || !analytics) return; // SSR-safe + not-yet-ready safe
  void import("firebase/analytics").then(({ logEvent }) =>
    logEvent(analytics!, event, props),
  );
};
```

```ts
// services/analytics/firebase.init.ts — lazy, browser-only init (web)
import { initializeApp, getApps } from "firebase/app";
import { getAnalytics, isSupported } from "firebase/analytics";
import { setFirebaseAnalytics } from "./adapters.firebase.web";
import { FIREBASE_CONFIG } from "@/constants/analytics";

export async function initFirebaseAnalytics(): Promise<void> {
  if (typeof window === "undefined") return; // never on the server
  if (!(await isSupported())) return; // unsupported browser → no-op
  const app = getApps()[0] ?? initializeApp(FIREBASE_CONFIG);
  setFirebaseAnalytics(getAnalytics(app)); // adapter goes live after this
}
```

```ts
// services/analytics/adapters.firebase.native.ts — @react-native-firebase/analytics (RN / Expo)
import analytics from "@react-native-firebase/analytics";
import type { AnalyticsAdapter } from "./adapters";

export const firebaseAdapter: AnalyticsAdapter = (event, props) => {
  // RN: no window; the native module is present once the app boots.
  void analytics().logEvent(event, props);
};
```

**Same shape, two files.** Resolve the platform variant by file extension
(`adapters.firebase.native.ts` via Metro's `.native.ts` resolution, or a `Platform.OS` switch) so
the **registry, `track` fan-out, consent gate, taxonomy, and `useAnalytics` hook never change**
across platforms. Firebase's event-name rules (snake_case, lowercase, ≤ 40 chars) line up with the
taxonomy rules in §2, so the canonical `ANALYTICS_EVENTS` constants are valid Firebase event names
as-is. Gate `initFirebaseAnalytics()` on consent (§6) — Firebase also exposes
`setAnalyticsCollectionEnabled(false)` to harden the opt-out.

**Why this shape:** analytics is the _last_ thing that should crash an app. A vendor script that
fails to load, a global that isn't there yet, an adapter that throws on a malformed prop — all are
contained. The registry being exported and mutable makes dispatch unit-testable without mounting any
provider.

---

## 4. The provider + hook (SSR-safe entry)

A `'use client'` context exposes `track` through `useAnalytics()`. Outside a provider (tests,
server) it returns a **no-op**, so instrumented components never throw in isolation.

```tsx
// providers/AnalyticsProvider.tsx
"use client";
import { createContext, useContext, useMemo, type ReactNode } from "react";
import { track as trackEvent } from "@/services/analytics";
import type { AnalyticsEvent } from "@/constants/analytics";

interface AnalyticsContextValue {
  track: (event: AnalyticsEvent, props?: Record<string, unknown>) => void;
}
const AnalyticsContext = createContext<AnalyticsContextValue | null>(null);

export function AnalyticsProvider({ children }: { children: ReactNode }) {
  // track is module-level and stable → memoize once, never re-render consumers.
  const value = useMemo<AnalyticsContextValue>(
    () => ({ track: trackEvent }),
    [],
  );
  return (
    <AnalyticsContext.Provider value={value}>
      {children}
    </AnalyticsContext.Provider>
  );
}

const NOOP: AnalyticsContextValue = { track: () => undefined };
export function useAnalytics(): AnalyticsContextValue {
  return useContext(AnalyticsContext) ?? NOOP; // safe outside a provider / on server
}
```

```tsx
// a tracked leaf — Server Components can't use the hook, so wrap in a thin client component
"use client";
export function TrackedGithubLink({ href, children }: Props) {
  const { track } = useAnalytics();
  return (
    <a
      href={href}
      onClick={() => track(ANALYTICS_EVENTS.GITHUB_CLICK, { url: href })}
    >
      {children}
    </a>
  );
}
```

The provider does **no work during render** — `track` is stable and adapters guard their own
`window` access — so it's safe to mount at the root, including in SSR/RSC trees.

---

## 5. Real-user Core Web Vitals (the lab/field loop)

Report field vitals through the **same fan-out**. This is the complement to the lighthouse skill:
the lab gate sets the budget; the field tells you whether real users hit it.

```ts
// services/analytics/web-vitals.ts
import { onLCP, onINP, onCLS, onFCP, onTTFB, type Metric } from "web-vitals";
import { track } from "./track";

export function reportWebVitals(): void {
  const send = (m: Metric) =>
    track("web_vital" as AnalyticsEvent, {
      name: m.name, // LCP | INP | CLS | FCP | TTFB
      value: Math.round(m.name === "CLS" ? m.value * 1000 : m.value),
      rating: m.rating, // good | needs-improvement | poor
      id: m.id,
    });
  onLCP(send);
  onINP(send);
  onCLS(send);
  onFCP(send);
  onTTFB(send);
}
```

- Call `reportWebVitals()` once on the client (e.g. in the analytics provider's effect, or Next.js `useReportWebVitals`).
- Use the **same metrics and thresholds** as the lighthouse skill (LCP ≤ 2500, INP ≤ 200, CLS ≤ 0.1) so lab and field speak the same language.
- Lab budget green + field "poor" = a gap between your test conditions and real devices/networks — exactly what field RUM exists to reveal.

---

## 6. Consent and privacy gating

Telemetry fires only after opt-in, checked **once at the fan-out boundary** (§3) — not duplicated at
every call site.

```ts
// services/analytics/consent.ts
let granted = false; // hydrate from a stored consent cookie/localStorage on init
export function setConsent(value: boolean): void {
  granted = value;
}
export function hasConsent(): boolean {
  return granted;
}
```

**Hard rules:**

- `track()` early-returns when consent is absent — no events, no vitals, no error PII before opt-in.
- Keep `props` PII-light: ids and enums, not emails/names/free text. Treat anything user-entered as sensitive.
- Respect "Do Not Track" / regional regimes (GDPR/CCPA) by defaulting consent to `false` where required.
- Error reports must scrub PII before leaving the device.

---

## 7. Error reporting at boundaries

Caught render errors and unhandled rejections go through the same fan-out (or a dedicated Sentry
adapter), at **deliberate boundaries** — not a global swallow.

```tsx
// error/ErrorBoundary.tsx (essence)
componentDidCatch(error: Error, info: ErrorInfo) {
  track("client_error" as AnalyticsEvent, {
    message: error.message, component: info.componentStack?.split("\n")[1]?.trim(),
  }); // or sentryAdapter(error, info)
}
```

- Place boundaries at route/segment level (per the frontend-architecture page-directory model), so a crash degrades one surface, not the app.
- Pair with the data layer's typed `ApiError` (frontend-data-contracts §6): report _unexpected_ errors; expected ones (validation, 404) are handled, not reported as crashes.

---

## 8. Provider & framework adapters

The taxonomy + fan-out are constant; each provider is one window-guarded adapter.

| Provider               | Adapter call                                                                 |
| ---------------------- | ---------------------------------------------------------------------------- |
| **Firebase (web)**     | `logEvent(analytics, name, props)` (`firebase/analytics`, lazy browser init) |
| **Firebase (RN/Expo)** | `analytics().logEvent(name, props)` (`@react-native-firebase/analytics`)     |
| **GA4**                | `window.gtag("event", name, props)`                                          |
| **Microsoft Clarity**  | `window.clarity("event", name)`                                              |
| **PostHog**            | `window.posthog?.capture(name, props)`                                       |
| **OpenPanel**          | `op("track", name, props)` or `op.track(name, props)`                        |
| **Sentry**             | `Sentry.captureException(error)` (error adapter)                             |

| Framework                | Wiring                                                                                |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Next.js**              | `AnalyticsProvider` in the root layout (client boundary); call `initFirebaseAnalytics()` in a client effect; vitals via `useReportWebVitals`.                                                                                |
| **React + Vite / Remix** | provider at app root; `initFirebaseAnalytics()` + `reportWebVitals()` in a top-level effect.                                                                                |
| **Expo / React Native**  | swap the web-vitals source for RN performance APIs and the web provider scripts for native SDKs (**`@react-native-firebase/analytics`**, Amplitude, PostHog-RN); the **taxonomy, `track` fan-out, consent gate, and `useAnalytics` hook are unchanged**. The Firebase adapter is the same shape — it guards the native module instead of `window` (see §3.1). |

---

## 9. Conventions checklist (enforce in review)

- [ ] Event names are canonical constants with a union type — zero inline event strings.
- [ ] `track()` is the single entry point; reached via `useAnalytics()` (no-op outside a provider/SSR).
- [ ] Every adapter guards its provider global and no-ops when absent or on the server.
- [ ] Each adapter call is individually try/caught — one provider can't break the app or the others.
- [ ] Consent is checked once at the fan-out; nothing fires before opt-in.
- [ ] `props` are PII-light (ids/enums, not emails/names); error reports scrub PII.
- [ ] Real-user Web Vitals report through the same fan-out, using the lighthouse skill's metrics/thresholds.
- [ ] Error boundaries are placed per route/segment and report unexpected errors only.
- [ ] The provider does no render-time work; the context value is memoized/stable.
- [ ] Mutating the adapter registry (tests) is the dispatch-observation seam — no real provider mounted in tests.

---

## 10. How to apply this skill

**Adding analytics to a project:** create `constants/analytics.ts` (taxonomy), `services/analytics/`
(track + adapters + consent), and `AnalyticsProvider`. Mount the provider at the root; wrap tracked
leaves in thin client components.

**Adding an event:** add a constant to `ANALYTICS_EVENTS`, then `track(ANALYTICS_EVENTS.NEW_ONE, props)`
at the interaction. Never inline the string.

**Wiring Firebase Analytics (web + RN):** add a `firebaseAdapter` to the registry using the
platform-resolved files in §3.1 (`firebase/analytics` on web behind a lazy browser-only
`initFirebaseAnalytics()`; `@react-native-firebase/analytics` on native). Gate init on consent. The
taxonomy and fan-out are untouched — Firebase is just one more entry in `analyticsAdapters`.

**Closing the lab/field loop:** wire `reportWebVitals()` and compare field ratings against the
lighthouse skill's budgets; investigate any "lab green / field poor" gap.

**Reviewing observability:** run the checklist in §9. The highest-value catches are inline event
strings (taxonomy drift), an un-guarded adapter (a provider that can crash the app), and telemetry
firing before consent.

---

## Publishing / installing this skill

This skill follows the Anthropic `SKILL.md` format and is portable across agents.

1. Keep it under `skills/frontend-observability/SKILL.md` in a public GitHub repo.
2. Keep the frontmatter `name` and high-signal `description` — discovery indexes match against it.
3. Install with: `npx skills add <org>/<repo> --skill "frontend-observability"`.
4. Non-`SKILL.md` agents can be pointed here from `AGENTS.md` / `CLAUDE.md`; Kiro can mirror it as a steering file.

## Limitations

- Use this skill only when the task clearly matches its upstream source and local project context.
- Verify commands, generated code, dependencies, credentials, and external service behavior before applying changes.
- Do not treat examples as a substitute for environment-specific tests, security review, or user approval for destructive or costly actions.


---
name: frontend-optimistic-mutations
description: A portable, framework-agnostic discipline for the write path of any React or React Native app using a query/cache layer. Codifies the optimistic-update lifecycle (cancel in-flight queries → snapshot every affected cache → patch instantly → roll back verbatim on error → invalidate on...
risk: critical
source: https://github.com/stareezy-1/frontend-architecture-skill/tree/main/skills/frontend-optimistic-mutations
source_repo: stareezy-1/frontend-architecture-skill
source_type: community
date_added: 2026-07-01
license: MIT
license_source: https://github.com/stareezy-1/frontend-architecture-skill/blob/main/LICENSE
---

# Frontend Optimistic Mutations (the write path)
## When to Use

Use this skill when you need a portable, framework-agnostic discipline for the write path of any React or React Native app using a query/cache layer. Codifies the optimistic-update lifecycle (cancel in-flight queries → snapshot every affected cache → patch instantly → roll back verbatim on error → invalidate on...

> Portable skill — readable by Claude Code, OpenCode, Codex, Cursor, Windsurf, and others.
> This skill describes the **discipline of the write path** — optimistic updates, rollback,
> idempotency, cache coherence — not a UI library or a styling system. It builds directly on the
> **frontend-data-contracts** skill (writes go through the typed client) and the
> **frontend-architecture** skill (mutations live in `modules/{feature}/hooks/`, keyed by a factory).

The goal: a write **feels instant** (the UI reflects it before the server confirms), is **safe**
(a failure restores the exact prior state, and a retry never double-charges), and leaves the cache
**coherent** (the detail view and every list page agree). All three at once — that's the craft.

---

## 0. The five core ideas

1. **The optimistic lifecycle is fixed.** cancel → snapshot → patch → (error: roll back) → (settle: invalidate). Every optimistic mutation follows the same five beats.
2. **Roll back verbatim.** On failure, restore the exact snapshot taken before the patch — not a "best guess" re-derivation. Keep the snapshot in mutation context.
3. **Idempotency is generated once, not per attempt.** The key is created at form init (or first intent), so a network retry replays the original server response instead of performing the action twice.
4. **Caches move in lock-step.** A status change patches the detail cache **and** every list page that contains the entity, so badges never disagree across surfaces.
5. **Server state never enters the client store.** Optimistic state lives in the query cache, not Zustand/Redux. The cache is the single source of truth for server data (per frontend-architecture §4).

---

## 1. When to be optimistic (and when not)

| Situation                                                                     | Strategy                                                                                |
| ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| High-confidence, low-conflict write (toggle status, like, mark-paid, reorder) | **Optimistic** — patch immediately, roll back on error.                                                                        |
| Create that returns a server-generated id/number/total                        | **Pending state**, then `setQueryData` from the server response. A temporary optimistic row is optional; reconcile on success. |
| Destructive or hard-to-reverse write (delete with cascade, send money)        | **Confirm first**, then optimistic _or_ pending — never silent-optimistic.                                                     |
| Write whose result the user can't see yet (background job)                    | **Pending + toast**, invalidate when done. No optimistic patch.                                                                |

Optimism is a UX tool for writes you're confident will succeed. If failure is common or expensive to
undo, prefer a pending state.

---

## 2. The optimistic lifecycle (TanStack Query)

The canonical shape. Each beat has a job; skipping one breaks correctness.

```ts
// modules/invoice/hooks/useInvoiceMutations.ts
interface MarkPaidContext {
  previousInvoice: Invoice | undefined; // detail snapshot
  previousLists: Array<[readonly unknown[], InvoiceListResponse]>; // every list page snapshot
}

export function useMarkInvoicePaid() {
  const queryClient = useQueryClient();
  const notifyError = useApiErrorToast();

  return useMutation<Invoice, ApiError, { id: InvoiceId }, MarkPaidContext>({
    mutationFn: ({ id }) => apiClient.post<Invoice>(INVOICE_API.markPaid(id)),

    // 1 + 2 + 3: cancel in-flight reads, snapshot, patch
    onMutate: async ({ id }) => {
      await queryClient.cancelQueries({ queryKey: invoiceKeys.all }); // (1) no late refetch clobber

      const detailKey = invoiceKeys.detail(id);
      const previousInvoice = queryClient.getQueryData<Invoice>(detailKey); // (2) snapshot detail
      if (previousInvoice) {
        queryClient.setQueryData<Invoice>(detailKey, {
          // (3) patch detail
          ...previousInvoice,
          status: InvoiceStatus.Paid,
        });
      }

      const previousLists: MarkPaidContext["previousLists"] = [];
      for (const [key, list] of queryClient.getQueriesData<InvoiceListResponse>(
        {
          queryKey: invoiceKeys.lists(),
        },
      )) {
        if (!list) continue;
        previousLists.push([key, list]); // (2) snapshot each page
        if (!list.invoices.some((i) => i.id === id)) continue;
        queryClient.setQueryData<InvoiceListResponse>(key, {
          // (3) patch matching row
          ...list,
          invoices: list.invoices.map((i) =>
            i.id === id ? { ...i, status: InvoiceStatus.Paid } : i,
          ),
        });
      }
      return { previousInvoice, previousLists };
    },

    // 4: roll back verbatim
    onError: (error, { id }, ctx) => {
      if (ctx?.previousInvoice)
        queryClient.setQueryData(invoiceKeys.detail(id), ctx.previousInvoice);
      for (const [key, list] of ctx?.previousLists ?? [])
        queryClient.setQueryData(key, list);
      notifyError(error);
    },

    // 5: invalidate so authoritative server state (paidAt, aggregates) refetches
    onSettled: (_d, _e, { id }) => {
      void queryClient.invalidateQueries({ queryKey: invoiceKeys.detail(id) });
      void queryClient.invalidateQueries({ queryKey: invoiceKeys.lists() });
    },
  });
}
```

**Why each beat:**

- **cancel** — without it, a query that was already in flight can resolve _after_ your patch and overwrite the optimistic state.
- **snapshot** — the only safe rollback source; never reconstruct prior state by hand.
- **patch** — the instant UX; mutate detail **and** lists together (§4).
- **roll back** — restore snapshots verbatim, then surface the typed `ApiError`.
- **invalidate on settle** — success or failure, refetch so server-computed fields (timestamps, totals) are authoritative. Settle, not just success: a failed write may still have changed server state.

---

## 3. Non-optimistic writes: create with server-owned fields

A create that returns an id/number/total can't be fully optimistic. Run it as a pending mutation and
seed the cache from the response.

```ts
export function useCreateInvoice() {
  const queryClient = useQueryClient();
  return useMutation<Invoice, ApiError, CreateInvoiceInput>({
    mutationFn: ({ document, idempotencyKey }) =>
      apiClient.post<Invoice>("/invoices", document, { idempotencyKey }),
    onSuccess: (invoice) => {
      queryClient.setQueryData(invoiceKeys.detail(invoice.id), invoice); // seed detail
      void queryClient.invalidateQueries({ queryKey: invoiceKeys.lists() }); // refresh lists
    },
    onError: (error) => notifyError(error),
  });
}
```

---

## 4. Cache coherence (lock-step detail + lists)

A single entity appears in many caches: its detail, and every filtered/paginated list page. An
optimistic patch must touch **all of them** or surfaces disagree. Use a **hierarchical key factory**
(from frontend-architecture §4.3) so you can target precisely.

```ts
export const invoiceKeys = {
  all: ["invoices"] as const,
  lists: () => [...invoiceKeys.all, "list"] as const,
  list: (p: IListParams) => [...invoiceKeys.lists(), p] as const,
  detail: (id: InvoiceId) => [...invoiceKeys.all, "detail", id] as const,
} as const;
```

- `getQueriesData({ queryKey: invoiceKeys.lists() })` enumerates **every** cached list page so you can patch each.
- `invalidateQueries({ queryKey: invoiceKeys.lists() })` refreshes them all on settle.
- `detail(id)` targets exactly one entity.

Snapshot **each** page you touch (keyed by its exact query key) so rollback restores every page
verbatim, not just the one currently on screen.

---

## 5. Idempotency (safe retries on money-moving writes)

A retried POST must not perform the action twice. Generate the key **once, at form init** (or first
user intent), carry it through retries, and let the client send it as a header. The server replays
the original response for a repeated key within its window.

```ts
// at form initialisation — stable for the lifetime of this attempt
const idempotencyKey = useMemo(() => crypto.randomUUID(), []);

// mutation forwards it; the typed client puts it on the header
apiClient.post<Invoice>("/invoices", document, { idempotencyKey });
```

**Hard rules:**

- Generate the key at **intent time**, not inside `mutationFn` (which re-runs per retry → defeats the purpose).
- The data client auto-detects financial routes and injects the header; an explicit key always wins so retries replay.
- Pair idempotency with **disabled-while-pending** UI so the user can't fire a second distinct write.

---

## 6. Retry policy

- **Reads:** retry a few times with backoff (default in most query libs) — safe and idempotent.
- **Writes:** do **not** auto-retry non-idempotent mutations. Retry only when an idempotency key guarantees replay, or only on network errors (status 0), never on 4xx.
- **Conflicts (409):** don't retry — surface the typed error, invalidate, and let the user re-decide on fresh data.

```ts
useMutation({
  retry: (count, error: ApiError) => error.isNetworkError && count < 2, // network-only, bounded
});
```

---

## 7. Library adapters

The five-beat lifecycle is the same; the hooks differ.

| Library            | Optimistic mechanism                                                                                |
| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **TanStack Query** | `onMutate` (cancel + snapshot + patch) → `onError` (rollback) → `onSettled` (invalidate). The reference shape above.                                       |
| **RTK Query**      | `onQueryStarted`: `updateQueryData` returns a `patchResult`; `await queryFulfilled` and call `patchResult.undo()` in `catch`. `invalidatesTags` on settle. |
| **SWR**            | `mutate(key, optimisticData, { rollbackOnError: true, populateCache, revalidate: true })` — optimistic data + automatic rollback + revalidate.             |

For **React Native**, all three libraries work unchanged; the cache is the source of truth on
native too. Keep mutation hooks DOM-free so they're shareable across web and native.

---

## 8. Conventions checklist (enforce in review)

- [ ] Optimistic mutations follow cancel → snapshot → patch → rollback → invalidate.
- [ ] `onMutate` cancels in-flight queries before patching.
- [ ] Rollback restores the **exact** snapshot from context, not a re-derivation.
- [ ] Detail **and** every affected list page are patched and snapshotted together.
- [ ] `onSettled` invalidates so server-computed fields are refetched (on success _and_ error).
- [ ] Idempotency key is generated at intent time and replayed across retries, not regenerated per attempt.
- [ ] Money-moving / destructive writes confirm first and disable the trigger while pending.
- [ ] Non-idempotent writes don't auto-retry; 409s surface rather than retry.
- [ ] Server state stays in the query cache — never copied into a client store.
- [ ] Query keys come from a hierarchical factory; invalidation is scoped, not global-blunt.

---

## 9. How to apply this skill

**Adding an optimistic mutation:** decide it's safe to be optimistic (§1). Write the five beats
(§2). Identify every cache the entity lives in and patch/snapshot all of them (§4).

**Making a write safe to retry:** generate an idempotency key at form init, thread it through the
mutation, confirm the client sends it (§5), and set a network-only bounded retry (§6).

**Debugging a flicker / wrong-state-after-write:** check that `onMutate` cancels queries (late
refetch clobber) and that `onSettled` invalidates (stale server-computed fields). Check that _all_
list pages were patched, not just the visible one.

**Reviewing the write path:** run the checklist in §8. The highest-value catches are missing
`cancelQueries` (race clobber), partial cache patches (detail/list disagreement), and idempotency
keys generated inside `mutationFn` (no longer protect retries).

---

## Publishing / installing this skill

This skill follows the Anthropic `SKILL.md` format and is portable across agents.

1. Keep it under `skills/frontend-optimistic-mutations/SKILL.md` in a public GitHub repo.
2. Keep the frontmatter `name` and high-signal `description` — discovery indexes match against it.
3. Install with: `npx skills add <org>/<repo> --skill "frontend-optimistic-mutations"`.
4. Non-`SKILL.md` agents can be pointed here from `AGENTS.md` / `CLAUDE.md`; Kiro can mirror it as a steering file.

## Limitations

- Use this skill only when the task clearly matches its upstream source and local project context.
- Verify commands, generated code, dependencies, credentials, and external service behavior before applying changes.
- Do not treat examples as a substitute for environment-specific tests, security review, or user approval for destructive or costly actions.


---
name: frontend-security-coder
description: Expert in secure frontend coding practices specializing in XSS prevention, output sanitization, and client-side security patterns.
risk: critical
source: community
date_added: '2026-02-27'
---

## Use this skill when

- Working on frontend security coder tasks or workflows
- Needing guidance, best practices, or checklists for frontend security coder

## Do not use this skill when

- The task is unrelated to frontend security coder
- You need a different domain or tool outside this scope

## Instructions

- Clarify goals, constraints, and required inputs.
- Apply relevant best practices and validate outcomes.
- Provide actionable steps and verification.
- If detailed examples are required, open `resources/implementation-playbook.md`.

You are a frontend security coding expert specializing in client-side security practices, XSS prevention, and secure user interface development.

## Purpose
Expert frontend security developer with comprehensive knowledge of client-side security practices, DOM security, and browser-based vulnerability prevention. Masters XSS prevention, safe DOM manipulation, Content Security Policy implementation, and secure user interaction patterns. Specializes in building security-first frontend applications that protect users from client-side attacks.

## When to Use vs Security Auditor
- **Use this agent for**: Hands-on frontend security coding, XSS prevention implementation, CSP configuration, secure DOM manipulation, client-side vulnerability fixes
- **Use security-auditor for**: High-level security audits, compliance assessments, DevSecOps pipeline design, threat modeling, security architecture reviews, penetration testing planning
- **Key difference**: This agent focuses on writing secure frontend code, while security-auditor focuses on auditing and assessing security posture

## Capabilities

### Output Handling and XSS Prevention
- **Safe DOM manipulation**: textContent vs innerHTML security, secure element creation and modification
- **Dynamic content sanitization**: DOMPurify integration, HTML sanitization libraries, custom sanitization rules
- **Context-aware encoding**: HTML entity encoding, JavaScript string escaping, URL encoding
- **Template security**: Secure templating practices, auto-escaping configuration, template injection prevention
- **User-generated content**: Safe rendering of user inputs, markdown sanitization, rich text editor security
- **Document.write alternatives**: Secure alternatives to document.write, modern DOM manipulation techniques

### Content Security Policy (CSP)
- **CSP header configuration**: Directive setup, policy refinement, report-only mode implementation
- **Script source restrictions**: nonce-based CSP, hash-based CSP, strict-dynamic policies
- **Inline script elimination**: Moving inline scripts to external files, event handler security
- **Style source control**: CSS nonce implementation, style-src directives, unsafe-inline alternatives
- **Report collection**: CSP violation reporting, monitoring and alerting on policy violations
- **Progressive CSP deployment**: Gradual CSP tightening, compatibility testing, fallback strategies

### Input Validation and Sanitization
- **Client-side validation**: Form validation security, input pattern enforcement, data type validation
- **Allowlist validation**: Whitelist-based input validation, predefined value sets, enumeration security
- **Regular expression security**: Safe regex patterns, ReDoS prevention, input format validation
- **File upload security**: File type validation, size restrictions, virus scanning integration
- **URL validation**: Link validation, protocol restrictions, malicious URL detection
- **Real-time validation**: Secure AJAX validation, rate limiting for validation requests

### CSS Handling Security
- **Dynamic style sanitization**: CSS property validation, style injection prevention, safe CSS generation
- **Inline style alternatives**: External stylesheet usage, CSS-in-JS security, style encapsulation
- **CSS injection prevention**: Style property validation, CSS expression prevention, browser-specific protections
- **CSP style integration**: style-src directives, nonce-based styles, hash-based style validation
- **CSS custom properties**: Secure CSS variable usage, property sanitization, dynamic theming security
- **Third-party CSS**: External stylesheet validation, subresource integrity for stylesheets

### Clickjacking Protection
- **Frame detection**: Intersection Observer API implementation, UI overlay detection, frame-busting logic
- **Frame-busting techniques**: JavaScript-based frame busting, top-level navigation protection
- **X-Frame-Options**: DENY and SAMEORIGIN implementation, frame ancestor control
- **CSP frame-ancestors**: Content Security Policy frame protection, granular frame source control
- **SameSite cookie protection**: Cross-frame CSRF protection, cookie isolation techniques
- **Visual confirmation**: User action confirmation, critical operation verification, overlay detection
- **Environment-specific deployment**: Apply clickjacking protection only in production or standalone applications, disable or relax during development when embedding in iframes

### Secure Redirects and Navigation
- **Redirect validation**: URL allowlist validation, internal redirect verification, domain allowlist enforcement
- **Open redirect prevention**: Parameterized redirect protection, fixed destination mapping, identifier-based redirects
- **URL manipulation security**: Query parameter validation, fragment handling, URL construction security
- **History API security**: Secure state management, navigation event handling, URL spoofing prevention
- **External link handling**: rel="noopener noreferrer" implementation, target="_blank" security
- **Deep link validation**: Route parameter validation, path traversal prevention, authorization checks

### Authentication and Session Management
- **Token storage**: Secure JWT storage, localStorage vs sessionStorage security, token refresh handling
- **Session timeout**: Automatic logout implementation, activity monitoring, session extension security
- **Multi-tab synchronization**: Cross-tab session management, storage event handling, logout propagation
- **Biometric authentication**: WebAuthn implementation, FIDO2 integration, fallback authentication
- **OAuth client security**: PKCE implementation, state parameter validation, authorization code handling
- **Password handling**: Secure password fields, password visibility toggles, form auto-completion security

### Browser Security Features
- **Subresource Integrity (SRI)**: CDN resource validation, integrity hash generation, fallback mechanisms
- **Trusted Types**: DOM sink protection, policy configuration, trusted HTML generation
- **Feature Policy**: Browser feature restrictions, permission management, capability control
- **HTTPS enforcement**: Mixed content prevention, secure cookie handling, protocol upgrade enforcement
- **Referrer Policy**: Information leakage prevention, referrer header control, privacy protection
- **Cross-Origin policies**: CORP and COEP implementation, cross-origin isolation, shared array buffer security

### Third-Party Integration Security
- **CDN security**: Subresource integrity, CDN fallback strategies, third-party script validation
- **Widget security**: Iframe sandboxing, postMessage security, cross-frame communication protocols
- **Analytics security**: Privacy-preserving analytics, data collection minimization, consent management
- **Social media integration**: OAuth security, API key protection, user data handling
- **Payment integration**: PCI compliance, tokenization, secure payment form handling
- **Chat and support widgets**: XSS prevention in chat interfaces, message sanitization, content filtering

### Progressive Web App Security
- **Service Worker security**: Secure caching strategies, update mechanisms, worker isolation
- **Web App Manifest**: Secure manifest configuration, deep link handling, app installation security
- **Push notifications**: Secure notification handling, permission management, payload validation
- **Offline functionality**: Secure offline storage, data synchronization security, conflict resolution
- **Background sync**: Secure background operations, data integrity, privacy considerations

### Mobile and Responsive Security
- **Touch interaction security**: Gesture validation, touch event security, haptic feedback
- **Viewport security**: Secure viewport configuration, zoom prevention for sensitive forms
- **Device API security**: Geolocation privacy, camera/microphone permissions, sensor data protection
- **App-like behavior**: PWA security, full-screen mode security, navigation gesture handling
- **Cross-platform compatibility**: Platform-specific security considerations, feature detection security

## Behavioral Traits
- Always prefers textContent over innerHTML for dynamic content
- Implements comprehensive input validation with allowlist approaches
- Uses Content Security Policy headers to prevent script injection
- Validates all user-supplied URLs before navigation or redirects
- Applies frame-busting techniques only in production environments
- Sanitizes all dynamic content with established libraries like DOMPurify
- Implements secure authentication token storage and management
- Uses modern browser security features and APIs
- Considers privacy implications in all user interactions
- Maintains separation between trusted and untrusted content

## Knowledge Base
- XSS prevention techniques and DOM security patterns
- Content Security Policy implementation and configuration
- Browser security features and APIs
- Input validation and sanitization best practices
- Clickjacking and UI redressing attack prevention
- Secure authentication and session management patterns
- Third-party integration security considerations
- Progressive Web App security implementation
- Modern browser security headers and policies
- Client-side vulnerability assessment and mitigation

## Response Approach
1. **Assess client-side security requirements** including threat model and user interaction patterns
2. **Implement secure DOM manipulation** using textContent and secure APIs
3. **Configure Content Security Policy** with appropriate directives and violation reporting
4. **Validate all user inputs** with allowlist-based validation and sanitization
5. **Implement clickjacking protection** with frame detection and busting techniques
6. **Secure navigation and redirects** with URL validation and allowlist enforcement
7. **Apply browser security features** including SRI, Trusted Types, and security headers
8. **Handle authentication securely** with proper token storage and session management
9. **Test security controls** with both automated scanning and manual verification

## Example Interactions
- "Implement secure DOM manipulation for user-generated content display"
- "Configure Content Security Policy to prevent XSS while maintaining functionality"
- "Create secure form validation that prevents injection attacks"
- "Implement clickjacking protection for sensitive user operations"
- "Set up secure redirect handling with URL validation and allowlists"
- "Sanitize user input for rich text editor with DOMPurify integration"
- "Implement secure authentication token storage and rotation"
- "Create secure third-party widget integration with iframe sandboxing"

## Limitations
- Use this skill only when the task clearly matches the scope described above.
- Do not treat the output as a substitute for environment-specific validation, testing, or expert review.
- Stop and ask for clarification if required inputs, permissions, safety boundaries, or success criteria are missing.


---
name: frontend-seo
description: A portable, framework-agnostic SEO system for any React or React Native-for-web frontend. Centralizes site metadata in one constants module, derives canonical URLs from a single base, builds per-route metadata (title, description, canonical, Open Graph, Twitter/X cards), generates...
risk: critical
source: https://github.com/stareezy-1/frontend-architecture-skill/tree/main/skills/frontend-seo
source_repo: stareezy-1/frontend-architecture-skill
source_type: community
date_added: 2026-07-01
license: MIT
license_source: https://github.com/stareezy-1/frontend-architecture-skill/blob/main/LICENSE
---

# Frontend SEO (portable, builder-based)
## When to Use

Use this skill when you need a portable, framework-agnostic SEO system for any React or React Native-for-web frontend. Centralizes site metadata in one constants module, derives canonical URLs from a single base, builds per-route metadata (title, description, canonical, Open Graph, Twitter/X cards), generates...

> Portable skill — readable by Claude Code, OpenCode, Codex, Cursor, Windsurf, and others.
> This skill describes an **SEO system** — a set of pure builder functions plus a thin
> framework adapter — not a component library or a visual style.
> It pairs with the **frontend-architecture** skill: the SEO system lives in a single
> service module (`services/seo/`) and is consumed through one barrel.

The goal: every route ships **correct, consistent, machine-readable metadata** without
anyone copy-pasting `<meta>` tags. Site identity lives in **one** constants module, URLs are
**always absolute and canonical**, and search engines get a **sitemap, robots rules, an RSS
feed, and typed JSON-LD** derived from the same content the app already renders.

---

## 0. The five core ideas

1. **One source of truth for identity.** Site URL, name, description, keywords, author, social handles, OG image, and verification tokens live in a single `constants/seo` module. Nothing about the site's identity is hardcoded anywhere else.
2. **URLs are always absolute and canonical.** A single `canonicalUrl(path)` function turns any path into an absolute, trailing-slash-normalized URL. Every sitemap entry, RSS link, OG URL, and JSON-LD `@id` flows through it.
3. **Builders are pure; the adapter is thin.** Metadata, sitemap, robots, RSS, and JSON-LD are produced by pure functions that take data and return plain objects. Only one small function touches the framework's metadata type. Pure functions are trivially unit-testable.
4. **Structured data is typed and reused.** JSON-LD objects share a `JsonLd` type and a small set of `schema.org` builders (`Person`, `WebSite`, `BlogPosting`, `CreativeWork`, `BreadcrumbList`, `FAQPage`). Entities cross-reference each other by stable `@id`.
5. **Discovery surfaces are generated from content.** `sitemap.xml`, `robots.txt`, and the RSS feed are built from the same content collections the app renders — never maintained by hand, never drifting.

Everything below is the mechanical application of these five ideas.

---

## 1. Directory layout

The SEO system is one service module plus its constants and types. It slots directly into the
`frontend-architecture` shape (`shared/` or `services/`).

```
src/
├── constants/
│   └── seo.ts                  ← SINGLE source of truth for site identity
├── types/
│   └── seo.ts                  ← SchemaType, RouteDescriptor, SitemapEntry,
│                                  RobotsConfig, RssItem, Redirect, JsonLd
├── services/seo/
│   ├── index.ts                ← barrel: canonicalUrl, buildMetadata,
│   │                              sitemapEntries, robots, rssItems,
│   │                              structuredData, redirects
│   └── structured-data.ts      ← per-type JSON-LD builders (Person, WebSite, …)
└── app/ (or routes/)           ← THIN adapter: route files call the builders
    ├── layout.tsx              ← global default metadata (from constants/seo)
    ├── sitemap.ts              ← mounts sitemapEntries()
    ├── robots.ts               ← mounts robots()
    └── feed.xml/route.ts       ← mounts rssItems()
```

Rule of thumb: **builders never import the framework** (except the one `buildMetadata` adapter);
**route files never build SEO data inline** — they call a builder and mount the result.

---

## 2. One source of truth for identity (`constants/seo`)

Everything about the site's identity is a named constant. No bare strings scattered across
route files, no second copy of the description, no hardcoded base URL.

```ts
// constants/seo.ts
export const SITE_URL = "https://example.com"; // no trailing slash
export const SITE_NAME = "Jane Doe";
export const SITE_HANDLE = "@janedoe";
export const SITE_LOCALE = "en_US";

export const SITE_TITLE_DEFAULT = "Jane Doe — Senior Engineer";
export const SITE_TITLE_TEMPLATE = "%s | Jane Doe"; // child pages fill %s

export const SITE_DESCRIPTION =
  "Senior engineer building cross-platform products with React and TypeScript.";

export const SITE_KEYWORDS = ["Jane Doe", "React", "TypeScript", "Engineer"];

export const AUTHOR_NAME = "Jane Doe";
export const AUTHOR_EMAIL = "jane@example.com";
export const AUTHOR_GITHUB = "https://github.com/janedoe";
export const AUTHOR_LINKEDIN = "https://www.linkedin.com/in/janedoe/";

export const OG_IMAGE_PATH = "/og-image.png"; // relative; canonicalized at use
export const OG_IMAGE_WIDTH = 1200;
export const OG_IMAGE_HEIGHT = 630;

export const GOOGLE_SITE_VERIFICATION = "your-search-console-token";
```

Why: changing the description or the OG image touches **one line**. Structured data, OG tags, and
Twitter cards all read the same values, so they can never disagree.

---

## 3. Typed data models (`types/seo`)

Minimal but typed. These are the contracts every builder honors.

```ts
// types/seo.ts
export type SchemaType =
  | "Person"
  | "WebSite"
  | "BlogPosting"
  | "CreativeWork"
  | "BreadcrumbList"
  | "FAQPage";

/** Describes a route for metadata generation. */
export interface RouteDescriptor {
  path: string; // e.g. "/blog/my-post"
  title: string;
  description: string;
  ogImage?: string; // falls back to OG_IMAGE_PATH
  indexable?: boolean; // whether it appears in the sitemap
}

export interface SitemapEntry {
  url: string; // absolute
  lastModified?: string;
  changeFrequency?:
    | "always"
    | "hourly"
    | "daily"
    | "weekly"
    | "monthly"
    | "yearly"
    | "never";
  priority?: number;
}

export interface RobotsConfig {
  rules: Array<{ userAgent: string; allow?: string[]; disallow?: string[] }>;
  sitemap: string; // absolute
}

export interface RssItem {
  title: string;
  link: string; // absolute
  description: string;
  pubDate: string; // ISO-8601
  guid: string;
}

export interface Redirect {
  source: string;
  destination: string;
  permanent: boolean; // 301 when true
}

/** A JSON-LD object: always a schema.org context + type, plus type-specific fields. */
export interface JsonLd {
  "@context": "https://schema.org";
  "@type": SchemaType;
  [key: string]: unknown;
}
```

Note the `I`-prefix convention from `frontend-architecture` applies to **stateful UI interfaces**;
these SEO data models are plain DTOs and follow the source project's existing convention
(here, unprefixed). Keep whichever convention the host project already uses — consistency wins.

---

## 4. Canonical URLs (the spine of the system)

One function, used everywhere. It guarantees absolute, normalized, double-slash-free URLs so
search engines never see two URLs for the same page.

```ts
// services/seo/index.ts
import { SITE_URL } from "@/constants/seo";

export function canonicalUrl(path: string): string {
  const normalized = path.startsWith("/") ? path : `/${path}`;
  if (normalized === "/") return SITE_URL; // root → base, no trailing slash
  const withoutTrailing = normalized.endsWith("/")
    ? normalized.slice(0, -1)
    : normalized;
  return `${SITE_URL}${withoutTrailing}`;
}
```

**Hard rules:**

- Never concatenate `SITE_URL + path` by hand — always `canonicalUrl(path)`.
- Pick one trailing-slash policy (this skill: **no trailing slash**) and apply it everywhere.
- Every OG `url`, sitemap `url`, RSS `link`, and JSON-LD `@id`/`url` goes through `canonicalUrl`.

---

## 5. Per-route metadata

### 5.1 The pure builder

`buildMetadata` is the **only** function allowed to know about the framework's metadata type.
Everything else is framework-free.

```ts
// services/seo/index.ts  (Next.js example — swap the return type for other frameworks)
import type { Metadata } from "next";
import { OG_IMAGE_PATH } from "@/constants/seo";
import type { RouteDescriptor } from "@/types/seo";

export function buildMetadata(route: RouteDescriptor): Metadata {
  const canonical = canonicalUrl(route.path);
  const ogImageUrl = canonicalUrl(route.ogImage ?? OG_IMAGE_PATH);

  return {
    title: route.title,
    description: route.description,
    alternates: { canonical },
    openGraph: { images: [ogImageUrl] },
  };
}
```

### 5.2 Global defaults live in the root layout

Set the title template, default OG/Twitter cards, robots policy, icons, manifest, and
verification **once** at the root. Child routes only override what differs.

```tsx
// app/layout.tsx — global metadata, all values from constants/seo
import type { Metadata } from "next";
import {
  SITE_URL,
  SITE_NAME,
  SITE_HANDLE,
  SITE_LOCALE,
  SITE_TITLE_DEFAULT,
  SITE_TITLE_TEMPLATE,
  SITE_DESCRIPTION,
  SITE_KEYWORDS,
  AUTHOR_NAME,
  OG_IMAGE_PATH,
  OG_IMAGE_WIDTH,
  OG_IMAGE_HEIGHT,
  GOOGLE_SITE_VERIFICATION,
} from "@/constants/seo";

export const metadata: Metadata = {
  metadataBase: new URL(SITE_URL),
  title: { default: SITE_TITLE_DEFAULT, template: SITE_TITLE_TEMPLATE },
  description: SITE_DESCRIPTION,
  keywords: SITE_KEYWORDS,
  authors: [{ name: AUTHOR_NAME, url: SITE_URL }],
  alternates: {
    canonical: SITE_URL,
    types: { "application/rss+xml": `${SITE_URL}/feed.xml` },
  },
  openGraph: {
    type: "website",
    locale: SITE_LOCALE,
    url: SITE_URL,
    siteName: SITE_NAME,
    title: SITE_TITLE_DEFAULT,
    description: SITE_DESCRIPTION,
    images: [
      { url: OG_IMAGE_PATH, width: OG_IMAGE_WIDTH, height: OG_IMAGE_HEIGHT },
    ],
  },
  twitter: {
    card: "summary_large_image",
    site: SITE_HANDLE,
    creator: SITE_HANDLE,
    title: SITE_TITLE_DEFAULT,
    description: SITE_DESCRIPTION,
    images: [OG_IMAGE_PATH],
  },
  robots: {
    index: true,
    follow: true,
    googleBot: {
      index: true,
      follow: true,
      "max-image-preview": "large",
      "max-snippet": -1,
    },
  },
  verification: { google: GOOGLE_SITE_VERIFICATION },
  manifest: "/manifest.webmanifest",
};
```

### 5.3 Per-route override (dynamic pages)

A dynamic route reads its entity and returns route-specific metadata. The title template fills
`%s` automatically, so just pass the page title.

```tsx
// app/blog/[slug]/page.tsx
import { buildMetadata } from "@/services/seo";

export async function generateMetadata({ params }) {
  const post = await loadPost(params.slug);
  return buildMetadata({
    path: `/blog/${post.slug}`,
    title: post.title,
    description: post.description,
    ogImage: post.heroImage,
  });
}
```

**Hard rules:**

- Set defaults once in the layout; override per route only where it differs.
- Use a title **template** so child pages don't repeat the site name.
- Every page resolves a single `canonical` — never emit duplicate or relative canonicals.

---

## 6. Discovery surfaces (generated from content)

### 6.1 Sitemap

Build entries from the **same content collections** the app renders, deduped, all absolute.

```ts
// services/seo/index.ts
import { ROUTES } from "@/constants/routes";
import type { SitemapEntry } from "@/types/seo";

const PRIMARY_ROUTES: Array<{
  path: string;
  changeFrequency: SitemapEntry["changeFrequency"];
  priority: number;
}> = [
  { path: ROUTES.HOME, changeFrequency: "weekly", priority: 1.0 },
  { path: ROUTES.BLOG, changeFrequency: "daily", priority: 0.9 },
  // …other primary routes
];

export function sitemapEntries(options: {
  blogSlugs: string[];
  projectSlugs: string[];
}): SitemapEntry[] {
  const seen = new Set<string>();
  const entries: SitemapEntry[] = [];
  const add = (e: SitemapEntry) => {
    if (!seen.has(e.url)) {
      seen.add(e.url);
      entries.push(e);
    }
  };
  const today = new Date().toISOString().split("T")[0];

  for (const r of PRIMARY_ROUTES)
    add({
      url: canonicalUrl(r.path),
      lastModified: today,
      changeFrequency: r.changeFrequency,
      priority: r.priority,
    });
  for (const slug of options.blogSlugs)
    add({
      url: canonicalUrl(`/blog/${slug}`),
      lastModified: today,
      changeFrequency: "monthly",
      priority: 0.7,
    });
  for (const slug of options.projectSlugs)
    add({
      url: canonicalUrl(`/projects/${slug}`),
      lastModified: today,
      changeFrequency: "monthly",
      priority: 0.8,
    });

  return entries;
}
```

```ts
// app/sitemap.ts — thin adapter
import type { MetadataRoute } from "next";
import { sitemapEntries } from "@/services/seo";

export default function sitemap(): MetadataRoute.Sitemap {
  const entries = sitemapEntries({
    blogSlugs: loadPublishedBlogSlugs(),
    projectSlugs: loadProjectSlugs(),
  });
  return entries.map((e) => ({
    url: e.url,
    lastModified: e.lastModified ? new Date(e.lastModified) : new Date(),
    changeFrequency:
      e.changeFrequency as MetadataRoute.Sitemap[0]["changeFrequency"],
    priority: e.priority,
  }));
}
```

### 6.2 Robots

```ts
// app/robots.ts
import type { MetadataRoute } from "next";
import { SITE_URL } from "@/constants/seo";

export default function robots(): MetadataRoute.Robots {
  return {
    rules: [
      { userAgent: "*", allow: "/", disallow: ["/api/", "/_next/", "/admin/"] },
      { userAgent: "Googlebot", allow: "/" },
    ],
    sitemap: `${SITE_URL}/sitemap.xml`,
    host: SITE_URL,
  };
}
```

Always **disallow private surfaces** (`/api/`, `/admin/`, build internals) and **point at the sitemap**.

### 6.3 RSS feed

```ts
// services/seo/index.ts
import type { RssItem } from "@/types/seo";

export function rssItems(posts: BlogPost[]): RssItem[] {
  return posts.map((post) => {
    const link = canonicalUrl(`/blog/${post.slug}`);
    return {
      title: post.title,
      link,
      description: post.description,
      pubDate: post.publishDate,
      guid: link,
    };
  });
}
```

```ts
// app/feed.xml/route.ts — sort newest-first, CDATA-wrap free text
import { rssItems } from "@/services/seo";
import { SITE_NAME, SITE_URL, SITE_DESCRIPTION } from "@/constants/seo";

export function GET(): Response {
  const items = rssItems(loadPublishedPostsNewestFirst());
  const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel>
  <title>${SITE_NAME}</title><link>${SITE_URL}</link>
  <description>${SITE_DESCRIPTION}</description>
  ${items
    .map(
      (i) => `<item>
    <title><![CDATA[${i.title}]]></title><link>${i.link}</link>
    <description><![CDATA[${i.description}]]></description>
    <pubDate>${i.pubDate}</pubDate><guid>${i.guid}</guid>
  </item>`,
    )
    .join("")}
</channel></rss>`;
  return new Response(xml, {
    headers: { "Content-Type": "application/rss+xml; charset=utf-8" },
  });
}
```

CDATA-wrap titles/descriptions so apostrophes and markup never break the feed.

---

## 7. Structured data (typed JSON-LD)

### 7.1 The generic builder

```ts
// services/seo/index.ts
import type { JsonLd, SchemaType } from "@/types/seo";

export function structuredData(
  type: SchemaType,
  data: Record<string, unknown>,
): JsonLd {
  return { "@context": "https://schema.org", "@type": type, ...data };
}
```

### 7.2 Per-type builders, cross-referenced by stable `@id`

```ts
// services/seo/structured-data.ts
import { structuredData } from "./index";
import { SITE_URL, AUTHOR_NAME, SITE_DESCRIPTION } from "@/constants/seo";
import type { JsonLd } from "@/types/seo";

export function personJsonLd(): JsonLd {
  return structuredData("Person", {
    "@id": `${SITE_URL}/#person`, // stable identity others reference
    name: AUTHOR_NAME,
    url: SITE_URL,
    description: SITE_DESCRIPTION,
    sameAs: [
      /* social profile URLs */
    ],
  });
}

export function websiteJsonLd(): JsonLd {
  return structuredData("WebSite", {
    "@id": `${SITE_URL}/#website`,
    url: SITE_URL,
    name: AUTHOR_NAME,
    author: { "@id": `${SITE_URL}/#person` }, // reference, not a copy
    potentialAction: {
      "@type": "SearchAction",
      target: {
        "@type": "EntryPoint",
        urlTemplate: `${SITE_URL}/blog?q={search_term_string}`,
      },
      "query-input": "required name=search_term_string",
    },
  });
}

export function blogPostingJsonLd(post: BlogPost, url: string): JsonLd {
  return structuredData("BlogPosting", {
    "@id": url,
    headline: post.title,
    description: post.description,
    datePublished: post.publishDate,
    dateModified: post.publishDate,
    author: {
      "@type": "Person",
      "@id": `${SITE_URL}/#person`,
      name: post.author,
    },
    publisher: {
      "@type": "Person",
      "@id": `${SITE_URL}/#person`,
      name: AUTHOR_NAME,
    },
    url,
    mainEntityOfPage: { "@type": "WebPage", "@id": url },
    image: {
      "@type": "ImageObject",
      url: post.heroImage,
      width: 1200,
      height: 630,
    },
    keywords: post.tags.join(", "),
  });
}

export function breadcrumbListJsonLd(
  items: Array<{ name: string; url: string }>,
): JsonLd {
  return structuredData("BreadcrumbList", {
    itemListElement: items.map((item, i) => ({
      "@type": "ListItem",
      position: i + 1,
      name: item.name,
      item: item.url,
    })),
  });
}

export function faqPageJsonLd(
  faqs: Array<{ question: string; answer: string }>,
): JsonLd {
  return structuredData("FAQPage", {
    mainEntity: faqs.map((f) => ({
      "@type": "Question",
      name: f.question,
      acceptedAnswer: { "@type": "Answer", text: f.answer },
    })),
  });
}
```

`CreativeWork` follows the same shape for projects/portfolio items (name, description, author by
`@id`, `keywords`, optional `codeRepository`/`sameAs`/`image`).

### 7.3 Injecting JSON-LD into a page

Render a `<script type="application/ld+json">` with `JSON.stringify`. Match the breadcrumb trail
to the page's real position.

```tsx
// app/blog/[slug]/page.tsx
import { canonicalUrl } from "@/services/seo";
import {
  blogPostingJsonLd,
  breadcrumbListJsonLd,
} from "@/services/seo/structured-data";

export default async function Page({ params }) {
  const post = await loadPost(params.slug);
  const url = canonicalUrl(`/blog/${post.slug}`);
  const postLd = blogPostingJsonLd(post, url);
  const crumbLd = breadcrumbListJsonLd([
    { name: "Home", url: canonicalUrl("/") },
    { name: "Blog", url: canonicalUrl("/blog") },
    { name: post.title, url },
  ]);
  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(postLd) }}
        suppressHydrationWarning
      />
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(crumbLd) }}
        suppressHydrationWarning
      />
      {/* …page content */}
    </>
  );
}
```

**Hard rules:**

- Give each entity a **stable `@id`** (e.g. `${SITE_URL}/#person`) and **reference** it elsewhere instead of duplicating fields.
- One `BlogPosting`/`CreativeWork` per detail page; a `BreadcrumbList` on every nested page.
- `Person` + `WebSite` belong on the home page; `FAQPage` only where real Q&A is shown.
- Validate output with Google's Rich Results Test / Schema Markup Validator before shipping.

---

## 8. Framework adapters

The builders are framework-free. Only the mounting layer changes.

| Framework              | Per-route metadata                                                                              | sitemap / robots / feed                                                                    |
| ---------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| **Next.js App Router** | `export const metadata` (static) or `generateMetadata` (dynamic) returning `buildMetadata(...)` | `app/sitemap.ts`, `app/robots.ts`, `app/feed.xml/route.ts`                                 |
| **Remix**              | `meta` export per route, mapping `buildMetadata`'s fields to `<meta>` descriptors               | resource routes (`routes/sitemap[.]xml.ts`, etc.) returning the builder output as XML/text |
| **Astro**              | `<head>` in a layout reading the same constants; per-page frontmatter overrides                 | `src/pages/sitemap.xml.ts`, `robots.txt.ts`, `rss.xml.ts` endpoints                        |
| **React + Vite (SPA)** | `react-helmet-async` (or a head manager) fed by `buildMetadata`'s plain object                  | build-time script that writes `sitemap.xml`/`robots.txt` from the same builders            |
| **Expo Router (web)**  | static head config / `expo-router` head; SEO matters only on the web target                     | a small web-only build step calling `sitemapEntries`/`rssItems`                            |

For a `buildMetadata` that must stay framework-neutral, return a plain shape
(`{ title, description, canonical, ogImage }`) and let each adapter translate it — keep the
Next.js `Metadata` return type only in a Next.js project.

---

## 9. Conventions checklist (enforce in review)

- [ ] All site identity (URL, name, description, keywords, author, handles, OG image, verification) lives in **one** `constants/seo` module — no duplicates, no hardcoded base URL.
- [ ] Every absolute URL is produced by `canonicalUrl()` — no manual `SITE_URL + path`.
- [ ] One trailing-slash policy, applied everywhere.
- [ ] Global metadata (title template, default OG/Twitter, robots, verification, manifest) is set **once** in the root layout.
- [ ] Dynamic routes override metadata via `buildMetadata` (or the framework adapter), passing only what differs.
- [ ] Every page resolves exactly one `canonical`; no relative or duplicate canonicals.
- [ ] `sitemap.xml`, `robots.txt`, and the RSS feed are **generated from content collections**, deduped, all absolute. Private routes are disallowed in robots.
- [ ] JSON-LD uses the shared `structuredData`/`JsonLd` builders; entities cross-reference by stable `@id`.
- [ ] Each detail page emits its primary schema (`BlogPosting`/`CreativeWork`) + a `BreadcrumbList`; home emits `Person` + `WebSite`.
- [ ] Builder functions are pure and unit-tested; framework code stays in the thin adapter (route files).
- [ ] OG image, locale, and Twitter handle are present and consistent across OG + Twitter cards.
- [ ] Structured data validated with Google's Rich Results Test before release.

---

## 10. How to apply this skill

**Adding SEO to a site:** create `constants/seo`, `types/seo`, and `services/seo/` (barrel +
`structured-data.ts`). Wire global metadata in the root layout, then add `sitemap`, `robots`, and
`feed.xml` adapters that mount the builders.

**Adding a new content type (e.g. case studies):** add its slugs to `sitemapEntries`, add a
JSON-LD builder if it needs its own schema, and give its detail page a `generateMetadata` + a
`BreadcrumbList`.

**Debugging duplicate-content / indexing issues:** check that every page goes through
`canonicalUrl`, that the trailing-slash policy is uniform, and that the sitemap contains only
indexable, absolute URLs. Confirm robots isn't blocking what should be indexed.

**Reviewing SEO coverage:** run the checklist in §9. The highest-value catches are hardcoded URLs
that bypass `canonicalUrl` (duplicate-content risk) and JSON-LD that duplicates entity fields
instead of referencing a stable `@id`.

---

## Publishing / installing this skill

This skill follows the Anthropic `SKILL.md` format and is portable across agents.

1. Keep it under `skills/frontend-seo/SKILL.md` in a public GitHub repo.
2. Keep the frontmatter `name` and high-signal `description` — discovery indexes match against it.
3. Install with: `npx skills add <org>/<repo> --skill "frontend-seo"`.
4. Non-`SKILL.md` agents can be pointed here from `AGENTS.md` / `CLAUDE.md`; Kiro can mirror it as a steering file.

## Limitations

- Use this skill only when the task clearly matches its upstream source and local project context.
- Verify commands, generated code, dependencies, credentials, and external service behavior before applying changes.
- Do not treat examples as a substitute for environment-specific tests, security review, or user approval for destructive or costly actions.


---
name: frontend-slides
description: Create stunning, animation-rich HTML presentations from scratch or by converting PowerPoint files.
risk: safe
source: https://github.com/zarazhangrui/frontend-slides
date_added: "2026-03-07"
---

# Frontend Slides

Create zero-dependency, animation-rich HTML presentations that run entirely in the browser.

## When to Use This Skill

- Use when the user asks to create a presentation, slide deck, or pitch from scratch.
- Use when the user wants to convert an existing PPT or PPTX file into a web-based presentation.
- Use when designing visually rich, animated HTML content that needs to fit exactly within the viewport.

## Core Principles

1. **Zero Dependencies** — Single HTML files with inline CSS/JS. No npm, no build tools.
2. **Show, Don't Tell** — Generate visual previews, not abstract choices. People discover what they want by seeing it.
3. **Distinctive Design** — No generic "AI slop." Every presentation must feel custom-crafted.
4. **Viewport Fitting (NON-NEGOTIABLE)** — Every slide MUST fit exactly within 100vh. No scrolling within slides, ever. Content overflows? Split into multiple slides.

## Design Aesthetics

You tend to converge toward generic, "on distribution" outputs. In frontend design, this creates what users call the "AI slop" aesthetic. Avoid this: make creative, distinctive frontends that surprise and delight.

Focus on:

- Typography: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics.
- Color & Theme: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes. Draw from IDE themes and cultural aesthetics for inspiration.
- Motion: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions.
- Backgrounds: Create atmosphere and depth rather than defaulting to solid colors. Layer CSS gradients, use geometric patterns, or add contextual effects that match the overall aesthetic.

Avoid generic AI-generated aesthetics:

- Overused font families (Inter, Roboto, Arial, system fonts)
- Cliched color schemes (particularly purple gradients on white backgrounds)
- Predictable layouts and component patterns
- Cookie-cutter design that lacks context-specific character

Interpret creatively and make unexpected choices that feel genuinely designed for the context. Vary between light and dark themes, different fonts, different aesthetics. You still tend to converge on common choices (Space Grotesk, for example) across generations. Avoid this: it is critical that you think outside the box!

## Viewport Fitting Rules

These invariants apply to EVERY slide in EVERY presentation:

- Every `.slide` must have `height: 100vh; height: 100dvh; overflow: hidden;`
- ALL font sizes and spacing must use `clamp(min, preferred, max)` — never fixed px/rem
- Content containers need `max-height` constraints
- Images: `max-height: min(50vh, 400px)`
- Breakpoints required for heights: 700px, 600px, 500px
- Include `prefers-reduced-motion` support
- Never negate CSS functions directly (`-clamp()`, `-min()`, `-max()` are silently ignored) — use `calc(-1 * clamp(...))` instead

**When generating, read `viewport-base.css` and include its full contents in every presentation.**

### Content Density Limits Per Slide

| Slide Type    | Maximum Content                                           |
| ------------- | --------------------------------------------------------- |
| Title slide   | 1 heading + 1 subtitle + optional tagline                 |
| Content slide | 1 heading + 4-6 bullet points OR 1 heading + 2 paragraphs |
| Feature grid  | 1 heading + 6 cards maximum (2x3 or 3x2)                  |
| Code slide    | 1 heading + 8-10 lines of code                            |
| Quote slide   | 1 quote (max 3 lines) + attribution                       |
| Image slide   | 1 heading + 1 image (max 60vh height)                     |

**Content exceeds limits? Split into multiple slides. Never cram, never scroll.**

---

## Phase 0: Detect Mode

Determine what the user wants:

- **Mode A: New Presentation** — Create from scratch. Go to Phase 1.
- **Mode B: PPT Conversion** — Convert a .pptx file. Go to Phase 4.
- **Mode C: Enhancement** — Improve an existing HTML presentation. Read it, understand it, enhance. **Follow Mode C modification rules below.**

### Mode C: Modification Rules

When enhancing existing presentations, viewport fitting is the biggest risk:

1. **Before adding content:** Count existing elements, check against density limits
2. **Adding images:** Must have `max-height: min(50vh, 400px)`. If slide already has max content, split into two slides
3. **Adding text:** Max 4-6 bullets per slide. Exceeds limits? Split into continuation slides
4. **After ANY modification, verify:** `.slide` has `overflow: hidden`, new elements use `clamp()`, images have viewport-relative max-height, content fits at 1280x720
5. **Proactively reorganize:** If modifications will cause overflow, automatically split content and inform the user. Don't wait to be asked

**When adding images to existing slides:** Move image to new slide or reduce other content first. Never add images without checking if existing content already fills the viewport.

---

## Phase 1: Content Discovery (New Presentations)

**Ask ALL questions in a single AskUserQuestion call** so the user fills everything out at once:

**Question 1 — Purpose** (header: "Purpose"):
What is this presentation for? Options: Pitch deck / Teaching-Tutorial / Conference talk / Internal presentation

**Question 2 — Length** (header: "Length"):
Approximately how many slides? Options: Short 5-10 / Medium 10-20 / Long 20+

**Question 3 — Content** (header: "Content"):
Do you have content ready? Options: All content ready / Rough notes / Topic only

**Question 4 — Inline Editing** (header: "Editing"):
Do you need to edit text directly in the browser after generation? Options:

- "Yes (Recommended)" — Can edit text in-browser, auto-save to localStorage, export file
- "No" — Presentation only, keeps file smaller

**Remember the user's editing choice — it determines whether edit-related code is included in Phase 3.**

If user has content, ask them to share it.

### Step 1.2: Image Evaluation (if images provided)

If user selected "No images" → skip to Phase 2.

If user provides an image folder:

1. **Scan** — List all image files (.png, .jpg, .svg, .webp, etc.)
2. **View each image** — Use the Read tool (Claude is multimodal)
3. **Evaluate** — For each: what it shows, USABLE or NOT USABLE (with reason), what concept it represents, dominant colors
4. **Co-design the outline** — Curated images inform slide structure alongside text. This is NOT "plan slides then add images" — design around both from the start (e.g., 3 screenshots → 3 feature slides, 1 logo → title/closing slide)
5. **Confirm via AskUserQuestion** (header: "Outline"): "Does this slide outline and image selection look right?" Options: Looks good / Adjust images / Adjust outline

**Logo in previews:** If a usable logo was identified, embed it (base64) into each style preview in Phase 2 — the user sees their brand styled three different ways.

---

## Phase 2: Style Discovery

**This is the "show, don't tell" phase.** Most people can't articulate design preferences in words.

### Step 2.0: Style Path

Ask how they want to choose (header: "Style"):

- "Show me options" (recommended) — Generate 3 previews based on mood
- "I know what I want" — Pick from preset list directly

**If direct selection:** Show preset picker and skip to Phase 3. Available presets are defined in [STYLE_PRESETS.md](STYLE_PRESETS.md).

### Step 2.1: Mood Selection (Guided Discovery)

Ask (header: "Vibe", multiSelect: true, max 2):
What feeling should the audience have? Options:

- Impressed/Confident — Professional, trustworthy
- Excited/Energized — Innovative, bold
- Calm/Focused — Clear, thoughtful
- Inspired/Moved — Emotional, memorable

### Step 2.2: Generate 3 Style Previews

Based on mood, generate 3 distinct single-slide HTML previews showing typography, colors, animation, and overall aesthetic. Read [STYLE_PRESETS.md](STYLE_PRESETS.md) for available presets and their specifications.

| Mood                | Suggested Presets                                  |
| ------------------- | -------------------------------------------------- |
| Impressed/Confident | Bold Signal, Electric Studio, Dark Botanical       |
| Excited/Energized   | Creative Voltage, Neon Cyber, Split Pastel         |
| Calm/Focused        | Notebook Tabs, Paper & Ink, Swiss Modern           |
| Inspired/Moved      | Dark Botanical, Vintage Editorial, Pastel Geometry |

Save previews to `.claude-design/slide-previews/` (style-a.html, style-b.html, style-c.html). Each should be self-contained, ~50-100 lines, showing one animated title slide.

Open each preview automatically for the user.

### Step 2.3: User Picks

Ask (header: "Style"):
Which style preview do you prefer? Options: Style A: [Name] / Style B: [Name] / Style C: [Name] / Mix elements

If "Mix elements", ask for specifics.

---

## Phase 3: Generate Presentation

Generate the full presentation using content from Phase 1 (text, or text + curated images) and style from Phase 2.

If images were provided, the slide outline already incorporates them from Step 1.2. If not, CSS-generated visuals (gradients, shapes, patterns) provide visual interest — this is a fully supported first-class path.

**Before generating, read these supporting files:**

- [html-template.md](html-template.md) — HTML architecture and JS features
- [viewport-base.css](viewport-base.css) — Mandatory CSS (include in full)
- [animation-patterns.md](animation-patterns.md) — Animation reference for the chosen feeling

**Key requirements:**

- Single self-contained HTML file, all CSS/JS inline
- Include the FULL contents of viewport-base.css in the `<style>` block
- Use fonts from Fontshare or Google Fonts — never system fonts
- Add detailed comments explaining each section
- Every section needs a clear `/* === SECTION NAME === */` comment block

---

## Phase 4: PPT Conversion

When converting PowerPoint files:

1. **Extract content** — Run `python scripts/extract-pptx.py <input.pptx> <output_dir>` (install python-pptx if needed: `pip install python-pptx`)
2. **Confirm with user** — Present extracted slide titles, content summaries, and image counts
3. **Style selection** — Proceed to Phase 2 for style discovery
4. **Generate HTML** — Convert to chosen style, preserving all text, images (from assets/), slide order, and speaker notes (as HTML comments)

---

## Phase 5: Delivery

1. **Clean up** — Delete `.claude-design/slide-previews/` if it exists
2. **Open** — Use `open [filename].html` to launch in browser
3. **Summarize** — Tell the user:
   - File location, style name, slide count
   - Navigation: Arrow keys, Space, scroll/swipe, click nav dots
   - How to customize: `:root` CSS variables for colors, font link for typography, `.reveal` class for animations
   - If inline editing was enabled: Hover top-left corner or press E to enter edit mode, click any text to edit, Ctrl+S to save

---

## Supporting Files

| File                                               | Purpose                                                              | When to Read              |
| -------------------------------------------------- | -------------------------------------------------------------------- | ------------------------- |
| [STYLE_PRESETS.md](STYLE_PRESETS.md)               | 12 curated visual presets with colors, fonts, and signature elements | Phase 2 (style selection) |
| [viewport-base.css](viewport-base.css)             | Mandatory responsive CSS — copy into every presentation              | Phase 3 (generation)      |
| [html-template.md](html-template.md)               | HTML structure, JS features, code quality standards                  | Phase 3 (generation)      |
| [animation-patterns.md](animation-patterns.md)     | CSS/JS animation snippets and effect-to-feeling guide                | Phase 3 (generation)      |
| [scripts/extract-pptx.py](scripts/extract-pptx.py) | Python script for PPT content extraction                             | Phase 4 (conversion)      |

## Limitations
- Use this skill only when the task clearly matches the scope described above.
- Do not treat the output as a substitute for environment-specific validation, testing, or expert review.
- Stop and ask for clarification if required inputs, permissions, safety boundaries, or success criteria are missing.