Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 4 additions & 8 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,14 +89,10 @@ Some files are produced by scripts; edit the generator, not the output:

- `docs/commands.md` and the website command reference — `npm run docs:commands`
(run after `npm run build`, which writes the oclif manifest).
- `docs/demo.webp` (the animated README terminal) — `npm run docs:demo`, which
runs [VHS](https://github.com/charmbracelet/vhs) on `docs/demo.tape`. Requires
`vhs` and an ffmpeg built with **libwebp** (Homebrew's core ffmpeg lacks it):
`brew tap homebrew-ffmpeg/ffmpeg && brew install homebrew-ffmpeg/ffmpeg/ffmpeg --with-webp`.
Also needs an authenticated pdcli profile (use a sandbox). For a polished,
reproducible recording, first run `./scripts/seed-demo.sh` — it builds a clean
"pdcli demo" pipeline (sane deal values, a duplicate person, a stale deal so
`audit` has findings) and points the tape at it.
- `docs/demo.svg` (the animated README terminal) — `npm run docs:demo`. It's a
self-contained, deterministic SMIL SVG (no fonts/scripts/network) drawn from
the curated `SCRIPT` scenes in `scripts/gen-demo.mjs`; edit the generator, not
the SVG.

`oclif.manifest.json` is generated and git-ignored; never commit it.

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
[![Docs](https://img.shields.io/badge/docs-wavyx.github.io%2Fpdcli-1292EE)](https://wavyx.github.io/pdcli/)

<p align="center">
<img src="https://raw.githubusercontent.com/wavyx/pdcli/main/docs/demo.webp" alt="pdcli demo — pipeline health, deal summary, and pipeline coverage" width="760">
<img src="https://raw.githubusercontent.com/wavyx/pdcli/main/docs/demo.svg" alt="pdcli demo — pipeline health, a won deal update, and a data-hygiene audit" width="820">
</p>

Command-line interface for [Pipedrive](https://www.pipedrive.com/) — fast, scriptable, built for terminals, CI pipelines, and AI agents.
Expand Down
19 changes: 19 additions & 0 deletions docs/demo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
57 changes: 0 additions & 57 deletions docs/demo.tape

This file was deleted.

Binary file removed docs/demo.webp
Binary file not shown.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@
"scripts": {
"build": "oclif manifest",
"docs:commands": "npm run build && node scripts/gen-commands.mjs",
"docs:demo": "vhs docs/demo.tape",
"docs:demo": "node scripts/gen-demo.mjs",
"lint": "eslint . && prettier --check .",
"lint:fix": "eslint --fix . && prettier --write .",
"test": "vitest run",
Expand Down
203 changes: 203 additions & 0 deletions scripts/gen-demo.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
// Generates docs/demo.svg — a self-contained, animated SVG terminal that types
// two real pdcli commands and reveals their output, on a ~12s loop.
//
// Constraints (so it renders inside a GitHub README <img>):
// - SMIL <animate> only — no <script>, no <foreignObject>, no external
// fonts/CSS. The only URL is the SVG namespace.
// - Deterministic: never reads the clock or uses randomness, so re-running
// the generator produces a byte-identical file (clean diffs in CI).
//
// Content mirrors the homepage hero (website/src/components/Home.astro): the
// "pipeline health" report and the "deal update … --status won" confirmation.
//
// Run via `npm run docs:demo`. Do not hand-edit docs/demo.svg.
import { writeFileSync, mkdirSync } from 'node:fs'

// The SVG namespace URI is literally http://www.w3.org/2000/svg — it is an
// identifier, not a fetched resource, and is required for the doc to be valid.
const SVG_NS = 'http://www.w3.org/2000/svg'

/** Escape the five XML-significant characters. `&` first to avoid doubling. */
export function escapeXml(s) {
if (s === null || s === undefined) return ''
return String(s)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&apos;')
}

// Each scene: a command that types out, then its rendered output lines.
// A `cls` per line maps to a fill colour class defined once in <style>.
export const SCRIPT = [
{
cmd: 'pdcli pipeline health',
out: [
{ t: '┌ SALES PIPELINE ───────────────── Q2 ┐', cls: 'dim' },
{ t: 'Qualified 18 deals €142,000', cls: 'fg' },
{ t: 'Contact 11 deals € 98,500', cls: 'fg' },
{ t: 'Proposal 7 deals € 76,200', cls: 'fg' },
{ t: 'Negotiation 4 deals € 51,000', cls: 'fg' },
{ t: '─────────────────────────────────────', cls: 'dim' },
{ t: 'weighted forecast €221,480', cls: 'ok' },
{ t: 'win rate 32% · avg cycle 24d', cls: 'dim' },
],
},
{
cmd: 'pdcli deal update 4821 --status won',
out: [{ t: '✓ Acme renewal → Won · activity logged', cls: 'ok' }],
},
{
cmd: 'pdcli audit',
out: [
{ t: '┌ DATA HYGIENE ──────────── 11 checks ┐', cls: 'dim' },
{ t: '● 3 duplicate deals', cls: 'warn' },
{ t: '● 5 deals stale > 30 days', cls: 'warn' },
{ t: '○ 8 missing a next step', cls: 'dim' },
{ t: '─────────────────────────────────────', cls: 'dim' },
{ t: '3 must-fix · audit --strict gates CI', cls: 'ok' },
],
},
]

// Layout constants (px). Monospace metrics keep the typing reveal aligned.
const FONT_SIZE = 15
const WIDTH = 880 // roomy terminal width so no output line crowds the edge
const PAD = 26
const BAR_H = 44
// Glyph advance used to PACE the typing reveal and place the caret. A slight
// over-estimate of a 15px monospace advance; the rest state doesn't depend on
// it being exact (the clip snaps fully open once typing finishes — see below).
const CH = 11.0
const LINE_H = 26
const PROMPT = '❯ '
const FONT_STACK =
"'SFMono-Regular', 'JetBrains Mono', 'Fira Code', Consolas, ui-monospace, monospace"

// Per-scene timing (seconds). The demo plays through each scene once, ~6s each.
const SCENE = 6
const TYPE = 1.6 // time spent typing the command

/** Build one scene as a <g> with SMIL reveal animations. */
function renderScene(scene, index) {
const begin = index * SCENE
// The demo plays once and rests on the LAST scene (robust across renderers —
// no looping state to degenerate). So the last scene never resets/hides.
const isLast = index === SCRIPT.length - 1
const cmd = scene.cmd
const cmdChars = cmd.length
// Body baseline for the command line, then one row per output line.
const cmdY = BAR_H + PAD + LINE_H
const promptW = PROMPT.length * CH
const cmdW = cmdChars * CH

// Typing reveal: a clip rect whose width grows from 0 to the command width.
const clipId = `type${index}`
const clipReset = isLast
? ''
: `<set attributeName="width" to="0" begin="${begin + SCENE}s"/>`
// Clip height runs from above the cap line to BELOW the baseline so glyph
// descenders (the tails of p/g/y) aren't sheared off — the rect's bottom must
// clear the baseline (cmdY), not sit on it.
const clipTop = cmdY - LINE_H
const clipH = LINE_H + 10
// Grow the clip to ~cmdW while "typing", then SNAP it fully open at TYPE end
// so the held command is never cropped — the rest state no longer depends on
// CH matching the viewer's exact glyph advance.
const revealFull = `<set attributeName="width" to="${WIDTH}" begin="${begin + TYPE}s" fill="freeze"/>`
const typing = `<clipPath id="${clipId}"><rect x="${PAD}" y="${clipTop}" height="${clipH}" width="0"><animate attributeName="width" begin="${begin}s" dur="${TYPE}s" from="0" to="${cmdW}" fill="freeze" repeatCount="1"/>${revealFull}${clipReset}</rect></clipPath>`

// The command text, clipped so it appears to type left-to-right.
const cmdText = `<text x="${PAD + promptW}" y="${cmdY}" class="cmd" clip-path="url(#${clipId})">${escapeXml(cmd)}</text>`

// Caret sits at the end of the typed text, blinking only during this scene.
const caretX = PAD + promptW + cmdW
// Last scene's caret keeps blinking on the resting frame; earlier scenes
// switch off at scene end (their whole group also fades out then).
const caretOff = isLast
? ''
: `<animate attributeName="opacity" values="1;0" begin="${begin + SCENE - 0.2}s" dur="0.01s" fill="freeze"/>`
const caret = `<rect x="${caretX}" y="${cmdY - FONT_SIZE}" width="${CH}" height="${FONT_SIZE + 3}" class="cur" opacity="0"><animate attributeName="opacity" values="0;1" begin="${begin}s" dur="0.01s" fill="freeze"/>${caretOff}<animate attributeName="opacity" values="1;0;1" begin="${begin + TYPE}s" dur="0.8s" repeatCount="indefinite"/></rect>`

// Prompt glyph, always visible while the scene is on screen.
const prompt = `<text x="${PAD}" y="${cmdY}" class="prompt">${escapeXml(PROMPT)}</text>`

// Output lines fade in together once typing finishes.
let lines = ''
scene.out.forEach((ln, i) => {
const y = cmdY + (i + 2) * LINE_H
lines += `<text x="${PAD}" y="${y}" class="${ln.cls}">${escapeXml(ln.t)}</text>`
})
const outHide = isLast
? ''
: `<set attributeName="opacity" to="0" begin="${begin + SCENE}s"/>`
const outGroup = `<g opacity="0">${lines}<animate attributeName="opacity" values="0;1" begin="${begin + TYPE + 0.2}s" dur="0.3s" fill="freeze"/>${outHide}</g>`

// Play-once sequence: scene 0 is visible from t=0 and hides when scene 1
// begins; the last scene appears at its `begin` and stays (rests). No reshow,
// so there's no loop state for a renderer to land on mid-degeneration.
let sceneVisible
if (isLast) {
sceneVisible =
index === 0
? ''
: `<set attributeName="opacity" to="1" begin="${begin}s"/>`
} else if (index === 0) {
sceneVisible = `<set attributeName="opacity" to="0" begin="${begin + SCENE}s"/>`
} else {
sceneVisible = `<set attributeName="opacity" to="1" begin="${begin}s"/><set attributeName="opacity" to="0" begin="${begin + SCENE}s"/>`
}

return `<g opacity="${index === 0 ? 1 : 0}">${typing}${prompt}${cmdText}${caret}${outGroup}${sceneVisible}</g>`
}

/** Build the full demo SVG document as a deterministic string. */
export function buildDemoSvg() {
// Tall enough for the prompt line + the largest output block.
const maxLines = Math.max(...SCRIPT.map((s) => s.out.length))
const height = BAR_H + PAD * 2 + LINE_H * (maxLines + 3)

const dotColors = ['#ff5f56', '#ffbd2e', '#27c93f']
const dots = dotColors
.map(
(c, i) =>
`<circle class="dot" cx="${PAD + 6 + i * 20}" cy="${BAR_H / 2}" r="6" fill="${c}"/>`,
)
.join('')

const title = `<text x="${PAD + 78}" y="${BAR_H / 2 + 4}" class="bar">zsh · ~/acme</text>`

const scenes = SCRIPT.map((s, i) => renderScene(s, i)).join('')

return `<svg xmlns="${SVG_NS}" width="${WIDTH}" height="${height}" viewBox="0 0 ${WIDTH} ${height}" role="img" aria-label="Animated demo of pdcli: pipeline health, a winning deal update, and a data-hygiene audit">
<style>
text { font-family: ${FONT_STACK}; font-size: ${FONT_SIZE}px; white-space: pre; }
.bar { fill: #6b7d72; font-size: 13px; }
.prompt { fill: #4ade80; }
.cmd { fill: #e6efe9; }
.fg { fill: #cdddd3; }
.dim { fill: #6b7d72; }
.warn { fill: #e5c07b; }
.ok { fill: #4ade80; }
.cur { fill: #4ade80; }
</style>
<rect x="0" y="0" width="${WIDTH}" height="${height}" rx="14" fill="#0c1611"/>
<rect x="0" y="0" width="${WIDTH}" height="${BAR_H}" rx="14" fill="#0a120e"/>
<rect x="0" y="${BAR_H - 14}" width="${WIDTH}" height="14" fill="#0a120e"/>
${dots}
${title}
${scenes}
</svg>
`
}

/* CLI entry — guarded so imports stay pure (mirrors gen-commands.mjs). */
const invokedDirectly =
process.argv[1] && import.meta.url === `file://${process.argv[1]}`

if (invokedDirectly) {
mkdirSync(new URL('../docs/', import.meta.url), { recursive: true })
writeFileSync(new URL('../docs/demo.svg', import.meta.url), buildDemoSvg())
console.log('Wrote docs/demo.svg')
}
Loading
Loading