diff --git a/.github/workflows/guidelines.md b/.github/workflows/guidelines.md index 7aa66cd9..06b34192 100644 --- a/.github/workflows/guidelines.md +++ b/.github/workflows/guidelines.md @@ -110,6 +110,72 @@ Existing single-theme workshop images are migration candidates, not exceptions: instead of duplicating the binary. - Do not delete an original asset until no Markdown or HTML reference uses it. +## GitHub visual language system + +When a generated diagram or illustration depicts a GitHub concept — such as an issue, pull request, discussion, commit, repository, or workflow run — use the GitHub visual language to represent it. This keeps diagrams recognisable to learners who already know the GitHub UI and avoids generic icon fonts or ambiguous shapes. + +### Octicon-inspired shapes + +Embed simplified Octicon-style shapes directly in SVG markup as inline `` or geometric primitives. Do not link to external icon files or import icon fonts — diagrams must be self-contained. + +| Concept | Shape guidance | +|---------|----------------| +| Issue (open) | Circle outline (`r 8`) with a smaller filled dot inside; stroke and fill use the open/green semantic color | +| Issue (closed) | Solid circle with an ✕ or checkmark path inside; fill uses the closed semantic color | +| Pull request (open) | Two small circles connected by a curved branch path (branch left, merge right); stroke uses the open/green color | +| Pull request (merged) | Same branch shape with a diamond merge point; fill and stroke use the merged/purple color | +| Pull request (draft) | Dashed circle outline with a pencil stub; stroke and fill use the muted/grey color | +| Discussion | Rounded speech-bubble outline (rect + pointer triangle); stroke uses the accent color | +| Commit | Small solid circle (`r 5`) centred on a horizontal branch line | +| Repository | Open book or folder outline using two rounded rect halves | +| Workflow / Actions run | Right-pointing filled triangle (play button) inside a rounded square | +| Schedule trigger | Clock face: circle outline + two short line segments for hands | + +Use these shapes at 16 × 16 or 24 × 24 logical units; scale to fit the diagram's label boxes using a `transform="scale(...)"` or by drawing at the target size directly. + +### Primer semantic colors for entity states + +Apply state-specific colors consistently so learners can interpret diagram nodes at a glance. + +| State | Light mode | Dark mode | Applies to | +|-------|-----------|-----------|------------| +| Open | `#1a7f37` | `#3fb950` | Open issues, open PRs | +| Closed | `#cf222e` | `#f85149` | Closed issues, closed PRs | +| Merged | `#8250df` | `#a371f7` | Merged pull requests | +| Draft | `#57606a` | `#8b949e` | Draft PRs, pending items | +| In progress | `#9a6700` | `#e3b341` | Running workflow steps, in-flight items | +| Done / Success | `#1a7f37` | `#3fb950` | Completed steps, passing checks | +| Skipped | `#57606a` | `#8b949e` | Skipped steps, inactive paths | +| Danger / Error | `#cf222e` | `#f85149` | Failed checks, error states | + +When the diagram is theme-aware, apply the matching column's values to each SVG variant. + +### GitHub visual language usage rules + +- **Always use GitHub icons** when a node represents a GitHub entity (issue, PR, discussion, commit, repository). Do not substitute plain rectangles or generic bullet shapes for recognisable GitHub concepts. +- **Accompany every icon node with a text label.** The icon conveys type; the label conveys content. Together they must be readable without prior knowledge of the icon. +- **Match state to color.** An open-issue node must use the open/green semantic color; a merged-PR node must use the merged/purple color. Do not use accent blue for concept nodes that have an explicit state color. +- **Keep icon shapes minimal.** Octicon-inspired primitives should be legible at diagram scale — omit ornamental detail that disappears below 24 px. +- **Use accent blue (`#0969da` / `#2f81f7`) for non-GitHub-entity highlights** such as data flows, trigger arrows, or focus callouts that do not correspond to a GitHub object. +- **Do not mix icon vocabularies.** Never combine Octicon-style shapes with Material Design, Font Awesome, or other third-party icon conventions in the same diagram. + +### Enforcing the visual language spec + +Run the static SVG visual language checker locally before committing new or updated SVG files: + +```bash +node scripts/check-svg-visual-language.js +``` + +To check specific files only: + +```bash +SVG_FILES="workshop/images/foo-light.svg workshop/images/foo-dark.svg" \ + node scripts/check-svg-visual-language.js +``` + +The check runs automatically in CI via `.github/workflows/svg-visual-language-check.yml` whenever SVG files change. Pull requests that introduce violations in changed SVG files will fail the check. Push events that introduce violations on `main` will create or update a tracked issue. + ## Alert callouts: use `
` only for multi-line content ### Alert level ceiling diff --git a/.github/workflows/svg-visual-language-check.yml b/.github/workflows/svg-visual-language-check.yml new file mode 100644 index 00000000..7a7029d6 --- /dev/null +++ b/.github/workflows/svg-visual-language-check.yml @@ -0,0 +1,112 @@ +name: SVG Visual Language Check + +on: + push: + paths: + - "**/*.svg" + - "scripts/check-svg-visual-language.js" + - ".github/workflows/svg-visual-language-check.yml" + pull_request: + paths: + - "**/*.svg" + - "scripts/check-svg-visual-language.js" + - ".github/workflows/svg-visual-language-check.yml" + +permissions: + contents: read + issues: write + +jobs: + svg-visual-language: + name: SVG Visual Language (GitHub VLS) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "20" + + - name: Find changed SVG files + id: changed + if: github.event_name == 'push' + run: | + if git cat-file -e "${{ github.event.before }}" 2>/dev/null; then + files=$(git diff --name-only --diff-filter=ACMR \ + "${{ github.event.before }}" "${{ github.sha }}" -- '*.svg' || true) + else + # First push or force-push: check all SVG files + files=$(git ls-files '*.svg' || true) + fi + echo "files<> "$GITHUB_OUTPUT" + echo "$files" >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + - name: Run visual language check on changed SVG files (push) + id: check-push + if: github.event_name == 'push' + continue-on-error: true + env: + SVG_FILES: ${{ steps.changed.outputs.files }} + run: node scripts/check-svg-visual-language.js + + - name: Run visual language check on all SVG files (pull_request) + id: check-pr + if: github.event_name == 'pull_request' + continue-on-error: true + run: node scripts/check-svg-visual-language.js + + - name: File issue for violations found on main + if: > + (steps.check-push.outcome == 'failure') && + github.event_name == 'push' && + github.ref == 'refs/heads/main' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const title = 'SVG visual language violation detected on main' + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}` + const body = [ + 'The SVG visual language check failed on `main`.', + '', + `Workflow run: ${runUrl}`, + '', + 'One or more workshop SVG files do not follow the GitHub visual language system', + 'defined in `.github/workflows/guidelines.md`.', + '', + 'Common violations:', + '- Unicode status/icon characters (✓ ✗ ⚡ 🕐 ▶) used instead of Octicon-inspired SVG paths', + '- Missing `role="img"` or accessible label on root `` element', + '- State-labeled shapes (Open, Closed, Merged, Draft) using off-palette fill colors', + '- Non-standard canvas width for themed (-light/-dark) variants', + '', + 'See the workflow run for the full list of violations and which files to fix.', + 'Refer to `.github/workflows/guidelines.md` § "GitHub visual language system" for the spec.', + ].join('\n') + const { owner, repo } = context.repo + const { data: search } = await github.rest.search.issuesAndPullRequests({ + q: `repo:${owner}/${repo} is:issue is:open in:title "${title}"`, + per_page: 5, + }) + const existing = search.items.find((i) => i.title === title) + if (existing) { + await github.rest.issues.createComment({ + owner, repo, issue_number: existing.number, body, + }) + } else { + await github.rest.issues.create({ + owner, repo, title, body, + labels: ['bug', 'accessibility'], + }) + } + + - name: Fail job when violations found on a pull request + if: > + steps.check-pr.outcome == 'failure' && + github.event_name == 'pull_request' + run: | + echo "SVG visual language violations found. See the check output above for details." + echo "Refer to .github/workflows/guidelines.md § 'GitHub visual language system' for the spec." + exit 1 diff --git a/.github/workflows/workshop-explanatory-diagrams.md b/.github/workflows/workshop-explanatory-diagrams.md index b611dc15..82c58787 100644 --- a/.github/workflows/workshop-explanatory-diagrams.md +++ b/.github/workflows/workshop-explanatory-diagrams.md @@ -228,17 +228,40 @@ Match the visual family of the UI screenshot workflow while staying conceptual: primary text `#24292f`, muted text `#57606a`, and accent `#0969da` - Dark palette: background `#0d1117`, panel `#161b22`, border `#30363d`, primary text `#f0f6fc`, muted text `#8b949e`, and accent `#2f81f7` -- Adapt semantic success, attention, danger, and done colors for sufficient - contrast in each theme +- Apply Primer semantic state colors from `.github/workflows/guidelines.md` + (GitHub visual language system section) when nodes represent GitHub entities + with a defined state (open, closed, merged, draft, in-progress, done, error) - Use simple labeled boxes, arrows, chips, dashed groupings, and numbered badges - Keep labels short and learner-friendly - Add a short title inside the graphic and a short annotation below it - Add `role="img"` and `aria-label` matching the Markdown alt text - Output valid, self-contained SVG only +### GitHub icon usage in diagrams + +When a diagram node represents a GitHub concept, use an Octicon-inspired inline +shape as described in the GitHub visual language system section of +`.github/workflows/guidelines.md`. Embed the shape directly in the SVG — do not +reference external files or icon fonts. + +Key shapes: + +- **Issue**: circle outline with inner dot (open/green) or solid circle with ✕ + (closed/red) +- **Pull request**: two-circle branch-and-merge path (open/green or merged/purple) +- **Discussion**: rounded speech-bubble outline (accent blue) +- **Commit**: small solid circle on a horizontal branch line +- **Workflow run**: right-pointing filled triangle inside a rounded square +- **Schedule trigger**: clock face (circle + two hand segments) + +Always label every icon node with a short text label. Use the icon to convey +type and the label to convey content. Apply the matching Primer state color so +the diagram is self-explanatory to anyone familiar with the GitHub UI. + ### Diagram content rules - Explain the concept, not a literal product screenshot +- Use GitHub visual language icons when a node represents a GitHub entity - Keep the visual readable at a glance - Use a left-to-right or top-to-bottom flow unless another layout is clearly better diff --git a/.github/workflows/workshop-ui-screenshots.md b/.github/workflows/workshop-ui-screenshots.md index 95a0fae7..4ed9d414 100644 --- a/.github/workflows/workshop-ui-screenshots.md +++ b/.github/workflows/workshop-ui-screenshots.md @@ -233,17 +233,27 @@ change set. - Represent GitHub's navigation tabs (Code, Issues, Pull requests, Actions, etc.) as a horizontal tab bar with the relevant tab underlined in the palette accent color. +- **Use GitHub visual language icons** (Octicon-inspired inline SVG shapes) for + any GitHub entity that appears in the illustration. Follow the shape guide and + Primer semantic state color table in the GitHub visual language system section + of `.github/workflows/guidelines.md`. Do not substitute plain rectangles or + generic symbols for recognisable GitHub concepts. +- For Issues and Pull requests tabs/rows: include the Octicon-inspired icon + (circle-with-dot for issues; branch-merge path for PRs) in the matching state + color (open/green, closed/red, merged/purple, draft/grey) alongside each row. +- For Discussions: use a rounded speech-bubble outline in accent blue. - For Actions-tab screenshots: show a workflow list panel with a single row - representing the relevant workflow, a status icon (green ✓ for success, - yellow (in-progress hourglass) for in-progress), and the workflow name. + representing the relevant workflow, a status icon (filled play-triangle in + `#1a7f37` for success, hourglass or spinner in `#9a6700` for in-progress) and + the workflow name. Apply Primer semantic state colors from the guidelines. - For Run workflow button: render a blue button labelled "Run workflow" in the Actions sidebar. - For Codespace/fork/commit dialogs: use a centered modal panel with the palette's panel background and border colors. - For schedule badge / workflow list badge: render the Actions sidebar list with - a clock icon and "Scheduled" label. -- For skipped steps: render the job-step list with a grey `-` icon and - "Skipped" label next to the step name. + a clock-face icon (Octicon clock shape) and "Scheduled" label. +- For skipped steps: render the job-step list with a grey dash icon in the + skipped/muted color and "Skipped" label next to the step name. - For summary/run log panels: render a dark panel (`#0d1117`) with monospace output lines in `#c9d1d9`, showing representative output from the described step. diff --git a/scripts/check-svg-visual-language.js b/scripts/check-svg-visual-language.js new file mode 100644 index 00000000..c7e65a19 --- /dev/null +++ b/scripts/check-svg-visual-language.js @@ -0,0 +1,445 @@ +// @ts-check +'use strict'; + +/** + * SVG Visual Language Check + * + * Validates that workshop SVG files follow the GitHub visual language system + * defined in .github/workflows/guidelines.md. + * + * Checks: + * 1. Accessibility — root must have role="img" and aria-label. + * 2. Icon characters — Unicode status/icon characters (✓ ✗ ✕ ⚡ 🕐 ▶ ►) + * must not appear in SVG nodes used as visual indicators. + * Use Octicon-inspired inline SVG paths instead (see guidelines). + * 3. Canvas dimensions — light/dark variant files (*-light.svg, *-dark.svg) + * must use viewBox="0 0 1200 560". + * 4. State-color parity — labeled state badges/pills ("Open", "Closed", + * "Merged", "Draft", "In progress") must use the correct Primer semantic + * fill color from the guidelines palette. + * + * Run directly (checks all SVGs in workshop/images/): + * node scripts/check-svg-visual-language.js + * + * Check specific files via SVG_FILES env var: + * SVG_FILES="workshop/images/foo.svg workshop/images/bar.svg" \ + * node scripts/check-svg-visual-language.js + * + * Exit 0 when all files pass; exit 1 when violations are found. + */ + +const fs = require('fs'); +const path = require('path'); + +// --------------------------------------------------------------------------- +// File discovery +// --------------------------------------------------------------------------- + +const repoRoot = path.resolve(__dirname, '..'); + +/** Recursively collect *.svg files under `dir`. */ +function findSvgFiles(dir) { + if (!fs.existsSync(dir)) return []; + const result = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + result.push(...findSvgFiles(full)); + } else if (entry.isFile() && entry.name.endsWith('.svg')) { + result.push(full); + } + } + return result; +} + +const envFiles = process.env.SVG_FILES; +const svgFiles = envFiles + ? envFiles + .split(/[\n\r\s]+/) + .map((f) => f.trim()) + .filter((f) => f.endsWith('.svg')) + .map((f) => path.resolve(repoRoot, f)) + .filter((f) => fs.existsSync(f)) + : findSvgFiles(path.join(repoRoot, 'workshop', 'images')); + +// --------------------------------------------------------------------------- +// Primer semantic state color palette (from guidelines.md) +// Light and dark hex values, lowercased. +// --------------------------------------------------------------------------- + +/** @type {Record} */ +const STATE_COLORS = { + open: { light: ['#1a7f37'], dark: ['#3fb950'] }, + closed: { light: ['#cf222e'], dark: ['#f85149'] }, + merged: { light: ['#8250df'], dark: ['#a371f7'] }, + draft: { light: ['#57606a'], dark: ['#8b949e'] }, + // "In progress" maps to the attention/warning palette. + 'in progress': { light: ['#9a6700'], dark: ['#e3b341'] }, +}; + +/** + * State keywords used in diagram text labels that indicate a Primer state. + * Maps the lowercase text label to the state key in STATE_COLORS. + * + * Labels are matched only when the label text is SHORT (≤ STATE_LABEL_MAX_LEN + * characters) and the keyword appears as the COMPLETE label or as the label's + * leading word. This avoids false positives from content strings like + * "Draft summaries & comments" where "draft" is a modifier, not a state tag. + * @type {Record} + */ +const LABEL_TO_STATE = { + open: 'open', + closed: 'closed', + merged: 'merged', + draft: 'draft', + 'in progress': 'in progress', + 'in-progress': 'in progress', +}; + +/** + * Maximum length (characters) of a label hint for it to be treated as a + * state badge/pill. Labels longer than this are assumed to be content, not + * a status indicator. + */ +const STATE_LABEL_MAX_LEN = 15; + +/** + * Unicode characters that should be replaced with Octicon-style inline SVG + * paths per the GitHub visual language guidelines. + * + * Keys are the Unicode characters; values are human-readable descriptions of + * the recommended replacement shape. + * + * These are flagged only when used as standalone icon indicators (short text + * nodes ≤ ICON_CHAR_MAX_LEN characters), not when they appear inside longer + * terminal output strings where the character is part of the content itself. + */ +const ICON_CHARS = { + '✓': 'Octicon check path inside a circle', + '✔': 'Octicon check path inside a circle', + '✗': 'Octicon X path inside a circle', + '✕': 'Octicon X path inside a circle', + '✘': 'Octicon X path inside a circle', + '⚡': 'Octicon play-triangle (workflow trigger icon)', + '🕐': 'Octicon clock-face shape (circle + hand segments)', + '🕛': 'Octicon clock-face shape (circle + hand segments)', + '▶': 'Octicon play-triangle inside a rounded square', + '►': 'Octicon play-triangle inside a rounded square', +}; + +/** + * Maximum length of a text node for it to be treated as a standalone icon + * indicator. Text nodes longer than this are assumed to be content/output + * strings (e.g. terminal output) where the character is part of the text. + */ +const ICON_CHAR_MAX_LEN = 20; + +const ICON_CHARS_RE = new RegExp( + Object.keys(ICON_CHARS) + .map((c) => c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + .join('|') +); + +// --------------------------------------------------------------------------- +// Standard canvas +// --------------------------------------------------------------------------- + +/** + * Approved canvas widths for workshop SVG files. + * 1200px is the standard for most diagrams; 960px is used for tool-card images + * (side-quest-01-02-*) that display in a 2-column grid layout. + */ +const APPROVED_WIDTHS = new Set([960, 1200]); + +/** Regex that matches the root opening tag. */ +const SVG_OPEN_TAG_RE = /]*)>/i; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Extract the value of an attribute from an SVG tag string. + * @param {string} tag - Raw HTML/SVG tag string. + * @param {string} name - Attribute name. + * @returns {string | null} + */ +function attrValue(tag, name) { + const re = new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, 'i'); + const m = re.exec(tag); + if (!m) return null; + return m[1] !== undefined ? m[1] : m[2]; +} + +/** + * Strip all XML/HTML tags from a string, returning only the plain text + * content between tags. Uses a character-level scanner that provably removes + * every `<` and `>` character (and everything between them) from the output, + * regardless of whether tags are well-formed. + * @param {string} str + * @returns {string} + */ +function stripTags(str) { + let result = ''; + let inTag = false; + for (let i = 0; i < str.length; i++) { + const c = str[i]; + if (c === '<') { + inTag = true; + } else if (c === '>') { + inTag = false; + } else if (!inTag) { + result += c; + } + } + return result; +} + +/** + * Extract all text content from SVG and elements. + * Returns each non-empty trimmed text string found. + * @param {string} svg + * @returns {string[]} + */ +function extractTextContent(svg) { + const results = []; + const textRe = /<(?:text|tspan)\b[^>]*>([\s\S]*?)<\/(?:text|tspan)>/gi; + let m; + while ((m = textRe.exec(svg)) !== null) { + const inner = stripTags(m[1]).trim(); + if (inner) results.push(inner); + } + return results; +} + +/** + * Find all / blocks with their fill and content. + * Returns objects { fill, content } for every text element that has a + * non-empty text content. + * @param {string} svg + * @returns {Array<{fill: string, content: string}>} + */ +function extractTextNodes(svg) { + const results = []; + // Match full blocks (including nested tspan). + const textRe = /]*)>([\s\S]*?)<\/text>/gi; + let m; + while ((m = textRe.exec(svg)) !== null) { + const attrs = m[1]; + const body = m[2]; + const fill = (attrValue(attrs, 'fill') || '').toLowerCase(); + // Strip inner tags and whitespace — use the character-level scanner. + const content = stripTags(body).trim(); + if (content) results.push({ fill, content }); + } + return results; +} + +/** + * Find all shape elements (rect/circle/ellipse/path/polygon) and their fill + * attribute values, along with any adjacent or nearby sibling text that might + * label them. + * + * This is a heuristic. It returns {fill, labelHint} where labelHint is the + * content of a element that appears right after the shape inside the + * same group, if present. + * + * @param {string} svg + * @returns {Array<{fill: string, labelHint: string}>} + */ +function extractShapeFills(svg) { + const results = []; + // Match shapes + their trailing text siblings within the same group. + const shapeRe = + /<(?:rect|circle|ellipse|path|polygon)\b([^/]*?)(?:\/>|>[\s\S]*?<\/(?:rect|circle|ellipse|path|polygon)>)/gi; + let m; + while ((m = shapeRe.exec(svg)) !== null) { + const attrs = m[1]; + const fill = (attrValue(attrs, 'fill') || '').toLowerCase(); + if (!fill || fill === 'none' || fill === 'transparent') continue; + // Look for a text element within the next 800 characters (same group heuristic). + const nearby = svg.slice(m.index + m[0].length, m.index + m[0].length + 800); + const textM = /]*>([\s\S]*?)<\/text>/i.exec(nearby); + const labelHint = textM ? stripTags(textM[1]).trim() : ''; + results.push({ fill, labelHint }); + } + return results; +} + +// --------------------------------------------------------------------------- +// Check functions +// --------------------------------------------------------------------------- + +/** + * @param {string} svgContent + * @param {string} relPath + * @returns {string[]} violation messages + */ +function checkAccessibility(svgContent, relPath) { + const violations = []; + const openM = SVG_OPEN_TAG_RE.exec(svgContent); + if (!openM) { + violations.push('No root element found.'); + return violations; + } + const rootAttrs = openM[1]; + if (!/\brole\s*=\s*"img"/i.test(rootAttrs)) { + violations.push('Missing role="img" on root element.'); + } + // Accept either aria-label (inline) or aria-labelledby (reference to /). + if (!/\baria-label(?:ledby)?\s*=/i.test(rootAttrs)) { + violations.push( + 'Missing accessible label on root element. ' + + 'Add aria-label="..." or aria-labelledby="..." (paired with a element).' + ); + } + return violations; +} + +/** + * @param {string} svgContent + * @param {string} relPath + * @returns {string[]} violation messages + */ +function checkIconCharacters(svgContent, relPath) { + const violations = []; + const textNodes = extractTextContent(svgContent); + for (const text of textNodes) { + // Skip long text nodes — these are content strings (terminal output, prose) + // where a ✓ or similar character is part of the text itself, not an icon. + if (text.length > ICON_CHAR_MAX_LEN) continue; + const m = ICON_CHARS_RE.exec(text); + if (m) { + const char = m[0]; + const replacement = ICON_CHARS[char] || 'an Octicon-inspired inline SVG path'; + violations.push( + `Unicode icon character ${JSON.stringify(char)} found in text node ` + + `${JSON.stringify(text.substring(0, 60))}. ` + + `Replace with ${replacement} per the GitHub visual language guidelines.` + ); + } + } + return violations; +} + +/** + * @param {string} svgContent + * @param {string} relPath + * @returns {string[]} violation messages + */ +function checkCanvasDimensions(svgContent, relPath) { + const violations = []; + const isThemed = /-(?:light|dark)\.svg$/i.test(relPath); + if (!isThemed) return violations; // single-theme files are exempt + + const openM = SVG_OPEN_TAG_RE.exec(svgContent); + if (!openM) return violations; + const rootAttrs = openM[1]; + const viewBox = attrValue(rootAttrs, 'viewBox') || ''; + // Extract the width from viewBox="0 0 W H". + const vbM = viewBox.match(/^0\s+0\s+(\d+)/); + if (vbM) { + const w = parseInt(vbM[1], 10); + if (!APPROVED_WIDTHS.has(w)) { + violations.push( + `Themed variant uses a non-standard canvas width of ${w}px ` + + `(viewBox="${viewBox}"). ` + + `Standard widths are: ${[...APPROVED_WIDTHS].join(', ')}. ` + + 'Use viewBox="0 0 1200 " for full-width diagrams.' + ); + } + } + return violations; +} + +/** + * Check that state-labeled shape nodes use the correct Primer semantic color. + * + * Heuristic: find shapes whose nearest text sibling contains a known state + * label as a standalone word and verify the shape fill matches the Primer + * state color for the file's theme (light/dark). + * + * @param {string} svgContent + * @param {string} relPath + * @returns {string[]} violation messages + */ +function checkStateColors(svgContent, relPath) { + const violations = []; + const isDark = /-dark\.svg$/i.test(relPath); + const isLight = /-light\.svg$/i.test(relPath); + if (!isDark && !isLight) return violations; // skip single-theme files + + const theme = isDark ? 'dark' : 'light'; + const shapes = extractShapeFills(svgContent); + + for (const { fill, labelHint } of shapes) { + // Use the raw label for comparison (no HTML entity decoding needed since + // state keywords like "open", "closed", "merged" contain no entities). + const lowerLabel = labelHint.toLowerCase().trim(); + // Skip long labels — these are content strings, not state tags. + if (lowerLabel.length > STATE_LABEL_MAX_LEN) continue; + for (const [keyword, stateKey] of Object.entries(LABEL_TO_STATE)) { + // Match only when the label IS the state keyword (possibly with a + // numeric count like "Open (3)" or "Closed 2"), not when the keyword + // modifies a noun like "Draft result". + // Pattern: optional leading space, keyword, then only digits/parens/spaces. + const exactRe = new RegExp(`^${keyword}(?:\\s*\\(\\d+\\)|\\s+\\d+)?\\s*$`); + if (!exactRe.test(lowerLabel)) continue; + const expectedColors = STATE_COLORS[stateKey][theme]; + if (expectedColors.length === 0) continue; + if (!expectedColors.includes(fill)) { + violations.push( + `Shape with label ${JSON.stringify(labelHint.substring(0, 40))} ` + + `suggests state "${stateKey}" but uses fill="${fill}". ` + + `Expected Primer ${theme}-mode color: ${expectedColors.join(' or ')}.` + ); + } + break; // matched one keyword — no need to keep checking others + } + } + return violations; +} + +// --------------------------------------------------------------------------- +// Runner +// --------------------------------------------------------------------------- + +let totalViolations = 0; +const errorFiles = []; + +for (const svgPath of svgFiles) { + const relPath = path.relative(repoRoot, svgPath); + const svgContent = fs.readFileSync(svgPath, 'utf-8'); + + const violations = [ + ...checkAccessibility(svgContent, relPath), + ...checkIconCharacters(svgContent, relPath), + ...checkCanvasDimensions(svgContent, relPath), + ...checkStateColors(svgContent, relPath), + ]; + + if (violations.length > 0) { + totalViolations += violations.length; + errorFiles.push(relPath); + process.stderr.write(`\n${relPath}\n`); + for (const v of violations) { + process.stderr.write(` - ${v}\n`); + } + } +} + +if (totalViolations > 0) { + process.stderr.write( + `\n${totalViolations} visual language violation(s) found in ${errorFiles.length} file(s).\n` + ); + process.stderr.write( + 'See .github/workflows/guidelines.md § "GitHub visual language system" for the full spec.\n' + ); + process.exit(1); +} else { + const count = svgFiles.length; + process.stdout.write( + `${count} SVG file(s) checked — no visual language violations found.\n` + ); + process.exit(0); +}