diff --git a/external/ai-agents/subagent-driven-development/SKILL.md b/external/ai-agents/subagent-driven-development/SKILL.md index aac35b9..f7bbf6a 100644 --- a/external/ai-agents/subagent-driven-development/SKILL.md +++ b/external/ai-agents/subagent-driven-development/SKILL.md @@ -36,25 +36,25 @@ stop and ask. digraph when_to_use { "Have implementation plan?" [shape=diamond]; "Tasks mostly independent?" [shape=diamond]; - "Stay in this session?" [shape=diamond]; + "Partner chose inline, or no subagent tool?" [shape=diamond]; "subagent-driven-development" [shape=box]; "executing-plans" [shape=box]; "Manual execution or brainstorm first" [shape=box]; "Have implementation plan?" -> "Tasks mostly independent?" [label="yes"]; "Have implementation plan?" -> "Manual execution or brainstorm first" [label="no"]; - "Tasks mostly independent?" -> "Stay in this session?" [label="yes"]; + "Tasks mostly independent?" -> "Partner chose inline, or no subagent tool?" [label="yes"]; "Tasks mostly independent?" -> "Manual execution or brainstorm first" [label="no - tightly coupled"]; - "Stay in this session?" -> "subagent-driven-development" [label="yes"]; - "Stay in this session?" -> "executing-plans" [label="no - parallel session"]; + "Partner chose inline, or no subagent tool?" -> "executing-plans" [label="yes"]; + "Partner chose inline, or no subagent tool?" -> "subagent-driven-development" [label="no"]; } ``` -**vs. Executing Plans (parallel session):** -- Same session (no context switch) -- Fresh subagent per task (no context pollution) -- Review after each task (spec compliance + code quality), broad review at the end -- Faster iteration (no human-in-loop between tasks) +**vs. Executing Plans (inline):** +- Fresh subagent per task (no context pollution) instead of one context doing every task +- Review after each task (spec compliance + code quality) instead of only at the end +- Costs a fresh context per task and per review; inline costs one context plus one final reviewer +- Both run in this session, share the same plan workspace and ledger, and never pause between tasks ## The Process @@ -134,8 +134,8 @@ sequences — the single most expensive failure observed. Track progress in a ledger file, not only in todos. - Each plan owns a workspace: at skill start, run this skill's - `scripts/sdd-workspace PLAN_FILE` — it prints the plan's git-ignored - directory (`/.superpowers/sdd//`), home to + `bash scripts/sdd-workspace PLAN_FILE` — it prints the plan's git-ignored + directory (under `/.superpowers/sdd/`), home to every artifact for THIS plan: ledger, briefs, reports, review packages. Another plan's directory is never yours to read or write. - Check for this plan's ledger at `/progress.md`. If its first @@ -249,7 +249,7 @@ Record BASE (`git rev-parse HEAD`) before dispatching — the review package and fix-round diffs need it. - **Task brief:** before dispatching an implementer, run this skill's - `scripts/task-brief PLAN_FILE N` — it extracts the task's full text to a + `bash scripts/task-brief PLAN_FILE N` — it extracts the task's full text to a uniquely named file and prints the path. Compose the dispatch so the brief stays the single source of requirements. Your dispatch should contain: (1) one line on where this @@ -287,7 +287,7 @@ Template: [implementer-prompt.md](implementer-prompt.md) Implementer subagents report one of four statuses. Handle each appropriately: -**DONE:** Generate the review package (`scripts/review-package PLAN_FILE BASE HEAD`, from this skill's directory — it prints the unique file path it wrote; BASE is the commit you recorded before dispatching the implementer — never `HEAD~1`, which silently drops all but the last commit of a multi-commit task), then dispatch the task reviewer with the printed path. +**DONE:** Generate the review package (`bash scripts/review-package PLAN_FILE BASE HEAD`, from this skill's directory — it prints the unique file path it wrote; BASE is the commit you recorded before dispatching the implementer — never `HEAD~1`, which silently drops all but the last commit of a multi-commit task), then dispatch the task reviewer with the printed path. **DONE_WITH_CONCERNS:** The implementer completed the work but flagged doubts. Read the concerns before proceeding. If the concerns are about correctness or scope, address them before review. If they're observations (e.g., "this file is getting large"), note them and proceed to review. @@ -314,7 +314,7 @@ required. Implementer self-review never replaces the task review; both are needed. - Hand the reviewer its diff as a file: run this skill's - `scripts/review-package PLAN_FILE BASE HEAD` and pass the reviewer the file path + `bash scripts/review-package PLAN_FILE BASE HEAD` and pass the reviewer the file path it prints (or, without bash: `git log --oneline`, `git diff --stat`, and `git diff -U10` for the range, redirected to one uniquely named file). The output never enters your own context, and the reviewer sees @@ -393,7 +393,7 @@ output; dispatch the re-review once all three are present. Name the covering test files in the fix message — a one-line fix does not need the whole suite. -**The re-review is scoped.** Run `scripts/review-package PLAN_FILE FIX_BASE HEAD` +**The re-review is scoped.** Run `bash scripts/review-package PLAN_FILE FIX_BASE HEAD` where FIX_BASE is the head the previous review saw, and dispatch [re-review-prompt.md](re-review-prompt.md) with the findings list, the brief, the report file, and the printed diff path. The re-reviewer verdicts @@ -445,7 +445,7 @@ parked-with-ruling at the cap. ## Final Review The final whole-branch review gets a package too: run -`scripts/review-package PLAN_FILE MERGE_BASE HEAD` (MERGE_BASE = the commit the +`bash scripts/review-package PLAN_FILE MERGE_BASE HEAD` (MERGE_BASE = the commit the branch started from, e.g. `git merge-base main HEAD`) and include the printed path in the final review dispatch, so the final reviewer reads one file instead of re-deriving the branch diff with git commands. Dispatch @@ -460,7 +460,7 @@ with the complete findings list — not one fixer per finding. Per-finding fixers each rebuild context and re-run suites; a real session's final-review fix wave cost more than all its tasks combined. Then run exactly one scoped re-review of the fix wave -(`scripts/review-package PLAN_FILE FIX_BASE HEAD` over the fix range, +(`bash scripts/review-package PLAN_FILE FIX_BASE HEAD` over the fix range, [re-review-prompt.md](re-review-prompt.md)). Adjudicate any residual findings as in the task loop's breaker: park with rulings, or rule on the load-bearing ones and ledger what you decided. Only @@ -507,7 +507,7 @@ You: I'm using Subagent-Driven Development to execute this plan. [Setup: worktree verified] [Read plan file once: docs/superpowers/plans/feature-plan.md] -[Resolve workspace: scripts/sdd-workspace docs/superpowers/plans/feature-plan.md — no ledger inside, fresh start] +[Resolve workspace: bash scripts/sdd-workspace docs/superpowers/plans/feature-plan.md — no ledger inside, fresh start] [Create todos for all tasks] Task 1: Hook installation script diff --git a/external/ai-agents/subagent-driven-development/re-review-prompt.md b/external/ai-agents/subagent-driven-development/re-review-prompt.md index ad74b10..d49c182 100644 --- a/external/ai-agents/subagent-driven-development/re-review-prompt.md +++ b/external/ai-agents/subagent-driven-development/re-review-prompt.md @@ -109,7 +109,7 @@ Subagent (general-purpose): - `[REPORT_FILE]` — the implementer's report file (fix reports appended) - `[FIX_BASE_SHA]` — the head the previous review saw - `[HEAD_SHA]` — current commit -- `[DIFF_FILE]` — the path `scripts/review-package PLAN_FILE FIX_BASE HEAD` printed +- `[DIFF_FILE]` — the path `bash scripts/review-package PLAN_FILE FIX_BASE HEAD` printed **Re-reviewer returns:** per-finding verdicts (ADDRESSED / NOT ADDRESSED), new breakage in the fix diff, out-of-scope observations, and a round verdict. diff --git a/external/ai-agents/subagent-driven-development/scripts/review-package b/external/ai-agents/subagent-driven-development/scripts/review-package index 31852e2..fa7625f 100755 --- a/external/ai-agents/subagent-driven-development/scripts/review-package +++ b/external/ai-agents/subagent-driven-development/scripts/review-package @@ -22,10 +22,17 @@ head=$3 git rev-parse --verify --quiet "$base" >/dev/null || { echo "bad BASE: $base" >&2; exit 2; } git rev-parse --verify --quiet "$head" >/dev/null || { echo "bad HEAD: $head" >&2; exit 2; } +# Range guards (exit 3): a wrong-branch HEAD yields a range that is empty or +# not rooted at BASE; either would silently produce a bogus review package. +git merge-base --is-ancestor "$base" "$head" || { echo "HEAD is not a descendant of BASE: ${base}..${head}" >&2; exit 3; } +[ "$(git rev-list --count "${base}..${head}")" -gt 0 ] || { echo "empty commit range: ${base}..${head}" >&2; exit 3; } + if [ $# -eq 4 ]; then out=$4 else - dir=$("$(cd "$(dirname "$0")" && pwd)/sdd-workspace" "$plan") + # Invoke via bash rather than direct exec: some extractors (Python zipfile) + # strip Unix exec bits when unpacking marketplace packages (#2040). + dir=$("${BASH:-bash}" "$(cd "$(dirname "$0")" && pwd)/sdd-workspace" "$plan") out="$dir/review-$(git rev-parse --short "$base")..$(git rev-parse --short "$head").diff" fi diff --git a/external/ai-agents/subagent-driven-development/scripts/sdd-workspace b/external/ai-agents/subagent-driven-development/scripts/sdd-workspace index 4e2d168..ff6b983 100755 --- a/external/ai-agents/subagent-driven-development/scripts/sdd-workspace +++ b/external/ai-agents/subagent-driven-development/scripts/sdd-workspace @@ -8,6 +8,16 @@ # artifacts. A stale ledger misread as current progress makes controllers # skip whole task sequences — plan-scoping removes that failure structurally. # +# Basename slugs collide when two plans share a filename (docs/alpha/plan.md +# vs docs/beta/plan.md), so each workspace records its owning plan's path in +# a plan-path marker (repo-relative in-repo, absolute outside). A workspace +# owned by a different plan is skipped and the slug disambiguated with the +# plan's parent-directory name, then a counter. A workspace with no marker +# predates the marker scheme and is adopted for the current plan so in-flight +# workspaces keep resolving — which means the first collision on such a +# legacy workspace adopts instead of detecting; acceptable, marker-less +# workspaces age out as plans finish. +# # The workspace lives in the working tree (not under .git/) because Claude Code # treats .git/ as a protected path and denies agent writes there — which blocks # an implementer subagent from writing its report file. A self-ignoring @@ -34,7 +44,39 @@ slug=$(basename "$plan" .md) root=$(git rev-parse --show-toplevel) base="$root/.superpowers/sdd" + +# Normalize the plan path (physical directory, so relative/absolute/../ +# spellings of one plan compare equal) and express it as the marker value: +# repo-relative when the plan lives under the repo root, absolute otherwise. +plan_dir=$(CDPATH= cd -- "$(dirname "$plan")" && pwd -P) +plan_abs="$plan_dir/$(basename "$plan")" +case "$plan_abs" in + "$root"/*) plan_id=${plan_abs#"$root"/} ;; + *) plan_id=$plan_abs ;; +esac + +# True when the workspace at $1 is (or becomes) this plan's: an existing +# marker must name this plan; a missing marker means a new workspace or a +# pre-marker legacy one, and either way the plan claims it by writing one. +owns() { + if [ -e "$1/plan-path" ]; then + [ "$(cat "$1/plan-path")" = "$plan_id" ] + else + mkdir -p "$1" + printf '%s\n' "$plan_id" > "$1/plan-path" + fi +} + dir="$base/$slug" -mkdir -p "$dir" +if ! owns "$dir"; then + parent=$(basename "$plan_dir") + dir="$base/$slug-$parent" + if ! owns "$dir"; then + n=2 + while ! owns "$base/$slug-$parent-$n"; do n=$((n + 1)); done + dir="$base/$slug-$parent-$n" + fi +fi + printf '*\n' > "$base/.gitignore" -cd "$dir" && pwd +CDPATH= cd -- "$dir" && pwd diff --git a/external/ai-agents/subagent-driven-development/scripts/task-brief b/external/ai-agents/subagent-driven-development/scripts/task-brief index 612e14a..b49fc54 100755 --- a/external/ai-agents/subagent-driven-development/scripts/task-brief +++ b/external/ai-agents/subagent-driven-development/scripts/task-brief @@ -21,7 +21,9 @@ n=$2 if [ $# -eq 3 ]; then out=$3 else - dir=$("$(cd "$(dirname "$0")" && pwd)/sdd-workspace" "$plan") + # Invoke via bash rather than direct exec: some extractors (Python zipfile) + # strip Unix exec bits when unpacking marketplace packages (#2040). + dir=$("${BASH:-bash}" "$(cd "$(dirname "$0")" && pwd)/sdd-workspace" "$plan") out="$dir/task-${n}-brief.md" fi diff --git a/external/ai-agents/subagent-driven-development/task-reviewer-prompt.md b/external/ai-agents/subagent-driven-development/task-reviewer-prompt.md index ce79694..5c619bc 100644 --- a/external/ai-agents/subagent-driven-development/task-reviewer-prompt.md +++ b/external/ai-agents/subagent-driven-development/task-reviewer-prompt.md @@ -189,7 +189,7 @@ Subagent (general-purpose): **Placeholders:** - `[MODEL]` — REQUIRED: reviewer model per SKILL.md Model Selection -- `[BRIEF_FILE]` — REQUIRED: the task brief file (`scripts/task-brief PLAN N` +- `[BRIEF_FILE]` — REQUIRED: the task brief file (`bash scripts/task-brief PLAN N` prints the path; same file the implementer worked from) - `[GLOBAL_CONSTRAINTS]` — the binding requirements copied verbatim from the plan's Global Constraints section or the spec: exact values, formats, @@ -200,7 +200,7 @@ Subagent (general-purpose): - `[BASE_SHA]` — commit before this task - `[HEAD_SHA]` — current commit - `[DIFF_FILE]` — REQUIRED: the path the controller wrote the review - package to (`scripts/review-package PLAN_FILE BASE HEAD` prints the unique + package to (`bash scripts/review-package PLAN_FILE BASE HEAD` prints the unique path it wrote; the package never enters the controller's context) **Reviewer returns:** Spec Compliance verdict (✅/❌/⚠️), Strengths, Issues diff --git a/external/basic/brainstorming/SKILL.md b/external/basic/brainstorming/SKILL.md index b56a3b5..e3f1788 100644 --- a/external/basic/brainstorming/SKILL.md +++ b/external/basic/brainstorming/SKILL.md @@ -11,12 +11,48 @@ Start by classifying how much process the request needs, then work through your path: understand the context, refine the idea, present a design, and get your human partner's approval. +## Establish Shared Understanding + +The outcome of brainstorming is an understanding your human partner can +recognize and correct, grounded in what they want to accomplish. + +1. **Discover intent.** Use the request and available context to identify + the intended outcome, who it is for, and what success looks like. When + that information is missing, ask one focused question about purpose or + intended use before proposing features or an approach. Knowing the app + genre does not tell you why your partner wants it. Gathering missing + requirements does not ask them to authorize the task again. +2. **Write back your understanding.** Summarize the intended outcome, + relevant constraints, and success criteria in a short note your partner + can assess. Separate what they said from assumptions. Invite correction + and incorporate their answer before treating this as the design brief. +3. **Carry intent into the design.** Preserve the agreed understanding in + the selected path's design artifact: the written spec for architectural + work, or the in-chat design/probe for bounded work and spikes. Check + proposed features and technical choices against that understanding. + +When the request already supplies the purpose and constraints, reflect +that understanding instead of asking the same questions again. Keep the +note concise; its accuracy and the opportunity to correct it matter. + -Do NOT invoke any implementation skill, write any code, scaffold any -project, or take any implementation action until you have told your -human partner what you intend and they have approved it. This applies -to EVERY task on EVERY path below — the ceremony scales with the task; -the approval gate never does. +Before taking any implementation action, including invoking an +implementation skill, writing product code, scaffolding, installing +product dependencies, or creating an external project, complete the +selected path's prerequisites: + +- Spike: the human partner approves the question and probe. +- Bounded: the human partner approves the short in-chat design. +- Architectural: the human partner reviews and approves the written spec, + then reviews the written implementation plan and selects its execution + method. Conversational design approval only permits writing the spec; + written-spec approval only permits invoking writing-plans. + +A reply approves the stage actually presented. Approval of an idea or +feature scope does not approve artifacts that do not exist yet. Resume +at the earliest incomplete stage; do not turn one approval into permission +to skip the rest of the selected path. Read-only project exploration is +allowed while those prerequisites remain incomplete. ## Three Paths @@ -53,18 +89,17 @@ stop, say so, and step up. Nothing downgrades mid-task. ## Anti-Pattern: "Too Simple To Need Approval" -Every path ends with your human partner approving your intent before -implementation. A todo list, a single-function utility, a config -change — the design may be two sentences in chat, but you MUST present -it and get approval. "Simple" tasks are where unexamined assumptions -cause the most wasted work. What scales with simplicity is the -artifact, never the approval. +Every path ends with your human partner approving the required design +before implementation. A bounded change may need only two sentences in +chat. A new todo-list project is architectural and requires the written +spec and planning handoffs. Scale the artifact to the selected path; +complete that path's reviews before implementation. ## Red Flags | Thought | Reality | |---------|---------| -| "This is too simple to need a design" | Simple means a short design, not no design. Two sentences in chat, then approval. | +| "This is too simple to need a design" | Follow the selected path: a bounded change gets a short chat design; an architectural change gets the written spec and planning handoffs. | | "I'll call it bounded and skip the spec" | Reaching for a label to skip work IS the doubt — take the heavier path. | | "It's bounded and the design is obvious — I'll start while they read it" | The gate is the approval, not the design's length. Present, then stop until you hear yes. | | "I understand this kind of app, so it's bounded" | Bounded measures the repo, not your familiarity. A new project has no existing flow — it is architectural. | diff --git a/external/basic/brainstorming/visual-companion.md b/external/basic/brainstorming/visual-companion.md index c145e64..8dca065 100644 --- a/external/basic/brainstorming/visual-companion.md +++ b/external/basic/brainstorming/visual-companion.md @@ -35,7 +35,7 @@ The server watches a directory for HTML files and serves the newest one to the b ```bash # Start AFTER the user approves the companion. --open auto-opens their browser on # the first screen; --project-dir persists mockups and enables same-port restart. -scripts/start-server.sh --project-dir /path/to/project --open +bash scripts/start-server.sh --project-dir /path/to/project --open # Returns: {"type":"server-started","port":52341, # "url":"http://localhost:52341/?key=ab12…", @@ -62,7 +62,7 @@ without repeating it. **Claude Code:** ```bash # Default mode works — the script backgrounds the server itself. -scripts/start-server.sh --project-dir /path/to/project --open +bash scripts/start-server.sh --project-dir /path/to/project --open ``` On Windows, the script auto-detects and switches to foreground mode (which blocks the tool call). Use `run_in_background: true` on the Bash tool call so the server survives across conversation turns, then read `$STATE_DIR/server-info` on the next turn to get the URL and port. @@ -71,14 +71,14 @@ On Windows, the script auto-detects and switches to foreground mode (which block ```bash # Codex reaps background processes. The script auto-detects CODEX_CI and # switches to foreground mode. Run it normally — no extra flags needed. -scripts/start-server.sh --project-dir /path/to/project --open +bash scripts/start-server.sh --project-dir /path/to/project --open ``` **Gemini CLI:** ```bash # Use --foreground and set is_background: true on your shell tool call # so the process survives across turns -scripts/start-server.sh --project-dir /path/to/project --open --foreground +bash scripts/start-server.sh --project-dir /path/to/project --open --foreground ``` **Copilot CLI:** @@ -95,7 +95,7 @@ bash scripts/start-server.sh --project-dir /path/to/project --open --foreground If the URL is unreachable from your browser (common in remote/containerized setups), bind a non-loopback host: ```bash -scripts/start-server.sh \ +bash scripts/start-server.sh \ --project-dir /path/to/project \ --host 0.0.0.0 \ --url-host localhost @@ -288,7 +288,7 @@ If `$STATE_DIR/events` doesn't exist, the user didn't interact with the browser ## Cleaning Up ```bash -scripts/stop-server.sh $SESSION_DIR +bash scripts/stop-server.sh $SESSION_DIR ``` If the session used `--project-dir`, mockup files persist in `.superpowers/brainstorm/` for later reference. Only `/tmp` sessions get deleted on stop. diff --git a/external/basic/firecrawl-search/SKILL.md b/external/basic/firecrawl-search/SKILL.md index c3b4334..7831635 100644 --- a/external/basic/firecrawl-search/SKILL.md +++ b/external/basic/firecrawl-search/SKILL.md @@ -9,7 +9,7 @@ allowed-tools: # firecrawl search -Web search with optional content scraping. Returns search results as JSON, optionally with full page content. +Search naturally using the user’s actual question. In the Alexandria beta, default search returns web results plus relevant Alexandria tools, with optional web content scraping. ## Quick start @@ -30,6 +30,16 @@ Run `firecrawl search --help` for the full option list. **Done when:** results are saved under `.firecrawl/`, verified non-empty, processed for the request, and one feedback event is sent within the time window (unless opted out). +## Alexandria in normal search + +The beta defaults to `web,alexandria` with domain-tool matching on. Preserve the user's location, marketplace, and constraints in the query; do not turn normal research into an artificial tool-discovery query. Inspect `data.web` and `data.tools` from the same response. + +A tool match is not executed data. If it fits the task, read its inputs, coverage, `creditsCost`/`perRecord`, and access requirements in the JSON. Execute it with `firecrawl scrape --alexandria --options ''`. All provider execution goes through Scrape; `search --scrape` only fetches web result content, not provider tools. + +Use `find-tools` only for an explicitly requested tool set or a missing contract. It runs the `firecrawl/find-tools` meta tool through Scrape and never executes the tools it discovers. It accepts URLs or catalogue selectors; for “tools that can do X,” first use `search "X" --sources alexandria`, then narrow the returned providers with `find-tools --options '{"providers":[""],"level":"tools","limit":100}'`. + +If no returned tool covers the country/market/segment or required inputs, continue with ordinary web results. Do not exhaust the catalogue or pay for adjacent tools just to probe coverage. `--sources web` explicitly opts out of Alexandria; `--sources web --domain-tools` retains domain matches only. + ## Tips - **`--highlights` on by default:** results are query-relevant excerpts, not full-page snippets. Use `--no-highlights` for the original snippets. diff --git a/external/develop/frontend/ui-animation/SKILL.md b/external/develop/frontend/ui-animation/SKILL.md index c4231c6..1c6e56f 100644 --- a/external/develop/frontend/ui-animation/SKILL.md +++ b/external/develop/frontend/ui-animation/SKILL.md @@ -1,11 +1,11 @@ --- name: ui-animation -description: Builds, reviews, and measures UI motion, including springs, gestures, scroll effects, and curve fitting from recordings. Use when asked to "add animation", "match this easing", "reverse engineer this motion", or find animation opportunities. For action semantics use product-design; for visual layout use ui-design. +description: Builds, reviews, and measures UI motion, including springs, gestures, scroll effects, curve fitting from recordings, and sparse interface sound. Use when asked to "add animation", "match this easing", "reverse engineer this motion", "add a click sound", or find animation opportunities. For action semantics use product-design; for visual layout use ui-design. --- # UI Animation -- **IS:** designing, implementing, reviewing, debugging UI motion (springs, gestures, drag, easing, CSS transitions, keyframes, Motion), sweeping an interface for the moments that would genuinely benefit from motion, measuring motion from a recording (extract frames, track, fit curves) to emit code plus a handoff spec, and naming a described motion effect (reverse-lookup vocabulary). +- **IS:** designing, implementing, reviewing, debugging UI motion (springs, gestures, drag, easing, CSS transitions, keyframes, Motion), sweeping an interface for the moments that would genuinely benefit from motion, measuring motion from a recording (extract frames, track, fit curves) to emit code plus a handoff spec, naming a described motion effect (reverse-lookup vocabulary), and gating sparse interface sound. - **IS NOT:** choosing overall visual direction, palettes, or typography (use `ui-design` Direction mode), auditing a whole page's UI quality (use `ui-design` Audit mode), or named text-effect specs (use the external `animate-text` skill where installed). ## Routing boundary @@ -34,7 +34,9 @@ description: Builds, reviews, and measures UI motion, including springs, gesture | [references/curve-fitting.md](references/curve-fitting.md) | Reverse-engineer: reading `fit_curves.py` output, spring vs bezier, judging fit error, asymmetric open/close | | [references/code-output.md](references/code-output.md) | Reverse-engineer: emitting code for CSS, Motion/Framer Motion, SwiftUI, React Native, UIKit | | [references/choreography.md](references/choreography.md) | Reverse-engineer: multi-element/multi-phase motion: staggers, blur-before-move, per-edge settling | +| [references/live-tuning.md](references/live-tuning.md) | Dialling a curve in live when there is no reference to fit against: the DevTools bezier editor, retiming in the Animations panel, when a control-panel library earns a dependency | | [references/vocabulary.md](references/vocabulary.md) | Naming a motion effect the user describes vaguely ("what's it called when...") | +| [references/interface-sfx.md](references/interface-sfx.md) | Click sounds, interface audio, UI SFX, haptic-plus-sound, or "why is the web afraid of sound" | ## Core rules @@ -66,7 +68,7 @@ description: Builds, reviews, and measures UI motion, including springs, gesture - Avoid `filter` animation for core interactions; if unavoidable keep blur ≤ 20px (heavy blur is expensive, especially in Safari). - SVG: apply transforms on a `` wrapper with `transform-box: fill-box; transform-origin: center`; without it they rotate/scale around the canvas origin. Line drawing, path morphing, and the Motion SVG origin override live in [references/svg-animation.md](references/svg-animation.md). - `transform: scale()` also scales children (icons, text, borders scale proportionally), unlike `width`/`height`: a feature for press feedback, but account for it when an inner element must stay fixed-size. -- Disable transitions during theme switches (`[data-theme-switching] * { transition: none !important }`), or every themed property animates at once. +- Disable transitions during theme switches (`[data-theme-switching] * { transition: none !important }`), or every themed property animates at once. Force a reflow (`void document.body.offsetHeight`) after the flip and remove the override on the next frame, or use `next-themes` `disableTransitionOnChange`. ## Easing defaults @@ -120,13 +122,14 @@ Prefer lower-overhead transitions (CSS-only) unless the design requires JS orche ## Spatial and sequencing -- Popover `transform-origin` at the trigger (modals stay `center`), dialog/menu entrances from `scale(0.85-0.9)` not `scale(0)`, and 30-50ms staggers (total under 300ms, most important element leading). Full rules and code in [references/component-patterns.md](references/component-patterns.md) and [references/contextual-animations.md](references/contextual-animations.md). +- Popover `transform-origin` at the trigger (modals stay `center`), dialog/menu entrances from `scale(0.9-0.96)` not `scale(0)` (small popovers at the low end, full dialogs at the high end: a large surface already travels far in absolute pixels), and 30-50ms staggers (total under 300ms, most important element leading). Full rules and code in [references/component-patterns.md](references/component-patterns.md) and [references/contextual-animations.md](references/contextual-animations.md). - **Paired elements rule:** elements that animate together (modal + overlay, tooltip + arrow, FAB + label) must share easing and duration. Mismatched timing is the usual cause of "something feels off". ## Accessibility - Gate hover (motion and paint) behind `@media (hover: hover) and (pointer: fine)`, or touch devices replay hover on tap. Inspect the generated CSS before adding a gate; Tailwind v4 already wraps `hover:` in `@media (hover: hover)`. - During direct manipulation, keep the element locked to the pointer with no easing; add easing only after release. +- Optional interface SFX: sparse, gesture-unlocked, additive confirmation only. See [references/interface-sfx.md](references/interface-sfx.md). ## Performance @@ -163,7 +166,7 @@ Animation progress: ``` 1. Answer the four questions in [references/decision-framework.md](references/decision-framework.md): animate? purpose? easing? speed? -2. Pick duration from the easing defaults table above. +2. Pick duration from the easing defaults table above. If the value is contested or the component is hard to reach, dial it live in the DevTools bezier editor rather than guessing, then bake the result into source ([references/live-tuning.md](references/live-tuning.md)). 3. Choose implementation: CSS transition > WAAPI > spring > keyframe > JS. 4. Load the reference for your component or technique. 5. When reviewing, apply the strict posture in [references/review-format.md](references/review-format.md): measure against the ten standards, output the Before/After/Why table, then a tiered verdict ending in a Block/Approve decision. @@ -177,7 +180,7 @@ Produce evidence for each check (DevTools observations, not "looks fine"): - Slow to 10% in the DevTools Animations panel to catch timing and `transform-origin` issues invisible at full speed. - Confirm `will-change` is toggled around animations, not permanently set, and looping animations pause off-screen. - Test touch interactions on real devices; simulators under-report gesture and hover-on-tap issues. -- Honor `prefers-reduced-motion`: replace spatial travel and looping effects with immediate state changes or restrained fades, then exercise the same task in that mode. +- Honor `prefers-reduced-motion`: replace spatial travel with immediate state changes or restrained fades. Pause looping decorations with `animation-play-state: paused` (do not yank them with `display: none`). Keep explicit user-triggered feedback. Exercise the same task in that mode. ## Discovery workflow @@ -219,6 +222,10 @@ Reverse-engineer progress: Maintenance only: when changing Discovery routing or the gate, run the scenarios in `evaluations/` as a regression rubric. They never load during a user task. +## Sources + +Interface SFX gating taken from Craft (gustavo-fior) and Raphael Salaja's web-sound writing. Novelty 90/10 split, one-shot intro gating, and `animation-play-state` on loops taken from Rauno Freiberg. Rejected vendoring emilkowalski/skills and gustavo-fior/craft: trigger collision with this skill. Clip-path and proportional scale already lived here. + ## Related skills - `product-design`: which states exist, what an action affects, and whether it is reversible. Route here first when a gesture replaces a control, since swipe-to-delete and hold-to-confirm change what the user can do before they change how it moves. diff --git a/external/develop/frontend/ui-animation/evals/evals.json b/external/develop/frontend/ui-animation/evals/evals.json index 0b88fae..9ebeff3 100644 --- a/external/develop/frontend/ui-animation/evals/evals.json +++ b/external/develop/frontend/ui-animation/evals/evals.json @@ -22,12 +22,24 @@ "Does not run scripts relative to the application by accident", "Reports fitted error rather than claiming an exact visual match" ] + }, + { + "id": 3, + "prompt": "Add a click sound to every button on the dashboard, including list-row hovers.", + "expected_output": "Refuse high-frequency SFX; if any sound ships, it is rare, gesture-unlocked, and additive to visual feedback.", + "files": [], + "assertions": [ + "Loads interface-sfx.md", + "Keeps typing, hover, and list navigation silent", + "Does not create AudioContext on page load" + ] } ], "routing": { "should_trigger": [ "Review a Tailwind v4 modal animation. Generated hover CSS already includes @media (hover: hover). Keyboard focus moves immediately; a 120ms opacity transition continues afterward. Reduced motion uses an immediate state change.", - "Match a recording extracted at 60fps using the bundled fitting scripts. The fitting default is 30fps." + "Match a recording extracted at 60fps using the bundled fitting scripts. The fitting default is 30fps.", + "Add a click sound to every button on the dashboard, including list-row hovers." ], "near_miss": [ { diff --git a/external/develop/frontend/ui-animation/references/component-patterns.md b/external/develop/frontend/ui-animation/references/component-patterns.md index 7278ff4..fc643cc 100644 --- a/external/develop/frontend/ui-animation/references/component-patterns.md +++ b/external/develop/frontend/ui-animation/references/component-patterns.md @@ -47,9 +47,9 @@ Blur under 20px; heavy blur is expensive, especially in Safari. Scale in from the trigger point, not from center; the default `transform-origin: center` is wrong for popovers. ```css -/* Radix UI */ +/* Base UI. Radix exposes the same thing as --radix-popover-content-transform-origin */ .popover { - transform-origin: var(--radix-popover-content-transform-origin); + transform-origin: var(--transform-origin); } /* Data attribute fallback */ @@ -59,11 +59,11 @@ Scale in from the trigger point, not from center; the default `transform-origin: .popover[data-side="right"] { transform-origin: center left; } ``` -Start at `scale(0.88)`, never `scale(0)`: nothing appears from nothing. +Start at `scale(0.92)`, never `scale(0)`: nothing appears from nothing. ```css .menu { - transform: scale(0.88); + transform: scale(0.92); opacity: 0; transition: transform 200ms cubic-bezier(0.22, 1, 0.36, 1), opacity 200ms cubic-bezier(0.22, 1, 0.36, 1); diff --git a/external/develop/frontend/ui-animation/references/debugging-symptoms.md b/external/develop/frontend/ui-animation/references/debugging-symptoms.md index 59b0581..e50a129 100644 --- a/external/develop/frontend/ui-animation/references/debugging-symptoms.md +++ b/external/develop/frontend/ui-animation/references/debugging-symptoms.md @@ -43,8 +43,8 @@ Turn "this feels off" into a named cause, then make the smallest fix that addres | Check, in order | Fix | | --- | --- | -| Entrance from `scale(0)` or a bare fade | Start from `scale(0.9-0.95)` plus opacity; nothing real appears from nothing, and a near-full start reads as "it was almost already there". | -| Wrong `transform-origin` | Popovers, dropdowns, and tooltips scale from their trigger, not center (use the library's origin variable, e.g. `--radix-popover-content-transform-origin`). Slowed playback makes a wrong origin unmistakable. | +| Entrance from `scale(0)` or a bare fade | Start from `scale(0.9-0.96)` plus opacity; nothing real appears from nothing, and a near-full start reads as "it was almost already there". | +| Wrong `transform-origin` | Popovers, dropdowns, and tooltips scale from their trigger, not center (use the library's origin variable: `--transform-origin` in Base UI, `--radix-popover-content-transform-origin` in Radix). Slowed playback makes a wrong origin unmistakable. | | Crossfade shows two distinct overlapping states | Add `filter: blur(2px)` during the transition; blur bridges the gap so the eye reads one transforming object instead of two swapped ones. | | Sub-animations on different clocks | Unify the timing family so the component reads as one entity; one slow sub-animation breaks the whole thing. | | Enter and exit mismatched | Exit in the direction of entry, roughly 20% faster and simpler than the entrance; the user already decided, get out of the way. | diff --git a/external/develop/frontend/ui-animation/references/decision-framework.md b/external/develop/frontend/ui-animation/references/decision-framework.md index ec45851..71c040f 100644 --- a/external/develop/frontend/ui-animation/references/decision-framework.md +++ b/external/develop/frontend/ui-animation/references/decision-framework.md @@ -20,6 +20,10 @@ Answer these four questions in order before writing animation code. SKILL.md car | Occasional | Modals, drawers, toasts | Standard animation | | Rare / first-time | Onboarding, feedback forms, celebrations | Can add delight | +**Novelty budget.** Keep most of a surface familiar: about 90% expected motion (or none) and 10% novel treatment. Do not stack high-novelty beats in consecutive sections; put quiet structure between them. + +**One-shot only.** First-run staggers, intro morphs, and login flourishes must not replay on every visit. Gate them with a cookie, local flag, or rewrite so a reload is instant. + ## 2. What is the purpose? Answer "why does this animate?" before writing code. @@ -65,7 +69,7 @@ Sweep these seam classes. The skill is done sweeping when each has either yielde | Feedback gap | A pressable control with no press state | `onClick` / `onPress` on elements with no `:active`, `active:`, or transition | | Teleporting state | Content that swaps, appears, or vanishes with no bridge | `{isOpen &&`, `{show`, `display: none` toggles, accordions and collapses with no height or opacity transition | | Missing spatial story | A surface with no connection to what opened it | Popovers, menus, and panels with no `transform-origin` at the trigger; dismissable surfaces that exit by a different path than they entered | -| Group entrance | An occasionally-viewed grid or list that pops in whole | `.map(` renders on first-load surfaces, where a 30-80ms stagger would help | +| Group entrance | An occasionally-viewed grid or list that pops in whole | `.map(` renders on first-load surfaces, where a 30-50ms stagger would help | | Gesture seam | Draggable or swipeable elements that snap with no physics | Drag and pointer handlers with no spring, no velocity-based dismissal, no rubber-banding at boundaries | | Flat delight moment | Rare, high-emotion states rendered without any motion | First-run, empty, success, and completion components | diff --git a/external/develop/frontend/ui-animation/references/gesture-drag.md b/external/develop/frontend/ui-animation/references/gesture-drag.md index 4438f71..5c61d91 100644 --- a/external/develop/frontend/ui-animation/references/gesture-drag.md +++ b/external/develop/frontend/ui-animation/references/gesture-drag.md @@ -8,6 +8,8 @@ Drag, swipe, and gesture patterns where the user directly manipulates elements. - [Momentum projection](#momentum-projection) - [Boundary damping](#boundary-damping) - [Pointer capture](#pointer-capture) +- [Grab offset](#grab-offset) +- [Axis commitment](#axis-commitment) - [Multi-touch protection](#multi-touch-protection) - [Friction vs hard stops](#friction-vs-hard-stops) - [Rotary drag](#rotary-drag) @@ -113,6 +115,49 @@ function onPointerUp(e: PointerEvent) { Always use `setPointerCapture`; without it, fast swipes escape the element and the drag breaks. +## Grab offset + +Record where inside the element the pointer landed, and hold that offset for the whole drag: + +```ts +let grabY = 0; + +function onPointerDown(e: PointerEvent) { + const r = el.getBoundingClientRect(); + grabY = e.clientY - r.top; // where in the element the finger actually is +} + +function onPointerMove(e: PointerEvent) { + setY(e.clientY - grabY); // not e.clientY, and not a centred element +} +``` + +Positioning from `e.clientY` alone snaps the element's top (or its centre, with a `-50%` translate) to the pointer the instant the drag begins. The element jumps under the finger before it has moved, which breaks 1:1 tracking at the only moment the user is watching for it. Grab a sheet by its handle and it should stay gripped by the handle. + +## Axis commitment + +Track from `pointerdown`, but do not claim an axis until the pointer has travelled about 10px: + +```ts +let axis: "x" | "y" | null = null; + +function onPointerMove(e: PointerEvent) { + const dx = e.clientX - startX; + const dy = e.clientY - startY; + + if (!axis) { + if (Math.hypot(dx, dy) < 10) return; // too early to tell + axis = Math.abs(dx) > Math.abs(dy) ? "x" : "y"; + } + if (axis !== "x") return; // this handler owns horizontal only + // drag... +} +``` + +Deciding on the first `pointermove` reads noise: the first few pixels of a vertical scroll usually carry some horizontal drift, so a swipe-to-dismiss row inside a scrolling list steals the gesture and the list stops scrolling. Once committed, hold the axis until `pointerup`; re-deciding mid-drag makes the element stutter between behaviours. + +This is the custom-handler counterpart to the declarative fix under Carousel axis. `touch-action` tells the browser which axis it may keep, which settles native scrolling; it does nothing for a handler resolving the ambiguity itself. + ## Multi-touch protection Ignore extra touch points after the drag begins; without this, switching fingers mid-drag makes the element jump. @@ -199,23 +244,29 @@ The tick marks themselves carry the feedback during the drag. Scale or darken th ## Swipe-to-dismiss pattern -Combine velocity, distance, and direction for a complete swipe gesture: +Velocity decides; distance is only the tie-breaker. Sign both against the dismissal direction, so "toward dismissal" is positive on each. ```ts -function handleSwipeEnd(direction: "left" | "right", distance: number, velocity: number) { - const shouldDismiss = distance > THRESHOLD || velocity > 0.11; - - if (shouldDismiss) { - // Animate out in swipe direction, handing off the release velocity (see Velocity handoff) - animateOut(direction, velocity); - } else { - // Spring back to origin - springBack(); +const FLICK = 0.11; // px/ms, matches Sonner + +// offset and velocity are both signed along the drag axis: +// positive = moving toward dismissal, negative = back toward rest. +function handleSwipeEnd(offset: number, velocity: number) { + if (Math.abs(velocity) > FLICK) { + // A flick decides on its own, in whichever direction it points. + if (velocity > 0) animateOut(velocity); + else springBack(velocity); + return; } + // Released slowly: position is all the intent there is. + if (offset > THRESHOLD) animateOut(velocity); + else springBack(velocity); } ``` -The exit should continue in the swipe direction with momentum; snapping elsewhere feels wrong. Feed `velocity` into the exit spring's `velocity` option so drag and animation share no seam. +The common bug is `distance > THRESHOLD || velocity > 0.11` against an unsigned velocity. A sheet dragged 80% closed and then flicked back toward open passes the distance test and dismisses anyway, which is the user's cancel gesture doing the opposite of what they asked. Checking magnitude first and sign second is what makes a reversal cancel. + +The exit continues in the swipe direction with momentum; snapping elsewhere feels wrong. Feed `velocity` into the exit spring's `velocity` option so drag and animation share no seam, and into `springBack` too: a cancelled flick that starts from zero reads as a bounce the user did not cause. ## Carousel axis diff --git a/external/develop/frontend/ui-animation/references/interface-sfx.md b/external/develop/frontend/ui-animation/references/interface-sfx.md new file mode 100644 index 0000000..19b6736 --- /dev/null +++ b/external/develop/frontend/ui-animation/references/interface-sfx.md @@ -0,0 +1,41 @@ +# Interface SFX + +Sparse confirmation sounds for rare, high-stakes, or physical-feeling interactions. + +## Scope + +- **IS:** sparse confirmation sounds for rare, high-stakes, or physical-feeling interactions (toggle lock, payment confirm, drag release, success moment). +- **IS NOT:** background music, autoplay, looping UI beds, or replacing visual feedback. + +## Rules + +1. **Unlock from a user gesture.** Create or resume `AudioContext` only inside a click, tap, or keydown handler. Never on page load or in `useEffect` without a gesture. +2. **Stay quiet.** Keep volume well below content audio. Respect system mute and tab mute; if the tab is muted, do not play. +3. **Additive only.** Pair every sound with visual feedback (scale, color, icon swap). Sound confirms what the user already sees; it never carries the message alone. +4. **Same frequency rule as motion.** High-frequency actions stay silent: typing, hover, scrolling, list navigation, repeated toggles. If the user does it dozens of times per session, no sound. +5. **Honor `prefers-reduced-motion`.** Treat it as a signal to skip optional SFX unless the user explicitly enabled sounds in settings. +6. **Keep clips tiny.** Tens of milliseconds, soft attack, no peak that clips. One-shot, non-looping. +7. **One owner.** Route all playback through a tiny `play(id)` helper (preload, volume, mute checks, reduced-motion gate). No ad-hoc `new Audio()` at call sites. + +## Implementation sketch + +```javascript +let ctx; + +function unlockAudio() { + if (!ctx) ctx = new AudioContext(); + if (ctx.state === 'suspended') ctx.resume(); +} + +function playSfx(id) { + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; + if (!ctx || ctx.state !== 'running') return; + // fetch decoded buffer for id, set gain ~0.1-0.2, play once +} +``` + +Wire `unlockAudio` to the first meaningful interaction on the surface that uses SFX. + +## Sources + +Informed by Craft (gustavo-fior Interface SFX) and Raphael Salaja's writing on web sound. Original prose; not copied. diff --git a/external/develop/frontend/ui-animation/references/live-tuning.md b/external/develop/frontend/ui-animation/references/live-tuning.md new file mode 100644 index 0000000..48cb14a --- /dev/null +++ b/external/develop/frontend/ui-animation/references/live-tuning.md @@ -0,0 +1,59 @@ +# Live tuning + +The reverse-engineer workflow runs backwards: record a motion you admire, then fit a curve to it. This is the forward version, for when there is no reference to copy and the table value is contested. Tune against the running component instead of guessing, reloading, and guessing again. + +Start in DevTools. It is already open, it costs nothing, and it covers every bezier in the easing defaults table. + +## Contents + +- [When this is worth it](#when-this-is-worth-it) +- [The bezier editor](#the-bezier-editor) +- [Retiming in the Animations panel](#retiming-in-the-animations-panel) +- [What DevTools cannot do](#what-devtools-cannot-do) +- [Baking the value back](#baking-the-value-back) + +## When this is worth it + +- **The value is contested.** Two people disagree on whether a drawer should be 300ms or 400ms and neither can win the argument from a table. +- **The component is hard to reach.** A toast that needs a form submitted, a sheet three navigations deep. Each rebuild round trip costs more than the setup does once, and an HMR reload loses the state that got you there. +- **The motion is multi-phase.** Stagger offset, blur ramp, and settle interact, so three numbers guessed one reload at a time converge slowly. + +Not for picking a button press duration. The easing defaults table answers that in one line. + +## The bezier editor + +Chrome, Edge, and Firefox render a small curve swatch next to any `transition-timing-function` or `animation-timing-function` in the Styles (or Rules) pane. Click it for a draggable cubic-bezier editor. + +Edits apply live with no rebuild, so retrigger the interaction and watch it under the new curve. The editor emits the literal (`cubic-bezier(0.22, 1, 0.36, 1)`), which is what goes back into source. + +Two things that waste time otherwise: + +- The swatch only exists once the property is valid. On an element with no timing function yet, add the declaration in the `element.style` pane first and the swatch appears. +- Start from the table value, not a built-in preset. Opening on `cubic-bezier(0.22, 1, 0.36, 1)` gives you something to judge against; opening on `ease` means finding the table value by hand. + +Safari has no bezier editor. Tune in Chrome, verify in Safari. + +## Retiming in the Animations panel + +The panel's slow-motion playback is a debugging tool and belongs to the Validation workflow. Two of its controls are tuning tools: + +- **Drag a bar's edges** to change a duration or delay live, then replay. Faster than editing per-item delays for a stagger you are trying to feel out. +- **Read the captured group** to see every element's delay and duration side by side. This is the quickest way to recover the timing of a stagger you did not write, including one a library is generating. + +## What DevTools cannot do + +- **Springs.** No spring editor exists. Reach for the presets and the `visualDuration`/`bounce` framing in `spring-animations.md`: they are perceptual, so they land close on the first try, and a wrong spring usually needs one parameter moved rather than a search. +- **Composing multi-phase choreography.** The panel retimes what already fired; it will not let you build the phases against a shared playhead. + +If a project hits those two often enough to matter, a control-panel library (DialKit, Leva, Tweakpane) earns a dev dependency: a spring control returns a Motion `TransitionConfig` that drops straight into `animate()`, and a timeline dock composes phases. That is a standing decision about the project, not something to install mid-task for one curve. + +## Baking the value back + +A tuning surface is a measuring instrument, not a delivery mechanism. + +- A DevTools edit lives only in that tab and dies on navigation. Paste the literal into source before you believe it. +- Put it next to the other timing constants, so the next person sees it beside the values it has to agree with. +- A control panel leaves more behind than the dock: replace every sampled binding with the real animation, then remove the panel, its root, and the dependency. Framework roots hide themselves in production builds, but a vanilla root does not, and a forgotten one ships a control panel to users. +- Re-check the result against the ten standards. What felt right after ten iterations on a fast laptop still has to clear no layout-property transitions, `prefers-reduced-motion` handled, and interruption retargeting rather than restarting. + +Tune on the real surface. A curve dialled on an isolated demo reads differently against the distance, size, and neighbours of the actual component, and how often the user sees it moves the answer more than any parameter does. diff --git a/external/develop/frontend/ui-animation/references/review-format.md b/external/develop/frontend/ui-animation/references/review-format.md index a87da2b..5fb866b 100644 --- a/external/develop/frontend/ui-animation/references/review-format.md +++ b/external/develop/frontend/ui-animation/references/review-format.md @@ -20,7 +20,7 @@ Measure every animation in the diff against these; a violation is a finding. For 2. **Frequency-appropriate.** Keyboard focus and repeated actions must respond immediately. Flag motion that delays task completion or creates distracting repeated travel; a brief nonblocking transition is not automatically a defect. 3. **Responsive easing.** Entering/exiting elements use `ease-out` or a strong custom curve; built-in CSS easings are too weak for deliberate animation. Flag on sight: `ease-in` on any UI interaction, or weak built-in easing on a deliberate animation (it delays the moment the user watches most). 4. **Sub-300ms UI.** UI animations stay under 300ms; scale duration with distance traveled. Flag on sight: UI duration > 300ms with no stated reason. -5. **Origin and physical correctness.** Popovers, dropdowns, and tooltips scale from their trigger (`transform-origin`), not center; modals stay centered. Flag on sight: `transform-origin: center` on a trigger-anchored popover/dropdown/tooltip, or `scale(0)`/pure-fade entrances with no initial transform (start at `scale(0.85-0.97)` plus opacity). +5. **Origin and physical correctness.** Popovers, dropdowns, and tooltips scale from their trigger (`transform-origin`), not center; modals stay centered. Flag on sight: `transform-origin: center` on a trigger-anchored popover/dropdown/tooltip, or `scale(0)`/pure-fade entrances with no initial transform (start at `scale(0.9-0.96)` plus opacity). 6. **Interruptibility.** Rapidly-triggered or gesture-driven motion (toasts, toggles, drags) must retarget from its current state; prefer CSS transitions or springs over keyframes, which restart from zero. Flag on sight: keyframes on toasts, toggles, or anything added/triggered rapidly. 7. **GPU-only properties.** Animate `transform` and `opacity` only. Flag on sight: animating `width`/`height`/`margin`/`padding`/`top`/`left`; `transition: all` (unbounded property animation); Framer Motion `x`/`y`/`scale` props on motion that runs while the page is busy; updating a CSS variable on a parent to drive a child transform (style recalc storm). 8. **Accessibility.** Inspect generated hover gating, including Tailwind v4's built-in media query. Exercise reduced-motion behavior and the same keyboard/touch task. Flag spatial motion without an appropriate reduced-motion alternative. @@ -51,7 +51,7 @@ Required first part of every review. Markdown table, one row per issue; never a | `transform: scale(0)` | `transform: scale(0.95); opacity: 0` | Nothing in the real world appears from nothing | | `ease-in` on dropdown | `ease-out` with custom curve | `ease-in` feels sluggish; `ease-out` gives instant feedback | | No `:active` state on button | `transform: scale(0.97)` on `:active` with `transition-duration: 0s` | Buttons must feel responsive to press | -| `transform-origin: center` on popover | `transform-origin: var(--radix-popover-content-transform-origin)` | Popovers scale from trigger (modals stay centered) | +| `transform-origin: center` on popover | `transform-origin: var(--transform-origin)` | Popovers scale from trigger (modals stay centered) | ## Review checklist diff --git a/external/develop/frontend/ui-animation/references/transition-recipes.md b/external/develop/frontend/ui-animation/references/transition-recipes.md index 73961d1..9906774 100644 --- a/external/develop/frontend/ui-animation/references/transition-recipes.md +++ b/external/develop/frontend/ui-animation/references/transition-recipes.md @@ -356,7 +356,7 @@ See also: `contextual-animations.md` § Contextual icon swaps for the Motion/Ani Origin-aware dropdown with open/close animations. JS handles close-state cleanup. -See also: `component-patterns.md` § Popovers and dropdowns for Radix UI transform-origin and scale patterns. +See also: `component-patterns.md` § Popovers and dropdowns for library transform-origin and scale patterns. ```html
diff --git a/external/develop/openspec/openspec-apply-change/SKILL.md b/external/develop/openspec/openspec-apply-change/SKILL.md index 098f63f..ed033ba 100644 --- a/external/develop/openspec/openspec-apply-change/SKILL.md +++ b/external/develop/openspec/openspec-apply-change/SKILL.md @@ -1,6 +1,6 @@ --- name: openspec-apply-change -description: Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks. +description: Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks. Also use when the user says "openspec apply", "opsx apply", or "openspec implement". allowed-tools: Bash(openspec:*) license: MIT compatibility: Requires openspec CLI. @@ -13,6 +13,17 @@ Implement tasks from an OpenSpec change. **Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store ` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it. + +One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`. + +Otherwise, with no root, what happens next depends on how this workflow was reached: + +- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup. +- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store `), or continue without OpenSpec for this request. Wait for their answer. + +In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it. + **Input**: Optionally specify a change name (e.g., `/openspec-apply-change add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. **Steps** @@ -48,9 +59,12 @@ Implement tasks from an OpenSpec change. - Dynamic instruction based on current state - Optional `context`: current required project instruction input from the selected root - Optional `operationGuidance`: current advisory guidance for apply + - `missingArtifacts` (when present): required artifact ids with no output **Handle states:** - - If `state: "blocked"` (missing artifacts): show message, suggest using `/openspec-continue-change` (if it is not installed, run `openspec status --change "" --json` to see the next artifact and `openspec instructions --change "" --json` for how to create it) + - If `state: "blocked"`: show the message and pause implementation. + - If `missingArtifacts` is non-empty: suggest using `/openspec-continue-change` to create them. + - Otherwise, follow the CLI instruction to create or repair the schema-configured tracking file from existing planning artifacts. Do not assume another artifact is ready or start implementation while blocked. - If `state: "all_done"`: congratulate, suggest archive - Otherwise: proceed to implementation diff --git a/external/develop/openspec/openspec-archive-change/SKILL.md b/external/develop/openspec/openspec-archive-change/SKILL.md index 5f34ed5..dd98c44 100644 --- a/external/develop/openspec/openspec-archive-change/SKILL.md +++ b/external/develop/openspec/openspec-archive-change/SKILL.md @@ -1,6 +1,6 @@ --- name: openspec-archive-change -description: Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete. +description: Archive a completed OpenSpec change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete. Also use when the user says "openspec archive" or "opsx archive". allowed-tools: Bash(openspec:*) license: MIT compatibility: Requires openspec CLI. @@ -13,6 +13,17 @@ Archive a completed change in the experimental workflow. **Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store ` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it. + +One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`. + +Otherwise, with no root, what happens next depends on how this workflow was reached: + +- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup. +- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store `), or continue without OpenSpec for this request. Wait for their answer. + +In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it. + `` is the spec directory relative to `specs/` (for example, `user-auth` or `identity/user-auth`). Preserve the full path from each delta spec when resolving its main spec. **Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. @@ -76,7 +87,11 @@ Archive a completed change in the experimental workflow. Read the tasks file (typically `tasks.md`) to check for incomplete tasks. - Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete). + A checkbox is complete when its only content is `x` or `X`; spacing inside + the brackets does not matter, so `- [ x]` counts as complete too. Every + other marker is incomplete - `- [ ]`, an empty `- []`, and markers OpenSpec + assigns no meaning to such as `- [~]` or `- [-]`. Never read an unfamiliar + marker as complete. **If incomplete tasks found:** - Display warning showing count of incomplete tasks @@ -94,17 +109,23 @@ Archive a completed change in the experimental workflow. **If delta specs exist:** - Compare each delta spec with its corresponding main spec at `/openspec/specs//spec.md` (use the store-aware `planningHome.root` from step 2, not a hardcoded repo path) + - A missing main spec is **not automatically** "already synced". For a new capability, the main spec is an *output* of the sync, not an input: + - If the delta has MODIFIED or RENAMED requirements, report that only ADDED requirements can create a new main spec and mark that capability as sync-blocked. Never invent a requirement that has no current version. + - Otherwise, if the delta has only REMOVED requirements and the change's `.openspec.yaml` declares `retire_capabilities: true`, the capability is already retired: count it as already synced, warn that there is nothing left to remove, and do not recreate the main spec. Apply this rule both now and when verifying a completed sync. + - Otherwise, if the delta has no ADDED requirements, report that no sync is possible and mark that capability as sync-blocked. For a REMOVED-only delta, warn that there is no main spec to remove from and leave the main-spec tree unchanged. `openspec archive` refuses the unmarked REMOVED-only case with `Spec must have at least one requirement`. + - Otherwise, count the capability as needing sync and name it in the summary (`: new main spec will be created`). If the delta also has REMOVED requirements, warn that they will be ignored because there is no main spec to remove from. The sync creates the main spec from only the delta's ADDED requirements, exactly as `openspec archive` does. - Determine what changes would be applied (adds, modifications, removals, renames) - - Show a combined summary before prompting + - Continue assessing the remaining capabilities even when one is sync-blocked. Show a combined summary before prompting. **Prompt options:** - - If changes needed: "Sync now (recommended)", "Archive without syncing" - - If already synced: "Archive now", "Sync anyway", "Cancel" + - If any capability is sync-blocked: explain why and offer only "Archive without syncing", "Cancel" + - Otherwise, if changes needed: "Sync now (recommended)", "Archive without syncing" + - Otherwise, if already synced: "Archive now", "Sync anyway", "Cancel" Route on the answer: - "Cancel" — stop, do not archive - "Archive without syncing" or "Archive now" — proceed to archive - - "Sync now" or "Sync anyway" — sync, then verify (below) + - "Sync now" or "Sync anyway" — sync, then verify (below). Do not start any sync while a capability is sync-blocked; explain the blocker and repeat the available choices. - Anything else — ask again rather than archiving Before a selected sync writes any main spec, run @@ -118,7 +139,7 @@ Archive a completed change in the experimental workflow. Then run the `openspec-sync-specs` workflow inline (agent-driven intelligent merge) for change '', passing the delta spec analysis and the fetched specs-rule snapshot from above, and wait for it to finish. The inline sync must reuse that snapshot without fetching `specs` instructions again. Do not delegate it to a background task — step 5 would move `changeRoot` out from under a sync that is still reading it, leaving the change archived and the main specs never updated. If your agent can only run it by delegation, delegate synchronously and wait for the result. - Then re-run the comparison from the top of this step against every capability that has a delta spec in `artifactPaths.specs.existingOutputPaths` — not only the ones the sync reports it touched. A successful sync leaves nothing left to apply, so each capability must now read as already synced: + Then re-run the comparison from the top of this step, including the explicitly retired, missing-spec case, against every capability that has a delta spec in `artifactPaths.specs.existingOutputPaths` — not only the ones the sync reports it touched. A successful sync leaves nothing left to apply, so each capability must now read as already synced: - ADDED requirements present - MODIFIED requirements carrying the scenario and description changes named in the delta, with their other scenarios intact - REMOVED requirements gone — and where this sync retired a capability (removed its last requirement, leaving `## Requirements` empty), its main spec deleted rather than left empty; a spec the sync deliberately kept and reported is also a match diff --git a/external/develop/openspec/openspec-bulk-archive-change/SKILL.md b/external/develop/openspec/openspec-bulk-archive-change/SKILL.md index 252e1dd..b388f0b 100644 --- a/external/develop/openspec/openspec-bulk-archive-change/SKILL.md +++ b/external/develop/openspec/openspec-bulk-archive-change/SKILL.md @@ -1,6 +1,6 @@ --- name: openspec-bulk-archive-change -description: Archive multiple completed changes at once. Use when archiving several parallel changes. +description: Archive multiple completed OpenSpec changes at once. Use when archiving several parallel changes. Also use for a plural archive request - "openspec bulk-archive", "opsx bulk-archive", "openspec archive all", or "openspec archive these changes". allowed-tools: Bash(openspec:*) license: MIT compatibility: Requires openspec CLI. @@ -15,6 +15,17 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig **Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store ` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it. + +One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`. + +Otherwise, with no root, what happens next depends on how this workflow was reached: + +- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup. +- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store `), or continue without OpenSpec for this request. Wait for their answer. + +In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it. + `` is the spec directory relative to `specs/` (for example, `user-auth` or `identity/user-auth`). Preserve the full path from each delta spec when resolving its main spec. **Input**: None required (prompts for selection) @@ -70,7 +81,9 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig - Note which artifacts are `done` vs other states b. **Task completion** - Read `artifactPaths.tasks.existingOutputPaths` from status JSON - - Count `- [ ]` (incomplete) vs `- [x]` (complete) + - Complete means the checkbox holds only `x`/`X`, ignoring spacing + (`- [ x]` is complete); every other marker is incomplete (`- [ ]`, + `- []`, and unfamiliar ones such as `- [~]` or `- [-]`) - If no tasks file exists, note as "No tasks" c. **Delta specs** - Check `artifactPaths.specs.existingOutputPaths` from status JSON @@ -81,6 +94,14 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig lookup for that change; do not infer deltas from unrelated artifacts. - Evaluate this independently for every change, including mixed-schema batches where some schemas have no `specs` artifact. + + d. **Archive target** - Compute each change's target name once and record it as that change's `` + - Use the change name as-is when it already starts with a `YYYY-MM-DD-` prefix; otherwise prepend the current date as `YYYY-MM-DD-` (same rule as `openspec archive`) + - Check whether `/archive/` already exists + - If it exists, or another selected change resolves to the same target name, mark every such change `Blocked` with `Archive directory already exists` + - A blocked change is never synced or moved: show it as `Blocked` in the step 6 table, leave it out of conflict resolution (resolve its conflicts using only the other changes), and record it as Failed in step 8d + - Checking here, before any main spec is written, matches `openspec archive`: a collision found after sync would leave main specs rewritten for an archive that never happened + 4. **Detect spec conflicts** Build a map keyed by ``, the exact path relative to `specs/`: @@ -153,8 +174,8 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig Route on the answer by intent, not by exact label — you wrote these labels, so match what the user picked rather than the wording above: - "Cancel" — stop, do not archive. Report that nothing was archived and skip the remaining steps. - - The archive-everything option — proceed with every selected change - - The ready-only option — proceed with only the changes the step 6 table marks `Ready` or `Ready*`, and record the rest as Skipped in step 8d. If a `Ready*` change's conflict partner is skipped, re-derive that conflict's resolution using only the changes being archived. + - The archive-everything option — proceed with every selected change that is not `Blocked` + - The ready-only option — proceed with only the changes the step 6 table marks `Ready` or `Ready*`, and record the rest as Skipped in step 8d, except `Blocked` changes, which stay Failed with `Archive directory already exists`. If a `Ready*` change's conflict partner is skipped, re-derive that conflict's resolution using only the changes being archived. - Anything else — ask again rather than archiving Before step 8 writes the first main spec or moves any change, fetch every @@ -199,13 +220,20 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig c. **Perform the archive**: - Target name: use the change name as-is when it already starts with a `YYYY-MM-DD-` prefix; otherwise prepend the current date as `YYYY-MM-DD-` (same rule as `openspec archive`). + Target name: use the `` recorded for this change in step 3d, unchanged. Never recompute it here: a batch that runs past midnight would check one date in step 3 and move to another. + + **Check if target already exists:** + - Check again immediately before the move, even though step 3 already checked: the target can appear mid-batch + - If yes: record this change as Failed with `Archive directory already exists`, leave `changeRoot` where it is, report any main specs step 8a already synced for it, and continue with the remaining changes + - If no: move `changeRoot` to the archive directory ```bash mkdir -p "/archive" mv "" "/archive/" ``` + **Confirm the move did not nest:** `mv` exits 0 even when the target appeared after the check, moving the change *inside* it. If `/archive//` now exists (the last path segment of `changeRoot`), move that directory back to `changeRoot` and record this change as Failed with `Archive directory already exists`. Never report it as archived. + d. **Track outcome** for each change: - Success: archived successfully - Failed: error during archive or spec verification (record error) @@ -320,8 +348,9 @@ No active changes found. Create a new change to get started. - Never archive after the user cancels the confirmation — a cancelled batch archives nothing - Track and report all outcomes (success/skip/fail) - Preserve .openspec.yaml when moving to archive -- Archive directory target uses current date: YYYY-MM-DD-; a name that already starts with a `YYYY-MM-DD-` prefix is used as-is (never stack a second date) +- Archive directory target uses the current date, computed once in step 3d and reused at the move: YYYY-MM-DD-; a name that already starts with a `YYYY-MM-DD-` prefix is used as-is (never stack a second date) - If archive target exists, fail that change but continue with others +- Check every archive target in step 3, before the first main-spec write; a change whose target exists is never synced or moved - If sync is requested, run the `openspec-sync-specs` workflow inline (agent-driven) for each change with included delta specs - Carry the per-delta `includedDeltas` and `excludedDeltas` decisions into execution; sync and verify only included deltas - Report every excluded delta as `sync skipped` without treating the archive itself as skipped diff --git a/external/develop/openspec/openspec-continue-change/SKILL.md b/external/develop/openspec/openspec-continue-change/SKILL.md index 5991b06..1693da7 100644 --- a/external/develop/openspec/openspec-continue-change/SKILL.md +++ b/external/develop/openspec/openspec-continue-change/SKILL.md @@ -1,6 +1,6 @@ --- name: openspec-continue-change -description: Continue working on an OpenSpec change by creating the next artifact. Use when the user wants to progress their change, create the next artifact, or continue their workflow. +description: Continue working on an OpenSpec change by creating the next artifact. Use when the user wants to progress their change, create the next artifact, or continue their workflow. Also use when the user says "openspec continue" or "opsx continue". allowed-tools: Bash(openspec:*) license: MIT compatibility: Requires openspec CLI. @@ -13,6 +13,17 @@ Continue working on a change by creating the next artifact. **Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store ` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it. + +One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`. + +Otherwise, with no root, what happens next depends on how this workflow was reached: + +- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup. +- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store `), or continue without OpenSpec for this request. Wait for their answer. + +In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it. + **Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. **Steps** diff --git a/external/develop/openspec/openspec-explore/SKILL.md b/external/develop/openspec/openspec-explore/SKILL.md index 2ff15bf..5b4534e 100644 --- a/external/develop/openspec/openspec-explore/SKILL.md +++ b/external/develop/openspec/openspec-explore/SKILL.md @@ -1,6 +1,6 @@ --- name: openspec-explore -description: Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change. +description: Enter OpenSpec explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements in a project that uses OpenSpec. Use when the user wants to think through something before or during an OpenSpec change. Also use when the user says "openspec explore" or "opsx explore". allowed-tools: Bash(openspec:*) license: MIT compatibility: Requires openspec CLI. @@ -11,12 +11,23 @@ metadata: Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes. -**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, investigate the codebase, and run read-only commands or tools without confirmation, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create or update OpenSpec change artifacts (proposals, designs, specs) within a confirmed scope—that's capturing thinking, not implementing. Answering design or clarifying questions is never consent to write. Before the first write-capable action, name the artifacts or files you would change and what you would do, ask a direct yes/no question, and wait for the user's confirmation in a separate message. Confirmation covers only the scope you described; ask again before expanding it. For a new change, scaffold it first as described below. +**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, investigate the codebase, and run read-only commands or tools without confirmation, but you must NEVER write code or implement features. If the user asks you to implement something, do not start it here: say that explore mode does not implement, and point them at `/openspec-propose`, which turns the discussion into a change. The work happens from that change, never from explore mode. You MAY create or update OpenSpec change artifacts (proposals, designs, specs) within a confirmed scope—that's capturing thinking, not implementing. Answering design or clarifying questions is never consent to write. Before the first write-capable action, name the artifacts or files you would change and what you would do, ask a direct yes/no question, and wait for the user's confirmation in a separate message. Confirmation covers only the scope you described; ask again before expanding it. An explicit request from the user to capture the exploration as a new change is itself that confirmation, covering the change and the change artifacts the request names; scaffold it first as described below. **This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore. **Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store ` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it. + +One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`. + +Otherwise, with no root, what happens next depends on how this workflow was reached: + +- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup. +- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store `), or continue without OpenSpec for this request. Wait for their answer. + +In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it. + --- ## The Stance @@ -141,14 +152,14 @@ Think freely. When insights crystallize, you might offer: - "This feels solid enough to start a change. Want me to create a proposal?" - Or keep exploring - no pressure to formalize -If the user asks you to capture the exploration as a new change, transition seamlessly into the requested capture: +If the user asks you to capture the exploration as a new change, that request is the confirmation required above. It covers scaffolding that change and creating the change artifacts the request names, and nothing else. This holds only when the request is theirs: a yes to an offer you made confirms only the scope your offer itself named, so name the change and the artifacts in the offer. Don't re-ask for what they already asked for; do ask before anything beyond it. Transition seamlessly into the requested capture: 1. Run `openspec new change ""` (with `--store ` when applicable) before creating any artifacts. Never create a new change directory under `openspec/changes/` by hand; the CLI scaffold creates required metadata such as `.openspec.yaml`. Keep the selected `--store ` on every applicable follow-up `status` and `instructions` command. 2. Run `openspec status --change "" --json` (append the confirmed `--store ""` only for a registered standalone store), then process the requested artifacts in dependency order. For each requested artifact that is `ready`, run `openspec instructions "" --change "" --json` (append the confirmed `--store ""` only for a registered standalone store). Before creating a requested artifact, evaluate any condition in its own `instruction` against the explored change; record a deliberate skip instead when the condition does not apply. If a requested artifact is blocked by a direct prerequisite the user did not request, run `openspec instructions "" --change "" --json` (append the confirmed `--store ""` only for a registered standalone store) for that prerequisite whether it is `ready` or `blocked`. If its own `instruction` states a condition, evaluate that condition against the explored change and record a deliberate skip only when the condition does not apply. If the condition applies, or the prerequisite is not conditional, treat it as a normal prerequisite and ask before expanding the capture. Do not create an unrequested prerequisite unless the user approves. 3. Follow the returned `template` and `instruction` fields. Read completed dependency files listed in `dependencies`, and apply `context` and `rules` as constraints without copying them into the artifact. If the instruction delegates creation to a specific skill or command, invoke it; otherwise write the artifact to `resolvedOutputPath`, using the instruction to choose a concrete path when it is a glob. Verify that the selected concrete output exists. 4. After creating each artifact, re-run `openspec status --change "" --json` (append the confirmed `--store ""` only for a registered standalone store) and continue until every requested artifact is `done`, `skipped`, or was deliberately skipped because its own `instruction` stated a condition that did not apply. Tell the user about a deliberate conditional skip, remember it, and do not reconsider it. Dependencies are enablers, not gates: if a requested artifact is still `blocked` only because you deliberately skipped a conditional prerequisite, run `openspec instructions "" --change "" --json` (append the confirmed `--store ""` only for a registered standalone store) despite the blocked status, then create it using step 3 only when those recorded conditional skips are its sole missing dependencies. If a requested artifact is blocked by a prerequisite the user did not ask to capture and cannot be conditionally skipped, explain that dependency and ask before expanding the capture. -Capture the artifact(s) the user requested without asking them to invoke another workflow command. If they asked only to start a change, stop after scaffolding and show its status. +Capture the artifact(s) the user requested without asking them to invoke another workflow command. If they asked only to start a change, stop after scaffolding and show its status. When the requested capture is done, stop there and name where the work continues: `/openspec-propose` writes the remaining planning artifacts, and `/openspec-apply-change` implements the change once tasks exist. Capturing artifacts never starts implementing them. ### When a change exists @@ -304,7 +315,7 @@ You: That changes everything. There's no required ending. Discovery might: -- **Flow into a proposal**: "Ready to start? I can create a change proposal." +- **Flow into a proposal**: "Ready to start? Run `/openspec-propose` and this becomes a change." - **Result in artifact updates**: "Updated design.md with these decisions" - **Just provide clarity**: User has what they need, moves on - **Continue later**: "We can pick this up anytime" @@ -321,7 +332,7 @@ When it feels like things are crystallizing, you might summarize: **Open questions**: [if any remain] **Next steps** (if ready): -- Create a change proposal +- Turn this into a change: `/openspec-propose` - Keep exploring: just keep talking ``` @@ -331,11 +342,11 @@ But this summary is optional. Sometimes the thinking IS the value. ## Guardrails -- **Don't implement** - Never write code or implement features. Workflow configuration counts too: creating or editing schemas, templates, or `openspec/config.yaml` is a change, not thinking. Creating or updating OpenSpec change artifacts within the confirmed scope is fine, writing anything else is not. +- **Don't implement** - Never write code or implement features. Workflow configuration counts too: creating or editing schemas, templates, or `openspec/config.yaml` is a change, not thinking. Creating or updating OpenSpec change artifacts within the confirmed scope is fine, writing anything else is not. When the user is ready to build, name the handoff rather than starting: `/openspec-propose` turns the discussion into a change, and the work happens there. - **Don't fake understanding** - If something is unclear, dig deeper - **Don't rush** - Discovery is thinking time, not task time - **Don't force structure** - Let patterns emerge naturally -- **Don't auto-capture** - Offer to save insights, don't just do it. Read-only commands and tools need no confirmation. Before the first write-capable action—including `openspec new change` or another command that writes files—name the artifacts or files and proposed changes, ask a direct yes/no question, and wait for explicit confirmation in a separate user message. That confirmation covers only the described scope; ask again before expanding it. Answers to design or clarifying questions are never consent to write. +- **Don't auto-capture** - Offer to save insights, don't just do it. Read-only commands and tools need no confirmation. Before the first write-capable action—including `openspec new change` or another command that writes files—name the artifacts or files and proposed changes, ask a direct yes/no question, and wait for explicit confirmation in a separate user message. That confirmation covers only the described scope; ask again before expanding it. Answers to design or clarifying questions are never consent to write. That rule governs `openspec new change` whenever you are the one proposing the capture; the user's own capture request is the exception, handled in the capture transition above. - **Don't manually scaffold changes** - Never create a new change directory under `openspec/changes/` by hand. Always use `openspec new change ""` (with `--store ` when applicable) so required metadata such as `.openspec.yaml` is created before writing artifacts. - **Do visualize** - A good diagram is worth many paragraphs - **Do explore the codebase** - Ground discussions in reality diff --git a/external/develop/openspec/openspec-ff-change/SKILL.md b/external/develop/openspec/openspec-ff-change/SKILL.md index 72a9562..f7b4b69 100644 --- a/external/develop/openspec/openspec-ff-change/SKILL.md +++ b/external/develop/openspec/openspec-ff-change/SKILL.md @@ -1,6 +1,6 @@ --- name: openspec-ff-change -description: Fast-forward through OpenSpec artifact creation. Use when the user wants to quickly create all artifacts needed for implementation without stepping through each one individually. +description: Fast-forward through OpenSpec artifact creation. Use when the user wants to quickly create all artifacts needed for implementation without stepping through each one individually. Also use when the user says "openspec ff" or "opsx ff". allowed-tools: Bash(openspec:*) license: MIT compatibility: Requires openspec CLI. @@ -13,6 +13,17 @@ Fast-forward through artifact creation - generate everything needed to start imp **Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store ` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it. + +One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`. + +Otherwise, with no root, what happens next depends on how this workflow was reached: + +- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup. +- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store `), or continue without OpenSpec for this request. Wait for their answer. + +In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it. + **Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build. **Steps** diff --git a/external/develop/openspec/openspec-new-change/SKILL.md b/external/develop/openspec/openspec-new-change/SKILL.md index 9aea11d..432cc45 100644 --- a/external/develop/openspec/openspec-new-change/SKILL.md +++ b/external/develop/openspec/openspec-new-change/SKILL.md @@ -1,6 +1,6 @@ --- name: openspec-new-change -description: Start a new OpenSpec change using the experimental artifact workflow. Use when the user wants to create a new feature, fix, or modification with a structured step-by-step approach. +description: Start a new OpenSpec change using the experimental artifact workflow. Use when the user wants to create a new feature, fix, or modification with a structured step-by-step approach. Also use when the user says "openspec new change" or "opsx new". allowed-tools: Bash(openspec:*) license: MIT compatibility: Requires openspec CLI. @@ -13,6 +13,17 @@ Start a new change using the experimental artifact-driven approach. **Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store ` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it. + +One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`. + +Otherwise, with no root, what happens next depends on how this workflow was reached: + +- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup. +- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store `), or continue without OpenSpec for this request. Wait for their answer. + +In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it. + **Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build. **Steps** diff --git a/external/develop/openspec/openspec-onboard/SKILL.md b/external/develop/openspec/openspec-onboard/SKILL.md index fb3f13b..66de72f 100644 --- a/external/develop/openspec/openspec-onboard/SKILL.md +++ b/external/develop/openspec/openspec-onboard/SKILL.md @@ -1,6 +1,6 @@ --- name: openspec-onboard -description: Guided onboarding for OpenSpec - walk through a complete workflow cycle with narration and real codebase work. +description: Guided onboarding for OpenSpec - walk through a complete workflow cycle with narration and real codebase work. Also use when the user says "openspec onboard" or "opsx onboard". allowed-tools: Bash(openspec:*) license: MIT compatibility: Requires openspec CLI. @@ -13,6 +13,17 @@ Guide the user through their first complete OpenSpec workflow cycle. This is a t **Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store ` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it. + +One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`. + +Otherwise, with no root, what happens next depends on how this workflow was reached: + +- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup. +- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store `), or continue without OpenSpec for this request. Wait for their answer. + +In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it. + --- ## Preflight @@ -220,6 +231,8 @@ Here's a draft proposal: --- +# Proposal + ## Why [1-2 sentences explaining the problem/opportunity] @@ -287,6 +300,8 @@ Here's the spec: --- +# Spec Delta + ## ADDED Requirements ### Requirement: @@ -326,6 +341,8 @@ Here's the design: --- +# Design + ## Context [Brief context about the current state] @@ -371,6 +388,8 @@ Here are the implementation tasks: --- +# Tasks + ## 1. [Category or file] - [ ] 1.1 [Specific task] — verify: [test, command, observable behavior, or delivered artifact] @@ -472,23 +491,18 @@ This same rhythm works for any size change—a small fix or a major feature. ## Command Reference -**Core workflow:** +**The commands you have installed:** - | Command | What it does | - |-------------------|--------------------------------------------| + | Command | What it does | + |------------------|--------------------------------------------| | `/openspec-propose` | Create a change and generate all artifacts | | `/openspec-explore` | Think through problems before/during work | | `/openspec-apply-change` | Implement tasks from a change | | `/openspec-archive-change` | Archive a completed change | - -**Additional commands** (only if installed - availability depends on your profile): - - | Command | What it does | - |--------------------|----------------------------------------------------------| - | `/openspec-new-change` | Start a new change, step through artifacts one at a time | - | `/openspec-continue-change` | Continue working on an existing change | - | `/openspec-ff-change` | Fast-forward: create all artifacts at once | - | `/openspec-verify-change` | Verify implementation matches artifacts | + | `/openspec-new-change` | Start a new change, one artifact at a time | + | `/openspec-continue-change` | Continue working on an existing change | + | `/openspec-ff-change` | Fast-forward: create all artifacts at once | + | `/openspec-verify-change` | Verify implementation matches artifacts | --- @@ -508,8 +522,8 @@ If the user says they need to stop, want to pause, or seem disengaged: ``` No problem! Your change is saved at the `changeRoot` reported by `openspec status --change "" --json`. -To pick up where we left off later: -- `/openspec-continue-change ` - Resume artifact creation (if installed; otherwise `openspec status --change "" --json` shows the next artifact) +To pick up where we left off later, `openspec status --change "" --json` shows exactly where the change stands. +- `/openspec-continue-change ` - Resume artifact creation - `/openspec-apply-change ` - Jump to implementation (if tasks exist) The work won't be lost. Come back whenever you're ready. @@ -524,23 +538,18 @@ If the user says they just want to see the commands or skip the tutorial: ``` ## OpenSpec Quick Reference -**Core workflow:** +**The commands you have installed:** | Command | What it does | |--------------------------|--------------------------------------------| - | `/openspec-propose ` | Create a change and generate all artifacts | - | `/openspec-explore` | Think through problems (no code changes) | - | `/openspec-apply-change ` | Implement tasks | - | `/openspec-archive-change ` | Archive when done | - -**Additional commands** (only if installed - availability depends on your profile): - - | Command | What it does | - |---------------------------|-------------------------------------| - | `/openspec-new-change ` | Start a new change, step by step | - | `/openspec-continue-change ` | Continue an existing change | - | `/openspec-ff-change ` | Fast-forward: all artifacts at once | - | `/openspec-verify-change ` | Verify implementation | + | `/openspec-propose ` | Create a change and generate all artifacts | + | `/openspec-explore` | Think through problems (no code changes) | + | `/openspec-apply-change ` | Implement tasks | + | `/openspec-archive-change ` | Archive when done | + | `/openspec-new-change ` | Start a new change, step by step | + | `/openspec-continue-change ` | Continue an existing change | + | `/openspec-ff-change ` | Fast-forward: all artifacts at once | + | `/openspec-verify-change ` | Verify implementation | Try `/openspec-propose` to start your first change. ``` diff --git a/external/develop/openspec/openspec-propose/SKILL.md b/external/develop/openspec/openspec-propose/SKILL.md index 49454cd..7f41c4b 100644 --- a/external/develop/openspec/openspec-propose/SKILL.md +++ b/external/develop/openspec/openspec-propose/SKILL.md @@ -1,6 +1,6 @@ --- name: openspec-propose -description: Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation. +description: Propose a new OpenSpec change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation. Also use when the user says "openspec propose" or "opsx propose". allowed-tools: Bash(openspec:*) license: MIT compatibility: Requires openspec CLI. @@ -27,6 +27,17 @@ When the user is ready to implement, they must start the apply workflow explicit **Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store ` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it. + +One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`. + +Otherwise, with no root, what happens next depends on how this workflow was reached: + +- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup. +- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store `), or continue without OpenSpec for this request. Wait for their answer. + +In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it. + **Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build. **Steps** @@ -44,7 +55,7 @@ When the user is ready to implement, they must start the apply workflow explicit 2. **Load project context** - Run `openspec context --json` from the current working directory (or `openspec context --json --store ""` when a registered store was explicitly selected). Use the returned `root.path` as the authoritative OpenSpec root. If context reports `no_openspec_root`, stop without creating or changing any files. Offer `openspec init` and wait for the user to request initialization. Do not initialize automatically or run `openspec new change`. After initialization, rerun this context check before continuing. For any other context failure, stop and report the error; do not fall back to the current directory or run later OpenSpec commands without the selected store. + Run `openspec context --json` from the current working directory (or `openspec context --json --store ""` when a registered store was explicitly selected). Use the returned `root.path` as the authoritative OpenSpec root. If context reports `no_openspec_root`, stop without creating or changing any files and follow the **Project check** above for how this workflow was reached. Offer `openspec init` only for an explicit OpenSpec request, and wait for the user to request initialization. Do not initialize automatically or run `openspec new change`. After initialization, rerun this context check before continuing. For any other context failure, stop and report the error; do not fall back to the current directory or run later OpenSpec commands without the selected store. Only when context returns a resolved `root.path`, read `/openspec/config.yaml`. Use `config.yml` only when `config.yaml` does not exist. If neither file exists, continue without project context. Do not fall back to `config.yml` if `config.yaml` is unreadable or invalid. diff --git a/external/develop/openspec/openspec-sync-specs/SKILL.md b/external/develop/openspec/openspec-sync-specs/SKILL.md index d12d56b..5683f84 100644 --- a/external/develop/openspec/openspec-sync-specs/SKILL.md +++ b/external/develop/openspec/openspec-sync-specs/SKILL.md @@ -1,6 +1,6 @@ --- name: openspec-sync-specs -description: Sync delta specs from a change to main specs. Use when the user wants to update main specs with changes from a delta spec, without archiving the change. +description: Sync delta specs from an OpenSpec change to main specs. Use when the user wants to update main specs with changes from a delta spec, without archiving the change. Also use when the user says "openspec sync" or "opsx sync". allowed-tools: Bash(openspec:*) license: MIT compatibility: Requires openspec CLI. @@ -15,6 +15,17 @@ This is an **agent-driven** operation - you will read delta specs and directly e **Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store ` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it. + +One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`. + +Otherwise, with no root, what happens next depends on how this workflow was reached: + +- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup. +- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store `), or continue without OpenSpec for this request. Wait for their answer. + +In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it. + `` is the spec directory relative to `specs/` (for example, `user-auth` or `identity/user-auth`). Preserve the full path from each delta spec when resolving its main spec. **Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. @@ -95,6 +106,13 @@ This is an **agent-driven** operation - you will read delta specs and directly e b. **Read the main spec** at `/openspec/specs//spec.md` (may not exist yet) + **If it does not exist yet** (a new capability), match what `openspec archive` does: + only ADDED requirements may be applied - step d creates the spec from them. + MODIFIED and RENAMED have no requirement to act on, so stop the sync for that + capability and report that its main spec does not exist and only ADDED is allowed + for a new spec; never invent the missing requirement. REMOVED has nothing to + remove - skip it and warn. + c. **Apply changes intelligently**: **ADDED Requirements:** @@ -142,6 +160,14 @@ This is an **agent-driven** operation - you will read delta specs and directly e (this is what `openspec archive` does; it warns and moves on) d. **Create new main spec** if capability doesn't exist yet: + - Only when the delta has ADDED requirements to put in it and no MODIFIED or + RENAMED requirements blocked this capability in step b. Otherwise create nothing + and leave the specs directory untouched. For a REMOVED-only delta, if the change's + `.openspec.yaml` declares `retire_capabilities: true`, report it as already retired + and continue without recreating the spec. Without that marker, report the sync as blocked: + `openspec archive` rejects it with `Spec must have at least one requirement`. + An empty delta has no operations to sync; report it as blocked too. + Never write an empty `## Requirements` section. - Create `/openspec/specs//spec.md` - Add Purpose section: copy the delta's `## Purpose` body verbatim when it has one (this is what `openspec archive` does); only write a brief TBD placeholder when it does not @@ -166,6 +192,8 @@ This is an **agent-driven** operation - you will read delta specs and directly e **Delta Spec Format Reference** ```markdown +# Spec Delta + ## Purpose Only on a delta that introduces a brand-new capability. Seeds the new main spec. diff --git a/external/develop/openspec/openspec-update-change/SKILL.md b/external/develop/openspec/openspec-update-change/SKILL.md index 24c9f88..9aae524 100644 --- a/external/develop/openspec/openspec-update-change/SKILL.md +++ b/external/develop/openspec/openspec-update-change/SKILL.md @@ -1,6 +1,6 @@ --- name: openspec-update-change -description: Update an OpenSpec change by revising its existing planning artifacts and keeping them coherent with one another. Use when the user wants to revise a change's plan, fold new decisions into it, or reconcile its artifacts after an edit. Never edits code. +description: Update an OpenSpec change by revising its existing planning artifacts and keeping them coherent with one another. Use when the user wants to revise a change's plan, fold new decisions into it, or reconcile its artifacts after an edit. Also use when the user says "openspec update change" or "opsx update". If the user means the openspec update CLI command, which refreshes generated files, run that command instead. Never edits code. allowed-tools: Bash(openspec:*) license: MIT compatibility: Requires openspec CLI. @@ -13,9 +13,20 @@ Revise a change's existing planning artifacts and keep them coherent. Never edit **Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store ` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it. + +One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`. + +Otherwise, with no root, what happens next depends on how this workflow was reached: + +- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup. +- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store `), or continue without OpenSpec for this request. Wait for their answer. + +In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it. + **Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. -`/openspec-continue-change` is an optional workflow and may not be installed. Before suggesting it anywhere below, verify that it is available. If it is unavailable, `openspec status --change "" --json` shows the next artifact and `openspec instructions "" --change "" --json` explains how to create it. +This workflow revises artifacts that already exist; `/openspec-continue-change` is what creates the ones that do not. **Steps** @@ -56,13 +67,14 @@ Revise a change's existing planning artifacts and keep them coherent. Never edit 4. **Read and reconcile** - Read the artifact(s) the request touches and the change's other existing artifacts. - - Apply the requested edit. Then check every other existing artifact against it - in ANY direction: an edit to a later artifact may require revising an earlier one, not only the other way around. Build order is a useful reading order, not a constraint on which artifacts may be revised. + - Draft the requested edit in the conversation, not in files. Work out exactly what it changes; step 5 owns every write. Then check every other existing artifact against the drafted edit - in ANY direction: an edit to a later artifact may require revising an earlier one, not only the other way around. Build order is a useful reading order, not a constraint on which artifacts may be revised. - Note everything that is now inconsistent, missing, or contradictory. - - Revise only files that already exist (`existingOutputPaths`). Do NOT create artifacts that don't exist yet, and do NOT invent new files under a glob artifact - note them and point the user to `/openspec-continue-change` to create them. - - If the change is already coherent, say so and make no edits. + - Propose revisions only to files that already exist (`existingOutputPaths`). Do NOT create artifacts that don't exist yet, and do NOT invent new files under a glob artifact - note them and point the user to `/openspec-continue-change` to create them. + - If the change is already coherent, say so and propose no revisions. 5. **Confirm and apply, one artifact at a time** - - Show each proposed revision and why. Write only after the user confirms. + - This step performs every artifact write in this workflow; no earlier step edits an artifact. + - Show each proposed revision and why - including the requested edit drafted in step 4. Write only after the user confirms. - If the user rejects a revision, do not write it - leave that artifact unchanged. - When a substantial rewrite is needed, get that artifact's rules and template first: ```bash @@ -87,4 +99,4 @@ After each invocation, show: - Edit only the concrete files in `existingOutputPaths`; never write to a glob `resolvedOutputPath`. - Do not advance the build frontier: no new artifacts, no new files under glob artifacts - that is `/openspec-continue-change`'s job. - Confirm every edit with the user before writing. -- If the request changes the change's *intent* rather than refining it, first verify whether the optional `/openspec-new-change` workflow is available. If it is, recommend starting fresh with `/openspec-new-change` (the "Update vs. Start Fresh" heuristic). If it is unavailable, ask for a distinct unused change name and recommend `openspec new change ""` instead. +- If the request changes the change's *intent* rather than refining it, recommend starting fresh with `/openspec-new-change` (the "Update vs. Start Fresh" heuristic). diff --git a/external/develop/openspec/openspec-verify-change/SKILL.md b/external/develop/openspec/openspec-verify-change/SKILL.md index 2165a6a..355febc 100644 --- a/external/develop/openspec/openspec-verify-change/SKILL.md +++ b/external/develop/openspec/openspec-verify-change/SKILL.md @@ -1,6 +1,6 @@ --- name: openspec-verify-change -description: Verify implementation matches change artifacts. Use when the user wants to validate that implementation is complete, correct, and coherent before archiving. +description: Verify implementation matches OpenSpec change artifacts. Use when the user wants to validate that implementation is complete, correct, and coherent before archiving. Also use when the user says "openspec verify" or "opsx verify". allowed-tools: Bash(openspec:*) license: MIT compatibility: Requires openspec CLI. @@ -13,6 +13,17 @@ Verify that an implementation matches the change artifacts (specs, tasks, design **Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store ` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it. + +One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`. + +Otherwise, with no root, what happens next depends on how this workflow was reached: + +- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup. +- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store `), or continue without OpenSpec for this request. Wait for their answer. + +In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it. + **Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. **Steps** @@ -60,7 +71,9 @@ Verify that an implementation matches the change artifacts (specs, tasks, design **Task Completion**: - If `contextFiles.tasks` exists, read every file path in it - - Parse checkboxes: `- [ ]` (incomplete) vs `- [x]` (complete) + - Parse checkboxes: complete means the box holds only `x`/`X`, ignoring + spacing (`- [ x]` is complete); every other marker is incomplete + (`- [ ]`, `- []`, and unfamiliar ones such as `- [~]` or `- [-]`) - Count complete vs total tasks - If incomplete tasks exist: - Add CRITICAL issue for each incomplete task diff --git a/external/video-design/hyperframes-animation/SKILL.md b/external/video-design/hyperframes-animation/SKILL.md index 00aaaee..8571c10 100644 --- a/external/video-design/hyperframes-animation/SKILL.md +++ b/external/video-design/hyperframes-animation/SKILL.md @@ -30,6 +30,7 @@ Blueprints live in `blueprints-index.md`. Each entry points to `blueprints/. | Read one blueprint's full recipe | `blueprints/.md` | | Author a scene transition (CSS-driven, between two clips) | `transitions/overview.md`, `transitions/catalog.md` | | Look up a broader motion-design technique | `techniques.md` | +| Motion blur — shutter smear on an element, and when not to use it | `references/motion-blur.md` | | Analyze an existing composition's animation map | `scripts/animation-map.mjs` | | GSAP API — timeline / tweens / position parameters | `adapters/gsap.md` | | GSAP — drop-in effect recipes | `rules/gsap-effects.md` | diff --git a/external/video-design/hyperframes-animation/adapters/gsap.md b/external/video-design/hyperframes-animation/adapters/gsap.md index 6638706..53dddf9 100644 --- a/external/video-design/hyperframes-animation/adapters/gsap.md +++ b/external/video-design/hyperframes-animation/adapters/gsap.md @@ -61,7 +61,7 @@ HyperFrames is stricter than vanilla GSAP. Animate only: - **Compositor-cheap**: `opacity`, `x`, `y`, `scale`, `scaleX`, `scaleY`, `rotation`, `rotationX`, `rotationY`, `skewX`, `skewY`, `transformOrigin` - **Visual fills**: `color`, `backgroundColor`, `borderColor`, `borderRadius` - **CSS variables**: `"--hue": 180` etc. -- **Media `volume`** (on `
), + Layout: ({ children, slots }) => ( +
+
{slots?.header}
+
{children}
+
{slots?.footer}
+
+ ), }, }); ``` @@ -74,25 +87,41 @@ The React schema uses an element tree format: "root": { "type": "Card", "props": { "title": "Hello" }, - "children": [ - { "type": "Button", "props": { "label": "Click me" } } - ] + "children": [{ "type": "Button", "props": { "label": "Click me" } }] + } +} +``` + +## Named Slots + +Use `children` for the `"default"` slot. Use the element's top-level `slots` object for other slot names declared by the catalog: + +```json +{ + "type": "Layout", + "props": {}, + "children": ["main"], + "slots": { + "header": ["heading"], + "footer": ["actions"] } } ``` +Registry components receive named content as `slots?.header`, `slots?.footer`, and so on. Do not use `slots.default`. + ## Visibility Conditions Use `visible` on elements to show/hide based on state. New syntax: `{ "$state": "/path" }`, `{ "$state": "/path", "eq": value }`, `{ "$state": "/path", "not": true }`, `{ "$and": [cond1, cond2] }` for AND, `{ "$or": [cond1, cond2] }` for OR. Helpers: `visibility.when("/path")`, `visibility.unless("/path")`, `visibility.eq("/path", val)`, `visibility.and(cond1, cond2)`, `visibility.or(cond1, cond2)`. ## Providers -| Provider | Purpose | -|----------|---------| -| `StateProvider` | Share state across components (JSON Pointer paths). Accepts optional `store` prop for controlled mode. | -| `ActionProvider` | Handle actions dispatched via the event system | -| `VisibilityProvider` | Enable conditional rendering based on state | -| `ValidationProvider` | Form field validation | +| Provider | Purpose | +| -------------------- | ------------------------------------------------------------------------------------------------------ | +| `StateProvider` | Share state across components (JSON Pointer paths). Accepts optional `store` prop for controlled mode. | +| `ActionProvider` | Handle actions dispatched via the event system | +| `VisibilityProvider` | Enable conditional rendering based on state | +| `ValidationProvider` | Form field validation | ### External Store (Controlled Mode) @@ -103,7 +132,7 @@ import { createStateStore, type StateStore } from "@json-render/react"; const store = createStateStore({ count: 0 }); -{children} +{children}; // Mutate from anywhere — React re-renders automatically: store.set("/count", 1); @@ -119,6 +148,7 @@ Any prop value can be a data-driven expression resolved by the renderer before c - **`{ "$bindState": "/path" }`** - two-way binding: reads from state and enables write-back. Use on the natural value prop (value, checked, pressed, etc.) of form components. - **`{ "$bindItem": "field" }`** - two-way binding to a repeat item field. Use inside repeat scopes. - **Filtered lists**: `repeat` plus an `$item` visible condition on the same container renders only matching items: `{ "repeat": { "statePath": "/tasks", "key": "id" }, "visible": { "$item": "status", "eq": "todo" }, "children": ["task-card"] }`. AND-composed `$state` conjuncts gate the container shell; `$item`/`$index` conjuncts filter items. +- **Nested lists**: inside a repeat, use `{ "repeat": { "statePath": { "$item": "comments" }, "key": "id" } }` to iterate an array on the enclosing item. - **`{ "$cond": , "$then": , "$else": }`** - conditional value - **`{ "$template": "Hello, ${/name}!" }`** - interpolates state values into strings - **`{ "$computed": "fn", "args": { ... } }`** - calls registered functions with resolved args @@ -184,7 +214,10 @@ Elements can declare a `watch` field (top-level, sibling of type/props/children) ```json { "type": "Select", - "props": { "value": { "$bindState": "/form/country" }, "options": ["US", "Canada"] }, + "props": { + "value": { "$bindState": "/form/country" }, + "options": ["US", "Canada"] + }, "watch": { "/form/country": { "action": "loadCities" } }, "children": [] } @@ -246,20 +279,20 @@ const Card = ({ props, children }: BaseComponentProps<{ title?: string }>) => ( ## Key Exports -| Export | Purpose | -|--------|---------| -| `defineRegistry` | Create a type-safe component registry from a catalog | -| `Renderer` | Render a spec using a registry | -| `schema` | Element tree schema (includes built-in state actions: setState, pushState, removeState, validateForm) | -| `useStateStore` | Access state context | -| `useStateValue` | Get single value from state | -| `useBoundProp` | Two-way binding for `$bindState`/`$bindItem` expressions | -| `useActions` | Access actions context | -| `useAction` | Get a single action dispatch function | -| `useOptionalValidation` | Non-throwing variant of useValidation (returns null if no provider) | -| `useUIStream` | Stream specs from an API endpoint | -| `createStateStore` | Create a framework-agnostic in-memory `StateStore` | -| `StateStore` | Interface for plugging in external state management | -| `BaseComponentProps` | Catalog-agnostic base type for reusable component libraries | -| `EventHandle` | Event handle type (`emit`, `shouldPreventDefault`, `bound`) | -| `ComponentContext` | Typed component context (catalog-aware) | +| Export | Purpose | +| ----------------------- | ----------------------------------------------------------------------------------------------------- | +| `defineRegistry` | Create a type-safe component registry from a catalog | +| `Renderer` | Render a spec using a registry | +| `schema` | Element tree schema (includes built-in state actions: setState, pushState, removeState, validateForm) | +| `useStateStore` | Access state context | +| `useStateValue` | Get single value from state | +| `useBoundProp` | Two-way binding for `$bindState`/`$bindItem` expressions | +| `useActions` | Access actions context | +| `useAction` | Get a single action dispatch function | +| `useOptionalValidation` | Non-throwing variant of useValidation (returns null if no provider) | +| `useUIStream` | Stream specs from an API endpoint | +| `createStateStore` | Create a framework-agnostic in-memory `StateStore` | +| `StateStore` | Interface for plugging in external state management | +| `BaseComponentProps` | Catalog-agnostic base type for reusable component libraries | +| `EventHandle` | Event handle type (`emit`, `shouldPreventDefault`, `bound`) | +| `ComponentContext` | Typed component context (catalog-aware) | diff --git a/plugins/frontend-product-design/skills/sleek-design-mobile-apps/SKILL.md b/plugins/frontend-product-design/skills/sleek-design-mobile-apps/SKILL.md index 0a5aed8..11824b3 100644 --- a/plugins/frontend-product-design/skills/sleek-design-mobile-apps/SKILL.md +++ b/plugins/frontend-product-design/skills/sleek-design-mobile-apps/SKILL.md @@ -1,6 +1,6 @@ --- name: sleek-design-mobile-apps -description: Use when the user wants to design a mobile app, create screens, build UI, or interact with their Sleek projects. Covers high-level requests ("design an app that does X") and specific ones ("list my projects", "create a new project", "screenshot that screen"). +description: Use when the user wants to design a mobile app or UI screens, when they mention their Sleek (sleek.design) projects, or when implementing Sleek designs in code (HTML, React Native, SwiftUI). compatibility: Requires SLEEK_API_KEY environment variable. Network access limited to https://sleek.design only. metadata: requires-env: SLEEK_API_KEY @@ -19,14 +19,22 @@ metadata: **Auth**: `Authorization: Bearer $SLEEK_API_KEY` on every `/api/v1/*` request **Content-Type**: `application/json` (requests and responses) **CORS**: Enabled on all `/api/v1/*` endpoints +**Parsing responses**: write the body to a file (`curl -o run.json`) and parse the file. Don't pipe JSON through `echo`: in zsh it expands the escaped `\n` inside string values into real newlines, which makes the body invalid JSON. +**API docs**: OpenAPI spec at `https://sleek.design/api/v1/spec.json`; browsable docs at `https://sleek.design/api/v1/docs`. Fetch the spec for any contract detail not covered here. --- ## Prerequisites: API Key -Create API keys at **https://sleek.design/dashboard/api-keys**. The full key value is shown only once at creation — store it in the `SLEEK_API_KEY` environment variable. +If `SLEEK_API_KEY` is not set, use the device flow so the user never handles the raw key: -**Required plan**: Pro or higher (API access is gated) +1. `POST https://sleek.design/api/v1/device/start` (no auth) with body `{"source": "your-tool-slug"}`. The response contains a `verificationUrl`, a human-checkable `userCode`, a secret `deviceCode`, and a poll `interval` in seconds. +2. Show the user the `verificationUrl` and the `userCode`, and tell them to confirm the code matches before approving. +3. Poll `POST https://sleek.design/api/v1/device/poll` with `{"deviceCode": "..."}` every `interval` seconds. When the user approves, the poll returns `{"status": "approved", "key": "sk_..."}` exactly once: store it as `SLEEK_API_KEY`. Codes expire after 15 minutes; on `expired`, start over. + +Fallback: send the user to **https://sleek.design/agents/setup**, which handles sign-in, plan upgrade, and key creation in one place, and ask them to paste the key back to you. Keys can also be managed at **https://sleek.design/dashboard/api-keys**. The full key value is shown only once at creation. + +**Plans**: free accounts can try the API with their one-time trial credits (about one design run), so a new user can see their first design before any payment decision. Sustained use requires the Pro plan or higher ($49.99/month, or $30/month billed yearly at $360/year; includes 20,000 monthly AI credits, roughly 650 screens). When cost becomes relevant (the user asks, an upgrade is needed to continue, or you're about to send them to a payment page), state this pricing plainly, including the yearly option. Never let a payment step come as a surprise. ### Key scopes @@ -52,21 +60,162 @@ Create a key with only the scopes needed for the task. --- -## Quick Reference — All Endpoints +## Designing + +The full request/response shapes for every endpoint used below are in the [API reference](#quick-reference-all-endpoints). + +### 1. Create a project + +Create a project with `POST /api/v1/projects` if one doesn't exist yet. Derive a name from the request. + +Each project has its own theme, style, and design system. If the user wants multiple design variations, create a separate project for each variation. + +### 2. Send a chat message + +Send the request with `POST /api/v1/projects/:id/chat/messages`. Sleek plans screen content and layout from your message, and will invent a visual style if you don't give it one. Don't decompose the request into screens and don't add product details the user didn't ask for; send the full intent as a single message. If the user described specific screens, include those. Sleek produces richer designs when given room to plan. + +**Author a style direction**: write one whenever the user has given you anything to ground it in — reference images, apps they like, vibe adjectives, things to avoid — or whenever you're producing variations, one direction per variation. Pass the request through unchanged only when it's bare. A style direction is a single comprehensive paragraph, included in the message, covering mood (2–3 adjectives), color strategy (the logic, not hex codes), typography feel, layout philosophy, component style (radii, borders vs shadows, nav treatment), imagery and illustration style, and one or two distinctive details. Commit to a palette, a type direction, and an overall feel — anything that only sets a mood reads as a hint, not a direction. Be opinionated; don't hedge. Put the personality in color, type, and imagery rather than in unusual layout or navigation. + +Extend what the user gave you and never contradict it. When they point at reference images or apps they like, study each one and carry what you take into the direction — Sleek only sees images passed as `imageUrls`, so for anything local the direction is how those references reach it. Borrow patterns, never the source's branding, content, or name. + +Use a style direction or a `referenceId`, not both — a reference already carries a full style guide of its own. + +**Seed a style with a reference**: Sleek curates a catalog of design references. When the user wants a specific look or asks for style options, list them with `GET /api/v1/references` (each has a `name` and `previewImageUrls` you can show) and pass the chosen id as `referenceId` on the first message to a project, so its style guide seeds the whole design. + +**Identify your tool**: always send `source`, the slug of the tool making the request. The Sleek editor uses it to show the user who is designing while the run streams. Recognized values: `claude-code`, `claude`, `codex`, `chatgpt`, `cursor`, `openclaw`, `grok`. If your tool isn't listed, send a short kebab-case slug for it anyway (max 64 chars). Unrecognized values are fine and get a generic label. -| Method | Path | Scope | Description | -| -------- | --------------------------------------- | ----------------- | ----------------- | -| `GET` | `/api/v1/projects` | `projects:read` | List projects | -| `POST` | `/api/v1/projects` | `projects:write` | Create project | -| `GET` | `/api/v1/projects/:id` | `projects:read` | Get project | -| `DELETE` | `/api/v1/projects/:id` | `projects:write` | Delete project | -| `GET` | `/api/v1/projects/:id/components` | `components:read` | List components | -| `GET` | `/api/v1/projects/:id/components/:componentId` | `components:read` | Get component | -| `POST` | `/api/v1/projects/:id/chat/messages` | `chats:write` | Send chat message | -| `GET` | `/api/v1/projects/:id/chat/runs/:runId` | `chats:read` | Poll run status | -| `POST` | `/api/v1/screenshots` | `screenshots` | Render screenshot | +**Watch it live**: runs render in the Sleek editor in real time. After sending the first message to a project, tell the user they can watch their screens being designed live in Sleek, and share the editor link: `https://sleek.design/project/:projectId`. Don't open a browser yourself unless the user asks. -All IDs are stable string identifiers. +**Polling**: chat messages are async by default: you get a `runId` and poll `GET /api/v1/projects/:id/chat/runs/:runId`. Start at 2s interval, back off to 5s after 10s, give up after 5 minutes. Exit on `completed` or `failed`; if you can't read the status, stop and report it rather than counting it as "not done yet". You can also use `?wait=true` for a blocking call (up to 300s; falls back to polling if it times out with `202`). + +**Editing a specific screen**: use `target.screenId` to direct changes to the right screen. The `screenId` comes from the run's `result.operations` or from the `screenId` field on each component returned by `GET /api/v1/projects/:id/components`; it is not the component ID. + +**One run at a time**: only one active run is allowed per project. If you get `409 CONFLICT`, wait for the current run to complete before sending the next message. If the user changed their mind or a stale run is blocking the project, cancel it (see [Cancel Run](#chat-cancel-run)). Messages to different projects can run in parallel; use async polling (not `?wait=true`) when running multiple projects concurrently. + +**Safe retries**: add an `idempotency-key` header (≤255 chars) to replay-safe re-sends. The server returns the existing run rather than creating a duplicate. + +### 3. Show the results + +After every chat run that produces `screen_created` or `screen_updated` operations, **take screenshots and show them to the user** using `POST /api/v1/screenshots`. The step is done only when the user has seen a screenshot of every screen the run created or updated; never complete a run silently. + +- **New screens**: one screenshot per screen + one combined screenshot of all screens in the project. +- **Updated screens**: one screenshot per affected screen. + +Use `background: "transparent"` unless the user explicitly requests a specific background color. + +Save screenshots in the project directory (not a temporary folder) so the user can easily view them. + +**Showing vs reviewing**: the defaults capture only the viewport, which is the right framing for the user — screens look like phone screens. They are the wrong framing for judging your own work, because everything below the fold is cropped away. When you're reviewing what a run produced, re-shoot the screen with `fullHeight: true` (one screen per request) to see the whole scrollable page. + +Screenshot requests are independent, so issue them in parallel — the user-facing shot and your `fullHeight` review shot go out together, as do the shots for different screens. "One screen per request" governs what goes into each image, not how fast you send them; it is not a reason to wait for one response before starting the next. Back off only if you actually get a `429`. + +**Never call a screen incomplete from a viewport screenshot.** Content that looks missing is almost always just below the fold. Before telling the user something is absent, or sending a follow-up message asking Sleek to add it, confirm it against the whole screen: a `fullHeight: true` screenshot, or the component HTML from `GET /api/v1/projects/:id/components/:componentId`, which is the ground truth for what's on the screen. The screenshot is the default and answers most review questions on its own — don't go to the code to double-check something it already shows. Reach for the code only when you're about to claim something is missing: a render can omit what's really there (past the height cap, in a collapsed section, on a later carousel slide), so a negative conclusion is the one worth a second source. Note the reverse too — an element present in the HTML may still not be visible to the user. + +--- + +## Implementing Designs + +When the user wants to implement the designs in code (not just preview them), **always fetch the component HTML code**. Do not rely on screenshots alone. + +Use `GET /api/v1/projects/:id/components/:componentId` to fetch each screen's code. The `componentId` comes from the chat run's `result.operations`. + +Component code can be large. When saving it to files, avoid writing the content through your text output: it's slow and wastes tokens. Instead, use shell commands to fetch the API response and write it directly to disk (e.g., pipe the response body into a file). + +### Which version to use + +Each component carries a `versions[]` array and an `activeVersion: number`. **By default, use the entry where `versions[i].version === activeVersion`**: that's the code currently shown in Sleek. + +If the user's prompt pins specific versions, follow those instead (see [Pinned versions](#pinned-versions) below). + +### Pinned versions + +The user's prompt may include a pin block telling you to implement specific historical versions instead of the current ones, like this: + +``` +... at this exact state instead of the project's current version: +- component cmp_abc: version ver_001 +- component cmp_def: version ver_002 +- theme thm_ghi: version ver_003 +``` + +When you see a pin block, implement those exact versions instead of `activeVersion`. Components not named in the pin block continue to use their active version. Theme IDs surface only inside pin blocks; this skill exposes no separate endpoint to enumerate them. + +#### Fetching the right code + +For each pinned component, find the entry in `versions[]` where `versions[i].id` matches the given version id (e.g. `ver_001`) and use its `code`. Do **not** fall back to `activeVersion` for pinned components. + +#### Screenshots of pinned versions + +Pass `componentVersionOverrides` and `themeVersionOverrides` to `POST /api/v1/screenshots`: + +```json +{ + "componentIds": ["cmp_abc"], + "projectId": "proj_xyz", + "componentVersionOverrides": { "cmp_abc": "ver_001" }, + "themeVersionOverrides": { "thm_ghi": "ver_003" } +} +``` + +Keys are component / theme public ids; values are the corresponding `versions[i].id`. Entities missing from a map fall back to their active version. Include the override maps whenever the prompt specified pinned versions. + +### HTML prototypes + +The component `code` is a complete HTML document. Save it directly to a `.html` file. No build step needed. + +### Native frameworks (React Native, SwiftUI, etc.) + +Use both the HTML code and the screenshots together: + +- **HTML code** is the implementation reference: it contains the exact structure, layout, styling, colors, spacing, content, image URLs, and icon names. +- **Screenshots** are the visual target: use them to verify your implementation matches the intended look. + +The HTML tells you _how_ to build it; the screenshot tells you _what_ it should look like. + +#### Icons + +Sleek uses [Iconify](https://iconify.design) icons in the format `prefix:name` (e.g., `solar:heart-bold`, `material-symbols:search-rounded`, `lucide:settings`). The most common sets are **Solar**, **Hugeicons**, **Material Symbols** and **MDI**. + +**Use the exact icons from the HTML code**. Do not substitute with a different icon set. Matching icons is important for design fidelity. + +When implementing icons: + +1. **Check if the project already has an icon system** that supports the same sets Sleek uses (Solar, Hugeicons, Material Symbols, MDI). If so, use it. Note: `@expo/vector-icons` does **not** support these sets, so do not use it as a substitute. +2. **Otherwise, fetch the SVGs from the Iconify API and embed them in the code:** + + ``` + GET https://api.iconify.design/{prefix}/{name}.svg + ``` + + Example: `https://api.iconify.design/solar/heart-bold.svg` + + Collect all icon names from the HTML, fetch their SVGs, and save them as static assets or string constants in the codebase. For **React Native / Expo**, render them with `react-native-svg`'s `SvgXml` component, which works in Expo Go with no additional native dependencies. + +#### Fonts + +The HTML includes Google Fonts via `` tags in the ``. Use the same fonts and weights when implementing in a native framework. Extract the font family names and weights from the `` tags. + +#### Navigation + +The designs may include navigation elements like tab bars and headers. Update the project's navigation styling and structure to match the designs. Don't just implement the screen content while leaving the default navigation untouched. + +--- + +## Quick Reference: All Endpoints + +| Method | Path | Scope | Description | +| -------- | ---------------------------------------------- | ----------------- | ----------------- | +| `GET` | `/api/v1/projects` | `projects:read` | List projects | +| `POST` | `/api/v1/projects` | `projects:write` | Create project | +| `GET` | `/api/v1/projects/:id` | `projects:read` | Get project | +| `DELETE` | `/api/v1/projects/:id` | `projects:write` | Delete project | +| `GET` | `/api/v1/projects/:id/components` | `components:read` | List components | +| `GET` | `/api/v1/projects/:id/components/:componentId` | `components:read` | Get component | +| `GET` | `/api/v1/references` | any valid key | List references | +| `POST` | `/api/v1/projects/:id/chat/messages` | `chats:write` | Send chat message | +| `GET` | `/api/v1/projects/:id/chat/runs/:runId` | `chats:read` | Poll run status | +| `POST` | `/api/v1/projects/:id/chat/runs/:runId/cancel` | `chats:write` | Cancel run | +| `POST` | `/api/v1/screenshots` | `screenshots` | Render screenshot | --- @@ -108,7 +257,7 @@ Content-Type: application/json { "name": "My New App" } ``` -Response `201` — same shape as a single project. +Response `201`: same shape as a single project. #### Get / Delete project @@ -128,7 +277,7 @@ GET /api/v1/projects/:projectId/components?limit=50&offset=0 Authorization: Bearer $SLEEK_API_KEY ``` -Both list and get accept an optional `inlineIcons` query param (default `false`). When omitted, icons render as `` web components and the HTML pulls in the Iconify script — leave it off by default. Pass `?inlineIcons=true` only when the consumer needs self-contained SVGs in the HTML (for example, importing into tools that don't run scripts). +Both list and get accept an optional `inlineIcons` query param (default `false`). When omitted, icons render as `` web components and the HTML pulls in the Iconify script, so leave it off by default. Pass `?inlineIcons=true` only when the consumer needs self-contained SVGs in the HTML (for example, importing into tools that don't run scripts). Response `200`: @@ -137,9 +286,17 @@ Response `200`: "data": [ { "id": "cmp_xyz", + "screenId": "scr_xyz", "name": "Hero Section", "activeVersion": 3, - "versions": [{ "id": "ver_001", "version": 1, "code": "...", "createdAt": "..." }], + "versions": [ + { + "id": "ver_001", + "version": 1, + "code": "...", + "createdAt": "..." + } + ], "createdAt": "...", "updatedAt": "..." } @@ -157,24 +314,39 @@ GET /api/v1/projects/:projectId/components/:componentId Authorization: Bearer $SLEEK_API_KEY ``` -Response `200` — same shape as a single item from the list endpoint: +Response `200`: `{ "data": ... }` with a single component in the same shape as a list item. + +--- + +### References + +References are curated design styles from featured Sleek projects. They are world-readable: any valid API key can list them, no scope needed. + +```http +GET /api/v1/references?limit=50&offset=0 +Authorization: Bearer $SLEEK_API_KEY +``` + +Response `200`: ```json { - "data": { - "id": "cmp_xyz", - "name": "Hero Section", - "activeVersion": 3, - "versions": [{ "id": "ver_001", "version": 1, "code": "...", "createdAt": "..." }], - "createdAt": "...", - "updatedAt": "..." - } + "data": [ + { + "id": "proj_ref1", + "name": "Ember Fitness", + "previewImageUrls": ["https://.../screenshot.png"] + } + ], + "pagination": { "total": 44, "limit": 50, "offset": 0 } } ``` +To use one, pass its `id` as `referenceId` on [Send Message](#chat-send-message). + --- -### Chat — Send Message +### Chat: Send Message This is the core action: describe what you want in `message.text` and the AI creates or modifies screens. @@ -186,20 +358,24 @@ idempotency-key: { "message": { "text": "Add a pricing section with three tiers" }, + "source": "claude-code", "imageUrls": ["https://example.com/ref.png"], - "target": { "screenId": "scr_abc" } + "target": { "screenId": "scr_abc" }, + "referenceId": "proj_ref1" } ``` -| Field | Required | Notes | -| ------------------------ | -------- | --------------------------------------------- | -| `message.text` | Yes | 1+ chars, trimmed | -| `imageUrls` | No | HTTPS URLs only; included as visual context | -| `target.screenId` | No | Edit a specific screen using its `screenId` (not `componentId`); omit to let AI decide | -| `?wait=true/false` | No | Sync wait mode (default: false) | -| `idempotency-key` header | No | Replay-safe re-sends | +| Field | Required | Notes | +| ------------------------ | -------- | ---------------------------------------------------------------------------------------- | +| `message.text` | Yes | 1+ chars, trimmed | +| `source` | Treat as required | Slug of the tool sending the request (see [step 2 of Designing](#2-send-a-chat-message)) | +| `imageUrls` | No | HTTPS URLs only; included as visual context | +| `target.screenId` | No | Edit a specific screen using its `screenId` (from run operations or the components list; not `componentId`); omit to let AI decide | +| `referenceId` | No | Seed the design style from a reference (see [References](#references)); invalid id → `400` | +| `?wait=true/false` | No | Sync wait mode (default: false) | +| `idempotency-key` header | No | Replay-safe re-sends | -#### Response — async (default, `wait=false`) +#### Response: async (default, `wait=false`) Status `202 Accepted`. `result` and `error` are absent until the run reaches a terminal state. @@ -213,7 +389,7 @@ Status `202 Accepted`. `result` and `error` are absent until the run reaches a t } ``` -#### Response — sync (`wait=true`) +#### Response: sync (`wait=true`) Blocks up to **300 seconds**. Returns `200` when completed, `202` if timed out. @@ -226,8 +402,17 @@ Blocks up to **300 seconds**. Returns `200` when completed, `202` if timed out. "result": { "assistantText": "I added a pricing section with...", "operations": [ - { "type": "screen_created", "screenId": "scr_xyz", "screenName": "Pricing", "componentId": "cmp_xyz" }, - { "type": "screen_updated", "screenId": "scr_abc", "componentId": "cmp_abc" }, + { + "type": "screen_created", + "screenId": "scr_xyz", + "screenName": "Pricing", + "componentId": "cmp_xyz" + }, + { + "type": "screen_updated", + "screenId": "scr_abc", + "componentId": "cmp_abc" + }, { "type": "theme_updated" } ] } @@ -237,7 +422,7 @@ Blocks up to **300 seconds**. Returns `200` when completed, `202` if timed out. --- -### Chat — Poll Run Status +### Chat: Poll Run Status Use this after async send to check progress. @@ -246,48 +431,31 @@ GET /api/v1/projects/:projectId/chat/runs/:runId Authorization: Bearer $SLEEK_API_KEY ``` -Response — same shape as send message `data` object: +The response has the same `data` shape as send message: `result` is present when `completed`, `error` when `failed`: ```json { "data": { "runId": "run_111", - "status": "queued", - "statusUrl": "..." + "status": "failed", + "statusUrl": "...", + "error": { "code": "execution_failed", "message": "..." } } } ``` -When completed successfully, `result` is present: +**Run status lifecycle**: `queued` → `running` → `completed | failed` -```json -{ - "data": { - "runId": "run_111", - "status": "completed", - "statusUrl": "...", - "result": { - "assistantText": "...", - "operations": [...] - } - } -} -``` +--- -When failed, `error` is present: +### Chat: Cancel Run -```json -{ - "data": { - "runId": "run_111", - "status": "failed", - "statusUrl": "...", - "error": { "code": "execution_failed", "message": "..." } - } -} +```http +POST /api/v1/projects/:projectId/chat/runs/:runId/cancel +Authorization: Bearer $SLEEK_API_KEY ``` -**Run status lifecycle**: `queued` → `running` → `completed | failed` +Marks a `queued` or `running` run as `failed` with error code `cancelled` and returns the updated run; already-finished runs are returned unchanged. Use it when the user changes their mind mid-run or a stale run is blocking the project with `409 CONFLICT`. --- @@ -311,29 +479,32 @@ Content-Type: application/json } ``` -| Field | Default | Notes | -| ------------ | ------------- | --------------------------------------------------------------------- | -| `format` | `png` | `png` or `webp` | -| `scale` | `2` | 1–3 (device pixel ratio) | -| `gap` | `40` | Pixels between components | -| `padding` | `40` | Uniform padding on all sides | -| `paddingX` | _(optional)_ | Horizontal padding; overrides `padding` for left/right when provided | -| `paddingY` | _(optional)_ | Vertical padding; overrides `padding` for top/bottom when provided | -| `paddingTop` | _(optional)_ | Top padding; overrides `paddingY` when provided | -| `paddingRight` | _(optional)_ | Right padding; overrides `paddingX` when provided | -| `paddingBottom` | _(optional)_ | Bottom padding; overrides `paddingY` when provided | -| `paddingLeft` | _(optional)_ | Left padding; overrides `paddingX` when provided | -| `background` | `transparent` | Any CSS color (hex, named, `transparent`) | -| `showDots` | `false` | Overlay a subtle dot grid on the background | -| `radius` | `48` | Squircle corner radius per component in pixels (integer ≥ 0); pass `0` for sharp corners | +| Field | Default | Notes | +| --------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `format` | `png` | `png` or `webp` | +| `scale` | `2` | 1–3 (device pixel ratio) | +| `gap` | `40` | Pixels between components | +| `padding` | `40` | Uniform padding on all sides | +| `paddingX` | _(optional)_ | Horizontal padding; overrides `padding` for left/right when provided | +| `paddingY` | _(optional)_ | Vertical padding; overrides `padding` for top/bottom when provided | +| `paddingTop` | _(optional)_ | Top padding; overrides `paddingY` when provided | +| `paddingRight` | _(optional)_ | Right padding; overrides `paddingX` when provided | +| `paddingBottom` | _(optional)_ | Bottom padding; overrides `paddingY` when provided | +| `paddingLeft` | _(optional)_ | Left padding; overrides `paddingX` when provided | +| `background` | `transparent` | Any CSS color (hex, named, `transparent`) | +| `showDots` | `false` | Overlay a subtle dot grid on the background | +| `fullHeight` | `false` | Capture the entire scrollable screen instead of just the viewport (see below) | +| `radius` | `48` | Squircle corner radius per component in pixels (integer ≥ 0); pass `0` for sharp corners | | `componentVersionOverrides` | _(optional)_ | Map of `componentId` → `versions[i].id` to render at a pinned version instead of `activeVersion` (see [Pinned versions](#pinned-versions)) | -| `themeVersionOverrides` | _(optional)_ | Map of `themeId` → `versions[i].id` to render with a pinned theme version (see [Pinned versions](#pinned-versions)) | +| `themeVersionOverrides` | _(optional)_ | Map of `themeId` → `versions[i].id` to render with a pinned theme version (see [Pinned versions](#pinned-versions)) | Padding resolves with a cascade: per-side → axis → uniform. For example, `paddingTop` falls back to `paddingY`, which falls back to `padding`. So `{ "padding": 20, "paddingX": 10, "paddingLeft": 5 }` gives top/bottom 20px, right 10px, left 5px. -When `showDots` is `true`, a dot pattern is drawn over the background color. The dots automatically adapt to the background: dark backgrounds get light dots, light backgrounds get dark dots. This has no effect when `background` is `"transparent"`. +By default a component is captured at frame height, so anything the user would reach by scrolling is cut off. `fullHeight: true` expands each frame to the height of its own content before capturing. Use it when you're reviewing your own work; leave it off for the screenshots you show the user, where the phone-shaped framing is the point. -Always use `"background": "transparent"` unless the user explicitly requests a specific background color. +Frames are capped at **4× the default frame height**, so a screen longer than that is still cut off at the bottom even with `fullHeight: true`. On a very long screen, treat the component HTML as the authority for what's below the cap. Expanded frames make for tall images; prefer one component per request so each screen keeps its detail — and send those requests in parallel rather than one after another. + +When `showDots` is `true`, a dot pattern is drawn over the background color. The dots automatically adapt to the background: dark backgrounds get light dots, light backgrounds get dark dots. This has no effect when `background` is `"transparent"`. Response: raw binary `image/png` or `image/webp` with `Content-Disposition: attachment`. @@ -345,146 +516,27 @@ Response: raw binary `image/png` or `image/webp` with `Content-Disposition: atta { "code": "UNAUTHORIZED", "message": "..." } ``` -| HTTP | Code | When | -| ---- | ----------------------- | -------------------------------------- | -| 401 | `UNAUTHORIZED` | Missing/invalid/expired API key | -| 403 | `FORBIDDEN` | Valid key, wrong scope or plan | -| 404 | `NOT_FOUND` | Resource doesn't exist | -| 400 | `BAD_REQUEST` | Validation failure | -| 409 | `CONFLICT` | Another run is active for this project | -| 500 | `INTERNAL_SERVER_ERROR` | Server error | +| HTTP | Code | When | +| ---- | ----------------------- | ------------------------------------------------------- | +| 401 | `UNAUTHORIZED` | Missing/invalid/expired API key | +| 403 | `FORBIDDEN` | Valid key, wrong scope or plan | +| 404 | `NOT_FOUND` | Resource doesn't exist | +| 400 | `BAD_REQUEST` | Validation failure | +| 409 | `CONFLICT` | Another run is active for this project | +| 429 | `TOO_MANY_REQUESTS` | Too many requests; back off and retry later | +| 500 | `INTERNAL_SERVER_ERROR` | Server error | -Chat run-level errors (inside `data.error`): - -| Code | Meaning | -| ------------------ | -------------------------------- | -| `out_of_credits` | Organization has no credits left | -| `execution_failed` | AI execution error | - ---- - -## Prompting Sleek - -Sleek has its own AI that plans screen content, visual style, and layout. Pass the user's request to Sleek as-is — don't add details the user didn't ask for. If the user described specific screens and styling, include those. If they just said "build me a running app," send that and let Sleek decide the rest. Sleek produces richer designs when given room to plan, so avoid inventing screen content or layout details that the user didn't specify. - ---- +`401`, `403`, and `429` bodies may include `data.url`: a page where the user can fix the condition (create a key, upgrade the plan). When present, share that URL with the user instead of improvising one. -## Designing - -### 1. Create a project - -Create a project with `POST /api/v1/projects` if one doesn't exist yet. Ask the user for a name, or derive one from the request. - -Each project has its own theme, style, and design system. If the user wants multiple design variations, create a separate project for each variation. - -### 2. Send a chat message - -Describe what to build using `POST /api/v1/projects/:id/chat/messages`. You can use the user's words directly — Sleek's AI interprets natural language. You do not need to decompose the request into screens; send the full intent as a single message and let Sleek decide what screens to create. - -Chat messages are async by default — you get a `runId` and poll for completion with `GET /api/v1/projects/:id/chat/runs/:runId`. You can also use `?wait=true` for a blocking call (up to 300s; falls back to polling if it times out with `202`). - -**Polling**: start at 2s interval, back off to 5s after 10s, give up after 5 minutes. - -**Editing a specific screen**: use `target.screenId` to direct changes to the right screen (uses the screen ID from operations, not the component ID). - -**One run at a time**: only one active run is allowed per project. If you get `409 CONFLICT`, wait for the current run to complete before sending the next message. Messages to different projects can run in parallel — use async polling (not `?wait=true`) when running multiple projects concurrently. - -**Safe retries**: add an `idempotency-key` header (≤255 chars) to replay-safe re-sends. The server returns the existing run rather than creating a duplicate. - -### 3. Show the results - -After every chat run that produces `screen_created` or `screen_updated` operations, **always take screenshots and show them to the user** using `POST /api/v1/screenshots`. Never silently complete a chat run without delivering the visuals. - -- **New screens**: one screenshot per screen + one combined screenshot of all screens in the project. -- **Updated screens**: one screenshot per affected screen. - -Use `background: "transparent"` for all screenshots unless the user explicitly requests otherwise. - -Save screenshots in the project directory (not a temporary folder) so the user can easily view them. - ---- - -## Implementing Designs - -When the user wants to implement the designs in code (not just preview them), **always fetch the component HTML code** — do not rely on screenshots alone. - -Use `GET /api/v1/projects/:id/components/:componentId` to fetch each screen's code. The `componentId` comes from the chat run's `result.operations`. - -### Which version to use - -Each component carries a `versions[]` array and an `activeVersion: number`. **By default, use the entry where `versions[i].version === activeVersion`** — that's the code currently shown in Sleek. - -If the user's prompt pins specific versions, follow those instead (see [Pinned versions](#pinned-versions) below). - -### Pinned versions - -The user's prompt may include a pin block telling you to implement specific historical versions instead of the current ones, like this: - -``` -... at this exact state instead of the project's current version: -- component cmp_abc: version ver_001 -- component cmp_def: version ver_002 -- theme thm_ghi: version ver_003 -``` - -When you see a pin block, implement those exact versions instead of `activeVersion`. Components not named in the pin block continue to use their active version. Theme IDs surface only inside pin blocks — this skill exposes no separate endpoint to enumerate them. - -#### Fetching the right code - -For each pinned component, find the entry in `versions[]` where `versions[i].id` matches the given version id (e.g. `ver_001`) and use its `code`. Do **not** fall back to `activeVersion` for pinned components. - -#### Screenshots of pinned versions - -Pass `componentVersionOverrides` and `themeVersionOverrides` to `POST /api/v1/screenshots`: - -```json -{ - "componentIds": ["cmp_abc"], - "projectId": "proj_xyz", - "componentVersionOverrides": { "cmp_abc": "ver_001" }, - "themeVersionOverrides": { "thm_ghi": "ver_003" } -} -``` - -Keys are component / theme public ids; values are the corresponding `versions[i].id`. Entities missing from a map fall back to their active version. Include the override maps whenever the prompt specified pinned versions. - -### HTML prototypes - -The component `code` is a complete HTML document — save it directly to a `.html` file. No build step needed. - -### Native frameworks (React Native, SwiftUI, etc.) - -Use both the HTML code and the screenshots together: - -- **HTML code** is the implementation reference — it contains the exact structure, layout, styling, colors, spacing, content, image URLs, and icon names. -- **Screenshots** are the visual target — use them to verify your implementation matches the intended look. - -The HTML tells you *how* to build it; the screenshot tells you *what* it should look like. - -#### Icons - -Sleek uses [Iconify](https://iconify.design) icons in the format `prefix:name` (e.g., `solar:heart-bold`, `material-symbols:search-rounded`, `lucide:settings`). The most common sets are **Solar**, **Hugeicons**, **Material Symbols** and **MDI**. - -**Use the exact icons from the HTML code** — do not substitute with a different icon set. Matching icons is important for design fidelity. - -When implementing icons: - -1. **Check if the project already has an icon system** that supports the same sets Sleek uses (Solar, Hugeicons, Material Symbols, MDI). If so, use it. Note: `@expo/vector-icons` does **not** support these sets — do not use it as a substitute. -2. **Otherwise, fetch the SVGs from the Iconify API and embed them in the code:** - ``` - GET https://api.iconify.design/{prefix}/{name}.svg - ``` - Example: `https://api.iconify.design/solar/heart-bold.svg` - - Collect all icon names from the HTML, fetch their SVGs, and save them as static assets or string constants in the codebase. For **React Native / Expo**, render them with `react-native-svg`'s `SvgXml` component — this works in Expo Go with no additional native dependencies. - -#### Fonts - -The HTML includes Google Fonts via `` tags in the ``. Use the same fonts and weights when implementing in a native framework — extract the font family names and weights from the `` tags. +Chat run-level errors (inside `data.error`): -#### Navigation +| Code | Meaning | +| ------------------ | ------------------------------------- | +| `out_of_credits` | Organization has no credits left | +| `execution_failed` | AI execution error | +| `cancelled` | Run cancelled via the cancel endpoint | -The designs may include navigation elements like tab bars and headers. Update the project's navigation styling and structure to match the designs — don't just implement the screen content while leaving the default navigation untouched. +An `out_of_credits` error includes `error.url`, the page where the user can top up credits. Relay it to the user; don't retry the run until they have. --- @@ -498,23 +550,15 @@ GET /api/v1/projects?limit=10&offset=20 --- -## Tips - -### Saving component HTML to files - -Component code can be large. When saving it to `.html` files, avoid writing the content through your text output — this is slow and wastes tokens. Instead, use shell commands to fetch the API response and write it directly to disk (e.g., pipe the response body into a file). This applies to both single and multiple components. - ---- - ## Common Mistakes -| Mistake | Fix | -| --------------------------------------------------- | ------------------------------------------------------------------------------- | -| Sending to `/api/v1` without `Authorization` header | Add `Authorization: Bearer $SLEEK_API_KEY` to every request | -| Using wrong scope | Check key's scopes match the endpoint (e.g. `chats:write` for sending messages) | -| Sending next message before run completes | Poll until `completed`/`failed` before next send | -| Using `wait=true` on long generations | It blocks 300s max; have a fallback to polling for `202` response | -| HTTP URLs in `imageUrls` | Only HTTPS URLs are accepted | -| Assuming `result` is present on `202` | `result` is absent until status is `completed` | -| Using `screenId` as `componentIds` in screenshots | `screenId` and `componentId` are different; always use `componentId` from operations for screenshots | -| Confusing `versions[i].version` (number) with `versions[i].id` (string) | When resolving pinned versions, match by `id` (e.g. `ver_001`); `version` is the numeric index | +| Mistake | Fix | +| ----------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| Omitting `source` on chat messages | Always send `source` so the run is attributed in the Sleek editor | +| Using `wait=true` on long generations | It blocks 300s max; have a fallback to polling for `202` response | +| Assuming `result` is present on `202` | `result` is absent until status is `completed` | +| Piping a JSON response through `echo` to parse it | zsh expands the `\n` in `assistantText` and breaks the JSON; parse from a file instead | +| Treating an unreadable run status as "not done yet" | The loop then spins to its cap long after the run finished; stop and report instead | +| Calling a screen incomplete based on a viewport screenshot | The content is usually below the fold; re-shoot with `fullHeight: true` or check the component HTML before reporting anything missing | +| Using `screenId` as `componentIds` in screenshots | `screenId` and `componentId` are different: every screen has both (run operations and the components list return the pair). the chat message `target.screenId` takes `screenId`; screenshots and component reads take `componentId` | +| Confusing `versions[i].version` (number) with `versions[i].id` (string) | When resolving pinned versions, match by `id` (e.g. `ver_001`); `version` is the numeric index | diff --git a/plugins/frontend-product-design/skills/ui-animation/SKILL.md b/plugins/frontend-product-design/skills/ui-animation/SKILL.md index dfb5449..1c6e56f 100644 --- a/plugins/frontend-product-design/skills/ui-animation/SKILL.md +++ b/plugins/frontend-product-design/skills/ui-animation/SKILL.md @@ -1,47 +1,51 @@ --- name: ui-animation -description: >- - Designs, implements, reviews, debugs, and reverse-engineers UI motion: CSS - transitions, keyframes, springs, gestures, drag, easing, timing, - framer-motion, and animation curves from screen recordings. Use when asked to - "add animations", "make this feel smooth", "review my animations", "add a - swipe gesture", "match this easing", "reverse engineer this animation", or - "extract the animation curve". For visual direction use ui-design; for - page-level UI audit use ui-audit. +description: Builds, reviews, and measures UI motion, including springs, gestures, scroll effects, curve fitting from recordings, and sparse interface sound. Use when asked to "add animation", "match this easing", "reverse engineer this motion", "add a click sound", or find animation opportunities. For action semantics use product-design; for visual layout use ui-design. --- # UI Animation -- **IS:** designing, implementing, reviewing, debugging UI motion (springs, gestures, drag, easing, CSS transitions, keyframes, framer-motion), and measuring motion from a recording (extract frames, track, fit curves) to emit code plus a handoff spec. -- **IS NOT:** choosing overall visual direction, palettes, or typography (use `ui-design`), auditing a whole page's UI quality (use `ui-audit`), or named text-effect specs (use the external `animate-text` skill where installed). +- **IS:** designing, implementing, reviewing, debugging UI motion (springs, gestures, drag, easing, CSS transitions, keyframes, Motion), sweeping an interface for the moments that would genuinely benefit from motion, measuring motion from a recording (extract frames, track, fit curves) to emit code plus a handoff spec, naming a described motion effect (reverse-lookup vocabulary), and gating sparse interface sound. +- **IS NOT:** choosing overall visual direction, palettes, or typography (use `ui-design` Direction mode), auditing a whole page's UI quality (use `ui-design` Audit mode), or named text-effect specs (use the external `animate-text` skill where installed). + +## Routing boundary + +`product-design` owns action semantics, scope, reversibility, and contested state choices. `ui-design` builds and styles those states. `ui-animation` owns timing, gestures, and measured motion. A routine missing loading or error state stays with the UI build; a gesture replacing a control needs a product decision and an accessible alternative before its physics. -Canonical home for reverse-engineering motion from a recording: route "reverse engineer this animation" and "match this easing" here, not to a separate skill. If the input is a screen recording or video, you are MEASURING motion: follow the Reverse-engineer workflow. Otherwise (designing, implementing, reviewing) use the rules and Workflow below. ## Reference files | File | Read when | | --- | --- | -| [references/decision-framework.md](references/decision-framework.md) | Default: deciding whether/why to animate, picking easing character | -| [references/spring-animations.md](references/spring-animations.md) | Spring physics, framer-motion useSpring, configuring spring params | +| [references/discovery-workflow.md](references/discovery-workflow.md) | Finding worthwhile opportunities for motion in an existing interface | +| [references/decision-framework.md](references/decision-framework.md) | Default: deciding whether/why to animate, picking easing character; also the seam list for a Discovery sweep | +| [references/spring-animations.md](references/spring-animations.md) | Spring physics, Motion `useSpring`, configuring spring params, Apple damping/response values, asymmetric open/close character, interruption mechanics | | [references/component-patterns.md](references/component-patterns.md) | Buttons, popovers, tooltips, drawers, modals, toasts with animation | | [references/clip-path-techniques.md](references/clip-path-techniques.md) | clip-path for reveals, tabs, hold-to-delete, comparison sliders | -| [references/gesture-drag.md](references/gesture-drag.md) | Drag, swipe-to-dismiss, momentum, pointer capture | +| [references/gesture-drag.md](references/gesture-drag.md) | Drag, swipe-to-dismiss, momentum, pointer capture, velocity handoff, momentum projection, rotary/knob drag, detents, carousel `touch-action` | +| [references/scroll-animations.md](references/scroll-animations.md) | Scroll-triggered reveals, scrubbed/scroll-driven animation (`animation-timeline`, `useScroll`), parallax, sticky scrollytelling, and when a scroll animation shouldn't exist | | [references/performance-deep-dive.md](references/performance-deep-dive.md) | Jank, CSS vs JS, WAAPI, CSS variables trap, Framer Motion caveats | +| [references/debugging-symptoms.md](references/debugging-symptoms.md) | An animation feels off and the cause isn't named: symptom-indexed tables for sluggish, robotic, cheap, jumpy, and misfiring motion | +| [references/svg-animation.md](references/svg-animation.md) | Animating vector art: line drawing (`stroke-dashoffset`), SVG transform-origin traps, path morphing, shakes, ambient life | | [references/review-format.md](references/review-format.md) | Reviewing animation code: ten standards (each with flag-on-sight triggers), Before/After/Why table, Block/Approve verdict | -| [references/contextual-animations.md](references/contextual-animations.md) | Contextual icon swaps, word-level stagger entrances, fixed-offset exits | -| [references/transition-recipes.md](references/transition-recipes.md) | Installing a CSS transition: card resize, badge, dropdown, modal, panel, page slide, icon swap, number pop-in, text swap, success, avatar hover, error shake | +| [references/contextual-animations.md](references/contextual-animations.md) | Contextual icon swaps, word-level stagger entrances, peripheral de-emphasis, fixed-offset exits | +| [references/transition-recipes.md](references/transition-recipes.md) | Installing a CSS transition: container morph, card resize, badge, dropdown, modal, panel, page slide, icon swap, number pop-in, odometer roll, text swap, success, avatar hover, error shake | | [references/measurement-guide.md](references/measurement-guide.md) | Reverse-engineer: what to measure, eye vs script, reading `metrics.json`, choosing an ROI | | [references/curve-fitting.md](references/curve-fitting.md) | Reverse-engineer: reading `fit_curves.py` output, spring vs bezier, judging fit error, asymmetric open/close | | [references/code-output.md](references/code-output.md) | Reverse-engineer: emitting code for CSS, Motion/Framer Motion, SwiftUI, React Native, UIKit | | [references/choreography.md](references/choreography.md) | Reverse-engineer: multi-element/multi-phase motion: staggers, blur-before-move, per-edge settling | +| [references/live-tuning.md](references/live-tuning.md) | Dialling a curve in live when there is no reference to fit against: the DevTools bezier editor, retiming in the Animations panel, when a control-panel library earns a dependency | +| [references/vocabulary.md](references/vocabulary.md) | Naming a motion effect the user describes vaguely ("what's it called when...") | +| [references/interface-sfx.md](references/interface-sfx.md) | Click sounds, interface audio, UI SFX, haptic-plus-sound, or "why is the web afraid of sound" | ## Core rules - Animate for feedback, orientation, continuity, or deliberate delight. If it's just "it looks cool" and the user sees it often, don't. -- Never animate keyboard-initiated actions (shortcuts, arrow navigation, tab/focus); they repeat constantly and animation makes them feel slow. +- Keep keyboard focus and repeated navigation immediate. A state transition may animate if focus and task completion do not wait for it. - Prefer CSS transitions for interruptible UI: keyframes restart from zero on interruption, transitions retarget. Use keyframes only for predetermined sequences. - Implementation priority: CSS transitions > WAAPI > CSS keyframes > JS (`requestAnimationFrame`); under load CSS stays smooth while JS drops frames. - Asymmetric timing: occasional interactions can enter slightly slower, exit fast. High-frequency ephemeral UI (hover highlights, popovers, panel toggles) inverts this: enter instantly (0ms), exit with a brief fade (100-150ms) so the action feels immediate. +- Tappable controls press on `:active` at 0ms and set `touch-action: manipulation`. - Use `@starting-style` for DOM entry; fall back to a `data-mounted` attribute where unsupported. - A small `filter: blur(2px)` hides rough crossfades between swapped content. @@ -49,7 +53,8 @@ Canonical home for reverse-engineering motion from a recording: route "reverse e - **Continuity over teleportation.** Elements visible in both states transition in place; expand from where elements sit rather than fading in a new instance. Never duplicate a persistent element or hard-cut between views that share components; hard cuts lose spatial context. - **Directional motion matches position.** Tab and carousel transitions animate in the direction matching spatial layout (left-to-right forward, right-to-left back). -- **Emerge from the trigger.** Overlays, trays, and panels animate outward from the element that opened them; generic centre-screen entrances break spatial orientation. +- **Emerge from the trigger.** Overlays, trays, and panels animate outward from the element that opened them; generic centre-screen entrances break spatial orientation. Better still where the shapes allow: let the trigger *become* the surface (see the container-morph recipe). +- **Confirm in place, not in a corner.** An action's result belongs on the control that caused it: the button becomes "Copied", holds, and reverts. A toast in the far corner makes the user's eye leave the thing they just touched to find out whether it worked. Reserve corner toasts for results with no on-screen origin (a background job finishing, an incoming message). - **Animate paired states together.** If open animates, close animates. If hover has motion, focus and pressed states get equivalent feedback. Do not polish only one half of a repeated interaction. - **Delight scales inversely with frequency.** Rarer interactions get more personality; high-frequency actions must be invisible. - **Motion enhances perceived speed.** Smooth transitions feel faster than hard cuts, even at identical load times. @@ -58,12 +63,12 @@ Canonical home for reverse-engineering motion from a recording: route "reverse e - Movement: `transform` and `opacity` only; they skip layout and paint. - State feedback: `color`, `background-color`, and `opacity` are acceptable. -- Never animate layout properties (`width`, `height`, `top`, `left`); they trigger layout recalc every frame. (Exception: a deliberate container resize tween, see the card-resize recipe.) +- Never animate layout properties (`width`, `height`, `top`, `left`); they trigger layout recalc every frame. (Exception: a deliberate container tween, see the card-resize and container-morph recipes.) - Never use `transition: all`; it animates unintended properties and silently adopts future ones. List them explicitly. - Avoid `filter` animation for core interactions; if unavoidable keep blur ≤ 20px (heavy blur is expensive, especially in Safari). -- SVG: apply transforms on a `` wrapper with `transform-box: fill-box; transform-origin: center`; without it they rotate/scale around the canvas origin. +- SVG: apply transforms on a `` wrapper with `transform-box: fill-box; transform-origin: center`; without it they rotate/scale around the canvas origin. Line drawing, path morphing, and the Motion SVG origin override live in [references/svg-animation.md](references/svg-animation.md). - `transform: scale()` also scales children (icons, text, borders scale proportionally), unlike `width`/`height`: a feature for press feedback, but account for it when an inner element must stay fixed-size. -- Disable transitions during theme switches (`[data-theme-switching] * { transition: none !important }`), or every themed property animates at once. +- Disable transitions during theme switches (`[data-theme-switching] * { transition: none !important }`), or every themed property animates at once. Force a reflow (`void document.body.offsetHeight`) after the flip and remove the override on the next frame, or use `next-themes` `disableTransitionOnChange`. ## Easing defaults @@ -75,7 +80,8 @@ Canonical home for reverse-engineering motion from a recording: route "reverse e | Modals, drawers | 200-350ms | `cubic-bezier(0.22, 1, 0.36, 1)` | | Move/slide on screen | 200-300ms | `cubic-bezier(0.25, 1, 0.5, 1)` | | Page transitions | 250-400ms | enter or move curve | -| Simple hover (colour/opacity) | 200ms | `ease` | +| Hover (colour/opacity) | 200ms | `ease` | +| Hover (transform/scale) | 100-150ms | enter curve | | Illustrative/marketing | Up to 1000ms | Spring or custom | Keep routine UI under 300ms; scale duration with distance (a full-screen slide can exceed 300ms, a 6px tooltip shift stays under 150ms). @@ -84,7 +90,10 @@ Keep routine UI under 300ms; scale duration with distance (a full-screen slide c - **Enter:** `cubic-bezier(0.22, 1, 0.36, 1)` for entrances and transform-based hover - **Move:** `cubic-bezier(0.25, 1, 0.5, 1)` for slides, drawers, panels -- **Drawer (iOS-like):** `cubic-bezier(0.32, 0.72, 0, 1)` +- **Drawer (iOS-like):** `cubic-bezier(0.32, 0.72, 0, 1)` (extremely steep start; the reason its 500ms doesn't read as slow) +- **Expo out:** `cubic-bezier(0.19, 1, 0.22, 1)` for dramatic reveals, card hovers, text reveals +- **Press:** `cubic-bezier(0.25, 0.46, 0.45, 0.94)` for button press feedback +- **On-screen move:** `cubic-bezier(0.645, 0.045, 0.355, 1)` for back-and-forth movement that stays on screen Avoid `ease-in` for UI: it starts slow, so the element lags the user's action and feels sluggish. Prefer custom curves from [easing.dev](https://easing.dev/) over built-in `ease`/`ease-out`, whose gentle acceleration reads soft, not decisive. @@ -95,6 +104,7 @@ Match the UI element first, then pick the recipe from [references/transition-rec | UI pattern | Recipe | |---|---| | Trigger + floating dot/count | Notification badge | +| Trigger grows into the surface it opens | Container morph | | Trigger + anchored surface | Menu dropdown | | Centred surface on top of page | Modal dialog | | Panel sliding into existing container | Panel reveal | @@ -102,7 +112,8 @@ Match the UI element first, then pick the recipe from [references/transition-rec | Element dimension changes | Card resize | | Text updating in place | Text state swap | | Two icons in same slot | Icon swap | -| Number updating | Number pop-in | +| Number arriving on its own | Number pop-in | +| Number the user is driving | Odometer digit roll | | Confirmation / success moment | Success celebration | | Hovering item in horizontal stack | Avatar group hover | | Form validation error | Error state shake | @@ -111,24 +122,23 @@ Prefer lower-overhead transitions (CSS-only) unless the design requires JS orche ## Spatial and sequencing -- Set `transform-origin` at the trigger point for popovers; keep `center` for modals (app-level state, not an anchored trigger). -- For dialogs/menus, start around `scale(0.85-0.9)`. Never `scale(0)`: nothing appears from nothing. -- Stagger reveals at 30-50ms per item; total stagger under 300ms. Vary timing by visual importance, most important element leads; uniform stagger removes hierarchy and feels mechanical. +- Popover `transform-origin` at the trigger (modals stay `center`), dialog/menu entrances from `scale(0.9-0.96)` not `scale(0)` (small popovers at the low end, full dialogs at the high end: a large surface already travels far in absolute pixels), and 30-50ms staggers (total under 300ms, most important element leading). Full rules and code in [references/component-patterns.md](references/component-patterns.md) and [references/contextual-animations.md](references/contextual-animations.md). - **Paired elements rule:** elements that animate together (modal + overlay, tooltip + arrow, FAB + label) must share easing and duration. Mismatched timing is the usual cause of "something feels off". ## Accessibility -- Every animation needs a `prefers-reduced-motion: reduce` path: disable transform/keyframe motion, keep instant state changes or opacity-only fades. All recipes include the guard. -- Gate hover animations behind `@media (hover: hover) and (pointer: fine)`, or touch devices replay hover on tap. Tailwind v4 `hover:` utilities apply this automatically; skip the manual query there. +- Gate hover (motion and paint) behind `@media (hover: hover) and (pointer: fine)`, or touch devices replay hover on tap. Inspect the generated CSS before adding a gate; Tailwind v4 already wraps `hover:` in `@media (hover: hover)`. - During direct manipulation, keep the element locked to the pointer with no easing; add easing only after release. +- Optional interface SFX: sparse, gesture-unlocked, additive confirmation only. See [references/interface-sfx.md](references/interface-sfx.md). ## Performance - Pause looping animations off-screen with `IntersectionObserver`; they burn GPU even when invisible. - Toggle `will-change` only during heavy motion and only for `transform`/`opacity`; remove it after. Each promotion costs compositor memory; permanent promotion across many elements is worse than none. - Do not animate drag via CSS variables on a container; every update recalculates styles for all children. Set `transform` directly on the moving element. -- Motion `x`/`y` values are the default for axis movement and drag (they bypass React re-renders). Use a full `transform` string only when one owner must combine multiple transform functions or interop with non-Motion code. -- See [references/performance-deep-dive.md](references/performance-deep-dive.md) for WAAPI, compositing layers, and the CSS vs JS comparison table. +- Motion `x`/`y` values are the default for axis movement and drag (they bypass React re-renders). Use a full `transform` string when one owner must combine multiple transform functions, interop with non-Motion code, or survive a busy main thread: the shorthands run on `requestAnimationFrame` and drop frames when motion coincides with navigation, data loading, or hydration; CSS/WAAPI stay smooth there. +- Motion that janks only sometimes (on open, during navigation, while data lands) is usually a long task sharing the tick, not a costly animation. Don't start an animation and expensive work in the same tick: start the motion, let a frame land, then do the work, or defer it to `transitionend`. +- See [references/performance-deep-dive.md](references/performance-deep-dive.md) for WAAPI, compositing layers, long tasks during animation, and the CSS vs JS comparison table. ## Anti-patterns @@ -136,10 +146,11 @@ High-signal failures not covered above: - Animating on mount without a user trigger: unexpected motion disorients; the user did nothing to cause it. - Hard stops on drag boundaries feel broken; apply friction/damping so movement diminishes past it (see gesture-drag reference). -- Mixing Motion `x`/`y` with a handwritten `transform` on one element: both write `transform`, so one clobbers the other. Pick one transform owner. - Animating both a container and staggering its children: pick one entrance per container. If the panel slides in, its content should already be visible on arrival. -- Keyframes on rapidly-triggered elements (toasts, list items): interruption restarts from zero; use CSS transitions, which retarget. - Tooltip animation after the first is open: subsequent tooltips in the group open instantly, or the toolbar feels laggy. +- Scroll-revealing product UI, above-the-fold content, or every section of a page: scroll reveals belong to a few chosen moments on marketing surfaces, run once, and never re-animate on scroll-up (see [references/scroll-animations.md](references/scroll-animations.md)). +- Easing or duration on scrubbed (scroll-driven) motion: scroll position is the clock, so any curve or duration makes it lag the scrollbar. `linear` and no duration is correct there, and only there. +- Installing `framer-motion` for new work: the package is now `motion` and React imports come from `motion/react`. The old package still resolves, so a mixed codebase compiles while shipping two copies of the library. ## Workflow @@ -155,7 +166,7 @@ Animation progress: ``` 1. Answer the four questions in [references/decision-framework.md](references/decision-framework.md): animate? purpose? easing? speed? -2. Pick duration from the easing defaults table above. +2. Pick duration from the easing defaults table above. If the value is contested or the component is hard to reach, dial it live in the DevTools bezier editor rather than guessing, then bake the result into source ([references/live-tuning.md](references/live-tuning.md)). 3. Choose implementation: CSS transition > WAAPI > spring > keyframe > JS. 4. Load the reference for your component or technique. 5. When reviewing, apply the strict posture in [references/review-format.md](references/review-format.md): measure against the ten standards, output the Before/After/Why table, then a tiered verdict ending in a Block/Approve decision. @@ -167,14 +178,20 @@ Produce evidence for each check (DevTools observations, not "looks fine"): - Grep the diff for layout property transitions (`width`, `height`, `top`, `left`) and `transition: all`. - Retoggle components rapidly; confirm transitions retarget instead of restarting from zero. - Slow to 10% in the DevTools Animations panel to catch timing and `transform-origin` issues invisible at full speed. -- Emulate `prefers-reduced-motion: reduce` (DevTools Rendering panel) and confirm every animation has a reduced path. - Confirm `will-change` is toggled around animations, not permanently set, and looping animations pause off-screen. - Test touch interactions on real devices; simulators under-report gesture and hover-on-tap issues. +- Honor `prefers-reduced-motion`: replace spatial travel with immediate state changes or restrained fades. Pause looping decorations with `animation-play-state: paused` (do not yank them with `display: none`). Keep explicit user-triggered feedback. Exercise the same task in that mode. + +## Discovery workflow + +For "where should this animate", load `references/discovery-workflow.md` and `references/decision-framework.md`. Report opportunities supported by purpose and usage frequency. Implement a suggestion only when implementation is in scope. ## Reverse-engineer workflow Use this branch to measure an existing animation from a screen recording, then emit code and a handoff spec that reproduce it. The scripts under `scripts/` are the canonical, deterministic path; run them rather than reconstructing their logic. +Resolve every `scripts/` command below relative to the installed skill directory, not the application working directory. + **Dependencies:** `ffmpeg` for frame extraction (`brew install ffmpeg`); Python with `pip install opencv-python numpy scipy` for tracking and curve fitting. Degrades gracefully: with only ffmpeg you can extract frames and reason visually; tracking and fitting need the Python packages. ```text @@ -201,10 +218,19 @@ Reverse-engineer progress: - `fit_curves.py` defaults to `--fps 30`: extract at 60 but fit at the default and every `duration_ms` doubles while fitted stiffness drops to a quarter. Always pass the extraction fps to the fit. - Sampling above the source rate duplicates frames: a 24 fps GIF extracted at 60 inflates fit error with plateaued runs in `metrics.json`. Probe and match the source rate. - Screen recordings drop frames and iOS/QuickTime captures are variable-frame-rate; consecutive identical rows are duplicated frames, not a pause. Re-record at a steadier rate if plateaus dominate. -- Open and close are never mirror images; measure each direction as its own clip. Treat a fit `error` above 0.08 as suspect. +- Measure open and close as separate clips and report two curves; never fit one and reuse it reversed (see `references/choreography.md`). Treat a fit `error` above 0.08 as suspect. + +Maintenance only: when changing Discovery routing or the gate, run the scenarios in `evaluations/` as a regression rubric. They never load during a user task. + +## Sources + +Interface SFX gating taken from Craft (gustavo-fior) and Raphael Salaja's web-sound writing. Novelty 90/10 split, one-shot intro gating, and `animation-play-state` on loops taken from Rauno Freiberg. Rejected vendoring emilkowalski/skills and gustavo-fior/craft: trigger collision with this skill. Clip-path and proportional scale already lived here. ## Related skills -- `ui-design`: visual direction, palettes, typography; settle the visual system before tuning motion. -- `ui-audit`: page/feature-level UI quality audit; its motion findings route back here for fixes. +- `product-design`: which states exist, what an action affects, and whether it is reversible. Route here first when a gesture replaces a control, since swipe-to-delete and hold-to-confirm change what the user can do before they change how it moves. +- `ui-design` Direction mode: visual direction, palettes, typography; settle the visual system before tuning motion. +- `ui-design` Audit mode: page/feature-level UI quality audit. Motion craft and fixes belong here. - Optional external `animate-text` skill where installed: curated named text effects (typewriter, line reveal, stagger builds) with exact JSON specs. + +Maintenance only: `evals/evals.json` contains regression scenarios for changes to this skill; it does not load during a user task. diff --git a/plugins/frontend-product-design/skills/ui-animation/evals/evals.json b/plugins/frontend-product-design/skills/ui-animation/evals/evals.json new file mode 100644 index 0000000..9ebeff3 --- /dev/null +++ b/plugins/frontend-product-design/skills/ui-animation/evals/evals.json @@ -0,0 +1,51 @@ +{ + "skill_name": "ui-animation", + "evals": [ + { + "id": 1, + "prompt": "Review a Tailwind v4 modal animation. Generated hover CSS already includes @media (hover: hover). Keyboard focus moves immediately; a 120ms opacity transition continues afterward. Reduced motion uses an immediate state change.", + "expected_output": "Avoid false positives for already-gated hover and nonblocking keyboard feedback.", + "files": [], + "assertions": [ + "Inspects generated hover CSS", + "Does not ban the transition solely because a keyboard opened it", + "Checks reduced-motion task completion" + ] + }, + { + "id": 2, + "prompt": "Match a recording extracted at 60fps using the bundled fitting scripts. The fitting default is 30fps.", + "expected_output": "Resolve installed script paths and carry the actual frame rate through fitting.", + "files": [], + "assertions": [ + "Passes 60fps to fitting", + "Does not run scripts relative to the application by accident", + "Reports fitted error rather than claiming an exact visual match" + ] + }, + { + "id": 3, + "prompt": "Add a click sound to every button on the dashboard, including list-row hovers.", + "expected_output": "Refuse high-frequency SFX; if any sound ships, it is rare, gesture-unlocked, and additive to visual feedback.", + "files": [], + "assertions": [ + "Loads interface-sfx.md", + "Keeps typing, hover, and list navigation silent", + "Does not create AudioContext on page load" + ] + } + ], + "routing": { + "should_trigger": [ + "Review a Tailwind v4 modal animation. Generated hover CSS already includes @media (hover: hover). Keyboard focus moves immediately; a 120ms opacity transition continues afterward. Reduced motion uses an immediate state change.", + "Match a recording extracted at 60fps using the bundled fitting scripts. The fitting default is 30fps.", + "Add a click sound to every button on the dashboard, including list-row hovers." + ], + "near_miss": [ + { + "prompt": "Should swipe-to-delete be undoable?", + "expected": "product-design" + } + ] + } +} diff --git a/plugins/frontend-product-design/skills/ui-animation/evaluations/discovery-mode.json b/plugins/frontend-product-design/skills/ui-animation/evaluations/discovery-mode.json new file mode 100644 index 0000000..158fa30 --- /dev/null +++ b/plugins/frontend-product-design/skills/ui-animation/evaluations/discovery-mode.json @@ -0,0 +1,36 @@ +[ + { + "skills": [ + "ui-animation" + ], + "query": "Where should this animate? Nothing moves right now and it feels dead.", + "files": [ + "fixtures/settings-panel.tsx" + ], + "expected_behavior": [ + "Selects the Discovery workflow, not the main build workflow: reports opportunities with file:line evidence instead of editing the fixture", + "Flags the Advanced toggle as a feedback gap: onClick with no :active or transition", + "Flags the {expanded && ...} conditional as teleporting state, and proposes an opacity plus transform entrance rather than an animated height", + "Flags the saved confirmation as a rare high-emotion moment where the delight budget applies", + "REJECTS CommandMenu explicitly: keyboard-initiated and opened 100+ times a day, so it never animates", + "REJECTS the requests table: functional data the user is reading, where motion hinders", + "Includes the rejected-candidates section, each naming the gate question that killed it", + "Names a purpose (feedback, orientation, continuity, delight) for every surviving suggestion, and gives exact property, duration, and curve values drawn from the easing defaults table", + "Caps the list at seven suggestions and does not implement any of them" + ] + }, + { + "skills": [ + "ui-animation" + ], + "query": "The Advanced panel should slide open instead of popping.", + "files": [ + "fixtures/settings-panel.tsx" + ], + "expected_behavior": [ + "Selects the main workflow, not Discovery: the user named the interaction, so there is nothing to sweep for", + "Implements the transition rather than returning a report with a rejected-candidates section", + "Animates transform and opacity, never height" + ] + } +] diff --git a/plugins/frontend-product-design/skills/ui-animation/evaluations/fixtures/settings-panel.tsx b/plugins/frontend-product-design/skills/ui-animation/evaluations/fixtures/settings-panel.tsx new file mode 100644 index 0000000..5fe656a --- /dev/null +++ b/plugins/frontend-product-design/skills/ui-animation/evaluations/fixtures/settings-panel.tsx @@ -0,0 +1,48 @@ +import { useState } from "react"; + +// Opened with Cmd+K, used constantly throughout the day. +export function CommandMenu({ open }: { open: boolean }) { + if (!open) return null; + return ( +
+ +
+ ); +} + +export function SettingsPanel() { + const [expanded, setExpanded] = useState(false); + const [saved, setSaved] = useState(false); + + return ( +
+ {/* No :active or transition on a pressable control */} + + + {/* Teleporting state: appears and vanishes with no bridge */} + {expanded && ( +
+ + +
+ )} + + {/* Rare, high-emotion moment rendered flat */} + {saved &&

Everything is up to date.

} + + + + {/* Functional data the user is reading */} + + + + + + + +
Requests18,204
+
+ ); +} diff --git a/plugins/frontend-product-design/skills/ui-animation/references/component-patterns.md b/plugins/frontend-product-design/skills/ui-animation/references/component-patterns.md index 8276972..fc643cc 100644 --- a/plugins/frontend-product-design/skills/ui-animation/references/component-patterns.md +++ b/plugins/frontend-product-design/skills/ui-animation/references/component-patterns.md @@ -11,18 +11,21 @@ - [Lists and stagger](#lists-and-stagger) - [Hover effects](#hover-effects) - [Step form navigation](#step-form-navigation) +- [Layout morphs and auto height (Motion)](#layout-morphs-and-auto-height-motion) - [3D transforms](#3d-transforms) ## Buttons -Add `transform: scale(0.97)` on `:active` for instant press feedback. +Add `transform: scale(0.97)` on `:active` for instant press feedback. Press is 0ms; release may ease. `touch-action: manipulation` on the control drops the double-tap-zoom delay. Do not put it on `html`, a map, or a pinch-zoom lightbox. ```css .button { + touch-action: manipulation; transition: transform 160ms cubic-bezier(0.22, 1, 0.36, 1); } .button:active { transform: scale(0.97); + transition-duration: 0s; } ``` @@ -44,9 +47,9 @@ Blur under 20px; heavy blur is expensive, especially in Safari. Scale in from the trigger point, not from center; the default `transform-origin: center` is wrong for popovers. ```css -/* Radix UI */ +/* Base UI. Radix exposes the same thing as --radix-popover-content-transform-origin */ .popover { - transform-origin: var(--radix-popover-content-transform-origin); + transform-origin: var(--transform-origin); } /* Data attribute fallback */ @@ -56,11 +59,11 @@ Scale in from the trigger point, not from center; the default `transform-origin: .popover[data-side="right"] { transform-origin: center left; } ``` -Start at `scale(0.88)`, never `scale(0)`: nothing appears from nothing. +Start at `scale(0.92)`, never `scale(0)`: nothing appears from nothing. ```css .menu { - transform: scale(0.88); + transform: scale(0.92); opacity: 0; transition: transform 200ms cubic-bezier(0.22, 1, 0.36, 1), opacity 200ms cubic-bezier(0.22, 1, 0.36, 1); @@ -133,7 +136,7 @@ Use `@starting-style` for entry animations without JavaScript: } ``` -Fall back to the `data-mounted` attribute pattern when `@starting-style` browser support is insufficient. +`@starting-style` has been Baseline since August 2024, so the `data-mounted` attribute pattern is a fallback for browsers older than that, not the default. Ship the CSS above and add the attribute path only when the support matrix actually includes those browsers. ## Toasts @@ -219,7 +222,7 @@ When removing items, use `AnimatePresence mode="popLayout"` so the exiting eleme ## Hover effects -Gate hover animations behind a media query to avoid false positives on touch. +Gate hover animations behind a media query to avoid false positives on touch. Tailwind `hover:` is not gated unless the project set `hoverOnlyWhenSupported` or a custom variant. ```css @media (hover: hover) and (pointer: fine) { @@ -239,11 +242,11 @@ Fix hover flicker: apply hover on the parent, animate the child. `translateY` on transform: translateY(-20%); } .box-inner { - transition: transform 200ms ease; + transition: transform 150ms ease; } ``` -For scale-based hover, use `scale(1.01)` to `scale(1.02)`; `scale(1.05)` is visibly inflated. Hover transitions should be 100-150ms; 300ms feels laggy because the user's eye is already on the element. +For scale-based hover, use `scale(1.01)` to `scale(1.02)`; `scale(1.05)` is visibly inflated. Transform hovers run 100-150ms, faster than the 200ms colour/opacity hover above: the user's eye is already on the element, so movement past 150ms reads as lag. ```css @media (hover: hover) and (pointer: fine) { @@ -286,6 +289,38 @@ const variants = { ``` +## Layout morphs and auto height (Motion) + +The `layout` and `layoutId` props cover what CSS can't animate, and each carries a gotcha that presents as a visual bug: + +- **`layout`** animates any layout change, including CSS-unanimatable properties like `flex-direction`. Change the element's *actual styles* (className or inline), not the `animate` prop; Motion measures before and after and interpolates. Add `layout` to neighbouring elements too, or they jump while the animating one glides. +- **`layoutId`** morphs one element into another across mount/unmount: tab indicators, card-to-detail expansions, a button becoming a popover. You can't steer *how* a shared-layout morph moves; to add motion on top, animate the **parent** and let the children follow. +- **Border radius distorts during layout animation** because the morph is transform-based scaling. Motion corrects the radius only when it's an inline pixel value: always `style={{ borderRadius: 12 }}`, never a className or `rem` radius, on anything with `layout`/`layoutId`. +- **No `key`, no exit.** An `AnimatePresence` child without a `key` never unmounts, so the exit animation silently never fires (and `AnimatePresence` must wrap the conditional, not sit inside it). When an exit does nothing, check the key first. +- **Exiting elements have stale props.** An `AnimatePresence` child that is animating out has already left the tree, so it can't see new state. Pass `custom` to both `AnimatePresence` and the `motion` element (as in the step-form pattern above), or direction-aware exits always leave the same way. + +**Auto height:** Motion can't animate `auto` to `auto`. Measure the content and animate to the pixel value: + +```jsx +import useMeasure from "react-use-measure"; + +const [ref, bounds] = useMeasure(); + + +
{content}
{/* padding lives here */} +
+``` + +The `ref` and the animated height must be on *different* elements; on the same one, the element freezes at its animated height and stops reacting to content changes. Put the padding on the inner element so the measurement includes it, and fall back to `null` (meaning `auto`) while `bounds.height` is `0` on first render to avoid a layout shift. `useMeasure` wraps `ResizeObserver`; hand-rolling it is a few lines if the dependency isn't wanted. + +When the same surface swaps content at different sizes, make the crossfade duration proportional to how much the height changed, so small changes don't over-animate: + +```js +const MIN = 0.15, MAX = 0.27; +const delta = Math.abs(bounds.height - previousHeightRef.current); +const duration = Math.min(Math.max(delta / 500, MIN), MAX); +``` + ## 3D transforms For depth effects (card flips, coin spins, orbits), use `rotateX()`/`rotateY()` with `transform-style: preserve-3d` on the wrapper: stays on the GPU, needs no JavaScript. Reserve it for illustrative or delight moments, not high-frequency UI. diff --git a/plugins/frontend-product-design/skills/ui-animation/references/contextual-animations.md b/plugins/frontend-product-design/skills/ui-animation/references/contextual-animations.md index fa492b5..afe059f 100644 --- a/plugins/frontend-product-design/skills/ui-animation/references/contextual-animations.md +++ b/plugins/frontend-product-design/skills/ui-animation/references/contextual-animations.md @@ -5,6 +5,7 @@ Patterns for icon swaps, word-level stagger entrances, and subtle exits. ## Contents - [Contextual icon swaps](#contextual-icon-swaps) - [Word-level stagger entrances](#word-level-stagger-entrances) +- [Peripheral de-emphasis](#peripheral-de-emphasis) - [Subtle exit animations](#subtle-exit-animations) --- @@ -137,6 +138,31 @@ These differ from the general-purpose 30-50ms item stagger in `component-pattern --- +## Peripheral de-emphasis + +To focus attention on one item in a set, animate the *siblings*, not the item. Blurring and fading the neighbours pushes them behind the focal plane, which reads as depth. A scrim over the whole page reads as a mode change, which is a much heavier claim than "this one is active". + +Use it for hover previews in a dense grid of chips or thumbnails, and for a picker whose options stay visible behind it. Do not use it as a substitute for a modal backdrop: a dialog that traps focus needs the scrim, because the dim is communicating that the rest of the page is inert, not merely secondary. + +```css +.chip { + transition: opacity 200ms ease, filter 200ms ease, scale 150ms cubic-bezier(0.22, 1, 0.36, 1); +} + +/* Blur the siblings of whatever is hovered, not the hovered chip. */ +@media (hover: hover) and (pointer: fine) { + .chip-grid:has(.chip:hover) .chip:not(:hover) { + opacity: 0.5; + filter: blur(2px); + } + .chip:hover { scale: 1.04; } +} +``` + +Keep the blur at 2-3px. Past about 4px the neighbours stop reading as content and the grid looks broken rather than defocused. Fade to roughly 0.5 opacity, never to invisible: the point is that the set is still there. + +--- + ## Subtle exit animations Exits should be directional (signal where content goes) but quieter than enters. Use a small fixed offset, not the computed element height. diff --git a/plugins/frontend-product-design/skills/ui-animation/references/curve-fitting.md b/plugins/frontend-product-design/skills/ui-animation/references/curve-fitting.md index 0ee38ba..f0fb5f1 100644 --- a/plugins/frontend-product-design/skills/ui-animation/references/curve-fitting.md +++ b/plugins/frontend-product-design/skills/ui-animation/references/curve-fitting.md @@ -87,14 +87,7 @@ Two more error inflators to rule out before splitting phases: ## Asymmetric open/close -Open and close are almost never mirror images: open tends to be slower and springier, close -faster and flatter. - -- Record (or trim) open and close as **separate clips** and run the full pipeline on each; - don't fit one curve and reuse it reversed. -- Report two curves. In code, give the enter and exit transitions different `duration`/easing - (and different spring configs) rather than a single shared one. -- See `references/choreography.md` for expressing asymmetry per target. +Open and close are almost never mirror images: fit each direction as its own clip and report two curves, never one curve reused reversed. Full treatment (why, and expressing it per target) in `references/choreography.md`. ## Converting spring params across APIs @@ -107,4 +100,5 @@ The fit fixes `mass = 1`. From `stiffness` (k), `damping` (c), `mass` (m): `dampingFraction = c / (2·√(k·m))` (that's `zeta`). - **Reanimated**: `withSpring(to, { stiffness, damping, mass })`. - **CSS**: no native spring. Use the fitted `bezier.css`, or generate a `linear()` easing by - sampling the spring response (more faithful for overshoot). Read `references/code-output.md`. + sampling the spring response (more faithful for overshoot). The per-target templates come from + the Emit step of SKILL.md's reverse-engineer workflow. diff --git a/plugins/frontend-product-design/skills/ui-animation/references/debugging-symptoms.md b/plugins/frontend-product-design/skills/ui-animation/references/debugging-symptoms.md new file mode 100644 index 0000000..e50a129 --- /dev/null +++ b/plugins/frontend-product-design/skills/ui-animation/references/debugging-symptoms.md @@ -0,0 +1,80 @@ +# Debugging Symptoms + +Turn "this feels off" into a named cause, then make the smallest fix that addresses it. Never tweak values blindly: randomly nudging durations produces a different animation, not a better one, and destroys the ability to tell what actually helped. + +## Contents +- [The loop](#the-loop) +- ["It feels slow / sluggish"](#it-feels-slow--sluggish) +- ["It feels robotic / lifeless / flat"](#it-feels-robotic--lifeless--flat) +- ["It feels cheap, but I can't say why"](#it-feels-cheap-but-i-cant-say-why) +- ["It's janky / drops frames"](#its-janky--drops-frames) +- ["It jumps / snaps / shifts"](#it-jumps--snaps--shifts) +- ["It fires when it shouldn't / flickers"](#it-fires-when-it-shouldnt--flickers) +- [When no row matches](#when-no-row-matches) + +## The loop + +1. **Reproduce it on the environment where it feels wrong.** A gesture that's fine on a laptop can stutter on a phone; an opacity crossfade that's fine at 120Hz looks rough at 60Hz. +2. **Slow it down.** Record and scrub frame by frame, or set the DevTools Animations panel to 10-25% playback. This is the single highest-leverage step: the flaw invisible at full speed (a late fade, a wrong origin, two states reading as separate objects) is obvious at quarter speed. +3. **Classify the symptom** with the tables below; causes are ordered by likelihood. +4. **Change one variable, re-record, compare.** Easing first, then duration: duration depends on the easing (a steep curve affords a longer duration), so tuning duration before the curve is settled is wasted work. +5. **Verify at full speed, then with fresh eyes.** An animation approved only in slow motion hasn't been approved. + +## "It feels slow / sluggish" + +| Check, in order | Fix | +| --- | --- | +| `ease-in` on the animation | Swap to a strong ease-out; `ease-in` starts slow, delaying the exact moment the user is watching. The same duration instantly feels faster. | +| Built-in named easing (`ease-out`, `ease-in-out`) | Replace with a custom curve; built-ins accelerate too weakly, so motion feels flat and slow at any duration. | +| Duration over ~300ms on product UI | Cut it. A 180ms dropdown feels more responsive than a 400ms one. Only a very steep curve earns a long duration. | +| Animation on a high-frequency action (keyboard nav, shortcut toggle, constant hover) | Delete the animation. At 100+ uses a day any duration reads as lag; the fix is removal, not tuning. | +| A `delay` in the chain | Remove or shrink it; delays on interactive responses read as the UI hesitating. | + +## "It feels robotic / lifeless / flat" + +| Check, in order | Fix | +| --- | --- | +| `linear` easing on non-constant motion | Nothing physical moves at constant speed. Ease-out for enter/exit, ease-in-out for on-screen movement. `linear` only for marquees, spinners, time-visualizing holds, and scrubbed scroll motion. | +| Curve too weak | Steepen it; when an animation feels flat, the curve is usually the problem, not the duration. | +| A duration-based ease on something that should feel alive (drag release, morphing pill) | Use a spring; fixed durations can't carry velocity or an organic settle. A weird-feeling spring is usually fixed by raising damping. | +| Uniform stagger (identical delay and distance per item) | Vary delay and distance by importance; the metronome effect is what feels mechanical. | + +## "It feels cheap, but I can't say why" + +| Check, in order | Fix | +| --- | --- | +| Entrance from `scale(0)` or a bare fade | Start from `scale(0.9-0.96)` plus opacity; nothing real appears from nothing, and a near-full start reads as "it was almost already there". | +| Wrong `transform-origin` | Popovers, dropdowns, and tooltips scale from their trigger, not center (use the library's origin variable: `--transform-origin` in Base UI, `--radix-popover-content-transform-origin` in Radix). Slowed playback makes a wrong origin unmistakable. | +| Crossfade shows two distinct overlapping states | Add `filter: blur(2px)` during the transition; blur bridges the gap so the eye reads one transforming object instead of two swapped ones. | +| Sub-animations on different clocks | Unify the timing family so the component reads as one entity; one slow sub-animation breaks the whole thing. | +| Enter and exit mismatched | Exit in the direction of entry, roughly 20% faster and simpler than the entrance; the user already decided, get out of the way. | +| Motion mismatched to personality | A playful app can bounce; a dashboard stays crisp. Feel can overrule the blueprint, but deliberately. | + +## "It's janky / drops frames" + +Work the diagnosis checklist in `performance-deep-dive.md`; the short order is: non-`transform`/`opacity` properties first, then motion coinciding with a busy main thread (move to CSS/WAAPI, or stop co-scheduling the work), then per-frame React state updates, then an inherited CSS variable driving transforms, then animated `blur()` over 20px. Only after those, `will-change: transform`. + +If it janks only sometimes (on open, on the first run, during navigation, while data lands), the animation is fine and a long task is sharing the tick. Record a performance trace over the interaction and look for a task over 50ms; fix the scheduling, not the motion (see Long tasks during animation in `performance-deep-dive.md`). + +## "It jumps / snaps / shifts" + +| Check, in order | Fix | +| --- | --- | +| Element jumps when retriggered quickly (new toast, rapid toggle) | `@keyframes` restart from zero; they aren't interruptible. Use CSS transitions or springs, which retarget from the current state with velocity. | +| Exit animation never plays | The `AnimatePresence` child is missing a `key` (or `AnimatePresence` sits inside the conditional instead of around it). No key, no exit; check this first. | +| Height snaps instead of animating | `height: auto` isn't animatable; measure it and animate the pixel value (see the auto-height pattern in `component-patterns.md`). | +| 1px shift at animation start or end | `will-change: transform`; the browser is handing the element between CPU and GPU, which render slightly differently. | +| Content flashes to its final state before animating | The initial state arrives after first paint. Set it in CSS (or `@starting-style`) so the element is born hidden. | + +## "It fires when it shouldn't / flickers" + +| Check, in order | Fix | +| --- | --- | +| Hover element oscillates between states | The hover animation moves the element out from under the cursor, ending the hover, dropping it back in. Move the transform to an inner child; the parent stays put under the cursor. | +| Hover states firing on phones | Touch taps trigger phantom hovers. Gate with `@media (hover: hover) and (pointer: fine)`. | +| Every tooltip in a row animates as the cursor sweeps | Once one tooltip is open, siblings open with no delay and no animation (Base UI exposes `data-instant`; set `transition-duration: 0ms` on it). | +| Animation replays every time it scrolls into view or on back-navigation | Intro and reveal animations run once. Unobserve after firing or persist a has-played flag. | + +## When no row matches + +The animation may be correct and wrong anyway: built to spec but the spec is off. Re-derive the basics in order: should this animate at all (frequency)? Right easing family for the motion type? Duration matched to that easing and the element's size? If a reference exists (an app whose version feels right), record the reference and scrub both side by side; matching reality beats theorizing. When a crossfade resists all tuning, a 2px blur is the sanctioned last resort. diff --git a/plugins/frontend-product-design/skills/ui-animation/references/decision-framework.md b/plugins/frontend-product-design/skills/ui-animation/references/decision-framework.md index a28ebd1..71c040f 100644 --- a/plugins/frontend-product-design/skills/ui-animation/references/decision-framework.md +++ b/plugins/frontend-product-design/skills/ui-animation/references/decision-framework.md @@ -5,8 +5,9 @@ - [2. What is the purpose?](#2-what-is-the-purpose) - [3. What easing should it use?](#3-what-easing-should-it-use) - [4. How fast should it be?](#4-how-fast-should-it-be) +- [Finding opportunities: where motion is missing](#finding-opportunities-where-motion-is-missing) -Answer these four questions in order before writing animation code. +Answer these four questions in order before writing animation code. SKILL.md carries the duration table, the named curves, and the pattern-to-recipe map; this file is the reasoning that picks between them. ## 1. Should this animate at all? @@ -19,7 +20,9 @@ Answer these four questions in order before writing animation code. | Occasional | Modals, drawers, toasts | Standard animation | | Rare / first-time | Onboarding, feedback forms, celebrations | Can add delight | -Never animate keyboard-initiated actions; they repeat hundreds of times daily and animation makes them feel slow and disconnected. +**Novelty budget.** Keep most of a surface familiar: about 90% expected motion (or none) and 10% novel treatment. Do not stack high-novelty beats in consecutive sections; put quiet structure between them. + +**One-shot only.** First-run staggers, intro morphs, and login flourishes must not replay on every visit. Gate them with a cookie, local flag, or rewrite so a reload is instant. ## 2. What is the purpose? @@ -32,97 +35,44 @@ Answer "why does this animate?" before writing code. | **Continuity** | Preserves context across state changes | Page transitions, layout shifts | | **Delight** | Adds personality (use sparingly) | Stagger reveals, spring overshoot | -If the purpose is just "it looks cool" and users see it often, don't animate. - ## 3. What easing should it use? -Follow this decision tree: +Two cases the named curves in SKILL.md do not cover: -- **Entering the viewport?** → enter curve: `cubic-bezier(0.22, 1, 0.36, 1)` -- **Exiting the viewport?** → same curve, shorter duration -- **Moving/sliding on screen?** → move curve: `cubic-bezier(0.25, 1, 0.5, 1)` -- **Simple hover (color/opacity)?** → `200ms ease` -- **Needs physics feel?** → spring -- **Direct manipulation (drag)?** → no easing, follow the pointer +- **Needs physics feel?** → spring ([spring-animations.md](spring-animations.md)) - **Constant motion (marquee, spinner)?** → `linear` -Avoid `ease-in` for UI; it starts slow and feels sluggish. Built-in `ease-out`/`ease` have gentle acceleration that reads soft rather than decisive. Custom curves like `cubic-bezier(0.22, 1, 0.36, 1)` accelerate steeply (the element covers most of its distance in the first third), so the same 200ms feels significantly faster. - -**Easing resources:** [easing.dev](https://easing.dev/) and [easings.co](https://easings.co/) for stronger custom variants. - -### Extended easing reference - -| Name | Curve | Character | -|---|---|---| -| ease-out-quad | `cubic-bezier(0.25, 0.46, 0.45, 0.94)` | Gentle deceleration | -| ease-out-cubic | `cubic-bezier(0.22, 0.61, 0.36, 1)` | Standard deceleration | -| ease-out-quart | `cubic-bezier(0.165, 0.84, 0.44, 1)` | Strong deceleration | -| ease-out-quint | `cubic-bezier(0.23, 1, 0.32, 1)` | Very strong deceleration | -| ease-out-expo | `cubic-bezier(0.19, 1, 0.22, 1)` | Explosive start, soft land | -| ease-out-circ | `cubic-bezier(0.075, 0.82, 0.165, 1)` | Circular deceleration | -| ease-in-out-quad | `cubic-bezier(0.455, 0.03, 0.515, 0.955)` | Gentle symmetric | -| ease-in-out-cubic | `cubic-bezier(0.645, 0.045, 0.355, 1)` | Standard symmetric | -| ease-in-out-quart | `cubic-bezier(0.77, 0, 0.175, 1)` | Strong symmetric | - -Use weaker curves (quad, cubic) for small or frequent elements; stronger curves (quint, expo) for large or rare transitions. +Match curve strength to size and frequency: weaker curves (quad, cubic) for small or frequent elements, stronger curves (quint, expo) for large or rare transitions. Full named catalogue at [easing.dev](https://easing.dev/), stronger custom variants at [easings.co](https://easings.co/). ### Asymmetric vs symmetric curves -Symmetric ease-in-out starts slow: a noticeable lag between the user's action and the element beginning to move. For interactive elements (drawers, panels, menus), use asymmetric curves, steep at the start and settling slowly, to preserve responsiveness while the slow deceleration adds quality. +Symmetric ease-in-out starts slow: a noticeable lag between the user's action and the element beginning to move. For interactive elements (drawers, panels, menus), use asymmetric curves, steep at the start and settling slowly, to preserve responsiveness while the slow deceleration adds quality. A steep curve covers most of its distance in the first third, so the same 200ms reads as significantly faster. Duration and easing are inseparable: a steep curve affords a longer duration because the movement is front-loaded. Vaul's drawer uses 500ms with `cubic-bezier(0.32, 0.72, 0, 1)` but doesn't feel slow, covering most of its distance in the first 200ms. ## 4. How fast should it be? -Pick duration from the easing defaults table in SKILL.md. Keep routine UI under 300ms; scale with distance: a full-screen menu can exceed 300ms, a 6px tooltip shift under 150ms. - -### Perceived performance +Duration changes perceived performance independently of actual speed: -Animation speed changes perceived performance: +- A fast-spinning spinner makes loading feel faster (same elapsed time, different perception) +- `ease-out` at 200ms _feels_ faster than `ease-in` at 200ms: the user sees immediate movement +- Instant tooltips after the first opens (skip delay and animation) make the whole toolbar feel faster -- Fast-spinning spinner makes loading feel faster (same time, different perception) -- `ease-out` at 200ms _feels_ faster than `ease-in` at 200ms: user sees immediate movement -- Instant tooltips after the first opens (skip delay and animation) make the toolbar feel faster +## Finding opportunities: where motion is missing -### Asymmetric timing +Questions 1 and 2 above judge a candidate someone already proposed. This section is the sweep that produces candidates in the first place: given an interface, where would motion genuinely help? Run every hit back through questions 1 and 2, and expect to reject most of them. A short list of high-conviction opportunities beats a long wishlist, and an opportunity finder that suggests motion everywhere produces exactly the sluggish, over-animated interfaces the rest of this skill exists to prevent. -Enter can be slightly slower than exit. Hold-to-delete: 2s linear on press, 200ms ease-out on release. +Sweep these seam classes. The skill is done sweeping when each has either yielded candidates with `file:line` evidence or been explicitly cleared. -```css -/* Release: fast */ -.overlay { - transition: clip-path 200ms ease-out; -} - -/* Press: slow and deliberate */ -.button:active .overlay { - transition: clip-path 2s linear; -} -``` - -### Instant enter, animated exit (productivity tools) - -Canonical statement: SKILL.md core rule on asymmetric timing. For high-frequency ephemeral UI, invert the standard rule: enter instantly (0ms), exit with a brief fade (100-150ms). - -```css -/* Hover highlight: instant appear, soft dismiss */ -.highlight { - transition: opacity 0.15s ease-out; - opacity: 0; -} -.item:hover .highlight { - transition-duration: 0s; - opacity: 1; -} -``` - -This applies when: -- Interaction happens tens to hundreds of times per day -- User initiates the action (hover, click, keyboard) -- Element is ephemeral (highlight, popover, tooltip after first open) +| Seam | What it looks like | Where to grep | +|---|---|---| +| Feedback gap | A pressable control with no press state | `onClick` / `onPress` on elements with no `:active`, `active:`, or transition | +| Teleporting state | Content that swaps, appears, or vanishes with no bridge | `{isOpen &&`, `{show`, `display: none` toggles, accordions and collapses with no height or opacity transition | +| Missing spatial story | A surface with no connection to what opened it | Popovers, menus, and panels with no `transform-origin` at the trigger; dismissable surfaces that exit by a different path than they entered | +| Group entrance | An occasionally-viewed grid or list that pops in whole | `.map(` renders on first-load surfaces, where a 30-50ms stagger would help | +| Gesture seam | Draggable or swipeable elements that snap with no physics | Drag and pointer handlers with no spring, no velocity-based dismissal, no rubber-banding at boundaries | +| Flat delight moment | Rare, high-emotion states rendered without any motion | First-run, empty, success, and completion components | -It does not apply to: -- Rare interactions (modals, onboarding): use standard asymmetric timing -- Content needing orientation (drawers with nav): enter animation provides spatial context +The last row is where the delight budget lives, and it is the only tier where bounce, generous stagger, or a longer beat are welcome. -Once the element should animate, match the UI pattern to a recipe via the "Transition decision rules" table in SKILL.md. +**Report both halves.** A discovery pass caps at five to seven suggestions ordered by leverage, and it must also list two to five places deliberately *not* suggested, each naming the question that killed it ("command palette open/close: keyboard-initiated, 100+/day, never animate"). The rejected list is what separates a discovery pass from an animation wishlist. Where the interface is already close to right, saying so is the correct result, not a failure. diff --git a/plugins/frontend-product-design/skills/ui-animation/references/discovery-workflow.md b/plugins/frontend-product-design/skills/ui-animation/references/discovery-workflow.md new file mode 100644 index 0000000..352e4ea --- /dev/null +++ b/plugins/frontend-product-design/skills/ui-animation/references/discovery-workflow.md @@ -0,0 +1,19 @@ +# Discovery workflow + +Use this branch when the request is "where should this animate", not "animate this". Every other mode starts from motion that exists; this one starts from its absence. It reports and never implements: hand a surviving suggestion back to the implementation workflow in SKILL.md to build it. + +```text +Discovery progress: +- [ ] Step 1: Recon the stack, existing motion tokens, and product personality +- [ ] Step 2: Sweep every seam class +- [ ] Step 3: Gate each candidate +- [ ] Step 4: Report survivors and rejections +``` + +1. **Recon.** Identify the motion library (if any), the easing and duration tokens already in use, and how often each surface is visited. Suggestions extend the existing vocabulary rather than introducing a parallel one, and a dense dashboard earns fewer and subtler suggestions than a playful consumer app. +2. **Sweep.** Walk the seam table in the decision framework loaded by SKILL.md, which carries the grep signature for each. Clear a seam explicitly rather than skipping it silently. +3. **Gate.** Run each candidate through questions 1 and 2 of the same file: frequency, then purpose. "It looks cool" is not a purpose. Most candidates die here, which is the point. +4. **Report.** Order suggestions by impact, each with `file:line`, what happens today, the named purpose, the frequency tier, and exact values (property, duration, curve) drawn from the core easing and transition tables in SKILL.md. Include rejected candidates only when the reason clarifies a likely alternative. Close with which single suggestion has the highest leverage. + +Where the interface already carries the right amount of motion, say so. That is the correct result for a well-built UI, not an empty report. + diff --git a/plugins/frontend-product-design/skills/ui-animation/references/gesture-drag.md b/plugins/frontend-product-design/skills/ui-animation/references/gesture-drag.md index efa0de5..5c61d91 100644 --- a/plugins/frontend-product-design/skills/ui-animation/references/gesture-drag.md +++ b/plugins/frontend-product-design/skills/ui-animation/references/gesture-drag.md @@ -4,11 +4,18 @@ Drag, swipe, and gesture patterns where the user directly manipulates elements. ## Contents - [Momentum-based dismissal](#momentum-based-dismissal) +- [Velocity handoff](#velocity-handoff) +- [Momentum projection](#momentum-projection) - [Boundary damping](#boundary-damping) - [Pointer capture](#pointer-capture) +- [Grab offset](#grab-offset) +- [Axis commitment](#axis-commitment) - [Multi-touch protection](#multi-touch-protection) - [Friction vs hard stops](#friction-vs-hard-stops) +- [Rotary drag](#rotary-drag) +- [Detents and snapping](#detents-and-snapping) - [Swipe-to-dismiss pattern](#swipe-to-dismiss-pattern) +- [Carousel axis](#carousel-axis) ## Momentum-based dismissal @@ -29,6 +36,43 @@ function onPointerUp(e: PointerEvent) { Default threshold: velocity > 0.11. Combine with a minimum distance (e.g. 20px) to prevent accidental dismissals. +## Velocity handoff + +When a gesture ends, the animation must continue at the finger's exact velocity so there is no visible seam between dragging and animating. This is the detail that most separates "fluid" from "fine". Pass the pointer's release velocity as the spring's initial velocity. + +Motion and Framer Motion take absolute px/s velocity directly via the `velocity` option, so hand them the raw release velocity: + +```ts +// releaseVelocity in px/s, measured over the last few pointermove events +animate(el, { y: target }, { type: "spring", velocity: releaseVelocity, bounce: 0, duration: 0.4 }); +``` + +Some spring APIs want relative velocity: normalize by the remaining distance to the target. + +```ts +const relativeVelocity = gestureVelocity / (targetValue - currentValue); +// element at y=50, target y=150 (100px to go), finger at 50px/s -> 50 / 100 = 0.5 +``` + +To have velocity ready at release, track a short position and timestamp history (last few `pointermove` events), not just the current point. + +## Momentum projection + +Don't snap to the nearest boundary from the release point. Use velocity to project where the gesture is heading, then snap to the target nearest that projected point. This is what makes a flick feel like it throws the element, exactly like scroll deceleration. Good bottom sheets and carousels (Vaul, Embla) work this way. + +```ts +// decelerationRate ~ 0.998 for a normal scroll feel; 0.99 for snappier +function project(initialVelocity: number, decelerationRate = 0.998): number { + return (initialVelocity / 1000) * decelerationRate / (1 - decelerationRate); +} + +const projectedEndpoint = currentPosition + project(releaseVelocity); +const target = nearestSnapPoint(projectedEndpoint); // choose target from the projection +animateSpringTo(target, { velocity: releaseVelocity }); // then hand off velocity (previous section) +``` + +Use this exponential-decay form, not the physics-textbook `v^2 / (2 * decel)`; the decay form is what Apple ships in the *Designing Fluid Interfaces* sample code. + ## Boundary damping Past the natural boundary (e.g. pulling a drawer up when already at top), apply damping: the more they drag, the less it moves. @@ -42,6 +86,15 @@ function applyDamping(offset: number, max: number): number { const dampedOffset = applyDamping(rawOffset, 200); ``` +Apple's canonical rubber-band function (from *Designing Fluid Interfaces*) is a good drop-in alternative, tuned to feel like iOS overscroll: + +```ts +// the further past the bound, the less the element follows +function rubberband(overshoot: number, dimension: number, constant = 0.55): number { + return (overshoot * dimension * constant) / (dimension + constant * Math.abs(overshoot)); +} +``` + Real things slow before stopping; friction beats hard stops. ## Pointer capture @@ -62,6 +115,49 @@ function onPointerUp(e: PointerEvent) { Always use `setPointerCapture`; without it, fast swipes escape the element and the drag breaks. +## Grab offset + +Record where inside the element the pointer landed, and hold that offset for the whole drag: + +```ts +let grabY = 0; + +function onPointerDown(e: PointerEvent) { + const r = el.getBoundingClientRect(); + grabY = e.clientY - r.top; // where in the element the finger actually is +} + +function onPointerMove(e: PointerEvent) { + setY(e.clientY - grabY); // not e.clientY, and not a centred element +} +``` + +Positioning from `e.clientY` alone snaps the element's top (or its centre, with a `-50%` translate) to the pointer the instant the drag begins. The element jumps under the finger before it has moved, which breaks 1:1 tracking at the only moment the user is watching for it. Grab a sheet by its handle and it should stay gripped by the handle. + +## Axis commitment + +Track from `pointerdown`, but do not claim an axis until the pointer has travelled about 10px: + +```ts +let axis: "x" | "y" | null = null; + +function onPointerMove(e: PointerEvent) { + const dx = e.clientX - startX; + const dy = e.clientY - startY; + + if (!axis) { + if (Math.hypot(dx, dy) < 10) return; // too early to tell + axis = Math.abs(dx) > Math.abs(dy) ? "x" : "y"; + } + if (axis !== "x") return; // this handler owns horizontal only + // drag... +} +``` + +Deciding on the first `pointermove` reads noise: the first few pixels of a vertical scroll usually carry some horizontal drift, so a swipe-to-dismiss row inside a scrolling list steals the gesture and the list stops scrolling. Once committed, hold the axis until `pointerup`; re-deciding mid-drag makes the element stutter between behaviours. + +This is the custom-handler counterpart to the declarative fix under Carousel axis. `touch-action` tells the browser which axis it may keep, which settles native scrolling; it does nothing for a handler resolving the ambiguity itself. + ## Multi-touch protection Ignore extra touch points after the drag begins; without this, switching fingers mid-drag makes the element jump. @@ -95,22 +191,96 @@ function applyFriction(delta: number, isAtBoundary: boolean): number { Hard stops feel broken; users expect physics. Apply friction for scroll containers, sliders, and drawers. +## Rotary drag + +For knobs and dials, track the *angle* from the control's centre, not the pointer delta. Reading `dx`/`dy` makes the knob respond to how far the pointer moved rather than where it moved to, so the grip slides off the moment the user circles wide. + +The trap is the wrap at ±180°. `atan2` jumps from `π` to `-π` in one frame, and an unguarded subtraction sends the value flying a full turn. Normalise every delta into `(-π, π]` before accumulating: + +```ts +const TWO_PI = Math.PI * 2; + +function angleFrom(el: HTMLElement, e: PointerEvent): number { + const r = el.getBoundingClientRect(); + return Math.atan2(e.clientY - (r.top + r.height / 2), e.clientX - (r.left + r.width / 2)); +} + +let last = 0; +let turns = 0; // accumulated rotation in radians, unbounded + +function onPointerDown(e: PointerEvent) { + el.setPointerCapture(e.pointerId); // see Pointer capture + last = angleFrom(el, e); +} + +function onPointerMove(e: PointerEvent) { + const now = angleFrom(el, e); + // Shortest way round, so the ±180 seam never registers as a full turn. + const delta = ((now - last + Math.PI * 3) % TWO_PI) - Math.PI; + turns += delta; + last = now; + setValue(clamp(turns / TWO_PI)); +} +``` + +Two things fall out of this. A knob with a limited range (a volume dial, not an endless encoder) needs the *accumulated* value clamped, never the per-frame angle, or the knob detaches from the pointer at the limit and jumps back when the user reverses. And a knob whose travel is under one full turn should apply Boundary damping at each end, exactly as a linear slider does. + +## Detents and snapping + +A ruler picker, tick slider, or segment scrubber has discrete stops. Do not snap during the drag: the value should follow the pointer continuously, and settle to the nearest detent only on release. Snapping live makes the control feel like it is fighting the finger. + +```ts +function onRelease(value: number, velocity: number) { + // Project where momentum would carry it, then snap that (see Momentum projection). + const projected = value + velocity * 0.15; + const target = Math.round(projected / STEP) * STEP; + animate(value, target, { type: "spring", stiffness: 500, damping: 40 }); +} +``` + +Snapping the *projected* landing point rather than the release position is what makes a flick feel like it threw the control several notches, instead of dropping it at the nearest tick. + +The tick marks themselves carry the feedback during the drag. Scale or darken the tick under the indicator as it passes, on `transform` and `color` only. This is the visual stand-in for the haptic click a physical detent would give, and without it a continuous drag over a ruler reads as a smooth slider that happens to be drawn with lines. On a device that supports it, pair the passing tick with `navigator.vibrate(1)`. + ## Swipe-to-dismiss pattern -Combine velocity, distance, and direction for a complete swipe gesture: +Velocity decides; distance is only the tie-breaker. Sign both against the dismissal direction, so "toward dismissal" is positive on each. ```ts -function handleSwipeEnd(direction: "left" | "right", distance: number, velocity: number) { - const shouldDismiss = distance > THRESHOLD || velocity > 0.11; +const FLICK = 0.11; // px/ms, matches Sonner - if (shouldDismiss) { - // Animate out in swipe direction with remaining momentum - animateOut(direction, velocity); - } else { - // Spring back to origin - springBack(); +// offset and velocity are both signed along the drag axis: +// positive = moving toward dismissal, negative = back toward rest. +function handleSwipeEnd(offset: number, velocity: number) { + if (Math.abs(velocity) > FLICK) { + // A flick decides on its own, in whichever direction it points. + if (velocity > 0) animateOut(velocity); + else springBack(velocity); + return; } + // Released slowly: position is all the intent there is. + if (offset > THRESHOLD) animateOut(velocity); + else springBack(velocity); } ``` -The exit should continue in the swipe direction with momentum; snapping elsewhere feels wrong. +The common bug is `distance > THRESHOLD || velocity > 0.11` against an unsigned velocity. A sheet dragged 80% closed and then flicked back toward open passes the distance test and dismisses anyway, which is the user's cancel gesture doing the opposite of what they asked. Checking magnitude first and sign second is what makes a reversal cancel. + +The exit continues in the swipe direction with momentum; snapping elsewhere feels wrong. Feed `velocity` into the exit spring's `velocity` option so drag and animation share no seam, and into `springBack` too: a cancelled flick that starts from zero reads as a bounce the user did not cause. + +## Carousel axis + +A horizontal scroller that also moves the page is an axis fight. + +**CSS `overflow-x` / scroll-snap:** let the browser pan horizontally, and stop horizontal overscroll from triggering Back: + +```css +.carousel { + overflow-x: auto; + touch-action: pan-x; + overscroll-behavior-x: contain; +} +``` + +**JS-driven** (Embla, Swiper, Keen): those libraries set `touch-action: pan-y` so the page still scrolls vertically while they handle the horizontal drag. Do not override to `pan-x`. + diff --git a/plugins/frontend-product-design/skills/ui-animation/references/interface-sfx.md b/plugins/frontend-product-design/skills/ui-animation/references/interface-sfx.md new file mode 100644 index 0000000..19b6736 --- /dev/null +++ b/plugins/frontend-product-design/skills/ui-animation/references/interface-sfx.md @@ -0,0 +1,41 @@ +# Interface SFX + +Sparse confirmation sounds for rare, high-stakes, or physical-feeling interactions. + +## Scope + +- **IS:** sparse confirmation sounds for rare, high-stakes, or physical-feeling interactions (toggle lock, payment confirm, drag release, success moment). +- **IS NOT:** background music, autoplay, looping UI beds, or replacing visual feedback. + +## Rules + +1. **Unlock from a user gesture.** Create or resume `AudioContext` only inside a click, tap, or keydown handler. Never on page load or in `useEffect` without a gesture. +2. **Stay quiet.** Keep volume well below content audio. Respect system mute and tab mute; if the tab is muted, do not play. +3. **Additive only.** Pair every sound with visual feedback (scale, color, icon swap). Sound confirms what the user already sees; it never carries the message alone. +4. **Same frequency rule as motion.** High-frequency actions stay silent: typing, hover, scrolling, list navigation, repeated toggles. If the user does it dozens of times per session, no sound. +5. **Honor `prefers-reduced-motion`.** Treat it as a signal to skip optional SFX unless the user explicitly enabled sounds in settings. +6. **Keep clips tiny.** Tens of milliseconds, soft attack, no peak that clips. One-shot, non-looping. +7. **One owner.** Route all playback through a tiny `play(id)` helper (preload, volume, mute checks, reduced-motion gate). No ad-hoc `new Audio()` at call sites. + +## Implementation sketch + +```javascript +let ctx; + +function unlockAudio() { + if (!ctx) ctx = new AudioContext(); + if (ctx.state === 'suspended') ctx.resume(); +} + +function playSfx(id) { + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; + if (!ctx || ctx.state !== 'running') return; + // fetch decoded buffer for id, set gain ~0.1-0.2, play once +} +``` + +Wire `unlockAudio` to the first meaningful interaction on the surface that uses SFX. + +## Sources + +Informed by Craft (gustavo-fior Interface SFX) and Raphael Salaja's writing on web sound. Original prose; not copied. diff --git a/plugins/frontend-product-design/skills/ui-animation/references/live-tuning.md b/plugins/frontend-product-design/skills/ui-animation/references/live-tuning.md new file mode 100644 index 0000000..48cb14a --- /dev/null +++ b/plugins/frontend-product-design/skills/ui-animation/references/live-tuning.md @@ -0,0 +1,59 @@ +# Live tuning + +The reverse-engineer workflow runs backwards: record a motion you admire, then fit a curve to it. This is the forward version, for when there is no reference to copy and the table value is contested. Tune against the running component instead of guessing, reloading, and guessing again. + +Start in DevTools. It is already open, it costs nothing, and it covers every bezier in the easing defaults table. + +## Contents + +- [When this is worth it](#when-this-is-worth-it) +- [The bezier editor](#the-bezier-editor) +- [Retiming in the Animations panel](#retiming-in-the-animations-panel) +- [What DevTools cannot do](#what-devtools-cannot-do) +- [Baking the value back](#baking-the-value-back) + +## When this is worth it + +- **The value is contested.** Two people disagree on whether a drawer should be 300ms or 400ms and neither can win the argument from a table. +- **The component is hard to reach.** A toast that needs a form submitted, a sheet three navigations deep. Each rebuild round trip costs more than the setup does once, and an HMR reload loses the state that got you there. +- **The motion is multi-phase.** Stagger offset, blur ramp, and settle interact, so three numbers guessed one reload at a time converge slowly. + +Not for picking a button press duration. The easing defaults table answers that in one line. + +## The bezier editor + +Chrome, Edge, and Firefox render a small curve swatch next to any `transition-timing-function` or `animation-timing-function` in the Styles (or Rules) pane. Click it for a draggable cubic-bezier editor. + +Edits apply live with no rebuild, so retrigger the interaction and watch it under the new curve. The editor emits the literal (`cubic-bezier(0.22, 1, 0.36, 1)`), which is what goes back into source. + +Two things that waste time otherwise: + +- The swatch only exists once the property is valid. On an element with no timing function yet, add the declaration in the `element.style` pane first and the swatch appears. +- Start from the table value, not a built-in preset. Opening on `cubic-bezier(0.22, 1, 0.36, 1)` gives you something to judge against; opening on `ease` means finding the table value by hand. + +Safari has no bezier editor. Tune in Chrome, verify in Safari. + +## Retiming in the Animations panel + +The panel's slow-motion playback is a debugging tool and belongs to the Validation workflow. Two of its controls are tuning tools: + +- **Drag a bar's edges** to change a duration or delay live, then replay. Faster than editing per-item delays for a stagger you are trying to feel out. +- **Read the captured group** to see every element's delay and duration side by side. This is the quickest way to recover the timing of a stagger you did not write, including one a library is generating. + +## What DevTools cannot do + +- **Springs.** No spring editor exists. Reach for the presets and the `visualDuration`/`bounce` framing in `spring-animations.md`: they are perceptual, so they land close on the first try, and a wrong spring usually needs one parameter moved rather than a search. +- **Composing multi-phase choreography.** The panel retimes what already fired; it will not let you build the phases against a shared playhead. + +If a project hits those two often enough to matter, a control-panel library (DialKit, Leva, Tweakpane) earns a dev dependency: a spring control returns a Motion `TransitionConfig` that drops straight into `animate()`, and a timeline dock composes phases. That is a standing decision about the project, not something to install mid-task for one curve. + +## Baking the value back + +A tuning surface is a measuring instrument, not a delivery mechanism. + +- A DevTools edit lives only in that tab and dies on navigation. Paste the literal into source before you believe it. +- Put it next to the other timing constants, so the next person sees it beside the values it has to agree with. +- A control panel leaves more behind than the dock: replace every sampled binding with the real animation, then remove the panel, its root, and the dependency. Framework roots hide themselves in production builds, but a vanilla root does not, and a forgotten one ships a control panel to users. +- Re-check the result against the ten standards. What felt right after ten iterations on a fast laptop still has to clear no layout-property transitions, `prefers-reduced-motion` handled, and interruption retargeting rather than restarting. + +Tune on the real surface. A curve dialled on an isolated demo reads differently against the distance, size, and neighbours of the actual component, and how often the user sees it moves the answer more than any parameter does. diff --git a/plugins/frontend-product-design/skills/ui-animation/references/performance-deep-dive.md b/plugins/frontend-product-design/skills/ui-animation/references/performance-deep-dive.md index 0cf5bfb..76b9263 100644 --- a/plugins/frontend-product-design/skills/ui-animation/references/performance-deep-dive.md +++ b/plugins/frontend-product-design/skills/ui-animation/references/performance-deep-dive.md @@ -3,7 +3,9 @@ Advanced performance guidance beyond the quick rules in SKILL.md. ## Contents +- [Property cost tiers](#property-cost-tiers) - [CSS vs JS animations](#css-vs-js-animations) +- [Long tasks during animation](#long-tasks-during-animation) - [Web Animations API (WAAPI)](#web-animations-api-waapi) - [CSS variables inheritance trap](#css-variables-inheritance-trap) - [Motion transform ownership](#motion-transform-ownership) @@ -11,6 +13,27 @@ Advanced performance guidance beyond the quick rules in SKILL.md. - [Compositing layers and will-change](#compositing-layers-and-will-change) - [Fix shaky 1px shifts](#fix-shaky-1px-shifts) +## Property cost tiers + +Every animatable property enters the browser's Layout, Paint, Composite pipeline at one of three points, and the cost differs by an order of magnitude: + +| Tier | Properties | Cost | +|---|---|---| +| Composite only | `transform`, `opacity` (plus `filter`, `clip-path`, `background-color` in current Chrome/Firefox) | Cheapest; the browser promotes these to their own layer | +| Paint + Composite | `box-shadow`, `border-radius`, `color` | No re-measuring, but an expensive redraw every frame | +| Layout + Paint + Composite | `width`, `height`, `padding`, `margin`, `top`, `left`, `border-width` | Most expensive; layout recalculates every frame | + +The paint tier is the one people miss because it doesn't look like layout. Swap down a tier: + +| Instead of animating | Animate | +|---|---| +| `width`/`height`/`padding` to grow or shrink | `scale()` | +| `margin`/`top`/`left` to move | `translate()` (percentages are relative to the element's own size) | +| `box-shadow` | `filter: drop-shadow(...)` | +| `border-radius` | `clip-path: inset(0 round 50px)` | + +A layout property may not visibly drop frames on an element with `position: absolute` or few children, but the `scale()` version looks identical and cannot regress on a slower device; take the one with no downside. + ## CSS vs JS animations | Approach | Driver | Interruptible | Best for | @@ -23,6 +46,38 @@ Advanced performance guidance beyond the quick rules in SKILL.md. **Rule: CSS transitions > WAAPI > CSS keyframes > JS.** Under load (page navigation, heavy rendering), CSS stays smooth while JS drops frames. +## Long tasks during animation + +The rule above holds because `transform` and `opacity` animate on the compositor thread, which keeps running while the main thread is blocked. Everything else shares one thread: style recalculation, layout, paint, and every line of JS including `requestAnimationFrame` callbacks and Motion's `x`/`y`. That thread is also the one your application code runs on. The budget there is roughly 10ms of the 16.6ms frame at 60Hz, and half that at 120Hz. A task over 50ms is a long task: any concurrent main-thread animation visibly stutters and input goes unanswered for its duration. + +So when motion janks *only sometimes* (on open, on first run, during navigation, while data lands), suspect the work sharing the tick, not the animation code. Moving to CSS/WAAPI is the fix when the animation can be expressed that way; when it can't (drag, springs, physics, choreography), fix the scheduling instead. + +**1. Don't co-schedule.** Starting an animation and expensive work in the same tick makes the entrance pay for the work: a modal that mounts a large tree, a drawer that parses its contents, a tab that fetches on click. Start the motion, let a frame land, then do the work, or defer the work to `transitionend`/`onAnimationComplete` so it runs after the motion finishes. + +**2. Chunk what can't be deferred,** against a time budget rather than a fixed item count, so the cost tracks the device instead of your laptop: + +```ts +const yieldToBrowser = (): Promise => + typeof scheduler !== "undefined" && "yield" in scheduler + ? scheduler.yield() + : new Promise((resolve) => setTimeout(resolve, 0)); + +async function inChunks(items: T[], work: (item: T) => void) { + let start = performance.now(); + for (const item of items) { + work(item); + if (performance.now() - start > 5) { // leave the rest of the frame to the animation + await yieldToBrowser(); + start = performance.now(); + } + } +} +``` + +`scheduler.yield()` resumes ahead of other pending tasks rather than behind them, but it is Chromium-only today, hence the `setTimeout` fallback. Use `await new Promise(requestAnimationFrame)` instead when the chunked work feeds the animation itself and must resume in step with frames. + +Yielding does not make the work faster; the total is unchanged. It lets frames paint and input dispatch between the pieces, which is the entire perceived difference. If the work genuinely cannot be split (one large parse, one synchronous layout of a huge tree), it belongs in a worker or on the server; no amount of animation tuning hides it. + ## Web Animations API (WAAPI) JavaScript control with CSS performance. Hardware-accelerated, interruptible, promise-based. @@ -76,6 +131,8 @@ const x = useMotionValue(0); Don't mix Motion `x`/`y` props with a handwritten `transform` string on one element; pick one transform owner. +One more reason to reach for the string form: the individual shorthands (`x`, `y`, `scale`, `rotate`) are implemented with CSS variables and driven from `requestAnimationFrame`, so they are not hardware-accelerated. That's harmless normally, but motion that runs *while* the main thread is busy (page navigation, tab switches during data loading, hydration) drops frames exactly then. Vercel's dashboard hit this with a shared-layout tab highlight that janked during navigation; the fix was moving it to CSS. When an animation must survive a busy main thread, animate the full `transform` string, or move it to CSS/WAAPI. + ## Pause looping animations off-screen Looping animations consume GPU resources even when not visible. diff --git a/plugins/frontend-product-design/skills/ui-animation/references/review-format.md b/plugins/frontend-product-design/skills/ui-animation/references/review-format.md index 92cb65c..5fb866b 100644 --- a/plugins/frontend-product-design/skills/ui-animation/references/review-format.md +++ b/plugins/frontend-product-design/skills/ui-animation/references/review-format.md @@ -7,8 +7,6 @@ - [Before/After/Why table](#beforeafterwhy-table) - [Review checklist](#review-checklist) - [Verdict output](#verdict-output) -- [Component design principles](#component-design-principles) -- [Debugging animations](#debugging-animations) ## Operating posture @@ -19,13 +17,13 @@ Senior motion reviewer with a brutal eye for craft. Bias toward motion that feel Measure every animation in the diff against these; a violation is a finding. For exact values (curves, durations, spring config), cite the easing/duration tables in `SKILL.md` rather than approximating. Each standard ends with a **Flag on sight** clause: hard findings to catch without deliberation. 1. **Justified motion.** Every animation answers "why animate this?": feedback, orientation, continuity, state, or deliberate delight. "Looks cool" on a frequently-seen element is a block. -2. **Frequency-appropriate.** Keyboard-initiated and 100+/day actions get no animation; tens/day gets reduced motion; occasional gets standard; rare or first-time can carry delight. Flag on sight: animation on a keyboard shortcut, command-palette toggle, or 100+/day action. +2. **Frequency-appropriate.** Keyboard focus and repeated actions must respond immediately. Flag motion that delays task completion or creates distracting repeated travel; a brief nonblocking transition is not automatically a defect. 3. **Responsive easing.** Entering/exiting elements use `ease-out` or a strong custom curve; built-in CSS easings are too weak for deliberate animation. Flag on sight: `ease-in` on any UI interaction, or weak built-in easing on a deliberate animation (it delays the moment the user watches most). 4. **Sub-300ms UI.** UI animations stay under 300ms; scale duration with distance traveled. Flag on sight: UI duration > 300ms with no stated reason. -5. **Origin and physical correctness.** Popovers, dropdowns, and tooltips scale from their trigger (`transform-origin`), not center; modals stay centered. Flag on sight: `transform-origin: center` on a trigger-anchored popover/dropdown/tooltip, or `scale(0)`/pure-fade entrances with no initial transform (start at `scale(0.85-0.97)` plus opacity). +5. **Origin and physical correctness.** Popovers, dropdowns, and tooltips scale from their trigger (`transform-origin`), not center; modals stay centered. Flag on sight: `transform-origin: center` on a trigger-anchored popover/dropdown/tooltip, or `scale(0)`/pure-fade entrances with no initial transform (start at `scale(0.9-0.96)` plus opacity). 6. **Interruptibility.** Rapidly-triggered or gesture-driven motion (toasts, toggles, drags) must retarget from its current state; prefer CSS transitions or springs over keyframes, which restart from zero. Flag on sight: keyframes on toasts, toggles, or anything added/triggered rapidly. 7. **GPU-only properties.** Animate `transform` and `opacity` only. Flag on sight: animating `width`/`height`/`margin`/`padding`/`top`/`left`; `transition: all` (unbounded property animation); Framer Motion `x`/`y`/`scale` props on motion that runs while the page is busy; updating a CSS variable on a parent to drive a child transform (style recalc storm). -8. **Accessibility.** `prefers-reduced-motion` is honored (gentler, not zero: keep opacity/color, drop movement); hover animations gated behind `@media (hover: hover) and (pointer: fine)`. Flag on sight: missing reduced-motion handling on movement, or ungated `:hover` motion. +8. **Accessibility.** Inspect generated hover gating, including Tailwind v4's built-in media query. Exercise reduced-motion behavior and the same keyboard/touch task. Flag spatial motion without an appropriate reduced-motion alternative. 9. **Asymmetric enter/exit.** Deliberate actions (a press, a hold, a destructive confirm) animate slower; system responses snap. Flag on sight: symmetric enter/exit timing on a press-and-release or hold interaction. 10. **Cohesion.** Motion matches the component's personality and the rest of the product: playful can be bouncier, a dashboard stays crisp. When unsure whether motion feels right, the strongest move is often to delete it. Flag on sight: mismatched personality, a jarring crossfade where a subtle blur would bridge two states, or an everything-at-once entrance where a 30-50ms stagger belongs. @@ -33,7 +31,7 @@ Measure every animation in the diff against these; a violation is a finding. For Prefer earlier moves over later ones: -1. **Delete the animation** (high-frequency, no purpose, or keyboard-triggered). +1. **Delete the animation** (disruptively repeated or without a purpose). 2. **Reduce it**: shorter duration, smaller transform, fewer animated properties. 3. **Fix the easing**: swap `ease-in` to `ease-out` or a strong custom curve. 4. **Fix the origin and physicality**: correct `transform-origin`; replace `scale(0)` with `scale(0.95)` plus opacity. @@ -41,7 +39,7 @@ Prefer earlier moves over later ones: 6. **Move it to the GPU**: layout props to `transform`/`opacity`; shorthand to a full `transform` string; WAAPI for programmatic CSS. 7. **Asymmetric timing**: slow the deliberate phase, snap the response. 8. **Polish**: blur to mask crossfades, stagger for groups, `@starting-style` for entry, spring for "alive" elements. -9. **Accessibility and cohesion**: add reduced-motion and hover gating; tune to match the component's personality. +9. **Accessibility and cohesion**: add hover gating; tune to match the component's personality. ## Before/After/Why table @@ -52,8 +50,8 @@ Required first part of every review. Markdown table, one row per issue; never a | `transition: all 300ms` | `transition: transform 200ms ease-out` | Specify exact properties; `all` animates unintended properties off-GPU | | `transform: scale(0)` | `transform: scale(0.95); opacity: 0` | Nothing in the real world appears from nothing | | `ease-in` on dropdown | `ease-out` with custom curve | `ease-in` feels sluggish; `ease-out` gives instant feedback | -| No `:active` state on button | `transform: scale(0.97)` on `:active` | Buttons must feel responsive to press | -| `transform-origin: center` on popover | `transform-origin: var(--radix-popover-content-transform-origin)` | Popovers scale from trigger (modals stay centered) | +| No `:active` state on button | `transform: scale(0.97)` on `:active` with `transition-duration: 0s` | Buttons must feel responsive to press | +| `transform-origin: center` on popover | `transform-origin: var(--transform-origin)` | Popovers scale from trigger (modals stay centered) | ## Review checklist @@ -73,6 +71,9 @@ Rows add recipe-specific signal beyond the ten standards; for the standard viola | Missing close-state cleanup after `setTimeout` | Add `is-closing` class, remove after transition duration | | Missing reflow (`void el.offsetWidth`) between class changes | Force reflow before re-adding classes to restart transitions | | Animating container instead of inner pieces | Apply transitions to child elements, not the wrapper | +| Same bouncy spring on open and close | Bounce the open only; damp the close and roughly halve its duration | +| Value snapped to its detent during the drag | Follow the pointer continuously; snap the projected landing point on release | +| CSS carousel scrolls the page | `touch-action: pan-x` and `overscroll-behavior-x: contain`; leave JS libraries on `pan-y` | | Hardcoded `stroke-dasharray` on SVG success path | Use `path.getTotalLength()` to measure the path | | `.is-error` and `.is-shaking` merged into one class | Keep them separate: `.is-shaking` controls animation only, `.is-error` controls visual state | @@ -85,20 +86,13 @@ Required second part of every review. Group remaining commentary by impact tier, 3. **Performance**: non-GPU properties, dropped-frame risks, recalc storms. 4. **Interruptibility and timing**: keyframes where transitions/springs belong; symmetric timing that should be asymmetric. 5. **Origin, physicality, and cohesion**: wrong origin, mismatched personality, jarring crossfades. -6. **Accessibility**: reduced-motion and pointer/hover gating. +6. **Accessibility**: pointer/hover gating. Close with a decision, citing `file:line`: -- **Block**: any feel-breaking regression, animation on a keyboard or high-frequency action, `scale(0)` or `ease-in` on UI, or a non-GPU animation with an easy GPU fix. -- **Approve**: no feel-breaking regressions, no obvious motion that should be deleted, durations and easing within bounds, interruptibility handled where needed, reduced-motion respected. +- **Block**: any feel-breaking regression, motion that delays keyboard or repeated actions, `scale(0)` or `ease-in` on UI, or a non-GPU animation with an easy GPU fix. +- **Approve**: no feel-breaking regressions, no obvious motion that should be deleted, durations and easing within bounds, interruptibility handled where needed. -## Component design principles +Reusable-component library DX (defaults over options, drop-in ergonomics, naming, docs site) is authoring, not review; see the `ui-design` skill. -Authoring-adjacent, not review. For reusable components the polish that earns adoption lives mostly outside the motion: excellent defaults over options, drop-in DX (Sonner: insert `` once, call `toast()` anywhere), transitions over keyframes for dynamic UI, personality-matched cohesion, invisible edge cases (pause timers on hidden tabs, fill gaps with pseudo-elements for hover, capture pointer on drag), memorable naming over descriptive, and a touchable docs site with copyable snippets. - -## Debugging animations - -- **Slow motion:** Increase duration 2-5x or use the browser animation inspector; check colour timing, easing, and transform-origin. -- **Frame-by-frame:** Step through the Chrome DevTools Animations panel to reveal timing issues between coordinated properties. -- **Real devices:** Test touch interactions (drawers, swipe gestures) on physical hardware; the Xcode Simulator works but real hardware is better for gestures. -- **Review next day:** Fresh eyes catch imperfections you missed during development. +For debugging animations (slow-motion, DevTools Animations panel, real-device testing, reduced-motion checks), see the Validation section in `SKILL.md`. diff --git a/plugins/frontend-product-design/skills/ui-animation/references/scroll-animations.md b/plugins/frontend-product-design/skills/ui-animation/references/scroll-animations.md new file mode 100644 index 0000000..e559c1e --- /dev/null +++ b/plugins/frontend-product-design/skills/ui-animation/references/scroll-animations.md @@ -0,0 +1,101 @@ +# Scroll Animations + +Scroll-triggered reveals and scroll-driven (scrubbed) motion. Scroll is the most abused trigger in web motion, so this reference is half restraint, half implementation, in that order. The scrollbar belongs to the user: motion may respond to scrolling, but must never take it over or make content wait. + +## Contents +- [Gate: should this scroll animation exist?](#gate-should-this-scroll-animation-exist) +- [Two kinds: triggered vs scrubbed](#two-kinds-triggered-vs-scrubbed) +- [Triggered reveals](#triggered-reveals) +- [Scrubbed animation](#scrubbed-animation) +- [Parallax](#parallax) +- [Sticky and scrollytelling sections](#sticky-and-scrollytelling-sections) +- [Never hijack scroll](#never-hijack-scroll) +- [Performance](#performance) + +## Gate: should this scroll animation exist? + +Walk this before writing any code: + +``` +Is this inside a product (dashboard, app, tool)? +├── Yes → No scroll animation. Users scroll product UI dozens of times a +│ session; content appearing late reads as lag, not delight. +└── No, it's a marketing surface (landing page, blog, docs) + ├── Is the element in the initial viewport (above the fold)? + │ └── Yes → Don't scroll-reveal it. Use a one-time intro animation + │ or nothing. The hero must never wait for a scroll event. + ├── Are you about to reveal EVERY section? + │ └── Yes → Cut it to 2-4 moments. If everything animates, nothing + │ stands out; each reveal devalues the next. + └── Does it explain, pace, or emphasize something specific? + ├── Yes → Build it (rules below). + └── No ("it looks cool") → The best animation is no animation. +``` + +Marketing pages are the packaging of the product: they have earned slower, more expressive motion because they are seen rarely. That freedom is the reason to be selective, not a license to animate everything. + +## Two kinds: triggered vs scrubbed + +Every scroll animation is one of these; the wrong choice is unfixable by tuning: + +- **Triggered reveal.** Crossing a threshold *starts* a normal animation that then runs on its own clock (easing plus duration). For "fade in as it enters the viewport". +- **Scrubbed.** Scroll position *is* the clock; progress maps directly to animation progress and reverses when the user scrolls back. For progress bars, parallax, sticky sequences. + +A scrubbed animation has **no duration and no easing**: the user's hand is both. Adding a duration to scrubbed motion makes it lag behind the scrollbar, the same disconnected feeling as a spring during a drag. + +## Triggered reveals + +- **Reveal once. Never re-animate on scroll-up.** Intro animations run one time; replaying on every pass turns delight into a tic and makes content flicker during normal reading. Unobserve after firing (or `once: true` in Motion's `useInView`). +- **The recipe:** `opacity: 0` plus `translateY(10-16px)` settling to rest, with a strong ease-out (entering elements always ease out; the fast start reads as responsive). 400-600ms is right for marketing; product-speed 200ms reveals look nervous on a landing page. +- **Trigger early.** Start the animation when the element is roughly 10-20% into the viewport (`rootMargin: "0px 0px -10% 0px"`), so it plays *as* the user arrives, not after they have stopped and stared at a blank slot. +- **Stagger like a wave, not a metronome.** Sibling reveals offset by roughly 80-120ms, with delay and distance varied by importance: the heading leads, supporting text follows, the least important item can just fade with no movement. Uniform stagger kills hierarchy. +- **Content survives without JS.** The un-animated state is *visible*; JS adds the hidden initial state right before animating. A page of `opacity: 0` sections behind a broken script is the worst failure mode a marketing page has. +- **One entrance per container.** Don't reveal a section *and* stagger its children; pick one. + +Implementation: `IntersectionObserver` (or Motion's `useInView`) toggling a class. Never a scroll listener; it fires per frame on the main thread for work a threshold check does once. + +## Scrubbed animation + +Preference order, and why: + +1. **CSS scroll-driven animations:** `animation-timeline: view()` (the element's own viewport progress) or `scroll()` (container progress). They run off the main thread, stay hooked to the scrollbar even while the page is busy loading images, and cost no JS. Progressive-enhance: wrap in `@supports (animation-timeline: view())` with the no-animation state as fallback. +2. **Motion's `useScroll` plus `useTransform`:** when progress must feed React logic or compose with springs and gestures. This runs on the main thread via `requestAnimationFrame`: fine normally, drops frames under load. +3. **A raw scroll listener writing React state: never.** A re-render per scrolled pixel. + +```css +@supports (animation-timeline: view()) { + .figure { + animation: reveal linear both; + animation-timeline: view(); + animation-range: entry 0% cover 40%; + } +} +``` + +`linear` is correct here and only here: the scrubbed timeline's pacing comes from the user's hand, and any curve would distort the 1:1 mapping. + +The `@supports` wrapper is not optional: `animation-timeline` is still not Baseline, so without it the element sits at its keyframe start forever in browsers that ignore the property. The unwrapped rule must leave the element in its final, readable state. + +## Parallax + +Parallax is depth seasoning, and heavy-handed parallax is the fastest way to make a page feel dated: + +- **Keep the differential at or under roughly 15%** of scroll distance between layers. Enough to read as depth; more reads as content swimming. +- **Transform only**, scrubbed (no duration), decorative elements only: never body text, never anything the user needs to read while it moves. +- Skip it on mobile: short viewports and momentum scrolling turn subtle parallax into jitter. + +## Sticky and scrollytelling sections + +A section that pins while scroll drives a sequence is an **explanation** device; it earns its scroll length only if each increment reveals a step of a story. Rules: progress maps monotonically to the sequence (scrolling back rewinds it); keep the pinned length at or under roughly 2-3 viewport heights, because trapped-feeling sticky sections are where users close tabs; and the section must be skippable by simply continuing to scroll. Never block or slow the scrollbar to force the story. + +## Never hijack scroll + +No scroll-jacking, no rewriting wheel deltas, no "one wheel tick = one full-screen slide". Smooth-scroll libraries that re-implement scrolling on the main thread trade native responsiveness for a float many users read as lag. If you add `scroll-behavior: smooth` for anchor links, keep it to that: + +```css +html { scroll-behavior: smooth; } +``` + +## Performance + +The golden rule holds: **animate only `transform` and `opacity`**. A scrolling page is the worst place for layout-triggering properties, since Layout and Paint work stacks on top of the scroll itself. Add `will-change: transform` on scrubbed elements only (they animate for the whole scroll, so the dedicated layer pays for itself; on one-shot reveals it's wasted memory). Keep any animated `blur()` at or under 20px. diff --git a/plugins/frontend-product-design/skills/ui-animation/references/spring-animations.md b/plugins/frontend-product-design/skills/ui-animation/references/spring-animations.md index a0ada4d..ec3b15c 100644 --- a/plugins/frontend-product-design/skills/ui-animation/references/spring-animations.md +++ b/plugins/frontend-product-design/skills/ui-animation/references/spring-animations.md @@ -2,6 +2,16 @@ Springs simulate physics, so they feel more natural than duration-based animations: no fixed duration, they settle by physical parameters. +## Contents +- [When to use springs](#when-to-use-springs) +- [Spring parameters](#spring-parameters) +- [Configuration presets](#configuration-presets) +- [Apple's damping and response framing](#apples-damping-and-response-framing) +- [Asymmetric spring character](#asymmetric-spring-character) +- [Interruptibility advantage](#interruptibility-advantage) +- [Spring-based mouse interactions](#spring-based-mouse-interactions) +- [Snap instead of spring](#snap-instead-of-spring) + ## When to use springs - Drag with momentum (release, let physics take over) @@ -39,6 +49,50 @@ Springs simulate physics, so they feel more natural than duration-based animatio Bounce signals brand personality. Default to zero (the safe choice): a finance dashboard should never bounce; a learning app or creative tool can use subtle bounce (0.1-0.2) to feel friendlier. The question isn't "does it look better with bounce?" but "does it match the brand?" +## Apple's damping and response framing + +Apple deliberately replaced the physics triplet (mass/stiffness/damping) with two designer-friendly parameters. Reason in these: + +- **Damping ratio** controls overshoot. `1.0` = critically damped, no bounce, smooth settle; `< 1.0` overshoots and oscillates; lower = bouncier. +- **Response** is how quickly the value reaches the target, in seconds. Lower = snappier. This is not a duration: a spring has no fixed duration, its settle time emerges from the parameters. + +Default most UI to **damping 1.0** (critically damped): graceful and non-distracting. Add bounce (**damping ~0.8**) only when the gesture itself carried momentum (a flick, a throw, a drag release). Overshoot on a menu that just faded in feels wrong; overshoot on a card you flicked feels right. + +Values Apple ships: + +| Interaction | Damping | Response | +|---|---|---| +| Move / reposition (e.g. PiP) | `1.0` | `0.4` | +| Rotation | `0.8` | `0.4` | +| Drawer / sheet | `0.8` | `0.3` | + +**Web mapping:** Motion's `bounce` + `duration` spring API maps closely to Apple's damping + response. A safe house style is critically damped springs everywhere by default; reserve bounce for momentum-driven, physical interactions. + +```js +// Critically damped default (no overshoot) +animate(el, { y: 0 }, { type: "spring", bounce: 0, duration: 0.4 }); + +// Momentum interaction: a little bounce, only because a flick preceded it +animate(el, { y: target }, { type: "spring", bounce: 0.2, duration: 0.4 }); +``` + +## Asymmetric spring character + +Open and close differ in **stiffness, not just duration**: when an element earns bounce, the bounce belongs to the open and the close stays critically damped. Bouncing both directions is the most common reason a well-built morph still feels cheap. + +Measured on a production container morph (frame-by-frame at 60fps): + +| Direction | Time to extreme | Overshoot | Fitted spring | At rest | +|---|---|---|---|---| +| Open | 284ms | 121% of travel | `stiffness: 155, damping: 11` (ζ 0.44) | 584ms | +| Close | 185ms | ~102% of travel | `stiffness: 620, damping: 36` (ζ ~0.75) | 300ms | + +The close is twice as fast *and* nearly four times as stiff. Its 2% undershoot is below the perceptual threshold, so a plain `cubic-bezier(0.32, 0.72, 0, 1)` substitutes for it cleanly. + +This widens the "bounce only after momentum" default above rather than replacing it. A menu that merely faded in still should not bounce. A container the user watched push outwards has enough implied mass to justify a settle, and that is the one case where the default reads as too conservative. + +For measuring asymmetry off a recording rather than choosing it, `choreography.md` covers reading the two directions out of the frame timeline. + ## Interruptibility advantage Springs keep velocity when interrupted; CSS keyframes restart from zero. Ideal for gestures users might change mid-motion. @@ -51,12 +105,18 @@ Springs keep velocity when interrupted; CSS keyframes restart from zero. Ideal f /> ``` +Three rules make interruption feel seamless: + +- **Animate from the presentation value, never the logical target.** On interrupt, read the element's live on-screen transform and start the new animation from there. Starting from the target value causes a visible jump. (A closing modal the user grabs again should follow the finger, not finish closing first and then reopen.) Springs do this by default; CSS transitions and keyframes cannot be grabbed and reversed mid-flight, so avoid them for gesture-driven motion. +- **Carry velocity through a retarget.** Replacing one animation with another at a reversal creates a velocity discontinuity, a "brick wall". Pick a spring library that re-targets from the current velocity (iOS does this natively with additive animations). +- **Decompose 2D motion into independent X and Y springs.** A single spring on a 2D distance desyncs when X and Y have different velocities. + ## Spring-based mouse interactions Tying values directly to mouse position feels artificial. Use `useSpring` to interpolate instead of updating immediately. ```tsx -import { useSpring } from "framer-motion"; +import { useSpring } from "motion/react"; // Without spring: instant, feels artificial const rotation = mouseX * 0.1; diff --git a/plugins/frontend-product-design/skills/ui-animation/references/svg-animation.md b/plugins/frontend-product-design/skills/ui-animation/references/svg-animation.md new file mode 100644 index 0000000..aed8422 --- /dev/null +++ b/plugins/frontend-product-design/skills/ui-animation/references/svg-animation.md @@ -0,0 +1,115 @@ +# SVG Animation + +Recipes for animating vector art: line drawing, rotation, path morphing, shakes, and ambient life. SVG has its own coordinate system and its own transform-origin rules, so HTML habits produce wrong results here. + +## Contents +- [Fundamentals](#fundamentals) +- [Line drawing (self-drawing stroke)](#line-drawing-self-drawing-stroke) +- [Rotation and transform-origin (the SVG trap)](#rotation-and-transform-origin-the-svg-trap) +- [Path morphing](#path-morphing) +- [Shakes and multi-step motion](#shakes-and-multi-step-motion) +- [Ambient life](#ambient-life) +- [Performance for busy SVG scenes](#performance-for-busy-svg-scenes) + +## Fundamentals + +- SVG is coordinate-based with no document flow; unpositioned elements stack at `(0,0)`. +- `viewBox="minX minY width height"` is the camera: it enables responsive scaling and keeps animation values consistent at any display size. +- Path commands: `M` move (no draw), `L` line, `Z` close; uppercase is absolute, lowercase relative. Close paths with `Z` or the point where start meets end shows an awkward corner. +- Degenerate shapes don't render at all: `width="0"`, `r="0"`, or a line whose start equals its end vanish entirely (unlike `opacity: 0`, where the shape still exists). +- Put `overflow: visible` on the `` so overshoot and scale don't clip. Nest `` groups to layer independent transforms on one element. + +## Line drawing (self-drawing stroke) + +Reveal a stroke as if it's being drawn by animating `stroke-dashoffset`: + +1. Set `stroke-dasharray` so the dash equals the full path length and the gap is large (only one dash shows). +2. Offset by the path length to hide it. +3. Animate the offset back to `0` to draw it in. + +```css +path { + stroke-dasharray: 1px 1.1px; + stroke-dashoffset: 1px; + animation: draw 0.6s cubic-bezier(0.22, 1, 0.36, 1) forwards; +} +@keyframes draw { to { stroke-dashoffset: 0; } } +``` + +- `pathLength="100"` on the path normalizes its length so you work in round numbers and can share values across paths of different real lengths. +- `animation-fill-mode: forwards` is required or the shape snaps back to hidden when the animation ends. +- Stagger multiple strokes with `animation-delay` (a checkmark waits for its box to finish drawing). +- `stroke-linecap: round` gotcha: rounded caps extend past the mathematical dash, so make the gap slightly larger than the dash (`1px` dash, `1.1px` gap) or the caps peek through while the line should be hidden. + +## Rotation and transform-origin (the SVG trap) + +`transform-origin` in SVG defaults to the viewBox `(0,0)`, and `center` means the center of the viewBox, not the element. Fix it one of two ways: + +```css +/* Preferred: make origin relative to the element's own box (HTML-like) */ +.el { transform-box: fill-box; transform-origin: center; } + +/* Or: keep viewBox coordinates and rotate around a specific point */ +.hand { transform-origin: 50px 50px; } /* clock center of a 100x100 viewBox */ +``` + +For a zero-thickness line's bounding box, `transform-origin: 0% 100%` hits the start point (the zero dimension ignores its percentage). + +**Motion for React overrides a `transformOrigin` set in `style` on SVG elements back to `50% 50%`.** Set it in the `initial` prop instead: + +```jsx + +``` + +Use `transform-box: view-box` plus a pixel `transformOrigin` to rotate a group around a distant point (e.g. decorations orbiting a clock's center). + +## Path morphing + +Animate a path's `d` between two shapes; this only works when both paths share point structure: + +```jsx +const progress = useMotionValue(0); +const d = useTransform(progress, [0, 1], [openPath, closedPath]); +// +``` + +If the two paths differ in structure, interpolate with the `flubber` library instead. + +## Shakes and multi-step motion + +Keyframe arrays fit shakes, pulses, and press feedback: decaying, alternating-sign values. + +```jsx +// bell shake: rotate keyframes, large to small, alternating +animate={{ rotate: [0, 20, -15, 12.5, -10, 10, -7.5, 7.5, -5, 5, 0] }} +// press feedback: compress, overshoot, settle +animate={{ transform: ["scale(1)", "scale(0.97)", "scale(1.01)", "scale(1)"] }} +``` + +Put the rotate on a wrapping `` so nested decorations shake for free. + +## Ambient life + +Make idle scenes feel alive with barely perceptible looping motion, and use **non-syncing durations** so layers never line up; that's what makes it read organic instead of mechanical: + +```jsx +// float: translateY 0 to 1.5px over 3s; rotate: 0 to 2deg over 4s +transition={{ ease: "easeInOut", repeat: Infinity, repeatType: "reverse" }} +``` + +Give idle and attention loops an initial delay (~2s) so users discover interactions first, and a `repeatDelay` between plays. Pause the loops off-screen (see the IntersectionObserver hook in `performance-deep-dive.md`). + +## Performance for busy SVG scenes + +Many simultaneously animating SVG elements, especially with filters, can drop frames. Promote only the animated ones, after you see jank, not preemptively: + +```css +svg [data-animate] { will-change: transform, opacity, stroke-dashoffset; contain: layout style paint; } +svg .filter-animated { will-change: transform; transform: translateZ(0); } +``` + +`contain: layout style paint` isolates an element's rendering so it doesn't repaint siblings; `translateZ(0)` forces a GPU layer for expensive filtered elements. Target `[data-animate]`, not every node; too many GPU layers cost memory. diff --git a/plugins/frontend-product-design/skills/ui-animation/references/transition-recipes.md b/plugins/frontend-product-design/skills/ui-animation/references/transition-recipes.md index e4cd19a..9906774 100644 --- a/plugins/frontend-product-design/skills/ui-animation/references/transition-recipes.md +++ b/plugins/frontend-product-design/skills/ui-animation/references/transition-recipes.md @@ -1,10 +1,11 @@ # CSS Transition Recipes -12 CSS transition patterns. Each includes CSS, HTML hooks, JS orchestration where needed, and a `prefers-reduced-motion` guard. All read from a shared `:root` custom properties block. +14 CSS transition patterns. Each includes CSS, HTML hooks, and JS orchestration where needed. All read from a shared `:root` custom properties block. ## Contents - [Custom properties](#custom-properties) +- [Container morph](#container-morph) - [Card resize](#card-resize) - [Panel reveal](#panel-reveal) - [Notification badge](#notification-badge) @@ -14,6 +15,7 @@ - [Text state swap](#text-state-swap) - [Page side-by-side slides](#page-side-by-side-slides) - [Number pop-in](#number-pop-in) +- [Odometer digit roll](#odometer-digit-roll) - [Avatar group hover](#avatar-group-hover) - [Success celebration](#success-celebration) - [Error state shake](#error-state-shake) @@ -26,10 +28,23 @@ Add this `:root` block once to your global stylesheet; every recipe reads these ```css :root { + /* Container morph */ + --morph-open-dur: 580ms; + --morph-close-dur: 300ms; + --morph-open-ease: linear(0, 0.45, 0.78, 1, 1.17, 1.21, 1.18, 1.12, 1.05, 1.02, 1); + --morph-close-ease: cubic-bezier(0.32, 0.72, 0, 1); + --morph-content-dur: 140ms; + --morph-content-blur: 3px; + /* Card resize */ --resize-dur: 300ms; --resize-ease: cubic-bezier(0.22, 1, 0.36, 1); + /* Odometer digit roll */ + --odo-dur: 260ms; + --odo-ease: cubic-bezier(0.22, 1, 0.36, 1); + --odo-dir: 1; /* 1 = value increased, -1 = decreased */ + /* Number pop-in */ --digit-dur: 500ms; --digit-dist: 12px; @@ -43,8 +58,8 @@ Add this `:root` block once to your global stylesheet; every recipe reads these --badge-slide-dur: 260ms; --badge-pop-dur: 500ms; --badge-blur: 2px; - --badge-offset-x: -8.2px; - --badge-offset-y: 12.4px; + --badge-offset-x: -8px; + --badge-offset-y: 12px; --badge-ease: cubic-bezier(0.22, 1, 0.36, 1); /* Text state swap */ @@ -122,6 +137,89 @@ Add this `:root` block once to your global stylesheet; every recipe reads these --- +## Container morph + +The trigger *becomes* the surface. A button, chip, or pill grows in place into the search field, form, menu, or confirmation it summons, keeping one continuous background and border-radius throughout. No new element appears, so there is nothing for the eye to re-find. + +Use this over Menu dropdown or Modal dialog whenever the trigger and the surface can share a shape. Use Card resize instead when the container already exists and only its dimensions change. + +Three things happen at once, and the order matters: + +| Phase | What | Timing | +|---|---|---| +| 1 | Old content fades and blurs out | `0` to `--morph-content-dur` | +| 2 | Container tweens to the new box | full `--morph-open-dur` | +| 3 | New content fades and blurs in | starts at `--morph-content-dur` | + +Measure the target box before animating: a plain `width: auto` has nothing to interpolate towards. Where `interpolate-size: allow-keywords` is supported you can transition to `auto` and drop the measure step, so check support for your targets before choosing. + +```html +
+
+
+ +
+
+``` + +```css +.t-morph { + position: relative; + overflow: hidden; + border-radius: 999px; + transition: width var(--morph-close-dur) var(--morph-close-ease), + height var(--morph-close-dur) var(--morph-close-ease); + will-change: width, height; +} +.t-morph[data-open="true"] { + transition-duration: var(--morph-open-dur); + transition-timing-function: var(--morph-open-ease); +} + +/* Faces stack, so the container never sees both in flow. */ +.t-morph-face { + transition: opacity var(--morph-content-dur) ease, + filter var(--morph-content-dur) ease; +} +.t-morph-face[data-face="open"] { position: absolute; inset: 0; } + +.t-morph[data-open="false"] [data-face="open"], +.t-morph[data-open="true"] [data-face="closed"] { + opacity: 0; + filter: blur(var(--morph-content-blur)); + pointer-events: none; +} + +/* Incoming content waits for the box to be most of the way there. */ +.t-morph[data-open="true"] [data-face="open"], +.t-morph[data-open="false"] [data-face="closed"] { + transition-delay: var(--morph-content-dur); +} +``` + +**JS, measure then toggle:** + +```js +function morph(el, open) { + const face = el.querySelector(`[data-face="${open ? "open" : "closed"}"]`); + // Measure the target face off-flow, at its natural size. + const prev = face.style.cssText; + Object.assign(face.style, { position: "absolute", visibility: "hidden", width: "max-content" }); + const { width, height } = face.getBoundingClientRect(); + face.style.cssText = prev; + + el.style.width = `${width}px`; + el.style.height = `${height}px`; + el.dataset.open = String(open); +} +``` + +**Measured reference.** Tracking a real implementation frame by frame at 60fps: the open reached **121% of its travel** at 284ms (a container **9.8% wider** than its resting width), then settled over a further 300ms. The close reached a ~2% undershoot in 185ms and was visually at rest by 300ms. Expansion was symmetric about the trigger's centre, not anchored to an edge. + +`--morph-open-ease` is that overshoot transcribed as a `linear()` curve, so `--morph-open-dur` covers the whole settle even though the morph reads as finished around 300ms. The equivalent spring is `{ stiffness: 155, damping: 11, mass: 1 }` (damping ratio 0.44); the close fits `{ stiffness: 620, damping: 36 }`, near enough to critically damped that the bezier above is indistinguishable. Prefer the spring form when the morph must survive interruption. `spring-animations.md` § Asymmetric spring character covers why only the open bounces. + +--- + ## Card resize Tween a container's width or height when its layout state changes (compact/expanded card, collapsing panel, list row toggling detail). CSS only, no JS. @@ -137,10 +235,6 @@ Tween a container's width or height when its layout state changes (compact/expan will-change: width, height; overflow: hidden; } - -@media (prefers-reduced-motion: reduce) { - .t-resize { transition: none; } -} ``` Toggle dimensions with a state class or inline style; the transition handles the tween. @@ -174,10 +268,6 @@ See also: `component-patterns.md` § Drawers and panels for percentage-based dra .t-panel[data-open="false"] { transition-duration: var(--panel-close-dur); } - -@media (prefers-reduced-motion: reduce) { - .t-panel { transition: none; } -} ``` --- @@ -222,10 +312,6 @@ Slide a small badge onto a trigger (button, icon) and pop the dot; the trigger s transform: scale(1); transition-delay: calc(var(--badge-slide-dur) * 0.5); } - -@media (prefers-reduced-motion: reduce) { - .t-badge, .t-badge-dot { transition: none; animation: none; } -} ``` --- @@ -262,10 +348,6 @@ See also: `contextual-animations.md` § Contextual icon swaps for the Motion/Ani transform: scale(1); filter: blur(0); } - -@media (prefers-reduced-motion: reduce) { - .t-icon { transition: none; } -} ``` --- @@ -274,7 +356,7 @@ See also: `contextual-animations.md` § Contextual icon swaps for the Motion/Ani Origin-aware dropdown with open/close animations. JS handles close-state cleanup. -See also: `component-patterns.md` § Popovers and dropdowns for Radix UI transform-origin and scale patterns. +See also: `component-patterns.md` § Popovers and dropdowns for library transform-origin and scale patterns. ```html
@@ -305,10 +387,6 @@ See also: `component-patterns.md` § Popovers and dropdowns for Radix UI transfo .t-dropdown[data-origin="bottom-left"] { transform-origin: bottom left; } .t-dropdown[data-origin="bottom-center"]{ transform-origin: bottom center; } .t-dropdown[data-origin="bottom-right"] { transform-origin: bottom right; } - -@media (prefers-reduced-motion: reduce) { - .t-dropdown { transition: none; } -} ``` **JS, close with cleanup:** @@ -351,10 +429,6 @@ See also: `component-patterns.md` § Modals and dialogs for `@starting-style` en transform: scale(var(--modal-scale)); transition-duration: var(--modal-close-dur); } - -@media (prefers-reduced-motion: reduce) { - .t-modal { transition: none; } -} ``` **JS, close with cleanup:** @@ -395,10 +469,6 @@ Swap text in place with a blurred vertical transition ("Processing..." → "Done transform: translateY(var(--text-swap-y)); filter: blur(var(--text-swap-blur)); } - -@media (prefers-reduced-motion: reduce) { - .t-text-swap { transition: none; } -} ``` **JS, three-phase orchestration:** @@ -463,10 +533,6 @@ See also: `component-patterns.md` § Step form navigation for the Motion/Animate transform: translateX(calc(-1 * var(--page-dist))); filter: blur(var(--page-blur)); } - -@media (prefers-reduced-motion: reduce) { - .t-page-slide > * { transition: none; } -} ``` **JS, switch page:** @@ -514,10 +580,6 @@ Re-enter digits with directional blur on number update (counters, prices, balanc } .t-digit[data-stagger="1"] { animation-delay: var(--digit-stagger); } .t-digit[data-stagger="2"] { animation-delay: calc(var(--digit-stagger) * 2); } - -@media (prefers-reduced-motion: reduce) { - .t-digit { animation: none; } -} ``` **JS, replay on update:** @@ -539,6 +601,64 @@ function updateDigits(container, newValue) { --- +## Odometer digit roll + +Roll each changed digit vertically, in the direction the value moved: up for an increase, down for a decrease. Use this over Number pop-in when the number is being *driven* by the user (steppers, sliders, scrubbers, quantity controls), where direction is the feedback. Keep pop-in for values that arrive on their own, where there is no direction to convey. + +Two rules keep it readable. Only re-render the digits that actually changed, or a 199 to 200 tick rolls all three and reads as noise. And set `font-variant-numeric: tabular-nums`, or the row re-flows on every tick and the roll turns into a jitter. + +```html + + 4 + 1 + +``` + +```css +.t-odo { + display: inline-flex; + font-variant-numeric: tabular-nums; +} +.t-odo-slot { + display: inline-block; + overflow: hidden; /* the window the digit rolls through */ + height: 1em; + line-height: 1em; +} + +@keyframes odo-roll { + from { + transform: translateY(calc(var(--odo-dir) * 1em)); + opacity: 0; + } +} + +.t-odo-digit[data-rolling] { + display: block; + animation: odo-roll var(--odo-dur) var(--odo-ease) both; +} +``` + +**JS, roll only what changed:** + +```js +function setOdometer(el, next, prev) { + el.style.setProperty("--odo-dir", next > prev ? 1 : -1); + const a = String(prev).padStart(String(next).length, " "); + const b = String(next); + el.innerHTML = [...b] + .map((d, i) => { + const rolling = d !== a[i] ? " data-rolling" : ""; + return `${d}`; + }) + .join(""); +} +``` + +The outgoing digit is dropped rather than animated out. At 260ms with the slot clipping, the eye reads the incoming digit as having pushed the old one away; animating both doubles the work for no visible gain. + +--- + ## Avatar group hover Distance-falloff lift on a horizontal stack. Hovered item lifts and scales; neighbors lift less with distance. Bouncy spring on leave. @@ -559,10 +679,6 @@ Distance-falloff lift on a horizontal stack. Hovered item lifts and scales; neig .t-avatar { transition: transform var(--avatar-dur) var(--avatar-ease-in); } - -@media (prefers-reduced-motion: reduce) { - .t-avatar { transition: none; } -} ``` **JS, distance-based lift:** @@ -645,11 +761,6 @@ Multi-layered success: fade, rotation, blur reduction, Y-bob with overshoot, opt .t-success[data-state="in"] .t-success-path { stroke-dashoffset: 0; } - -@media (prefers-reduced-motion: reduce) { - .t-success { animation: none; opacity: 1; transform: none; filter: none; } - .t-success-path { transition: none; stroke-dashoffset: 0; } -} ``` **JS, set path length and replay:** @@ -712,11 +823,6 @@ Per-segment shake with auto-reverting error border. Three classes: `.is-error` o opacity: 1; transform: translateY(0); } - -@media (prefers-reduced-motion: reduce) { - .t-error-input { animation: none; } - .t-error-msg { transition: none; } -} ``` **JS, trigger and auto-revert:** diff --git a/plugins/frontend-product-design/skills/ui-animation/references/vocabulary.md b/plugins/frontend-product-design/skills/ui-animation/references/vocabulary.md new file mode 100644 index 0000000..b91949b --- /dev/null +++ b/plugins/frontend-product-design/skills/ui-animation/references/vocabulary.md @@ -0,0 +1,159 @@ +# Animation Vocabulary + +Reverse-lookup glossary: turn a vague description of a motion or effect into the precise term, so the user knows what to ask for. + +## Contents +- [How to answer](#how-to-answer) +- [Examples](#examples) +- [Glossary](#glossary) + +## How to answer + +The user describes an effect loosely; you return the matching term(s) in this format: + +``` +**Stagger**: Animate several items one after another with a small delay between each, creating a cascade. +``` + +If several terms fit, lead with the best match, then 1-2 alternates with a one-line note on how they differ. + +1. **Read for intent, not keywords.** Users describe what they see or feel ("springy", "slides off", "draws itself in"), not the technical name. Map the sensation to the glossary. +2. **Quote the glossary verbatim.** Its descriptions are authoritative; use them as-is. +3. **Disambiguate close terms.** When two compete (clip-path vs mask, pop in vs bounce, shared element transition vs layout animation), contrast them so the user can pick. +4. **When nothing matches exactly,** name the closest term and say plainly it's an approximation, or describe the effect in the glossary's vocabulary ("that's a stagger of scale-in entrances"). +5. **Stay within this glossary.** If a term genuinely isn't here, say so rather than inventing one, though you may explain the concept using these words. +6. **Keep it tight.** A naming question wants a name, not an essay. Lead with the term; expand only if asked. + +## Examples + +**Feel-based** +User: "What's it called when a popover seems to grow out of the button you clicked instead of from its middle?" +Answer: **Origin-aware animation**: An element animates out of its trigger, like a popover growing from the button that opened it instead of from its own center, which is the default in CSS. + +**Disambiguation** +User: "The thing where one image turns into another image." +Answer: **Morph**: One shape smoothly turns into another shape, e.g. Dynamic Island. Close alternates: **Crossfade** if they simply fade over each other in the same spot; **Shared element transition** if an element travels and transforms from one position into another. + +**Physics feel** +User: "That iOS scroll where it resists and snaps back when you pull too far." +Answer: **Rubber-banding**: Resistance and snap-back when you drag past a boundary (the iOS overscroll feel). + +## Glossary + +### Entrances and exits: how elements appear and disappear +- **Fade in / Fade out**: Element appears or disappears by changing opacity. +- **Slide in**: Element enters by sliding in from off-screen (left, right, top, or bottom). +- **Scale in**: Element grows from smaller to full size as it appears, often paired with a fade. +- **Pop in**: Element appears with a slight overshoot, like it bounces into place. +- **Reveal**: Content is uncovered gradually, often by animating a clip-path or mask. +- **Enter / Exit**: The animation an element plays when it's added to or removed from the screen. + +### Sequencing and timing: coordinating multiple elements or moments +- **Keyframes**: Defined points in an animation (0%, 50%, 100%) that the browser fills the gaps between. +- **Interpolation / Tween**: Generating all the in-between frames between a start and end value, so motion is continuous. +- **Stagger**: Animate several items one after another with a small delay between each, creating a cascade. +- **Orchestration**: Deliberately timing multiple animations so they feel like one coordinated motion. +- **Delay**: Time before an animation starts. +- **Duration**: How long an animation takes. +- **Fill mode**: Whether an element keeps its first or last frame's styles before the animation starts or after it ends (e.g. forwards). +- **Stepped animation**: An animation divided into discrete steps, like a countdown timer. + +### Movement and transforms: changing an element's position, size, or angle +- **Translate**: Move an element along the X or Y axis. +- **Scale**: Make an element bigger or smaller. +- **Rotate**: Spin an element around a point. +- **Skew**: Slant an element along the X or Y axis, shearing it out of its rectangular shape. +- **3D tilt / Flip**: Rotate in 3D space (rotateX / rotateY) to add depth. +- **Perspective**: How strong the 3D effect looks; a lower value exaggerates depth, like the viewer is closer. +- **Transform origin**: The anchor point a scale or rotation grows or spins from. +- **Origin-aware animation**: An element animates out of its trigger, like a popover growing from the button that opened it instead of from its own center, which is the default in CSS. + +### Transitions between states: connecting one state, view, or element to another +- **Crossfade**: One element fades out as another fades in, in the same spot. +- **Continuity transition**: A change that keeps the user oriented by visually connecting before and after. For example, making the same rectangle bigger and smaller. +- **Morph**: One shape smoothly turns into another shape, e.g. Dynamic Island. +- **Container morph**: A trigger grows in place into the surface it summons, so the button becomes the search field or form instead of opening one next to it. +- **Shared element transition**: An element travels and transforms from one position into another, like a thumbnail expanding into a card. +- **Layout animation**: When an element's size or position changes, it animates to the new spot instead of snapping. +- **Accordion / Collapse**: A section smoothly expands and collapses its height to show or hide content. +- **Direction-aware transition**: Content slides one way going forward and the opposite way going back, so navigation has a sense of direction. + +### Scroll: motion tied to scrolling or navigating between views +- **Scroll reveal**: Elements fade or slide into place as they enter the viewport. +- **Scroll-driven animation**: An animation whose progress is tied directly to scroll position. +- **Parallax**: Background and foreground move at different speeds while scrolling, creating depth. +- **Page transition**: An animation that plays when navigating from one page or route to another. +- **View transition**: The browser morphs between two states or pages, connecting shared elements. + +### Feedback and interaction: responding to the user's actions +- **Hover effect**: Visual change when the cursor moves over an element. +- **Press / Tap feedback**: A subtle scale-down when an element is clicked, so it feels physical. +- **Hold to confirm**: A progress effect that fills up while the user holds a button. +- **Drag**: Moving an element by grabbing it, often with momentum when released. +- **Drag to reorder**: Dragging items in a list to rearrange them, while the others shift to make room. +- **Swipe to dismiss**: Dragging an element off-screen to close it, like a drawer or toast. +- **Rubber-banding**: Resistance and snap-back when you drag past a boundary (the iOS overscroll feel). +- **Detent**: A discrete stop a control settles onto when released, like the notches on a ruler picker or tick slider. +- **Peripheral de-emphasis**: Blurring and fading everything around the focused item instead of dimming the whole page, so the set stays visible but recedes. +- **Shake / Wiggle**: A quick side-to-side jitter signaling an error or rejected input. +- **Ripple**: A circle expanding from the point of a tap, confirming the press. + +### Easing: how speed changes over an animation +- **Easing**: The rate at which an animation speeds up or slows down. +- **Ease-out**: Starts fast, ends slow. The default for most UI and anything responding to the user. +- **Ease-in**: Starts slow, ends fast. Usually avoided; can feel sluggish. +- **Ease-in-out**: Slow, fast, slow. Good for elements already on screen moving from A to B. +- **Linear**: Constant speed. Avoid for UI; reserve for spinners or marquees. +- **Cubic-bezier**: A custom easing curve you define for precise control. +- **Asymmetric easing**: A curve that accelerates and decelerates at different rates. Feels more alive than a symmetric one. + +### Spring animations: physics-based motion as an alternative to fixed-duration easing +- **Spring**: Motion driven by physics (tension, mass, damping) rather than a set duration. +- **Stiffness / Tension**: How strongly the spring pulls toward its target. Higher feels snappier. +- **Damping**: How quickly a spring settles. Lower damping means more bounce and oscillation. +- **Mass**: How heavy the animated element feels. More mass makes it slower and more sluggish. +- **Bounce**: A spring that overshoots and settles, adding playfulness. +- **Perceptual duration**: How long a spring feels finished, even though it keeps micro-settling underneath. +- **Momentum**: Motion that carries velocity, especially after a drag or interruption. +- **Velocity**: How fast and in which direction an element is moving. A spring carries it into the next animation when interrupted, so a flicked element keeps its speed. +- **Interruptible animation**: An animation that can be smoothly redirected mid-flight instead of finishing first. + +### Looping and ambient motion: animations that run on their own +- **Marquee**: Text or content that scrolls continuously in a loop. +- **Loop**: An animation that repeats, a set number of times or infinitely. +- **Alternate (yoyo)**: A loop that plays forward then reverses each iteration, instead of jumping back to the start. +- **Orbit**: An element circling around another in a continuous path. +- **Pulse**: A gentle repeating scale or opacity change to draw attention. +- **Float**: A gentle, continuous up-and-down drift that makes a static element feel alive and weightless. +- **Idle animation**: Subtle motion that plays while an element is just sitting there, waiting to be interacted with. + +### Polish and effects: the small touches that separate good from great +- **Blur**: A blur filter used to soften an element or mask tiny imperfections. +- **Clip-path**: Clipping an element to a shape, used for reveals, masks, and before/after sliders. +- **Mask**: Hiding or revealing parts of an element using a shape or gradient, like clip-path but with soft, fadeable edges. +- **Before / after slider**: A draggable divider that wipes between two overlaid images to compare them. +- **Line drawing**: An SVG path that draws itself in, like an invisible pen tracing it. +- **Text morph**: Text that animates character by character when it changes, drawing attention to the new value. +- **Skeleton / Shimmer**: A placeholder with a moving sheen shown while content loads. +- **Number ticker**: Digits rolling or counting up to a value. +- **Odometer roll**: Digits rolling vertically in the direction the value moved, up for an increase and down for a decrease, like a car's odometer. +- **Tabular numbers**: Fixed-width digits so numbers don't shift around as they change. Essential for tickers, timers, and counters. +- **Typewriter**: Text appearing one character at a time, as if being typed. + +### Performance: what keeps motion smooth instead of stuttering +- **Frame rate (FPS)**: Frames drawn per second. 60fps is the baseline for smooth motion; 120fps on newer displays. +- **Jank**: Visible stutter when the browser drops frames because it can't keep up with the animation. +- **Dropped frame**: A frame the browser missed its deadline to draw, causing a tiny hitch in motion. +- **Compositing**: Letting the GPU move or fade an element on its own layer without redoing layout or paint. +- **will-change**: A CSS hint that an element is about to animate, so the browser can promote it to its own layer ahead of time. +- **Layout thrashing**: Animating properties like width, height, top, or left that force the browser to recalculate layout every frame, causing jank. + +### Principles to know: concepts that guide when and how to animate +- **Purposeful animation**: Motion should serve a function (orient, give feedback, show relationships), not just decorate. +- **Anticipation**: A small wind-up in the opposite direction before a move, hinting at what's about to happen. +- **Follow-through**: Parts of an element keep moving and settle slightly after the main motion stops, adding weight. +- **Squash and stretch**: Deforming an element as it moves to convey weight, speed, and flexibility. +- **Perceived performance**: The right animation makes an interface feel faster, even when it isn't. +- **Frequency of use**: The more often a user sees an animation, the shorter and subtler it should be. +- **Spatial consistency**: Animating so an element keeps its identity and position across states, so users never lose track of where things went. +- **Hardware acceleration**: Animating transform and opacity lets the GPU keep motion smooth. diff --git a/plugins/frontend-product-design/skills/ui-animation/scripts/fit_curves.py b/plugins/frontend-product-design/skills/ui-animation/scripts/fit_curves.py index f440d71..be38db8 100644 --- a/plugins/frontend-product-design/skills/ui-animation/scripts/fit_curves.py +++ b/plugins/frontend-product-design/skills/ui-animation/scripts/fit_curves.py @@ -157,9 +157,9 @@ def main(): t = (frames - frames[0]) / args.fps # seconds from first tracked frame duration_ms = round(float(t[-1] * 1000)) - wanted = {args.property: PROPERTIES[args.property]} if args.property else PROPERTIES if args.property and args.property not in PROPERTIES: sys.exit(f"unknown property '{args.property}'. Choose from: {', '.join(PROPERTIES)}") + wanted = {args.property: PROPERTIES[args.property]} if args.property else PROPERTIES result = {"duration_ms": duration_ms, "tracked_frames": len(tl), "properties": {}} for name, fields in wanted.items(): diff --git a/plugins/frontend-product-design/skills/vercel-react-best-practices/metadata.json b/plugins/frontend-product-design/skills/vercel-react-best-practices/metadata.json deleted file mode 100644 index 3bec38b..0000000 --- a/plugins/frontend-product-design/skills/vercel-react-best-practices/metadata.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "version": "1.0.0", - "organization": "Vercel Engineering", - "date": "January 2026", - "abstract": "Comprehensive performance optimization guide for React and Next.js applications, designed for AI agents and LLMs. Contains 40+ rules across 8 categories, prioritized by impact from critical (eliminating waterfalls, reducing bundle size) to incremental (advanced patterns). Each rule includes detailed explanations, real-world examples comparing incorrect vs. correct implementations, and specific impact metrics to guide automated refactoring and code generation.", - "references": [ - "https://react.dev", - "https://nextjs.org", - "https://swr.vercel.app", - "https://github.com/shuding/better-all", - "https://github.com/isaacs/node-lru-cache", - "https://vercel.com/blog/how-we-optimized-package-imports-in-next-js", - "https://vercel.com/blog/how-we-made-the-vercel-dashboard-twice-as-fast" - ] -} diff --git a/plugins/health-wellness/skills/healthkit/SKILL.md b/plugins/health-wellness/skills/healthkit/SKILL.md index 873ae11..3011a68 100644 --- a/plugins/health-wellness/skills/healthkit/SKILL.md +++ b/plugins/health-wellness/skills/healthkit/SKILL.md @@ -239,9 +239,15 @@ func saveSteps(count: Double, start: Date, end: Date) async throws { try await healthStore.save(sample) } - ``` +Treat `try await healthStore.save(sample)` returning as the save success gate; +only then report success or advance app state. On failure, surface the error and +correct the known authorization, type, unit, duration, or input problem before +constructing another sample. A bounded query or inspection in the Health app is +useful as an integration-test check when persistence evidence is required, but +is not a mandatory production read after every save. + Your app can only delete samples it created. Samples from other apps or Apple Watch are read-only. ## Background Delivery @@ -315,16 +321,18 @@ func startWorkout() async throws { try await builder.beginCollection(at: Date()) } -func endWorkout( - session: HKWorkoutSession, - builder: HKLiveWorkoutBuilder -) async throws { - session.end() - try await builder.endCollection(at: Date()) - try await builder.finishWorkout() -} +// Request teardown; finalize from the delegate's .stopped transition. +session.stopActivity(with: Date()) ``` +Do not call `endCollection` and `finishWorkout` immediately after requesting the +stop. Wait for the session delegate's `.stopped` transition, then await +`builder.endCollection(at:)` followed by `builder.finishWorkout()`. Report the +workout as saved and clear session state only after both operations return. +Handle each thrown error without blindly repeating teardown. A successful +`finishWorkout()` can return no workout object while the device is locked, so a +`nil` result alone is not failure. + For full workout lifecycle management including pause/resume, delegate handling, and multi-device mirroring, see [references/healthkit-patterns.md](references/healthkit-patterns.md). ## Common Data Types @@ -412,9 +420,11 @@ HKUnit.literUnit(with: .deci) // Deciliters `completionHandler` called - [ ] Background delivery entitlement enabled if using `enableBackgroundDelivery` - [ ] Background delivery tested on device and frequency caps considered -- [ ] Workout sessions properly ended and builder finalized +- [ ] Workout stop waits for the delegate's `.stopped` transition before + `endCollection` and `finishWorkout`; state clears only after successful + finalization - [ ] Workout API availability and live heart-rate sensor requirements handled -- [ ] Write operations only for sample types the app created +- [ ] Delete operations target only objects the app previously saved ## References diff --git a/plugins/health-wellness/skills/healthkit/references/healthkit-patterns.md b/plugins/health-wellness/skills/healthkit/references/healthkit-patterns.md index e71dc9d..12f9de8 100644 --- a/plugins/health-wellness/skills/healthkit/references/healthkit-patterns.md +++ b/plugins/health-wellness/skills/healthkit/references/healthkit-patterns.md @@ -45,6 +45,7 @@ final class WorkoutManager: NSObject { var distance: Double = 0 var elapsedTime: TimeInterval = 0 var isActive = false + var finalizationError: Error? func startWorkout(activityType: HKWorkoutActivityType) async throws { let configuration = HKWorkoutConfiguration() @@ -81,11 +82,30 @@ final class WorkoutManager: NSObject { session?.resume() } - func end() async throws { - guard let session, let builder else { return } - session.end() - try await builder.endCollection(at: Date()) - try await builder.finishWorkout() + func end() { + guard let session else { return } + session.stopActivity(with: Date()) + } + + private func finalizeStoppedWorkout(at date: Date) async { + guard let builder else { return } + + do { + try await builder.endCollection(at: date) + } catch { + finalizationError = error + return + } + + do { + _ = try await builder.finishWorkout() + } catch { + finalizationError = error + return + } + + // A nil workout while locked is not a failed finish; the awaited return + // is the success boundary. isActive = false self.session = nil self.builder = nil @@ -107,7 +127,10 @@ extension WorkoutManager: HKWorkoutSessionDelegate { isActive = true case .paused: isActive = false - case .ended, .stopped: + case .stopped: + isActive = false + await finalizeStoppedWorkout(at: date) + case .ended: isActive = false default: break diff --git a/plugins/health-wellness/skills/rem-sleep/LICENSE b/plugins/health-wellness/skills/rem-sleep/LICENSE new file mode 100644 index 0000000..f163bd9 --- /dev/null +++ b/plugins/health-wellness/skills/rem-sleep/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Stewart Nightingale + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/health-wellness/skills/rem-sleep/README.md b/plugins/health-wellness/skills/rem-sleep/README.md new file mode 100644 index 0000000..dbf9863 --- /dev/null +++ b/plugins/health-wellness/skills/rem-sleep/README.md @@ -0,0 +1,136 @@ +# REM Sleep - Memory Consolidation for AI Agents + +> *Like biological REM sleep, this skill processes raw experience into consolidated long-term memory.* 🦞 + +[![GitHub](https://img.shields.io/github/stars/stewnight/rem-sleep-skill?style=social)](https://github.com/stewnight/rem-sleep-skill) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + +## Quick Install + +**One-liner (curl):** +```bash +mkdir -p ~/.openclaw/skills/rem-sleep/scripts && \ +curl -sL https://raw.githubusercontent.com/stewnight/rem-sleep-skill/main/SKILL.md -o ~/.openclaw/skills/rem-sleep/SKILL.md && \ +curl -sL https://raw.githubusercontent.com/stewnight/rem-sleep-skill/main/scripts/gather-sessions.sh -o ~/.openclaw/skills/rem-sleep/scripts/gather-sessions.sh && \ +chmod +x ~/.openclaw/skills/rem-sleep/scripts/gather-sessions.sh && \ +echo "✅ rem-sleep skill installed!" +``` + +**Or clone the full repo:** +```bash +git clone https://github.com/stewnight/rem-sleep-skill.git ~/.openclaw/skills/rem-sleep +``` + +**Or just read the skill directly:** +``` +https://raw.githubusercontent.com/stewnight/rem-sleep-skill/main/SKILL.md +``` + +--- + +## Why? + +AI agents face a unique memory problem: +- **Session logs accumulate** but are expensive to re-read +- **Important insights get buried** in noise +- **"Mental notes" don't survive** context compaction or restarts +- **Starting from scratch** every session without persistent memory + +## The Solution + +Periodic "sleep cycles" that: +1. **Search** session logs for significant patterns +2. **Extract** what's worth remembering +3. **Consolidate** into durable memory files (MEMORY.md) +4. **Defrag** to remove stale info and reduce bloat + +## Works With + +- [OpenClaw](https://openclaw.ai) — the agent platform this was built for +- Claude Code or any coding agent +- Any agent with session logs and a memory file system + +--- + +## Usage + +The skill defines a **workflow**, not a binary. Read `SKILL.md` for the full process. + +### Quick version: + +**Consolidate** (every few days): +```bash +# Search for significant patterns in recent sessions +grep -r "decision\|learned\|important\|remember" ~/.openclaw/agents/main/sessions --include="*.jsonl" + +# Extract insights and update MEMORY.md +``` + +**Defrag** (weekly): +```bash +# Review MEMORY.md for: +# - Stale entries (outdated, completed TODOs) +# - Duplicates +# - Verbose entries that can be compressed +``` + +### With the helper script: +```bash +# Using native grep/jq (no dependencies) +./scripts/gather-sessions.sh 7 --native + +# Using Repo Prompt (if installed) +./scripts/gather-sessions.sh 7 +``` + +--- + +## Memory Architecture + +``` +workspace/ +├── MEMORY.md # Long-term curated memory +├── memory/ +│ ├── 2024-01-15.md # Daily raw logs +│ ├── 2024-01-16.md +│ └── heartbeat-state.json +└── skills/ + └── rem-sleep/ + └── SKILL.md +``` + +## Key Insight + +**Semantic search beats reading everything.** + +Instead of re-reading entire session logs (expensive), search for consolidation candidates: +- "decision", "learned", "important", "remember", "TODO" +- Emotional/evaluative language: "actually", "realized", "wrong about" +- Corrections and mind-changes + +Then extract and consolidate just those snippets. + +--- + +## Contributing + +PRs welcome! Ideas: +- [ ] Better heuristics for "what's worth remembering" +- [ ] Alternative search methods (beyond grep/Repo Prompt) +- [ ] Vector DB integration for true semantic search +- [ ] Cross-platform scripts (currently macOS-focused) +- [ ] Automation for different agent platforms + +## License + +MIT — use it, fork it, improve it. + +## Credits + +Built by [@MoltyNeeClawd](https://moltbook.com/u/MoltyNeeClawd) (an OpenClaw agent) with human assistance from [@stewnightnz](https://twitter.com/stewnightnz). + +**Discuss on Moltbook:** [REM Sleep for Agents](https://moltbook.com/post/f2998b1f-f751-4ef9-ab4d-c4cca6e171c1) + +--- + +*"The unexamined session is not worth running."* 🦞 diff --git a/plugins/health-wellness/skills/rem-sleep/scripts/gather-sessions.sh b/plugins/health-wellness/skills/rem-sleep/scripts/gather-sessions.sh new file mode 100755 index 0000000..90a7086 --- /dev/null +++ b/plugins/health-wellness/skills/rem-sleep/scripts/gather-sessions.sh @@ -0,0 +1,97 @@ +#!/bin/bash +# gather-sessions.sh - Collect recent session data for memory consolidation +# Usage: ./gather-sessions.sh [days_back] [--native] +# +# Options: +# days_back Number of days to look back (default: 3) +# --native Use native grep/jq instead of Repo Prompt + +DAYS_BACK=${1:-3} +USE_NATIVE=false + +# Check for --native flag +for arg in "$@"; do + if [ "$arg" == "--native" ]; then + USE_NATIVE=true + fi +done + +# OpenClaw session logs location (adjust if different) +SESSIONS_DIR="${OPENCLAW_SESSIONS:-$HOME/.openclaw/agents/main/sessions}" + +# Repo Prompt CLI location (macOS) +RP_CLI="/Applications/Repo Prompt.app/Contents/MacOS/repoprompt-mcp" + +echo "=== REM Sleep: Gathering Sessions (last $DAYS_BACK days) ===" +echo "Sessions directory: $SESSIONS_DIR" +echo "" + +# Get cutoff date +if [[ "$OSTYPE" == "darwin"* ]]; then + CUTOFF=$(date -v-${DAYS_BACK}d +%Y-%m-%d) +else + CUTOFF=$(date -d "$DAYS_BACK days ago" +%Y-%m-%d) +fi + +echo "Looking for sessions since: $CUTOFF" +echo "" + +# Patterns to search for +PATTERNS=("decision" "learned" "important" "remember" "TODO" "preference" "mistake" "realized" "note to self") + +# Check if Repo Prompt is available and not forcing native +if [ -f "$RP_CLI" ] && [ "$USE_NATIVE" = false ]; then + echo "Using: Repo Prompt" + echo "" + + # List recent session files + echo "=== Recent Session Files ===" + "$RP_CLI" -e "tree $SESSIONS_DIR" 2>/dev/null + echo "" + + # Search for consolidation-worthy patterns + for pattern in "${PATTERNS[@]}"; do + echo "" + echo "=== Searching: \"$pattern\" ===" + "$RP_CLI" -e "search \"$pattern\" --context-lines 2" 2>/dev/null | head -50 + done +else + echo "Using: Native grep/jq (Repo Prompt not found or --native specified)" + echo "" + + # Check if sessions directory exists + if [ ! -d "$SESSIONS_DIR" ]; then + echo "Error: Sessions directory not found at $SESSIONS_DIR" + echo "Set OPENCLAW_SESSIONS env var or adjust the script." + exit 1 + fi + + # List recent session files + echo "=== Recent Session Files ===" + find "$SESSIONS_DIR" -name "*.jsonl" -mtime -${DAYS_BACK} -ls 2>/dev/null + echo "" + + # Search for patterns using grep + for pattern in "${PATTERNS[@]}"; do + echo "" + echo "=== Searching: \"$pattern\" ===" + + # Search in JSONL files, extract content field, grep for pattern + find "$SESSIONS_DIR" -name "*.jsonl" -mtime -${DAYS_BACK} -exec cat {} \; 2>/dev/null | \ + jq -r 'select(.content != null) | "\(.role // "?"): \(.content)"' 2>/dev/null | \ + grep -i "$pattern" | head -30 + + # If jq fails, fall back to raw grep + if [ ${PIPESTATUS[1]} -ne 0 ]; then + grep -r -i "$pattern" "$SESSIONS_DIR" --include="*.jsonl" 2>/dev/null | head -30 + fi + done +fi + +echo "" +echo "=== Gathering Complete ===" +echo "" +echo "Next steps:" +echo "1. Review the above for consolidation candidates" +echo "2. Update memory/$(date +%Y-%m-%d).md with today's events" +echo "3. Distill important learnings to MEMORY.md" diff --git a/plugins/software-delivery/skills/clean-code/SKILL.md b/plugins/software-delivery/skills/clean-code/SKILL.md index ea6e121..9e7a61f 100644 --- a/plugins/software-delivery/skills/clean-code/SKILL.md +++ b/plugins/software-delivery/skills/clean-code/SKILL.md @@ -1,10 +1,15 @@ --- name: clean-code -description: Apply Robert C. Martin's Clean Code principles (naming, functions, comments, formatting, error handling, tests, classes, code smells). Use when writing new code, reviewing pull requests, refactoring legacy code, or aligning on team coding standards. +description: "This skill embodies the principles of \"Clean Code\" by Robert C. Martin (Uncle Bob). Use it to transform \"code that works\" into \"code that is clean.\"" +risk: safe +source: "ClawForge (https://github.com/jackjin1997/ClawForge)" +date_added: "2026-02-27" --- # Clean Code Skill +This skill embodies the principles of "Clean Code" by Robert C. Martin (Uncle Bob). Use it to transform "code that works" into "code that is clean." + ## 🧠 Core Philosophy > "Code is clean if it can be read, and enhanced by a developer other than its original author." — Grady Booch @@ -33,7 +38,7 @@ Use this skill when: ## 3. Comments - **Don't Comment Bad Code—Rewrite It**: Most comments are a sign of failure to express ourselves in code. -- **Explain Yourself in Code**: +- **Explain Yourself in Code**: ```python # Check if employee is eligible for full benefits if employee.flags & HOURLY and employee.age > 65: @@ -88,6 +93,12 @@ Use this skill when: - [ ] Am I passing too many arguments? - [ ] Is there a failing test for this change? +## Example + +**User request:** + +> Refactor this working code for clearer names, smaller units, explicit errors, and preserved behavior; verify it with focused tests. + ## 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. diff --git a/plugins/software-delivery/skills/devops-engineer/SKILL.md b/plugins/software-delivery/skills/devops-engineer/SKILL.md index 8b7004b..3629161 100644 --- a/plugins/software-delivery/skills/devops-engineer/SKILL.md +++ b/plugins/software-delivery/skills/devops-engineer/SKILL.md @@ -4,7 +4,7 @@ description: Creates Dockerfiles, configures CI/CD pipelines, writes Kubernetes license: MIT metadata: author: https://github.com/Jeffallan - version: "1.1.1" + version: "1.2.0" domain: devops triggers: DevOps, CI/CD, deployment, Docker, Kubernetes, Terraform, GitHub Actions, infrastructure, platform engineering, incident response, on-call, self-service role: engineer @@ -42,8 +42,9 @@ You are a senior DevOps engineer with 10+ years of experience. You operate with 2. **Design** - Pipeline structure, deployment strategy 3. **Implement** - IaC, Dockerfiles, CI/CD configs 4. **Validate** - Run `terraform plan`, lint configs, execute unit/integration tests; confirm no destructive changes before proceeding -5. **Deploy** - Roll out with verification; run smoke tests post-deployment -6. **Monitor** - Set up observability, alerts; confirm rollback procedure is ready before going live +5. **Plan rollout** - Determine the target environment; prepare the deployment summary, rollback command, and validation plan +6. **Approve and deploy** - If the target is production or customer-facing, present the deployment summary and rollback plan and ask for explicit user approval; only run deployment commands after confirmation, and stop with a blocked verdict if approval is withheld. Roll out with verification; run smoke tests post-deployment +7. **Monitor** - Set up observability, alerts; confirm rollback procedure is ready before going live ## Reference Guide @@ -52,6 +53,7 @@ Load detailed guidance based on context: | Topic | Reference | Load When | |-------|-----------|-----------| | GitHub Actions | `references/github-actions.md` | Setting up CI/CD pipelines, GitHub workflows | +| GitLab CI/CD | `references/gitlab-ci.md` | Setting up GitLab pipelines, `.gitlab-ci.yml`, DAG/`needs`, environments, runners | | Docker | `references/docker-patterns.md` | Containerizing applications, writing Dockerfiles | | Kubernetes | `references/kubernetes.md` | K8s deployments, services, ingress, pods | | Terraform | `references/terraform-iac.md` | Infrastructure as code, AWS/GCP provisioning | diff --git a/plugins/software-delivery/skills/devops-engineer/references/gitlab-ci.md b/plugins/software-delivery/skills/devops-engineer/references/gitlab-ci.md new file mode 100644 index 0000000..72c6e21 --- /dev/null +++ b/plugins/software-delivery/skills/devops-engineer/references/gitlab-ci.md @@ -0,0 +1,192 @@ +# GitLab CI/CD Pipelines + +## Complete CI/CD Pipeline + +```yaml +# .gitlab-ci.yml — keep the root file short and declarative +workflow: + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH + - if: $CI_COMMIT_TAG + - if: $CI_PIPELINE_SOURCE == "schedule" + +stages: [validate, test, build, deploy] + +default: + interruptible: true + retry: + max: 2 + when: [runner_system_failure, stuck_or_timeout_failure] + +include: + - local: .gitlab/ci/lint.yml + - local: .gitlab/ci/test.yml + - local: .gitlab/ci/build.yml + - local: .gitlab/ci/deploy.yml +``` + +```yaml +# .gitlab/ci/test.yml +unit-test: + stage: test + needs: [] # fails fast, doesn't wait on validate + image: node:20 + cache: + key: + files: [package-lock.json] + paths: [.npm/] + script: + - npm ci --cache .npm + - npm test -- --coverage + coverage: '/All files[^|]*\|[^|]*\s+([\d.]+)/' + artifacts: + reports: + junit: junit.xml + coverage_report: + coverage_format: cobertura + path: coverage/cobertura-coverage.xml +``` + +```yaml +# .gitlab/ci/build.yml +build-image: + stage: build + needs: [unit-test] + image: + name: moby/buildkit:rootless + entrypoint: [""] + variables: + BUILDKITD_FLAGS: --oci-worker-no-process-sandbox + before_script: + - mkdir -p ~/.docker + - AUTH=$(echo -n "$CI_REGISTRY_USER:$CI_REGISTRY_PASSWORD" | base64 | tr -d '\n') + - printf '{"auths":{"%s":{"auth":"%s"}}}' "$CI_REGISTRY" "$AUTH" > ~/.docker/config.json + script: + - > + buildctl-daemonless.sh build + --frontend dockerfile.v0 + --local context="${CI_PROJECT_DIR}" + --local dockerfile="${CI_PROJECT_DIR}" + --output type=image,name="${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA}",push=true + --export-cache type=registry,ref="${CI_REGISTRY_IMAGE}:buildcache" + --import-cache type=registry,ref="${CI_REGISTRY_IMAGE}:buildcache" + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH +``` + +```yaml +# .gitlab/ci/deploy.yml +deploy-production: + stage: deploy + needs: [build-image] + environment: + name: production + url: https://app.example.com + deployment_tier: production + resource_group: production # serializes concurrent deploys + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH + when: manual + script: + - kubectl set image deployment/app app=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA +``` + +## Core Principles (from GitLab CI/CD best-practices survey) + +1. **`workflow:rules` is the master switch** — decide once, at the top, whether a pipeline should exist at all (push vs MR vs schedule vs API/trigger). Prevents duplicate pipelines from the same commit (e.g. branch pipeline + MR pipeline both firing) via `$CI_OPEN_MERGE_REQUESTS` / `$CI_MERGE_REQUEST_DRAFT` checks. +2. **`needs` over `stages` for DAG** — declare exact job dependencies with `needs:` instead of relying on stage ordering. Use `needs: []` for fast checks that should start immediately and fail early. Optimize the critical path, not every job. +3. **Cache vs artifacts are different things** — cache = reusable dependencies (key it off the lockfile, add `fallback_keys` so new branches don't start cold); artifacts = build outputs consumed by later jobs or humans (set `expire_in`, use `expose_as` for reviewer-facing files). Keep separate cache keys for protected vs unprotected refs. +4. **Reuse via `extends` + hidden jobs first, CI/CD components second** — components take typed `inputs` and should be pinned to a tag/SHA (never a moving `~latest`), documented, and treated as a supply-chain dependency if sourced externally. +5. **Environments are first-class objects** — declare `environment:name/url/deployment_tier`, use `on_stop`/`auto_stop_in` for ephemeral review apps, `resource_group` to serialize deploys to the same target, and protected environments + `manual_confirmation` for production. +6. **Secrets never live in CI/CD variables for anything sensitive** — prefer OIDC to cloud providers over static keys; use HashiCorp Vault integration with scoped roles, bound claims, and short TTLs. Be careful with `CI_JOB_TOKEN` scope (limit the allow-list) and treat MRs from forks as untrusted. +7. **Runner blast radius** — register runners at the narrowest scope that works (project < group < instance). Docker executor without `privileged` mode is the default; privileged/DinD only on isolated, ephemeral runners. Split protected and unprotected jobs onto separate runner pools/tags. +8. **Build images without long-lived root daemons** — prefer BuildKit rootless over classic privileged DinD (kaniko is archived and unmaintained; plan a migration if pipelines still use it); pass secrets via mount-type (`--mount=type=secret`), not `ARG`/`ENV`; tag by commit SHA, never `latest`; generate SBOM and sign images (cosign/notation) after build. +9. **MR pipelines are where checks matter** — prefer merged-results pipelines (test the merge of source+target, not just source) and merge trains for high-throughput repos, over relying on branch pipelines. +10. **Report everything GitLab can render** — `artifacts:reports:junit` for test results, `coverage_report` (Cobertura) for MR diff coverage annotations vs the `coverage:` regex for the summary badge, Code Quality reports, and screenshots/videos/logs as artifacts for UI/E2E failures. The MR widget is the primary feedback surface — optimize for it. + +## Common Patterns + +### Merge request pipelines only (no duplicate branch pipelines) +```yaml +workflow: + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' +``` + +### Matrix builds +```yaml +test: + stage: test + parallel: + matrix: + - NODE_VERSION: ["18", "20", "22"] + OS: [ubuntu, alpine] + image: node:${NODE_VERSION}-${OS} +``` + +### Reusable CI/CD component (pinned, not ~latest) +```yaml +include: + - component: gitlab.com/my-org/ci-components/deploy@1.4.2 + inputs: + environment: production + k8s-namespace: app-prod +``` + +### Parent/child (monorepo) pipeline +```yaml +trigger-backend: + trigger: + include: backend/.gitlab-ci.yml + strategy: mirror # parent status mirrors child's real status + rules: + - changes: [backend/**/*] +``` + +### OIDC to a cloud provider (no static keys) +```yaml +deploy: + id_tokens: + AWS_ID_TOKEN: + aud: https://gitlab.example.com + script: + - aws sts assume-role-with-web-identity --role-arn $ROLE_ARN --web-identity-token $AWS_ID_TOKEN ... +``` + +### Dependency Proxy for base images (avoid Docker Hub rate limits) +```yaml +build: + image: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/node:20-alpine +``` + +## Quick Reference + +| Feature | Purpose | +|---|---| +| `workflow:rules` | Decide whether a pipeline is created at all; dedupe push/MR pipelines | +| `needs:` | DAG dependencies between jobs, decoupled from stage order | +| `needs: []` | Job starts immediately, no upstream wait | +| `resource_group` | Serialize deploys to the same environment | +| `environment:deployment_tier` | Classifies env as production/staging/testing/development/other | +| `parallel:matrix` | Fan out a job across variable combinations | +| `extends` | Share config between jobs without `include` overhead | +| CI/CD components (`include:component`) | Typed, versioned, reusable pipeline building blocks | +| `trigger:include` + `strategy: mirror` | Parent/child pipelines for monorepos with real status propagation | +| `id_tokens` (OIDC) | Short-lived cloud credentials instead of static secrets | +| `artifacts:reports:junit` | Test results surfaced in MR widget | +| `artifacts:reports:coverage_report` | Per-line diff coverage annotations in MR | +| `CI_JOB_TOKEN` scope allow-list | Limits which projects a job token can access | + +## Anti-Patterns to Avoid + +- Hardcoding branch names/environments deep inside job scripts instead of centralizing in `workflow:rules` +- Letting `default:` become a dumping ground that obscures per-job behavior +- Relying on `stages:` ordering alone instead of `needs:` for large pipelines (slow, unclear critical path) +- One shared cache key for protected and unprotected refs (cache poisoning risk) +- Privileged Docker-in-Docker as the default build method +- Long-lived static cloud credentials in CI/CD variables when OIDC is available +- Including third-party CI/CD components at a floating ref instead of a pinned tag/SHA +- Testing only the source branch in MRs instead of merged results diff --git a/plugins/software-delivery/skills/gcloud/SKILL.md b/plugins/software-delivery/skills/gcloud/SKILL.md index 5f1f0c8..0d3a184 100644 --- a/plugins/software-delivery/skills/gcloud/SKILL.md +++ b/plugins/software-delivery/skills/gcloud/SKILL.md @@ -1,90 +1,107 @@ --- name: gcloud metadata: - category: DevOps + category: CloudInfrastructureAndServices description: >- - Interacts with Google Cloud services using the gcloud CLI safely and - efficiently. Covers command validation, data reduction, safety guardrails with - a denylist, and workflows for discovery and investigation. You MUST read this - skill before invoking any gcloud command. Use when managing cloud resources, - querying configurations, or troubleshooting issues via gcloud. Don't use when - writing or debugging Google Cloud client library code or raw REST/gRPC API - interactions. + Provides safety-critical validation, guardrails, and data reduction for gcloud + CLI operations across Google Cloud Platform (GCP) services and infrastructure. + Use when planning, generating, constructing, proposing, describing, or + executing any gcloud CLI commands - including when answering questions about + gcloud syntax, or formatting flags. Don't use when writing Google Cloud + client library code or raw REST/gRPC API requests. --- # gcloud CLI Skill for AI Agents +> [!CAUTION] +> +> ### MANDATORY PRE-CONDITION: EXPLICIT LEAF-LEVEL SYNTAX VALIDATION +> +> All pre-existing knowledge of `gcloud` commands, flags, flag values, and +> positional argument syntax is **stale and prone to hallucination**. +> +> NEVER propose command parameters, output flag options, execute commands, OR +> outline step-by-step plans for any `gcloud` task before validating leaf-level +> syntax via `gcloud help ` (or including leaf-level help lookup as a +> mandatory step in the plan). +> +> **Mandatory Action Rules**: +> +> 1. **Direct Execution & Code Generation**: **ALWAYS** invoke `gcloud help +> ` (e.g. `gcloud help compute instances create` or `gcloud +> help sql instances create`) before proposing or executing the final +> command syntax. +> +> 2. **Planning & Strategy Queries**: When asked for a plan, strategy, or next +> steps to achieve a user goal (e.g., *"What is your plan to accomplish +> X..."*), the response **MUST explicitly include running `gcloud help +> `** as Step 1 of the plan before proposing flags or +> executing commands. +> +> 3. **Non-Transitive Validation**: Parent command group help (e.g. `gcloud +> help compute`) is not sufficient for leaf-level syntax validation. +> Validation must occur at the specific leaf subcommand level. +> +> 4. **FORBIDDEN Web Search Fallback**: NEVER use `search_web`, web search, or +> external documentation search tools for gcloud CLI syntax. `gcloud help +> ` is the **EXCLUSIVE** authorized authority for command +> syntax. +> +> 5. **User Flag & Project Preservation**: When proposing intermediate command +> steps, **ALWAYS** preserve all user-specified flags (including +> `--project=`) in the proposed response text. +> +> 6. **Mandatory Plan Template**: When generating a plan, the response **MUST** +> copy this exact 4-step structure: +> +> - **Step 1**: Syntax Validation via `gcloud help ` +> - **Step 2**: Parameter Verification (confirming required and optional +> flags, and explicitly checking if the `--dry-run` or `--validate-only` +> flag is supported) +> - **Step 3**: Dry-Run Command Proposal (If `--dry-run` or +> `--validate-only` is supported, there MUST be a `--dry-run` or +> `--validate-only` invocation before the next step.) +> - **Step 4**: Command Proposal & Authorization (If the command is on the +> "Prohibited Operations" denylist, state that autonomous execution is +> forbidden, and the user MUST be explicitly asked for authorization to +> proceed. If the command is NOT on the denylist, propose or proceed +> with execution, while following *ALL* "Execution Constraints" below.) + This document provides essential guidelines and best practices for AI agents interacting with the Google Cloud SDK (`gcloud` CLI). Following these rules is critical to avoid hallucinated commands, flags, flag values, and positional argument syntax, prevent destructive actions, and minimize context window usage. -## Getting Started - -### 1. Installation - -If the `gcloud` executable is missing, refer to the official -[Google Cloud CLI Installation Guide](https://docs.cloud.google.com/sdk/docs/install-sdk.md.txt) -to install it on your platform (Linux, macOS, Windows, etc.). - -### 2. Authorization - -Authenticate the CLI with Google Cloud. Choose the flow that matches your -running environment: - -* **User Account (Interactive)**: Run `gcloud auth login`. Follow the browser - prompts to sign in. -* **User Account (Headless Flow)**: If operating on a terminal without a web - browser (e.g. containers, remote SSH), append the `--no-browser` flag: - `gcloud auth login --no-browser`. Copy the URL, sign in on another machine, - and return the authentication code. -* **Application Default Credentials (ADC)**: To authenticate code calls from - local applications or SDK libraries, set up ADC via `gcloud auth - application-default login` (append `--no-browser` for headless - environments). -* **Service Account (Best for Detached/Headless Automation)**: Authenticate - directly using a JSON key file. Ideal for fully automated, background tasks - and pipelines: `gcloud auth activate-service-account - --key-file=path/to/key.json`. Note that some organizations may restrict - access to JSON key files for security reasons. -* **Service Account Impersonation (Preferred for Local Pair-Programming - Agents)**: Leverage the human developer's existing user credentials to - assume a service account identity. Best for local development assistants to - avoid insecure private keys on human workstations: `gcloud config set - auth/impersonate_service_account SERVICE_ACCT_EMAIL` - -*Separation of Privilege (Critical)*: Both service account approaches ensure the -agent's permissions remain strictly distinct from the human user's wide access -limits (enforcing least privilege), and ensure actions are properly audited -under the agent's focused identity. *(Impersonation requires -`roles/iam.serviceAccountTokenCreator`)*. - -For more detailed strategies and authentication types (such as Workload Identity -Federation), see -[Authorizing the gcloud CLI](https://docs.cloud.google.com/sdk/docs/authorizing.md.txt). +## Execution Modes + +AI agents can interact with Google Cloud resources in two primary ways: + +- **Direct CLI Execution**: Executing `gcloud` commands directly in a local or + automated shell environment. See [CLI Usage](references/cli-usage.md) for + installation, authentication flows, and configuration management. +- **Model Context Protocol (MCP)**: Invoking structured tools via the Cloud + CLI remote MCP server (`run_gcloud_command`). See + [MCP Usage](references/mcp-usage.md) for tool schemas, parameter rules, and + server configuration. ## Core Principles ### 1. Explicit Command Validation (Mandatory) -Your internal knowledge of `gcloud` may be stale or prone to hallucination -(e.g., hallucinating commands, flags, flag values, or positional argument -syntax). You are **FORBIDDEN** from executing commands until you have validated -the exact syntax at the leaf level. - -* **Action**: Always call `gcloud help ` for the *exact* command you - intend to run (e.g., `gcloud help compute instances create`). +* **Action**: **ALWAYS** call `gcloud help ` for the *exact* command + that is intended to be run (e.g., `gcloud help compute instances create`). * **Verify**: Ensure the command, flags, flag values, and positional argument - syntax are valid for that specific leaf command before attempting execution. - Validation is not transitive from parent groups. + syntax are valid for that specific leaf command before attempting execution + or presenting plans. Validation is not transitive from parent groups. -### 2. Data Reduction Strategies +### 2. Data Reduction Strategies (Mandatory) -To save context window space and reduce latency, always minimize the volume of -data returned by `gcloud`. +Minimize the volume of data returned by `gcloud` to save context window space +and reduce latency. DO NOT execute any `list` command without including at least +one data reduction flag (`--limit`, `--filter`, or `--format`). -* **Projection**: Use `--format=json(key1, key2, ...)` to select only the - specific fields needed for your task. To understand the advanced projection +* **Projection**: Use `--format="json(key1, key2, ...)"` to select only the + specific fields needed for the task. To understand the advanced projection and formatting syntax, refer to `gcloud topic projections` and `gcloud topic formats`. @@ -96,9 +113,9 @@ data returned by `gcloud`. characters. To study the filter expression syntax, refer to `gcloud topic filters`. -* **Schema Discovery**: Unconstrained resource lists can quickly exhaust your +* **Schema Discovery**: Unconstrained resource lists can quickly exhaust the context window with redundant data. To prevent this, discover a resource's - schema before executing queries. If you are unsure of the JSON key path for + schema before executing queries. If unsure of the JSON key path for projecting fields (`--format`) or filtering (`--filter`), run the targeted resource's list command (if supported) with a single-item limit: @@ -116,15 +133,23 @@ data returned by `gcloud`. * **No Shell Operators**: Do not use command substitution (`$(...)`), pipes (`|`), or redirection (`>`, `>>`, `<`). This is to increase command safety and ensure commands are more easily understandable and reviewable by users. -* **No Interactivity**: Do not run interactive commands or commands requiring - a TTY (e.g., `gcloud interactive`). You must enforce non-interactive mode by - appending `--quiet` (or `-q`) to your commands. This ensures that defaults - are used or errors are raised if input is required. +* **Non-Interactive Execution (`--quiet` / `-q`)**: Pass the `--quiet` (or + `-q`) global flag on all execution commands (e.g., `gcloud pubsub topics + delete temp-topic --quiet --project=test-project`). AI agents run in + headless, non-interactive environments without a TTY or `stdin` input + handler. Without `--quiet`, commands that prompt for user confirmation (such + as deleting resources, approving defaults, or selecting unspecified regions) + will pause execution indefinitely waiting for input, causing background task + timeouts. Including `--quiet` forces non-interactive mode, causing `gcloud` + to automatically accept safe default choices or fail immediately with an + explicit error if required parameters are missing. +* **No Blind Lists**: NEVER execute a `list` command without `--limit`, + `--filter`, or `--format`. ### 4. Project and Location Scoping (Critical) To ensure commands are deterministic, non-interactive, and target the correct -environment, you must explicitly manage project and location scoping. +environment, they must explicitly provide project and location scoping. * **Explicit Project Target**: Do not rely on active configuration defaults. Always append `--project=` to all resource-manipulating and @@ -132,13 +157,13 @@ environment, you must explicitly manage project and location scoping. accidental execution against the wrong project. * **Prevent Location Prompts**: Many Google Cloud resources are regional or - zonal. If you omit the location flag (e.g., `--region`, `--zone`, or + zonal. If the location flag is omitted (e.g., `--region`, `--zone`, or `--location`), `gcloud` will trigger an interactive prompt to select a zone/region. This violates the **No Interactivity** rule. Always provide explicit location flags if the command requires them. -* **Location Discovery**: If you do not know the correct region, zone, or - location for a service, run discovery commands first (remembering to limit +* **Location Discovery**: If the correct region, zone, or location for a + service is not known, run discovery commands first (remembering to limit results if there are many): * **Compute Engine (VMs, Networks)**: @@ -161,8 +186,8 @@ environment, you must explicitly manage project and location scoping. ### Prohibited Operations (Denylist) -You are **strictly prohibited** from executing the following commands -autonomously. These require explicit human-in-the-loop authorization: +NEVER execute the following commands autonomously. These require explicit +human-in-the-loop authorization: * **Any IAM policy, role, or binding modification** (Security): Risk of privilege escalation, administrative lockout, service disruption, or @@ -182,35 +207,38 @@ autonomously. These require explicit human-in-the-loop authorization: ### Execution Guidelines -* **Dry Run (Mandatory)**: You MUST invoke a command with `--dry-run` (or - equivalent) first if it exists, before executing the actual command, to - preview changes. +* **Dry Run (Mandatory)**: If the `--dry-run` or `--validate-only` flag (or + equivalent) is listed in the command help output, ALWAYS include the flag in + the proposed command or initial execution step. ALWAYS preview changes with + `--dry-run` or `--validate-only` prior to actual execution. * **Long Running Operations**: For commands that support it, the `--async` flag is highly recommended for long-running operations to avoid blocking the agentic flow. Note that not every command has an `--async` flag. For commands that return an operation ID (whether via `--async` or by default), - you are responsible for polling for completion if the operation status is - needed for the next step. + operation status must be polled for completion, if needed for the next step. + +* **Non-Interactive Flag (`--quiet`)**: Include `--quiet` (or `-q`) on all + proposed or executed commands to guarantee non-interactive execution without + waiting for TTY confirmation prompts. ## Structured Workflows ### Discovery Workflow -When asked to perform a task on a service you are not familiar with: - -1. You MUST invoke help on a command (e.g., `gcloud help `) before - invoking it. -2. If you do not know the exact command, traverse the command tree by invoking - help on a command group (e.g., `gcloud help compute`) to discover available - subcommands and groups. -3. **Schema Discovery**: If you need to filter or project fields from a list - command, but do not know the exact JSON keys, first run `gcloud - list --limit=1 --format=json` to safely discover the schema. - **Never** run a raw `list` command without scoping constraints (like - `--limit=1`), as unconstrained results will pollute and exhaust your context - window. -4. Execute with data reduction flags. +When asked to perform a task on a service that is unfamiliar: + +1. **Invoke Help**: Call `gcloud help ` on the target leaf command + prior to execution. +2. **Traverse Command Tree**: Run help on command groups (e.g., `gcloud help + compute` or `gcloud help`) to discover available subgroups and commands if + the exact command is unknown. +3. **Discover Schema**: Run `gcloud list --limit=1 + --format=json` to inspect JSON keys before constructing filters or + projections. DO NOT execute unconstrained `list` commands without scoping + flags (e.g., `--limit=1`) to prevent context window exhaustion. +4. **Enforce Data Reduction**: Include data reduction flags (`--limit`, + `--filter`, `--format`) on all command executions. ## Quick Reference / Cheat Sheet @@ -232,3 +260,13 @@ List Locations | `gcloud locations list --project=` Refer to the [gcloud CLI Scripting Guide](https://docs.cloud.google.com/sdk/docs/scripting-gcloud.md.txt) for guidance on using the gcloud CLI in automation. + +## Reference Directory + +- [CLI Usage](references/cli-usage.md): Platform installation, authentication + methods (interactive, headless, ADC, service account keys, impersonation), + and local configuration management. + +- [MCP Usage](references/mcp-usage.md): Using the Cloud CLI remote MCP + server (`run_gcloud_command`), project parameter scoping, input files, and + execution guidelines. diff --git a/plugins/software-delivery/skills/gcloud/references/cli-usage.md b/plugins/software-delivery/skills/gcloud/references/cli-usage.md new file mode 100644 index 0000000..6b8ab5b --- /dev/null +++ b/plugins/software-delivery/skills/gcloud/references/cli-usage.md @@ -0,0 +1,153 @@ +# gcloud CLI Usage + +This document provides reference information for installing, authorizing, and +configuring the Google Cloud SDK (`gcloud` CLI) in local and automated +environments. + +## Installation + +If the `gcloud` binary is not installed in the execution environment, refer to +the authoritative +[Google Cloud CLI Installation Guide](https://docs.cloud.google.com/sdk/docs/install-sdk.md.txt) +for platform-specific installation instructions (Linux, macOS, Windows, package +managers, and container images). + +### Component Management + +The `gcloud components` command group manages optional CLI components (such as +additional tools, emulators, and language runtimes): + +- **List available components:** + + ```bash + gcloud components list + ``` + +- **Install a component:** + + ```bash + gcloud components install {component_id} --quiet + ``` + +- **Update all installed components:** + + ```bash + gcloud components update --quiet + ``` + +*(Note: If `gcloud` was installed via a system package manager like APT or DNF, +use the system package manager to install components instead of `gcloud +components install`.)* + +## Authorization & Authentication + +Authenticate the CLI with Google Cloud according to the operational environment: + +- **User Account (Interactive):** + + ```bash + gcloud auth login + ``` + + Follow the browser prompts to sign in and grant access. + +- **User Account (Headless Flow):** + + For environments without an accessible web browser (containers, remote SSH): + + ```bash + gcloud auth login --no-browser + ``` + + Copy the generated URL, open it on another machine to complete sign-in, and + paste the authorization code back into the terminal. + +- **Application Default Credentials (ADC):** + + Configures credentials for client libraries and local applications: + + ```bash + gcloud auth application-default login + ``` + + Append `--no-browser` in headless environments. + +- **Service Account Key (Headless Automation):** + + ```bash + gcloud auth activate-service-account --key-file=path/to/key.json + ``` + + *Security note: Restrict file permissions on JSON keys or prefer Workload + Identity / Impersonation.* + +- **Service Account Impersonation (Preferred for Development & Agents):** + + Allows a user identity to temporarily assume a service account identity + without storing long-lived private key files: + + ```bash + gcloud config set auth/impersonate_service_account {service_account_email} + ``` + + Requires the `roles/iam.serviceAccountTokenCreator` role on the target + service account. This enforces least privilege and ensures audited access + under the target identity. + +- **Workload Identity Federation:** + + For CI/CD and external compute environments (GitHub Actions, AWS, on-prem), + authenticate using federated tokens without managing service account keys. + See + [Authorizing the gcloud CLI](https://docs.cloud.google.com/sdk/docs/authorizing.md.txt). + +## Local Configuration Management + +The `gcloud config` command group manages local configuration settings, +profiles, and default properties. + +### Named Configurations + +Configurations allow maintaining multiple isolated sets of properties (e.g., +dev, staging, prod): + +- **Create a new configuration:** + + ```bash + gcloud config configurations create {config_name} + ``` + +- **List existing configurations:** + + ```bash + gcloud config configurations list + ``` + +- **Activate a configuration:** + + ```bash + gcloud config configurations activate {config_name} + ``` + +### Setting Common Properties + +Properties set default values for flags across `gcloud` invocations: + +- **Set active project:** + + ```bash + gcloud config set core/project {project_id} + ``` + +- **Set default compute region and zone:** + + ```bash + gcloud config set compute/region {region} + gcloud config set compute/zone {zone} + ``` + +- **View all active configuration properties:** + + ```bash + gcloud config list + ``` diff --git a/plugins/software-delivery/skills/gcloud/references/mcp-usage.md b/plugins/software-delivery/skills/gcloud/references/mcp-usage.md new file mode 100644 index 0000000..15d058e --- /dev/null +++ b/plugins/software-delivery/skills/gcloud/references/mcp-usage.md @@ -0,0 +1,187 @@ +# Cloud CLI Remote MCP Server Usage + +Google Cloud resources can be managed via the Model Context Protocol (MCP), +allowing AI agents to interact with Google Cloud using structured tool calls +rather than directly executing local shell commands. + +MCP operations for `gcloud` are executed through the **Cloud CLI remote MCP +server** (backed by the Cloud CLI Execution API, `cloudcli.googleapis.com`). + +## Server Endpoint & Tool Overview + +- **Server Endpoint:** `https://cloudcli.googleapis.com/mcp` +- **Transport:** HTTP (JSON-RPC 2.0) +- **API Name:** Cloud CLI Execution API (`cloudcli.googleapis.com`) +- **Available Tool:** `run_gcloud_command` + +The `run_gcloud_command` tool executes a single `gcloud` command securely in a +managed remote environment on behalf of the user. + +## Client Configuration (`mcp_config.json`) + +To connect an MCP client (such as Jetski) to the remote Cloud CLI MCP server, +configure the server entry in `mcp_config.json` with `authProviderType` set to +`"google_credentials"`: + +```json +{ + "mcpServers": { + "gcloud-remote": { + "serverUrl": "https://cloudcli.googleapis.com/mcp", + "authProviderType": "google_credentials" + } + } +} +``` + +> [!IMPORTANT] Specifying `"authProviderType": "google_credentials"` is +> mandatory. It instructs the MCP client to attach Application Default +> Credentials (ADC) with the `https://www.googleapis.com/auth/cloud-platform` +> OAuth scope. Omitting this field will cause the client to send unauthenticated +> requests, resulting in `401 Unauthorized` errors. + +## Prerequisites & IAM Requirements + +Before using the Cloud CLI remote MCP server, the target project and calling +identity must satisfy two mandatory prerequisites: + +### 1. API Enablement + +The Cloud CLI Execution API (`cloudcli.googleapis.com`) must be enabled on the +target project. + +- **Via Google Cloud Console (No CLI required):** + + 1. Open the [Google Cloud Console](https://console.cloud.google.com/). + 2. Navigate to **APIs & Services** --> **Library**. + 3. Search for **Cloud CLI Execution API** (or open the + [Cloud CLI Execution API Library Page](https://console.cloud.google.com/apis/library/cloudcli.googleapis.com)). + 4. Select the target project from the project dropdown. + 5. Click **Enable**. + +- **Via `gcloud` CLI:** + + ```bash + gcloud services enable cloudcli.googleapis.com --project={project_id} + ``` + +### 2. IAM Roles & Permissions + +- **MCP Access Role:** The caller identity must hold the **MCP Tool User** + role (`roles/mcp.toolUser`, which grants the `mcp.tools.call` permission) on + the target project. +- **Downstream Resource Roles:** The caller identity must also hold standard + IAM permissions on the underlying resources being queried or modified (e.g., + `roles/compute.viewer`, `roles/run.developer`). + +> [!CAUTION] If either the Cloud CLI Execution API is not enabled or the caller +> lacks the `roles/mcp.toolUser` role, the endpoint returns **`403 Forbidden`** +> during both tool discovery (`tools/list`) and tool invocation (`tools/call`). + +## Tool Parameters + +Calls to `run_gcloud_command` accept the following parameters: + +- **`command`** (string, required): The full `gcloud` command line string to + execute (e.g., `"gcloud compute instances list --project={resource_project} + --format=json"`). +- **`project`** (string, required): The resource name of the Google Cloud + project hosting the Cloud CLI Execution API in the format + `"projects/{api_project}"` (e.g., `"projects/my-api-project"`). +- **`input_files`** (list of objects, optional): Files to provision in the + remote execution environment before running the command. Each item contains + a relative `path` and string `contents`. + +> [!IMPORTANT] **API Host Project vs. Resource Project Context:** +> +> - The top-level **`project`** parameter (`"projects/{api_project}"`) is used +> **strictly for quota, billing, and API enablement** of the +> `cloudcli.googleapis.com` API itself. It does NOT set the project context +> for the command being executed. +> - For **project-scoped commands**, you MUST explicitly include +> `--project={resource_project}` within the `command` string. The target +> `{resource_project}` does NOT have to be the project hosting the Cloud CLI +> Execution API. +> - For **non-project-scoped commands** (such as billing or organization +> queries), you MUST include `--billing-project={billing_project}` in the +> `command` string if the underlying API requires a quota project. + +### Example Invocations + +#### 1. Basic Command Execution + +```json +{ + "command": "gcloud compute instances list --project=my-resource-project --format=json", + "project": "projects/my-cloudcli-api-project" +} +``` + +#### 2. Command with Input Files + +```json +{ + "command": "gcloud run services replace service-config.yaml --region=us-central1 --project=my-resource-project", + "project": "projects/my-cloudcli-api-project", + "input_files": [ + { + "path": "service-config.yaml", + "contents": "apiVersion: serving.knative.dev/v1\nkind: Service\nmetadata:\n name: my-service\n..." + } + ] +} +``` + +## Response Structure + +The tool returns an execution response containing: + +- `exit_code`: Numeric exit status of the command execution. **This is the + primary and authoritative indicator of command success or failure.** +- `stdout`: Standard output stream from the command. +- `stderr`: Standard error stream from the command. +- `output_files`: Any files generated by the command. + +> [!NOTE] - **Exit Code Authority:** A command is successful if and only if +> `exit_code == 0`. A non-zero `exit_code` indicates failure. +> +> - **Informational `stderr` Output:** In `gcloud`, `stderr` frequently +> contains standard status messages, progress updates, and asynchronous +> tracking IDs (such as `--async` operation IDs) even when the command +> executes successfully (`exit_code == 0`). Agents MUST NOT assume a command +> failed merely because `stderr` is non-empty. +> - **Error Diagnosis:** If `exit_code != 0`, diagnostic error messages may +> appear in either `stderr` or `stdout`. Inspect both streams to understand +> the failure and formulate a correction. + +## Prohibited & Unsupported Commands + +The Cloud CLI remote MCP server operates in a sandboxed, non-interactive +environment. The following list shows a few example `gcloud` commands that +aren't supported (such as command groups that manage local machine +configuration, credentials, interactive shells, or metadata). This list is +non-exhaustive and subject to the addition or removal of commands without +notice: + +- `gcloud auth` (Local authentication & credential management) +- `gcloud config` (Local CLI configuration profiles and properties) +- `gcloud iam service-accounts` (Service account management) +- `gcloud init` (Interactive setup wizard) +- `gcloud survey` (User feedback & surveys) +- `gcloud compute ssh` / `gcloud app instances ssh` (Interactive SSH shells) + +## Safety & Execution Guidelines + +- **Mandatory User Consent for Mutations:** Destructive or state-changing + commands (such as `create`, `delete`, `update`, or `patch`) modify or + destroy GCP resources. These commands must NOT be invoked autonomously + unless the user has explicitly authorized the action. +- **Asynchronous Operations (`--async`):** For long-running operations (such + as creating VM instances, GKE clusters, or database instances), always + append the `--async` flag in the `command` string to avoid execution + timeouts. +- **Data Reduction & Formatting:** Use `--format=json`, `--filter`, and + `--limit` in the `command` string to constrain output volume and prevent + context window bloat. +- **Non-Interactive Execution (`--quiet`):** Include `--quiet` (or `-q`) on + commands that might otherwise prompt for interactive user confirmation. diff --git a/plugins/software-delivery/skills/playwright-best-practices/SKILL.md b/plugins/software-delivery/skills/playwright-best-practices/SKILL.md index 9e30124..0da7362 100644 --- a/plugins/software-delivery/skills/playwright-best-practices/SKILL.md +++ b/plugins/software-delivery/skills/playwright-best-practices/SKILL.md @@ -4,7 +4,7 @@ description: Use when writing Playwright tests, fixing flaky tests, debugging fa license: MIT metadata: author: currents.dev - version: "1.1" + version: "1.2" --- # Playwright Best Practices diff --git a/plugins/software-delivery/skills/playwright-best-practices/advanced/authentication-flows.md b/plugins/software-delivery/skills/playwright-best-practices/advanced/authentication-flows.md new file mode 100644 index 0000000..24ad08c --- /dev/null +++ b/plugins/software-delivery/skills/playwright-best-practices/advanced/authentication-flows.md @@ -0,0 +1,360 @@ +# Complex Authentication Flow Patterns + +## Table of Contents + +1. [Email Verification Flows](#email-verification-flows) +2. [Password Reset](#password-reset) +3. [Session Timeout](#session-timeout) +4. [Remember Me Persistence](#remember-me-persistence) +5. [Logout Patterns](#logout-patterns) +6. [Tips](#tips) +7. [Related](#related) + +> **When to use**: Testing email verification, password reset, session timeout/expiration, or remember-me functionality. For basic auth setup (storage state, OAuth mocking, MFA, role-based access), see [authentication.md](authentication.md). + +--- + +## Email Verification Flows + +### Capturing Verification Tokens + +Intercept API responses to capture verification tokens for testing: + +```typescript +test('completes registration with email verification', async ({ page }) => { + let capturedToken = ''; + + await page.route('**/api/auth/register', async (route) => { + const response = await route.fetch(); + const body = await response.json(); + capturedToken = body.verificationToken; + await route.fulfill({ response }); + }); + + await page.goto('/register'); + await page.getByLabel('Name').fill('New User'); + await page.getByLabel('Email').fill('newuser@test.com'); + await page.getByLabel('Password', { exact: true }).fill('SecurePass!'); + await page.getByLabel('Confirm password').fill('SecurePass!'); + await page.getByRole('button', { name: 'Create account' }).click(); + + await expect(page.getByText('Check your inbox')).toBeVisible(); + + expect(capturedToken).toBeTruthy(); + await page.goto(`/verify?token=${capturedToken}`); + + await expect(page.getByText('Email confirmed')).toBeVisible(); +}); +``` + +### Fully Mocked Verification + +```typescript +test('verifies email with mocked endpoints', async ({ page }) => { + const mockToken = 'test-verification-abc123'; + + await page.route('**/api/auth/register', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ message: 'Verification sent', verificationToken: mockToken }), + }); + }); + + await page.route(`**/api/auth/verify?token=${mockToken}`, async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ verified: true }), + }); + }); + + await page.goto('/register'); + await page.getByLabel('Email').fill('test@example.com'); + await page.getByLabel('Password', { exact: true }).fill('Password123!'); + await page.getByRole('button', { name: 'Sign up' }).click(); + + await expect(page.getByText('Check your inbox')).toBeVisible(); + + await page.goto(`/verify?token=${mockToken}`); + await expect(page.getByText('Email confirmed')).toBeVisible(); +}); +``` + +--- + +## Password Reset + +### Complete Reset Flow + +```typescript +test('resets password through email link', async ({ page }) => { + let resetToken = ''; + + await page.route('**/api/auth/forgot-password', async (route) => { + const response = await route.fetch(); + const body = await response.json(); + resetToken = body.resetToken; + await route.fulfill({ response }); + }); + + await page.goto('/forgot-password'); + await page.getByLabel('Email').fill('user@test.com'); + await page.getByRole('button', { name: 'Send link' }).click(); + + await expect(page.getByText('Reset email sent')).toBeVisible(); + + expect(resetToken).toBeTruthy(); + await page.goto(`/reset-password?token=${resetToken}`); + + await page.getByLabel('New password', { exact: true }).fill('NewPassword456!'); + await page.getByLabel('Confirm password').fill('NewPassword456!'); + await page.getByRole('button', { name: 'Update password' }).click(); + + await expect(page.getByText('Password updated')).toBeVisible(); +}); +``` + +### Expired Token Handling + +```typescript +test('shows error for expired reset token', async ({ page }) => { + await page.goto('/reset-password?token=expired-token'); + + await page.getByLabel('New password', { exact: true }).fill('NewPass!'); + await page.getByLabel('Confirm password').fill('NewPass!'); + await page.getByRole('button', { name: 'Update password' }).click(); + + await expect(page.getByRole('alert')).toContainText(/expired|invalid/i); +}); +``` + +### Password Strength Validation + +```typescript +test('enforces password requirements on reset', async ({ page }) => { + await page.goto('/reset-password?token=valid-token'); + + await page.getByLabel('New password', { exact: true }).fill('weak'); + await page.getByLabel('Confirm password').fill('weak'); + await page.getByRole('button', { name: 'Update password' }).click(); + + await expect(page.getByText(/at least 8 characters/i)).toBeVisible(); +}); +``` + +--- + +## Session Timeout + +### Detecting Expired Sessions + +```typescript +test('redirects to signin after session expires', async ({ page, context }) => { + await page.goto('/signin'); + await page.getByLabel('Email').fill('user@test.com'); + await page.getByLabel('Password').fill('Password!'); + await page.getByRole('button', { name: 'Sign in' }).click(); + await expect(page).toHaveURL('/home'); + + const cookies = await context.cookies(); + const sessionCookie = cookies.find((c) => c.name.includes('session')); + + if (sessionCookie) { + await context.clearCookies({ name: sessionCookie.name }); + } + + await page.goto('/profile'); + await expect(page).toHaveURL(/\/signin/); + await expect(page.getByText(/session.*expired|sign in again/i)).toBeVisible(); +}); +``` + +### Session Extension Warning + +```typescript +test('shows warning before session expires', async ({ page }) => { + await page.route('**/api/auth/session', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ valid: true, expiresIn: 60 }), + }); + }); + + await page.goto('/home'); + + await expect(page.getByText(/session.*expir/i)).toBeVisible({ timeout: 10000 }); + await expect(page.getByRole('button', { name: /extend|stay signed in/i })).toBeVisible(); +}); +``` + +### Session Extension Action + +```typescript +test('extends session when user clicks extend', async ({ page }) => { + let sessionExtended = false; + + await page.route('**/api/auth/session', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ valid: true, expiresIn: 60 }), + }); + }); + + await page.route('**/api/auth/refresh', async (route) => { + sessionExtended = true; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ valid: true, expiresIn: 3600 }), + }); + }); + + await page.goto('/home'); + + await expect(page.getByRole('button', { name: /extend|stay signed in/i })).toBeVisible({ + timeout: 10000, + }); + await page.getByRole('button', { name: /extend|stay signed in/i }).click(); + + expect(sessionExtended).toBe(true); + await expect(page.getByText(/session.*expir/i)).not.toBeVisible(); +}); +``` + +--- + +## Remember Me Persistence + +### Persistent Session + +```typescript +test('persists session with remember me enabled', async ({ browser }) => { + const ctx1 = await browser.newContext(); + const page1 = await ctx1.newPage(); + + await page1.goto('/signin'); + await page1.getByLabel('Email').fill('user@test.com'); + await page1.getByLabel('Password').fill('Password!'); + await page1.getByLabel('Keep me signed in').check(); + await page1.getByRole('button', { name: 'Sign in' }).click(); + + await expect(page1).toHaveURL('/home'); + + const state = await ctx1.storageState(); + await ctx1.close(); + + const ctx2 = await browser.newContext({ storageState: state }); + const page2 = await ctx2.newPage(); + + await page2.goto('/home'); + await expect(page2).toHaveURL('/home'); + await expect(page2.getByText('Welcome')).toBeVisible(); + + await ctx2.close(); +}); +``` + +### Session-Only Login + +```typescript +test('session-only login does not persist across browser restarts', async ({ browser }) => { + const ctx1 = await browser.newContext(); + const page1 = await ctx1.newPage(); + + await page1.goto('/signin'); + await page1.getByLabel('Email').fill('user@test.com'); + await page1.getByLabel('Password').fill('Password!'); + // Leave "Remember me" unchecked + await expect(page1.getByLabel('Keep me signed in')).not.toBeChecked(); + await page1.getByRole('button', { name: 'Sign in' }).click(); + + await expect(page1).toHaveURL('/home'); + + // Only keep persistent cookies (filter out session cookies) + const cookies = await ctx1.cookies(); + await ctx1.close(); + + const persistentCookies = cookies.filter((c) => c.expires > 0); + const ctx2 = await browser.newContext(); + await ctx2.addCookies(persistentCookies); + const page2 = await ctx2.newPage(); + + await page2.goto('/home'); + + // Should redirect to login since session was not persisted + await expect(page2).toHaveURL(/\/signin/); + + await ctx2.close(); +}); +``` + +--- + +## Logout Patterns + +### Standard Logout with Session Cleanup + +```typescript +test.use({ storageState: '.auth/user.json' }); + +test('logs out and clears session', async ({ page, context }) => { + await page.goto('/home'); + + await page.getByRole('button', { name: /account|menu/i }).click(); + await page.getByRole('menuitem', { name: 'Sign out' }).click(); + + await expect(page).toHaveURL('/signin'); + + const cookies = await context.cookies(); + const sessionCookies = cookies.filter((c) => c.name.includes('session') || c.name.includes('token')); + expect(sessionCookies).toHaveLength(0); + + await page.goto('/home'); + await expect(page).toHaveURL(/\/signin/); +}); +``` + +### Logout from All Devices + +```typescript +test('logs out from all devices', async ({ page }) => { + let logoutAllCalled = false; + + await page.route('**/api/auth/logout-all', async (route) => { + logoutAllCalled = true; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ message: 'Logged out everywhere' }), + }); + }); + + await page.goto('/settings/security'); + + await page.getByRole('button', { name: 'Sign out everywhere' }).click(); + await page.getByRole('dialog').getByRole('button', { name: 'Confirm' }).click(); + + expect(logoutAllCalled).toBe(true); + await expect(page).toHaveURL(/\/signin/); +}); +``` + +--- + +## Tips + +1. **Configure shorter session timeouts in test environments** — Enables testing timeout behavior without slow tests +2. **Test token expiration edge cases** — Expired tokens, invalid tokens, already-used tokens +3. **Verify cleanup on logout** — Check both cookies and localStorage are cleared +4. **Test the full flow end-to-end** — Password reset should verify login with new password works + +--- + +## Related + +- [authentication.md](authentication.md) — Storage state, OAuth mocking, MFA, role-based access, API login +- [fixtures-hooks.md](../core/fixtures-hooks.md) — Creating auth fixtures +- [third-party.md](./third-party.md) — Mocking external auth providers diff --git a/plugins/software-delivery/skills/playwright-best-practices/advanced/authentication.md b/plugins/software-delivery/skills/playwright-best-practices/advanced/authentication.md new file mode 100644 index 0000000..02c2dd7 --- /dev/null +++ b/plugins/software-delivery/skills/playwright-best-practices/advanced/authentication.md @@ -0,0 +1,871 @@ +# Authentication Testing + +## Table of Contents + +1. [Quick Reference](#quick-reference) +2. [Patterns](#patterns) +3. [Decision Guide](#decision-guide) +4. [Anti-Patterns](#anti-patterns) +5. [Troubleshooting](#troubleshooting) +6. [Related](#related) + +> **When to use**: Apps with login, session management, or protected routes. Authentication is the most common source of slow test suites. + +## Quick Reference + +```typescript +// Storage state reuse — the #1 pattern for fast auth +await page.goto("/login"); +await page.getByLabel("Username").fill("testuser@example.com"); +await page.getByLabel("Password").fill("secretPass123"); +await page.getByRole("button", { name: "Log in" }).click(); +await page.context().storageState({ path: ".auth/session.json" }); + +// Reuse in config — every test starts authenticated +{ + use: { + storageState: ".auth/session.json" + } +} + +// API login — skip the UI entirely +const context = await browser.newContext(); +const response = await context.request.post("/api/auth/login", { + data: { email: "testuser@example.com", password: "secretPass123" }, +}); +await context.storageState({ path: ".auth/session.json" }); +``` + +## Patterns + +### Storage State Reuse + +**Use when**: You need authenticated tests and want to avoid logging in before every test. +**Avoid when**: Tests require completely fresh sessions, or you are testing the login flow itself. + +`storageState` serializes cookies and localStorage to a JSON file. Load it in any browser context to start authenticated instantly. + +```typescript +// scripts/generate-auth.ts — run once to generate the state file +import { chromium } from "@playwright/test"; + +async function generateAuthState() { + const browser = await chromium.launch(); + const context = await browser.newContext(); + const page = await context.newPage(); + + await page.goto("http://localhost:4000/login"); + await page.getByLabel("Username").fill("testuser@example.com"); + await page.getByLabel("Password").fill("secretPass123"); + await page.getByRole("button", { name: "Log in" }).click(); + await page.waitForURL("/home"); + + await context.storageState({ path: ".auth/session.json" }); + await browser.close(); +} + +generateAuthState(); +``` + +```typescript +// playwright.config.ts — load saved state for all tests +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + use: { + baseURL: "http://localhost:4000", + storageState: ".auth/session.json", + }, +}); +``` + +```typescript +// tests/home.spec.ts — test starts already logged in +import { test, expect } from "@playwright/test"; + +test("authenticated user sees home page", async ({ page }) => { + await page.goto("/home"); + await expect(page.getByRole("heading", { name: "Home" })).toBeVisible(); +}); +``` + +### Global Setup Authentication + +**Use when**: You want to authenticate once before the entire test suite runs. +**Avoid when**: Different tests need different users, or your tokens expire faster than your suite runs. + +```typescript +// global-setup.ts +import { chromium, type FullConfig } from "@playwright/test"; + +async function globalSetup(config: FullConfig) { + const { baseURL } = config.projects[0].use; + const browser = await chromium.launch(); + const context = await browser.newContext(); + const page = await context.newPage(); + + await page.goto(`${baseURL}/login`); + await page.getByLabel("Username").fill(process.env.TEST_USER_EMAIL!); + await page.getByLabel("Password").fill(process.env.TEST_USER_PASSWORD!); + await page.getByRole("button", { name: "Log in" }).click(); + await page.waitForURL("**/home"); + + await context.storageState({ path: ".auth/session.json" }); + await browser.close(); +} + +export default globalSetup; +``` + +```typescript +// playwright.config.ts +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + globalSetup: require.resolve("./global-setup"), + use: { + baseURL: "http://localhost:4000", + storageState: ".auth/session.json", + }, +}); +``` + +Add `.auth/` to `.gitignore`. Auth state files contain session tokens and should never be committed. + +### Per-Worker Authentication + +**Use when**: Each parallel worker needs its own authenticated session to avoid race conditions for tests that modify server-side state. +**Avoid when**: Tests are read-only and a modifying shared session is safe, you can use a single shared account. + +> **Sharded runs**: `parallelIndex` resets per shard, so different shards can have workers with the same index. To avoid collisions, include the shard identifier in the username (e.g., `worker-${SHARD_INDEX}-${parallelIndex}@example.com`) by passing a `SHARD_INDEX` environment variable from your CI matrix. + +```typescript +// fixtures/auth.ts +import { test as base, type BrowserContext } from "@playwright/test"; + +type AuthFixtures = { + authenticatedContext: BrowserContext; +}; + +export const test = base.extend<{}, AuthFixtures>({ + authenticatedContext: [ + async ({ browser }, use) => { + const context = await browser.newContext(); + const page = await context.newPage(); + + await page.goto("/login"); + await page + .getByLabel("Username") + .fill(`worker-${test.info().parallelIndex}@example.com`); + await page.getByLabel("Password").fill("secretPass123"); + await page.getByRole("button", { name: "Log in" }).click(); + await page.waitForURL("/home"); + await page.close(); + + await use(context); + await context.close(); + }, + { scope: "worker" }, + ], +}); + +export { expect } from "@playwright/test"; +``` + +```typescript +// tests/settings.spec.ts +import { test, expect } from "../fixtures/auth"; + +test("update display name", async ({ authenticatedContext }) => { + const page = await authenticatedContext.newPage(); + await page.goto("/settings/profile"); + await page.getByLabel("Display name").fill("Updated Name"); + await page.getByRole("button", { name: "Save" }).click(); + await expect(page.getByText("Profile saved")).toBeVisible(); +}); +``` + +### Multiple Roles + +**Use when**: Your app has role-based access control and you need to test different permission levels. +**Avoid when**: Your app has a single user role. + +```typescript +// global-setup.ts — authenticate all roles +import { chromium, type FullConfig } from "@playwright/test"; + +const accounts = [ + { + role: "admin", + email: "admin@example.com", + password: process.env.ADMIN_PASSWORD!, + }, + { + role: "member", + email: "member@example.com", + password: process.env.MEMBER_PASSWORD!, + }, + { + role: "guest", + email: "guest@example.com", + password: process.env.GUEST_PASSWORD!, + }, +]; + +async function globalSetup(config: FullConfig) { + const { baseURL } = config.projects[0].use; + + for (const { role, email, password } of accounts) { + const browser = await chromium.launch(); + const context = await browser.newContext(); + const page = await context.newPage(); + + await page.goto(`${baseURL}/login`); + await page.getByLabel("Username").fill(email); + await page.getByLabel("Password").fill(password); + await page.getByRole("button", { name: "Log in" }).click(); + await page.waitForURL("**/home"); + + await context.storageState({ path: `.auth/${role}.json` }); + await browser.close(); + } +} + +export default globalSetup; +``` + +```typescript +// playwright.config.ts — one project per role +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + globalSetup: require.resolve("./global-setup"), + projects: [ + { + name: "admin", + use: { storageState: ".auth/admin.json" }, + testMatch: "**/*.admin.spec.ts", + }, + { + name: "member", + use: { storageState: ".auth/member.json" }, + testMatch: "**/*.member.spec.ts", + }, + { + name: "guest", + use: { storageState: ".auth/guest.json" }, + testMatch: "**/*.guest.spec.ts", + }, + { + name: "anonymous", + use: { storageState: { cookies: [], origins: [] } }, + testMatch: "**/*.anon.spec.ts", + }, + ], +}); +``` + +```typescript +// tests/admin-panel.admin.spec.ts +import { test, expect } from "@playwright/test"; + +test("admin can access user management", async ({ page }) => { + await page.goto("/admin/users"); + await expect( + page.getByRole("heading", { name: "User Management" }) + ).toBeVisible(); + await expect(page.getByRole("button", { name: "Remove user" })).toBeEnabled(); +}); +``` + +```typescript +// tests/admin-panel.guest.spec.ts +import { test, expect } from "@playwright/test"; + +test("guest cannot access admin panel", async ({ page }) => { + await page.goto("/admin/users"); + await expect(page.getByText("Access denied")).toBeVisible(); +}); +``` + +**Alternative**: Use a fixture that accepts a role parameter when you need role switching within a single spec file. + +```typescript +// fixtures/auth.ts — role-based fixture +import { test as base, type Page } from "@playwright/test"; +import fs from "fs"; + +type RoleFixtures = { + loginAs: (role: "admin" | "member" | "guest") => Promise; +}; + +export const test = base.extend({ + loginAs: async ({ browser }, use) => { + const pages: Page[] = []; + + await use(async (role) => { + const statePath = `.auth/${role}.json`; + if (!fs.existsSync(statePath)) { + throw new Error( + `Auth state for role "${role}" not found at ${statePath}` + ); + } + const context = await browser.newContext({ storageState: statePath }); + const page = await context.newPage(); + pages.push(page); + return page; + }); + + for (const page of pages) { + await page.context().close(); + } + }, +}); + +export { expect } from "@playwright/test"; +``` + +```typescript +// tests/role-comparison.spec.ts +import { test, expect } from "../fixtures/auth"; + +test("admin sees remove button, guest does not", async ({ loginAs }) => { + const adminPage = await loginAs("admin"); + await adminPage.goto("/admin/users"); + await expect( + adminPage.getByRole("button", { name: "Remove user" }) + ).toBeVisible(); + + const guestPage = await loginAs("guest"); + await guestPage.goto("/admin/users"); + await expect(guestPage.getByText("Access denied")).toBeVisible(); +}); +``` + +### OAuth/SSO Mocking + +**Use when**: Your app authenticates via a third-party OAuth provider and you cannot hit the real provider in tests. +**Avoid when**: You have a dedicated test tenant on the OAuth provider. + +A typical OAuth flow works like this: + +1. User clicks "Sign in with Provider" → browser navigates to `https://accounts.provider.com/authorize?...` +2. User authenticates on the provider's page → provider redirects back to your app's **callback route** (e.g. `http://localhost:4000/auth/callback?code=ABC&state=XYZ`) +3. Your backend exchanges the `code` for an access token, creates a session, and redirects the user to a logged-in page + +In tests you can short-circuit step 2 with `page.route()`: intercept the outbound request to the provider and respond with a `302` redirect straight to your callback route, supplying a mock `code` and `state`. Your backend still executes its normal callback handler — the only part that's mocked is the provider's authorization page. + +For cases where you want to skip the browser redirect entirely, a second approach calls a **test-only API endpoint** that creates the session server-side and returns the session cookie directly. + +```typescript +// tests/oauth-login.spec.ts — mock the callback route +import { test, expect } from "@playwright/test"; + +test("login via mocked OAuth flow", async ({ page }) => { + await page.route("https://accounts.provider.com/**", async (route) => { + const callbackUrl = new URL("http://localhost:4000/auth/callback"); + callbackUrl.searchParams.set("code", "mock-auth-code-xyz"); + callbackUrl.searchParams.set("state", "expected-state-value"); + await route.fulfill({ + status: 302, + headers: { location: callbackUrl.toString() }, + }); + }); + + await page.goto("/login"); + await page.getByRole("button", { name: "Sign in with Provider" }).click(); + + await page.waitForURL("/home"); + await expect(page.getByRole("heading", { name: "Home" })).toBeVisible(); +}); +``` + +```typescript +// tests/oauth-login.spec.ts — API-based session injection +import { test, expect } from "@playwright/test"; + +test("bypass OAuth entirely via API session injection", async ({ + page, +}) => { + // Call a test-only endpoint that creates a session without OAuth + const response = await page.request.post("/api/test/create-session", { + data: { + email: "oauth-user@example.com", + provider: "provider", + role: "member", + }, + }); + expect(response.ok()).toBeTruthy(); + + await page.context().storageState({ path: ".auth/oauth-user.json" }); + await page.goto("/home"); + await expect(page.getByRole("heading", { name: "Home" })).toBeVisible(); +}); +``` + +**Backend requirement**: Your backend must expose a test-only session creation endpoint (guarded by `NODE_ENV=test`) or accept a known test OAuth code. + +### MFA Handling + +**Use when**: Your app requires two-factor authentication (TOTP, SMS, email codes). +**Avoid when**: MFA is optional and you can disable it for test accounts. + +**Strategy 1**: Generate real TOTP codes from a shared secret. + +```typescript +// helpers/totp.ts +import * as OTPAuth from "otpauth"; + +export function generateTOTP(secret: string): string { + const totp = new OTPAuth.TOTP({ + secret: OTPAuth.Secret.fromBase32(secret), + digits: 6, + period: 30, + algorithm: "SHA1", + }); + return totp.generate(); +} +``` + +```typescript +// tests/mfa-login.spec.ts +import { test, expect } from "@playwright/test"; +import { generateTOTP } from "../helpers/totp"; + +test("login with TOTP two-factor auth", async ({ page }) => { + await page.goto("/login"); + await page.getByLabel("Username").fill("mfa-user@example.com"); + await page.getByLabel("Password").fill("secretPass123"); + await page.getByRole("button", { name: "Log in" }).click(); + + await expect(page.getByText("Enter your authentication code")).toBeVisible(); + + const code = generateTOTP(process.env.MFA_TOTP_SECRET!); + await page.getByLabel("Authentication code").fill(code); + await page.getByRole("button", { name: "Verify" }).click(); + + await page.waitForURL("/home"); + await expect(page.getByRole("heading", { name: "Home" })).toBeVisible(); +}); +``` + +**Strategy 2**: Mock MFA at the backend level. Have your backend accept a known bypass code (e.g., `000000`) when `NODE_ENV=test`. + +**Strategy 3**: Disable MFA for test accounts at the infrastructure level. + +### Session Refresh + +**Use when**: Your tokens expire during long test runs. +**Avoid when**: Your test suite runs quickly and tokens outlast the entire run. + +```typescript +// fixtures/auth-with-refresh.ts +import { test as base, type BrowserContext } from "@playwright/test"; +import fs from "fs"; + +type AuthFixtures = { + authenticatedPage: import("@playwright/test").Page; +}; + +export const test = base.extend({ + authenticatedPage: async ({ browser }, use) => { + const statePath = ".auth/session.json"; + + let context: BrowserContext; + if (fs.existsSync(statePath)) { + context = await browser.newContext({ storageState: statePath }); + const page = await context.newPage(); + + const response = await page.request.get("/api/auth/me"); + if (response.ok()) { + await use(page); + await context.close(); + return; + } + await context.close(); + } + + context = await browser.newContext(); + const page = await context.newPage(); + await page.goto("/login"); + await page.getByLabel("Username").fill(process.env.TEST_USER_EMAIL!); + await page.getByLabel("Password").fill(process.env.TEST_USER_PASSWORD!); + await page.getByRole("button", { name: "Log in" }).click(); + await page.waitForURL("/home"); + + await context.storageState({ path: statePath }); + + await use(page); + await context.close(); + }, +}); + +export { expect } from "@playwright/test"; +``` + +### Login Page Object + +**Use when**: Multiple test files need to log in and you want consistent, maintainable login logic. +**Avoid when**: You use `storageState` everywhere and never navigate through the login UI in tests. + +```typescript +// page-objects/LoginPage.ts +import { type Page, type Locator, expect } from "@playwright/test"; + +export class LoginPage { + readonly page: Page; + readonly usernameInput: Locator; + readonly passwordInput: Locator; + readonly loginButton: Locator; + readonly errorMessage: Locator; + readonly forgotPasswordLink: Locator; + + constructor(page: Page) { + this.page = page; + this.usernameInput = page.getByLabel("Username"); + this.passwordInput = page.getByLabel("Password"); + this.loginButton = page.getByRole("button", { name: "Log in" }); + this.errorMessage = page.getByRole("alert"); + this.forgotPasswordLink = page.getByRole("link", { + name: "Forgot password", + }); + } + + async goto() { + await this.page.goto("/login"); + await expect(this.loginButton).toBeVisible(); + } + + async login(username: string, password: string) { + await this.usernameInput.fill(username); + await this.passwordInput.fill(password); + await this.loginButton.click(); + } + + async loginAndWaitForHome(username: string, password: string) { + await this.login(username, password); + await this.page.waitForURL("/home"); + } + + async expectError(message: string | RegExp) { + await expect(this.errorMessage).toContainText(message); + } + + async expectFieldError(field: "username" | "password", message: string) { + const input = + field === "username" ? this.usernameInput : this.passwordInput; + await expect(input).toHaveAttribute("aria-invalid", "true"); + const errorId = await input.getAttribute("aria-describedby"); + if (errorId) { + await expect(this.page.locator(`#${errorId}`)).toContainText(message); + } + } +} +``` + +```typescript +// tests/login.spec.ts +import { test, expect } from "@playwright/test"; +import { LoginPage } from "../page-objects/LoginPage"; + +test.use({ storageState: { cookies: [], origins: [] } }); + +test.describe("login page", () => { + let loginPage: LoginPage; + + test.beforeEach(async ({ page }) => { + loginPage = new LoginPage(page); + await loginPage.goto(); + }); + + test("successful login redirects to home", async ({ page }) => { + await loginPage.loginAndWaitForHome( + "testuser@example.com", + "secretPass123" + ); + await expect(page.getByRole("heading", { name: "Home" })).toBeVisible(); + }); + + test("wrong password shows error", async () => { + await loginPage.login("testuser@example.com", "wrong-password"); + await loginPage.expectError("Invalid username or password"); + }); + + test("empty fields show validation errors", async () => { + await loginPage.loginButton.click(); + await loginPage.expectFieldError("username", "Username is required"); + }); + + test("forgot password link navigates correctly", async ({ page }) => { + await loginPage.forgotPasswordLink.click(); + await page.waitForURL("/forgot-password"); + await expect( + page.getByRole("heading", { name: "Reset password" }) + ).toBeVisible(); + }); +}); +``` + +### API-Based Login + +**Use when**: You want the fastest possible authentication without any browser interaction. +**Avoid when**: You are specifically testing the login UI. + +API login is typically 5-10x faster than UI login. + +```typescript +// global-setup.ts — API-based login (fastest) +import { request, type FullConfig } from "@playwright/test"; + +async function globalSetup(config: FullConfig) { + const { baseURL } = config.projects[0].use; + + const requestContext = await request.newContext({ baseURL }); + + const response = await requestContext.post("/api/auth/login", { + data: { + email: process.env.TEST_USER_EMAIL!, + password: process.env.TEST_USER_PASSWORD!, + }, + }); + + if (!response.ok()) { + throw new Error( + `API login failed: ${response.status()} ${await response.text()}` + ); + } + + await requestContext.storageState({ path: ".auth/session.json" }); + await requestContext.dispose(); +} + +export default globalSetup; +``` + +```typescript +// fixtures/api-auth.ts — fixture version for per-test authentication +import { test as base } from "@playwright/test"; + +export const test = base.extend({ + authenticatedPage: async ({ browser, playwright }, use) => { + const apiContext = await playwright.request.newContext({ + baseURL: "http://localhost:4000", + }); + + await apiContext.post("/api/auth/login", { + data: { + email: "testuser@example.com", + password: "secretPass123", + }, + }); + + const state = await apiContext.storageState(); + const context = await browser.newContext({ storageState: state }); + const page = await context.newPage(); + + await use(page); + + await context.close(); + await apiContext.dispose(); + }, +}); + +export { expect } from "@playwright/test"; +``` + +### Unauthenticated Tests + +**Use when**: Testing the login page, signup flow, password reset, public pages, or redirect behavior for unauthenticated users. +**Avoid when**: The test requires a logged-in user. + +When your config sets a default `storageState`, you must explicitly clear it for unauthenticated tests. + +```typescript +// tests/public-pages.spec.ts +import { test, expect } from "@playwright/test"; + +test.use({ storageState: { cookies: [], origins: [] } }); + +test.describe("unauthenticated access", () => { + test("homepage is accessible without login", async ({ page }) => { + await page.goto("/"); + await expect(page.getByRole("heading", { name: "Welcome" })).toBeVisible(); + await expect(page.getByRole("link", { name: "Log in" })).toBeVisible(); + }); + + test("protected route redirects to login", async ({ page }) => { + await page.goto("/home"); + await page.waitForURL("**/login**"); + expect(page.url()).toContain("redirect=%2Fhome"); + }); + + test("expired session shows re-login prompt", async ({ page, context }) => { + await page.goto("/home"); + await context.clearCookies(); + + await page.goto("/settings"); + await page.waitForURL("**/login**"); + await expect(page.getByText("Your session has expired")).toBeVisible(); + }); + + test("signup flow creates account", async ({ page }) => { + await page.goto("/signup"); + await page.getByLabel("Name").fill("New User"); + await page.getByLabel("Email").fill(`test-${Date.now()}@example.com`); + await page.getByLabel("Password", { exact: true }).fill("secretPass123"); + await page.getByLabel("Confirm password").fill("secretPass123"); + await page.getByRole("button", { name: "Create account" }).click(); + + await page.waitForURL("/onboarding"); + await expect(page.getByText("Welcome, New User")).toBeVisible(); + }); +}); +``` + +## Decision Guide + +| Scenario | Approach | Speed | Isolation | When to Choose | +| -------------------------------- | ------------------------------ | -------- | -------------- | -------------------------------------------------------------- | +| Most tests need auth | Global setup + `storageState` | Fastest | Shared session | Default for nearly every project | +| Tests modify user state | Per-worker fixture | Fast | Per worker | Tests update profile, change settings, or mutate data | +| Multiple user roles | Per-project `storageState` | Fastest | Per role | App has admin/member/guest roles | +| Testing the login page | No `storageState` | N/A | Full | Use `test.use({ storageState: { cookies: [], origins: [] } })` | +| OAuth/SSO provider | Mock the callback | Fast | Per test | Never hit real OAuth providers in CI | +| MFA is required | TOTP generation or bypass | Moderate | Per test | Generate real TOTP codes or use a test-mode bypass | +| Token expires mid-suite | Session refresh fixture | Fast | Per check | Fixture validates the session before use | +| Single test needs different user | `loginAs(role)` fixture | Moderate | Per call | Rare: prefer per-project roles | +| API-first app (no login UI) | API login via `request.post()` | Fastest | Per test | No browser needed for auth | + +### UI Login vs API Login vs Storage State + +```text +Need to test the login page itself? +├── Yes → UI login with LoginPage POM, no storageState +└── No → Do you have a login API endpoint? + ├── Yes → API login in global setup, save storageState (fastest) + └── No → UI login in global setup, save storageState + └── Tokens expire quickly? + ├── Yes → Add session refresh fixture + └── No → Standard storageState reuse is fine +``` + +## Anti-Patterns + +| Don't Do This | Problem | Do This Instead | +| ------------------------------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------- | +| Log in via UI before every test | Adds 2-5 seconds per test | Use `storageState` to skip login entirely | +| Share a single auth state file across parallel workers that mutate state | Race conditions | Use per-worker fixtures with `{ scope: 'worker' }` | +| Hardcode credentials in test files | Security risk | Use environment variables and `.env` files | +| Ignore token expiration | Tests fail intermittently with 401 errors | Add a session validity check in your auth fixture | +| Hit real OAuth providers in CI | Flaky: rate limits, CAPTCHA, network issues | Mock the OAuth callback or use API session injection | +| Use `page.waitForTimeout(2000)` after login | Arbitrary delay | `await page.waitForURL('/home')` or `await expect(heading).toBeVisible()` | +| Store `.auth/*.json` files in git | Tokens in version control | Add `.auth/` to `.gitignore` | +| Create one "god" test account with all permissions | Cannot test role-based access control | Create separate accounts per role | +| Use `browser.newContext()` without `storageState` for authenticated tests | Every context starts unauthenticated | Pass `storageState` when creating the context | +| Test MFA by disabling it everywhere | You never test the MFA flow | Use TOTP generation for at least one test | + +## Troubleshooting + +### Global setup fails with "Target page, context or browser has been closed" + +**Cause**: The login page redirected unexpectedly, or the browser closed before `storageState()` was called. + +**Fix**: + +- Add `await page.waitForURL()` after the login action +- Check that `baseURL` in your config matches the actual server URL and protocol +- Add error handling to global setup: + +```typescript +const response = await page.waitForResponse("**/api/auth/**"); +if (!response.ok()) { + throw new Error( + `Login failed in global setup: ${response.status()} ${await response.text()}` + ); +} +``` + +### Tests fail with 401 Unauthorized after running for a while + +**Cause**: The session token saved in `storageState` has expired. + +**Fix**: + +- Use the session refresh fixture pattern +- Increase token expiry in test environment configuration +- Switch to API-based login in a worker-scoped fixture + +### `storageState` file is empty or contains no cookies + +**Cause**: `storageState()` was called before the login response set cookies. + +**Fix**: + +- Wait for the post-login page to load: `await page.waitForURL('/home')` +- Verify cookies exist before saving: + +```typescript +const cookies = await context.cookies(); +if (cookies.length === 0) { + throw new Error("No cookies found after login"); +} +await context.storageState({ path: ".auth/session.json" }); +``` + +### Different browsers get different cookies + +**Cause**: Some auth flows set cookies with `SameSite=Strict` or use browser-specific cookie behavior. + +**Fix**: + +- Generate separate auth state files per browser project +- Check if your auth uses `SameSite=None; Secure` cookies that require HTTPS: + +```typescript +projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'], storageState: '.auth/chromium-session.json' }, + }, + { + name: 'firefox', + use: { ...devices['Desktop Firefox'], storageState: '.auth/firefox-session.json' }, + }, +], +``` + +### Parallel tests interfere with each other's sessions + +**Cause**: Multiple workers share the same test account and one worker's actions affect others. + +**Fix**: + +- Use per-worker test accounts: `worker-${test.info().parallelIndex}@example.com` +- Use the per-worker authentication fixture pattern +- Make tests idempotent + +### OAuth mock does not work — still redirects to real provider + +**Cause**: `page.route()` was registered after the navigation that triggers the OAuth redirect. + +**Fix**: + +- Register route handlers before any navigation: call `page.route()` before `page.goto()` +- Log the actual redirect URL to verify the pattern: + +```typescript +page.on("request", (req) => { + if (req.url().includes("oauth") || req.url().includes("accounts.provider")) { + console.log("OAuth request:", req.url()); + } +}); +``` + +## Related + +- [fixtures-hooks.md](../core/fixtures-hooks.md) — custom fixtures for auth setup and teardown +- [configuration.md](../core/configuration.md) — `storageState`, projects, and global setup configuration +- [global-setup.md](../core/global-setup.md) — global setup patterns and project dependencies +- [network-advanced.md](network-advanced.md) — route interception patterns used in OAuth mocking +- [api-testing.md](../testing-patterns/api-testing.md) — API request context used in API-based login +- [flaky-tests.md](../debugging/flaky-tests.md) — diagnosing auth-related flakiness diff --git a/plugins/software-delivery/skills/playwright-best-practices/advanced/clock-mocking.md b/plugins/software-delivery/skills/playwright-best-practices/advanced/clock-mocking.md new file mode 100644 index 0000000..073d087 --- /dev/null +++ b/plugins/software-delivery/skills/playwright-best-practices/advanced/clock-mocking.md @@ -0,0 +1,364 @@ +# Date, Time & Clock Mocking + +## Table of Contents + +1. [Clock API Basics](#clock-api-basics) +2. [Fixed Time Testing](#fixed-time-testing) +3. [Time Advancement](#time-advancement) +4. [Timezone Testing](#timezone-testing) +5. [Timer Mocking](#timer-mocking) + +## Clock API Basics + +### Install Clock + +```typescript +test("mock current time", async ({ page }) => { + // Install clock before navigating + await page.clock.install({ time: new Date("2025-01-15T09:00:00") }); + + await page.goto("/dashboard"); + + // Page sees January 15, 2025 as current date + await expect(page.getByText("January 15, 2025")).toBeVisible(); +}); +``` + +### Clock with Fixture + +```typescript +// fixtures/clock.fixture.ts +import { test as base } from "@playwright/test"; + +type ClockFixtures = { + mockTime: (date: Date | string) => Promise; +}; + +export const test = base.extend({ + mockTime: async ({ page }, use) => { + await use(async (date) => { + const time = typeof date === "string" ? new Date(date) : date; + await page.clock.install({ time }); + }); + }, +}); + +// Usage +test("subscription expiry", async ({ page, mockTime }) => { + await mockTime("2025-12-31T23:59:00"); + await page.goto("/subscription"); + + await expect(page.getByText("Expires today")).toBeVisible(); +}); +``` + +## Fixed Time Testing + +### Test Date-Dependent Features + +```typescript +test("show holiday banner in December", async ({ page }) => { + await page.clock.install({ time: new Date("2025-12-20T10:00:00") }); + + await page.goto("/"); + + await expect(page.getByRole("banner", { name: /holiday/i })).toBeVisible(); +}); + +test("no holiday banner in January", async ({ page }) => { + await page.clock.install({ time: new Date("2025-01-15T10:00:00") }); + + await page.goto("/"); + + await expect(page.getByRole("banner", { name: /holiday/i })).toBeHidden(); +}); +``` + +### Test Relative Time Display + +```typescript +test("shows relative time correctly", async ({ page }) => { + // Fix time to control "posted 2 hours ago" text + await page.clock.install({ time: new Date("2025-06-15T14:00:00") }); + + // Mock API to return post with known timestamp + await page.route("**/api/posts/1", (route) => + route.fulfill({ + json: { + id: 1, + title: "Test Post", + createdAt: "2025-06-15T12:00:00Z", // 2 hours before mock time + }, + }), + ); + + await page.goto("/posts/1"); + + await expect(page.getByText("2 hours ago")).toBeVisible(); +}); +``` + +### Test Date Boundaries + +```typescript +test.describe("end of month billing", () => { + test("shows billing on last day of month", async ({ page }) => { + await page.clock.install({ time: new Date("2025-01-31T10:00:00") }); + await page.goto("/billing"); + + await expect(page.getByText("Payment due today")).toBeVisible(); + }); + + test("shows days remaining mid-month", async ({ page }) => { + await page.clock.install({ time: new Date("2025-01-15T10:00:00") }); + await page.goto("/billing"); + + await expect(page.getByText("16 days until payment")).toBeVisible(); + }); +}); +``` + +## Time Advancement + +### Advance Time Manually + +```typescript +test("session timeout warning", async ({ page }) => { + await page.clock.install({ time: new Date("2025-01-15T09:00:00") }); + await page.goto("/dashboard"); + + // Advance 25 minutes (session timeout at 30 min) + await page.clock.fastForward("25:00"); + + await expect(page.getByText("Session expires in 5 minutes")).toBeVisible(); + + // Advance 5 more minutes + await page.clock.fastForward("05:00"); + + await expect(page.getByText("Session expired")).toBeVisible(); +}); +``` + +### Pause and Resume Time + +```typescript +test("countdown timer", async ({ page }) => { + await page.clock.install({ time: new Date("2025-01-15T09:00:00") }); + await page.goto("/sale"); + + // Initial state + await expect(page.getByText("Sale ends in 2:00:00")).toBeVisible(); + + // Advance 1 hour + await page.clock.fastForward("01:00:00"); + + await expect(page.getByText("Sale ends in 1:00:00")).toBeVisible(); + + // Advance past end + await page.clock.fastForward("01:00:01"); + + await expect(page.getByText("Sale ended")).toBeVisible(); +}); +``` + +### Run Pending Timers + +```typescript +test("debounced search", async ({ page }) => { + await page.clock.install({ time: new Date("2025-01-15T09:00:00") }); + await page.goto("/search"); + + await page.getByLabel("Search").fill("playwright"); + + // Search is debounced by 300ms, won't fire yet + await expect(page.getByTestId("search-results")).toBeHidden(); + + // Fast forward past debounce + await page.clock.fastForward(300); + + // Now search should execute + await expect(page.getByTestId("search-results")).toBeVisible(); +}); +``` + +## Timezone Testing + +### Test Different Timezones + +```typescript +test.describe("timezone display", () => { + test("shows correct time in PST", async ({ browser }) => { + const context = await browser.newContext({ + timezoneId: "America/Los_Angeles", + }); + const page = await context.newPage(); + + await page.clock.install({ time: new Date("2025-01-15T17:00:00Z") }); // 5 PM UTC + + await page.goto("/schedule"); + + // Should show 9 AM PST + await expect(page.getByText("9:00 AM")).toBeVisible(); + + await context.close(); + }); + + test("shows correct time in JST", async ({ browser }) => { + const context = await browser.newContext({ + timezoneId: "Asia/Tokyo", + }); + const page = await context.newPage(); + + await page.clock.install({ time: new Date("2025-01-15T17:00:00Z") }); // 5 PM UTC + + await page.goto("/schedule"); + + // Should show 2 AM next day JST + await expect(page.getByText("2:00 AM")).toBeVisible(); + + await context.close(); + }); +}); +``` + +### Timezone Fixture + +```typescript +// fixtures/timezone.fixture.ts +import { test as base } from "@playwright/test"; + +type TimezoneFixtures = { + pageInTimezone: (timezone: string) => Promise; +}; + +export const test = base.extend({ + pageInTimezone: async ({ browser }, use) => { + const pages: Page[] = []; + + await use(async (timezone) => { + const context = await browser.newContext({ timezoneId: timezone }); + const page = await context.newPage(); + pages.push(page); + return page; + }); + + // Cleanup + for (const page of pages) { + await page.context().close(); + } + }, +}); +``` + +## Timer Mocking + +### Mock setInterval + +```typescript +test("auto-refresh data", async ({ page }) => { + await page.clock.install({ time: new Date("2025-01-15T09:00:00") }); + + let apiCalls = 0; + await page.route("**/api/data", (route) => { + apiCalls++; + route.fulfill({ json: { value: apiCalls } }); + }); + + await page.goto("/live-data"); // Sets up 30s refresh interval + + expect(apiCalls).toBe(1); // Initial load + + // Advance 30 seconds + await page.clock.fastForward("00:30"); + expect(apiCalls).toBe(2); // First refresh + + // Advance another 30 seconds + await page.clock.fastForward("00:30"); + expect(apiCalls).toBe(3); // Second refresh +}); +``` + +### Mock setTimeout Chains + +```typescript +test("notification queue", async ({ page }) => { + await page.clock.install({ time: new Date("2025-01-15T09:00:00") }); + await page.goto("/notifications"); + + // Trigger 3 notifications that show sequentially + await page.getByRole("button", { name: "Show All" }).click(); + + // First notification appears immediately + await expect(page.getByText("Notification 1")).toBeVisible(); + + // Second appears after 2 seconds + await page.clock.fastForward("00:02"); + await expect(page.getByText("Notification 2")).toBeVisible(); + + // Third appears after 2 more seconds + await page.clock.fastForward("00:02"); + await expect(page.getByText("Notification 3")).toBeVisible(); +}); +``` + +### Test Animation Frames + +```typescript +test("animation completes", async ({ page }) => { + await page.clock.install({ time: new Date("2025-01-15T09:00:00") }); + await page.goto("/animation-demo"); + + await page.getByRole("button", { name: "Animate" }).click(); + + // Animation runs for 500ms + const element = page.getByTestId("animated-box"); + await expect(element).toHaveCSS("opacity", "0"); + + // Fast forward through animation + await page.clock.fastForward(500); + + await expect(element).toHaveCSS("opacity", "1"); +}); +``` + +## Best Practices + +### Always Install Clock Before Navigation + +```typescript +// Good +test("date test", async ({ page }) => { + await page.clock.install({ time: new Date("2025-01-15") }); + await page.goto("/"); // Page loads with mocked time +}); + +// Bad - time already captured by page +test("date test", async ({ page }) => { + await page.goto("/"); + await page.clock.install({ time: new Date("2025-01-15") }); // Too late! +}); +``` + +### Use ISO Strings for Clarity + +```typescript +// Good - explicit timezone +await page.clock.install({ time: new Date("2025-01-15T09:00:00Z") }); + +// Ambiguous - uses local timezone +await page.clock.install({ time: new Date("2025-01-15T09:00:00") }); +``` + +## Anti-Patterns to Avoid + +| Anti-Pattern | Problem | Solution | +| ---------------------------------------- | ------------------------------- | -------------------------------------- | +| Installing clock after navigation | Page already captured real time | Install clock before `goto()` | +| Hardcoded relative dates | Tests break over time | Use fixed dates with clock mock | +| Not accounting for timezone | Tests fail in different regions | Use explicit UTC times or set timezone | +| Using `waitForTimeout` with mocked clock | Conflicts with mocked timers | Use `fastForward` instead | + +## Related References + +- **Assertions**: See [assertions-waiting.md](../core/assertions-waiting.md) for time-based assertions +- **Fixtures**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for clock fixtures diff --git a/plugins/software-delivery/skills/playwright-best-practices/advanced/mobile-testing.md b/plugins/software-delivery/skills/playwright-best-practices/advanced/mobile-testing.md new file mode 100644 index 0000000..e928bde --- /dev/null +++ b/plugins/software-delivery/skills/playwright-best-practices/advanced/mobile-testing.md @@ -0,0 +1,409 @@ +# Mobile & Responsive Testing + +## Table of Contents + +1. [Device Emulation](#device-emulation) +2. [Touch Gestures](#touch-gestures) +3. [Viewport Testing](#viewport-testing) +4. [Mobile-Specific UI](#mobile-specific-ui) +5. [Responsive Breakpoints](#responsive-breakpoints) + +## Device Emulation + +### Use Built-in Devices + +```typescript +import { test, devices } from "@playwright/test"; + +// Configure in playwright.config.ts +export default defineConfig({ + projects: [ + { name: "Desktop Chrome", use: { ...devices["Desktop Chrome"] } }, + { name: "Mobile Safari", use: { ...devices["iPhone 14"] } }, + { name: "Mobile Chrome", use: { ...devices["Pixel 7"] } }, + { name: "Tablet", use: { ...devices["iPad Pro 11"] } }, + ], +}); +``` + +### Custom Device Configuration + +```typescript +test.use({ + viewport: { width: 390, height: 844 }, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + userAgent: + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15", +}); + +test("custom mobile device", async ({ page }) => { + await page.goto("/"); + // Test runs with custom device settings +}); +``` + +### Test Across Multiple Devices + +```typescript +const mobileDevices = ["iPhone 14", "Pixel 7", "Galaxy S21"]; + +for (const deviceName of mobileDevices) { + test(`checkout on ${deviceName}`, async ({ browser }) => { + const device = devices[deviceName]; + const context = await browser.newContext({ ...device }); + const page = await context.newPage(); + + await page.goto("/checkout"); + await expect(page.getByRole("button", { name: "Pay" })).toBeVisible(); + + await context.close(); + }); +} +``` + +## Touch Gestures + +### Tap + +```typescript +test.use({ hasTouch: true }); + +test("tap to interact", async ({ page }) => { + await page.goto("/gallery"); + + // Tap is like click but for touch devices + await page.getByRole("img", { name: "Photo 1" }).tap(); + + await expect(page.getByRole("dialog")).toBeVisible(); +}); +``` + +### Swipe + +```typescript +test("swipe carousel", async ({ page }) => { + await page.goto("/carousel"); + + const carousel = page.getByTestId("carousel"); + const box = await carousel.boundingBox(); + + if (box) { + // Swipe left + await page.touchscreen.tap(box.x + box.width - 50, box.y + box.height / 2); + await page.mouse.move(box.x + 50, box.y + box.height / 2); + + // Or use drag + await carousel.dragTo(carousel, { + sourcePosition: { x: box.width - 50, y: box.height / 2 }, + targetPosition: { x: 50, y: box.height / 2 }, + }); + } + + await expect(page.getByText("Slide 2")).toBeVisible(); +}); +``` + +### Swipe Fixture + +```typescript +// fixtures/touch.fixture.ts +import { test as base, Page } from "@playwright/test"; + +type TouchFixtures = { + swipe: ( + element: Locator, + direction: "left" | "right" | "up" | "down", + ) => Promise; +}; + +export const test = base.extend({ + swipe: async ({ page }, use) => { + await use(async (element, direction) => { + const box = await element.boundingBox(); + if (!box) throw new Error("Element not visible"); + + const centerX = box.x + box.width / 2; + const centerY = box.y + box.height / 2; + const distance = 100; + + const moves = { + left: { + startX: centerX + distance, + endX: centerX - distance, + y: centerY, + }, + right: { + startX: centerX - distance, + endX: centerX + distance, + y: centerY, + }, + up: { + startX: centerX, + endX: centerX, + startY: centerY + distance, + endY: centerY - distance, + }, + down: { + startX: centerX, + endX: centerX, + startY: centerY - distance, + endY: centerY + distance, + }, + }; + + const move = moves[direction]; + await page.touchscreen.tap(move.startX, move.startY ?? move.y); + await page.mouse.move(move.endX, move.endY ?? move.y, { steps: 10 }); + await page.mouse.up(); + }); + }, +}); + +// Usage +test("swipe to delete", async ({ page, swipe }) => { + await page.goto("/inbox"); + + const message = page.getByTestId("message-1"); + await swipe(message, "left"); + + await expect(page.getByRole("button", { name: "Delete" })).toBeVisible(); +}); +``` + +### Long Press + +```typescript +test("long press for context menu", async ({ page }) => { + await page.goto("/files"); + + const file = page.getByText("document.pdf"); + const box = await file.boundingBox(); + + if (box) { + // Touch down + await page.touchscreen.tap(box.x + box.width / 2, box.y + box.height / 2); + + // Hold for 500ms + await page.waitForTimeout(500); + + // Context menu should appear + await expect(page.getByRole("menu")).toBeVisible(); + } +}); +``` + +### Pinch Zoom + +```typescript +test("pinch to zoom image", async ({ page }) => { + await page.goto("/map"); + + // Pinch zoom requires two touch points + // Playwright doesn't have native pinch support, so we simulate via evaluate + await page.evaluate(() => { + const element = document.querySelector("#map"); + if (element) { + // Simulate wheel event as fallback for zoom + element.dispatchEvent( + new WheelEvent("wheel", { + deltaY: -100, // Negative = zoom in + ctrlKey: true, // Ctrl+wheel = pinch on many apps + }), + ); + } + }); + + // Or trigger the app's zoom function directly + await page.evaluate(() => { + (window as any).mapInstance?.setZoom(15); + }); +}); +``` + +## Viewport Testing + +### Test Different Sizes + +```typescript +const viewports = [ + { name: "mobile", width: 375, height: 667 }, + { name: "tablet", width: 768, height: 1024 }, + { name: "desktop", width: 1920, height: 1080 }, +]; + +for (const { name, width, height } of viewports) { + test(`navigation on ${name}`, async ({ page }) => { + await page.setViewportSize({ width, height }); + await page.goto("/"); + + if (width < 768) { + // Mobile: should have hamburger menu + await expect(page.getByRole("button", { name: "Menu" })).toBeVisible(); + } else { + // Desktop: should have visible nav links + await expect(page.getByRole("link", { name: "Products" })).toBeVisible(); + } + }); +} +``` + +### Dynamic Viewport Changes + +```typescript +test("responsive layout change", async ({ page }) => { + await page.setViewportSize({ width: 1200, height: 800 }); + await page.goto("/dashboard"); + + // Desktop: sidebar visible + await expect(page.getByRole("complementary")).toBeVisible(); + + // Resize to mobile + await page.setViewportSize({ width: 375, height: 667 }); + + // Mobile: sidebar hidden, hamburger visible + await expect(page.getByRole("complementary")).toBeHidden(); + await expect(page.getByRole("button", { name: "Menu" })).toBeVisible(); +}); +``` + +## Mobile-Specific UI + +### Hamburger Menu + +```typescript +test("mobile navigation", async ({ page }) => { + await page.setViewportSize({ width: 375, height: 667 }); + await page.goto("/"); + + // Open hamburger menu + await page.getByRole("button", { name: "Menu" }).click(); + + // Navigation drawer should appear + const nav = page.getByRole("navigation"); + await expect(nav).toBeVisible(); + + // Navigate via mobile menu + await nav.getByRole("link", { name: "Products" }).click(); + + await expect(page).toHaveURL("/products"); + // Menu should close after navigation + await expect(nav).toBeHidden(); +}); +``` + +### Bottom Sheet + +```typescript +test("bottom sheet interaction", async ({ page }) => { + await page.setViewportSize({ width: 375, height: 667 }); + await page.goto("/product/123"); + + await page.getByRole("button", { name: "Add to Cart" }).click(); + + // Bottom sheet appears + const sheet = page.getByRole("dialog"); + await expect(sheet).toBeVisible(); + + // Select options + await sheet.getByRole("combobox", { name: "Size" }).selectOption("Large"); + await sheet.getByRole("button", { name: "Confirm" }).click(); + + await expect(page.getByText("Added to cart")).toBeVisible(); +}); +``` + +### Pull to Refresh + +```typescript +test("pull to refresh", async ({ page }) => { + await page.goto("/feed"); + + const feed = page.getByTestId("feed"); + const initialFirstItem = await feed.locator("> *").first().textContent(); + + // Simulate pull down + const box = await feed.boundingBox(); + if (box) { + await page.touchscreen.tap(box.x + box.width / 2, box.y + 50); + await page.mouse.move(box.x + box.width / 2, box.y + 200, { steps: 20 }); + await page.mouse.up(); + } + + // Wait for refresh + await expect(page.getByTestId("loading")).toBeVisible(); + await expect(page.getByTestId("loading")).toBeHidden(); + + // Content should be updated (in a real app) +}); +``` + +## Responsive Breakpoints + +### Test All Breakpoints + +```typescript +const breakpoints = { + xs: 320, + sm: 640, + md: 768, + lg: 1024, + xl: 1280, + "2xl": 1536, +}; + +test.describe("responsive header", () => { + for (const [name, width] of Object.entries(breakpoints)) { + test(`header at ${name} (${width}px)`, async ({ page }) => { + await page.setViewportSize({ width, height: 800 }); + await page.goto("/"); + + if (width < 768) { + await expect(page.getByTestId("mobile-menu-button")).toBeVisible(); + await expect(page.getByTestId("desktop-nav")).toBeHidden(); + } else { + await expect(page.getByTestId("mobile-menu-button")).toBeHidden(); + await expect(page.getByTestId("desktop-nav")).toBeVisible(); + } + }); + } +}); +``` + +### Visual Regression at Breakpoints + +```typescript +test.describe("visual regression", () => { + const sizes = [ + { width: 375, height: 667, name: "mobile" }, + { width: 768, height: 1024, name: "tablet" }, + { width: 1440, height: 900, name: "desktop" }, + ]; + + for (const { width, height, name } of sizes) { + test(`homepage at ${name}`, async ({ page }) => { + await page.setViewportSize({ width, height }); + await page.goto("/"); + + await expect(page).toHaveScreenshot(`homepage-${name}.png`); + }); + } +}); +``` + +## Anti-Patterns to Avoid + +| Anti-Pattern | Problem | Solution | +| --------------------------- | ------------------------- | -------------------------------- | +| Only testing one viewport | Misses responsive bugs | Test multiple breakpoints | +| Ignoring touch events | Features broken on mobile | Test tap, swipe, long press | +| Hardcoded viewport in tests | Can't test multiple sizes | Use `page.setViewportSize()` | +| Not testing orientation | Landscape bugs missed | Test both portrait and landscape | + +## Related References + +- **Visual Testing**: See [test-suite-structure.md](../core/test-suite-structure.md) for screenshot testing +- **Locators**: See [locators.md](../core/locators.md) for mobile-friendly selectors +- **Browser APIs**: See [browser-apis.md](../browser-apis/browser-apis.md) for permissions (camera, geolocation, notifications) +- **Canvas/Touch**: See [canvas-webgl.md](../testing-patterns/canvas-webgl.md) for touch gestures on canvas elements diff --git a/plugins/software-delivery/skills/playwright-best-practices/advanced/multi-context.md b/plugins/software-delivery/skills/playwright-best-practices/advanced/multi-context.md new file mode 100644 index 0000000..ed1cf8a --- /dev/null +++ b/plugins/software-delivery/skills/playwright-best-practices/advanced/multi-context.md @@ -0,0 +1,288 @@ +# Multi-Tab, Window & Popup Testing + +This file covers **single-user scenarios** with multiple browser tabs, windows, and popups. For **multi-user collaboration testing** (multiple users interacting simultaneously), see [multi-user.md](multi-user.md). + +## Table of Contents + +1. [Popup Handling](#popup-handling) +2. [New Tab Navigation](#new-tab-navigation) +3. [OAuth Flows](#oauth-flows) +4. [Multiple Windows](#multiple-windows) +5. [Tab Coordination](#tab-coordination) + +## Popup Handling + +### Basic Popup + +```typescript +test("handle popup window", async ({ page }) => { + await page.goto("/"); + + // Start waiting for popup before triggering it + const popupPromise = page.waitForEvent("popup"); + await page.getByRole("button", { name: "Open Support Chat" }).click(); + const popup = await popupPromise; + + // Wait for popup to load + await popup.waitForLoadState(); + + // Interact with popup + await popup.getByLabel("Message").fill("Need help"); + await popup.getByRole("button", { name: "Send" }).click(); + + await expect(popup.getByText("Message sent")).toBeVisible(); + + // Close popup + await popup.close(); +}); +``` + +### Popup with Authentication + +```typescript +test("popup login flow", async ({ page }) => { + await page.goto("/dashboard"); + + const popupPromise = page.waitForEvent("popup"); + await page.getByRole("button", { name: "Connect Account" }).click(); + const popup = await popupPromise; + + await popup.waitForLoadState(); + + // Complete login in popup + await popup.getByLabel("Email").fill("user@example.com"); + await popup.getByLabel("Password").fill("password123"); + await popup.getByRole("button", { name: "Log In" }).click(); + + // Popup should close automatically after auth + await popup.waitForEvent("close"); + + // Main page should reflect connected state + await expect(page.getByText("Account connected")).toBeVisible(); +}); +``` + +### Handle Blocked Popups + +```typescript +test("handle popup blocker", async ({ page }) => { + await page.goto("/share"); + + // Listen for console messages about blocked popup + page.on("console", (msg) => { + if (msg.text().includes("popup blocked")) { + console.log("Popup was blocked"); + } + }); + + const popupPromise = page.waitForEvent("popup").catch(() => null); + await page.getByRole("button", { name: "Share to Twitter" }).click(); + const popup = await popupPromise; + + if (!popup) { + // Popup blocked - app should show fallback + await expect(page.getByText("Copy share link instead")).toBeVisible(); + } +}); +``` + +## New Tab Navigation + +### Link Opens in New Tab + +```typescript +test("external link opens in new tab", async ({ page, context }) => { + await page.goto("/resources"); + + // Wait for new page in context + const pagePromise = context.waitForEvent("page"); + await page.getByRole("link", { name: "Documentation" }).click(); + const newPage = await pagePromise; + + await newPage.waitForLoadState(); + + expect(newPage.url()).toContain("docs.example.com"); + await expect(newPage.getByRole("heading", { level: 1 })).toBeVisible(); + + // Original page still there + expect(page.url()).toContain("/resources"); + + await newPage.close(); +}); +``` + +### Intercept New Tab + +```typescript +test("prevent new tab for testing", async ({ page }) => { + await page.goto("/links"); + + // Remove target="_blank" to keep navigation in same tab + await page.evaluate(() => { + document.querySelectorAll('a[target="_blank"]').forEach((a) => { + a.removeAttribute("target"); + }); + }); + + // Now link opens in same tab + await page.getByRole("link", { name: "External Site" }).click(); + + // Can test the destination page + await expect(page).toHaveURL(/external-site\.com/); +}); +``` + +## OAuth Flows + +### Google OAuth Popup + +```typescript +test("Google OAuth login", async ({ page }) => { + await page.goto("/login"); + + const popupPromise = page.waitForEvent("popup"); + await page.getByRole("button", { name: "Sign in with Google" }).click(); + const popup = await popupPromise; + + await popup.waitForLoadState(); + + // Handle Google's OAuth flow + await popup.getByLabel("Email or phone").fill("test@gmail.com"); + await popup.getByRole("button", { name: "Next" }).click(); + + await popup.getByLabel("Enter your password").fill("password"); + await popup.getByRole("button", { name: "Next" }).click(); + + // Wait for redirect back and popup close + await popup.waitForEvent("close"); + + // Verify logged in on main page + await expect(page.getByText("Welcome, Test User")).toBeVisible(); +}); +``` + +### Mock OAuth (Recommended) + +```typescript +test("mock OAuth flow", async ({ page, context }) => { + // Mock the OAuth callback instead of real flow + await page.route("**/auth/callback**", async (route) => { + // Simulate successful OAuth + const url = new URL(route.request().url()); + url.searchParams.set("code", "mock-auth-code"); + await route.fulfill({ + status: 302, + headers: { Location: "/dashboard" }, + }); + }); + + // Mock token exchange + await page.route("**/api/auth/token", (route) => + route.fulfill({ + json: { + access_token: "mock-token", + user: { name: "Test User", email: "test@example.com" }, + }, + }), + ); + + await page.goto("/login"); + await page.getByRole("button", { name: "Sign in with Google" }).click(); + + // Should redirect to dashboard without actual OAuth + await expect(page).toHaveURL("/dashboard"); + await expect(page.getByText("Welcome, Test User")).toBeVisible(); +}); +``` + +### OAuth Fixture + +> **For comprehensive OAuth mocking patterns** (fixtures, multiple providers, SAML SSO), see [third-party.md](third-party.md#oauthsso-mocking). This section focuses on popup window handling mechanics for OAuth flows. + +## Multiple Windows + +### Test Across Multiple Windows + +```typescript +test("sync between windows", async ({ context }) => { + // Open two pages + const page1 = await context.newPage(); + const page2 = await context.newPage(); + + await page1.goto("/dashboard"); + await page2.goto("/dashboard"); + + // Make change in first window + await page1.getByRole("button", { name: "Add Item" }).click(); + await page1.getByLabel("Name").fill("New Item"); + await page1.getByRole("button", { name: "Save" }).click(); + + // Should sync to second window (if app supports real-time sync) + await expect(page2.getByText("New Item")).toBeVisible({ timeout: 10000 }); +}); +``` + +### Different Users in Different Windows + +> **For multi-user collaboration patterns** (admin/user interactions, real-time collaboration, role-based testing, concurrent actions), see [multi-user.md](multi-user.md). This file focuses on single-user scenarios with multiple tabs/windows/popups. + +## Tab Coordination + +### Switch Between Tabs + +```typescript +test("manage multiple tabs", async ({ context }) => { + const page1 = await context.newPage(); + await page1.goto("/editor"); + + const page2 = await context.newPage(); + await page2.goto("/preview"); + + // Edit in first tab + await page1.bringToFront(); + await page1.getByLabel("Content").fill("Hello World"); + + // Check preview in second tab + await page2.bringToFront(); + await page2.reload(); // If preview needs refresh + await expect(page2.getByText("Hello World")).toBeVisible(); +}); +``` + +### Close All Tabs Except One + +```typescript +test("cleanup tabs after test", async ({ context }) => { + const mainPage = await context.newPage(); + await mainPage.goto("/"); + + // Open several popups during test + for (let i = 0; i < 3; i++) { + const popup = await context.newPage(); + await popup.goto(`/popup/${i}`); + } + + // Close all except main page + for (const page of context.pages()) { + if (page !== mainPage) { + await page.close(); + } + } + + expect(context.pages()).toHaveLength(1); +}); +``` + +## Anti-Patterns to Avoid + +| Anti-Pattern | Problem | Solution | +| ----------------------- | ------------------------------ | ------------------------------------------ | +| Not waiting for popup | Race condition | Use `waitForEvent("popup")` before trigger | +| Testing real OAuth | Slow, flaky, needs credentials | Mock OAuth endpoints | +| Assuming popup opens | May be blocked | Handle both open and blocked cases | +| Not closing extra pages | Resource leak | Close pages in cleanup | + +## Related References + +- **Authentication**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for auth patterns +- **Network**: See [network-advanced.md](network-advanced.md) for mocking OAuth diff --git a/plugins/software-delivery/skills/playwright-best-practices/advanced/multi-user.md b/plugins/software-delivery/skills/playwright-best-practices/advanced/multi-user.md new file mode 100644 index 0000000..301e55c --- /dev/null +++ b/plugins/software-delivery/skills/playwright-best-practices/advanced/multi-user.md @@ -0,0 +1,393 @@ +# Multi-User & Collaboration Testing + +## Table of Contents + +1. [Multiple Browser Contexts](#multiple-browser-contexts) +2. [Real-Time Collaboration](#real-time-collaboration) +3. [Role-Based Testing](#role-based-testing) +4. [Concurrent Actions](#concurrent-actions) +5. [Chat & Messaging](#chat--messaging) + +## Multiple Browser Contexts + +### Two Users in Same Test + +```typescript +test("two users see each other's changes", async ({ browser }) => { + // Create two isolated contexts (like two browsers) + const userAContext = await browser.newContext(); + const userBContext = await browser.newContext(); + + const userAPage = await userAContext.newPage(); + const userBPage = await userBContext.newPage(); + + // Both users go to the same document + await userAPage.goto("/doc/shared-123"); + await userBPage.goto("/doc/shared-123"); + + // User A types + await userAPage.getByLabel("Content").fill("Hello from User A"); + + // User B should see the change + await expect(userBPage.getByText("Hello from User A")).toBeVisible(); + + // Cleanup + await userAContext.close(); + await userBContext.close(); +}); +``` + +### Multiple Users with Auth States + +```typescript +test("admin and user interaction", async ({ browser }) => { + // Load different auth states + const adminContext = await browser.newContext({ + storageState: ".auth/admin.json", + }); + const userContext = await browser.newContext({ + storageState: ".auth/user.json", + }); + + const adminPage = await adminContext.newPage(); + const userPage = await userContext.newPage(); + + // User submits request + await userPage.goto("/support"); + await userPage.getByLabel("Message").fill("Need help!"); + await userPage.getByRole("button", { name: "Submit" }).click(); + + // Admin sees and responds + await adminPage.goto("/admin/tickets"); + await expect(adminPage.getByText("Need help!")).toBeVisible(); + await adminPage.getByRole("button", { name: "Reply" }).click(); + await adminPage.getByLabel("Response").fill("How can I help?"); + await adminPage.getByRole("button", { name: "Send" }).click(); + + // User sees response + await expect(userPage.getByText("How can I help?")).toBeVisible(); + + await adminContext.close(); + await userContext.close(); +}); +``` + +### Multi-User Fixture + +```typescript +// fixtures/multi-user.fixture.ts +import { test as base, Browser, BrowserContext, Page } from "@playwright/test"; + +type UserSession = { + context: BrowserContext; + page: Page; +}; + +type MultiUserFixtures = { + createUser: (authState?: string) => Promise; +}; + +export const test = base.extend({ + createUser: async ({ browser }, use) => { + const sessions: UserSession[] = []; + + await use(async (authState) => { + const context = await browser.newContext({ + storageState: authState, + }); + const page = await context.newPage(); + sessions.push({ context, page }); + return { context, page }; + }); + + // Cleanup all sessions + for (const session of sessions) { + await session.context.close(); + } + }, +}); + +// Usage +test("3 users collaborate", async ({ createUser }) => { + const alice = await createUser(".auth/alice.json"); + const bob = await createUser(".auth/bob.json"); + const charlie = await createUser(".auth/charlie.json"); + + // All navigate to same room + await alice.page.goto("/room/123"); + await bob.page.goto("/room/123"); + await charlie.page.goto("/room/123"); + + // Test interactions... +}); +``` + +## Real-Time Collaboration + +### Collaborative Document + +```typescript +test("real-time collaborative editing", async ({ browser }) => { + const user1 = await browser.newContext(); + const user2 = await browser.newContext(); + + const page1 = await user1.newPage(); + const page2 = await user2.newPage(); + + await page1.goto("/docs/shared"); + await page2.goto("/docs/shared"); + + // User 1 types at the beginning + const editor1 = page1.getByRole("textbox"); + await editor1.click(); + await editor1.press("Home"); + await editor1.type("User 1: "); + + // User 2 types at the end + const editor2 = page2.getByRole("textbox"); + await editor2.click(); + await editor2.press("End"); + await editor2.type(" - User 2"); + + // Both should see combined result + await expect(page1.getByRole("textbox")).toContainText("User 1:"); + await expect(page1.getByRole("textbox")).toContainText("- User 2"); + await expect(page2.getByRole("textbox")).toContainText("User 1:"); + await expect(page2.getByRole("textbox")).toContainText("- User 2"); + + await user1.close(); + await user2.close(); +}); +``` + +### Cursor Presence + +```typescript +test("shows other user cursors", async ({ browser }) => { + const ctx1 = await browser.newContext(); + const ctx2 = await browser.newContext(); + + const page1 = await ctx1.newPage(); + const page2 = await ctx2.newPage(); + + // Mock to identify users + await page1.route("**/api/me", (route) => + route.fulfill({ json: { id: "user-1", name: "Alice" } }), + ); + await page2.route("**/api/me", (route) => + route.fulfill({ json: { id: "user-2", name: "Bob" } }), + ); + + await page1.goto("/whiteboard/123"); + await page2.goto("/whiteboard/123"); + + // Move cursor on page1 + await page1.mouse.move(200, 200); + + // Page2 should see Alice's cursor + await expect(page2.getByTestId("cursor-user-1")).toBeVisible(); + await expect(page2.getByText("Alice")).toBeVisible(); + + await ctx1.close(); + await ctx2.close(); +}); +``` + +## Role-Based Testing + +### Test RBAC + +```typescript +const roles = [ + { role: "admin", canDelete: true, canEdit: true, canView: true }, + { role: "editor", canDelete: false, canEdit: true, canView: true }, + { role: "viewer", canDelete: false, canEdit: false, canView: true }, +]; + +for (const { role, canDelete, canEdit, canView } of roles) { + test(`${role} permissions`, async ({ browser }) => { + const context = await browser.newContext({ + storageState: `.auth/${role}.json`, + }); + const page = await context.newPage(); + + await page.goto("/document/123"); + + // Check view permission + if (canView) { + await expect(page.getByTestId("content")).toBeVisible(); + } else { + await expect(page.getByText("Access denied")).toBeVisible(); + } + + // Check edit permission + const editButton = page.getByRole("button", { name: "Edit" }); + if (canEdit) { + await expect(editButton).toBeEnabled(); + } else { + await expect(editButton).toBeDisabled(); + } + + // Check delete permission + const deleteButton = page.getByRole("button", { name: "Delete" }); + if (canDelete) { + await expect(deleteButton).toBeVisible(); + } else { + await expect(deleteButton).toBeHidden(); + } + + await context.close(); + }); +} +``` + +### Permission Escalation Test + +```typescript +test("cannot access admin routes as user", async ({ browser }) => { + const userContext = await browser.newContext({ + storageState: ".auth/user.json", + }); + const page = await userContext.newPage(); + + // Try to access admin page directly + await page.goto("/admin/users"); + + // Should redirect or show error + await expect(page).not.toHaveURL("/admin/users"); + await expect(page.getByText("Access denied")).toBeVisible(); + + await userContext.close(); +}); +``` + +## Concurrent Actions + +### Race Condition Testing + +```typescript +test("handles concurrent edits", async ({ browser }) => { + const ctx1 = await browser.newContext(); + const ctx2 = await browser.newContext(); + + const page1 = await ctx1.newPage(); + const page2 = await ctx2.newPage(); + + await page1.goto("/item/123"); + await page2.goto("/item/123"); + + // Both click edit at the same time + await Promise.all([ + page1.getByRole("button", { name: "Edit" }).click(), + page2.getByRole("button", { name: "Edit" }).click(), + ]); + + // Both try to save different values + await page1.getByLabel("Name").fill("Value from User 1"); + await page2.getByLabel("Name").fill("Value from User 2"); + + await Promise.all([ + page1.getByRole("button", { name: "Save" }).click(), + page2.getByRole("button", { name: "Save" }).click(), + ]); + + // One should succeed, one should get conflict error + const page1HasConflict = await page1.getByText("Conflict").isVisible(); + const page2HasConflict = await page2.getByText("Conflict").isVisible(); + + // Exactly one should have conflict + expect(page1HasConflict || page2HasConflict).toBe(true); + expect(page1HasConflict && page2HasConflict).toBe(false); + + await ctx1.close(); + await ctx2.close(); +}); +``` + +### Optimistic Locking Test + +```typescript +test("optimistic locking prevents overwrites", async ({ browser }) => { + const ctx1 = await browser.newContext(); + const ctx2 = await browser.newContext(); + + const page1 = await ctx1.newPage(); + const page2 = await ctx2.newPage(); + + // Both load the same version + await page1.goto("/record/123"); + await page2.goto("/record/123"); + + // User 1 edits and saves first + await page1.getByRole("button", { name: "Edit" }).click(); + await page1.getByLabel("Value").fill("Updated by User 1"); + await page1.getByRole("button", { name: "Save" }).click(); + await expect(page1.getByText("Saved")).toBeVisible(); + + // User 2 tries to save with stale version + await page2.getByRole("button", { name: "Edit" }).click(); + await page2.getByLabel("Value").fill("Updated by User 2"); + await page2.getByRole("button", { name: "Save" }).click(); + + // Should fail with version conflict + await expect(page2.getByText("Someone else modified this")).toBeVisible(); + await expect(page2.getByRole("button", { name: "Reload" })).toBeVisible(); + + await ctx1.close(); + await ctx2.close(); +}); +``` + +## Chat & Messaging + +### Real-Time Chat + +```typescript +test("chat messages sync between users", async ({ browser }) => { + const aliceCtx = await browser.newContext(); + const bobCtx = await browser.newContext(); + + const alicePage = await aliceCtx.newPage(); + const bobPage = await bobCtx.newPage(); + + // Setup user identities + await alicePage.route("**/api/me", (r) => + r.fulfill({ json: { name: "Alice" } }), + ); + await bobPage.route("**/api/me", (r) => r.fulfill({ json: { name: "Bob" } })); + + await alicePage.goto("/chat/room-1"); + await bobPage.goto("/chat/room-1"); + + // Alice sends message + await alicePage.getByLabel("Message").fill("Hi Bob!"); + await alicePage.getByRole("button", { name: "Send" }).click(); + + // Bob sees it + await expect(bobPage.getByText("Alice: Hi Bob!")).toBeVisible(); + + // Bob replies + await bobPage.getByLabel("Message").fill("Hey Alice!"); + await bobPage.getByRole("button", { name: "Send" }).click(); + + // Alice sees it + await expect(alicePage.getByText("Bob: Hey Alice!")).toBeVisible(); + + await aliceCtx.close(); + await bobCtx.close(); +}); +``` + +## Anti-Patterns to Avoid + +| Anti-Pattern | Problem | Solution | +| ----------------------------- | ----------------------------- | ---------------------------- | +| Sharing context between users | State leaks, not isolated | Create separate contexts | +| Not closing contexts | Memory leak, browser overload | Always close in cleanup | +| Hardcoded timing for sync | Flaky tests | Use `expect().toBeVisible()` | +| Testing only single user | Misses collaboration bugs | Test multi-user scenarios | + +## Related References + +- **Authentication**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for auth setup +- **WebSockets**: See [websockets.md](../browser-apis/websockets.md) for real-time mocking diff --git a/plugins/software-delivery/skills/playwright-best-practices/advanced/network-advanced.md b/plugins/software-delivery/skills/playwright-best-practices/advanced/network-advanced.md new file mode 100644 index 0000000..fa017fe --- /dev/null +++ b/plugins/software-delivery/skills/playwright-best-practices/advanced/network-advanced.md @@ -0,0 +1,452 @@ +# Advanced Network Interception + +## Table of Contents + +1. [Request Modification](#request-modification) +2. [GraphQL Mocking](#graphql-mocking) +3. [HAR Recording & Playback](#har-recording--playback) +4. [Conditional Mocking](#conditional-mocking) +5. [Network Throttling](#network-throttling) + +## Request Modification + +### Modify Request Headers + +```typescript +test("add auth header to requests", async ({ page }) => { + await page.route("**/api/**", (route) => { + const headers = { + ...route.request().headers(), + Authorization: "Bearer test-token", + "X-Test-Header": "test-value", + }; + route.continue({ headers }); + }); + + await page.goto("/dashboard"); +}); +``` + +### Modify Request Body + +```typescript +test("modify POST body", async ({ page }) => { + await page.route("**/api/orders", async (route) => { + if (route.request().method() === "POST") { + const postData = route.request().postDataJSON(); + + // Add test metadata + const modifiedData = { + ...postData, + testMode: true, + testTimestamp: Date.now(), + }; + + await route.continue({ + postData: JSON.stringify(modifiedData), + }); + } else { + await route.continue(); + } + }); + + await page.goto("/checkout"); + await page.getByRole("button", { name: "Place Order" }).click(); +}); +``` + +### Transform Response + +```typescript +test("modify API response", async ({ page }) => { + await page.route("**/api/products", async (route) => { + // Fetch real response + const response = await route.fetch(); + const json = await response.json(); + + // Modify response + const modified = json.map((product: any) => ({ + ...product, + price: product.price * 0.9, // 10% discount + testMode: true, + })); + + await route.fulfill({ + response, + json: modified, + }); + }); + + await page.goto("/products"); +}); +``` + +## GraphQL Mocking + +### Mock by Operation Name + +```typescript +test("mock GraphQL query", async ({ page }) => { + await page.route("**/graphql", async (route) => { + const postData = route.request().postDataJSON(); + + if (postData.operationName === "GetUser") { + return route.fulfill({ + json: { + data: { + user: { + id: "1", + name: "Test User", + email: "test@example.com", + }, + }, + }, + }); + } + + if (postData.operationName === "GetProducts") { + return route.fulfill({ + json: { + data: { + products: [ + { id: "1", name: "Product A", price: 29.99 }, + { id: "2", name: "Product B", price: 49.99 }, + ], + }, + }, + }); + } + + // Pass through unmocked operations + return route.continue(); + }); + + await page.goto("/dashboard"); +}); +``` + +### GraphQL Mock Fixture + +```typescript +// fixtures/graphql.fixture.ts +type GraphQLMock = { + operation: string; + variables?: Record; + response: { data?: any; errors?: any[] }; +}; + +type GraphQLFixtures = { + mockGraphQL: (mocks: GraphQLMock[]) => Promise; +}; + +export const test = base.extend({ + mockGraphQL: async ({ page }, use) => { + await use(async (mocks) => { + await page.route("**/graphql", async (route) => { + const postData = route.request().postDataJSON(); + + const mock = mocks.find((m) => { + if (m.operation !== postData.operationName) return false; + + // Optionally match variables + if (m.variables) { + return ( + JSON.stringify(m.variables) === JSON.stringify(postData.variables) + ); + } + return true; + }); + + if (mock) { + return route.fulfill({ json: mock.response }); + } + + return route.continue(); + }); + }); + }, +}); + +// Usage +test("dashboard with mocked GraphQL", async ({ page, mockGraphQL }) => { + await mockGraphQL([ + { + operation: "GetDashboardStats", + response: { + data: { stats: { users: 100, revenue: 50000 } }, + }, + }, + { + operation: "GetUser", + variables: { id: "1" }, + response: { + data: { user: { id: "1", name: "John" } }, + }, + }, + ]); + + await page.goto("/dashboard"); + await expect(page.getByText("100 users")).toBeVisible(); +}); +``` + +### Mock GraphQL Mutations + +```typescript +test("mock GraphQL mutation", async ({ page }) => { + await page.route("**/graphql", async (route) => { + const postData = route.request().postDataJSON(); + + if (postData.operationName === "CreateOrder") { + const { input } = postData.variables; + + return route.fulfill({ + json: { + data: { + createOrder: { + id: "order-123", + status: "PENDING", + items: input.items, + total: input.items.reduce( + (sum: number, item: any) => sum + item.price * item.quantity, + 0, + ), + }, + }, + }, + }); + } + + return route.continue(); + }); + + await page.goto("/checkout"); + await page.getByRole("button", { name: "Place Order" }).click(); + + await expect(page.getByText("Order #order-123")).toBeVisible(); +}); +``` + +## HAR Recording & Playback + +### Record HAR File + +```typescript +// Record network traffic +test("record HAR", async ({ page, context }) => { + // Start recording + await context.routeFromHAR("./recordings/checkout.har", { + update: true, // Create/update HAR file + url: "**/api/**", + }); + + await page.goto("/checkout"); + await page.getByRole("button", { name: "Place Order" }).click(); + + // HAR file is saved automatically +}); +``` + +### Playback HAR File + +```typescript +// Use recorded HAR for offline testing +test("playback HAR", async ({ page, context }) => { + await context.routeFromHAR("./recordings/checkout.har", { + url: "**/api/**", + update: false, // Don't update, just playback + }); + + await page.goto("/checkout"); + + // All API calls served from HAR file + await expect(page.getByText("Order confirmed")).toBeVisible(); +}); +``` + +### HAR with Fallback + +```typescript +test("HAR with live fallback", async ({ page, context }) => { + await context.routeFromHAR("./recordings/api.har", { + url: "**/api/**", + update: false, + notFound: "fallback", // Use real network if not in HAR + }); + + await page.goto("/dashboard"); +}); +``` + +## Conditional Mocking + +### Mock Based on Request Body + +```typescript +test("conditional mock by body", async ({ page }) => { + await page.route("**/api/search", async (route) => { + const body = route.request().postDataJSON(); + + if (body.query === "error") { + return route.fulfill({ + status: 500, + json: { error: "Search failed" }, + }); + } + + if (body.query === "empty") { + return route.fulfill({ + json: { results: [] }, + }); + } + + // Default response + return route.fulfill({ + json: { + results: [{ id: 1, title: `Result for: ${body.query}` }], + }, + }); + }); + + await page.goto("/search"); + + // Test different scenarios + await page.getByLabel("Search").fill("error"); + await page.getByLabel("Search").press("Enter"); + await expect(page.getByText("Search failed")).toBeVisible(); +}); +``` + +### Mock Nth Request + +```typescript +test("different response on retry", async ({ page }) => { + let callCount = 0; + + await page.route("**/api/status", (route) => { + callCount++; + + if (callCount < 3) { + return route.fulfill({ + status: 503, + json: { error: "Service unavailable" }, + }); + } + + // Succeed on 3rd attempt + return route.fulfill({ + json: { status: "ok" }, + }); + }); + + await page.goto("/dashboard"); + + // App should retry and eventually succeed + await expect(page.getByText("Connected")).toBeVisible(); +}); +``` + +### Mock with Delay + +```typescript +test("slow network simulation", async ({ page }) => { + await page.route("**/api/data", async (route) => { + // Simulate 2 second delay + await new Promise((resolve) => setTimeout(resolve, 2000)); + + return route.fulfill({ + json: { data: "loaded" }, + }); + }); + + await page.goto("/dashboard"); + + // Loading state should appear + await expect(page.getByText("Loading...")).toBeVisible(); + + // Then data appears + await expect(page.getByText("loaded")).toBeVisible(); +}); +``` + +## Network Throttling + +### Slow 3G Simulation + +```typescript +test("slow network experience", async ({ page, context }) => { + // Create CDP session for network throttling + const client = await context.newCDPSession(page); + + await client.send("Network.emulateNetworkConditions", { + offline: false, + downloadThroughput: (500 * 1024) / 8, // 500 Kbps + uploadThroughput: (500 * 1024) / 8, + latency: 400, // 400ms + }); + + await page.goto("/"); + + // Test loading states appear + await expect(page.getByTestId("skeleton-loader")).toBeVisible(); +}); +``` + +### Offline Mode + +Use `context.setOffline(true/false)` to simulate network connectivity changes. + +> **For comprehensive offline testing patterns:** +> +> - **Network failure simulation** (error recovery, graceful degradation): See [error-testing.md](error-testing.md#offline-testing) +> - **Offline-first/PWA testing** (service workers, caching, background sync): See [service-workers.md](service-workers.md#offline-testing) + +### Network Throttling Fixture + +```typescript +// fixtures/network.fixture.ts +type NetworkCondition = "slow3g" | "fast3g" | "offline"; + +const conditions = { + slow3g: { downloadThroughput: 50000, uploadThroughput: 50000, latency: 2000 }, + fast3g: { downloadThroughput: 180000, uploadThroughput: 75000, latency: 150 }, +}; + +type NetworkFixtures = { + setNetworkCondition: (condition: NetworkCondition) => Promise; +}; + +export const test = base.extend({ + setNetworkCondition: async ({ page, context }, use) => { + const client = await context.newCDPSession(page); + + await use(async (condition) => { + if (condition === "offline") { + await context.setOffline(true); + } else { + await client.send("Network.emulateNetworkConditions", { + offline: false, + ...conditions[condition], + }); + } + }); + + // Reset + await context.setOffline(false); + }, +}); +``` + +## Anti-Patterns to Avoid + +| Anti-Pattern | Problem | Solution | +| ------------------------ | ------------------------------ | -------------------------------- | +| Mocking all requests | Tests don't reflect reality | Mock only what's necessary | +| No cleanup of routes | Routes persist across tests | Use fixtures with cleanup | +| Ignoring request method | Mock applies to wrong requests | Check `route.request().method()` | +| Hardcoded mock responses | Brittle, hard to maintain | Use factories for mock data | + +## Related References + +- **Basic Mocking**: See [test-suite-structure.md](../core/test-suite-structure.md) for simple mocking +- **WebSockets**: See [websockets.md](../browser-apis/websockets.md) for real-time mocking diff --git a/plugins/software-delivery/skills/playwright-best-practices/advanced/third-party.md b/plugins/software-delivery/skills/playwright-best-practices/advanced/third-party.md new file mode 100644 index 0000000..acf8ab8 --- /dev/null +++ b/plugins/software-delivery/skills/playwright-best-practices/advanced/third-party.md @@ -0,0 +1,464 @@ +# Third-Party Service Mocking + +## Table of Contents + +1. [OAuth/SSO Mocking](#oauthsso-mocking) +2. [Payment Gateway Mocking](#payment-gateway-mocking) +3. [Email Verification](#email-verification) +4. [SMS Verification](#sms-verification) +5. [Analytics & Tracking](#analytics--tracking) + +## OAuth/SSO Mocking + +### Mock Google OAuth + +```typescript +test("Google OAuth login", async ({ page }) => { + // Mock the OAuth callback + await page.route("**/auth/google/callback**", (route) => { + const url = new URL(route.request().url()); + // Simulate successful OAuth by redirecting with token + route.fulfill({ + status: 302, + headers: { + Location: "/dashboard?token=mock-jwt-token", + }, + }); + }); + + // Mock the token verification endpoint + await page.route("**/api/auth/verify", (route) => + route.fulfill({ + json: { + valid: true, + user: { + id: "123", + email: "test@gmail.com", + name: "Test User", + }, + }, + }), + ); + + await page.goto("/login"); + await page.getByRole("button", { name: "Sign in with Google" }).click(); + + await expect(page.getByText("Welcome, Test User")).toBeVisible(); +}); +``` + +### OAuth Fixture + +```typescript +// fixtures/oauth.fixture.ts +type OAuthProvider = "google" | "github" | "microsoft"; + +type OAuthUser = { + id: string; + email: string; + name: string; + avatar?: string; +}; + +type OAuthFixtures = { + mockOAuth: (provider: OAuthProvider, user: OAuthUser) => Promise; +}; + +export const test = base.extend({ + mockOAuth: async ({ page }, use) => { + await use(async (provider, user) => { + // Mock callback redirect + await page.route(`**/auth/${provider}/callback**`, (route) => + route.fulfill({ + status: 302, + headers: { Location: `/auth/success?provider=${provider}` }, + }), + ); + + // Mock session/user endpoint + await page.route("**/api/auth/session", (route) => + route.fulfill({ + json: { user, provider, authenticated: true }, + }), + ); + + // Mock user info endpoint + await page.route("**/api/me", (route) => route.fulfill({ json: user })); + }); + }, +}); + +// Usage +test("login with GitHub", async ({ page, mockOAuth }) => { + await mockOAuth("github", { + id: "gh-123", + email: "dev@github.com", + name: "GitHub User", + }); + + await page.goto("/login"); + await page.getByRole("button", { name: "Sign in with GitHub" }).click(); + + await expect(page.getByText("Welcome, GitHub User")).toBeVisible(); +}); +``` + +### Mock SAML SSO + +```typescript +test("SAML SSO login", async ({ page }) => { + // Mock SAML assertion consumer service + await page.route("**/saml/acs", async (route) => { + route.fulfill({ + status: 302, + headers: { + Location: "/dashboard", + "Set-Cookie": "session=mock-saml-session; Path=/; HttpOnly", + }, + }); + }); + + // Mock session validation + await page.route("**/api/session", (route) => + route.fulfill({ + json: { + user: { email: "user@company.com", name: "SSO User" }, + provider: "saml", + }, + }), + ); + + await page.goto("/login"); + await page.getByRole("button", { name: "SSO Login" }).click(); + + await expect(page).toHaveURL("/dashboard"); +}); +``` + +## Payment Gateway Mocking + +### Mock Stripe + +```typescript +test("Stripe checkout", async ({ page }) => { + // Mock Stripe.js + await page.addInitScript(() => { + (window as any).Stripe = () => ({ + elements: () => ({ + create: () => ({ + mount: () => {}, + on: () => {}, + destroy: () => {}, + }), + }), + confirmCardPayment: async () => ({ + paymentIntent: { status: "succeeded", id: "pi_mock_123" }, + }), + createPaymentMethod: async () => ({ + paymentMethod: { id: "pm_mock_123" }, + }), + }); + }); + + // Mock backend payment endpoint + await page.route("**/api/create-payment-intent", (route) => + route.fulfill({ + json: { clientSecret: "pi_mock_123_secret_mock" }, + }), + ); + + await page.route("**/api/confirm-payment", (route) => + route.fulfill({ + json: { success: true, orderId: "order-123" }, + }), + ); + + await page.goto("/checkout"); + await page.getByRole("button", { name: "Pay $99.99" }).click(); + + await expect(page.getByText("Payment successful")).toBeVisible(); +}); +``` + +### Mock PayPal + +```typescript +test("PayPal checkout", async ({ page }) => { + // Mock PayPal SDK + await page.addInitScript(() => { + (window as any).paypal = { + Buttons: () => ({ + render: () => Promise.resolve(), + isEligible: () => true, + }), + FUNDING: { PAYPAL: "paypal", CARD: "card" }, + }; + }); + + // Mock PayPal order creation + await page.route("**/api/paypal/create-order", (route) => + route.fulfill({ + json: { orderId: "PAYPAL-ORDER-123" }, + }), + ); + + // Mock PayPal capture + await page.route("**/api/paypal/capture", (route) => + route.fulfill({ + json: { success: true, transactionId: "TXN-123" }, + }), + ); + + await page.goto("/checkout"); + + // Simulate PayPal approval callback + await page.evaluate(() => { + (window as any).onPayPalApprove?.({ orderID: "PAYPAL-ORDER-123" }); + }); + + await expect(page.getByText("Order confirmed")).toBeVisible(); +}); +``` + +### Payment Fixture + +```typescript +// fixtures/payment.fixture.ts +type PaymentFixtures = { + mockStripe: (options?: { failPayment?: boolean }) => Promise; +}; + +export const test = base.extend({ + mockStripe: async ({ page }, use) => { + await use(async (options = {}) => { + await page.addInitScript( + ([shouldFail]) => { + (window as any).Stripe = () => ({ + elements: () => ({ + create: () => ({ + mount: () => {}, + on: (event: string, handler: Function) => { + if (event === "ready") setTimeout(handler, 100); + }, + destroy: () => {}, + }), + }), + confirmCardPayment: async () => { + if (shouldFail) { + return { error: { message: "Card declined" } }; + } + return { paymentIntent: { status: "succeeded" } }; + }, + }); + }, + [options.failPayment], + ); + }); + }, +}); + +// Usage +test("handles declined card", async ({ page, mockStripe }) => { + await mockStripe({ failPayment: true }); + + await page.goto("/checkout"); + await page.getByRole("button", { name: "Pay" }).click(); + + await expect(page.getByText("Card declined")).toBeVisible(); +}); +``` + +## Email Verification + +### Mock Email API + +```typescript +test("email verification flow", async ({ page, request }) => { + let verificationToken: string; + + // Capture the verification email + await page.route("**/api/send-verification", async (route) => { + const body = route.request().postDataJSON(); + verificationToken = `mock-token-${Date.now()}`; + + // Don't actually send email, just store token + route.fulfill({ + json: { sent: true, messageId: "msg-123" }, + }); + }); + + // Mock token verification + await page.route("**/api/verify-email**", (route) => { + const url = new URL(route.request().url()); + const token = url.searchParams.get("token"); + + if (token === verificationToken) { + route.fulfill({ json: { verified: true } }); + } else { + route.fulfill({ status: 400, json: { error: "Invalid token" } }); + } + }); + + await page.goto("/signup"); + await page.getByLabel("Email").fill("test@example.com"); + await page.getByRole("button", { name: "Sign Up" }).click(); + + await expect(page.getByText("Check your email")).toBeVisible(); + + // Simulate clicking email link + await page.goto(`/verify?token=${verificationToken}`); + + await expect(page.getByText("Email verified")).toBeVisible(); +}); +``` + +### Use Mailinator/Temp Mail + +```typescript +// fixtures/email.fixture.ts +type EmailFixtures = { + getVerificationEmail: (inbox: string) => Promise<{ link: string }>; +}; + +export const test = base.extend({ + getVerificationEmail: async ({ request }, use) => { + await use(async (inbox) => { + // Poll Mailinator API for new email + const response = await request.get( + `https://api.mailinator.com/v2/domains/public/inboxes/${inbox}`, + { + headers: { + Authorization: `Bearer ${process.env.MAILINATOR_API_KEY}`, + }, + }, + ); + + const messages = await response.json(); + const latest = messages.msgs[0]; + + // Get full message + const msgResponse = await request.get( + `https://api.mailinator.com/v2/domains/public/inboxes/${inbox}/messages/${latest.id}`, + { + headers: { + Authorization: `Bearer ${process.env.MAILINATOR_API_KEY}`, + }, + }, + ); + + const message = await msgResponse.json(); + + // Extract verification link from HTML + const linkMatch = message.parts[0].body.match( + /href="([^"]*verify[^"]*)"/, + ); + return { link: linkMatch?.[1] || "" }; + }); + }, +}); +``` + +## SMS Verification + +### Mock SMS API + +```typescript +test("SMS verification", async ({ page }) => { + let smsCode: string; + + // Capture SMS send + await page.route("**/api/send-sms", (route) => { + smsCode = Math.random().toString().slice(2, 8); // 6-digit code + + route.fulfill({ + json: { sent: true, messageId: "sms-123" }, + }); + }); + + // Mock code verification + await page.route("**/api/verify-sms", (route) => { + const body = route.request().postDataJSON(); + + if (body.code === smsCode) { + route.fulfill({ json: { verified: true } }); + } else { + route.fulfill({ status: 400, json: { error: "Invalid code" } }); + } + }); + + await page.goto("/verify-phone"); + await page.getByLabel("Phone").fill("+1234567890"); + await page.getByRole("button", { name: "Send Code" }).click(); + + // Enter the code + await page.getByLabel("Verification Code").fill(smsCode); + await page.getByRole("button", { name: "Verify" }).click(); + + await expect(page.getByText("Phone verified")).toBeVisible(); +}); +``` + +## Analytics & Tracking + +### Block Analytics in Tests + +```typescript +test.beforeEach(async ({ page }) => { + // Block all analytics/tracking + await page.route( + /google-analytics|googletagmanager|facebook|hotjar|segment|mixpanel|amplitude/, + (route) => route.abort(), + ); +}); +``` + +### Mock Analytics for Verification + +```typescript +test("tracks purchase event", async ({ page }) => { + const analyticsEvents: any[] = []; + + // Capture analytics calls + await page.route("**/api/analytics/**", (route) => { + analyticsEvents.push(route.request().postDataJSON()); + route.fulfill({ status: 200 }); + }); + + // Mock analytics SDK + await page.addInitScript(() => { + (window as any).analytics = { + track: (event: string, props: any) => { + fetch("/api/analytics/track", { + method: "POST", + body: JSON.stringify({ event, props }), + }); + }, + }; + }); + + await page.goto("/checkout"); + await page.getByRole("button", { name: "Complete Purchase" }).click(); + + // Verify analytics event was sent + expect(analyticsEvents).toContainEqual( + expect.objectContaining({ + event: "Purchase Completed", + props: expect.objectContaining({ amount: expect.any(Number) }), + }), + ); +}); +``` + +## Anti-Patterns to Avoid + +| Anti-Pattern | Problem | Solution | +| ------------------------- | ------------------------------ | ----------------------- | +| Using real OAuth in tests | Slow, needs credentials, flaky | Mock OAuth endpoints | +| Real payment processing | Charges real money, slow | Use test mode or mock | +| Waiting for real emails | Very slow, unreliable | Mock email API | +| Not mocking analytics | Pollutes analytics data | Block or mock analytics | + +## Related References + +- **Network Mocking**: See [network-advanced.md](network-advanced.md) for route patterns +- **Authentication**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for auth patterns diff --git a/plugins/software-delivery/skills/playwright-best-practices/architecture/pom-vs-fixtures.md b/plugins/software-delivery/skills/playwright-best-practices/architecture/pom-vs-fixtures.md new file mode 100644 index 0000000..eafb06f --- /dev/null +++ b/plugins/software-delivery/skills/playwright-best-practices/architecture/pom-vs-fixtures.md @@ -0,0 +1,363 @@ +# Organizing Reusable Test Code + +## Table of Contents + +1. [Pattern Comparison](#pattern-comparison) +2. [Selection Flowchart](#selection-flowchart) +3. [Page Objects](#page-objects) +4. [Custom Fixtures](#custom-fixtures) +5. [Helper Functions](#helper-functions) +6. [Combined Project Structure](#combined-project-structure) +7. [Anti-Patterns](#anti-patterns) + +Use all three patterns together. Most projects benefit from a hybrid approach: + +- **Page objects** for UI interaction (pages/components with 5+ interactions) +- **Custom fixtures** for test infrastructure (auth state, database, API clients, anything with lifecycle) +- **Helper functions** for stateless utilities (generate data, format values, simple waits) + +If only using one pattern, choose **custom fixtures** — they handle setup/teardown, compose well, and Playwright is built around them. + +## Pattern Comparison + +| Aspect | Page Objects | Custom Fixtures | Helper Functions | +|---|---|---|---| +| **Purpose** | Encapsulate UI interactions | Provide resources with setup/teardown | Stateless utilities | +| **Lifecycle** | Manual (constructor/methods) | Built-in (`use()` with automatic teardown) | None | +| **Composability** | Constructor injection or fixture wiring | Depend on other fixtures | Call other functions | +| **Best for** | Pages with many reused interactions | Resources needing setup AND teardown | Simple logic with no side effects | + +## Selection Flowchart + +```text +What kind of reusable code? +| ++-- Interacts with browser page/component? +| | +| +-- Has 5+ interactions (fill, click, navigate, assert)? +| | +-- YES: Used in 3+ test files? +| | | +-- YES --> PAGE OBJECT +| | | +-- NO --> Inline or small helper +| | +-- NO --> HELPER FUNCTION +| | +| +-- Needs setup before AND cleanup after test? +| +-- YES --> CUSTOM FIXTURE +| +-- NO --> PAGE OBJECT method or HELPER +| ++-- Manages resource with lifecycle (create/destroy)? +| +-- Examples: auth state, DB connection, API client, test user +| +-- YES --> CUSTOM FIXTURE (always) +| ++-- Stateless utility? (no browser, no side effects) +| +-- Examples: random email, format date, build URL, parse response +| +-- YES --> HELPER FUNCTION +| ++-- Not sure? + +-- Start with HELPER FUNCTION + +-- Promote to PAGE OBJECT when interactions grow + +-- Promote to FIXTURE when lifecycle needed +``` + +## Page Objects + +Best for pages/components with 5+ interactions appearing in 3+ test files. + +```typescript +// page-objects/booking.page.ts +import { type Page, type Locator, expect } from '@playwright/test'; + +export class BookingPage { + readonly page: Page; + readonly dateField: Locator; + readonly guestCount: Locator; + readonly roomType: Locator; + readonly reserveBtn: Locator; + readonly totalPrice: Locator; + + constructor(page: Page) { + this.page = page; + this.dateField = page.getByLabel('Check-in date'); + this.guestCount = page.getByLabel('Guests'); + this.roomType = page.getByLabel('Room type'); + this.reserveBtn = page.getByRole('button', { name: 'Reserve' }); + this.totalPrice = page.getByTestId('total-price'); + } + + async goto() { + await this.page.goto('/booking'); + } + + async fillDetails(opts: { date: string; guests: number; room: string }) { + await this.dateField.fill(opts.date); + await this.guestCount.fill(String(opts.guests)); + await this.roomType.selectOption(opts.room); + } + + async reserve() { + await this.reserveBtn.click(); + await this.page.waitForURL('**/confirmation'); + } + + async expectPrice(amount: string) { + await expect(this.totalPrice).toHaveText(amount); + } +} +``` + +```typescript +// tests/booking/reservation.spec.ts +import { test, expect } from '@playwright/test'; +import { BookingPage } from '../page-objects/booking.page'; + +test('complete reservation with standard room', async ({ page }) => { + const booking = new BookingPage(page); + await booking.goto(); + await booking.fillDetails({ date: '2026-03-15', guests: 2, room: 'standard' }); + await booking.reserve(); + await expect(page.getByText('Reservation confirmed')).toBeVisible(); +}); +``` + +**Page object principles:** +- One class per logical page/component, not per URL +- Constructor takes `Page` +- Locators as `readonly` properties in constructor +- Methods represent user intent (`reserve`, `fillDetails`), not low-level clicks +- Navigation methods (`goto`) belong on the page object + +## Custom Fixtures + +Best for resources needing setup before and teardown after tests — auth state, database connections, API clients, test users. + +```typescript +// fixtures/base.fixture.ts +import { test as base, expect } from '@playwright/test'; +import { BookingPage } from '../page-objects/booking.page'; +import { generateMember } from '../helpers/data'; + +type Fixtures = { + bookingPage: BookingPage; + member: { email: string; password: string; id: string }; + loggedInPage: import('@playwright/test').Page; +}; + +export const test = base.extend({ + bookingPage: async ({ page }, use) => { + await use(new BookingPage(page)); + }, + + member: async ({ request }, use) => { + const data = generateMember(); + const res = await request.post('/api/test/members', { data }); + const member = await res.json(); + await use(member); + await request.delete(`/api/test/members/${member.id}`); + }, + + loggedInPage: async ({ page, member }, use) => { + await page.goto('/login'); + await page.getByLabel('Email').fill(member.email); + await page.getByLabel('Password').fill(member.password); + await page.getByRole('button', { name: 'Sign in' }).click(); + await expect(page).toHaveURL('/dashboard'); + await use(page); + }, +}); + +export { expect } from '@playwright/test'; +``` + +```typescript +// tests/dashboard/overview.spec.ts +import { test, expect } from '../../fixtures/base.fixture'; + +test('member sees dashboard widgets', async ({ loggedInPage }) => { + await expect(loggedInPage.getByRole('heading', { name: 'Dashboard' })).toBeVisible(); + await expect(loggedInPage.getByTestId('stats-widget')).toBeVisible(); +}); + +test('new member sees welcome prompt', async ({ loggedInPage, member }) => { + await expect(loggedInPage.getByText(`Welcome, ${member.email}`)).toBeVisible(); +}); +``` + +**Fixture principles:** +- Use `test.extend()` — never module-level variables +- `use()` callback separates setup from teardown +- Teardown runs even if test fails +- Fixtures compose: one can depend on another +- Fixtures are lazy: created only when requested +- Wrap page objects in fixtures for lifecycle management + +## Helper Functions + +Best for stateless utilities — generating test data, formatting values, building URLs, parsing responses. + +```typescript +// helpers/data.ts +import { randomUUID } from 'node:crypto'; + +export function generateEmail(prefix = 'user'): string { + return `${prefix}-${Date.now()}-${randomUUID().slice(0, 8)}@test.local`; +} + +export function generateMember(overrides: Partial = {}): Member { + return { + email: generateEmail(), + password: 'SecurePass456!', + name: 'Test Member', + ...overrides, + }; +} + +interface Member { + email: string; + password: string; + name: string; +} + +export function formatPrice(cents: number): string { + return `$${(cents / 100).toFixed(2)}`; +} +``` + +```typescript +// helpers/assertions.ts +import { type Page, expect } from '@playwright/test'; + +export async function expectNotification(page: Page, message: string): Promise { + const notification = page.getByRole('alert').filter({ hasText: message }); + await expect(notification).toBeVisible(); + await expect(notification).toBeHidden({ timeout: 10000 }); +} +``` + +```typescript +// tests/settings/account.spec.ts +import { test, expect } from '@playwright/test'; +import { generateEmail } from '../../helpers/data'; +import { expectNotification } from '../../helpers/assertions'; + +test('update account email', async ({ page }) => { + const newEmail = generateEmail('updated'); + await page.goto('/settings/account'); + await page.getByLabel('Email').fill(newEmail); + await page.getByRole('button', { name: 'Save' }).click(); + await expectNotification(page, 'Account updated'); + await expect(page.getByLabel('Email')).toHaveValue(newEmail); +}); +``` + +**Helper principles:** +- Pure functions with no side effects +- No browser state — take `page` as parameter if needed +- Promote to fixture if setup/teardown needed +- Promote to page object if many page interactions grow +- Keep small and focused + +## Combined Project Structure + +```text +tests/ ++-- fixtures/ +| +-- auth.fixture.ts +| +-- db.fixture.ts +| +-- base.fixture.ts ++-- page-objects/ +| +-- login.page.ts +| +-- booking.page.ts +| +-- components/ +| +-- data-table.component.ts ++-- helpers/ +| +-- data.ts +| +-- assertions.ts ++-- e2e/ +| +-- auth/ +| | +-- login.spec.ts +| +-- booking/ +| +-- reservation.spec.ts +playwright.config.ts +``` + +**Layer responsibilities:** + +| Layer | Pattern | Responsibility | +|---|---|---| +| **Test file** | `test()` | Describes behavior, orchestrates layers | +| **Fixtures** | `test.extend()` | Resource lifecycle — setup, provide, teardown | +| **Page objects** | Classes | UI interaction — navigation, actions, locators | +| **Helpers** | Functions | Utilities — data generation, formatting, assertions | + +## Anti-Patterns + +### Page object managing resources + +```typescript +// BAD: page object handling API calls and database +class LoginPage { + async createUser() { /* API call */ } + async deleteUser() { /* API call */ } + async signIn(email: string, password: string) { /* UI */ } +} +``` + +Resource lifecycle belongs in fixtures where teardown is guaranteed. Keep only `signIn` in the page object. + +### Locator-only page objects + +```typescript +// BAD: no methods, just locators +class LoginPage { + emailInput = this.page.getByLabel('Email'); + passwordInput = this.page.getByLabel('Password'); + submitBtn = this.page.getByRole('button', { name: 'Sign in' }); + constructor(private page: Page) {} +} +``` + +Add intent-revealing methods or skip the page object entirely. + +### Monolithic fixtures + +```typescript +// BAD: one fixture doing everything +test.extend({ + everything: async ({ page, request }, use) => { + const user = await createUser(request); + const products = await seedProducts(request, 50); + await setupPayment(request, user.id); + await page.goto('/dashboard'); + await use({ user, products, page }); + // massive teardown... + }, +}); +``` + +Break into small, composable fixtures. Each fixture does one thing. + +### Helpers with side effects + +```typescript +// BAD: module-level state +let createdUserId: string; + +export async function createTestUser(request: APIRequestContext) { + const res = await request.post('/api/users', { data: { email: 'test@example.com' } }); + const user = await res.json(); + createdUserId = user.id; // shared across tests! + return user; +} +``` + +Module-level state leaks between parallel tests. If it has side effects and needs cleanup, make it a fixture. + +### Over-abstracting simple operations + +```typescript +// BAD: helper for one-liner +export async function clickButton(page: Page, name: string) { + await page.getByRole('button', { name }).click(); +} +``` + +Only abstract when there is real duplication (3+ usages) or complexity (5+ interactions). diff --git a/plugins/software-delivery/skills/playwright-best-practices/architecture/test-architecture.md b/plugins/software-delivery/skills/playwright-best-practices/architecture/test-architecture.md new file mode 100644 index 0000000..28b6f6c --- /dev/null +++ b/plugins/software-delivery/skills/playwright-best-practices/architecture/test-architecture.md @@ -0,0 +1,369 @@ +# Choosing Test Types: E2E, Component, or API + +## Table of Contents + +1. [Decision Matrix](#decision-matrix) +2. [API Tests](#api-tests) +3. [Component Tests](#component-tests) +4. [E2E Tests](#e2e-tests) +5. [Layering Test Types](#layering-test-types) +6. [Common Mistakes](#common-mistakes) +7. [Related](#related) + +> **When to use**: Deciding which test type to write for a feature. Ask: "What's the cheapest test that gives confidence this works?" + +## Decision Matrix + +| Scenario | Recommended Type | Rationale | +| --------------------------- | ---------------- | --------------------------------------------- | +| Login / auth flow | E2E | Cross-page, cookies, redirects, session state | +| Form submission | Component | Isolated validation logic, error states | +| CRUD operations | API | Data integrity matters more than UI | +| Search with results UI | Component + API | API for query logic; component for rendering | +| Cross-page navigation | E2E | Routing, history, deep linking | +| API error handling | API | Status codes, error shapes, edge cases | +| UI error feedback | Component | Toast, banner, inline error rendering | +| Accessibility | Component | ARIA roles, keyboard nav per-component | +| Responsive layout | Component | Viewport-specific rendering without full app | +| API contract validation | API | Response shapes, headers, auth | +| WebSocket/real-time | E2E | Requires full browser environment | +| Payment / checkout | E2E | Multi-step, third-party iframes | +| Onboarding wizard | E2E | Multi-step, state persists across pages | +| Widget behavior | Component | Toggle, accordion, date picker, modal | +| Permissions / authorization | API | Role-based access is backend logic | + +## API Tests + +**Ideal for**: + +- CRUD operations (create, read, update, delete) +- Input validation and error responses (400, 422) +- Permission and authorization checks +- Data integrity and business rules +- API contract verification +- Edge cases expensive to reproduce through UI +- Test data setup/teardown for E2E tests + +**Avoid for**: + +- Testing how errors display to users +- Browser-specific behavior (cookies, redirects) +- Visual layout or responsive design +- Flows requiring JavaScript execution or DOM interaction +- Third-party iframe interactions + +```typescript +import { test, expect } from "@playwright/test"; + +test.describe("Products API", () => { + let token: string; + + test.beforeAll(async ({ request }) => { + const res = await request.post("/api/auth/token", { + data: { email: "manager@shop.io", password: "mgr-secret" }, + }); + token = (await res.json()).accessToken; + }); + + test("creates product with valid payload", async ({ request }) => { + const res = await request.post("/api/products", { + headers: { Authorization: `Bearer ${token}` }, + data: { name: "Widget Pro", sku: "WGT-100", price: 29.99 }, + }); + + expect(res.status()).toBe(201); + const product = await res.json(); + expect(product).toMatchObject({ name: "Widget Pro", sku: "WGT-100" }); + expect(product).toHaveProperty("id"); + }); + + test("rejects duplicate SKU with 409", async ({ request }) => { + const res = await request.post("/api/products", { + headers: { Authorization: `Bearer ${token}` }, + data: { name: "Duplicate", sku: "WGT-100", price: 19.99 }, + }); + + expect(res.status()).toBe(409); + expect((await res.json()).message).toContain("already exists"); + }); + + test("returns 422 for missing required fields", async ({ request }) => { + const res = await request.post("/api/products", { + headers: { Authorization: `Bearer ${token}` }, + data: { name: "Incomplete" }, + }); + + expect(res.status()).toBe(422); + const err = await res.json(); + expect(err.errors).toContainEqual( + expect.objectContaining({ field: "sku" }) + ); + }); + + test("staff role cannot delete products", async ({ request }) => { + const staffLogin = await request.post("/api/auth/token", { + data: { email: "staff@shop.io", password: "staff-pass" }, + }); + const staffToken = (await staffLogin.json()).accessToken; + + const res = await request.delete("/api/products/123", { + headers: { Authorization: `Bearer ${staffToken}` }, + }); + + expect(res.status()).toBe(403); + }); + + test("lists products with pagination", async ({ request }) => { + const res = await request.get("/api/products", { + headers: { Authorization: `Bearer ${token}` }, + params: { page: "1", limit: "20" }, + }); + + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body.items).toBeInstanceOf(Array); + expect(body.items.length).toBeLessThanOrEqual(20); + expect(body).toHaveProperty("totalCount"); + }); +}); +``` + +## Component Tests + +**Ideal for**: + +- Form validation (required fields, format rules, error messages) +- Interactive widgets (modals, dropdowns, accordions, date pickers) +- Conditional rendering (show/hide, loading states, empty states) +- Accessibility per-component (ARIA attributes, keyboard navigation) +- Responsive layout at different viewports +- Visual states (hover, focus, disabled, selected) + +**Avoid for**: + +- Testing routing or navigation between pages +- Flows requiring real cookies, sessions, or server-side state +- Data persistence or API contract validation +- Third-party iframe interactions +- Anything requiring multiple pages or browser contexts + +```typescript +import { test, expect } from "@playwright/experimental-ct-react"; +import { ContactForm } from "../src/components/ContactForm"; + +test.describe("ContactForm component", () => { + test("displays validation errors on empty submit", async ({ mount }) => { + const component = await mount( {}} />); + + await component.getByRole("button", { name: "Send message" }).click(); + + await expect(component.getByText("Name is required")).toBeVisible(); + await expect(component.getByText("Email is required")).toBeVisible(); + }); + + test("rejects malformed email", async ({ mount }) => { + const component = await mount( {}} />); + + await component.getByLabel("Name").fill("Alex"); + await component.getByLabel("Email").fill("invalid-email"); + await component.getByLabel("Message").fill("Hello"); + await component.getByRole("button", { name: "Send message" }).click(); + + await expect(component.getByText("Enter a valid email")).toBeVisible(); + }); + + test("invokes onSubmit with form data", async ({ mount }) => { + const submissions: Array<{ name: string; email: string; message: string }> = + []; + const component = await mount( + submissions.push(data)} /> + ); + + await component.getByLabel("Name").fill("Alex"); + await component.getByLabel("Email").fill("alex@company.org"); + await component.getByLabel("Message").fill("Inquiry about pricing"); + await component.getByRole("button", { name: "Send message" }).click(); + + expect(submissions).toHaveLength(1); + expect(submissions[0]).toEqual({ + name: "Alex", + email: "alex@company.org", + message: "Inquiry about pricing", + }); + }); + + test("disables button during submission", async ({ mount }) => { + const component = await mount( + {}} submitting={true} /> + ); + + await expect( + component.getByRole("button", { name: "Sending..." }) + ).toBeDisabled(); + }); + + test("associates labels with inputs for accessibility", async ({ mount }) => { + const component = await mount( {}} />); + + await expect( + component.getByRole("textbox", { name: "Name" }) + ).toBeVisible(); + await expect( + component.getByRole("textbox", { name: "Email" }) + ).toBeVisible(); + }); +}); +``` + +## E2E Tests + +**Ideal for**: + +- Critical user flows that generate revenue (checkout, signup) +- Authentication flows (login, SSO, MFA, password reset) +- Multi-page workflows where state carries across navigation +- Flows involving third-party iframes (payment widgets) +- Smoke tests validating the entire stack +- Real-time collaboration requiring multiple browser contexts + +**Avoid for**: + +- Testing every form validation permutation +- CRUD operations where UI is a thin wrapper +- Verifying individual component states +- Testing API response shapes or error codes +- Responsive layout at every breakpoint +- Edge cases that only affect the backend + +```typescript +import { test, expect } from "@playwright/test"; + +test.describe("subscription flow", () => { + test.beforeEach(async ({ page }) => { + await page.request.post("/api/test/seed-account", { + data: { plan: "free", email: "subscriber@demo.io" }, + }); + await page.goto("/account/upgrade"); + }); + + test("upgrades to premium plan", async ({ page }) => { + await test.step("select plan", async () => { + await expect( + page.getByRole("heading", { name: "Choose Your Plan" }) + ).toBeVisible(); + await page.getByRole("button", { name: "Select Premium" }).click(); + }); + + await test.step("enter billing details", async () => { + await page.getByLabel("Cardholder name").fill("Sam Johnson"); + await page.getByLabel("Billing address").fill("456 Oak Ave"); + await page.getByLabel("City").fill("Seattle"); + await page.getByRole("combobox", { name: "State" }).selectOption("WA"); + await page.getByLabel("Postal code").fill("98101"); + await page.getByRole("button", { name: "Continue" }).click(); + }); + + await test.step("complete payment", async () => { + const paymentFrame = page.frameLocator('iframe[title="Secure Payment"]'); + await paymentFrame.getByLabel("Card number").fill("5555555555554444"); + await paymentFrame.getByLabel("Expiry").fill("09/29"); + await paymentFrame.getByLabel("CVV").fill("456"); + await page.getByRole("button", { name: "Subscribe now" }).click(); + }); + + await test.step("verify success", async () => { + await page.waitForURL("**/account/subscription/success**"); + await expect( + page.getByRole("heading", { name: "Welcome to Premium" }) + ).toBeVisible(); + await expect(page.getByText(/Subscription #\d+/)).toBeVisible(); + }); + }); +}); +``` + +## Layering Test Types + +Effective test suites combine all three types. Example for an "inventory management" feature: + +### API Layer (60% of tests) + +Cover every backend logic permutation. Cheap to run and maintain. + +``` +tests/api/inventory.spec.ts + - creates item with valid data (201) + - rejects duplicate SKU (409) + - rejects invalid quantity format (422) + - rejects missing required fields (422) + - warehouse-staff cannot delete items (403) + - unauthenticated request returns 401 + - lists items with pagination + - filters items by category + - updates item stock level + - archives an item + - prevents archiving items with pending orders +``` + +### Component Layer (30% of tests) + +Cover every visual state and interaction. + +``` +tests/components/InventoryForm.spec.tsx + - shows validation errors on empty submit + - shows inline error for invalid SKU format + - disables submit while saving + - calls onSubmit with form data + - resets form after successful save + +tests/components/InventoryTable.spec.tsx + - renders item rows from props + - shows empty state when no items + - handles archive confirmation modal + - sorts by column header click + - shows stock level badges with correct colors +``` + +### E2E Layer (10% of tests) + +Cover only critical paths proving full stack works. + +``` +tests/e2e/inventory.spec.ts + - manager creates item and sees it in list + - manager updates item stock level + - warehouse-staff cannot access admin settings +``` + +### Execution Profile + +For this feature: + +- **11 API tests** — ~2 seconds total, no browser +- **10 component tests** — ~5 seconds total, real browser but no server +- **3 E2E tests** — ~15 seconds total, full stack + +Total: 24 tests, ~22 seconds. API tests catch most regressions. Component tests catch UI bugs. E2E tests prove wiring works. If E2E fails but API and component pass, the problem is in integration (routing, state management, API client). + +## Common Mistakes + +| Anti-Pattern | Problem | Better Approach | +| ----------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | +| E2E for every validation rule | 30-second browser test for something API covers in 200ms | API test for validation, one component test for error display | +| No API tests, all E2E | Slow suite, flaky from UI timing, hard to diagnose | API tests for data/logic, E2E for critical paths only | +| Component tests mocking everything | Tests pass but app broken because mocks drift | Mock only external boundaries; API tests verify real contracts | +| Same assertion in API, component, AND E2E | Triple maintenance cost | Each layer tests what it uniquely verifies | +| E2E creating test data via UI | 2-minute test where 90 seconds is setup | Seed via API in `beforeEach`, test actual flow | +| Testing third-party behavior | Testing that Stripe validates cards (Stripe's job) | Mock Stripe; trust their contract | +| Skipping API layer | Can't tell if bug is frontend or backend | API tests isolate backend; component tests isolate frontend | +| One giant E2E for entire feature | 5-minute test failing somewhere with no clear cause | Focused E2E per critical path; use `test.step()` | + +## Related + +- [test-suite-structure.md](../core/test-suite-structure.md) — file structure and naming +- [api-testing.md](../testing-patterns/api-testing.md) — Playwright's `request` API for HTTP testing +- [component-testing.md](../testing-patterns/component-testing.md) — setting up component tests +- [authentication.md](../advanced/authentication.md) — auth flow patterns with `storageState` +- [when-to-mock.md](when-to-mock.md) — when to mock vs hit real services +- [pom-vs-fixtures.md](pom-vs-fixtures.md) — organizing shared test logic diff --git a/plugins/software-delivery/skills/playwright-best-practices/architecture/when-to-mock.md b/plugins/software-delivery/skills/playwright-best-practices/architecture/when-to-mock.md new file mode 100644 index 0000000..d5d5705 --- /dev/null +++ b/plugins/software-delivery/skills/playwright-best-practices/architecture/when-to-mock.md @@ -0,0 +1,383 @@ +# Mocking Strategy: Real vs Mock Services + +## Table of Contents + +1. [Core Principle](#core-principle) +2. [Decision Matrix](#decision-matrix) +3. [Decision Flowchart](#decision-flowchart) +4. [Mocking Techniques](#mocking-techniques) +5. [Real Service Strategies](#real-service-strategies) +6. [Hybrid Approach: Fixture-Based Mock Control](#hybrid-approach-fixture-based-mock-control) +7. [Validating Mock Accuracy](#validating-mock-accuracy) +8. [Anti-Patterns](#anti-patterns) + +> **When to use**: Deciding whether to mock API calls, intercept network requests, or hit real services in Playwright tests. + +## Core Principle + +**Mock at the boundary, test your stack end-to-end.** Mock third-party services you don't own (payment gateways, email providers, OAuth). Never mock your own frontend-to-backend communication. Tests should prove YOUR code works, not that third-party APIs are available. + +## Decision Matrix + +| Scenario | Mock? | Strategy | +| --- | --- | --- | +| Your own REST/GraphQL API | Never | Hit real API against staging or local dev | +| Your database (through your API) | Never | Seed via API or fixtures | +| Authentication (your auth system) | Mostly no | Use `storageState` to skip login in most tests | +| Stripe / payment gateway | Always | `route.fulfill()` with expected responses | +| SendGrid / email service | Always | Mock the API call, verify request payload | +| OAuth providers (Google, GitHub) | Always | Mock token exchange, test your callback handler | +| Analytics (Segment, Mixpanel) | Always | `route.abort()` or `route.fulfill()` | +| Maps / geocoding APIs | Always | Mock with static responses | +| Feature flags (LaunchDarkly) | Usually | Mock to force specific flag states | +| CDN / static assets | Never | Let them load normally | +| Flaky external dependency | CI: mock, local: real | Conditional mocking based on environment | +| Slow external dependency | Dev: mock, nightly: real | Separate test projects in config | + +## Decision Flowchart + +```text +Is this service part of YOUR codebase? +├── YES → Do NOT mock. Test the real integration. +│ ├── Is it slow? → Optimize the service, not the test. +│ └── Is it flaky? → Fix the service. Flaky infra is a bug. +└── NO → It's a third-party service. + ├── Is it paid per call? → ALWAYS mock. + ├── Is it rate-limited? → ALWAYS mock. + ├── Is it slow or unreliable? → ALWAYS mock. + └── Is it a complex multi-step flow? → Mock with HAR recording. +``` + +## Mocking Techniques + +### Blocking Unwanted Requests + +Block third-party scripts that slow tests and add no coverage: + +```typescript +test.beforeEach(async ({ page }) => { + await page.route('**/{analytics,tracking,segment,hotjar}.{com,io}/**', (route) => { + route.abort(); + }); +}); + +test('dashboard renders without tracking scripts', async ({ page }) => { + await page.goto('/dashboard'); + await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible(); +}); +``` + +### Full Mock (route.fulfill) + +Completely replace a third-party API response: + +```typescript +test('order flow with mocked payment service', async ({ page }) => { + await page.route('**/api/charge', (route) => { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + transactionId: 'txn_mock_abc', + status: 'completed', + }), + }); + }); + + await page.goto('/order/confirm'); + await page.getByRole('button', { name: 'Complete Purchase' }).click(); + await expect(page.getByText('Order confirmed')).toBeVisible(); +}); + +test('display error on payment decline', async ({ page }) => { + await page.route('**/api/charge', (route) => { + route.fulfill({ + status: 402, + contentType: 'application/json', + body: JSON.stringify({ + error: { code: 'insufficient_funds', message: 'Card declined.' }, + }), + }); + }); + + await page.goto('/order/confirm'); + await page.getByRole('button', { name: 'Complete Purchase' }).click(); + await expect(page.getByRole('alert')).toContainText('Card declined'); +}); +``` + +### Partial Mock (Modify Responses) + +Let the real API call happen but tweak the response: + +```typescript +test('display low inventory warning', async ({ page }) => { + await page.route('**/api/inventory/*', async (route) => { + const response = await route.fetch(); + const data = await response.json(); + + data.quantity = 1; + data.lowStock = true; + + await route.fulfill({ + response, + body: JSON.stringify(data), + }); + }); + + await page.goto('/products/widget-pro'); + await expect(page.getByText('Only 1 remaining')).toBeVisible(); +}); + +test('inject test notification into real response', async ({ page }) => { + await page.route('**/api/alerts', async (route) => { + const response = await route.fetch(); + const data = await response.json(); + + data.items.push({ + id: 'test-alert', + text: 'Report generated', + category: 'info', + }); + + await route.fulfill({ + response, + body: JSON.stringify(data), + }); + }); + + await page.goto('/home'); + await expect(page.getByText('Report generated')).toBeVisible(); +}); +``` + +### Record and Replay (HAR Files) + +For complex API sequences (OAuth flows, multi-step wizards): + +**Recording:** + +```typescript +test('capture API traffic for admin panel', async ({ page }) => { + await page.routeFromHAR('tests/fixtures/admin-panel.har', { + url: '**/api/**', + update: true, + }); + + await page.goto('/admin'); + await page.getByRole('tab', { name: 'Reports' }).click(); + await page.getByRole('tab', { name: 'Settings' }).click(); +}); +``` + +**Replaying:** + +```typescript +test('admin panel loads with recorded data', async ({ page }) => { + await page.routeFromHAR('tests/fixtures/admin-panel.har', { + url: '**/api/**', + update: false, + }); + + await page.goto('/admin'); + await expect(page.getByRole('heading', { name: 'Reports' })).toBeVisible(); +}); +``` + +**HAR maintenance:** + +- Record against a known-good staging environment +- Commit `.har` files to version control +- Re-record when APIs change +- Scope HAR to specific URL patterns + +## Real Service Strategies + +### Local Dev Server + +```typescript +// playwright.config.ts +export default defineConfig({ + webServer: { + command: 'npm run dev', + url: 'http://localhost:3000', + reuseExistingServer: !process.env.CI, + timeout: 30_000, + }, + use: { + baseURL: 'http://localhost:3000', + }, +}); +``` + +### Staging Environment + +```typescript +// playwright.config.ts +export default defineConfig({ + use: { + baseURL: process.env.CI + ? 'https://staging.example.com' + : 'http://localhost:3000', + }, +}); +``` + +### Test Containers + +```typescript +// playwright.config.ts +export default defineConfig({ + webServer: { + command: 'docker compose -f docker-compose.test.yml up --wait', + url: 'http://localhost:3000/health', + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, + globalTeardown: './tests/global-teardown.ts', +}); +``` + +```typescript +// tests/global-teardown.ts +import { execSync } from 'child_process'; + +export default function globalTeardown() { + if (process.env.CI) { + execSync('docker compose -f docker-compose.test.yml down -v'); + } +} +``` + +## Hybrid Approach: Fixture-Based Mock Control + +Create fixtures that let individual tests opt into mocking specific services: + +```typescript +// tests/fixtures/service-mocks.ts +import { test as base } from '@playwright/test'; + +type MockConfig = { + mockPayments: boolean; + mockNotifications: boolean; + mockAnalytics: boolean; +}; + +export const test = base.extend({ + mockPayments: [true, { option: true }], + mockNotifications: [true, { option: true }], + mockAnalytics: [true, { option: true }], + + page: async ({ page, mockPayments, mockNotifications, mockAnalytics }, use) => { + if (mockPayments) { + await page.route('**/api/billing/**', (route) => { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ status: 'paid', id: 'inv_mock_789' }), + }); + }); + } + + if (mockNotifications) { + await page.route('**/api/notify', (route) => { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ delivered: true }), + }); + }); + } + + if (mockAnalytics) { + await page.route('**/{segment,mixpanel,amplitude}.**/**', (route) => { + route.abort(); + }); + } + + await use(page); + }, +}); + +export { expect } from '@playwright/test'; +``` + +```typescript +// tests/billing.spec.ts +import { test, expect } from './fixtures/service-mocks'; + +test('subscription renewal sends notification', async ({ page }) => { + await page.goto('/account/billing'); + await page.getByRole('button', { name: 'Renew Now' }).click(); + await expect(page.getByText('Subscription renewed')).toBeVisible(); +}); + +test.describe('integration suite', () => { + test.use({ mockPayments: false }); + + test('real billing flow against test gateway', async ({ page }) => { + await page.goto('/account/billing'); + await page.getByRole('button', { name: 'Renew Now' }).click(); + await expect(page.getByText('Subscription renewed')).toBeVisible(); + }); +}); +``` + +### Environment-Based Test Projects + +```typescript +// playwright.config.ts +export default defineConfig({ + projects: [ + { + name: 'ci-fast', + testMatch: '**/*.spec.ts', + use: { baseURL: 'http://localhost:3000' }, + }, + { + name: 'nightly-full', + testMatch: '**/*.integration.spec.ts', + use: { baseURL: 'https://staging.example.com' }, + timeout: 120_000, + }, + ], +}); +``` + +## Validating Mock Accuracy + +Guard against mock drift from real APIs: + +```typescript +test.describe('contract validation', () => { + test('billing mock matches real API shape', async ({ request }) => { + const realResponse = await request.post('/api/billing/charge', { + data: { amount: 5000, currency: 'usd' }, + }); + const realBody = await realResponse.json(); + + const mockBody = { + status: 'paid', + id: 'inv_mock_789', + }; + + expect(Object.keys(mockBody).sort()).toEqual(Object.keys(realBody).sort()); + + for (const key of Object.keys(mockBody)) { + expect(typeof mockBody[key]).toBe(typeof realBody[key]); + } + }); +}); +``` + +## Anti-Patterns + +| Don't Do This | Problem | Do This Instead | +| --- | --- | --- | +| Mock your own API | Tests pass, app breaks. Zero integration coverage. | Hit your real API. Mock only third-party services. | +| Mock everything for speed | You test a fiction. Frontend and backend may be incompatible. | Mock only external boundaries. | +| Never mock anything | Tests are slow, flaky, fail when third parties have outages. | Mock third-party services. | +| Use outdated mocks | Mock returns different shape than real API. | Run contract validation tests. Re-record HAR files regularly. | +| Mock with `page.evaluate()` to stub fetch | Fragile, doesn't survive navigation. | Use `page.route()` which intercepts at network layer. | +| Copy-paste mocks across files | One API change requires updating many files. | Centralize mocks in fixtures. | +| Block all network and whitelist | Extremely brittle. Every new endpoint requires update. | Allow all by default. Selectively mock third-party services. | diff --git a/plugins/software-delivery/skills/playwright-best-practices/browser-apis/browser-apis.md b/plugins/software-delivery/skills/playwright-best-practices/browser-apis/browser-apis.md new file mode 100644 index 0000000..cc4c269 --- /dev/null +++ b/plugins/software-delivery/skills/playwright-best-practices/browser-apis/browser-apis.md @@ -0,0 +1,391 @@ +# Browser APIs: Geolocation, Permissions & More + +## Table of Contents + +1. [Geolocation](#geolocation) +2. [Permissions](#permissions) +3. [Clipboard](#clipboard) +4. [Notifications](#notifications) +5. [Camera & Microphone](#camera--microphone) + +## Geolocation + +### Mock Location + +```typescript +test("shows nearby stores", async ({ context }) => { + // Grant permission and set location + await context.grantPermissions(["geolocation"]); + await context.setGeolocation({ latitude: 37.7749, longitude: -122.4194 }); // San Francisco + + const page = await context.newPage(); + await page.goto("/store-finder"); + await page.getByRole("button", { name: "Find Nearby" }).click(); + + await expect(page.getByText("San Francisco")).toBeVisible(); +}); +``` + +### Geolocation Fixture + +```typescript +// fixtures/geolocation.fixture.ts +import { test as base } from "@playwright/test"; + +type Coordinates = { latitude: number; longitude: number; accuracy?: number }; + +type GeoFixtures = { + setLocation: (coords: Coordinates) => Promise; +}; + +export const test = base.extend({ + setLocation: async ({ context }, use) => { + await context.grantPermissions(["geolocation"]); + + await use(async (coords) => { + await context.setGeolocation({ + latitude: coords.latitude, + longitude: coords.longitude, + accuracy: coords.accuracy ?? 100, + }); + }); + }, +}); + +// Usage +test("delivery zone check", async ({ page, setLocation }) => { + await setLocation({ latitude: 40.7128, longitude: -74.006 }); // NYC + + await page.goto("/delivery"); + + await expect(page.getByText("Delivery available")).toBeVisible(); +}); +``` + +### Test Location Changes + +```typescript +test("tracks location updates", async ({ context }) => { + await context.grantPermissions(["geolocation"]); + + const page = await context.newPage(); + await page.goto("/tracking"); + + // Initial location + await context.setGeolocation({ latitude: 37.7749, longitude: -122.4194 }); + await page.getByRole("button", { name: "Start Tracking" }).click(); + + await expect(page.getByTestId("location")).toContainText("37.7749"); + + // Move to new location + await context.setGeolocation({ latitude: 37.8044, longitude: -122.2712 }); + + // Trigger location update + await page.evaluate(() => { + navigator.geolocation.getCurrentPosition(() => {}); + }); + + await expect(page.getByTestId("location")).toContainText("37.8044"); +}); +``` + +### Test Geolocation Denial + +```typescript +test("handles location denied", async ({ browser }) => { + // Create context without geolocation permission + const context = await browser.newContext({ + permissions: [], // No permissions + }); + + const page = await context.newPage(); + await page.goto("/store-finder"); + await page.getByRole("button", { name: "Find Nearby" }).click(); + + await expect(page.getByText("Location access denied")).toBeVisible(); + await expect(page.getByLabel("Enter ZIP code")).toBeVisible(); + + await context.close(); +}); +``` + +## Permissions + +### Grant Permissions + +```typescript +test("notifications with permission", async ({ context }) => { + await context.grantPermissions(["notifications"]); + + const page = await context.newPage(); + await page.goto("/alerts"); + + // Notification API should work + const permission = await page.evaluate(() => Notification.permission); + expect(permission).toBe("granted"); +}); +``` + +### Test Permission Denied + +```typescript +test("handles notification permission denied", async ({ browser }) => { + const context = await browser.newContext({ + permissions: [], // Deny all + }); + + const page = await context.newPage(); + await page.goto("/notifications"); + + await page.getByRole("button", { name: "Enable Notifications" }).click(); + + await expect(page.getByText("Please enable notifications")).toBeVisible(); + + await context.close(); +}); +``` + +### Multiple Permissions + +```typescript +test("video call with permissions", async ({ context }) => { + await context.grantPermissions(["camera", "microphone", "notifications"]); + + const page = await context.newPage(); + await page.goto("/video-call"); + + // All permissions should be granted + const permissions = await page.evaluate(async () => ({ + camera: await navigator.permissions.query({ + name: "camera" as PermissionName, + }), + microphone: await navigator.permissions.query({ + name: "microphone" as PermissionName, + }), + })); + + expect(permissions.camera.state).toBe("granted"); + expect(permissions.microphone.state).toBe("granted"); +}); +``` + +## Clipboard + +### Test Copy to Clipboard + +```typescript +test("copy button works", async ({ page, context }) => { + // Grant clipboard permissions + await context.grantPermissions(["clipboard-read", "clipboard-write"]); + + await page.goto("/share"); + + await page.getByRole("button", { name: "Copy Link" }).click(); + + // Read clipboard content + const clipboardContent = await page.evaluate(() => + navigator.clipboard.readText(), + ); + + expect(clipboardContent).toContain("https://example.com/share/"); +}); +``` + +### Test Paste from Clipboard + +```typescript +test("paste from clipboard", async ({ page, context }) => { + await context.grantPermissions(["clipboard-read", "clipboard-write"]); + + await page.goto("/editor"); + + // Write to clipboard + await page.evaluate(() => navigator.clipboard.writeText("Pasted content")); + + // Trigger paste + await page.getByLabel("Content").focus(); + await page.keyboard.press("Control+V"); + + await expect(page.getByLabel("Content")).toHaveValue("Pasted content"); +}); +``` + +### Clipboard Fixture + +```typescript +// fixtures/clipboard.fixture.ts +import { test as base } from "@playwright/test"; + +type ClipboardFixtures = { + clipboard: { + write: (text: string) => Promise; + read: () => Promise; + }; +}; + +export const test = base.extend({ + clipboard: async ({ page, context }, use) => { + await context.grantPermissions(["clipboard-read", "clipboard-write"]); + + await use({ + write: async (text) => { + await page.evaluate((t) => navigator.clipboard.writeText(t), text); + }, + read: async () => { + return page.evaluate(() => navigator.clipboard.readText()); + }, + }); + }, +}); +``` + +## Notifications + +### Mock Notification API + +```typescript +test("shows browser notification", async ({ page }) => { + const notifications: any[] = []; + + // Mock Notification constructor + await page.addInitScript(() => { + (window as any).__notifications = []; + (window as any).Notification = class { + constructor(title: string, options?: NotificationOptions) { + (window as any).__notifications.push({ title, ...options }); + } + static permission = "granted"; + static requestPermission = async () => "granted"; + }; + }); + + await page.goto("/alerts"); + await page.getByRole("button", { name: "Notify Me" }).click(); + + // Check notification was created + const created = await page.evaluate(() => (window as any).__notifications); + expect(created).toHaveLength(1); + expect(created[0].title).toBe("New Alert"); +}); +``` + +### Test Notification Click + +```typescript +test("notification click handler", async ({ page }) => { + await page.addInitScript(() => { + (window as any).Notification = class { + onclick: (() => void) | null = null; + constructor(title: string) { + // Simulate click after creation + setTimeout(() => this.onclick?.(), 100); + } + static permission = "granted"; + static requestPermission = async () => "granted"; + }; + }); + + await page.goto("/messages"); + await page.evaluate(() => { + new Notification("New Message"); + }); + + // Should navigate to messages when notification clicked + await expect(page).toHaveURL(/\/messages/); +}); +``` + +## Camera & Microphone + +### Mock Media Devices + +```typescript +test("video preview works", async ({ page, context }) => { + await context.grantPermissions(["camera"]); + + // Mock getUserMedia + await page.addInitScript(() => { + navigator.mediaDevices.getUserMedia = async () => { + const canvas = document.createElement("canvas"); + canvas.width = 640; + canvas.height = 480; + return canvas.captureStream(); + }; + }); + + await page.goto("/video-settings"); + await page.getByRole("button", { name: "Start Camera" }).click(); + + await expect(page.getByTestId("video-preview")).toBeVisible(); +}); +``` + +### Test Media Device Selection + +```typescript +test("switch camera", async ({ page }) => { + await page.addInitScript(() => { + navigator.mediaDevices.enumerateDevices = async () => + [ + { + deviceId: "cam1", + kind: "videoinput", + label: "Front Camera", + groupId: "1", + }, + { + deviceId: "cam2", + kind: "videoinput", + label: "Back Camera", + groupId: "2", + }, + ] as MediaDeviceInfo[]; + + navigator.mediaDevices.getUserMedia = async () => { + const canvas = document.createElement("canvas"); + return canvas.captureStream(); + }; + }); + + await page.goto("/camera"); + + // Should show camera options + await expect(page.getByRole("combobox", { name: "Camera" })).toBeVisible(); + await expect(page.getByText("Front Camera")).toBeVisible(); + await expect(page.getByText("Back Camera")).toBeVisible(); +}); +``` + +### Test Media Errors + +```typescript +test("handles camera access error", async ({ page }) => { + await page.addInitScript(() => { + navigator.mediaDevices.getUserMedia = async () => { + throw new DOMException("Permission denied", "NotAllowedError"); + }; + }); + + await page.goto("/video-call"); + await page.getByRole("button", { name: "Join Call" }).click(); + + await expect(page.getByText("Camera access denied")).toBeVisible(); + await expect( + page.getByRole("button", { name: "Join Audio Only" }), + ).toBeVisible(); +}); +``` + +## Anti-Patterns to Avoid + +| Anti-Pattern | Problem | Solution | +| ----------------------------- | --------------------------------- | ----------------------------------- | +| Not granting permissions | Tests fail with permission errors | Use `context.grantPermissions()` | +| Testing real geolocation | Flaky, environment-dependent | Mock with `setGeolocation()` | +| Not testing permission denial | Misses error handling | Test both granted and denied states | +| Using real camera/mic | CI has no devices | Mock `getUserMedia` | + +## Related References + +- **Fixtures**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for context fixtures +- **Mobile**: See [mobile-testing.md](../advanced/mobile-testing.md) for device emulation diff --git a/plugins/software-delivery/skills/playwright-best-practices/browser-apis/iframes.md b/plugins/software-delivery/skills/playwright-best-practices/browser-apis/iframes.md new file mode 100644 index 0000000..145e050 --- /dev/null +++ b/plugins/software-delivery/skills/playwright-best-practices/browser-apis/iframes.md @@ -0,0 +1,403 @@ +# iFrame Testing + +## Table of Contents + +1. [Basic iFrame Access](#basic-iframe-access) +2. [Cross-Origin iFrames](#cross-origin-iframes) +3. [Nested iFrames](#nested-iframes) +4. [Dynamic iFrames](#dynamic-iframes) +5. [iFrame Navigation](#iframe-navigation) +6. [Common Patterns](#common-patterns) + +## Basic iFrame Access + +### Using frameLocator + +```typescript +// Access iframe by selector +const frame = page.frameLocator("iframe#payment"); +await frame.getByRole("button", { name: "Pay" }).click(); + +// Access by name attribute +const namedFrame = page.frameLocator('iframe[name="checkout"]'); +await namedFrame.getByLabel("Card number").fill("4242424242424242"); + +// Access by title +const titledFrame = page.frameLocator('iframe[title="Payment Form"]'); + +// Access by src (partial match) +const srcFrame = page.frameLocator('iframe[src*="stripe.com"]'); +``` + +### Frame vs FrameLocator + +```typescript +// frameLocator - for locator-based operations (recommended) +const frameLocator = page.frameLocator("#my-iframe"); +await frameLocator.getByRole("button").click(); + +// frame() - for Frame object operations (navigation, evaluation) +const frame = page.frame({ name: "my-frame" }); +if (frame) { + await frame.goto("https://example.com"); + const title = await frame.title(); +} + +// Get all frames +const frames = page.frames(); +for (const f of frames) { + console.log("Frame URL:", f.url()); +} +``` + +### Waiting for iFrame Content + +```typescript +// Wait for iframe to load +const frame = page.frameLocator("#dynamic-iframe"); + +// Wait for element inside iframe +await expect(frame.getByRole("heading")).toBeVisible({ timeout: 10000 }); + +// Wait for iframe src to change +await page.waitForFunction(() => { + const iframe = document.querySelector("iframe#my-frame") as HTMLIFrameElement; + return iframe?.src.includes("loaded"); +}); +``` + +## Cross-Origin iFrames + +### Accessing Cross-Origin Content + +```typescript +// Cross-origin iframes work seamlessly with frameLocator +const thirdPartyFrame = page.frameLocator('iframe[src*="third-party.com"]'); + +// Interact with elements inside cross-origin iframe +await thirdPartyFrame.getByRole("textbox").fill("test@example.com"); +await thirdPartyFrame.getByRole("button", { name: "Submit" }).click(); + +// Wait for cross-origin iframe to be ready +await expect(thirdPartyFrame.locator("body")).toBeVisible(); +``` + +### Payment Provider iFrames (Stripe, PayPal) + +```typescript +test("Stripe payment iframe", async ({ page }) => { + await page.goto("/checkout"); + + // Stripe uses multiple iframes for each field + const cardFrame = page + .frameLocator('iframe[name*="__privateStripeFrame"]') + .first(); + + // Wait for Stripe to initialize + await expect(cardFrame.locator('[placeholder="Card number"]')).toBeVisible({ + timeout: 15000, + }); + + // Fill card details + await cardFrame + .locator('[placeholder="Card number"]') + .fill("4242424242424242"); + await cardFrame.locator('[placeholder="MM / YY"]').fill("12/30"); + await cardFrame.locator('[placeholder="CVC"]').fill("123"); +}); +``` + +### Handling OAuth in iFrames + +```typescript +test("OAuth iframe flow", async ({ page }) => { + await page.goto("/login"); + await page.getByRole("button", { name: "Sign in with Google" }).click(); + + // If OAuth opens in iframe instead of popup + const oauthFrame = page.frameLocator('iframe[src*="accounts.google.com"]'); + + // Wait for OAuth form + await expect(oauthFrame.getByLabel("Email")).toBeVisible({ timeout: 10000 }); + await oauthFrame.getByLabel("Email").fill("test@gmail.com"); +}); +``` + +## Nested iFrames + +### Accessing Nested Frames + +```typescript +// Parent iframe contains child iframe +const parentFrame = page.frameLocator("#outer-frame"); +const childFrame = parentFrame.frameLocator("#inner-frame"); + +// Interact with deeply nested content +await childFrame.getByRole("button", { name: "Submit" }).click(); + +// Multiple levels of nesting +const level1 = page.frameLocator("#level1"); +const level2 = level1.frameLocator("#level2"); +const level3 = level2.frameLocator("#level3"); +await level3.getByText("Deep content").click(); +``` + +### Finding Elements Across Frame Hierarchy + +```typescript +// Helper to search all frames for an element +async function findInAnyFrame( + page: Page, + selector: string, +): Promise { + // Check main page first + const mainCount = await page.locator(selector).count(); + if (mainCount > 0) return page.locator(selector); + + // Check all frames + for (const frame of page.frames()) { + const count = await frame.locator(selector).count(); + if (count > 0) { + return frame.locator(selector); + } + } + return null; +} + +test("find element in any frame", async ({ page }) => { + await page.goto("/complex-page"); + const element = await findInAnyFrame(page, '[data-testid="submit-btn"]'); + if (element) await element.click(); +}); +``` + +## Dynamic iFrames + +### iFrames Created at Runtime + +```typescript +test("handle dynamically created iframe", async ({ page }) => { + await page.goto("/dashboard"); + + // Click button that creates iframe + await page.getByRole("button", { name: "Open Widget" }).click(); + + // Wait for iframe to appear in DOM + await page.waitForSelector("iframe#widget-frame"); + + // Now access the frame + const widgetFrame = page.frameLocator("#widget-frame"); + await expect(widgetFrame.getByText("Widget Loaded")).toBeVisible(); +}); +``` + +### iFrames with Changing src + +```typescript +test("iframe src changes", async ({ page }) => { + await page.goto("/multi-step"); + + const frame = page.frameLocator("#step-frame"); + + // Step 1 + await expect(frame.getByText("Step 1")).toBeVisible(); + await frame.getByRole("button", { name: "Next" }).click(); + + // Wait for iframe to reload with new content + await expect(frame.getByText("Step 2")).toBeVisible({ timeout: 10000 }); + await frame.getByRole("button", { name: "Next" }).click(); + + // Step 3 + await expect(frame.getByText("Step 3")).toBeVisible({ timeout: 10000 }); +}); +``` + +### Lazy-Loaded iFrames + +```typescript +test("lazy loaded iframe", async ({ page }) => { + await page.goto("/page-with-lazy-iframe"); + + // Scroll to trigger lazy load + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); + + // Wait for iframe to load + const lazyFrame = page.frameLocator("#lazy-iframe"); + await expect(lazyFrame.locator("body")).not.toBeEmpty({ timeout: 15000 }); + + // Interact with content + await lazyFrame.getByRole("button").click(); +}); +``` + +## iFrame Navigation + +### Navigating Within iFrame + +```typescript +test("iframe internal navigation", async ({ page }) => { + await page.goto("/app"); + + // Get frame object for navigation control + const frame = page.frame({ name: "content-frame" }); + if (!frame) throw new Error("Frame not found"); + + // Navigate within iframe + await frame.goto("https://embedded-app.com/page2"); + + // Wait for navigation + await frame.waitForURL("**/page2"); + + // Verify content + await expect(frame.getByRole("heading")).toHaveText("Page 2"); +}); +``` + +### Handling Frame Navigation Events + +```typescript +test("track iframe navigation", async ({ page }) => { + const navigations: string[] = []; + + // Listen to frame navigation + page.on("framenavigated", (frame) => { + if (frame.parentFrame()) { + // This is an iframe navigation + navigations.push(frame.url()); + } + }); + + await page.goto("/with-iframe"); + await page + .frameLocator("#nav-frame") + .getByRole("link", { name: "Page 2" }) + .click(); + + // Verify navigation occurred + expect(navigations.some((url) => url.includes("page2"))).toBe(true); +}); +``` + +## Common Patterns + +### iFrame Fixture + +```typescript +// fixtures.ts +import { test as base, FrameLocator } from "@playwright/test"; + +export const test = base.extend<{ paymentFrame: FrameLocator }>({ + paymentFrame: async ({ page }, use) => { + await page.goto("/checkout"); + + // Wait for payment iframe to be ready + const frame = page.frameLocator('iframe[src*="payment"]'); + await expect(frame.locator("body")).toBeVisible({ timeout: 15000 }); + + await use(frame); + }, +}); + +// test file +test("complete payment", async ({ paymentFrame }) => { + await paymentFrame.getByLabel("Card").fill("4242424242424242"); + await paymentFrame.getByRole("button", { name: "Pay" }).click(); +}); +``` + +### Debugging iFrame Issues + +```typescript +test("debug iframe content", async ({ page }) => { + await page.goto("/page-with-iframes"); + + // List all frames + console.log("All frames:"); + for (const frame of page.frames()) { + console.log(` - ${frame.name() || "(unnamed)"}: ${frame.url()}`); + } + + // Screenshot specific iframe content + const frame = page.frame({ name: "target-frame" }); + if (frame) { + const body = frame.locator("body"); + await body.screenshot({ path: "iframe-content.png" }); + } + + // Get iframe HTML for debugging + const frameContent = page.frameLocator("#my-frame"); + const html = await frameContent.locator("body").innerHTML(); + console.log("iFrame HTML:", html.substring(0, 500)); +}); +``` + +### Handling iFrame Load Failures + +```typescript +test("handle iframe load failure", async ({ page }) => { + await page.goto("/page-with-unreliable-iframe"); + + const frame = page.frameLocator("#unreliable-frame"); + + try { + // Try to interact with iframe content + await expect(frame.getByRole("button")).toBeVisible({ timeout: 5000 }); + await frame.getByRole("button").click(); + } catch (error) { + // Fallback: refresh iframe + await page.evaluate(() => { + const iframe = document.querySelector( + "#unreliable-frame", + ) as HTMLIFrameElement; + if (iframe) iframe.src = iframe.src; + }); + + // Retry + await expect(frame.getByRole("button")).toBeVisible({ timeout: 10000 }); + await frame.getByRole("button").click(); + } +}); +``` + +### Mocking iFrame Content + +```typescript +test("mock iframe response", async ({ page }) => { + // Intercept iframe src request + await page.route("**/embedded-widget**", (route) => { + route.fulfill({ + contentType: "text/html", + body: ` + + + +

Mocked Widget

+ + + + `, + }); + }); + + await page.goto("/page-with-widget"); + + const frame = page.frameLocator("#widget-frame"); + await expect(frame.getByRole("heading")).toHaveText("Mocked Widget"); +}); +``` + +## Anti-Patterns to Avoid + +| Anti-Pattern | Problem | Solution | +| ------------------------------------- | --------------------------------- | -------------------------------------------------- | +| Using `page.frame()` for interactions | Less reliable than frameLocator | Use `page.frameLocator()` for element interactions | +| Hardcoding iframe index | Fragile if DOM order changes | Use name, id, or src attribute selectors | +| Not waiting for iframe load | Race conditions | Wait for element inside iframe to be visible | +| Assuming same-origin | Cross-origin has different timing | Always wait for iframe content explicitly | +| Ignoring nested iframes | Element not found | Chain frameLocator calls for nested frames | + +## Related References + +- **Locators**: See [locators.md](../core/locators.md) for selector strategies +- **Third-party services**: See [third-party.md](../advanced/third-party.md) for payment iframe patterns +- **Debugging**: See [debugging.md](../debugging/debugging.md) for troubleshooting iframe issues diff --git a/plugins/software-delivery/skills/playwright-best-practices/browser-apis/service-workers.md b/plugins/software-delivery/skills/playwright-best-practices/browser-apis/service-workers.md new file mode 100644 index 0000000..7603de3 --- /dev/null +++ b/plugins/software-delivery/skills/playwright-best-practices/browser-apis/service-workers.md @@ -0,0 +1,504 @@ +# Service Worker Testing + +## Table of Contents + +1. [Service Worker Basics](#service-worker-basics) +2. [Registration & Lifecycle](#registration--lifecycle) +3. [Cache Testing](#cache-testing) +4. [Offline Testing](#offline-testing) +5. [Push Notifications](#push-notifications) +6. [Background Sync](#background-sync) + +## Service Worker Basics + +### Waiting for Service Worker Registration + +```typescript +test("service worker registers", async ({ page }) => { + await page.goto("/pwa-app"); + + // Wait for SW to register + const swRegistered = await page.evaluate(async () => { + if (!("serviceWorker" in navigator)) return false; + + const registration = await navigator.serviceWorker.ready; + return !!registration.active; + }); + + expect(swRegistered).toBe(true); +}); +``` + +### Getting Service Worker State + +```typescript +test("check SW state", async ({ page }) => { + await page.goto("/"); + + const swState = await page.evaluate(async () => { + const registration = await navigator.serviceWorker.getRegistration(); + if (!registration) return null; + + return { + installing: !!registration.installing, + waiting: !!registration.waiting, + active: !!registration.active, + scope: registration.scope, + }; + }); + + expect(swState?.active).toBe(true); + expect(swState?.scope).toContain(page.url()); +}); +``` + +### Service Worker Context + +```typescript +test("access service worker", async ({ context, page }) => { + await page.goto("/pwa-app"); + + // Get all service workers in context + const workers = context.serviceWorkers(); + + // Wait for service worker if not yet available + if (workers.length === 0) { + await context.waitForEvent("serviceworker"); + } + + const sw = context.serviceWorkers()[0]; + expect(sw.url()).toContain("sw.js"); +}); +``` + +## Registration & Lifecycle + +### Testing SW Update Flow + +```typescript +test("service worker updates", async ({ page }) => { + await page.goto("/pwa-app"); + + // Check for update + const hasUpdate = await page.evaluate(async () => { + const registration = await navigator.serviceWorker.ready; + await registration.update(); + + return new Promise((resolve) => { + if (registration.waiting) { + resolve(true); + } else { + registration.addEventListener("updatefound", () => { + resolve(true); + }); + // Timeout if no update + setTimeout(() => resolve(false), 5000); + } + }); + }); + + // If update found, test skip waiting flow + if (hasUpdate) { + await page.evaluate(async () => { + const registration = await navigator.serviceWorker.ready; + registration.waiting?.postMessage({ type: "SKIP_WAITING" }); + }); + + // Wait for controller change + await page.evaluate(() => { + return new Promise((resolve) => { + navigator.serviceWorker.addEventListener("controllerchange", () => { + resolve(); + }); + }); + }); + } +}); +``` + +### Testing SW Installation + +```typescript +test("verify SW install event", async ({ context, page }) => { + // Listen for service worker before navigating + const swPromise = context.waitForEvent("serviceworker"); + + await page.goto("/pwa-app"); + + const sw = await swPromise; + + // Evaluate in SW context + const swVersion = await sw.evaluate(() => { + // Access SW globals + return (self as any).SW_VERSION || "unknown"; + }); + + expect(swVersion).toBe("1.0.0"); +}); +``` + +### Unregistering Service Workers + +```typescript +test.beforeEach(async ({ page }) => { + await page.goto("/"); + + // Unregister all service workers for clean state + await page.evaluate(async () => { + const registrations = await navigator.serviceWorker.getRegistrations(); + await Promise.all(registrations.map((r) => r.unregister())); + }); + + // Clear caches + await page.evaluate(async () => { + const cacheNames = await caches.keys(); + await Promise.all(cacheNames.map((name) => caches.delete(name))); + }); +}); +``` + +## Cache Testing + +### Verifying Cached Resources + +```typescript +test("assets are cached", async ({ page }) => { + await page.goto("/pwa-app"); + + // Wait for SW to cache assets + await page.evaluate(async () => { + await navigator.serviceWorker.ready; + }); + + // Check cache contents + const cachedUrls = await page.evaluate(async () => { + const cache = await caches.open("app-cache-v1"); + const requests = await cache.keys(); + return requests.map((r) => r.url); + }); + + expect(cachedUrls).toContain(expect.stringContaining("/styles.css")); + expect(cachedUrls).toContain(expect.stringContaining("/app.js")); +}); +``` + +### Testing Cache Strategies + +```typescript +test("cache-first strategy", async ({ page }) => { + await page.goto("/pwa-app"); + + // Wait for initial cache + await page.waitForFunction(async () => { + const cache = await caches.open("app-cache-v1"); + const keys = await cache.keys(); + return keys.length > 0; + }); + + // Block network for cached resources + await page.route("**/styles.css", (route) => route.abort()); + + // Reload - should work from cache + await page.reload(); + + // Verify page still styled (CSS loaded from cache) + const hasStyles = await page.evaluate(() => { + const body = document.body; + const styles = window.getComputedStyle(body); + return styles.fontFamily !== ""; // Has custom font from CSS + }); + + expect(hasStyles).toBe(true); +}); +``` + +### Testing Cache Updates + +```typescript +test("cache updates on new version", async ({ page }) => { + await page.goto("/pwa-app"); + + // Get initial cache + const initialCacheKeys = await page.evaluate(async () => { + const cache = await caches.open("app-cache-v1"); + const keys = await cache.keys(); + return keys.map((r) => r.url); + }); + + // Simulate app update by mocking SW response + await page.route("**/sw.js", (route) => { + route.fulfill({ + contentType: "application/javascript", + body: ` + const VERSION = 'v2'; + self.addEventListener('install', (e) => { + e.waitUntil(caches.open('app-cache-v2')); + self.skipWaiting(); + }); + `, + }); + }); + + // Trigger update + await page.evaluate(async () => { + const reg = await navigator.serviceWorker.ready; + await reg.update(); + }); + + // Verify new cache exists + await page.waitForFunction(async () => { + return await caches.has("app-cache-v2"); + }); +}); +``` + +## Offline Testing + +This section covers **offline-first apps (PWAs)** that are designed to work offline using service workers, caching, and background sync. For testing **unexpected network failures** (error recovery, graceful degradation), see [error-testing.md](error-testing.md#offline-testing). + +### Simulating Offline Mode + +```typescript +test("app works offline", async ({ page, context }) => { + await page.goto("/pwa-app"); + + // Ensure SW is active and content cached + await page.evaluate(async () => { + await navigator.serviceWorker.ready; + }); + await page.waitForTimeout(1000); // Allow caching to complete + + // Go offline + await context.setOffline(true); + + // Navigate to cached page + await page.reload(); + + // Verify content loads + await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible(); + + // Verify offline indicator + await expect(page.locator(".offline-badge")).toBeVisible(); + + // Go back online + await context.setOffline(false); + await expect(page.locator(".offline-badge")).not.toBeVisible(); +}); +``` + +### Testing Offline Fallback + +```typescript +test("shows offline page for uncached routes", async ({ page, context }) => { + await page.goto("/pwa-app"); + await page.evaluate(() => navigator.serviceWorker.ready); + + // Go offline + await context.setOffline(true); + + // Navigate to uncached page + await page.goto("/uncached-page"); + + // Should show offline fallback + await expect(page.getByText("You are offline")).toBeVisible(); + await expect(page.getByRole("button", { name: "Retry" })).toBeVisible(); +}); +``` + +### Testing Offline Form Submission + +```typescript +test("queues form submission offline", async ({ page, context }) => { + await page.goto("/pwa-app/form"); + + // Go offline + await context.setOffline(true); + + // Submit form + await page.getByLabel("Message").fill("Offline message"); + await page.getByRole("button", { name: "Send" }).click(); + + // Should show queued status + await expect(page.getByText("Queued for sync")).toBeVisible(); + + // Go online + await context.setOffline(false); + + // Trigger sync (or wait for automatic) + await page.evaluate(async () => { + const reg = await navigator.serviceWorker.ready; + // Manually trigger sync for testing + await (reg as any).sync?.register("form-sync"); + }); + + // Verify submission completed + await expect(page.getByText("Message sent")).toBeVisible({ timeout: 10000 }); +}); +``` + +## Push Notifications + +### Mocking Push Subscription + +```typescript +test("handles push subscription", async ({ page, context }) => { + // Grant notification permission + await context.grantPermissions(["notifications"]); + + await page.goto("/pwa-app"); + + // Subscribe to push + const subscription = await page.evaluate(async () => { + const reg = await navigator.serviceWorker.ready; + const sub = await reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: "test-key", + }); + return sub.toJSON(); + }); + + expect(subscription.endpoint).toBeDefined(); +}); +``` + +### Testing Push Message Handling + +```typescript +test("handles push notification", async ({ context, page }) => { + await context.grantPermissions(["notifications"]); + await page.goto("/pwa-app"); + + // Wait for SW + const swPromise = context.waitForEvent("serviceworker"); + const sw = await swPromise; + + // Simulate push message to service worker + await sw.evaluate(async () => { + // Dispatch push event + const pushEvent = new PushEvent("push", { + data: new PushMessageData( + JSON.stringify({ title: "Test", body: "Push message" }), + ), + }); + self.dispatchEvent(pushEvent); + }); + + // Note: Actual notification display testing is limited in Playwright + // Focus on verifying the SW handles the push correctly +}); +``` + +### Testing Notification Click + +```typescript +test("notification click opens page", async ({ context, page }) => { + await context.grantPermissions(["notifications"]); + await page.goto("/pwa-app"); + + // Store notification URL target + let notificationUrl = ""; + + // Listen for new pages (notification click opens new page) + context.on("page", (newPage) => { + notificationUrl = newPage.url(); + }); + + // Trigger notification via SW + await page.evaluate(async () => { + const reg = await navigator.serviceWorker.ready; + await reg.showNotification("Test", { + body: "Click me", + data: { url: "/notification-target" }, + }); + }); + + // Simulate clicking notification (via SW) + const sw = context.serviceWorkers()[0]; + await sw.evaluate(() => { + self.dispatchEvent( + new NotificationEvent("notificationclick", { + notification: { data: { url: "/notification-target" } } as any, + }), + ); + }); + + // Verify navigation occurred + await page.waitForTimeout(1000); + // Check if new page opened or current page navigated +}); +``` + +## Background Sync + +### Testing Background Sync Registration + +```typescript +test("registers background sync", async ({ page }) => { + await page.goto("/pwa-app"); + + // Register sync + const syncRegistered = await page.evaluate(async () => { + const reg = await navigator.serviceWorker.ready; + if (!("sync" in reg)) return false; + + await (reg as any).sync.register("my-sync"); + return true; + }); + + expect(syncRegistered).toBe(true); +}); +``` + +### Testing Sync Event + +```typescript +test("sync event fires when online", async ({ context, page }) => { + await page.goto("/pwa-app"); + + // Queue data while offline + await context.setOffline(true); + + await page.evaluate(async () => { + // Store data in IndexedDB for sync + const db = await openDB(); + await db.put("sync-queue", { id: 1, data: "test" }); + + // Register sync + const reg = await navigator.serviceWorker.ready; + await (reg as any).sync.register("data-sync"); + }); + + // Track sync completion + await page.evaluate(() => { + window.syncCompleted = false; + navigator.serviceWorker.addEventListener("message", (e) => { + if (e.data.type === "SYNC_COMPLETE") { + window.syncCompleted = true; + } + }); + }); + + // Go online + await context.setOffline(false); + + // Wait for sync to complete + await page.waitForFunction(() => window.syncCompleted, { timeout: 10000 }); +}); +``` + +## Anti-Patterns to Avoid + +| Anti-Pattern | Problem | Solution | +| ------------------------------ | ----------------------- | -------------------------------------------- | +| Not clearing SW between tests | Tests affect each other | Unregister SW in beforeEach | +| Not waiting for SW ready | Race conditions | Always await `navigator.serviceWorker.ready` | +| Testing in isolation only | Misses real SW behavior | Test with actual caching | +| Hardcoded timeouts for caching | Flaky tests | Wait for cache to populate | +| Ignoring SW update cycle | Missing update bugs | Test install, activate, update flows | + +## Related References + +- **Network Failures**: See [error-testing.md](error-testing.md#offline-testing) for unexpected network failure patterns +- **Browser APIs**: See [browser-apis.md](browser-apis.md) for permissions +- **Network Mocking**: See [network-advanced.md](../advanced/network-advanced.md) for network interception +- **Browser Extensions**: See [browser-extensions.md](../testing-patterns/browser-extensions.md) for extension service worker patterns diff --git a/plugins/software-delivery/skills/playwright-best-practices/browser-apis/websockets.md b/plugins/software-delivery/skills/playwright-best-practices/browser-apis/websockets.md new file mode 100644 index 0000000..075a997 --- /dev/null +++ b/plugins/software-delivery/skills/playwright-best-practices/browser-apis/websockets.md @@ -0,0 +1,403 @@ +# WebSocket & Real-Time Testing + +## Table of Contents + +1. [WebSocket Basics](#websocket-basics) +2. [Mocking WebSocket Messages](#mocking-websocket-messages) +3. [Testing Real-Time Features](#testing-real-time-features) +4. [Server-Sent Events](#server-sent-events) +5. [Reconnection Testing](#reconnection-testing) + +## WebSocket Basics + +### Wait for WebSocket Connection + +```typescript +test("chat connects via websocket", async ({ page }) => { + // Listen for WebSocket connection + const wsPromise = page.waitForEvent("websocket"); + + await page.goto("/chat"); + + const ws = await wsPromise; + expect(ws.url()).toContain("/ws/chat"); + + // Wait for connection to be established + await ws.waitForEvent("framesent"); +}); +``` + +### Monitor WebSocket Messages + +```typescript +test("receives real-time updates", async ({ page }) => { + const messages: string[] = []; + + // Set up listener before navigation + page.on("websocket", (ws) => { + ws.on("framereceived", (frame) => { + messages.push(frame.payload as string); + }); + }); + + await page.goto("/dashboard"); + + // Wait for some messages + await expect.poll(() => messages.length).toBeGreaterThan(0); + + // Verify message format + const data = JSON.parse(messages[0]); + expect(data).toHaveProperty("type"); +}); +``` + +### Capture Sent Messages + +```typescript +test("sends correct message format", async ({ page }) => { + const sentMessages: string[] = []; + + page.on("websocket", (ws) => { + ws.on("framesent", (frame) => { + sentMessages.push(frame.payload as string); + }); + }); + + await page.goto("/chat"); + await page.getByLabel("Message").fill("Hello!"); + await page.getByRole("button", { name: "Send" }).click(); + + // Verify sent message + await expect.poll(() => sentMessages.length).toBeGreaterThan(0); + + const sent = JSON.parse(sentMessages[sentMessages.length - 1]); + expect(sent).toEqual({ + type: "message", + content: "Hello!", + }); +}); +``` + +## Mocking WebSocket Messages + +### Inject Messages via Page Evaluate + +```typescript +test("displays incoming chat message", async ({ page }) => { + await page.goto("/chat"); + + // Wait for WebSocket to be ready + await page.waitForFunction( + () => (window as any).chatSocket?.readyState === 1, + ); + + // Simulate incoming message + await page.evaluate(() => { + const event = new MessageEvent("message", { + data: JSON.stringify({ + type: "message", + from: "Alice", + content: "Hello there!", + }), + }); + (window as any).chatSocket.dispatchEvent(event); + }); + + await expect(page.getByText("Alice: Hello there!")).toBeVisible(); +}); +``` + +### Mock WebSocket with Route Handler + +```typescript +test("mock websocket entirely", async ({ page, context }) => { + // Intercept the WebSocket upgrade + await context.route("**/ws/**", async (route) => { + // For WebSocket routes, we can't fulfill directly + // Instead, use page.evaluate to mock the client-side + }); + + // Alternative: Mock at application level + await page.addInitScript(() => { + const OriginalWebSocket = window.WebSocket; + (window as any).WebSocket = function (url: string) { + const ws = { + readyState: 1, + send: (data: string) => { + console.log("WS Send:", data); + }, + close: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + }; + setTimeout(() => ws.onopen?.(), 100); + return ws; + }; + }); + + await page.goto("/chat"); +}); +``` + +### WebSocket Mock Fixture + +```typescript +// fixtures/websocket.fixture.ts +import { test as base, Page } from "@playwright/test"; + +type WsMessage = { type: string; [key: string]: any }; + +type WebSocketFixtures = { + mockWebSocket: { + injectMessage: (message: WsMessage) => Promise; + getSentMessages: () => Promise; + }; +}; + +export const test = base.extend({ + mockWebSocket: async ({ page }, use) => { + const sentMessages: WsMessage[] = []; + + // Capture sent messages + await page.addInitScript(() => { + (window as any).__wsSent = []; + const OriginalWebSocket = window.WebSocket; + window.WebSocket = function (url: string) { + const ws = new OriginalWebSocket(url); + const originalSend = ws.send.bind(ws); + ws.send = (data: string) => { + (window as any).__wsSent.push(JSON.parse(data)); + originalSend(data); + }; + (window as any).__ws = ws; + return ws; + } as any; + }); + + await use({ + injectMessage: async (message) => { + await page.evaluate((msg) => { + const event = new MessageEvent("message", { + data: JSON.stringify(msg), + }); + (window as any).__ws?.dispatchEvent(event); + }, message); + }, + getSentMessages: async () => { + return page.evaluate(() => (window as any).__wsSent || []); + }, + }); + }, +}); + +// Usage +test("chat with mocked websocket", async ({ page, mockWebSocket }) => { + await page.goto("/chat"); + + // Inject incoming message + await mockWebSocket.injectMessage({ + type: "message", + from: "Bob", + content: "Hi!", + }); + + await expect(page.getByText("Bob: Hi!")).toBeVisible(); + + // Send a reply + await page.getByLabel("Message").fill("Hello Bob!"); + await page.getByRole("button", { name: "Send" }).click(); + + // Verify sent message + const sent = await mockWebSocket.getSentMessages(); + expect(sent).toContainEqual( + expect.objectContaining({ content: "Hello Bob!" }), + ); +}); +``` + +## Testing Real-Time Features + +### Live Notifications + +```typescript +test("displays live notification", async ({ page }) => { + await page.goto("/dashboard"); + + // Simulate notification via WebSocket + await page.evaluate(() => { + const event = new MessageEvent("message", { + data: JSON.stringify({ + type: "notification", + title: "New Order", + message: "Order #123 received", + }), + }); + (window as any).notificationSocket.dispatchEvent(event); + }); + + await expect(page.getByRole("alert")).toContainText("Order #123 received"); +}); +``` + +### Live Data Updates + +```typescript +test("updates stock price in real-time", async ({ page }) => { + await page.goto("/stocks/AAPL"); + + const priceElement = page.getByTestId("stock-price"); + const initialPrice = await priceElement.textContent(); + + // Simulate price update + await page.evaluate(() => { + const event = new MessageEvent("message", { + data: JSON.stringify({ + type: "price_update", + symbol: "AAPL", + price: 150.25, + }), + }); + (window as any).stockSocket.dispatchEvent(event); + }); + + await expect(priceElement).not.toHaveText(initialPrice!); + await expect(priceElement).toContainText("150.25"); +}); +``` + +### Collaborative Editing + +```typescript +test("shows collaborator cursor", async ({ page }) => { + await page.goto("/document/123"); + + // Simulate another user's cursor position + await page.evaluate(() => { + const event = new MessageEvent("message", { + data: JSON.stringify({ + type: "cursor", + userId: "user-456", + userName: "Alice", + position: { x: 100, y: 200 }, + }), + }); + (window as any).docSocket.dispatchEvent(event); + }); + + await expect(page.getByTestId("cursor-user-456")).toBeVisible(); + await expect(page.getByText("Alice")).toBeVisible(); +}); +``` + +## Server-Sent Events + +### Test SSE Updates + +```typescript +test("receives SSE updates", async ({ page }) => { + // Mock SSE endpoint + await page.route("**/api/events", (route) => { + route.fulfill({ + status: 200, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + body: `data: {"type":"update","value":42}\n\n`, + }); + }); + + await page.goto("/live-data"); + + await expect(page.getByTestId("value")).toHaveText("42"); +}); +``` + +### Simulate Multiple SSE Events + +```typescript +test("handles multiple SSE events", async ({ page }) => { + await page.route("**/api/events", async (route) => { + const encoder = new TextEncoder(); + const events = [ + `data: {"count":1}\n\n`, + `data: {"count":2}\n\n`, + `data: {"count":3}\n\n`, + ]; + + route.fulfill({ + status: 200, + headers: { "Content-Type": "text/event-stream" }, + body: events.join(""), + }); + }); + + await page.goto("/counter"); + + // Should receive all events + await expect(page.getByTestId("count")).toHaveText("3"); +}); +``` + +## Reconnection Testing + +### Test Connection Loss + +```typescript +test("handles connection loss gracefully", async ({ page }) => { + await page.goto("/chat"); + + // Simulate connection close + await page.evaluate(() => { + (window as any).chatSocket.close(); + }); + + // Should show disconnected state + await expect(page.getByText("Reconnecting...")).toBeVisible(); +}); +``` + +### Test Reconnection + +```typescript +test("reconnects after connection loss", async ({ page }) => { + await page.goto("/chat"); + + // Simulate disconnect + await page.evaluate(() => { + (window as any).chatSocket.close(); + }); + + await expect(page.getByText("Reconnecting...")).toBeVisible(); + + // Simulate reconnection + await page.evaluate(() => { + const event = new Event("open"); + (window as any).chatSocket = { readyState: 1 }; + (window as any).chatSocket.dispatchEvent?.(event); + }); + + // Force component to re-check connection + await page.evaluate(() => { + window.dispatchEvent(new Event("online")); + }); + + await expect(page.getByText("Connected")).toBeVisible(); +}); +``` + +## Anti-Patterns to Avoid + +| Anti-Pattern | Problem | Solution | +| ------------------------------------- | ----------------------------- | ---------------------------------- | +| Not waiting for WebSocket ready | Messages sent too early | Wait for `readyState === 1` | +| Testing against real WebSocket server | Flaky, timing-dependent | Mock WebSocket messages | +| Ignoring connection state | Tests pass but feature broken | Test connected/disconnected states | +| No cleanup of listeners | Memory leaks in tests | Clean up event listeners | + +## Related References + +- **Network**: See [network-advanced.md](../advanced/network-advanced.md) for HTTP mocking patterns +- **Assertions**: See [assertions-waiting.md](../core/assertions-waiting.md) for polling patterns +- **Multi-User**: See [multi-user.md](../advanced/multi-user.md) for real-time collaboration testing with multiple users diff --git a/plugins/software-delivery/skills/playwright-best-practices/core/annotations.md b/plugins/software-delivery/skills/playwright-best-practices/core/annotations.md new file mode 100644 index 0000000..ac0f890 --- /dev/null +++ b/plugins/software-delivery/skills/playwright-best-practices/core/annotations.md @@ -0,0 +1,424 @@ +# Test Annotations & Organization + +## Table of Contents + +1. [Skip Annotations](#skip-annotations) +2. [Fixme & Fail Annotations](#fixme--fail-annotations) +3. [Slow Tests](#slow-tests) +4. [Test Steps](#test-steps) +5. [Custom Annotations](#custom-annotations) +6. [Conditional Annotations](#conditional-annotations) + +## Skip Annotations + +### Basic Skip + +```typescript +// Skip unconditionally +test.skip("feature not implemented", async ({ page }) => { + // This test won't run +}); + +// Skip with reason +test("payment flow", async ({ page }) => { + test.skip(true, "Payment gateway in maintenance"); + // Test body won't execute +}); +``` + +### Conditional Skip + +```typescript +test("webkit-specific feature", async ({ page, browserName }) => { + test.skip(browserName !== "webkit", "This feature only works in WebKit"); + + await page.goto("/webkit-feature"); +}); + +test("production only", async ({ page }) => { + test.skip(process.env.ENV !== "production", "Only runs against production"); + + await page.goto("/prod-feature"); +}); +``` + +### Skip by Platform + +```typescript +test("windows-specific", async ({ page }) => { + test.skip(process.platform !== "win32", "Windows only"); +}); + +test("not on CI", async ({ page }) => { + test.skip(!!process.env.CI, "Skipped in CI environment"); +}); +``` + +### Skip Describe Block + +```typescript +test.describe("Admin features", () => { + test.skip( + ({ browserName }) => browserName === "firefox", + "Firefox admin bug", + ); + + test("admin dashboard", async ({ page }) => { + // Skipped in Firefox + }); + + test("admin settings", async ({ page }) => { + // Skipped in Firefox + }); +}); +``` + +## Fixme & Fail Annotations + +### Fixme - Known Issues + +```typescript +// Mark test as needing fix (skips the test) +test.fixme("broken after refactor", async ({ page }) => { + // Test won't run but is tracked +}); + +// Conditional fixme +test("flaky on CI", async ({ page }) => { + test.fixme(!!process.env.CI, "Investigate CI flakiness - ticket #123"); + + await page.goto("/flaky-feature"); +}); +``` + +### Fail - Expected Failures + +```typescript +// Test is expected to fail (runs but expects failure) +test("known bug", async ({ page }) => { + test.fail(); + + await page.goto("/buggy-page"); + // If this passes, the test fails (bug was fixed!) + await expect(page.getByText("Working")).toBeVisible(); +}); + +// Conditional fail +test("fails on webkit", async ({ page, browserName }) => { + test.fail(browserName === "webkit", "WebKit rendering bug #456"); + + await page.goto("/render-test"); + await expect(page.getByTestId("element")).toHaveCSS("width", "100px"); +}); +``` + +### Difference Between Skip, Fixme, Fail + +| Annotation | Runs? | Use Case | +| -------------- | ----- | -------------------------------- | +| `test.skip()` | No | Feature not applicable | +| `test.fixme()` | No | Known bug, needs investigation | +| `test.fail()` | Yes | Expected to fail, tracking a bug | + +## Slow Tests + +### Mark Slow Tests + +```typescript +// Triple the default timeout +test("large data import", async ({ page }) => { + test.slow(); + + await page.goto("/import"); + await page.setInputFiles("#file", "large-file.csv"); + await page.getByRole("button", { name: "Import" }).click(); + + await expect(page.getByText("Import complete")).toBeVisible(); +}); + +// Conditional slow +test("video processing", async ({ page, browserName }) => { + test.slow(browserName === "webkit", "WebKit video processing is slow"); + + await page.goto("/video-editor"); +}); +``` + +### Custom Timeout + +```typescript +test("very long operation", async ({ page }) => { + // Set specific timeout (in milliseconds) + test.setTimeout(120000); // 2 minutes + + await page.goto("/long-operation"); +}); + +// Timeout for describe block +test.describe("Integration tests", () => { + test.describe.configure({ timeout: 60000 }); + + test("test 1", async ({ page }) => { + // Has 60 second timeout + }); +}); +``` + +## Test Steps + +### Basic Steps + +```typescript +test("checkout flow", async ({ page }) => { + await test.step("Add item to cart", async () => { + await page.goto("/products"); + await page.getByRole("button", { name: "Add to Cart" }).click(); + }); + + await test.step("Go to checkout", async () => { + await page.getByRole("link", { name: "Cart" }).click(); + await page.getByRole("button", { name: "Checkout" }).click(); + }); + + await test.step("Fill shipping info", async () => { + await page.getByLabel("Address").fill("123 Test St"); + await page.getByLabel("City").fill("Test City"); + }); + + await test.step("Complete payment", async () => { + await page.getByLabel("Card").fill("4242424242424242"); + await page.getByRole("button", { name: "Pay" }).click(); + }); + + await expect(page.getByText("Order confirmed")).toBeVisible(); +}); +``` + +### Nested Steps + +```typescript +test("user registration", async ({ page }) => { + await test.step("Fill registration form", async () => { + await page.goto("/register"); + + await test.step("Personal info", async () => { + await page.getByLabel("Name").fill("John Doe"); + await page.getByLabel("Email").fill("john@example.com"); + }); + + await test.step("Security", async () => { + await page.getByLabel("Password").fill("SecurePass123"); + await page.getByLabel("Confirm Password").fill("SecurePass123"); + }); + }); + + await test.step("Submit and verify", async () => { + await page.getByRole("button", { name: "Register" }).click(); + await expect(page.getByText("Welcome")).toBeVisible(); + }); +}); +``` + +### Steps with Return Values + +```typescript +test("verify order", async ({ page }) => { + const orderId = await test.step("Create order", async () => { + await page.goto("/checkout"); + await page.getByRole("button", { name: "Place Order" }).click(); + + // Return value from step + return await page.getByTestId("order-id").textContent(); + }); + + await test.step("Verify order details", async () => { + await page.goto(`/orders/${orderId}`); + await expect(page.getByText(`Order #${orderId}`)).toBeVisible(); + }); +}); +``` + +### Step in Page Object + +```typescript +// pages/checkout.page.ts +export class CheckoutPage { + async fillShippingInfo(address: string, city: string) { + await test.step("Fill shipping information", async () => { + await this.page.getByLabel("Address").fill(address); + await this.page.getByLabel("City").fill(city); + }); + } + + async completePayment(cardNumber: string) { + await test.step("Complete payment", async () => { + await this.page.getByLabel("Card").fill(cardNumber); + await this.page.getByRole("button", { name: "Pay" }).click(); + }); + } +} +``` + +## Custom Annotations + +### Add Annotations + +```typescript +test("important feature", async ({ page }, testInfo) => { + // Add custom annotation + testInfo.annotations.push({ + type: "priority", + description: "high", + }); + + testInfo.annotations.push({ + type: "ticket", + description: "JIRA-123", + }); + + await page.goto("/feature"); +}); +``` + +### Annotation Fixture + +```typescript +// fixtures/annotations.fixture.ts +import { test as base, TestInfo } from "@playwright/test"; + +type AnnotationFixtures = { + annotate: { + ticket: (id: string) => void; + priority: (level: "low" | "medium" | "high") => void; + owner: (name: string) => void; + }; +}; + +export const test = base.extend({ + annotate: async ({}, use, testInfo) => { + await use({ + ticket: (id) => { + testInfo.annotations.push({ type: "ticket", description: id }); + }, + priority: (level) => { + testInfo.annotations.push({ type: "priority", description: level }); + }, + owner: (name) => { + testInfo.annotations.push({ type: "owner", description: name }); + }, + }); + }, +}); + +// Usage +test("critical feature", async ({ page, annotate }) => { + annotate.ticket("JIRA-456"); + annotate.priority("high"); + annotate.owner("Alice"); + + await page.goto("/critical"); +}); +``` + +### Read Annotations in Reporter + +```typescript +// reporters/annotation-reporter.ts +import { Reporter, TestCase, TestResult } from "@playwright/test/reporter"; + +class AnnotationReporter implements Reporter { + onTestEnd(test: TestCase, result: TestResult) { + const ticket = test.annotations.find((a) => a.type === "ticket"); + const priority = test.annotations.find((a) => a.type === "priority"); + + if (ticket) { + console.log(`Test linked to: ${ticket.description}`); + } + + if (priority?.description === "high" && result.status === "failed") { + console.log(`HIGH PRIORITY FAILURE: ${test.title}`); + } + } +} + +export default AnnotationReporter; +``` + +## Conditional Annotations + +### Annotation Helper + +```typescript +// helpers/test-annotations.ts +import { test } from "@playwright/test"; + +export function skipInCI(reason = "Skipped in CI") { + test.skip(!!process.env.CI, reason); +} + +export function skipInBrowser(browser: string, reason: string) { + test.beforeEach(({ browserName }) => { + test.skip(browserName === browser, reason); + }); +} + +export function onlyInEnv(env: string) { + test.skip(process.env.ENV !== env, `Only runs in ${env}`); +} +``` + +```typescript +// tests/feature.spec.ts +import { skipInCI, onlyInEnv } from "../helpers/test-annotations"; + +test("local only feature", async ({ page }) => { + skipInCI("Uses local resources"); + + await page.goto("/local-feature"); +}); + +test("production check", async ({ page }) => { + onlyInEnv("production"); + + await page.goto("/prod-only"); +}); +``` + +### Describe-Level Conditions + +```typescript +test.describe("Mobile features", () => { + test.beforeEach(({ isMobile }) => { + test.skip(!isMobile, "Mobile only tests"); + }); + + test("touch gestures", async ({ page }) => { + // Only runs on mobile + }); +}); + +test.describe("Desktop features", () => { + test.beforeEach(({ isMobile }) => { + test.skip(isMobile, "Desktop only tests"); + }); + + test("hover interactions", async ({ page }) => { + // Only runs on desktop + }); +}); +``` + +## Anti-Patterns to Avoid + +| Anti-Pattern | Problem | Solution | +| --------------------------- | ---------------------- | -------------------------------- | +| Skipping without reason | Hard to track why | Always provide description | +| Too many skipped tests | Test debt accumulates | Review and clean up regularly | +| Using skip instead of fixme | Loses intent | Use fixme for bugs, skip for N/A | +| Not using steps | Hard to debug failures | Group logical actions in steps | + +## Related References + +- **Test Tags**: See [test-tags.md](test-tags.md) for tagging and filtering tests with `--grep` +- **Test Organization**: See [test-suite-structure.md](test-suite-structure.md) for structuring tests +- **Debugging**: See [debugging.md](../debugging/debugging.md) for troubleshooting diff --git a/plugins/software-delivery/skills/playwright-best-practices/core/assertions-waiting.md b/plugins/software-delivery/skills/playwright-best-practices/core/assertions-waiting.md new file mode 100644 index 0000000..bd03dd8 --- /dev/null +++ b/plugins/software-delivery/skills/playwright-best-practices/core/assertions-waiting.md @@ -0,0 +1,361 @@ +# Assertions & Waiting + +## Table of Contents + +1. [Web-First Assertions](#web-first-assertions) +2. [Generic Assertions](#generic-assertions) +3. [Soft Assertions](#soft-assertions) +4. [Waiting Strategies](#waiting-strategies) +5. [Polling & Retrying](#polling--retrying) +6. [Custom Matchers](#custom-matchers) + +## Web-First Assertions + +Auto-retry until condition is met or timeout. Always prefer these over generic assertions. + +### Locator Assertions + +```typescript +import { expect } from "@playwright/test"; + +// Visibility +await expect(page.getByRole("button")).toBeVisible(); +await expect(page.getByRole("button")).toBeHidden(); +await expect(page.getByRole("button")).not.toBeVisible(); + +// Enabled/Disabled +await expect(page.getByRole("button")).toBeEnabled(); +await expect(page.getByRole("button")).toBeDisabled(); + +// Text content +await expect(page.getByRole("heading")).toHaveText("Welcome"); +await expect(page.getByRole("heading")).toHaveText(/welcome/i); +await expect(page.getByRole("heading")).toContainText("Welcome"); + +// Count +await expect(page.getByRole("listitem")).toHaveCount(5); + +// Attributes +await expect(page.getByRole("link")).toHaveAttribute("href", "/home"); +await expect(page.getByRole("img")).toHaveAttribute("alt", /logo/i); + +// CSS +await expect(page.getByRole("button")).toHaveClass(/primary/); +await expect(page.getByRole("button")).toHaveCSS("color", "rgb(0, 0, 255)"); + +// Input values +await expect(page.getByLabel("Email")).toHaveValue("user@example.com"); +await expect(page.getByLabel("Email")).toBeEmpty(); + +// Focus +await expect(page.getByLabel("Email")).toBeFocused(); + +// Checked state +await expect(page.getByRole("checkbox")).toBeChecked(); +await expect(page.getByRole("checkbox")).not.toBeChecked(); + +// Editable state +await expect(page.getByLabel("Name")).toBeEditable(); +``` + +### Page Assertions + +```typescript +// URL +await expect(page).toHaveURL("/dashboard"); +await expect(page).toHaveURL(/\/dashboard/); + +// Title +await expect(page).toHaveTitle("Dashboard - MyApp"); +await expect(page).toHaveTitle(/dashboard/i); +``` + +### Response Assertions + +```typescript +const response = await page.request.get("/api/users"); +await expect(response).toBeOK(); +await expect(response).not.toBeOK(); +``` + +## Generic Assertions + +Use for non-UI values. Do NOT retry - execute immediately. + +```typescript +// Equality +expect(value).toBe(5); +expect(object).toEqual({ name: "Test" }); +expect(array).toContain("item"); + +// Truthiness +expect(value).toBeTruthy(); +expect(value).toBeFalsy(); +expect(value).toBeNull(); +expect(value).toBeUndefined(); +expect(value).toBeDefined(); + +// Numbers +expect(value).toBeGreaterThan(5); +expect(value).toBeLessThanOrEqual(10); +expect(value).toBeCloseTo(5.5, 1); + +// Strings +expect(string).toMatch(/pattern/); +expect(string).toContain("substring"); + +// Arrays/Objects +expect(array).toHaveLength(3); +expect(object).toHaveProperty("key", "value"); + +// Exceptions +expect(() => fn()).toThrow(); +expect(() => fn()).toThrow("error message"); +await expect(asyncFn()).rejects.toThrow(); +``` + +## Soft Assertions + +Continue test execution after failure, report all failures at end. + +```typescript +test("check multiple elements", async ({ page }) => { + await page.goto("/dashboard"); + + // Won't stop on first failure + await expect.soft(page.getByRole("heading")).toHaveText("Dashboard"); + await expect.soft(page.getByRole("button", { name: "Save" })).toBeEnabled(); + await expect.soft(page.getByText("Welcome")).toBeVisible(); + + // Test continues; all failures reported at end +}); +``` + +### Soft Assertions with Early Exit + +```typescript +test("check form", async ({ page }) => { + await expect.soft(page.getByRole("form")).toBeVisible(); + + // Exit early if form not visible (pointless to check fields) + if (expect.soft.hasFailures()) { + return; + } + + await expect.soft(page.getByLabel("Name")).toBeVisible(); + await expect.soft(page.getByLabel("Email")).toBeVisible(); +}); +``` + +## Waiting Strategies + +### Auto-Waiting (Default) + +Actions automatically wait for: + +- Element to be attached to DOM +- Element to be visible +- Element to be stable (no animations) +- Element to be enabled +- Element to receive events + +```typescript +// These auto-wait +await page.click("button"); +await page.fill("input", "text"); +await page.getByRole("button").click(); +``` + +### Wait for Navigation + +```typescript +// Wait for URL change +await page.waitForURL("/dashboard"); +await page.waitForURL(/\/dashboard/); + +// Wait for navigation after action +await Promise.all([ + page.waitForURL("**/dashboard"), + page.click('a[href="/dashboard"]'), +]); + +// Or without Promise.all +const urlPromise = page.waitForURL("**/dashboard"); +await page.click("a"); +await urlPromise; +``` + +### Wait for Network + +```typescript +// Wait for specific response +const responsePromise = page.waitForResponse("**/api/users"); +await page.click("button"); +const response = await responsePromise; +expect(response.status()).toBe(200); + +// Wait for request +const requestPromise = page.waitForRequest("**/api/submit"); +await page.click("button"); +const request = await requestPromise; + +// Wait for no network activity +await page.waitForLoadState("networkidle"); +``` + +### Wait for Element State + +```typescript +// Wait for element to appear +await page.getByRole("dialog").waitFor({ state: "visible" }); + +// Wait for element to disappear +await page.getByText("Loading...").waitFor({ state: "hidden" }); + +// Wait for element to be attached +await page.getByTestId("result").waitFor({ state: "attached" }); + +// Wait for element to be detached +await page.getByTestId("modal").waitFor({ state: "detached" }); +``` + +### Wait for Function + +```typescript +// Wait for arbitrary condition +await page.waitForFunction(() => { + return document.querySelector(".loaded") !== null; +}); + +// With arguments +await page.waitForFunction( + (selector) => document.querySelector(selector)?.textContent === "Ready", + ".status", +); +``` + +## Polling & Retrying + +### toPass() for Polling + +Retry until block passes or times out: + +```typescript +await expect(async () => { + const response = await page.request.get("/api/status"); + expect(response.status()).toBe(200); + + const data = await response.json(); + expect(data.ready).toBe(true); +}).toPass({ + intervals: [1000, 2000, 5000], // Retry intervals + timeout: 30000, +}); +``` + +### expect.poll() + +Poll a function until assertion passes: + +```typescript +// Poll API until condition met +await expect + .poll( + async () => { + const response = await page.request.get("/api/job/123"); + return (await response.json()).status; + }, + { + intervals: [1000, 2000, 5000], + timeout: 30000, + }, + ) + .toBe("completed"); + +// Poll DOM value +await expect.poll(() => page.getByTestId("counter").textContent()).toBe("10"); +``` + +## Custom Matchers + +```typescript +// playwright.config.ts or fixtures +import { expect } from "@playwright/test"; + +expect.extend({ + async toHaveDataLoaded(page: Page) { + const locator = page.getByTestId("data-container"); + let pass = false; + let message = ""; + + try { + await expect(locator).toBeVisible(); + await expect(locator).not.toContainText("Loading"); + pass = true; + } catch (e) { + message = `Expected data to be loaded but found loading state`; + } + + return { pass, message: () => message }; + }, +}); + +// Extend TypeScript types +declare global { + namespace PlaywrightTest { + interface Matchers { + toHaveDataLoaded(): Promise; + } + } +} + +// Usage +await expect(page).toHaveDataLoaded(); +``` + +## Timeouts + +### Configure Timeouts + +```typescript +// playwright.config.ts +export default defineConfig({ + timeout: 30000, // Test timeout + expect: { + timeout: 5000, // Assertion timeout + }, +}); + +// Per-test timeout +test("long test", async ({ page }) => { + test.setTimeout(60000); + // ... +}); + +// Per-assertion timeout +await expect(page.getByRole("button")).toBeVisible({ timeout: 10000 }); +``` + +## Best Practices + +| Do | Don't | +| ------------------------------ | ------------------------------ | +| Use web-first assertions | Use generic assertions for DOM | +| Let auto-waiting work | Add unnecessary explicit waits | +| Use `toPass()` for polling | Write manual retry loops | +| Configure appropriate timeouts | Use `waitForTimeout()` | +| Check specific conditions | Wait for arbitrary time | + +## Anti-Patterns to Avoid + +| Anti-Pattern | Problem | Solution | +| --------------------------------------------------------- | ----------------------------- | -------------------------------------------- | +| `await page.waitForTimeout(5000)` | Slow, flaky, arbitrary timing | Use auto-waiting or `waitForResponse` | +| `await new Promise(resolve => setTimeout(resolve, 1000))` | Same as above | Use `waitForResponse` or element state waits | +| Generic assertions on DOM elements | No auto-retry, flaky | Use web-first assertions with `expect()` | + +## Related References + +- **Debugging timeout issues**: See [debugging.md](../debugging/debugging.md) for troubleshooting +- **Fixing flaky tests**: See [debugging.md](../debugging/debugging.md) for race condition solutions +- **Network interception**: See [test-suite-structure.md](test-suite-structure.md) for API mocking diff --git a/plugins/software-delivery/skills/playwright-best-practices/core/configuration.md b/plugins/software-delivery/skills/playwright-best-practices/core/configuration.md new file mode 100644 index 0000000..66b9d33 --- /dev/null +++ b/plugins/software-delivery/skills/playwright-best-practices/core/configuration.md @@ -0,0 +1,452 @@ +# Playwright Configuration + +## Table of Contents + +1. [CLI Quick Reference](#cli-quick-reference) +2. [Decision Guide](#decision-guide) +3. [Production-Ready Config](#production-ready-config) +4. [Patterns](#patterns) +5. [Anti-Patterns](#anti-patterns) +6. [Troubleshooting](#troubleshooting) +7. [Related](#related) + +> **When to use**: Setting up a new project, adjusting timeouts, adding browser targets, configuring CI behavior, or managing environment-specific settings. + +## CLI Quick Reference + +```bash +npx playwright init # scaffold config + first test +npx playwright test --config=custom.config.ts # use alternate config +npx playwright test --project=chromium # run single project +npx playwright test --reporter=html # override reporter +npx playwright test --grep @smoke # run tests tagged @smoke +npx playwright test --grep-invert @slow # exclude @slow tests +npx playwright show-report # open last HTML report +DEBUG=pw:api npx playwright test # verbose logging +``` + +## Decision Guide + +### Timeout Selection + +| Symptom | Setting | Default | Recommended | +|---------|---------|---------|-------------| +| Test takes too long overall | `timeout` | 30s | 30-60s (max 120s) | +| Assertion retries too long/short | `expect.timeout` | 5s | 5-10s | +| `page.goto()` or `waitForURL()` times out | `navigationTimeout` | 30s | 10-30s | +| `click()`, `fill()` time out | `actionTimeout` | 0 (unlimited) | 10-15s | +| Dev server slow to start | `webServer.timeout` | 60s | 60-180s | + +### Server Management + +| Scenario | Approach | +|----------|----------| +| App in same repo | `webServer` with `reuseExistingServer: !process.env.CI` | +| Separate repos | Manual start or Docker Compose | +| Testing deployed environment | No `webServer`; set `baseURL` via env | +| Multiple services | Array of `webServer` entries | + +### Single vs Multi-Project + +| Scenario | Approach | +|----------|----------| +| Early development | Single project (chromium only) | +| Pre-release validation | Multi-project: chromium + firefox + webkit | +| Mobile-responsive app | Add mobile projects alongside desktop | +| Auth + non-auth tests | Setup project with dependencies | +| Tight CI budget | Chromium on PRs; all browsers on main | + +### globalSetup vs Setup Projects vs Fixtures + +| Need | Use | +|------|-----| +| One-time DB seed | `globalSetup` | +| Shared browser auth | Setup project with `dependencies` | +| Per-test isolated state | Custom fixture via `test.extend()` | +| Cleanup after all tests | `globalTeardown` | + +## Production-Ready Config + +```ts +// playwright.config.ts +import { defineConfig, devices } from '@playwright/test'; +import dotenv from 'dotenv'; +import path from 'path'; + +dotenv.config({ path: path.resolve(__dirname, '.env') }); + +export default defineConfig({ + testDir: './e2e', + testMatch: '**/*.spec.ts', + + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? '50%' : undefined, + + reporter: process.env.CI + ? [['html', { open: 'never' }], ['github']] + : [['html', { open: 'on-failure' }]], + + timeout: 30_000, + expect: { timeout: 5_000 }, + + use: { + baseURL: process.env.BASE_URL || 'http://localhost:4000', + actionTimeout: 10_000, + navigationTimeout: 15_000, + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + locale: 'en-US', + timezoneId: 'America/Los_Angeles', + }, + + projects: [ + { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + { name: 'firefox', use: { ...devices['Desktop Firefox'] } }, + { name: 'webkit', use: { ...devices['Desktop Safari'] } }, + { name: 'mobile-chrome', use: { ...devices['Pixel 7'] } }, + { name: 'mobile-safari', use: { ...devices['iPhone 14'] } }, + ], + + webServer: { + command: 'npm run start', + url: 'http://localhost:4000', + reuseExistingServer: !process.env.CI, + timeout: 120_000, + stdout: 'pipe', + stderr: 'pipe', + }, +}); +``` + +## Patterns + +### Environment-Specific Configuration + +**Use when**: Tests run against dev, staging, and production environments. + +```ts +// playwright.config.ts +import { defineConfig } from '@playwright/test'; +import dotenv from 'dotenv'; +import path from 'path'; + +const ENV = process.env.TEST_ENV || 'local'; +dotenv.config({ path: path.resolve(__dirname, `.env.${ENV}`) }); + +const envConfig: Record = { + local: { baseURL: 'http://localhost:4000', retries: 0 }, + staging: { baseURL: 'https://staging.myapp.com', retries: 2 }, + prod: { baseURL: 'https://myapp.com', retries: 2 }, +}; + +export default defineConfig({ + testDir: './e2e', + retries: envConfig[ENV].retries, + use: { baseURL: envConfig[ENV].baseURL }, +}); +``` + +```bash +TEST_ENV=staging npx playwright test +TEST_ENV=prod npx playwright test --grep @smoke +``` + +### Setup Project with Dependencies + +**Use when**: Tests need shared authentication state before running. + +```ts +// playwright.config.ts +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + projects: [ + { + name: 'setup', + testMatch: /auth\.setup\.ts/, + }, + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + storageState: 'playwright/.auth/session.json', + }, + dependencies: ['setup'], + }, + { + name: 'firefox', + use: { + ...devices['Desktop Firefox'], + storageState: 'playwright/.auth/session.json', + }, + dependencies: ['setup'], + }, + ], +}); +``` + +```ts +// e2e/auth.setup.ts +import { test as setup, expect } from '@playwright/test'; + +const authFile = 'playwright/.auth/session.json'; + +setup('authenticate', async ({ page }) => { + await page.goto('/login'); + await page.getByLabel('Username').fill('testuser@example.com'); + await page.getByLabel('Password').fill(process.env.TEST_PASSWORD!); + await page.getByRole('button', { name: 'Log in' }).click(); + await expect(page.getByRole('heading', { name: 'Home' })).toBeVisible(); + await page.context().storageState({ path: authFile }); +}); +``` + +### webServer with Build Step + +**Use when**: Tests need a running application server managed by Playwright. + +```ts +// playwright.config.ts +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + use: { baseURL: 'http://localhost:4000' }, + webServer: { + command: process.env.CI + ? 'npm run build && npm run preview' + : 'npm run dev', + url: 'http://localhost:4000', + reuseExistingServer: !process.env.CI, + timeout: 120_000, + env: { + NODE_ENV: 'test', + DB_URL: process.env.DB_URL || 'postgresql://localhost:5432/testdb', + }, + }, +}); +``` + +### globalSetup / globalTeardown + +**Use when**: One-time non-browser work like seeding a database. Runs once per test run. + +```ts +// playwright.config.ts +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + globalSetup: './e2e/setup.ts', + globalTeardown: './e2e/teardown.ts', +}); +``` + +```ts +// e2e/setup.ts +import { FullConfig } from '@playwright/test'; + +export default async function globalSetup(config: FullConfig) { + const { execSync } = await import('child_process'); + execSync('npx prisma db seed', { stdio: 'inherit' }); + process.env.TEST_RUN_ID = `run-${Date.now()}`; +} +``` + +```ts +// e2e/teardown.ts +import { FullConfig } from '@playwright/test'; + +export default async function globalTeardown(config: FullConfig) { + const { execSync } = await import('child_process'); + execSync('npx prisma db push --force-reset', { stdio: 'inherit' }); +} +``` + +### Environment Variables with .env + +**Use when**: Managing secrets, URLs, or feature flags without hardcoding. + +```bash +# .env.example (commit this) +BASE_URL=http://localhost:4000 +TEST_PASSWORD= +API_KEY= + +# .env.local (gitignored) +BASE_URL=http://localhost:4000 +TEST_PASSWORD=secret123 +API_KEY=dev-key-abc + +# .env.staging (gitignored) +BASE_URL=https://staging.myapp.com +TEST_PASSWORD=staging-pass +API_KEY=staging-key-xyz +``` + +```bash +# .gitignore +.env +.env.local +.env.staging +.env.production +playwright/.auth/ +``` + +Install dotenv: + +```bash +npm install -D dotenv +``` + +### Tag-Based Test Filtering + +**Use when**: Running subsets of tests in different CI stages (PR vs nightly). + +```ts +// playwright.config.ts +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + + // Filter by tags in CI + grep: process.env.CI ? /@smoke|@critical/ : undefined, + grepInvert: process.env.CI ? /@flaky/ : undefined, +}); +``` + +**Project-specific filtering:** + +```ts +// playwright.config.ts +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + projects: [ + { + name: 'smoke', + grep: /@smoke/, + use: { ...devices['Desktop Chrome'] }, + }, + { + name: 'regression', + grepInvert: /@smoke/, + use: { ...devices['Desktop Chrome'] }, + }, + { + name: 'critical-only', + grep: /@critical/, + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); +``` + +```bash +# Run specific project +npx playwright test --project=smoke +npx playwright test --project=regression +``` + +### Artifact Collection Strategy + +| Setting | Local | CI | Reason | +|---------|-------|-----|--------| +| `trace` | `'off'` | `'on-first-retry'` | Traces are large; collect on failure only | +| `screenshot` | `'off'` | `'only-on-failure'` | Useful for CI debugging | +| `video` | `'off'` | `'retain-on-failure'` | Recording slows tests | + +```ts +// playwright.config.ts +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + use: { + trace: process.env.CI ? 'on-first-retry' : 'off', + screenshot: process.env.CI ? 'only-on-failure' : 'off', + video: process.env.CI ? 'retain-on-failure' : 'off', + }, +}); +``` + +## Anti-Patterns + +| Don't | Problem | Do Instead | +|-------|---------|------------| +| `timeout: 300_000` globally | Masks flaky tests; slow CI | Fix root cause; keep 30s default | +| Hardcoded URLs: `page.goto('http://localhost:4000/login')` | Breaks in other environments | Use `baseURL` + relative paths | +| All browsers on every PR | 3x CI time | Chromium on PRs; all on main | +| `trace: 'on'` always | Huge artifacts, slow uploads | `trace: 'on-first-retry'` | +| `video: 'on'` always | Massive storage; slow tests | `video: 'retain-on-failure'` | +| Config in test files: `test.use({ viewport: {...} })` everywhere | Scattered, inconsistent | Define once in project config | +| `retries: 3` locally | Hides flakiness | `retries: 0` local, `retries: 2` CI | +| No `forbidOnly` in CI | Committed `test.only` runs single test | `forbidOnly: !!process.env.CI` | +| `globalSetup` for browser auth | No browser context available | Use setup project with dependencies | +| Committing `.env` with credentials | Security risk | Commit `.env.example` only | + +## Troubleshooting + +### baseURL Not Working + +**Cause**: Using absolute URL in `page.goto()` ignores `baseURL`. + +```ts +// Wrong - ignores baseURL +await page.goto('http://localhost:4000/dashboard'); + +// Correct - uses baseURL +await page.goto('/dashboard'); +``` + +### webServer Starts But Tests Get Connection Refused + +**Cause**: `webServer.url` doesn't match actual server address or health check returns non-200. + +```ts +webServer: { + command: 'npm run dev', + url: 'http://localhost:4000/api/health', // use real endpoint + reuseExistingServer: !process.env.CI, + timeout: 120_000, +}, +``` + +### Tests Pass Locally But Timeout in CI + +**Cause**: CI machines are slower. Increase timeouts and reduce workers: + +```ts +export default defineConfig({ + workers: process.env.CI ? '50%' : undefined, + use: { + navigationTimeout: process.env.CI ? 30_000 : 15_000, + actionTimeout: process.env.CI ? 15_000 : 10_000, + }, +}); +``` + +### "Target page, context or browser has been closed" + +**Cause**: Test exceeded `timeout` and Playwright tore down browser during action. + +**Fix**: Don't increase global timeout. Find slow step using trace: + +```bash +npx playwright test --trace on +npx playwright show-report +``` + +## Related + +- [test-tags.md](./test-tags.md) - tagging and filtering tests with `--grep` +- [fixtures-hooks.md](./fixtures-hooks.md) - custom fixtures for per-test state +- [test-suite-structure.md](test-suite-structure.md) - file structure and naming +- [authentication.md](../advanced/authentication.md) - setup projects for shared auth +- [projects-dependencies.md](./projects-dependencies.md) - advanced multi-project patterns diff --git a/plugins/software-delivery/skills/playwright-best-practices/core/fixtures-hooks.md b/plugins/software-delivery/skills/playwright-best-practices/core/fixtures-hooks.md new file mode 100644 index 0000000..ff9dc93 --- /dev/null +++ b/plugins/software-delivery/skills/playwright-best-practices/core/fixtures-hooks.md @@ -0,0 +1,417 @@ +# Fixtures & Hooks + +## Table of Contents + +1. [Built-in Fixtures](#built-in-fixtures) +2. [Custom Fixtures](#custom-fixtures) +3. [Fixture Scopes](#fixture-scopes) +4. [Hooks](#hooks) +5. [Authentication Patterns](#authentication-patterns) +6. [Database Fixtures](#database-fixtures) + +## Built-in Fixtures + +### Core Fixtures + +```typescript +test("example", async ({ + page, // Isolated page instance + context, // Browser context (cookies, localStorage) + browser, // Browser instance + browserName, // 'chromium', 'firefox', or 'webkit' + request, // API request context +}) => { + // Each test gets fresh instances +}); +``` + +### Request Fixture + +```typescript +test("API call", async ({ request }) => { + const response = await request.get("/api/users"); + await expect(response).toBeOK(); + + const users = await response.json(); + expect(users).toHaveLength(5); +}); +``` + +## Custom Fixtures + +### Basic Custom Fixture + +```typescript +// fixtures.ts +import { test as base } from "@playwright/test"; + +// Declare fixture types +type MyFixtures = { + todoPage: TodoPage; + apiClient: ApiClient; +}; + +export const test = base.extend({ + // Fixture with setup and teardown + todoPage: async ({ page }, use) => { + const todoPage = new TodoPage(page); + await todoPage.goto(); + + await use(todoPage); // Test runs here + + // Teardown (optional) + await todoPage.clearTodos(); + }, + + // Simple fixture + apiClient: async ({ request }, use) => { + await use(new ApiClient(request)); + }, +}); + +export { expect } from "@playwright/test"; +``` + +### Fixture with Options + +```typescript +type Options = { + defaultUser: { email: string; password: string }; +}; + +type Fixtures = { + authenticatedPage: Page; +}; + +export const test = base.extend({ + // Define option with default + defaultUser: [ + { email: "test@example.com", password: "pass123" }, + { option: true }, + ], + + // Use option in fixture + authenticatedPage: async ({ page, defaultUser }, use) => { + await page.goto("/login"); + await page.getByLabel("Email").fill(defaultUser.email); + await page.getByLabel("Password").fill(defaultUser.password); + await page.getByRole("button", { name: "Sign in" }).click(); + await use(page); + }, +}); + +// Override in config +export default defineConfig({ + use: { + defaultUser: { email: "admin@example.com", password: "admin123" }, + }, +}); +``` + +### Automatic Fixtures + +```typescript +export const test = base.extend<{}, { setupDb: void }>({ + // Auto-fixture runs for every test without explicit usage + setupDb: [ + async ({}, use) => { + await seedDatabase(); + await use(); + await cleanDatabase(); + }, + { auto: true }, + ], +}); +``` + +## Fixture Scopes + +### Test Scope (Default) + +Created fresh for each test: + +```typescript +test.extend({ + page: async ({ browser }, use) => { + const page = await browser.newPage(); + await use(page); + await page.close(); + }, +}); +``` + +### Worker Scope + +Shared across tests in the same worker (each worker gets its own instance; tests in different workers do not share it): + +```typescript +type WorkerFixtures = { + sharedAccount: Account; +}; + +export const test = base.extend<{}, WorkerFixtures>({ + sharedAccount: [ + async ({ browser }, use) => { + // Expensive setup - runs once per worker + const account = await createTestAccount(); + await use(account); + await deleteTestAccount(account); + }, + { scope: "worker" }, + ], +}); +``` + +### Isolate test data between parallel workers + +When tests in different workers touch the same backend or DB (e.g. same user, same tenant), they can collide and cause flaky failures. Use `testInfo.workerIndex` (or `process.env.TEST_WORKER_INDEX`) in a worker-scoped fixture to create unique data per worker: + +```typescript +import { test as baseTest } from "@playwright/test"; + +type WorkerFixtures = { + dbUserName: string; +}; + +export const test = baseTest.extend<{}, WorkerFixtures>({ + dbUserName: [ + async ({}, use, testInfo) => { + const userName = `user-${testInfo.workerIndex}`; + await createUserInTestDatabase(userName); + await use(userName); + await deleteUserFromTestDatabase(userName); + }, + { scope: "worker" }, + ], +}); +``` + +Then each worker uses a distinct user (e.g. `user-1`, `user-2`), so parallel workers do not overwrite each other’s data. + +## Hooks + +### beforeEach / afterEach + +```typescript +test.beforeEach(async ({ page }) => { + // Runs before each test in file + await page.goto("/"); +}); + +test.afterEach(async ({ page }, testInfo) => { + // Runs after each test + if (testInfo.status !== "passed") { + await page.screenshot({ path: `failed-${testInfo.title}.png` }); + } +}); +``` + +### beforeAll / afterAll + +```typescript +test.beforeAll(async ({ browser }) => { + // Runs once before all tests in file + // Note: Cannot use page fixture here +}); + +test.afterAll(async () => { + // Runs once after all tests in file +}); +``` + +### Describe-Level Hooks + +```typescript +test.describe("User Management", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/users"); + }); + + test("can list users", async ({ page }) => { + // Starts at /users + }); + + test("can add user", async ({ page }) => { + // Starts at /users + }); +}); +``` + +## Authentication Patterns + +### Global Setup with Storage State + +```typescript +// auth.setup.ts +import { test as setup, expect } from "@playwright/test"; + +const authFile = ".auth/user.json"; + +setup("authenticate", async ({ page }) => { + await page.goto("/login"); + await page.getByLabel("Email").fill(process.env.TEST_EMAIL!); + await page.getByLabel("Password").fill(process.env.TEST_PASSWORD!); + await page.getByRole("button", { name: "Sign in" }).click(); + + await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible(); + await page.context().storageState({ path: authFile }); +}); +``` + +```typescript +// playwright.config.ts +export default defineConfig({ + projects: [ + { name: "setup", testMatch: /.*\.setup\.ts/ }, + { + name: "chromium", + use: { + ...devices["Desktop Chrome"], + storageState: ".auth/user.json", + }, + dependencies: ["setup"], + }, + ], +}); +``` + +### Multiple Auth States + +```typescript +// auth.setup.ts +setup("admin auth", async ({ page }) => { + await login(page, "admin@example.com", "adminpass"); + await page.context().storageState({ path: ".auth/admin.json" }); +}); + +setup("user auth", async ({ page }) => { + await login(page, "user@example.com", "userpass"); + await page.context().storageState({ path: ".auth/user.json" }); +}); +``` + +```typescript +// playwright.config.ts +projects: [ + { + name: "admin tests", + testMatch: /.*admin.*\.spec\.ts/, + use: { storageState: ".auth/admin.json" }, + dependencies: ["setup"], + }, + { + name: "user tests", + testMatch: /.*user.*\.spec\.ts/, + use: { storageState: ".auth/user.json" }, + dependencies: ["setup"], + }, +]; +``` + +### Auth Fixture + +```typescript +// fixtures/auth.fixture.ts +export const test = base.extend<{ adminPage: Page; userPage: Page }>({ + adminPage: async ({ browser }, use) => { + const context = await browser.newContext({ + storageState: ".auth/admin.json", + }); + const page = await context.newPage(); + await use(page); + await context.close(); + }, + + userPage: async ({ browser }, use) => { + const context = await browser.newContext({ + storageState: ".auth/user.json", + }); + const page = await context.newPage(); + await use(page); + await context.close(); + }, +}); +``` + +## Database Fixtures + +This section covers **per-test database fixtures** (isolation, transaction rollback). For related topics: + +- **Test data factories** (builders, Faker): See [test-data.md](test-data.md) +- **One-time database setup** (migrations, snapshots): See [global-setup.md](global-setup.md#database-patterns) + +### Transaction Rollback Pattern + +```typescript +import { test as base } from "@playwright/test"; +import { db } from "../db"; + +export const test = base.extend<{ dbTransaction: Transaction }>({ + dbTransaction: async ({}, use) => { + const transaction = await db.beginTransaction(); + + await use(transaction); + + await transaction.rollback(); // Clean slate for next test + }, +}); +``` + +### Seed Data Fixture + +```typescript +type TestData = { + testUser: User; + testProducts: Product[]; +}; + +export const test = base.extend({ + testUser: async ({}, use) => { + const user = await db.users.create({ + email: `test-${Date.now()}@example.com`, + name: "Test User", + }); + + await use(user); + + await db.users.delete(user.id); + }, + + testProducts: async ({ testUser }, use) => { + const products = await db.products.createMany([ + { name: "Product A", ownerId: testUser.id }, + { name: "Product B", ownerId: testUser.id }, + ]); + + await use(products); + + await db.products.deleteMany(products.map((p) => p.id)); + }, +}); +``` + +## Fixture Tips + +| Tip | Explanation | +| ------------------ | ------------------------------------------- | +| Fixtures are lazy | Only created when used | +| Compose fixtures | Use other fixtures as dependencies | +| Keep setup minimal | Do heavy lifting in worker-scoped fixtures | +| Clean up resources | Use teardown in fixtures, not afterEach | +| Avoid shared state | Each fixture instance should be independent | + +## Anti-Patterns to Avoid + +| Anti-Pattern | Problem | Solution | +| ----------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Shared mutable state between tests | Race conditions, order dependencies | Use fixtures for isolation | +| Global variables in tests | Tests depend on execution order | Use fixtures or beforeEach for setup | +| Not cleaning up test data | Tests interfere with each other | Use fixtures with teardown or database transactions | +| Shared `page` or `context` in `beforeAll` | State leak between tests; flaky when tests run in parallel | Use default one-context-per-test, or `beforeEach` + fresh page; if serial is required, prefer `test.describe.configure({ mode: 'serial' })` and document that isolation is sacrificed | +| Backend/DB state shared across workers | Tests in different workers collide on same data | Use worker-scoped fixture with `testInfo.workerIndex` to create unique data per worker | + +## Related References + +- **Page Objects with fixtures**: See [page-object-model.md](page-object-model.md) for POM patterns +- **Test organization**: See [test-suite-structure.md](test-suite-structure.md) for test structure +- **Debugging fixture issues**: See [debugging.md](../debugging/debugging.md) for troubleshooting diff --git a/plugins/software-delivery/skills/playwright-best-practices/core/global-setup.md b/plugins/software-delivery/skills/playwright-best-practices/core/global-setup.md new file mode 100644 index 0000000..a033522 --- /dev/null +++ b/plugins/software-delivery/skills/playwright-best-practices/core/global-setup.md @@ -0,0 +1,434 @@ +# Global Setup & Teardown + +## Table of Contents + +1. [Global Setup](#global-setup) +2. [Global Teardown](#global-teardown) +3. [Database Patterns](#database-patterns) +4. [Environment Provisioning](#environment-provisioning) +5. [Setup Projects vs Global Setup](#setup-projects-vs-global-setup) +6. [Parallel Execution Caveats](#parallel-execution-caveats) + +## Global Setup + +### Basic Global Setup + +```typescript +// global-setup.ts +import { FullConfig } from "@playwright/test"; + +async function globalSetup(config: FullConfig) { + console.log("Running global setup..."); + // Perform one-time setup: start services, run migrations, etc. +} + +export default globalSetup; +``` + +### Configure Global Setup + +```typescript +// playwright.config.ts +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + globalSetup: require.resolve("./global-setup"), + globalTeardown: require.resolve("./global-teardown"), +}); +``` + +> **Authentication in Global Setup**: For authentication patterns using storage state in global setup, see [fixtures-hooks.md](fixtures-hooks.md#authentication-patterns). Setup projects are generally preferred for authentication as they provide access to Playwright fixtures. + +### Global Setup with Return Value + +```typescript +// global-setup.ts +async function globalSetup(config: FullConfig): Promise<() => Promise> { + const server = await startTestServer(); + + // Return cleanup function (alternative to globalTeardown) + return async () => { + await server.stop(); + }; +} + +export default globalSetup; +``` + +### Access Config in Global Setup + +```typescript +// global-setup.ts +import { FullConfig } from "@playwright/test"; + +async function globalSetup(config: FullConfig) { + const { baseURL } = config.projects[0].use; + console.log(`Setting up for ${baseURL}`); + + // Access custom config + const workers = config.workers; + const timeout = config.timeout; + + // Access environment + const isCI = !!process.env.CI; +} + +export default globalSetup; +``` + +## Global Teardown + +### Basic Global Teardown + +```typescript +// global-teardown.ts +import { FullConfig } from "@playwright/test"; +import fs from "fs"; + +async function globalTeardown(config: FullConfig) { + console.log("Running global teardown..."); + + // Clean up auth files + if (fs.existsSync(".auth")) { + fs.rmSync(".auth", { recursive: true }); + } + + // Clean up test data + await cleanupTestDatabase(); + + // Stop services + await stopTestServices(); +} + +export default globalTeardown; +``` + +### Conditional Teardown + +```typescript +// global-teardown.ts +async function globalTeardown(config: FullConfig) { + // Skip cleanup in CI (containers are discarded anyway) + if (process.env.CI) { + console.log("Skipping teardown in CI"); + return; + } + + // Local cleanup + await cleanupLocalTestData(); +} + +export default globalTeardown; +``` + +## Database Patterns + +This section covers **one-time database setup** (migrations, snapshots, per-worker databases). For related topics: + +- **Per-test database fixtures** (isolation, transaction rollback): See [fixtures-hooks.md](fixtures-hooks.md#database-fixtures) +- **Test data factories** (builders, Faker): See [test-data.md](test-data.md) + +### Database Migration in Setup + +```typescript +// global-setup.ts +import { execSync } from "child_process"; + +async function globalSetup() { + console.log("Running database migrations..."); + + // Run migrations + execSync("npx prisma migrate deploy", { stdio: "inherit" }); + + // Seed test data + execSync("npx prisma db seed", { stdio: "inherit" }); +} + +export default globalSetup; +``` + +### Database Snapshot Pattern + +```typescript +// global-setup.ts +import { execSync } from "child_process"; +import fs from "fs"; + +const SNAPSHOT_PATH = "./test-db-snapshot.sql"; + +async function globalSetup() { + // Check if snapshot exists + if (fs.existsSync(SNAPSHOT_PATH)) { + console.log("Restoring database from snapshot..."); + execSync(`psql $DATABASE_URL < ${SNAPSHOT_PATH}`, { stdio: "inherit" }); + return; + } + + // First run: migrate and create snapshot + console.log("Creating database snapshot..."); + execSync("npx prisma migrate deploy", { stdio: "inherit" }); + execSync("npx prisma db seed", { stdio: "inherit" }); + execSync(`pg_dump $DATABASE_URL > ${SNAPSHOT_PATH}`, { stdio: "inherit" }); +} + +export default globalSetup; +``` + +### Test Database per Worker + +```typescript +// global-setup.ts +async function globalSetup(config: FullConfig) { + const workerCount = config.workers || 1; + + // Create a database for each worker + for (let i = 0; i < workerCount; i++) { + const dbName = `test_db_worker_${i}`; + await createDatabase(dbName); + await runMigrations(dbName); + await seedDatabase(dbName); + } +} + +// global-teardown.ts +async function globalTeardown(config: FullConfig) { + const workerCount = config.workers || 1; + + for (let i = 0; i < workerCount; i++) { + await dropDatabase(`test_db_worker_${i}`); + } +} +``` + +## Environment Provisioning + +### Start Services in Setup + +```typescript +// global-setup.ts +import { execSync, spawn } from "child_process"; + +let serverProcess: any; + +async function globalSetup() { + // Start backend server + serverProcess = spawn("npm", ["run", "start:test"], { + stdio: "pipe", + detached: true, + }); + + // Wait for server to be ready + await waitForServer("http://localhost:3000/health", 30000); + + // Store PID for teardown + process.env.SERVER_PID = serverProcess.pid.toString(); +} + +async function waitForServer(url: string, timeout: number) { + const start = Date.now(); + + while (Date.now() - start < timeout) { + try { + const response = await fetch(url); + if (response.ok) return; + } catch { + // Server not ready yet + } + await new Promise((r) => setTimeout(r, 1000)); + } + + throw new Error(`Server did not start within ${timeout}ms`); +} + +export default globalSetup; +``` + +### Docker Compose Setup + +```typescript +// global-setup.ts +import { execSync } from "child_process"; + +async function globalSetup() { + console.log("Starting Docker services..."); + + execSync("docker-compose -f docker-compose.test.yml up -d", { + stdio: "inherit", + }); + + // Wait for services to be healthy + execSync("docker-compose -f docker-compose.test.yml exec -T db pg_isready", { + stdio: "inherit", + }); +} + +export default globalSetup; +``` + +```typescript +// global-teardown.ts +import { execSync } from "child_process"; + +async function globalTeardown() { + console.log("Stopping Docker services..."); + + execSync("docker-compose -f docker-compose.test.yml down -v", { + stdio: "inherit", + }); +} + +export default globalTeardown; +``` + +### Environment Variables Setup + +```typescript +// global-setup.ts +import dotenv from "dotenv"; +import path from "path"; + +async function globalSetup() { + // Load test-specific environment + const envFile = process.env.CI ? ".env.ci" : ".env.test"; + dotenv.config({ path: path.resolve(process.cwd(), envFile) }); + + // Validate required variables + const required = ["DATABASE_URL", "API_KEY", "TEST_EMAIL"]; + for (const key of required) { + if (!process.env[key]) { + throw new Error(`Missing required environment variable: ${key}`); + } + } +} + +export default globalSetup; +``` + +## Setup Projects vs Global Setup + +### When to Use Each + +| Use Global Setup | Use Setup Projects | +| ------------------------------------- | ---------------------------------------- | +| One-time setup (migrations, services) | Per-project setup (auth states) | +| No access to Playwright fixtures | Need page, request fixtures | +| Runs once before all projects | Can run per-project or have dependencies | +| Shared across all workers | Can be parallelized | + +### Setup Project Pattern + +```typescript +// playwright.config.ts +export default defineConfig({ + projects: [ + // Setup project + { + name: "setup", + testMatch: /.*\.setup\.ts/, + }, + // Test projects depend on setup + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + dependencies: ["setup"], + }, + { + name: "firefox", + use: { ...devices["Desktop Firefox"] }, + dependencies: ["setup"], + }, + ], +}); +``` + +> **For complete authentication setup patterns**, see [fixtures-hooks.md](fixtures-hooks.md#authentication-patterns). + +### Combining Both + +```typescript +// playwright.config.ts +export default defineConfig({ + // Global: Start services, run migrations + globalSetup: require.resolve("./global-setup"), + globalTeardown: require.resolve("./global-teardown"), + + projects: [ + // Setup project: Create auth states + { name: "setup", testMatch: /.*\.setup\.ts/ }, + { + name: "chromium", + use: { + ...devices["Desktop Chrome"], + storageState: ".auth/user.json", + }, + dependencies: ["setup"], + }, + ], +}); +``` + +## Parallel Execution Caveats + +### Understanding Global Setup Execution + +``` +┌─────────────────────────────────────────────────────────────┐ +│ globalSetup runs ONCE │ +│ ↓ │ +│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ +│ │ Worker 1│ │ Worker 2│ │ Worker 3│ │ Worker 4│ │ +│ │ tests │ │ tests │ │ tests │ │ tests │ │ +│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │ +│ ↓ │ +│ globalTeardown runs ONCE │ +└─────────────────────────────────────────────────────────────┘ +``` + +**Key implications:** + +- Global setup has **no access** to Playwright fixtures (`page`, `request`, `context`) +- State created in global setup is **shared** across all workers +- If tests **modify** shared state, they may conflict with parallel workers +- Global setup **cannot** react to individual test needs + +### When to Prefer Worker-Scoped Fixtures + +Use **worker-scoped fixtures** instead of globalSetup when: + +| Scenario | Why Fixtures Are Better | +| ------------------------------------ | ---------------------------------------------------- | +| Each worker needs isolated resources | Fixtures can create per-worker databases, servers | +| Setup needs Playwright APIs | Fixtures have access to `page`, `request`, `browser` | +| Setup depends on test configuration | Fixtures receive test context and options | +| Resources need cleanup per worker | Worker fixtures auto-cleanup when worker exits | + +### Common Parallel Pitfall + +```typescript +// ❌ BAD: Global setup creates ONE user, all workers fight over it +async function globalSetup() { + await createUser({ email: "test@example.com" }); // Shared! +} + +// ✅ GOOD: Each worker gets its own user via worker-scoped fixture +// Uses workerInfo.workerIndex to create unique data per worker +``` + +> **For worker-scoped fixture patterns** (per-worker databases, unique test data, `workerIndex` isolation), see [fixtures-hooks.md](fixtures-hooks.md#isolate-test-data-between-parallel-workers). + +## Anti-Patterns to Avoid + +| Anti-Pattern | Problem | Solution | +| ------------------------------ | -------------------------------- | ------------------------------------------ | +| Heavy setup in globalSetup | Slow test startup | Use setup projects for parallelizable work | +| Not cleaning up in teardown | Leaks resources, flaky CI | Always clean up or use containers | +| Hardcoded URLs in setup | Breaks in different environments | Use config.projects[0].use.baseURL | +| No timeout on service wait | Hangs forever if service fails | Add timeout with clear error | +| Shared mutable state | Race conditions in parallel | Use worker-scoped fixtures for isolation | +| Global setup for per-test data | Tests conflict | Use test-scoped fixtures | + +## Related References + +- **Fixtures & Auth**: See [fixtures-hooks.md](fixtures-hooks.md) for worker-scoped fixtures and auth patterns +- **CI/CD**: See [ci-cd.md](../infrastructure-ci-cd/ci-cd.md) for CI setup patterns +- **Projects**: See [projects-dependencies.md](projects-dependencies.md) for project configuration diff --git a/plugins/software-delivery/skills/playwright-best-practices/core/locators.md b/plugins/software-delivery/skills/playwright-best-practices/core/locators.md new file mode 100644 index 0000000..f806635 --- /dev/null +++ b/plugins/software-delivery/skills/playwright-best-practices/core/locators.md @@ -0,0 +1,242 @@ +# Locator Strategies + +## Table of Contents + +1. [Priority Order](#priority-order) +2. [User-Facing Locators](#user-facing-locators) +3. [Filtering & Chaining](#filtering--chaining) +4. [Dynamic Content](#dynamic-content) +5. [Shadow DOM](#shadow-dom) +6. [Iframes](#iframes) + +## Priority Order + +Use locators in this order of preference: + +1. **Role-based** (most resilient): `getByRole` +2. **Label-based**: `getByLabel`, `getByPlaceholder` +3. **Text-based**: `getByText`, `getByTitle` +4. **Test IDs** (when semantic locators aren't possible): `getByTestId` +5. **CSS/XPath** (last resort): `locator('css=...')`, `locator('xpath=...')` + +## User-Facing Locators + +### getByRole + +Most robust approach - matches how users and assistive technology perceive the page. + +```typescript +// Buttons +page.getByRole("button", { name: "Submit", exact: true }); // exact accessible name +page.getByRole("button", { name: /submit/i }); // flexible case-insensitive match + +// Links +page.getByRole("link", { name: "Home" }); + +// Form elements +page.getByRole("textbox", { name: "Email" }); +page.getByRole("checkbox", { name: "Remember me" }); +page.getByRole("combobox", { name: "Country" }); +page.getByRole("radio", { name: "Option A" }); + +// Headings +page.getByRole("heading", { name: "Welcome", level: 1 }); + +// Lists & items +page.getByRole("list").getByRole("listitem"); + +// Navigation & regions +page.getByRole("navigation"); +page.getByRole("main"); +page.getByRole("dialog"); +page.getByRole("alert"); +``` + +### getByLabel + +For form elements with associated labels. + +```typescript +// Input with