diff --git a/README.md b/README.md
index ffd22f5..167085b 100644
--- a/README.md
+++ b/README.md
@@ -17,6 +17,32 @@
---
+## :sparkles: New: CodeFlow Card — a slick repo-stats GitHub Action
+
+Drop a self-updating SVG card on your README that shows your repo's **health grade**, **scale**, **fragility (top blast-radius files)**, and **hidden costs** — recomputed on every merge. Same analyzer as the web app; zero drift.
+
+```yaml
+# .github/workflows/codeflow-card.yml
+on: { push: { branches: [main] }, pull_request: { types: [closed] }, workflow_dispatch: {} }
+jobs:
+ card:
+ runs-on: ubuntu-latest
+ permissions: { contents: write, pull-requests: write }
+ steps:
+ - uses: actions/checkout@v4
+ - uses: braedonsaunders/codeflow/card@v1
+```
+
+Then in your README:
+
+```markdown
+
+```
+
+Optional opt-in `receipts: true` posts a thermal-receipt-style sticky comment on every merged PR, itemizing the merge: `+/- LOC`, blast-radius before/after, grade delta. See [card/README.md](./card/README.md) for full inputs.
+
+---
+
## Why CodeFlow?
Ever opened a new codebase and felt completely lost? **CodeFlow** turns any GitHub repository or local codebase into an interactive architecture map in seconds.
@@ -71,6 +97,9 @@ Color files by commit frequency to see which parts of your codebase are most act
### PR Impact Analysis
Paste a PR URL to see exactly which files it affects and calculate the blast radius of proposed changes.
+### CodeFlow Card (GitHub Action)
+Auto-updating SVG card on your README — health grade, scale, fragility, hidden costs — recomputed every merge. Optional thermal-receipt PR comments. See [card/](./card/).
+
### Markdown & Wiki-Link Graph
Point CodeFlow at an Obsidian vault or any markdown directory to see notes as a connected graph. Both `[[wiki-links]]` and `[text](./relative.md)` links become edges; each note is a `note`-layer node (distinct color) with a `dependencies[]` array in the JSON export.
@@ -347,6 +376,55 @@ node --test tests/
---
+## Card Style Gallery
+
+All examples below are real cards rendered by the [CodeFlow Card Action](./card/) against this very repo. Pick one and drop it on your README.
+
+### `style: compact` — default
+
+
+
+### `style: compact` with `show-grade: false, show-score: false`
+
+For public READMEs where you'd rather show data than a letter grade. The card stays informational — files, functions, LOC, languages, tests — without the judgmental bits.
+
+
+
+### `accent` — any preset or CSS color
+
+The accent recolors the sparklines, links, and pin. Presets: `purple` (default), `teal`, `cyan`, `green`, `pink`, `blue`, `amber`, `red`. Or pass any CSS color (e.g. `#ff6b6b`).
+
+
+
+
+### `style: row` — status-bar strip
+
+
+
+### `style: minimal` — single text line
+
+
+
+### `style: hero` — splashy gradient
+
+
+
+`hero` with `show-grade: false`:
+
+
+
+### `style: detailed` — information-rich
+
+Everything: grade, scale, language breakdown, composition (connections, tests, folders, function stats, patterns), top folders, fragility, hidden costs.
+
+
+
+`detailed` with `show-grade: false`:
+
+
+
+---
+
## Star History
If you find CodeFlow useful, please star the repo.
diff --git a/card/README.md b/card/README.md
new file mode 100644
index 0000000..045e80d
--- /dev/null
+++ b/card/README.md
@@ -0,0 +1,88 @@
+# CodeFlow Card
+
+A GitHub Action that drops a slick auto-updating SVG card on your README — health grade, scale, fragility, hidden costs — recomputed every merge by [codeflow](https://github.com/braedonsaunders/codeflow).
+
+The card uses the **same analyzer** as the codeflow web app. There's no separate parser, no version drift — the Action reads codeflow's `index.html` and runs its analyzer in a Node `vm`.
+
+## Quick start
+
+Drop this file in `.github/workflows/codeflow-card.yml`:
+
+```yaml
+name: CodeFlow Card
+on:
+ push:
+ branches: [main]
+ pull_request:
+ types: [closed]
+ workflow_dispatch:
+
+jobs:
+ card:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ pull-requests: write
+ steps:
+ - uses: actions/checkout@v4
+ - uses: braedonsaunders/codeflow/card@v1
+ with:
+ theme: dark
+ receipts: false # set true to post merged-PR comments
+```
+
+Then add this to your README:
+
+```markdown
+
+```
+
+The Action commits the rendered SVG to `.github/codeflow-card.svg` (overwriting it on every run) and a small history file at `.github/codeflow-card.json` that powers the sparklines and deltas.
+
+## Inputs
+
+| Input | Default | Description |
+|---|---|---|
+| `output` | `.github/codeflow-card.svg` | Path to write the card SVG. |
+| `state` | `.github/codeflow-card.json` | Path to the JSON history file (sparklines, deltas). |
+| `theme` | `dark` | `dark` \| `light`. |
+| `panels` | `grade,scale,fragility,hidden-costs` | Comma-separated list. Drop any to exclude. |
+| `receipts` | `false` | Post a thermal-receipt-style sticky comment on each merged PR. |
+| `sparkline-window` | `30` | Recent runs to keep in state for sparklines. |
+| `pin` | `true` | Show the "Powered by codeflow" footer. |
+| `commit-message` | `chore: update codeflow card [skip ci]` | Commit message used by the Action. |
+| `github-token` | `${{ github.token }}` | Token for committing and posting receipts. |
+
+## What's on the card
+
+- **Health** — letter grade (A+ → F) with delta arrow vs the last run, plus the underlying score.
+- **Scale** — files / functions / LOC / languages, each with a 30-run sparkline (after the second run).
+- **Fragility** — top 3 highest-blast-radius files. The numbers nobody usually shows.
+- **Hidden costs** — circular deps, dead code %, average coupling. Lower-is-better arrows.
+
+## PR receipts (opt-in)
+
+When `receipts: true`, every merged PR gets a sticky comment with a thermal-receipt-style summary of what the merge changed:
+
+```
+--- CODEFLOW RECEIPT ---
+PR #482 @yourhandle
+--------------------------
+LOC +312
+functions +4
+dead code −1
+circular deps −1
+blast radius 23 → 18 ▼
+health B+ → A- ▲
+--------------------------
+ thank you for your merge
+```
+
+The comment is sticky (updates in place via `` marker) so re-runs don't spam the PR.
+
+## Notes
+
+- **First run**: with no history yet, sparklines and deltas don't render — the panels degrade gracefully.
+- **Permissions**: the workflow needs `contents: write` to commit the SVG, and `pull-requests: write` if `receipts: true`.
+- **CI cost**: analysis runs in pure Node (no Docker, no external APIs); typical run is 10–30 seconds depending on repo size.
+- **Privacy**: nothing leaves the runner. Same guarantee as the codeflow web app.
diff --git a/card/action.yml b/card/action.yml
new file mode 100644
index 0000000..b386dec
--- /dev/null
+++ b/card/action.yml
@@ -0,0 +1,70 @@
+name: 'CodeFlow Card'
+description: 'Auto-updating SVG card of your repo health, fragility, and structure. Powered by codeflow.'
+author: 'braedonsaunders'
+branding:
+ icon: 'activity'
+ color: 'purple'
+inputs:
+ output:
+ description: 'Path to write the card SVG.'
+ required: false
+ default: '.github/codeflow-card.svg'
+ state:
+ description: 'Path to the JSON history file used for sparklines and deltas.'
+ required: false
+ default: '.github/codeflow-card.json'
+ theme:
+ description: 'Card theme: dark | light | auto.'
+ required: false
+ default: 'dark'
+ accent:
+ description: 'Accent color. Either a preset (purple|teal|cyan|green|pink|blue|amber|red) or any CSS color (e.g. #ff6b6b).'
+ required: false
+ default: ''
+ style:
+ description: 'Card style: compact | row | minimal | hero | detailed.'
+ required: false
+ default: 'compact'
+ panels:
+ description: 'Only used when style=detailed. Comma-separated: grade, scale, languages, fragility, hidden-costs, patterns.'
+ required: false
+ default: 'grade,scale'
+ show-grade:
+ description: 'Show the letter grade. Set to false on public READMEs to keep the card informational without judgment.'
+ required: false
+ default: 'true'
+ show-score:
+ description: 'Show the numeric score. Set to false to hide the granular health number while keeping the letter.'
+ required: false
+ default: 'true'
+ receipts:
+ description: 'Post a thermal-receipt-style PR comment when a PR is merged.'
+ required: false
+ default: 'false'
+ sparkline-window:
+ description: 'How many recent runs to keep in state for sparklines/deltas.'
+ required: false
+ default: '30'
+ pin:
+ description: 'Show "Powered by codeflow" footer link.'
+ required: false
+ default: 'true'
+ commit-message:
+ description: 'Commit message used when the SVG / state file change.'
+ required: false
+ default: 'chore: update codeflow card [skip ci]'
+ commit-author-name:
+ description: 'Git commit author name.'
+ required: false
+ default: 'codeflow-card[bot]'
+ commit-author-email:
+ description: 'Git commit author email.'
+ required: false
+ default: 'codeflow-card[bot]@users.noreply.github.com'
+ github-token:
+ description: 'Token used to commit and post receipts. Defaults to the workflow token.'
+ required: false
+ default: ${{ github.token }}
+runs:
+ using: 'node20'
+ main: 'index.js'
diff --git a/card/examples/compact-pink.svg b/card/examples/compact-pink.svg
new file mode 100644
index 0000000..d0ba04c
--- /dev/null
+++ b/card/examples/compact-pink.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/card/examples/compact-private.svg b/card/examples/compact-private.svg
new file mode 100644
index 0000000..1002019
--- /dev/null
+++ b/card/examples/compact-private.svg
@@ -0,0 +1 @@
+braedonsaunders/codeflow@galleryFILES38FNS555LOC10.2KLANGS7TESTS3powered by codeflow
\ No newline at end of file
diff --git a/card/examples/compact-teal.svg b/card/examples/compact-teal.svg
new file mode 100644
index 0000000..de2443d
--- /dev/null
+++ b/card/examples/compact-teal.svg
@@ -0,0 +1 @@
+braedonsaunders/codeflow@galleryHEALTHB80/100FILES38FNS555LOC10.2KLANGS7powered by codeflow
\ No newline at end of file
diff --git a/card/examples/compact.svg b/card/examples/compact.svg
new file mode 100644
index 0000000..2ea4755
--- /dev/null
+++ b/card/examples/compact.svg
@@ -0,0 +1 @@
+braedonsaunders/codeflow@galleryHEALTHB80/100FILES38FNS555LOC10.2KLANGS7powered by codeflow
\ No newline at end of file
diff --git a/card/examples/detailed-private.svg b/card/examples/detailed-private.svg
new file mode 100644
index 0000000..1202bf1
--- /dev/null
+++ b/card/examples/detailed-private.svg
@@ -0,0 +1 @@
+braedonsaunders/codeflow@galleryFILES38FNS555LOC10.2KLANGS7LANGUAGES.html67%.js19%.mjs8%.md5%.yml1%CONNECTIONS142TEST FILES3FOLDERS3AVG FN LINES15.8LONGEST FN16LPATTERNS7TOP FOLDERS · BY FILE COUNTtests20card16root2FRAGILITY · TOP BLAST RADIUSindex.html20 direct · 25 transitivecard/lib/collect.js5 direct · 24 transitivetests/fixtures/golden-world/src/math.js2 direct · 26 transitiveupdated 2026-04-28powered by codeflow
\ No newline at end of file
diff --git a/card/examples/detailed.svg b/card/examples/detailed.svg
new file mode 100644
index 0000000..a5a4eac
--- /dev/null
+++ b/card/examples/detailed.svg
@@ -0,0 +1 @@
+braedonsaunders/codeflow@galleryHEALTHB80SCORE / 100FILES38FNS555LOC10.2KLANGS7LANGUAGES.html67%.js19%.mjs8%.md5%.yml1%CONNECTIONS142TEST FILES3FOLDERS3AVG FN LINES15.8LONGEST FN16LPATTERNS7TOP FOLDERS · BY FILE COUNTtests20card16root2FRAGILITY · TOP BLAST RADIUSindex.html20 direct · 25 transitivecard/lib/collect.js5 direct · 24 transitivetests/fixtures/golden-world/src/math.js2 direct · 26 transitiveCIRCULAR DEPS1DEAD CODE0.7%AVG COUPLING3.7updated 2026-04-28powered by codeflow
\ No newline at end of file
diff --git a/card/examples/hero-private.svg b/card/examples/hero-private.svg
new file mode 100644
index 0000000..f5717ab
--- /dev/null
+++ b/card/examples/hero-private.svg
@@ -0,0 +1 @@
+CODEFLOW · CODEBASE OVERVIEWbraedonsaunders/codeflow@galleryFILES38FUNCTIONS555LINES OF CODE10.2KLANGUAGES7TEST FILES3CONNECTIONS142powered by codeflow
\ No newline at end of file
diff --git a/card/examples/hero.svg b/card/examples/hero.svg
new file mode 100644
index 0000000..b40b682
--- /dev/null
+++ b/card/examples/hero.svg
@@ -0,0 +1 @@
+CODEFLOW · HEALTH REPORTbraedonsaunders/codeflow@galleryB80SCORE / 100FILES38FUNCTIONS555LINES OF CODE10.2KLANGUAGES7powered by codeflow
\ No newline at end of file
diff --git a/card/examples/minimal.svg b/card/examples/minimal.svg
new file mode 100644
index 0000000..46998a3
--- /dev/null
+++ b/card/examples/minimal.svg
@@ -0,0 +1 @@
+braedonsaunders/codeflow · B · 38 files · 555 fns · 10.2K LOC · 7 langscodeflow
\ No newline at end of file
diff --git a/card/examples/row.svg b/card/examples/row.svg
new file mode 100644
index 0000000..7ccc8e8
--- /dev/null
+++ b/card/examples/row.svg
@@ -0,0 +1 @@
+Bbraedonsaunders/codeflow38 files · 555 fns · 10.2K LOC · 7 langs · 3 tests · score 80codeflow
\ No newline at end of file
diff --git a/card/index.js b/card/index.js
new file mode 100644
index 0000000..bc404b7
--- /dev/null
+++ b/card/index.js
@@ -0,0 +1,161 @@
+// CodeFlow Card — GitHub Action entry point.
+// Reads codeflow's analyzer out of index.html, runs it on the consuming repo,
+// renders an SVG card, optionally posts a PR receipt comment, then commits the
+// updated card + history file back to the repo.
+
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+
+const { loadInputs } = require('./lib/inputs.js');
+const { loadAnalyzer, locateIndexHtml } = require('./lib/analyzer.js');
+const { buildAnalyzed } = require('./lib/collect.js');
+const { readState, appendRun, writeState, snapshotFromAnalysis } = require('./lib/state.js');
+const { renderCard } = require('./render/card.js');
+const { renderReceiptMarkdown } = require('./render/receipt-md.js');
+const { commitAndPush } = require('./lib/git.js');
+const { upsertStickyComment } = require('./lib/pr.js');
+
+function log(msg) {
+ process.stdout.write('[codeflow-card] ' + msg + '\n');
+}
+
+function loadEvent() {
+ const eventPath = process.env.GITHUB_EVENT_PATH;
+ if (!eventPath || !fs.existsSync(eventPath)) return null;
+ try {
+ return JSON.parse(fs.readFileSync(eventPath, 'utf8'));
+ } catch {
+ return null;
+ }
+}
+
+function repoSlug() {
+ return process.env.GITHUB_REPOSITORY || '';
+}
+
+function repoOwnerName() {
+ const slug = repoSlug();
+ if (!slug.includes('/')) return [null, null];
+ return slug.split('/');
+}
+
+async function run() {
+ const inputs = loadInputs();
+ const repoRoot = process.env.GITHUB_WORKSPACE || process.cwd();
+ log('analyzing ' + repoRoot);
+
+ const actionDir = __dirname;
+ const indexHtmlPath = locateIndexHtml(actionDir, repoRoot);
+ log('analyzer source: ' + indexHtmlPath);
+
+ const { Parser, buildAnalysisData, calcBlast, calcHealth } = loadAnalyzer(indexHtmlPath);
+
+ const { analyzed, allFns } = await buildAnalyzed(repoRoot, Parser);
+ log('collected ' + analyzed.length + ' files (' + allFns.length + ' functions)');
+
+ const data = await buildAnalysisData({
+ analyzed,
+ allFns,
+ excludePatterns: [],
+ progress: () => {},
+ yieldFn: async () => {},
+ });
+ log('analysis: files=' + data.stats.files + ' fns=' + data.stats.functions + ' loc=' + data.stats.loc);
+
+ const event = loadEvent();
+ const sha = process.env.GITHUB_SHA || null;
+ const actor = process.env.GITHUB_ACTOR || null;
+ const prNumber =
+ (event && event.pull_request && event.pull_request.number) ||
+ (event && event.number) ||
+ null;
+ const ctx = { sha, actor, pr: prNumber };
+
+ const snapshot = snapshotFromAnalysis(data, { calcBlast, calcHealth }, ctx);
+ log('grade=' + (snapshot.grade || '?') + ' score=' + (snapshot.score == null ? '?' : snapshot.score));
+
+ const stateAbs = path.resolve(repoRoot, inputs.state);
+ const state = readState(stateAbs);
+ const previous = state.runs.length > 0 ? state.runs[state.runs.length - 1] : null;
+
+ // Render card SVG.
+ const slug = repoSlug();
+ const svg = renderCard({
+ snapshot,
+ history: state.runs,
+ theme: inputs.theme,
+ accent: inputs.accent,
+ style: inputs.style,
+ panels: inputs.panels,
+ showGrade: inputs.showGrade,
+ showScore: inputs.showScore,
+ repo: slug,
+ sha: sha,
+ pin: inputs.pin,
+ });
+ const outAbs = path.resolve(repoRoot, inputs.output);
+ fs.mkdirSync(path.dirname(outAbs), { recursive: true });
+ fs.writeFileSync(outAbs, svg);
+ log('wrote card → ' + path.relative(repoRoot, outAbs));
+
+ // Append snapshot to state.
+ const updatedState = appendRun(state, snapshot, inputs.sparklineWindow);
+ writeState(stateAbs, updatedState);
+ log('wrote state → ' + path.relative(repoRoot, stateAbs));
+
+ // Receipt comment (opt-in, only on merged PRs).
+ const isMergedPR =
+ !!event && event.pull_request && event.pull_request.merged === true && prNumber != null;
+ if (inputs.receipts && isMergedPR && inputs.token) {
+ try {
+ const [owner, name] = repoOwnerName();
+ if (owner && name) {
+ const body = renderReceiptMarkdown({
+ snapshot,
+ previous,
+ repo: slug,
+ pr: { number: prNumber, actor: event.pull_request.user && event.pull_request.user.login },
+ actor,
+ });
+ await upsertStickyComment({
+ token: inputs.token,
+ owner,
+ repo: name,
+ issueNumber: prNumber,
+ body,
+ });
+ log('posted receipt comment to PR #' + prNumber);
+ }
+ } catch (e) {
+ log('receipt post failed: ' + (e.message || e));
+ }
+ } else if (inputs.receipts) {
+ log('receipts enabled, but not a merged PR — skipping');
+ }
+
+ // Commit changes back to the repo (skip when running outside a checkout).
+ if (process.env.GITHUB_ACTIONS === 'true') {
+ try {
+ const result = commitAndPush({
+ cwd: repoRoot,
+ paths: [path.relative(repoRoot, outAbs), path.relative(repoRoot, stateAbs)],
+ message: inputs.commitMessage,
+ authorName: inputs.commitAuthorName,
+ authorEmail: inputs.commitAuthorEmail,
+ push: !isMergedPR && (process.env.GITHUB_REF_TYPE === 'branch' || (process.env.GITHUB_REF || '').startsWith('refs/heads/')),
+ });
+ log('git: ' + (result.committed ? 'committed' : 'no changes (' + result.reason + ')'));
+ } catch (e) {
+ log('git commit failed: ' + (e.message || e));
+ }
+ } else {
+ log('local mode — skipping git commit');
+ }
+}
+
+run().catch((err) => {
+ process.stderr.write('[codeflow-card] error: ' + (err.stack || err.message || err) + '\n');
+ process.exit(1);
+});
diff --git a/card/lib/analyzer.js b/card/lib/analyzer.js
new file mode 100644
index 0000000..5c150a2
--- /dev/null
+++ b/card/lib/analyzer.js
@@ -0,0 +1,74 @@
+// Extract the codeflow analyzer block from index.html and run it in a Node vm
+// context. Mirrors what tests/codeflow-golden.test.mjs does — the analyzer is
+// the single source of truth, lives in one file, never drifts.
+
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+const vm = require('vm');
+
+const START_MARKER = '// ===== CODEFLOW_ANALYZER_START =====';
+const END_MARKER = '// ===== CODEFLOW_ANALYZER_END =====';
+const METRICS_START = '// ===== CODEFLOW_METRICS_START =====';
+const METRICS_END = '// ===== CODEFLOW_METRICS_END =====';
+
+function sliceBlock(html, startMarker, endMarker, label) {
+ const start = html.indexOf(startMarker);
+ const end = html.indexOf(endMarker, start);
+ if (start < 0 || end < 0) {
+ throw new Error(
+ 'Could not locate ' + label + ' block. Expected ' + startMarker + ' / ' + endMarker + '.'
+ );
+ }
+ return html.slice(start, end);
+}
+
+function loadAnalyzer(htmlPath) {
+ const html = fs.readFileSync(htmlPath, 'utf8');
+ const analyzerSource = sliceBlock(html, START_MARKER, END_MARKER, 'analyzer');
+ const metricsSource = sliceBlock(html, METRICS_START, METRICS_END, 'metrics');
+
+ const context = {
+ console,
+ TreeSitter: undefined,
+ Babel: undefined,
+ acorn: undefined,
+ getSecurityScanContent(file) {
+ return file && file.content ? file.content : '';
+ },
+ isSanitizedPreviewRenderer() {
+ return false;
+ },
+ };
+ vm.createContext(context);
+ const exposeExports =
+ '\nthis.Parser = Parser;' +
+ '\nthis.buildAnalysisData = buildAnalysisData;' +
+ '\nthis.calcBlast = calcBlast;' +
+ '\nthis.calcHealth = calcHealth;';
+ vm.runInContext(analyzerSource + '\n' + metricsSource + exposeExports, context, {
+ filename: 'codeflow-analyzer.js',
+ });
+
+ return {
+ Parser: context.Parser,
+ buildAnalysisData: context.buildAnalysisData,
+ calcBlast: context.calcBlast,
+ calcHealth: context.calcHealth,
+ };
+}
+
+function locateIndexHtml(actionDir, repoRoot) {
+ // 1) Adjacent to the Action (same repo as codeflow itself):
+ const adjacent = path.join(actionDir, '..', 'index.html');
+ if (fs.existsSync(adjacent)) return adjacent;
+ // 2) Repo root (when the Action is run from inside codeflow's checkout):
+ const local = path.join(repoRoot, 'index.html');
+ if (fs.existsSync(local)) return local;
+ throw new Error(
+ 'Could not find index.html. Tried ' + adjacent + ' and ' + local + '.'
+ );
+}
+
+module.exports = { loadAnalyzer, locateIndexHtml, START_MARKER, END_MARKER };
diff --git a/card/lib/collect.js b/card/lib/collect.js
new file mode 100644
index 0000000..abdf765
--- /dev/null
+++ b/card/lib/collect.js
@@ -0,0 +1,99 @@
+// Walk a repo and collect file content + parsed functions, mirroring the shape
+// the analyzer expects (see tests/codeflow-golden.test.mjs analyzeFixture).
+
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+
+const DEFAULT_IGNORES = new Set([
+ '.git',
+ 'node_modules',
+ '.next',
+ '.nuxt',
+ 'dist',
+ 'build',
+ 'out',
+ 'coverage',
+ '.cache',
+ '.parcel-cache',
+ '.turbo',
+ '.vercel',
+ '.idea',
+ '.vscode',
+ '__pycache__',
+ '.venv',
+ 'venv',
+ 'target',
+ 'bin',
+ 'obj',
+]);
+
+function walk(root, current, files, Parser) {
+ let entries;
+ try {
+ entries = fs.readdirSync(current, { withFileTypes: true });
+ } catch {
+ return;
+ }
+ for (const entry of entries) {
+ if (entry.name.startsWith('.git')) continue;
+ if (DEFAULT_IGNORES.has(entry.name)) continue;
+ const full = path.join(current, entry.name);
+ if (entry.isDirectory()) {
+ walk(root, full, files, Parser);
+ continue;
+ }
+ if (!entry.isFile()) continue;
+ if (!Parser.isIncluded(entry.name)) continue;
+ const repoPath = path.relative(root, full).split(path.sep).join('/');
+ files.push({
+ fullPath: full,
+ path: repoPath,
+ name: path.basename(repoPath),
+ folder: repoPath.includes('/') ? repoPath.slice(0, repoPath.lastIndexOf('/')) : 'root',
+ isCode: Parser.isCode(entry.name),
+ });
+ }
+}
+
+async function buildAnalyzed(repoRoot, Parser) {
+ const files = [];
+ walk(repoRoot, repoRoot, files, Parser);
+ files.sort((a, b) => a.path.localeCompare(b.path));
+
+ const analyzed = [];
+ const allFns = [];
+ for (const file of files) {
+ let content;
+ try {
+ content = fs.readFileSync(file.fullPath, 'utf8');
+ } catch {
+ continue;
+ }
+ const layer = Parser.detectLayer(file.path);
+ const isContainer = Parser.isScriptContainer(file.path);
+ const actualIsCode =
+ file.isCode !== false && (!isContainer || Parser.hasEmbeddedCode(content, file.path));
+ const functions = actualIsCode ? Parser.extract(content, file.path) : [];
+ analyzed.push({
+ path: file.path,
+ name: file.name,
+ folder: file.folder,
+ content,
+ functions,
+ lines: content ? content.split('\n').length : 0,
+ layer,
+ churn: 0,
+ isCode: actualIsCode,
+ });
+ if (actualIsCode) {
+ for (const fn of functions) {
+ allFns.push(Object.assign({}, fn, { folder: file.folder, layer }));
+ }
+ }
+ }
+ return { analyzed, allFns };
+}
+
+module.exports = { buildAnalyzed };
diff --git a/card/lib/git.js b/card/lib/git.js
new file mode 100644
index 0000000..ca04038
--- /dev/null
+++ b/card/lib/git.js
@@ -0,0 +1,49 @@
+// Commit the SVG and JSON state file back to the repo. Uses git CLI directly.
+
+'use strict';
+
+const { execFileSync } = require('child_process');
+
+function git(args, cwd) {
+ return execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] }).toString().trim();
+}
+
+function gitSafe(args, cwd) {
+ try {
+ return git(args, cwd);
+ } catch (e) {
+ return null;
+ }
+}
+
+function configureIdentity(cwd, name, email) {
+ git(['config', 'user.name', name], cwd);
+ git(['config', 'user.email', email], cwd);
+}
+
+function commitAndPush(opts) {
+ const cwd = opts.cwd;
+ const paths = opts.paths || [];
+ if (paths.length === 0) return { committed: false, reason: 'no paths' };
+
+ configureIdentity(cwd, opts.authorName, opts.authorEmail);
+
+ for (const p of paths) {
+ gitSafe(['add', p], cwd);
+ }
+
+ const status = git(['status', '--porcelain', '--', ...paths], cwd);
+ if (!status) return { committed: false, reason: 'no changes' };
+
+ git(['commit', '-m', opts.message], cwd);
+
+ if (opts.push) {
+ const branch = opts.branch || gitSafe(['rev-parse', '--abbrev-ref', 'HEAD'], cwd) || 'HEAD';
+ if (branch && branch !== 'HEAD') {
+ git(['push', 'origin', branch], cwd);
+ }
+ }
+ return { committed: true };
+}
+
+module.exports = { git, gitSafe, commitAndPush };
diff --git a/card/lib/inputs.js b/card/lib/inputs.js
new file mode 100644
index 0000000..4ac4338
--- /dev/null
+++ b/card/lib/inputs.js
@@ -0,0 +1,72 @@
+// Read GitHub Actions inputs (INPUT_) and provide a
+// process.env-friendly fallback for local dry-runs.
+
+'use strict';
+
+function readInput(name, defaultValue) {
+ const envKey = 'INPUT_' + name.toUpperCase().replace(/-/g, '_');
+ const v = process.env[envKey];
+ if (v === undefined || v === '') return defaultValue;
+ return v;
+}
+
+function asBool(value, fallback) {
+ if (value === undefined || value === null || value === '') return fallback;
+ if (typeof value === 'boolean') return value;
+ return /^(true|1|yes|on)$/i.test(String(value).trim());
+}
+
+function asInt(value, fallback) {
+ const n = parseInt(String(value), 10);
+ return Number.isFinite(n) ? n : fallback;
+}
+
+function asList(value, fallback) {
+ if (!value) return fallback;
+ return String(value)
+ .split(',')
+ .map((s) => s.trim())
+ .filter(Boolean);
+}
+
+function loadInputs() {
+ const output = readInput('output', '.github/codeflow-card.svg');
+ const state = readInput('state', '.github/codeflow-card.json');
+ const theme = readInput('theme', 'dark');
+ const accent = readInput('accent', '');
+ const style = readInput('style', 'compact');
+ // panels only used when style=detailed. Empty means "show everything for the style".
+ const panels = asList(readInput('panels', ''), []);
+ // Privacy: hide judgmental metrics on a publicly displayed README.
+ const showGrade = asBool(readInput('show-grade', ''), true);
+ const showScore = asBool(readInput('show-score', ''), true);
+ const receipts = asBool(readInput('receipts', ''), false);
+ const sparklineWindow = asInt(readInput('sparkline-window', ''), 30);
+ const pin = asBool(readInput('pin', ''), true);
+ const commitMessage = readInput('commit-message', 'chore: update codeflow card [skip ci]');
+ const commitAuthorName = readInput('commit-author-name', 'codeflow-card[bot]');
+ const commitAuthorEmail = readInput(
+ 'commit-author-email',
+ 'codeflow-card[bot]@users.noreply.github.com'
+ );
+ const token = readInput('github-token', process.env.GITHUB_TOKEN || '');
+ return {
+ output,
+ state,
+ theme,
+ accent,
+ style,
+ panels,
+ showGrade,
+ showScore,
+ receipts,
+ sparklineWindow,
+ pin,
+ commitMessage,
+ commitAuthorName,
+ commitAuthorEmail,
+ token,
+ };
+}
+
+module.exports = { loadInputs };
diff --git a/card/lib/pr.js b/card/lib/pr.js
new file mode 100644
index 0000000..424e422
--- /dev/null
+++ b/card/lib/pr.js
@@ -0,0 +1,89 @@
+// Post or update a sticky PR comment using the GitHub REST API. No deps.
+
+'use strict';
+
+const https = require('https');
+
+const STICKY_MARKER = '';
+
+function ghRequest(opts) {
+ const { token, method, path, body } = opts;
+ return new Promise((resolve, reject) => {
+ const data = body ? JSON.stringify(body) : null;
+ const req = https.request(
+ {
+ method,
+ hostname: 'api.github.com',
+ path,
+ headers: {
+ 'Authorization': 'Bearer ' + token,
+ 'Accept': 'application/vnd.github+json',
+ 'User-Agent': 'codeflow-card-action',
+ 'X-GitHub-Api-Version': '2022-11-28',
+ ...(data ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } : {}),
+ },
+ },
+ (res) => {
+ const chunks = [];
+ res.on('data', (c) => chunks.push(c));
+ res.on('end', () => {
+ const text = Buffer.concat(chunks).toString('utf8');
+ if (res.statusCode >= 400) {
+ reject(new Error('GitHub API ' + method + ' ' + path + ' returned ' + res.statusCode + ': ' + text));
+ return;
+ }
+ try {
+ resolve(text ? JSON.parse(text) : {});
+ } catch {
+ resolve({ raw: text });
+ }
+ });
+ }
+ );
+ req.on('error', reject);
+ if (data) req.write(data);
+ req.end();
+ });
+}
+
+async function listIssueComments({ token, owner, repo, issueNumber }) {
+ const out = [];
+ let page = 1;
+ while (true) {
+ const items = await ghRequest({
+ token,
+ method: 'GET',
+ path: '/repos/' + owner + '/' + repo + '/issues/' + issueNumber + '/comments?per_page=100&page=' + page,
+ });
+ if (!Array.isArray(items) || items.length === 0) break;
+ out.push(...items);
+ if (items.length < 100) break;
+ page += 1;
+ }
+ return out;
+}
+
+async function upsertStickyComment(opts) {
+ const { token, owner, repo, issueNumber, body } = opts;
+ const finalBody = body.includes(STICKY_MARKER) ? body : STICKY_MARKER + '\n\n' + body;
+
+ const existing = await listIssueComments({ token, owner, repo, issueNumber });
+ const mine = existing.find((c) => c.body && c.body.includes(STICKY_MARKER));
+
+ if (mine) {
+ return ghRequest({
+ token,
+ method: 'PATCH',
+ path: '/repos/' + owner + '/' + repo + '/issues/comments/' + mine.id,
+ body: { body: finalBody },
+ });
+ }
+ return ghRequest({
+ token,
+ method: 'POST',
+ path: '/repos/' + owner + '/' + repo + '/issues/' + issueNumber + '/comments',
+ body: { body: finalBody },
+ });
+}
+
+module.exports = { upsertStickyComment, STICKY_MARKER };
diff --git a/card/lib/state.js b/card/lib/state.js
new file mode 100644
index 0000000..1d2be65
--- /dev/null
+++ b/card/lib/state.js
@@ -0,0 +1,144 @@
+// Read/write the JSON history file that powers sparklines and deltas.
+
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+
+function readState(statePath) {
+ try {
+ const raw = fs.readFileSync(statePath, 'utf8');
+ const parsed = JSON.parse(raw);
+ if (parsed && Array.isArray(parsed.runs)) return parsed;
+ } catch {
+ // missing or corrupt — start fresh
+ }
+ return { version: 1, runs: [] };
+}
+
+function appendRun(state, snapshot, windowSize) {
+ const runs = state.runs.slice();
+ runs.push(snapshot);
+ if (runs.length > windowSize) runs.splice(0, runs.length - windowSize);
+ return { version: 1, runs };
+}
+
+function writeState(statePath, state) {
+ fs.mkdirSync(path.dirname(statePath), { recursive: true });
+ fs.writeFileSync(statePath, JSON.stringify(state, null, 2) + '\n');
+}
+
+function topBlasts(data, calcBlast, n) {
+ if (!data || !Array.isArray(data.files) || typeof calcBlast !== 'function') return [];
+ const ranked = [];
+ for (const file of data.files) {
+ if (!file.isCode) continue;
+ let blast;
+ try {
+ blast = calcBlast(file.path, data.connections, data.files);
+ } catch {
+ continue;
+ }
+ const direct = (blast && blast.count) || 0;
+ const transitive = (blast && blast.transitiveCount) || 0;
+ const total = direct + transitive;
+ if (total === 0) continue;
+ ranked.push({ path: file.path, direct, transitive, total });
+ }
+ ranked.sort((a, b) => b.total - a.total);
+ return ranked.slice(0, n);
+}
+
+function snapshotFromAnalysis(data, helpers, ctx) {
+ const stats = (data && data.stats) || {};
+ const calcBlast = helpers.calcBlast;
+ const calcHealth = helpers.calcHealth;
+
+ let health = null;
+ if (typeof calcHealth === 'function') {
+ try {
+ health = calcHealth(data);
+ } catch {
+ health = null;
+ }
+ }
+ const grade = health ? health.grade : null;
+ const score = health ? health.score : null;
+
+ const fragility = topBlasts(data, calcBlast, 3);
+
+ // Issues breakdown
+ const issues = Array.isArray(data && data.issues) ? data.issues : [];
+ const circular = issues.filter((i) => i && i.title && i.title.includes('Circular')).length;
+ const godObjects = issues.filter((i) => i && i.title && i.title.includes('Large')).length;
+ const avgCoupling = stats.files > 0 ? stats.connections / stats.files : 0;
+
+ // Top folders by file count
+ let topFolders = [];
+ if (Array.isArray(data && data.files)) {
+ const counts = new Map();
+ for (const f of data.files) {
+ const top = (f.folder || 'root').split('/')[0] || 'root';
+ counts.set(top, (counts.get(top) || 0) + 1);
+ }
+ topFolders = Array.from(counts.entries())
+ .map(([name, count]) => ({ name, count }))
+ .sort((a, b) => b.count - a.count)
+ .slice(0, 5);
+ }
+
+ // Function size stats — derive from `code` if `lines` isn't populated.
+ const fnLines = Array.isArray(data && data.functions)
+ ? data.functions
+ .map((fn) => fn.lines || (fn.code ? fn.code.split('\n').length : 0))
+ .filter((n) => n > 0)
+ : [];
+ const avgFnLines = fnLines.length
+ ? Math.round((fnLines.reduce((a, b) => a + b, 0) / fnLines.length) * 10) / 10
+ : 0;
+ const longestFn = fnLines.length ? Math.max.apply(null, fnLines) : 0;
+
+ // Test ratio (heuristic)
+ let testFiles = 0;
+ if (Array.isArray(data && data.files)) {
+ for (const f of data.files) {
+ if (/\.(test|spec)\.|\/(tests?|specs?|__tests__)\//i.test(f.path)) testFiles += 1;
+ }
+ }
+
+ const languageList = Array.isArray(stats.languages) ? stats.languages : [];
+
+ return {
+ at: new Date().toISOString(),
+ sha: ctx.sha || null,
+ pr: ctx.pr || null,
+ actor: ctx.actor || null,
+ files: stats.files || 0,
+ functions: stats.functions || 0,
+ loc: stats.loc || 0,
+ languages: languageList.length,
+ topLanguages: languageList.slice(0, 5).map((l) => ({ ext: l.ext, pct: l.pct, lines: l.lines })),
+ dead: stats.dead || 0,
+ deadPct: stats.functions ? Math.round((stats.dead / stats.functions) * 1000) / 10 : 0,
+ circular,
+ godObjects,
+ avgCoupling: Math.round(avgCoupling * 10) / 10,
+ securityIssues: stats.security || 0,
+ patterns: stats.patterns || 0,
+ duplicates: stats.duplicates || 0,
+ layerViolations: stats.violations || 0,
+ connections: stats.connections || 0,
+ avgFnLines,
+ longestFn,
+ testFiles,
+ testRatio: stats.files ? Math.round((testFiles / stats.files) * 1000) / 10 : 0,
+ topFolders,
+ folders: topFolders.length,
+ grade,
+ score,
+ topBlast: fragility[0] || null,
+ fragility,
+ };
+}
+
+module.exports = { readState, appendRun, writeState, snapshotFromAnalysis, topBlasts };
diff --git a/card/package.json b/card/package.json
new file mode 100644
index 0000000..c953f5d
--- /dev/null
+++ b/card/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "codeflow-card",
+ "version": "1.0.0",
+ "private": true,
+ "description": "GitHub Action: auto-updating SVG card of your repo health, fragility, and structure.",
+ "main": "index.js",
+ "type": "commonjs",
+ "engines": {
+ "node": ">=20"
+ },
+ "scripts": {
+ "dry-run": "node index.js"
+ },
+ "license": "MIT"
+}
diff --git a/card/render/card.js b/card/render/card.js
new file mode 100644
index 0000000..e734d94
--- /dev/null
+++ b/card/render/card.js
@@ -0,0 +1,15 @@
+// Card entry point. Dispatches to the requested style; see styles.js for
+// individual renderers.
+
+'use strict';
+
+const { renderStyle, ALL_STYLES, escapeXml, fmtNum } = require('./styles.js');
+
+function renderCard(opts) {
+ const style = opts.style || 'compact';
+ const history = opts.history || [];
+ const prev = history.length > 0 ? history[history.length - 1] : null;
+ return renderStyle(style, Object.assign({}, opts, { prev }));
+}
+
+module.exports = { renderCard, escapeXml, fmtNum, ALL_STYLES };
diff --git a/card/render/receipt-md.js b/card/render/receipt-md.js
new file mode 100644
index 0000000..42a5f9d
--- /dev/null
+++ b/card/render/receipt-md.js
@@ -0,0 +1,94 @@
+// Markdown version of the merged-PR receipt. GitHub PR comments are markdown,
+// so the comment uses this; the SVG variant is retained for future image use.
+
+'use strict';
+
+function diff(curr, prev) {
+ if (prev == null || curr == null) return null;
+ if (typeof curr !== 'number' || typeof prev !== 'number') return null;
+ const d = curr - prev;
+ if (d === 0) return null;
+ return d;
+}
+
+function fmtSigned(n) {
+ if (n == null) return '—';
+ return (n > 0 ? '+' : '') + n;
+}
+
+function dirArrow(n, lowerIsBetter) {
+ if (n == null || n === 0) return '';
+ const better = lowerIsBetter ? n < 0 : n > 0;
+ const sym = n < 0 ? '▼' : '▲';
+ return better ? ' :small_blue_diamond: ' + sym : ' :warning: ' + sym;
+}
+
+function gradeArrow(curr, prev) {
+ if (!curr || !prev || curr === prev) return '';
+ const order = ['F', 'D', 'C', 'B', 'A'];
+ const c = order.indexOf(curr[0]);
+ const p = order.indexOf(prev[0]);
+ if (c > p) return ' ▲';
+ if (c < p) return ' ▼';
+ return '';
+}
+
+function renderReceiptMarkdown(opts) {
+ const snap = opts.snapshot;
+ const prev = opts.previous;
+ const repo = opts.repo || '';
+ const pr = opts.pr || {};
+ const actor = opts.actor || pr.actor || '';
+ const number = pr.number ? '#' + pr.number : '';
+
+ const lines = [];
+ lines.push('```');
+ lines.push('--- CODEFLOW RECEIPT ---');
+ if (number || actor) {
+ lines.push('PR ' + number + (actor ? ' @' + actor : ''));
+ }
+ if (repo) lines.push(repo);
+ lines.push('--------------------------');
+
+ const dLoc = diff(snap.loc, prev && prev.loc);
+ const dFns = diff(snap.functions, prev && prev.functions);
+ const dDead = diff(snap.dead, prev && prev.dead);
+ const dCirc = diff(snap.circular, prev && prev.circular);
+
+ function pushRow(label, value) {
+ const labelPad = label.padEnd(14, ' ');
+ lines.push(labelPad + value);
+ }
+
+ if (dLoc != null) pushRow('LOC', fmtSigned(dLoc));
+ else pushRow('LOC', String(snap.loc));
+ if (dFns != null) pushRow('functions', fmtSigned(dFns));
+ else pushRow('functions', String(snap.functions));
+ if (dDead != null) pushRow('dead code', fmtSigned(dDead));
+ if (dCirc != null) pushRow('circular deps', fmtSigned(dCirc));
+
+ if (snap.topBlast) {
+ if (prev && prev.topBlast && prev.topBlast.total !== snap.topBlast.total) {
+ const d = snap.topBlast.total - prev.topBlast.total;
+ pushRow('blast radius', prev.topBlast.total + ' → ' + snap.topBlast.total + (d < 0 ? ' ▼' : ' ▲'));
+ } else {
+ pushRow('blast radius', String(snap.topBlast.total));
+ }
+ }
+ if (snap.grade) {
+ if (prev && prev.grade && prev.grade !== snap.grade) {
+ pushRow('health', prev.grade + ' → ' + snap.grade + gradeArrow(snap.grade, prev.grade));
+ } else {
+ pushRow('health', snap.grade);
+ }
+ }
+
+ lines.push('--------------------------');
+ lines.push(' thank you for your merge');
+ lines.push('```');
+ lines.push('');
+ lines.push('_powered by [codeflow](https://github.com/braedonsaunders/codeflow)_');
+ return lines.join('\n');
+}
+
+module.exports = { renderReceiptMarkdown };
diff --git a/card/render/receipt.js b/card/render/receipt.js
new file mode 100644
index 0000000..ba44c23
--- /dev/null
+++ b/card/render/receipt.js
@@ -0,0 +1,138 @@
+// Render the merged-PR "thermal receipt" SVG. Itemizes the merge as a
+// shareable artifact.
+
+'use strict';
+
+const { getTheme } = require('./theme.js');
+const { escapeXml } = require('./card.js');
+
+function diff(curr, prev) {
+ if (prev == null || curr == null) return null;
+ if (typeof curr !== 'number' || typeof prev !== 'number') return null;
+ const d = curr - prev;
+ if (d === 0) return null;
+ return d;
+}
+
+function fmtSigned(n, opts) {
+ if (n == null) return '—';
+ const sign = n > 0 ? '+' : '';
+ const v = (opts && opts.suffix) ? sign + n + opts.suffix : sign + n;
+ return v;
+}
+
+function arrow(n, lowerIsBetter, theme) {
+ if (n == null || n === 0) return { sym: '', color: theme.textDim };
+ const better = lowerIsBetter ? n < 0 : n > 0;
+ return {
+ sym: n < 0 ? '▼' : '▲',
+ color: better ? theme.green : theme.red,
+ };
+}
+
+function row(label, value, color, theme) {
+ return { label, value, color: color || theme.text };
+}
+
+function renderReceipt(opts) {
+ const theme = getTheme(opts.theme || 'dark');
+ const snap = opts.snapshot;
+ const prev = opts.previous;
+ const repo = opts.repo || '';
+ const pr = opts.pr || {};
+ const actor = opts.actor || pr.actor || 'unknown';
+ const number = pr.number ? '#' + pr.number : '';
+
+ const dLoc = diff(snap.loc, prev && prev.loc);
+ const dFns = diff(snap.functions, prev && prev.functions);
+ const dDead = diff(snap.dead, prev && prev.dead);
+ const dCirc = diff(snap.circular, prev && prev.circular);
+ const dCoup = prev ? Math.round((snap.avgCoupling - prev.avgCoupling) * 10) / 10 : null;
+
+ const rows = [];
+ rows.push(row('PR', escapeXml(number + (actor ? ' @' + actor : '')), theme.text, theme));
+
+ if (dLoc != null) {
+ const a = arrow(dLoc, false, theme);
+ rows.push(row('LOC', fmtSigned(dLoc), a.color, theme));
+ } else {
+ rows.push(row('LOC', String(snap.loc), theme.text, theme));
+ }
+ if (dFns != null) {
+ const a = arrow(dFns, false, theme);
+ rows.push(row('functions', fmtSigned(dFns), a.color, theme));
+ } else {
+ rows.push(row('functions', String(snap.functions), theme.text, theme));
+ }
+ if (dDead != null) {
+ const a = arrow(dDead, true, theme);
+ rows.push(row('dead code', fmtSigned(dDead), a.color, theme));
+ }
+ if (dCirc != null) {
+ const a = arrow(dCirc, true, theme);
+ rows.push(row('circular deps', fmtSigned(dCirc), a.color, theme));
+ }
+ if (dCoup != null && dCoup !== 0) {
+ const a = arrow(dCoup, true, theme);
+ rows.push(row('coupling', fmtSigned(dCoup), a.color, theme));
+ }
+ if (snap.topBlast) {
+ const prevTop = prev && prev.topBlast ? prev.topBlast.total : null;
+ const currTop = snap.topBlast.total;
+ if (prevTop != null && currTop !== prevTop) {
+ const a = arrow(currTop - prevTop, true, theme);
+ rows.push(row('blast radius', prevTop + ' → ' + currTop + ' ' + a.sym, a.color, theme));
+ } else {
+ rows.push(row('blast radius', String(currTop), theme.text, theme));
+ }
+ }
+ if (snap.grade) {
+ if (prev && prev.grade && prev.grade !== snap.grade) {
+ const order = ['F', 'D', 'C', 'B', 'A'];
+ const better = order.indexOf(snap.grade[0]) > order.indexOf(prev.grade[0]);
+ const color = better ? theme.green : theme.red;
+ rows.push(row('health', prev.grade + ' → ' + snap.grade, color, theme));
+ } else {
+ rows.push(row('health', snap.grade, theme.text, theme));
+ }
+ }
+
+ const W = 360;
+ const PAD = 18;
+ const headerH = 64;
+ const rowH = 22;
+ const footerH = 50;
+ const totalH = headerH + rows.length * rowH + footerH;
+
+ const dashes =
+ '';
+
+ const rowsSvg = rows
+ .map((r, i) => {
+ const y = headerH + i * rowH;
+ return (
+ '' +
+ escapeXml(r.label) +
+ '' +
+ '' +
+ r.value +
+ ''
+ );
+ })
+ .join('');
+
+ return (
+ '' +
+ '' +
+ 'CODEFLOW RECEIPT' +
+ '' + escapeXml(repo) + '' +
+ '' + dashes + '' +
+ rowsSvg +
+ '' + dashes + '' +
+ 'thank you for your merge' +
+ 'powered by codeflow' +
+ ''
+ );
+}
+
+module.exports = { renderReceipt };
diff --git a/card/render/sparkline.js b/card/render/sparkline.js
new file mode 100644
index 0000000..47538b4
--- /dev/null
+++ b/card/render/sparkline.js
@@ -0,0 +1,67 @@
+// Tiny inline SVG sparkline. No external lib.
+
+'use strict';
+
+function sparkline(values, opts) {
+ const o = opts || {};
+ const width = o.width || 80;
+ const height = o.height || 18;
+ const stroke = o.stroke || '#a78bfa';
+ const fill = o.fill || 'rgba(167,139,250,0.18)';
+ if (!Array.isArray(values) || values.length === 0) {
+ return '';
+ }
+ const data = values.map((v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0));
+ if (data.length === 1) data.unshift(data[0]);
+ let min = Math.min.apply(null, data);
+ let max = Math.max.apply(null, data);
+ if (min === max) {
+ min -= 1;
+ max += 1;
+ }
+ const range = max - min;
+ const stepX = data.length > 1 ? width / (data.length - 1) : width;
+ const points = data.map((v, i) => {
+ const x = i * stepX;
+ const y = height - ((v - min) / range) * height;
+ return [Math.round(x * 100) / 100, Math.round(y * 100) / 100];
+ });
+ const linePath = points
+ .map((p, i) => (i === 0 ? 'M' + p[0] + ' ' + p[1] : 'L' + p[0] + ' ' + p[1]))
+ .join(' ');
+ const areaPath =
+ linePath +
+ ' L' +
+ points[points.length - 1][0] +
+ ' ' +
+ height +
+ ' L' +
+ points[0][0] +
+ ' ' +
+ height +
+ ' Z';
+ return (
+ '' +
+ '' +
+ '' +
+ ''
+ );
+}
+
+module.exports = { sparkline };
diff --git a/card/render/styles.js b/card/render/styles.js
new file mode 100644
index 0000000..a47bfe7
--- /dev/null
+++ b/card/render/styles.js
@@ -0,0 +1,656 @@
+// Card style variants. Each style is a function that takes the same opts and
+// returns an SVG string. Picked by the `style` input on the Action.
+
+'use strict';
+
+const { getTheme } = require('./theme.js');
+const { sparkline } = require('./sparkline.js');
+
+const ALL_STYLES = ['compact', 'row', 'minimal', 'hero', 'detailed'];
+
+function escapeXml(str) {
+ return String(str == null ? '' : str)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+function fmtNum(n) {
+ if (n == null) return '—';
+ if (typeof n !== 'number') return String(n);
+ if (n >= 1_000_000) return Math.round(n / 100_000) / 10 + 'M';
+ if (n >= 1_000) return Math.round(n / 100) / 10 + 'K';
+ return String(n);
+}
+
+function gradeColor(theme, grade) {
+ if (!grade) return theme.textDim;
+ if (grade.startsWith('A')) return theme.green;
+ if (grade.startsWith('B')) return theme.green;
+ if (grade.startsWith('C')) return theme.amber;
+ if (grade.startsWith('D')) return theme.amber;
+ return theme.red;
+}
+
+function gradeArrow(curr, prev, theme) {
+ if (!prev || !curr || curr === prev) return '';
+ const order = ['F', 'D', 'C', 'B', 'A'];
+ const ci = order.indexOf(curr[0]);
+ const pi = order.indexOf(prev[0]);
+ if (ci < 0 || pi < 0) return '';
+ if (ci > pi) return '▲';
+ if (ci < pi) return '▼';
+ return '';
+}
+
+function svgWrap(width, height, theme, body, opts) {
+ const rounded = opts && opts.radius != null ? opts.radius : 12;
+ return (
+ '' +
+ '' +
+ body +
+ ''
+ );
+}
+
+const CODEFLOW_URL = 'https://github.com/braedonsaunders/codeflow';
+
+function pinFooter(theme, x, y, showPin) {
+ if (!showPin) return '';
+ return (
+ '' +
+ '' +
+ 'powered by codeflow' +
+ ''
+ );
+}
+
+// ============================================================================
+// Style: compact (default) — grade left, 4 scale stats right, single row
+// ============================================================================
+
+function renderCompact(opts) {
+ const theme = getTheme(opts.theme, { accent: opts.accent });
+ const snap = opts.snapshot;
+ const prev = opts.prev;
+ const repo = opts.repo || '';
+ const sha = opts.sha ? opts.sha.slice(0, 7) : '';
+ const showGrade = opts.showGrade !== false;
+ const showScore = opts.showScore !== false && showGrade;
+ const W = 720;
+ const H = 140;
+ const PAD = 18;
+
+ const grade = snap.grade || '?';
+ const score = typeof snap.score === 'number' ? snap.score : null;
+ const color = gradeColor(theme, grade);
+ const arrow = prev ? gradeArrow(grade, prev.grade, theme) : '';
+ const history = (opts.history || []).concat([snap]);
+
+ // When grade is hidden, we show 5 scale stats instead of 4 to fill the space.
+ const stats = showGrade
+ ? [
+ { label: 'FILES', value: fmtNum(snap.files), key: 'files' },
+ { label: 'FNS', value: fmtNum(snap.functions), key: 'functions' },
+ { label: 'LOC', value: fmtNum(snap.loc), key: 'loc' },
+ { label: 'LANGS', value: snap.languages, key: 'languages' },
+ ]
+ : [
+ { label: 'FILES', value: fmtNum(snap.files), key: 'files' },
+ { label: 'FNS', value: fmtNum(snap.functions), key: 'functions' },
+ { label: 'LOC', value: fmtNum(snap.loc), key: 'loc' },
+ { label: 'LANGS', value: snap.languages, key: 'languages' },
+ { label: 'TESTS', value: snap.testFiles || 0, key: 'testFiles' },
+ ];
+
+ const gradeBoxW = showGrade ? 130 : 0;
+ const statsAreaX = PAD + gradeBoxW + (showGrade ? 18 : 0);
+ const statsAreaW = W - statsAreaX - PAD;
+ const colW = statsAreaW / stats.length;
+
+ const headerY = 26;
+ const header =
+ '' + escapeXml(repo) + '' +
+ (sha ? '@' + escapeXml(sha) + '' : '');
+
+ const gradeBlock = showGrade
+ ? '' +
+ 'HEALTH' +
+ '' + escapeXml(grade) + arrow + '' +
+ (showScore && score != null
+ ? '' + score + '' +
+ '/100'
+ : '') +
+ ''
+ : '';
+
+ const statCells = stats
+ .map((s, i) => {
+ const cx = statsAreaX + i * colW;
+ const series = history.map((r) => r[s.key]).filter((v) => typeof v === 'number');
+ const sp = series.length > 1 ? sparkline(series, { width: Math.min(64, colW - 12), height: 14, stroke: theme.spark, fill: theme.sparkBg }) : '';
+ return (
+ '' +
+ '' + s.label + '' +
+ '' + s.value + '' +
+ (sp ? '' + sp + '' : '') +
+ ''
+ );
+ })
+ .join('');
+
+ const footer = pinFooter(theme, W - PAD, H - 10, opts.pin !== false);
+
+ return svgWrap(W, H, theme, header + gradeBlock + statCells + footer);
+}
+
+// ============================================================================
+// Style: row — single horizontal status-bar
+// ============================================================================
+
+function renderRow(opts) {
+ const theme = getTheme(opts.theme, { accent: opts.accent });
+ const snap = opts.snapshot;
+ const repo = opts.repo || '';
+ const showGrade = opts.showGrade !== false;
+ const showScore = opts.showScore !== false && showGrade;
+ const W = 720;
+ const H = 60;
+ const PAD = 16;
+
+ const grade = snap.grade || '?';
+ const score = typeof snap.score === 'number' ? snap.score : null;
+ const color = gradeColor(theme, grade);
+
+ const gradePill = showGrade
+ ? '' +
+ '' +
+ '' + escapeXml(grade) + '' +
+ ''
+ : '';
+
+ const repoX = showGrade ? PAD + 56 : PAD;
+ const repoText =
+ '' + escapeXml(repo) + '';
+
+ const statsParts = [
+ fmtNum(snap.files) + ' files',
+ fmtNum(snap.functions) + ' fns',
+ fmtNum(snap.loc) + ' LOC',
+ snap.languages + ' langs',
+ ];
+ if (snap.testFiles) statsParts.push(snap.testFiles + ' tests');
+ if (showScore && score != null) statsParts.push('score ' + score);
+ const statsText = statsParts.join(' · ');
+
+ const stats =
+ '' + escapeXml(statsText) + '';
+
+ const pin = opts.pin !== false
+ ? '' +
+ '' +
+ 'codeflow' +
+ ''
+ : '';
+
+ return svgWrap(W, H, theme, gradePill + repoText + stats + pin, { radius: 8 });
+}
+
+// ============================================================================
+// Style: minimal — single text line, monospace
+// ============================================================================
+
+function renderMinimal(opts) {
+ const theme = getTheme(opts.theme, { accent: opts.accent });
+ const snap = opts.snapshot;
+ const repo = opts.repo || '';
+ const showGrade = opts.showGrade !== false;
+ const W = 720;
+ const H = 40;
+ const PAD = 14;
+
+ const grade = snap.grade || '?';
+ const color = gradeColor(theme, grade);
+
+ const gradeSpan = showGrade
+ ? '' + escapeXml(grade) + '' +
+ ' · '
+ : '';
+
+ const line =
+ '' +
+ '' + escapeXml(repo) + '' +
+ ' · ' +
+ gradeSpan +
+ '' + fmtNum(snap.files) + '' +
+ ' files · ' +
+ '' + fmtNum(snap.functions) + '' +
+ ' fns · ' +
+ '' + fmtNum(snap.loc) + '' +
+ ' LOC · ' +
+ '' + snap.languages + '' +
+ ' langs' +
+ '' +
+ (opts.pin !== false
+ ? '' +
+ 'codeflow' +
+ ''
+ : '');
+
+ return svgWrap(W, H, theme, line, { radius: 6 });
+}
+
+// ============================================================================
+// Style: hero — bigger, splashier, gradient
+// ============================================================================
+
+function renderHero(opts) {
+ const theme = getTheme(opts.theme, { accent: opts.accent });
+ const snap = opts.snapshot;
+ const prev = opts.prev;
+ const repo = opts.repo || '';
+ const sha = opts.sha ? opts.sha.slice(0, 7) : '';
+ const showGrade = opts.showGrade !== false;
+ const showScore = opts.showScore !== false && showGrade;
+ const W = 720;
+ const H = 200;
+ const PAD = 24;
+
+ const grade = snap.grade || '?';
+ const score = typeof snap.score === 'number' ? snap.score : null;
+ const color = gradeColor(theme, grade);
+ const arrow = prev ? gradeArrow(grade, prev.grade, theme) : '';
+
+ const defs =
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '';
+
+ const header =
+ '' +
+ (showGrade ? 'CODEFLOW · HEALTH REPORT' : 'CODEFLOW · CODEBASE OVERVIEW') + '' +
+ '' + escapeXml(repo) + '' +
+ (sha ? '@' + escapeXml(sha) + '' : '');
+
+ const gradeBig = showGrade
+ ? '' + escapeXml(grade) + arrow + '' +
+ (showScore && score != null
+ ? '' + score + '' +
+ 'SCORE / 100'
+ : '')
+ : '';
+
+ // Grid of stats. With grade hidden we expand to a wider 3x2 grid that takes
+ // the whole card and keeps it informational.
+ const grid = showGrade
+ ? [
+ { label: 'FILES', value: fmtNum(snap.files) },
+ { label: 'FUNCTIONS', value: fmtNum(snap.functions) },
+ { label: 'LINES OF CODE', value: fmtNum(snap.loc) },
+ { label: 'LANGUAGES', value: snap.languages },
+ ]
+ : [
+ { label: 'FILES', value: fmtNum(snap.files) },
+ { label: 'FUNCTIONS', value: fmtNum(snap.functions) },
+ { label: 'LINES OF CODE', value: fmtNum(snap.loc) },
+ { label: 'LANGUAGES', value: snap.languages },
+ { label: 'TEST FILES', value: snap.testFiles || 0 },
+ { label: 'CONNECTIONS', value: fmtNum(snap.connections || 0) },
+ ];
+ const cols = showGrade ? 2 : 3;
+ const cellW = showGrade ? 140 : 215;
+ const cellH = 50;
+ const gridX = showGrade ? W - PAD - cellW * cols : PAD;
+ const gridY = showGrade ? 90 : 86;
+ const gridSvg = grid
+ .map((g, i) => {
+ const cx = gridX + (i % cols) * cellW;
+ const cy = gridY + Math.floor(i / cols) * cellH;
+ return (
+ '' +
+ '' + g.label + '' +
+ '' + g.value + '' +
+ ''
+ );
+ })
+ .join('');
+
+ const footer = pinFooter(theme, W - PAD, H - 14, opts.pin !== false);
+
+ return svgWrap(W, H, theme, defs + header + gradeBig + gridSvg + footer, { radius: 16 });
+}
+
+// ============================================================================
+// Style: detailed — original 4-panel renderer
+// ============================================================================
+
+function panelGradeDetailed(snap, prev, theme, x, y, width, opts) {
+ const showScore = opts && opts.showScore !== false;
+ const grade = snap.grade || '?';
+ const score = typeof snap.score === 'number' ? snap.score : null;
+ const color = gradeColor(theme, grade);
+ const arrow = prev ? gradeArrow(grade, prev.grade, theme) : '';
+ const prevLabel = prev && prev.grade && prev.grade !== grade ? '(was ' + escapeXml(prev.grade) + ')' : '';
+ const h = 110;
+ return {
+ height: h,
+ body:
+ '' +
+ '' +
+ 'HEALTH' +
+ '' + escapeXml(grade) + arrow + '' +
+ (showScore && score != null
+ ? '' + score + '' +
+ 'SCORE / 100'
+ : '') +
+ (prevLabel ? '' + prevLabel + '' : '') +
+ '',
+ };
+}
+
+function panelLanguagesDetailed(snap, theme, x, y, width) {
+ const list = Array.isArray(snap.topLanguages) ? snap.topLanguages.slice(0, 5) : [];
+ const headerH = 28;
+ const rowH = 22;
+ const h = headerH + Math.max(rowH * Math.min(list.length, 5), rowH) + 14;
+ const palette = [theme.accent, theme.spark, theme.green, theme.amber, theme.textDim];
+
+ let rows;
+ if (list.length === 0) {
+ rows = 'No language stats.';
+ } else {
+ rows = list
+ .map((lang, i) => {
+ const ry = headerH + 6 + i * rowH;
+ const label = '.' + (lang.ext || '?');
+ const pct = typeof lang.pct === 'number' ? lang.pct : 0;
+ const barW = Math.max(2, Math.round((pct / 100) * (width - 220)));
+ const barX = 80;
+ const color = palette[i % palette.length];
+ return (
+ '' + escapeXml(label) + '' +
+ '' +
+ '' +
+ '' + pct + '%'
+ );
+ })
+ .join('');
+ }
+ return {
+ height: h,
+ body:
+ '' +
+ '' +
+ 'LANGUAGES' +
+ rows +
+ '',
+ };
+}
+
+function panelComposition(snap, theme, x, y, width) {
+ const items = [
+ { label: 'CONNECTIONS', value: fmtNum(snap.connections || 0) },
+ { label: 'TEST FILES', value: snap.testFiles || 0 },
+ { label: 'FOLDERS', value: snap.folders || 0 },
+ { label: 'AVG FN LINES', value: snap.avgFnLines || 0 },
+ { label: 'LONGEST FN', value: snap.longestFn ? snap.longestFn + 'L' : '—' },
+ { label: 'PATTERNS', value: snap.patterns || 0 },
+ ];
+ const h = 88;
+ const cols = 3;
+ const colW = (width - 32) / cols;
+ const cells = items
+ .map((item, i) => {
+ const cx = 16 + (i % cols) * colW;
+ const cy = 22 + Math.floor(i / cols) * 32;
+ return (
+ '' +
+ '' + item.label + '' +
+ '' + escapeXml(item.value) + '' +
+ ''
+ );
+ })
+ .join('');
+ return {
+ height: h,
+ body:
+ '' +
+ '' +
+ cells +
+ '',
+ };
+}
+
+function panelTopFolders(snap, theme, x, y, width) {
+ const list = Array.isArray(snap.topFolders) ? snap.topFolders.slice(0, 5) : [];
+ const headerH = 28;
+ const rowH = 18;
+ const h = headerH + Math.max(rowH * Math.min(list.length, 5), rowH) + 12;
+ if (list.length === 0) {
+ return {
+ height: h,
+ body:
+ '' +
+ '' +
+ 'TOP FOLDERS' +
+ 'No folders found.' +
+ '',
+ };
+ }
+ const max = list[0].count;
+ const rows = list
+ .map((f, i) => {
+ const ry = headerH + 6 + i * rowH;
+ const barW = Math.max(2, Math.round((f.count / max) * (width - 220)));
+ return (
+ '' + escapeXml(f.name || 'root') + '' +
+ '' +
+ '' +
+ '' + f.count + ''
+ );
+ })
+ .join('');
+ return {
+ height: h,
+ body:
+ '' +
+ '' +
+ 'TOP FOLDERS · BY FILE COUNT' +
+ rows +
+ '',
+ };
+}
+
+function panelScaleDetailed(snap, history, theme, x, y, width) {
+ const items = [
+ { label: 'FILES', value: snap.files, key: 'files' },
+ { label: 'FNS', value: snap.functions, key: 'functions' },
+ { label: 'LOC', value: snap.loc, key: 'loc' },
+ { label: 'LANGS', value: snap.languages, key: 'languages' },
+ ];
+ const h = 88;
+ const colW = (width - 32) / items.length;
+ const cells = items
+ .map((item, i) => {
+ const cx = 16 + i * colW;
+ const series = history.map((r) => r[item.key]).filter((v) => typeof v === 'number');
+ const spark = series.length > 1 ? sparkline(series, { width: Math.min(80, colW - 16), height: 18, stroke: theme.spark, fill: theme.sparkBg }) : '';
+ return (
+ '' +
+ '' + item.label + '' +
+ '' + fmtNum(item.value) + '' +
+ (spark ? '' + spark + '' : '') +
+ ''
+ );
+ })
+ .join('');
+ return {
+ height: h,
+ body:
+ '' +
+ '' +
+ cells +
+ '',
+ };
+}
+
+function panelFragilityDetailed(snap, theme, x, y, width) {
+ const list = Array.isArray(snap.fragility) ? snap.fragility.slice(0, 3) : [];
+ const rowH = 18;
+ const headerH = 28;
+ const h = headerH + Math.max(rowH * 3, rowH * Math.max(list.length, 1)) + 12;
+ let rows = '';
+ if (list.length === 0) {
+ rows = 'No cross-file dependencies detected.';
+ } else {
+ rows = list
+ .map((f, i) => {
+ const ry = headerH + 6 + i * rowH;
+ const name = f.path.length > 48 ? '…' + f.path.slice(-47) : f.path;
+ return (
+ '' + escapeXml(name) + '' +
+ '' + f.direct + ' direct · ' + f.transitive + ' transitive'
+ );
+ })
+ .join('');
+ }
+ return {
+ height: h,
+ body:
+ '' +
+ '' +
+ 'FRAGILITY · TOP BLAST RADIUS' +
+ rows +
+ '',
+ };
+}
+
+function panelHiddenCostsDetailed(snap, prev, theme, x, y, width) {
+ const items = [
+ { label: 'CIRCULAR DEPS', value: snap.circular, prev: prev ? prev.circular : null, lowerIsBetter: true },
+ { label: 'DEAD CODE', value: snap.deadPct + '%', raw: snap.deadPct, prev: prev ? prev.deadPct : null, lowerIsBetter: true },
+ { label: 'AVG COUPLING', value: snap.avgCoupling, prev: prev ? prev.avgCoupling : null, lowerIsBetter: true },
+ ];
+ const h = 70;
+ const colW = (width - 32) / items.length;
+ const cells = items
+ .map((item, i) => {
+ const cx = 16 + i * colW;
+ let arrow = '';
+ const curr = typeof item.raw === 'number' ? item.raw : item.value;
+ if (item.prev != null && typeof curr === 'number' && curr !== item.prev) {
+ const better = item.lowerIsBetter ? curr < item.prev : curr > item.prev;
+ const sign = curr < item.prev ? '▼' : '▲';
+ const color = better ? theme.green : theme.red;
+ arrow = '' + sign + '';
+ }
+ return (
+ '' +
+ '' + item.label + '' +
+ '' + escapeXml(item.value) + arrow + '' +
+ ''
+ );
+ })
+ .join('');
+ return {
+ height: h,
+ body:
+ '' +
+ '' +
+ cells +
+ '',
+ };
+}
+
+function renderDetailed(opts) {
+ const theme = getTheme(opts.theme, { accent: opts.accent });
+ const snap = opts.snapshot;
+ const history = opts.history || [];
+ const prev = opts.prev;
+ const showGrade = opts.showGrade !== false;
+ const showScore = opts.showScore !== false && showGrade;
+ // Detailed always renders the full informational set unless `panels` was
+ // overridden explicitly. The default is everything. Grade panel auto-drops
+ // when `show-grade: false`.
+ const DEFAULT_PANELS = ['grade', 'scale', 'languages', 'composition', 'top-folders', 'fragility', 'hidden-costs'];
+ const requested = (opts.panels && opts.panels.length > 0) ? opts.panels : DEFAULT_PANELS;
+ const panels = showGrade ? requested : requested.filter((p) => p !== 'grade' && p !== 'hidden-costs');
+ const repo = opts.repo || '';
+ const sha = opts.sha ? opts.sha.slice(0, 7) : '';
+ const showPin = opts.pin !== false;
+
+ const W = 720;
+ const PAD = 22;
+ const HEADER_H = 60;
+ const PANEL_GAP = 14;
+ const FOOTER_H = 36;
+ const innerW = W - PAD * 2;
+
+ const blocks = [];
+ let cursorY = HEADER_H;
+
+ for (const p of panels) {
+ let panel = null;
+ if (p === 'grade') panel = panelGradeDetailed(snap, prev, theme, PAD, cursorY, innerW, { showScore });
+ else if (p === 'scale') panel = panelScaleDetailed(snap, history.concat([snap]), theme, PAD, cursorY, innerW);
+ else if (p === 'languages') panel = panelLanguagesDetailed(snap, theme, PAD, cursorY, innerW);
+ else if (p === 'composition') panel = panelComposition(snap, theme, PAD, cursorY, innerW);
+ else if (p === 'top-folders') panel = panelTopFolders(snap, theme, PAD, cursorY, innerW);
+ else if (p === 'fragility') panel = panelFragilityDetailed(snap, theme, PAD, cursorY, innerW);
+ else if (p === 'hidden-costs') panel = panelHiddenCostsDetailed(snap, prev, theme, PAD, cursorY, innerW);
+ if (!panel) continue;
+ blocks.push(panel.body);
+ cursorY += panel.height + PANEL_GAP;
+ }
+
+ const totalH = cursorY - PANEL_GAP + FOOTER_H + 10;
+ const header =
+ '' +
+ '' + escapeXml(repo) + '' +
+ '' +
+ (sha ? '@' + escapeXml(sha) : '') + '' +
+ '';
+
+ const footerY = totalH - 14;
+ const updated = new Date().toISOString().slice(0, 10);
+ const footer =
+ '' +
+ 'updated ' + updated + '' +
+ (showPin
+ ? '' +
+ 'powered by ' +
+ 'codeflow' +
+ ''
+ : '') +
+ '';
+
+ return svgWrap(W, totalH, theme, header + blocks.join('') + footer, { radius: 14 });
+}
+
+// ============================================================================
+// Dispatcher
+// ============================================================================
+
+const RENDERERS = {
+ compact: renderCompact,
+ row: renderRow,
+ minimal: renderMinimal,
+ hero: renderHero,
+ detailed: renderDetailed,
+};
+
+function renderStyle(style, opts) {
+ const r = RENDERERS[style] || RENDERERS.compact;
+ return r(opts);
+}
+
+module.exports = { renderStyle, ALL_STYLES, escapeXml, fmtNum };
diff --git a/card/render/theme.js b/card/render/theme.js
new file mode 100644
index 0000000..6ed5212
--- /dev/null
+++ b/card/render/theme.js
@@ -0,0 +1,86 @@
+// Color tokens. Codeflow's site uses a dark indigo/purple palette; we mirror
+// those values so the card looks like family.
+
+'use strict';
+
+const DARK = {
+ bg: '#0d1117',
+ bgAlt: '#161b22',
+ border: '#21262d',
+ text: '#e6edf3',
+ textDim: '#8b949e',
+ textFaint: '#6e7681',
+ accent: '#a78bfa', // codeflow purple
+ accentSoft: 'rgba(167,139,250,0.16)',
+ green: '#3fb950',
+ amber: '#d29922',
+ red: '#f85149',
+ spark: '#a78bfa',
+ sparkBg: 'rgba(167,139,250,0.18)',
+};
+
+const LIGHT = {
+ bg: '#ffffff',
+ bgAlt: '#f6f8fa',
+ border: '#d0d7de',
+ text: '#1f2328',
+ textDim: '#656d76',
+ textFaint: '#8c959f',
+ accent: '#6f42c1',
+ accentSoft: 'rgba(111,66,193,0.12)',
+ green: '#1a7f37',
+ amber: '#9a6700',
+ red: '#cf222e',
+ spark: '#6f42c1',
+ sparkBg: 'rgba(111,66,193,0.12)',
+};
+
+// Named accent presets. Users can also pass any hex/CSS color directly.
+const ACCENT_PRESETS = {
+ purple: { dark: '#a78bfa', light: '#6f42c1' },
+ teal: { dark: '#5eead4', light: '#0d9488' },
+ cyan: { dark: '#67e8f9', light: '#0891b2' },
+ green: { dark: '#86efac', light: '#16a34a' },
+ pink: { dark: '#f9a8d4', light: '#db2777' },
+ blue: { dark: '#93c5fd', light: '#2563eb' },
+ amber: { dark: '#fcd34d', light: '#d97706' },
+ red: { dark: '#fca5a5', light: '#dc2626' },
+};
+
+function withAlpha(hex, alpha) {
+ // Takes a #RRGGBB hex and returns rgba(r,g,b,a). Supports #RGB shorthand.
+ const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex);
+ if (!m) return hex;
+ let r, g, b;
+ if (m[1].length === 3) {
+ r = parseInt(m[1][0] + m[1][0], 16);
+ g = parseInt(m[1][1] + m[1][1], 16);
+ b = parseInt(m[1][2] + m[1][2], 16);
+ } else {
+ r = parseInt(m[1].slice(0, 2), 16);
+ g = parseInt(m[1].slice(2, 4), 16);
+ b = parseInt(m[1].slice(4, 6), 16);
+ }
+ return 'rgba(' + r + ',' + g + ',' + b + ',' + alpha + ')';
+}
+
+function resolveAccent(value, mode) {
+ if (!value) return null;
+ const preset = ACCENT_PRESETS[String(value).toLowerCase()];
+ if (preset) return preset[mode] || preset.dark;
+ return value; // assume CSS color
+}
+
+function getTheme(name, opts) {
+ const base = name === 'light' ? Object.assign({}, LIGHT) : Object.assign({}, DARK);
+ const accent = opts && opts.accent ? resolveAccent(opts.accent, name === 'light' ? 'light' : 'dark') : null;
+ if (accent) {
+ base.accent = accent;
+ base.accentSoft = withAlpha(accent, name === 'light' ? 0.12 : 0.16);
+ base.spark = accent;
+ base.sparkBg = withAlpha(accent, name === 'light' ? 0.12 : 0.18);
+ }
+ return base;
+}
+
+module.exports = { getTheme, DARK, LIGHT, ACCENT_PRESETS };
diff --git a/index.html b/index.html
index 034d5ab..da7eee7 100644
--- a/index.html
+++ b/index.html
@@ -762,6 +762,7 @@
// Parser And Static Analysis
// ---------------------------------------------------------------------------
+// ===== CODEFLOW_ANALYZER_START =====
const Parser={
// Tree-sitter parsers are loaded lazily from CDN and used when a language has
// a stable grammar path. Regex remains an explicit fallback, not a silent lie.
@@ -3272,8 +3273,11 @@
if(!response.ok)throw new Error('Unable to load analyzer source');
return response.text();
}).then(function(html){
- var parserStart=html.indexOf('const Parser={');
- var parserEnd=html.indexOf('\nfunction calcBlast',parserStart);
+ // Build markers via concatenation so they don't match themselves in the slice.
+ var startMarker='// ===== CODEFLOW_'+'ANALYZER_START =====';
+ var endMarker='// ===== CODEFLOW_'+'ANALYZER_END =====';
+ var parserStart=html.indexOf(startMarker);
+ var parserEnd=html.indexOf(endMarker,parserStart);
if(parserStart<0||parserEnd<0)throw new Error('Analyzer source markers missing');
var analyzerSource=html.slice(parserStart,parserEnd);
return [
@@ -3345,11 +3349,13 @@
return buildAnalysisData(options);
});
}
+// ===== CODEFLOW_ANALYZER_END =====
// ---------------------------------------------------------------------------
// Visualization Helpers
// ---------------------------------------------------------------------------
+// ===== CODEFLOW_METRICS_START =====
function calcBlast(fileId,conns,files){
// Comprehensive impact analysis for a file
// Connection format: {source: fileDefiningFn, target: fileCallingFn, fn: fnName, count: callCount}
@@ -3454,6 +3460,7 @@
if(score>=90)grade='A';else if(score>=80)grade='B';else if(score>=70)grade='C';else if(score>=60)grade='D';
return{score:score,grade:grade};
}
+// ===== CODEFLOW_METRICS_END =====
function calcPRRisk(prData, repoData) {
if (!prData || !repoData) return { score: 0, level: 'low', factors: [] };
diff --git a/tests/codeflow-golden.test.mjs b/tests/codeflow-golden.test.mjs
index 42f63f9..7ccf536 100644
--- a/tests/codeflow-golden.test.mjs
+++ b/tests/codeflow-golden.test.mjs
@@ -8,8 +8,10 @@ import vm from 'node:vm';
const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = join(__dirname, '..');
const htmlSource = await readFile(join(repoRoot, 'index.html'), 'utf8');
-const parserStart = htmlSource.indexOf('const Parser={');
-const parserEnd = htmlSource.indexOf('\nfunction calcBlast', parserStart);
+const startMarker = '// ===== CODEFLOW_ANALYZER_START =====';
+const endMarker = '// ===== CODEFLOW_ANALYZER_END =====';
+const parserStart = htmlSource.indexOf(startMarker);
+const parserEnd = htmlSource.indexOf(endMarker, parserStart);
if (parserStart < 0 || parserEnd < 0) {
throw new Error('Could not locate analyzer source in index.html');