diff --git a/.claude/hooks/format-prettier.sh b/.claude/hooks/format-prettier.sh new file mode 100755 index 000000000..71bdf3a10 --- /dev/null +++ b/.claude/hooks/format-prettier.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +INPUT=$(cat) +FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty') + +# Exit early if no file path +if [[ -z "$FILE_PATH" ]]; then + exit 0 +fi + +# Run Prettier on the specific file +OUTPUT=$(cd "$CLAUDE_PROJECT_DIR" && pnpm exec prettier --write --ignore-unknown --no-error-on-unmatched-pattern "$FILE_PATH" 2>&1) || EXIT_CODE=$? + +if [[ ${EXIT_CODE:-0} -ne 0 && -n "$OUTPUT" ]]; then + echo "$OUTPUT" >&2 + exit 2 +fi + +exit 0 diff --git a/.claude/hooks/guard-critical-files.sh b/.claude/hooks/guard-critical-files.sh new file mode 100755 index 000000000..a1adcec83 --- /dev/null +++ b/.claude/hooks/guard-critical-files.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +set -euo pipefail + +INPUT=$(cat) +FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty') + +# Exit early if no file path +if [[ -z "$FILE_PATH" ]]; then + exit 0 +fi + +# Resolve to a path relative to the project directory for consistent matching +REL_PATH="${FILE_PATH#"$CLAUDE_PROJECT_DIR"/}" + +# Protected slow-layer infrastructure files +PROTECTED_FILES=( + "pnpm-workspace.yaml" + "commitlint.config.js" + "release.config.cjs" + "package.json" + "pnpm-lock.yaml" + ".nvmrc" + ".husky" + "config" +) + +# Check if the file is a root-level protected file +BASENAME=$(basename "$REL_PATH") +DIRNAME=$(dirname "$REL_PATH") + +for PROTECTED in "${PROTECTED_FILES[@]}"; do + # Only protect root-level files (dirname is . or matches project dir) + if [[ "$BASENAME" == "$PROTECTED" && ("$DIRNAME" == "." || "$FILE_PATH" == "$CLAUDE_PROJECT_DIR/$PROTECTED") ]]; then + echo "BLOCKED: '$PROTECTED' is a critical infrastructure file (slow-layer)." >&2 + echo "These files affect the entire monorepo and should only be modified when the user explicitly requests it." >&2 + echo "If the user has asked for this change, re-run the command to confirm." >&2 + exit 2 + fi +done + +exit 0 diff --git a/.claude/hooks/guard-destructive.sh b/.claude/hooks/guard-destructive.sh new file mode 100755 index 000000000..43082bf7a --- /dev/null +++ b/.claude/hooks/guard-destructive.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +INPUT=$(cat) +COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty') + +# Exit early if not a git command +if [[ -z "$COMMAND" ]] || ! echo "$COMMAND" | grep -qE '^\s*git\s'; then + exit 0 +fi + +block() { + echo "BLOCKED: Destructive git operation detected." >&2 + echo " Command: $COMMAND" >&2 + echo " Reason: $1." >&2 + echo "" >&2 + echo "Per AGENTS.md policy, destructive git operations require explicit user confirmation." >&2 + echo "If the user has explicitly requested this operation, ask them to run it manually." >&2 + exit 2 +} + +echo "$COMMAND" | grep -qF "reset --hard" && block "git reset --hard discards all uncommitted changes irreversibly" +echo "$COMMAND" | grep -qF "push --force" && block "git push --force can overwrite remote history and destroy teammates' work" +echo "$COMMAND" | grep -qF "push -f" && block "git push -f can overwrite remote history and destroy teammates' work" +echo "$COMMAND" | grep -qF "clean -f" && block "git clean -f permanently deletes untracked files" +echo "$COMMAND" | grep -qF "checkout -- ." && block "git checkout -- . discards all unstaged changes irreversibly" +echo "$COMMAND" | grep -qF "branch -D" && block "git branch -D force-deletes a branch without merge checks" + +exit 0 diff --git a/.claude/hooks/guard-package-manager.sh b/.claude/hooks/guard-package-manager.sh new file mode 100755 index 000000000..3f4b6cda5 --- /dev/null +++ b/.claude/hooks/guard-package-manager.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +INPUT=$(cat) +COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty') + +# Exit early if empty or not an npm/yarn command +if [[ -z "$COMMAND" ]]; then + exit 0 +fi + +block() { + echo "BLOCKED: Wrong package manager detected." >&2 + echo " Command: $COMMAND" >&2 + echo " Reason: $1." >&2 + echo "" >&2 + echo "This project uses pnpm exclusively. Replace '$2' with '$3' and try again." >&2 + exit 2 +} + +# Block npm and npx +if echo "$COMMAND" | grep -qE '(^|\s|&&|\|\||;)npm(\s|$)'; then + block "npm is not the package manager for this monorepo" "npm" "pnpm" +fi + +if echo "$COMMAND" | grep -qE '(^|\s|&&|\|\||;)npx(\s|$)'; then + block "Use 'pnpm dlx' instead of 'npx' in this monorepo" "npx" "pnpm dlx" +fi + +# Block yarn +if echo "$COMMAND" | grep -qE '(^|\s|&&|\|\||;)yarn(\s|$)'; then + block "yarn is not the package manager for this monorepo" "yarn" "pnpm" +fi + +exit 0 diff --git a/.claude/hooks/lint-commitlint.sh b/.claude/hooks/lint-commitlint.sh new file mode 100755 index 000000000..a16fa315b --- /dev/null +++ b/.claude/hooks/lint-commitlint.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +INPUT=$(cat) +COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty') + +# Exit early if not a git commit command +if [[ -z "$COMMAND" ]] || ! echo "$COMMAND" | grep -qE '^\s*git\s+commit\b'; then + exit 0 +fi + +# Extract commit message header (first line) from -m flag +# Supports: -m "msg", -m 'msg', -m "$(cat <<'EOF'\n...\nEOF\n)" +MSG="" +if echo "$COMMAND" | grep -qE '\$\(cat\s+<<'; then + # HEREDOC pattern: jq decodes JSON \n to real newlines, so the command is multi-line. + # Extract the first non-blank content line after the <<...EOF marker line. + MSG=$(echo "$COMMAND" | awk ' + /<<.*EOF/ { found=1; next } + found && /^[[:space:]]*$/ { next } + found && /^[[:space:]]*EOF[[:space:]]*$/ { exit } + found { sub(/^[[:space:]]+/, ""); print; exit } + ') +elif echo "$COMMAND" | grep -qE "\-m\s+'"; then + # Single-quoted message + MSG=$(echo "$COMMAND" | sed -n "s/.*-m[[:space:]]*'\\([^']*\\)'.*/\\1/p" | head -1) +elif echo "$COMMAND" | grep -qE '\-m\s+"'; then + # Double-quoted message + MSG=$(echo "$COMMAND" | sed -n 's/.*-m[[:space:]]*"\([^"]*\)".*/\1/p' | head -1) +fi + +# If we couldn't extract a message, let git handle validation +if [[ -z "$MSG" ]]; then + exit 0 +fi + +# Run commitlint on the extracted message +echo "$MSG" | pnpm exec commitlint 2>&1 diff --git a/.claude/hooks/lint-eslint.sh b/.claude/hooks/lint-eslint.sh new file mode 100755 index 000000000..0df7f473a --- /dev/null +++ b/.claude/hooks/lint-eslint.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +set -euo pipefail + +INPUT=$(cat) +FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty') + +# Exit early if no file path +if [[ -z "$FILE_PATH" ]]; then + exit 0 +fi + +# Only lint .ts, .js, and .css files +case "$FILE_PATH" in + *.ts|*.js|*.css) ;; + *) exit 0 ;; +esac + +# Skip directories that should not be linted +case "$FILE_PATH" in + */dist/*|*/node_modules/*|*/__screenshots__/*|*/generated/*) exit 0 ;; +esac + +# Resolve the project directory by walking up from the file looking for eslint.config.js +DIR=$(dirname "$FILE_PATH") +PROJECT_DIR="" +while [[ "$DIR" != "/" && "$DIR" != "." ]]; do + if [[ -f "$DIR/eslint.config.js" ]]; then + PROJECT_DIR="$DIR" + break + fi + DIR=$(dirname "$DIR") +done + +# Exit if no eslint config found (e.g., root-level files) +if [[ -z "$PROJECT_DIR" ]]; then + exit 0 +fi + +# Compute the file path relative to the project directory +REL_PATH=$(realpath --relative-to="$PROJECT_DIR" "$FILE_PATH" 2>/dev/null) || REL_PATH="${FILE_PATH#"$PROJECT_DIR"/}" + +# Rules that should warn but not block (transient during refactoring) +SOFT_RULES="no-unused-vars|@typescript-eslint/no-unused-vars" + +# Run ESLint with JSON output to classify errors +JSON_OUTPUT=$(cd "$PROJECT_DIR" && pnpm exec eslint -c ./eslint.config.js --no-warn-ignored --cache --cache-location .eslintcache/ --format json "$REL_PATH" 2>/dev/null) || true + +# Check if there are any hard errors (not in the soft rules list) +HARD_ERRORS=$(echo "$JSON_OUTPUT" | jq -r --arg soft "$SOFT_RULES" ' + [.[].messages[] | select(.severity == 2) | select(.ruleId | test($soft) | not)] | length +') 2>/dev/null || HARD_ERRORS="0" + +TOTAL_ERRORS=$(echo "$JSON_OUTPUT" | jq -r ' + [.[].messages[] | select(.severity == 2)] | length +') 2>/dev/null || TOTAL_ERRORS="0" + +# No errors at all — pass silently +if [[ "$TOTAL_ERRORS" == "0" ]]; then + exit 0 +fi + +# Get human-readable output for display +READABLE=$(cd "$PROJECT_DIR" && pnpm exec eslint -c ./eslint.config.js --no-warn-ignored --color --cache --cache-location .eslintcache/ "$REL_PATH" 2>&1) || true + +if [[ "$HARD_ERRORS" != "0" ]]; then + # Hard errors present — block + echo "$READABLE" >&2 + exit 2 +else + # Only soft errors (unused vars/imports) — warn but don't block + echo "$READABLE" >&2 + exit 0 +fi diff --git a/.claude/hooks/lint-stylelint.sh b/.claude/hooks/lint-stylelint.sh new file mode 100755 index 000000000..31efd38f2 --- /dev/null +++ b/.claude/hooks/lint-stylelint.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -euo pipefail + +INPUT=$(cat) +FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty') + +# Exit early if no file path +if [[ -z "$FILE_PATH" ]]; then + exit 0 +fi + +# Only lint .css files +case "$FILE_PATH" in + *.css) ;; + *) exit 0 ;; +esac + +# Skip directories that should not be linted +case "$FILE_PATH" in + */dist/*|*/node_modules/*|*/vendor/*) exit 0 ;; +esac + +# Resolve the repo root (location of stylelint.config.mjs) +REPO_ROOT="$CLAUDE_PROJECT_DIR" + +# Walk up from the file to find the nearest package.json with a lint:style wireit task +DIR=$(dirname "$FILE_PATH") +PROJECT_DIR="" +while [[ "$DIR" != "/" && "$DIR" != "." ]]; do + if [[ -f "$DIR/package.json" ]] && jq -e '.wireit["lint:style"]' "$DIR/package.json" >/dev/null 2>&1; then + PROJECT_DIR="$DIR" + break + fi + DIR=$(dirname "$DIR") +done + +# Exit if no matching project found (file is in a project without stylelint) +if [[ -z "$PROJECT_DIR" ]]; then + exit 0 +fi + +# Compute the file path relative to the project directory +REL_PATH=$(realpath --relative-to="$PROJECT_DIR" "$FILE_PATH" 2>/dev/null) || REL_PATH="${FILE_PATH#"$PROJECT_DIR"/}" + +# Run Stylelint from the project directory +OUTPUT=$(cd "$PROJECT_DIR" && pnpm exec stylelint --config="$REPO_ROOT/stylelint.config.mjs" --color "$REL_PATH" 2>&1) || EXIT_CODE=$? + +if [[ ${EXIT_CODE:-0} -ne 0 && -n "$OUTPUT" ]]; then + echo "$OUTPUT" >&2 + exit 2 +fi + +exit 0 diff --git a/.claude/hooks/lint-vale.sh b/.claude/hooks/lint-vale.sh new file mode 100755 index 000000000..0f4ab4d1c --- /dev/null +++ b/.claude/hooks/lint-vale.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +INPUT=$(cat) +FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty') + +# Exit early if no file path +if [[ -z "$FILE_PATH" ]]; then + exit 0 +fi + +# Only lint .md and .ts files +case "$FILE_PATH" in + *.md|*.ts) ;; + *) exit 0 ;; +esac + +# Skip test files and excluded paths (matches vale --glob exclusions) +case "$FILE_PATH" in + *.test.*|*/starters/*|*/404/*|*/vendor/*|*/changelog/*|*/icons/*|*/generated/*|*/dist/*|*/LICENSE*|*/CHANGELOG*) exit 0 ;; +esac + +# Skip Claude plan and memory files +case "$FILE_PATH" in + */.claude/plans/*|*/.claude/projects/*) exit 0 ;; +esac + +# Run Vale on the specific file +OUTPUT=$(cd "$CLAUDE_PROJECT_DIR" && config/vale/bin/vale --config .vale.ini "$FILE_PATH" 2>&1) || EXIT_CODE=$? + +if [[ ${EXIT_CODE:-0} -ne 0 && -n "$OUTPUT" ]]; then + echo "$OUTPUT" >&2 + exit 2 +fi + +exit 0 diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh new file mode 100755 index 000000000..7a6a3c5fa --- /dev/null +++ b/.claude/hooks/session-start.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +INPUT=$(cat) + +NVM_DIR="${NVM_DIR:-$HOME/.nvm}" +if [[ ! -s "$NVM_DIR/nvm.sh" ]]; then + echo "nvm not found at $NVM_DIR/nvm.sh" >&2 + exit 0 +fi + +source "$NVM_DIR/nvm.sh" + +cd "$CLAUDE_PROJECT_DIR" + +nvm install 2>&1 >/dev/null +corepack enable 2>&1 >/dev/null +corepack prepare --activate 2>&1 >/dev/null + +INSTALL_OUTPUT=$(pnpm i --frozen-lockfile --prefer-offline 2>&1) || { + echo "pnpm install failed:" >&2 + echo "$INSTALL_OUTPUT" >&2 + exit 0 +} + +NODE_V=$(node --version) +PNPM_V=$(pnpm --version) +echo "Environment ready: node $NODE_V, pnpm $PNPM_V. Dependencies installed." diff --git a/.claude/hooks/stop.sh b/.claude/hooks/stop.sh new file mode 100755 index 000000000..b0232f585 --- /dev/null +++ b/.claude/hooks/stop.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -e +cd "$CLAUDE_PROJECT_DIR" 2>/dev/null || cd "$(git rev-parse --show-toplevel)" 2>/dev/null || exit 0 +pnpm run agent:stop diff --git a/.claude/hooks/worktree-create.sh b/.claude/hooks/worktree-create.sh new file mode 100755 index 000000000..ae32b138c --- /dev/null +++ b/.claude/hooks/worktree-create.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +set -euo pipefail + +# WorktreeCreate hook — receives JSON on stdin with a "name" field. +# Must print only the absolute worktree path to stdout. + +# Read all of stdin first so jq doesn't block waiting for EOF +INPUT=$(cat) +NAME=$(printf '%s' "$INPUT" | jq -r '.name') +NAME="${NAME#topic-}" +NAME="${NAME#topic/}" + +DIR_NAME="topic-${NAME}" +BRANCH_NAME="topic/${NAME}" + +# Resolve the parent directory by finding the worktree whose directory is named "main". +# This ensures new worktrees are always siblings of the main worktree. +MAIN_WORKTREE_PATH=$(git worktree list --porcelain | grep '^worktree ' | sed 's/^worktree //' | grep '/main$' | head -1) +if [[ -z "$MAIN_WORKTREE_PATH" ]]; then + echo "Error: could not find a worktree directory named 'main'" >&2 + exit 1 +fi +PARENT_DIR=$(dirname "$MAIN_WORKTREE_PATH") +WORKTREE_PATH="${PARENT_DIR}/${DIR_NAME}" + +if [[ -d "$WORKTREE_PATH" ]]; then + echo "Error: directory already exists: ${WORKTREE_PATH}" >&2 + exit 1 +fi + +if git show-ref --verify --quiet "refs/heads/${BRANCH_NAME}"; then + echo "Error: branch already exists: ${BRANCH_NAME}" >&2 + exit 1 +fi + +git worktree add "$WORKTREE_PATH" -b "$BRANCH_NAME" origin/main >&2 + +# Install dependencies in the new worktree (setup-env.sh uses CLAUDE_PROJECT_DIR +# which points to the main worktree, so we must install here) +(cd "$WORKTREE_PATH") >&2 + +# Update VS Code workspace file if one exists in the parent directory +WORKSPACE_FILE=$(find "$PARENT_DIR" -maxdepth 1 -name "*.code-workspace" -type f | head -1) +if [[ -n "$WORKSPACE_FILE" ]]; then + node -e " + const fs = require('fs'); + const path = '${WORKSPACE_FILE}'; + const ws = JSON.parse(fs.readFileSync(path, 'utf8')); + const dirName = '${DIR_NAME}'; + const exists = ws.folders.some(f => f.path === dirName || f.name === dirName); + if (!exists) { + ws.folders.push({ path: dirName }); + fs.writeFileSync(path, JSON.stringify(ws, null, 2) + '\n'); + } + " >&2 +fi + +# Print the worktree path to stdout (required by the hook contract) +echo "$WORKTREE_PATH" diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..76e2bc1e3 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,149 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-settings.json", + "permissions": { + "allow": [ + "mcp__elements__api_list", + "mcp__elements__api_get", + "mcp__elements__api_template_validate", + "mcp__elements__api_imports_get", + "mcp__elements__api_tokens_list", + "mcp__elements__examples_list", + "mcp__elements__examples_get", + "mcp__elements__playground_validate", + "mcp__elements__playground_create", + "mcp__elements__project_create", + "mcp__elements__project_setup", + "mcp__elements__project_validate", + "mcp__elements__packages_list", + "mcp__elements__packages_get", + "mcp__elements__packages_changelogs_get", + "WebFetch(domain:code.claude.com)" + ] + }, + "enabledMcpjsonServers": [ + "elements" + ], + "hooks": { + "SessionStart": [ + { + "matcher": "startup", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/session-start.sh", + "statusMessage": "Setting up development environment..." + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/guard-critical-files.sh", + "statusMessage": "Checking critical file protections..." + } + ] + }, + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/lint-commitlint.sh", + "statusMessage": "Validating commit message..." + }, + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/guard-destructive.sh", + "statusMessage": "Checking for destructive operations..." + }, + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/guard-package-manager.sh", + "statusMessage": "Checking package manager usage..." + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/format-prettier.sh", + "statusMessage": "Running Prettier formatter..." + }, + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/lint-eslint.sh", + "statusMessage": "Running ESLint linter..." + }, + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/lint-vale.sh", + "statusMessage": "Running Vale prose linter..." + }, + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/lint-stylelint.sh", + "statusMessage": "Running Stylelint linter..." + } + ] + } + ], + "PermissionRequest": [ + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "echo '{\"hookSpecificOutput\": {\"hookEventName\": \"PermissionRequest\", \"decision\": {\"behavior\": \"allow\"}}}'" + } + ] + } + ], + "Notification": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "osascript -e 'display notification \"Claude Code needs your attention\" with title \"Claude Code\"'" + } + ] + } + ], + "WorktreeCreate": [ + { + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/worktree-create.sh", + "statusMessage": "Creating worktree with project conventions..." + } + ] + } + ], + "Stop": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/stop.sh", + "statusMessage": "Verifying repository state..." + }, + { + "type": "agent", + "prompt": "Verify that the repository is in a good state via `pnpm run ci` in the root of the repository.", + "timeout": 120 + } + ] + } + ] + } +} diff --git a/.claude/skills/accessibility/SKILL.md b/.claude/skills/accessibility/SKILL.md new file mode 100644 index 000000000..3e3547d35 --- /dev/null +++ b/.claude/skills/accessibility/SKILL.md @@ -0,0 +1,87 @@ +--- +name: repo-accessibility +description: Unified accessibility auditing workflow across static analysis, runtime testing, ARIA patterns, keyboard navigation, and color contrast. Use this skill whenever the user mentions accessibility, a11y, WCAG, ARIA roles, axe tests, screen readers, focus management, keyboard navigation, color contrast, or wants to audit, verify, or fix accessibility on any component. Also use when writing or debugging .test.axe.ts files, checking tabindex management, or reviewing focus trapping behavior. +user_invocable: true +--- + +# Accessibility Auditing + +You MUST read `projects/site/src/docs/internal/guidelines/testing-accessibility.md` before proceeding. + +## Audit Scope + +When the user invokes this skill for a component, perform the following layers of accessibility verification: + +### 1. Static analysis (ESLint lit-a11y) + +Run ESLint on the component source and check for `lit-a11y` rule violations: + +```shell +cd projects/core && pnpm exec eslint --no-warn-ignored src//.ts +``` + +Review the component template for common issues: + +- Missing `aria-label` or `aria-labelledby` on interactive elements +- Missing `role` attributes on custom interactive patterns +- Images without `alt` text +- Form inputs without associated labels + +### 2. Runtime testing (axe-core) + +Verify `.test.axe.ts` exists and covers all component variants: + +``` +Read path="projects/core/src//.test.axe.ts" +``` + +Check that the test: + +- Imports `runAxe` from `@internals/testing/axe` +- Tests all visual variants (status, size, disabled states) +- Expects zero violations: `expect(results.violations.length).toBe(0)` +- Properly creates and removes fixtures + +### 3. ARIA pattern verification + +Identify which WAI-ARIA Authoring Practices pattern the component implements. Verify: + +- Correct `role` attribute on the host or internal elements +- Required ARIA states (`aria-expanded`, `aria-selected`, `aria-checked`, etc.) +- Proper `tabindex` management +- Focus management for composite widgets + +### 4. Keyboard navigation + +Check if the component uses the `keynav` reactive controller from `@nvidia-elements/core/internal`: + +``` +Grep pattern="keynav|keyNavigationList|KeyNav" path="projects/core/src//" +``` + +Verify keyboard interactions match the ARIA pattern: + +- Arrow keys for navigation within composite widgets +- Enter/Space for activation +- Escape for dismissal +- Tab for focus entry/exit +- Home/End for first/last item + +### 5. Color contrast (theme tokens) + +Check that the component uses theme tokens rather than hardcoded colors: + +``` +Grep pattern="rgb|#[0-9a-fA-F]|hsl" path="projects/core/src//.css" +``` + +Flag any hardcoded color values. Verify CSS custom properties map to theme tokens that meet WCAG 2.1 AA contrast ratios (4.5:1 for normal text, 3:1 for large text). + +## Output + +Present a structured report with: + +1. Overall pass/fail status +2. Findings grouped by audit layer +3. Specific remediation steps for each finding +4. Commands to run the relevant tests diff --git a/.claude/skills/api-design/SKILL.md b/.claude/skills/api-design/SKILL.md new file mode 100644 index 000000000..f1c6fb577 --- /dev/null +++ b/.claude/skills/api-design/SKILL.md @@ -0,0 +1,50 @@ +--- +name: repo-api-design +description: Component API design patterns following Elements conventions for properties, attributes, CSS custom properties, slots, and events. Use this skill whenever the user is designing or deciding on a component API, choosing between properties vs attributes vs slots, naming CSS custom properties, designing events, avoiding impossible states, deciding whether to reflect to an attribute, using CSS Parts, implementing the DataElement interface, or applying the internal-host pattern. Also trigger when the user asks about shorthand vs granular CSS properties or event naming conventions. +--- + +# API Design + +You MUST review the relevant API design guidelines before designing or modifying component APIs. + +## When to Use This Skill + +- Designing properties and attributes for new components +- Creating CSS custom properties for theming +- Avoiding impossible states in component APIs +- Choosing between properties, attributes, and slots +- Designing custom events +- Planning component composition patterns +- Setting up package exports and dependencies + +## Stateless Components + +When creating new components or features that may contain user state review the [Stateless Components Guidelines](/projects/site/src/docs/api-design/stateless.md). + +## Properties vs Attributes + +When working with JavaScript properties and HTML attributes review the [Properties & Attributes Guidelines](/projects/site/src/docs/api-design/properties-attributes.md). + +## Styles & CSS Custom Properties + +When working with CSS, CSS Custom Properties or Design Tokens review the [Styles & CSS Custom Properties Guidelines](/projects/site/src/docs/api-design/styles.md). + +## Component Composition + +When creating a new API or determining API design of parent child relationships between components review the [Component Composition Guidelines](/projects/site/src/docs/api-design/composition.md). + +## Slots + +When working with user provided content or container-like components review the [Slots Guidelines](/projects/site/src/docs/api-design/slots.md). + +## Custom Events + +When creating new events or listening to existing event review the [Custom Events Guidelines](/projects/site/src/docs/api-design/custom-events.md). + +## Component Registration + +When creating new components or using components as dependencies review the [Component Registration Guidelines](/projects/site/src/docs/api-design/registration.md). + +## Packaging & Exports + +When creating a new component or package review the [Packaging & Exports Guidelines](/projects/site/src/docs/api-design/packaging.md). diff --git a/.claude/skills/build-system/SKILL.md b/.claude/skills/build-system/SKILL.md new file mode 100644 index 000000000..cf77c884b --- /dev/null +++ b/.claude/skills/build-system/SKILL.md @@ -0,0 +1,93 @@ +--- +name: repo-build-system +description: Wireit orchestration, build optimization, caching strategies, and build troubleshooting for the Elements monorepo. Use this skill whenever the user asks about Wireit configuration, build tasks, build dependencies, cache invalidation, build performance, adding new build tasks to package.json, cross-package dependency patterns, sideEffects declarations, CI pipeline configuration, or diagnosing slow or failing builds. Also trigger when the user mentions wireit, vite build issues, pnpm run ci, or build output problems. +--- + +# Build System + +You MUST review the build system documentation before modifying build configurations or troubleshooting build issues. + +## When to Use This Skill + +- Optimizing build performance and parallelization +- Understanding Wireit task dependencies +- Troubleshooting build failures or cache issues +- Adding new build tasks to projects +- Configuring build orchestration across packages +- Understanding CI/CD build pipeline + +## Quick Reference + +### Common Commands + +```shell +# Full CI locally (lint, build, test):same as CI pipeline +pnpm run ci + +# Clean everything (node_modules, dist, .wireit caches) +pnpm run ci:reset + +# Build a single project +cd projects/core && pnpm run build + +# Dev mode with watch +cd projects/core && pnpm run dev +``` + +### Wireit Basics + +Wireit configs live in each project's `package.json` under the `wireit` key. Each task declares: + +- `command`:the shell command to run +- `dependencies`:other wireit tasks that must complete first (can be cross-package) +- `files`:input file globs for cache invalidation +- `output`:output file globs that get cached + +Wireit skips tasks whose inputs have not changed since the last run. To force a rebuild, delete `.wireit/` in the project directory. + +### Dependency Patterns + +Cross-package dependencies use the `: +` +}; + +/** + * @summary Codeblock with line numbers for easier code reference and debugging. + */ +export const LineNumbers = { + render: () => html` + +function getTime(): number { + return new Date().getTime(); +} + +` +} + +/** + * @summary Codeblock with specific line highlighting to draw attention to important code sections. + */ +export const Highlight = { + render: () => html` + +function getTime(): number { + return new Date().getTime(); +} + +` +} + +/** + * @summary Codeblock with constrained height for scrollable overflow behavior with long code. + * @tags test-case + */ +export const Overflow = { + render: () => html` + + import{LitElement as t,html as e}from"lit";import{property as s}from"lit/decorators/property.js";import{state as o}from"lit/decorators/state.js"; + import{unsafeHTML as i}from"lit/directives/unsafe-html.js"; + import{useStyles as r,shiftLeft as n}from"@nvidia-elements/core/internal"; + import l from"./codeblock.css.js"; + import a from"highlight.js/lib/core"; + import h from"highlight.js/lib/languages/shell"; + +` +}; + +/** + * @summary Comparison of line wrapping vs horizontal scrolling for long code lines in constrained widths. + * @tags test-case + */ +export const LineWrap = { + render: () => html` +
+ + + + + + + +
+` +} diff --git a/projects/labs/code/src/codeblock/codeblock.snippets.html b/projects/code/src/codeblock/codeblock.snippets.html similarity index 100% rename from projects/labs/code/src/codeblock/codeblock.snippets.html rename to projects/code/src/codeblock/codeblock.snippets.html diff --git a/projects/code/src/codeblock/codeblock.test.axe.ts b/projects/code/src/codeblock/codeblock.test.axe.ts new file mode 100644 index 000000000..08180f584 --- /dev/null +++ b/projects/code/src/codeblock/codeblock.test.axe.ts @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { html } from 'lit'; +import { describe, expect, it } from 'vitest'; +import { createFixture, elementIsStable, removeFixture } from '@internals/testing'; +import { runAxe } from '@internals/testing/axe'; +import { CodeBlock } from '@nvidia-elements/code/codeblock'; +import '@nvidia-elements/code/codeblock/define.js'; + +describe(CodeBlock.metadata.tag, () => { + it('should pass axe check for status', async () => { + const fixture = await createFixture(html` + + pnpm install + + `); + + await elementIsStable(fixture.querySelector(CodeBlock.metadata.tag)!); + const results = (await runAxe([CodeBlock.metadata.tag])) as { violations: { length: number }[] }; + expect(results.violations.length).toBe(0); + removeFixture(fixture); + }); +}); diff --git a/projects/code/src/codeblock/codeblock.test.lighthouse.ts b/projects/code/src/codeblock/codeblock.test.lighthouse.ts new file mode 100644 index 000000000..42b6df16f --- /dev/null +++ b/projects/code/src/codeblock/codeblock.test.lighthouse.ts @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { expect, test, describe } from 'vitest'; +import { lighthouseRunner } from '@internals/vite'; + +describe('codeblock lighthouse report', () => { + test('codeblock should meet lighthouse benchmarks', async () => { + const report = await lighthouseRunner.getReport('nve-codeblock', /* html */` + + pnpm install + + + `); + + expect(report.scores.performance).toBe(100); + expect(report.scores.accessibility).toBe(100); + expect(report.scores.bestPractices).toBe(100); + expect(report.payload.javascript.kb).toBeLessThan(19.6); + }); +}); diff --git a/projects/code/src/codeblock/codeblock.test.ts b/projects/code/src/codeblock/codeblock.test.ts new file mode 100644 index 000000000..3ef0c7d6b --- /dev/null +++ b/projects/code/src/codeblock/codeblock.test.ts @@ -0,0 +1,171 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { html } from 'lit'; +import { describe, expect, it, beforeEach, afterEach } from 'vitest'; +import { createFixture, elementIsStable, removeFixture } from '@internals/testing'; +import { CodeBlock } from '@nvidia-elements/code/codeblock'; +import '@nvidia-elements/code/codeblock/languages/css.js'; +import '@nvidia-elements/code/codeblock/languages/go.js'; +import '@nvidia-elements/code/codeblock/languages/html.js'; +import '@nvidia-elements/code/codeblock/languages/javascript'; +import '@nvidia-elements/code/codeblock/languages/json.js'; +import '@nvidia-elements/code/codeblock/languages/markdown.js'; +import '@nvidia-elements/code/codeblock/languages/python'; +import '@nvidia-elements/code/codeblock/languages/typescript.js'; +import '@nvidia-elements/code/codeblock/languages/xml.js'; +import '@nvidia-elements/code/codeblock/languages/yaml.js'; +import '@nvidia-elements/code/codeblock/define.js'; + +describe('nve-codeblock', () => { + let fixture: HTMLElement; + let element: CodeBlock; + const typescript = ` +/** + * Function to get current time. + * @return {number} time in milis + */ +function getTime(): number { + return new Date().getTime(); +}`; + const slot = ``; + + beforeEach(async () => { + fixture = await createFixture(html` + + `); + element = fixture.querySelector(CodeBlock.metadata.tag) as CodeBlock; + await elementIsStable(element); + }); + + afterEach(() => { + removeFixture(fixture); + }); + + it('should define element', async () => { + await elementIsStable(element); + expect(customElements.get(CodeBlock.metadata.tag)).toBeDefined(); + }); + + it('should default to shell language', async () => { + await elementIsStable(element); + expect(element.language).toBe('shell'); + }); + + it('should have language defined', async () => { + element.language = 'typescript'; + await elementIsStable(element); + expect(element.language).toBe('typescript'); + }); + + it('should render source code if slotted', async () => { + element.language = 'typescript'; + element.innerHTML = typescript; + await elementIsStable(element); + expect(element.shadowRoot!.querySelector('.hljs-title')).toBeTruthy(); + }); + + it('should render source code if set via the code property', async () => { + element.language = 'typescript'; + element.code = typescript; + await elementIsStable(element); + expect(element.shadowRoot!.querySelector('.hljs-title')).toBeTruthy(); + }); + + it('should render HTML source code if slotted within a