
Page Template
- 33 installs
- 62 repo stars
- Updated August 3, 2026
- terrylica/cc-skills
Helps with ai & agent building tasks.
About
page-template is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- page-template
- AI & Agent Building
- AI-coding skill
Page Template by the numbers
- 33 all-time installs (skills.sh)
- Ranked #8,975 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/terrylica/cc-skills --skill page-templateAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 33 |
|---|---|
| repo stars | ★ 62 |
| Last updated | August 3, 2026 |
| Repository | terrylica/cc-skills ↗ |
What it does
Helps with ai & agent building tasks.
Files
HTML Showcase — Page Template
Self-Evolving Skill: This skill improves through use. If instructions
are wrong, parameters drifted, or a workaround was needed — fix this file
immediately, don't defer. Only update for real, reproducible issues.
A static HTML mini-site whose navigation structure is the filesystem layout itself. Every page links to a shared CSS kernel served from jsDelivr; an auto-discovered nav rail and a master site-map.html are generated by scripts/build-nav.py from whatever directories you create under the site root. Pages are pure HTML with optional per-page CSS overrides. The architecture is built on five principles — read [`references/principles.md`](references/principles.md) first to internalize the WHY before extending or forking, and `references/sitemap.md` for the navigation contract.
The sitemap is the default
Every site this skill produces has the same shape:
<site-root>/
index.html ← site home (recommended)
overrides.css ← optional per-site tweaks
auto-nav.css ← generated by build-nav.py
auto-nav.js ← generated by build-nav.py
site-map.html ← generated by build-nav.py
<section-1>/ ← any subdirectory becomes a "section"
index.html
page-a.html
page-b.html
<2026-05-02-audit>/ ← YYYY-MM-DD- prefix sorts sections newest-first
index.html
findings.htmlYou don't hand-write the nav. You add HTML files in the directory shape you want, then run scripts/build-nav.py --root <site-root>. The script walks the tree, generates site-map.html, writes the rail's CSS/JS, and injects the same nav rail into every page using comment markers. Re-runs are idempotent.
For the architecture and trade-offs, see `references/sitemap.md`.
Read this skill at the principle level, not the instruction level
Every concrete artifact in this skill (class names, file paths, the specific CDN URL, the commit-message conventions, even the "section" naming) is an _instance_ of a small set of underlying principles. If you understand the principles, you can deviate intelligently from any specific instance without breaking the architecture. If you only follow the instructions, you'll bend the system out of shape the first time something doesn't fit your case.
The principles are catalogued in `references/principles.md`:
1. Single source of truth — every visual decision lives in one file 2. Semantic over atomic — class names describe what an element _is_ 3. Token-driven — every concrete value flows from a CSS custom property 4. Cascade discipline — @layer ordering enforces specificity globally 5. No hidden state — no JS, no inline CSS, no scattered overrides 6. Filesystem-as-sitemap — directory layout IS the navigation graph
Plus AI-collaboration patterns (why this design is LLM-friendly), and the rationale for using a CDN rather than copies.
Three-layer hierarchy
| Layer | Mutability | What it controls | Where it lives |
|---|---|---|---|
| H1 — Kernel | Edit once → ripples everywhere | Tokens (color, spacing, type), reset, base elements, components | assets/showcase.css (jsDelivr CDN) |
| H2 — Composition | Per-page | Section order, content, semantic markup | The HTML file itself |
| H3 — Overrides | Per-page (optional) | Color or density tweaks for ONE page | overrides.css next to the HTML |
The kernel is the SSoT for every visual decision. HTML never invents styles; it only arranges components defined by the kernel. To customize one page, drop a few CSS variables into overrides.css. To customize EVERY page, edit the kernel.
The auto-nav rail is _generated_, not hand-written. Its styling lives in auto-nav.css (written by build-nav.py); its content is injected between <!-- AUTO-NAV-START --> and <!-- AUTO-NAV-END --> markers in each page's <body>.
The rail and the master site-map.html are always dark (slate-950 surface, slate-300 text, indigo-400 accents), regardless of the host page's theme. The rail is the constant element across every page; pinning its theme keeps it visually stable whether the page it overlays is a light contractor showcase or a dark telemetry dashboard.
Pages within a section render in creation order, not alphabetical:
index.htmlalways first.- Pages following the
index_iter_<N>_<slug>.htmlnaming convention sort
by N numerically (so iter_10 comes after iter_9, not after iter_1).
- Other top-level pages sort by filesystem birthtime (
st_birthtimeon
macOS — never moved by edits or rebuilds).
- Nested pages fall back to the original subdir + index-first + alpha grouping.
The rail's runtime behavior:
- First load: width auto-fits to the longest unwrapped link (uses
width: max-content to measure each link's true intrinsic width, independent of the rail's current size), clamped to [220, 760]px.
- Drag the right-edge handle: resize manually within
[220, 1200]px;
the chosen width is persisted in localStorage.
- Double-click the handle: clears the saved width and re-runs
auto-fit (semantically: "reset to smart default").
- The handle is a 14px hit zone with an always-visible 2px indicator
line and a hover tooltip explaining the dual gesture.
- Within-section Prev/Next: pages inside a section get compact
‹ ›
buttons on the "Site" header row (zero added height) plus a Chrome-safe bare [ / ] keyboard shortcut. ‹ / [ goes to the sibling above (newer), › / ] to the one below (older); both are greyed out at the ends of the list. The keys ignore presses while a modifier is held or while focus is in the search box, so they never interrupt typing.
Search is on by default
Every rail (and the master site-map.html) gets a Search section mounted at the top, powered by Pagefind — a Rust-built static-search tool that produces a self-contained index with no server, no build pipeline, and a ~70KB client UI.
How it's wired:
build-nav.pyinjects 4 tags into every page's<head>in strict
order: pagefind CSS → auto-nav CSS → pagefind JS → auto-nav JS.
- The rail's first section is
<div id="auto-nav-search"></div>;
auto-nav.js calls new PagefindUI({...}) once Pagefind has loaded.
- The actual index lives at
<site-root>/pagefind/, generated by
running pagefind --site <site-root>.
scripts/site.sh navrunsbuild-nav.pyANDpagefind --site—
search refreshes automatically every time the rail is rebuilt.
If pagefind isn't installed, the rail still renders with the search input present but inert; auto-nav.js's mountSearch() short-circuits when window.PagefindUI is undefined. Install via brew install pagefind (or follow the official install docs).
Push-as-hook auto-resync
install.sh --hook installs a pre-push git hook at .githooks/pre-push and wires git config core.hooksPath .githooks. After that, every git push main automatically:
1. Auto-detects every site dir in your repo (any directory containing a site-map.html) 2. Runs scripts/site.sh nav <site-dir> (rebuilds rail + search index) 3. Runs scripts/site.sh push <site-dir> (rsync to bigblack via Tailscale)
The hook is non-blocking — if rsync fails (cellular, coffee shop, bigblack down), the push continues to GitHub anyway. Skip env vars:
| Variable | Effect |
|---|---|
NO_HTMLSHOWCASE_HOOK=1 | Skip the hook entirely |
NO_HTMLSHOWCASE_SYNC=1 | Run nav + search but skip the rsync |
NO_HTMLSHOWCASE_SEARCH=1 | Skip the pagefind regen step |
HTMLSHOWCASE_SITES="a b c" | Override auto-detection with explicit list |
Four contributor stances
Pick the role that matches your task. Full workflow for each in `references/contributing.md`.
| Role | Example task | Edits | Affects |
|---|---|---|---|
| Consumer | "Make me a contractor showcase site" | Your HTML | Just your site |
| Customizer | "Re-theme this site with our brand teal" | overrides.css | Just your site |
| Contributor | "Add a .timeline component to the kernel" | Kernel CSS upstream | Every site using this kernel |
| Publisher | "Our team forks the kernel and publishes from our own GitHub" | Your fork's kernel | Sites that pin to _your_ CDN URL |
When to use this skill
- Creating a static HTML site (one page or many) that records structured
work: audits, commits, metrics, reports, contractor showcases, telemetry views, weekly digests
- Replacing inline-CSS pages with the shared design system + auto-nav
- Bootstrapping a multi-page mini-site that grows into a contractor
portfolio, weekly-digest archive, or release-notes hub — without ever hand-maintaining the navigation
Do NOT use for: blog posts, marketing landing pages, interactive web apps.
What ships in this skill
| Path | Role |
|---|---|
templates/index.html | Site home skeleton (hero + 3 example sections + footer + markers) |
templates/section-index.html | Section landing-page skeleton |
templates/overrides.css.example | Reference for per-site customization (rename to overrides.css) |
templates/lychee.toml | Link-checker config |
scripts/build-nav.py | Universal sitemap builder — auto-nav + site-map.html generator |
scripts/check-orphan-pages.py | Pure-stdlib orphan-page graph validator |
scripts/site.sh | Build nav + validate + push to bigblack via Tailscale (see below) |
scripts/install.sh | One-shot bootstrap: install all 3 scripts into any repo |
references/principles.md | The WHY — five principles + AI patterns |
references/sitemap.md | The HOW — filesystem-as-sitemap contract, rail rendering |
references/contributing.md | The HOW — four stances with full workflows |
references/publishing.md | The WHERE — delivery surfaces (CDN vs tailnet) + bigblack setup |
The CSS kernel itself lives at the plugin level (plugins/html-showcase/assets/showcase.css) and is served from jsDelivr — the skeleton HTML references the public CDN URL, not a local file.
Where finished sites get hosted
Two surfaces, two roles:
| Surface | What goes there | When to use |
|---|---|---|
| jsDelivr CDN (public) | The kernel CSS only | Always — every page imports the kernel from one shared URL |
| bigblack on the tailnet | Your rendered sites | Default for internal audiences (no DNS, no auth UI, no public exposure) |
| jsDelivr / Pages / Workers | Your rendered sites | Only when an external reader genuinely needs the page |
For internal-audience sites (audit reports, contractor showcases, telemetry views, weekly digests), the bigblack tailnet path is the lowest-friction option. Adopting it in any repo is one command:
PLUGIN=${CLAUDE_PLUGIN_ROOT:-~/.claude/plugins/marketplaces/cc-skills/plugins/html-showcase}
bash "$PLUGIN/skills/page-template/scripts/install.sh"That installs all three pipeline scripts (build-nav.py, check-orphan-pages.py, site.sh) into <repo>/scripts/ and appends **/.published.json to .gitignore. The installer is idempotent (re-running it is a no-op) and non-destructive (--force to overwrite). To also seed a starter site directory:
bash "$PLUGIN/skills/page-template/scripts/install.sh" --site contractor-siteThen scripts/site.sh push <site-dir> regenerates the sitemap, validates locally (lychee + orphan check), and rsyncs to bigblack:~/sites/<repo>/<site-dir>/, served at https://bigblack.tail0f299b.ts.net:8448/<repo>/<site-dir>/. Push-side gating (build-nav + lychee + orphan check) is the only gate. Full mechanics, the URL formula, when NOT to use bigblack, and the bigblack one-time setup are in `references/publishing.md`.
Universal density knobs
Two CSS custom properties at the top of showcase.css control the entire visual rhythm. Override either in overrides.css to retune one site:
:root {
--density: 0.85; /* spacing multiplier; 1.0 baseline, lower = tighter */
--font-scale: 0.94; /* type multiplier; 1.0 baseline, lower = smaller */
}Every padding, gap, margin, and section rhythm in the kernel derives from the spacing scale; the spacing scale derives from --density. Body font size derives from --font-scale. There are no scattered magic numbers in component CSS — see Principle 3 in references/principles.md.
Component vocabulary
The kernel defines these semantic classes; HTML uses them. To inspect the full set, open the kernel CSS and search for class selectors.
| Class | Purpose |
|---|---|
.hero + .hero__inner / __eyebrow / __title / __lede / __cta-row | Top banner with gradient |
.chip--solid / .chip--ghost | Hero CTA buttons |
.metric-grid + .metric-card | At-a-glance number panel; modifiers --accent, --success, --warning |
.phase-grid + .phase-card | Phased timeline cards; modifiers --audit, --fix, --perf |
.commit-stack + .commit-card | Detailed commit cards with SHA chip + details grid |
.bug-grid + .bug-card (--high modifier) | Compact issue cards |
.feature-grid + .feature-card | Generic 4-column showcase grid with icon |
.reco-list + .reco-item (--p0 / --p1 / --p2) | Priority-ordered recommendations |
.badge (--high / --medium / --low / --success / --info / --neutral / --accent) | Severity / status labels |
.section-head / .section-intro | Per-section title row + framing paragraph |
.shell | Centered content shell with max-width and responsive padding |
.site-footer + .site-footer__grid / __legal | Provenance footer |
The auto-nav rail uses its own non-kernel classes (.auto-nav-rail, .rail-link, .rail-section, etc.) defined in auto-nav.css so the nav stays self-contained and a repo can adopt the rail without adopting the kernel.
If your page needs a content component not in this table, you have two choices — both legitimate, both documented in references/contributing.md:
- Add it to the kernel (Stance 3): semantic class name in the
components @layer, token-referenced values, BEM modifier variants.
- Use a local override for one-off cases (Stance 2): only if the
pattern is genuinely unique to one page; recurring patterns belong in the kernel.
Quick start (Consumer stance, sitemap-organized)
The fastest path: run the installer to bootstrap the pipeline scripts + a starter site, then iterate.
PLUGIN=${CLAUDE_PLUGIN_ROOT:-~/.claude/plugins/marketplaces/cc-skills/plugins/html-showcase}
# 1. Bootstrap the pipeline + starter site directory
bash "$PLUGIN/skills/page-template/scripts/install.sh" --site contractor-site
# 2. (Optional) Add one or more sections under contractor-site/
mkdir -p contractor-site/2026-05-02-first-section
cp "$PLUGIN/skills/page-template/templates/section-index.html" \
contractor-site/2026-05-02-first-section/index.html
cp "$PLUGIN/skills/page-template/templates/index.html" \
contractor-site/2026-05-02-first-section/page-a.html
# 3. Fill {{ PLACEHOLDERS }} in the HTML, then build the sitemap + nav
scripts/site.sh nav contractor-site
# 4. Validate (lychee + orphan check)
scripts/site.sh check contractor-site
# 5. View — any page reaches every other via the rail
open contractor-site/index.html
open contractor-site/site-map.htmlOr, if you'd rather copy the templates by hand without the installer:
PLUGIN=${CLAUDE_PLUGIN_ROOT:-~/.claude/plugins/marketplaces/cc-skills/plugins/html-showcase}
DEST=/path/to/your-site
mkdir -p "$DEST"
cp "$PLUGIN/skills/page-template/templates/index.html" "$DEST/"
cp "$PLUGIN/skills/page-template/templates/lychee.toml" "$DEST/"
python3 "$PLUGIN/skills/page-template/scripts/build-nav.py" --root "$DEST"site.sh falls back to the plugin-shipped build-nav.py when no copy is present in <repo>/scripts/, so even without the installer you can push to bigblack from any repo using the plugin-shipped script directly.
For the other three stances (Customizer, Contributor, Publisher), see `references/contributing.md`. For the publishing path to bigblack via Tailscale, see `references/publishing.md`.
CDN versioning
The kernel URL pins to the @main branch during early iteration, then to a tagged release once the kernel stabilizes:
@main → always-latest → use during development; jsDelivr cache flushed automatically on each release
@v<X.Y.Z> → immutable, content-locked → use for production-stable pages
@<sha> → immutable, commit-locked → use for forensic-grade pinningThe release flow auto-purges @main and smoke-tests @v<X.Y.Z> after each release. To force-refresh @main between releases (e.g., during heavy iteration on the kernel), run mise run release:cdn-purge from the cc-skills repo. To bypass cache entirely on a single page, append ?v=$(date +%s) to the kernel link.
The auto-nav assets (auto-nav.css, auto-nav.js) are generated locally by build-nav.py and live next to your HTML — they are not CDN-served. The ?v=N query string on those URLs is also a cache-bust knob; bump --asset-version when you change the rail's CSS or JS body inside build-nav.py.
Hard rules
These are baked into the kernel and templates; if you find yourself wanting to break them, fix the kernel instead (see Stance 3 in references/contributing.md).
- No inline
<style>blocks. - No
style=""attributes on HTML elements. - No utility-class soup in HTML — class names are semantic
(.metric-card, .badge--high), never atomic (flex p-4 bg-blue-500).
- The kernel is the single source of truth for every visual decision.
- HTML only arranges components; it never invents them.
- The filesystem layout IS the navigation graph; never hand-author the
rail HTML between <!-- AUTO-NAV-START --> and <!-- AUTO-NAV-END -->. Re-run scripts/build-nav.py after any structural change.
- Every site must pass Lychee link-check and the orphan-page detector
before it's considered shipped.
Post-Execution Reflection
After this skill completes, reflect before closing the task:
0. Locate yourself. — Find this SKILL.md's canonical path before editing. 1. What failed? — Fix the instruction. If a kernel component was missing, add it (Stance 3). If the sitemap rail rendered something surprising, fix build-nav.py AND references/sitemap.md. If a _principle_ was unclear, fix references/principles.md. 2. What worked better than expected? — If a new section pattern recurs, distill it into a kernel component or into templates/section-index.html. 3. What drifted? — Keep CDN URL pins, override examples, component vocabulary, and the rail HTML markers aligned across SKILL.md, the templates, and build-nav.py. 4. Log it. — Evolution-log entry with trigger, fix, evidence.
Do NOT defer. The next invocation inherits whatever you leave behind.
<!-- SSoT-OK: example version strings (e.g. @v<X.Y.Z>) are illustrative documentation of the publishing workflow, not actual published versions of this plugin. -->
Contributing — four stances for using and extending the kernel
Pick the stance that matches your task. Each stance has a different
blast radius and a different review path. The architecture supports all
four equally — choose based on what you actually need, not on what feels
"more committed."
At a glance
| # | Stance | Edits what | Affects whom | Best for |
|---|---|---|---|---|
| 1 | Consumer | One HTML file in your repo | Just your page | Shipping a single showcase page quickly |
| 2 | Customizer | One overrides.css next to your HTML | Just your page | Re-themed page (different brand color, density) without forking |
| 3 | Contributor | The kernel CSS in terrylica/cc-skills | Every page everywhere | Adding a missing component or fixing a bug |
| 4 | Publisher | A fork of cc-skills under your own GitHub | Pages that pin to _your_ CDN URL | A team or org wanting their own kernel canon |
The four stances are stable across all four — same files, same conventions, same review surface. What changes is the URL pin in your HTML's <link> and where the kernel commit lands.
---
Stance 1 — Consumer
You want: a new HTML showcase page that looks like the others. You are not customizing the kernel and you don't need any per-page color tweaks.
Workflow:
# 1. Copy the templates into your destination
DEST=/path/to/where/your-page/lives
mkdir -p "$DEST"
PLUGIN=${CLAUDE_PLUGIN_ROOT:-~/.claude/plugins/marketplaces/cc-skills/plugins/html-showcase}
cp "$PLUGIN/skills/page-template/templates/index.html" "$DEST/"
cp "$PLUGIN/skills/page-template/templates/lychee.toml" "$DEST/"
# 2. Fill in the {{ PLACEHOLDERS }} in index.html with real content.
# Keep the structure; replace only text and links.
# 3. Open the page (no server needed)
open "$DEST/index.html"
# 4. Verify integrity
lychee --config "$DEST/lychee.toml" "$DEST/**/*.html"
python3 "$PLUGIN/skills/page-template/scripts/check-orphan-pages.py" "$DEST/"What URL your page links to: @main (always-latest) during the kernel's iteration phase, @v<X.Y.Z> (immutable tag) once the kernel is stable.
Mental model: you are reading from a shared library. You don't ship the library; you pin to a version of it.
---
Stance 2 — Customizer
You want: the kernel's components and rhythm, but with different brand colors, a tighter density, or a different font for ONE page (not the whole shared kernel).
Workflow:
# 1. Same as Consumer, plus copy the override example
DEST=/path/to/your-page
PLUGIN=${CLAUDE_PLUGIN_ROOT:-~/.claude/plugins/marketplaces/cc-skills/plugins/html-showcase}
cp "$PLUGIN/skills/page-template/templates/overrides.css.example" "$DEST/overrides.css"
# 2. Edit overrides.css — uncomment the variables you want to override.
# Override file is tiny: just a :root {} block of CSS variables.Sample override:
:root {
--brand-primary: #14b8a6; /* teal instead of blue */
--brand-primary-deep: #0f766e;
--density: 0.75; /* extra tight */
--font-scale: 0.92; /* smaller body type */
}The order of <link> tags in your HTML matters: kernel first, overrides last, so your overrides win the cascade.
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/terrylica/cc-skills@main/plugins/html-showcase/assets/showcase.css"
/>
<link rel="stylesheet" href="overrides.css" />What's overridable: every CSS variable defined under :root in the kernel's tokens layer. Open the kernel CSS and search for --; that's your full vocabulary. Common overrides:
| Variable | Effect |
|---|---|
--density | Spacing scale (lower = tighter) |
--font-scale | Body type size |
--brand-primary, --brand-accent, --brand-amber, --brand-emerald | Brand colors |
--surface-page, --text-headline, --text-body | Surfaces and text |
--shell-max | Page width cap |
--font-display, --font-text, --font-mono-stack | Type families |
What's NOT overridable from `overrides.css`: layout structure, component shapes, animations. Those need to be Stance 3 (kernel edit) — by design, because changing them on one page only is usually a smell.
Mental model: you are tweaking a knob, not modifying the machine.
---
Stance 3 — Contributor (PR upstream to terrylica/cc-skills)
You want: a missing component (a .timeline for chronological events, a .testimonial-card for quotes, etc.), a fixed bug, or a refined token. The change should benefit every page that uses the kernel.
Workflow:
# 1. Clone or update your local copy of cc-skills
cd ~/eon/cc-skills # or wherever your clone lives
git pull origin main
git checkout -b feat/html-showcase-add-timeline-component
# 2. Edit the kernel
$EDITOR plugins/html-showcase/assets/showcase.css
# 3. Test against an existing page that uses @main (or use ?v=$(date +%s) for cache-bust)
mise run release:cdn-purge # forces jsDelivr to re-fetch from GitHub
# 4. Commit, push, open PR
git add plugins/html-showcase/assets/showcase.css
git commit -m "feat(html-showcase): add .timeline component"
git push -u origin feat/html-showcase-add-timeline-component
gh pr create --title "feat(html-showcase): add .timeline component" \
--body "Adds .timeline + .timeline-item to the components layer..."What to add when extending the kernel:
- A new component goes in the
components@layer. Use a semantic class
name (.timeline, not .tl-grid).
- All concrete values must reference existing tokens (
var(--space-N),
var(--brand-primary)). If you need a new token, add it to the tokens layer FIRST.
- If the component has variants (e.g.,
--success,--warning), use BEM
modifier syntax (.timeline-item--success) — matches the existing vocabulary.
- Update the "Component vocabulary" table in
SKILL.mdso future users
know the new class exists.
Review checklist (mentally, before opening PR):
- Does the component follow Principle 1 (single SSoT)? Yes if its styles
live entirely in the kernel.
- Does it follow Principle 2 (semantic over atomic)? Yes if the class
name describes WHAT it is, not HOW it looks.
- Does it follow Principle 3 (token-driven)? Yes if every value references
a token.
- Does it follow Principle 4 (cascade discipline)? Yes if it's in the right
@layer.
- Does it follow Principle 5 (no hidden state)? Yes if it works without JS.
If all five are yes, the change is in the spirit of the kernel.
What semantic-release does after merge: the next mise run release:full on main (typically run by the maintainer) bumps the marketplace version, auto-purges the jsDelivr cache for @main, and smoke-tests the new tagged URL. Pages pinned to @main see your component within seconds. Pages pinned to @v<X.Y.Z> see it after a manual re-pin.
---
Stance 4 — Publisher (your own kernel from your own fork)
You want: your team or org has its own brand, conventions, components, or copyright requirements that differ from terrylica's. You publish your own kernel from your own GitHub, and your team's pages link to your URL.
Workflow:
# 1. Fork cc-skills on GitHub (use your username/org, e.g. acme-corp/cc-skills)
gh repo fork terrylica/cc-skills --clone --remote --org acme-corp
# 2. Edit the kernel to match your brand
cd ~/eon/cc-skills # your fork
$EDITOR plugins/html-showcase/assets/showcase.css
# 3. Commit and push to your fork
git add plugins/html-showcase/assets/showcase.css
git commit -m "feat(html-showcase): adopt acme brand tokens"
git push origin mainYour team's CDN URL is automatically:
https://cdn.jsdelivr.net/gh/acme-corp/cc-skills@main/plugins/html-showcase/assets/showcase.css(swap acme-corp for your GitHub org/username; jsDelivr serves any public GitHub repo without registration).
Update your team's HTML pages to link your fork's URL instead of terrylica/cc-skills. Every showcase page across your org now reflects your kernel.
Pull upstream improvements when you want them:
git remote add upstream https://github.com/terrylica/cc-skills.git
git fetch upstream
git merge upstream/main # or rebase, your call
# Resolve conflicts in plugins/html-showcase/assets/showcase.css if any
git push origin mainOptional: cut your own tagged releases so your team's pages can pin to @v<X.Y.Z> for production stability:
mise run release:full # the cc-skills release flow works in your fork unchangedThis will bump _your_ fork's marketplace.json and create a tag in _your_ GitHub. Your CDN URLs at @v<X.Y.Z> then become immutable for your team.
Mental model: you are running your own copy of the shared infrastructure. The relationship to upstream is voluntary; you can pull improvements or diverge entirely.
---
Cross-stance rules (true regardless of which path you take)
- No inline CSS. Even when customizing, the override goes in
overrides.css, not <style> blocks or style="" attrs.
- HTML never invents components. If you need a new component, that's
Stance 3 (kernel edit). HTML composing existing components is fine; inventing new visual patterns inline is not.
- Pin URLs deliberately.
@mainfor active iteration (you accept that
pages may shift). @v<X.Y.Z> for production stability (pages frozen forever). @<commit-sha> for forensic immutability (rarely needed).
- Lychee + orphan-page check are required. Both gates exist in the
template; don't skip them. They are the only assurance that the page is reproducible from its sources.
Principles — why this skill is shaped the way it is
Read this once before extending the kernel, forking it, or making major
changes to a page's structure. The class names, file paths, and CDN URLs
in SKILL.md are concrete _instances_ of the principles below. Internalizethe principles, and you can deviate intelligently from any specific instance
without breaking the architecture.
The five load-bearing principles
These are the rules the kernel was built around. Every other decision in the skill follows from them.
1. Single source of truth
Every visual decision lives in exactly one place: the kernel CSS. Colors, spacing, type, shadows, borders, hover states, gradients — all defined once.
Why it matters. A design system fragments the moment a second source appears. If --brand-primary is #2563eb in the kernel and bg-blue-600 in some HTML utility class, you now have two truths and your future self will desync them. The kernel doesn't permit utility-class soup precisely because that's how desync starts.
How to detect a violation. If you find yourself writing style="..." or <style>...</style> inside HTML, you've violated SSoT. The fix is to add the pattern to the kernel (if it's recurring) or to put the override in overrides.css (if it's per-page).
2. Semantic over atomic
Class names describe what an element _is_, not what it _looks like_. <div class="metric-card">, never <div class="rounded-lg shadow-md p-6 border bg-white">.
Why it matters. Atomic class strings turn HTML into a liability for both humans and AI agents. To change "all metric cards lift on hover by 4px instead of 2px," you'd have to grep for the right combination of utility classes across every page; with semantic names you edit .metric-card:hover once. Semantic markup also compresses prompts: an LLM editing a 30-line HTML fragment with semantic class names has full context; the same fragment with 30 atomic classes per element is mostly noise.
How to detect a violation. If a class name describes a property (flex-row, text-blue-600, mt-4), it's atomic. The kernel's utility layer is intentionally tiny (.stack, .text-mono, .text-muted, .visually-hidden) — anything beyond that should be a component class.
3. Token-driven
Every concrete value (color, spacing, font size, shadow, border radius) flows from a CSS custom property defined at :root. No magic numbers in component CSS.
Why it matters. A design system is a network of relationships, not a list of values. The kernel exposes two universal multipliers (--density, --font-scale) that propagate through ~40 derived tokens. Changing one ripples in a coordinated way; changing 40 is a refactor.
The contract. When you add a new component, never write padding: 14px. Write padding: var(--card-pad) (or compose from --space-N). When you need a color, never write #1d4ed8. Write var(--brand-primary-strong). If the token you need doesn't exist, add it to the tokens layer first.
4. Cascade discipline
@layer reset, tokens, base, layout, components, utilities is declared once at the top of the kernel. Every rule lives in exactly one layer. Layers later in the order win specificity ties regardless of selector strength or source order.
Why it matters. Without @layer, CSS specificity is a probabilistic mess: a class selector beats a tag selector beats an attribute selector, unless !important is involved, unless a later rule overrides an earlier one with the same specificity, etc. With @layer, you read the layer order at the top and KNOW which rule wins. Edits are predictable.
Where overrides live. Per-page overrides in overrides.css are _outside_ the kernel's @layer declarations, which means they always win over the kernel — a fact users rely on. If you want kernel changes to win over user overrides, that's a different architecture (the user would need !important, which we don't want).
5. No hidden state
No JavaScript. No inline <style>. No style="" attributes. No theme toggles that depend on cookies or localStorage. No fonts loaded outside the kernel. No images that change layout when missing.
Why it matters. A page that depends on hidden state can't be screenshotted, archived, link-checked, or rendered in any context that doesn't replay that state. A showcase page is _evidence_ — it has to read the same to a human reviewer in 6 months as it does to you right now. JS breaks that. Inline styles break that. Cookie-driven themes break that.
The escape valve. If you genuinely need a behavior that requires JS (say, a sortable table), put it in a SECOND file (enhancements.js) and make sure the page is still readable and accurate without it. The kernel itself stays pure HTML+CSS.
AI-collaboration patterns (why this design is LLM-friendly)
The kernel was designed with the working assumption that an AI agent will spend more time reading and editing it than any human. Specific patterns follow:
- Predictable token names beat invented ones.
--blue-7is a name an
AI can predict from --blue-6 without context. $brandColorMain is one name in 10,000 it might invent. The kernel uses Open Props' naming conventions because they are systematic; a model that sees one knows twenty.
- Single-file edits review better than multi-file edits. Every
component lives in assets/showcase.css; every per-page tweak in overrides.css. A diff for a kernel change is one file, one section. A diff for re-theming a page is one file, ~5 lines. Reviewers (human or AI) can audit changes without holding multiple files in their head.
- Semantic class names compress prompts. A model summarizing a page
with <article class="metric-card"> retains the meaning. The same page with <article class="rounded-lg shadow-md p-6 border bg-white"> exhausts attention budget on layout noise. Semantic naming is the highest-leverage prompt-compression technique in HTML/CSS work.
- `@layer` makes specificity predictable. When an AI edits the
kernel and adds a rule, it doesn't have to compute selector specificity in its head. The layer order tells it whether the new rule will win. Predictability beats cleverness.
- Dark-mode-only beats `light-dark()` for showcase pages. A page
whose appearance depends on the _viewer's_ OS settings can't be reviewed by screenshot consistently. The kernel sets color-scheme: dark to pin the look. If you need light-mode showcase pages, fork the kernel and build a light variant — don't auto-switch based on prefers-color-scheme.
Why a CDN, not a local copy
A page that links to a copy of the kernel fixes its appearance to whatever existed when it was scaffolded. Kernel improvements never reach it. By linking to jsDelivr (a CDN), every page reflects the latest kernel within the chosen version pin (@main for live, @vN.N.N for stability), so a single push to the kernel ripples coordinated visual updates across every page in every repo. That's the whole point — the kernel is genuinely shared infrastructure, not a snippet.
Why marketplace.json, not per-plugin plugin.json
The cc-skills marketplace centralizes versioning in one file. Per-plugin plugin.json files exist for plugin discovery but aren't synced. New plugins get a single entry in .claude-plugin/marketplace.json; the entry is bumped (along with all 36 others) every time semantic-release runs.
The implication for forks: if you fork cc-skills to publish your own kernel, you inherit this single-file architecture. Adding a new plugin means one entry in marketplace.json, period. No webhook, no manual sync.
Publishing — where pages get hosted, and how
Read this when you're about to put a finished page somewhere a real
reader can open it. It's not about authoring (that's principles.md)or extending the kernel (that's contributing.md); it's about thedelivery surface.
Two surfaces, two roles
The kernel CSS and the rendered HTML pages live in different places, on purpose:
| Asset | Hosted at | Why |
|---|---|---|
assets/showcase.css | jsDelivr CDN (public) | Shared infrastructure: every page anywhere imports it via one URL. |
auto-nav.css, auto-nav.js | Generated next to your HTML | Site-local; written by build-nav.py at publish time. Versioned via the ?v=N query string. |
site-map.html + per-page rail HTML | Generated into your site dir | The sitemap is part of your published artifact; it ships alongside the pages it indexes. |
| Your rendered HTML pages | Tailscale tailnet on bigblack | Internal-only audience; no DNS, no public exposure, no reverse proxy. |
| (alternatively) HTML | jsDelivr / GH Pages / Workers | Public reach, public-internet caching, public-search visibility. |
The kernel is _shared infrastructure_; rendered pages (and their auto-generated nav assets) are _evidence_ intended for a specific audience. Treat the two surfaces independently. A page that imports the public kernel can still be served privately — the kernel CSS is the only public artifact, and the nav rail's CSS/JS travel with the site dir.
Pick a delivery surface
| Audience | Recommended surface | Why |
|---|---|---|
| Just you and the internal team | Tailscale on bigblack | Tailnet ACL = no public exposure, no auth UI, no rate limit. Setup once, push forever. |
| External (clients, the web) | jsDelivr / GitHub Pages / CF Workers | Public addressing. Costs nothing. Add only when an external reader actually needs the page. |
| Forensic, immutable, citable from outside | jsDelivr @<commit-sha> | Page becomes citable URL pinned to a git commit. Use when external reviewers need a link. |
Default to tailnet-only unless the page genuinely needs public reach. Public hosting forces you to think about secrets in the page, search visibility, retention, and trust boundaries you don't otherwise need.
The bigblack tailnet pattern (recommended for internal pages)
A single static directory on bigblack, served by tailscale serve to your tailnet at a stable port. Each repo gets its own subdirectory under that root. Rendered URL:
https://bigblack.tail0f299b.ts.net:8448/<repo>/<page>/<repo> is auto-derived from your git remote, so URLs don't collide between projects sharing one bigblack instance.
Bigblack one-time setup
ssh bigblack 'mkdir -p ~/sites'
ssh bigblack 'sudo tailscale serve --bg --https=8448 /home/tca/sites'That's the whole server. No nginx. No reverse proxy. No certs to renew — Tailscale terminates TLS automatically using its own MagicDNS cert.
Per-repo setup (one command)
PLUGIN=${CLAUDE_PLUGIN_ROOT:-~/.claude/plugins/marketplaces/cc-skills/plugins/html-showcase}
bash "$PLUGIN/skills/page-template/scripts/install.sh"That's it. install.sh is the one-shot bootstrap: it copies the three pipeline scripts (build-nav.py, check-orphan-pages.py, site.sh) into <repo>/scripts/ and appends **/.published.json to your .gitignore. It auto-detects the repo root via git rev-parse --show-toplevel, or falls back to $PWD.
The installer is idempotent (re-running with no changes prints = unchanged for every file) and non-destructive (refuses to overwrite an existing differing file unless you pass --force).
To also seed a starter site directory in one go:
bash "$PLUGIN/skills/page-template/scripts/install.sh" --site contractor-siteThat additionally copies templates/index.html, templates/overrides.css.example, and templates/lychee.toml into <repo>/contractor-site/.
If you'd rather copy by hand, the four-line manual form still works:
cp $CLAUDE_PLUGIN_ROOT/skills/page-template/scripts/build-nav.py ./scripts/
cp $CLAUDE_PLUGIN_ROOT/skills/page-template/scripts/check-orphan-pages.py ./scripts/
cp $CLAUDE_PLUGIN_ROOT/skills/page-template/scripts/site.sh ./scripts/
echo '**/.published.json' >> .gitignoreIn either form, the site.sh shipped here will fall back to the canonical build-nav.py shipped with this plugin if the in-repo copy is missing, so the very first push works even before you commit your scripts/ directory — but committing the three scripts keeps the repo self-contained.
(If you also want shorthand commands like mise run site:push, add a small .mise/tasks/site.toml that calls scripts/site.sh.)
The publish workflow
scripts/site.sh nav <local-dir> # regenerate site-map + auto-nav (no network)
scripts/site.sh check <local-dir> # nav + lychee + orphan-page check
scripts/site.sh push <local-dir> # nav + check + rsync to bigblack
scripts/site.sh url <local-dir> # print the URL where it lives
scripts/site.sh list # show every published page across projects
scripts/site.sh unpublish <local-dir> # remove (asks for confirmation)check always re-runs nav first; push always re-runs check first. Broken links, unreachable pages, or a stale rail abort the push before anything reaches bigblack. This is the only gate; there's no semantic-release step. The sitemap itself becomes part of the link graph that lychee + the orphan detector validate, so the rail's correctness is checked on every publish.
The URL formula
https://bigblack.tail0f299b.ts.net:8448/<repo>/<page>/
│ │
│ └── basename of the local dir you pushed
└── basename of `git remote get-url origin`, .git strippedOverride the auto-derived repo name with SITE_PROJECT_NAME=foo if your git remote name doesn't match the namespace you want. Override the SSH alias with SITE_BIGBLACK_SSH=… if your .ssh/config uses a different host name.
Push-side gating, not pull-side
The validation gate (lychee + orphan-page check) runs on the publisher's machine, before the rsync. There is no CI, no GitHub Action, no post-receive hook on bigblack.
This is intentional. Bigblack is a delivery surface, not a quality gate. The page reaches it only after the local validator says it's reachable and link-clean. If you find yourself wanting bigblack to refuse bad content, that's a sign the validation should be stricter on the publisher side (extend check-orphan-pages.py, tighten lychee.toml), not that bigblack should grow gating logic.
Provenance: .published.json
Each push writes a sidecar manifest into the published directory:
{
"project": "opendeviationbar-py",
"page": "contractor-site",
"commit": "b36acb24937b",
"published_utc": "2026-05-02T03:46:38Z",
"source_repo": "git@github.com:terrylica/opendeviationbar-py.git",
"url": "https://bigblack.tail0f299b.ts.net:8448/opendeviationbar-py/contractor-site/"
}Fetch it any time to correlate the live page back to a git revision:
curl -sk https://bigblack.tail0f299b.ts.net:8448/<repo>/<page>/.published.json | jqThe manifest is gitignored (regenerated on every push), so it never pollutes the source repo's history. The git history of the source repo already records every change that produced a publishable page.
When NOT to use bigblack
- The page must be citable from outside the tailnet — use jsDelivr or
GitHub Pages instead so the URL resolves on the public internet.
- The page is part of a public marketing or docs site — that's a
different audience and a different lifecycle; keep it on the public surface end-to-end.
- The page must survive the bigblack host going away — treat bigblack
as ephemeral; for archival, also push to a public surface or commit the rendered HTML into the source repo's git history.
For everything else (contractor showcases, audit reports, internal telemetry views, weekly digests, run summaries), bigblack on the tailnet is the lowest-friction option.
Where this pattern lives in the world
The pipeline pattern is borrowed from opendeviationbar-patterns's scripts/blob.sh (which pushes large binary files to bigblack via SSH+rsync). The HTML adaptation differs in two important ways:
1. Path-mirrored, not content-addressed. blob.sh URLs are /<sha[:2]>/<sha>/<filename>, which means the URL changes whenever the content does. That's correct for binary data fingerprinting; it's wrong for HTML pages a human is going to bookmark and re-visit. The site pattern uses /<repo>/<page>/ so URLs are stable across edits.
2. Validation gate before push. blob.sh doesn't validate (binary blobs are opaque); the site pattern does (HTML has a notion of "broken"). Lychee + the orphan-page detector are the gate.
The two pipelines coexist on bigblack — the SWS blob server runs on port 18130 (content-addressed binaries), and tailscale serve path runs on port 8448 (path-mirrored HTML). They share the same tailnet ACL but nothing else.
Sitemap — the filesystem IS the navigation
Read this when you're about to add a section, change a slug, hand-edit
the rail, or wonder why the nav rerenders the way it does. The
filesystem-as-sitemap principle is what lets a multi-page mini-site
grow without anyone ever maintaining a hand-written nav.
The contract
A site this skill produces has exactly one navigation source of truth: the directory layout under <site-root>/. The script scripts/build-nav.py walks that layout and derives:
- a master
site-map.htmlat the root, - the per-page nav rail (injected into every HTML page),
- two asset files (
auto-nav.css,auto-nav.js) sitting at the root.
You author HTML files in directories. The script handles everything else.
<site-root>/
index.html ← optional but recommended (site home)
overrides.css ← optional per-site customization
auto-nav.css ← generated; do not hand-edit
auto-nav.js ← generated; do not hand-edit
site-map.html ← generated; do not hand-edit
<section-slug>/ ← any subdir with at least one *.html is a "section"
index.html ← section landing (optional but recommended)
page-a.html
page-b.html
<YYYY-MM-DD-other>/ ← date-prefixed sections sort newest-first
...What the script discovers
build-nav.py --root <site-root> runs in three phases:
1. Walk. Find every *.html directly in <site-root> (the home + any misc top-level pages) and every *.html inside each section's full subtree (rglob, depth-unlimited). Subdirectories starting with . or _ are skipped (so _drafts/, .git/, _research/ etc. stay invisible), as is the Pagefind-generated pagefind/ index dir. Generated files (site-map.html) are also excluded.
Each page records its section_relpath (path within the section, e.g. learn/01-foo.html) and depth (0 for top-level, 1 for one subdir down, etc.). The recursive walk means a section like
2026-05-02-postmortem/
index.html ← depth 0
summary.html ← depth 0
learn/
01-foo.html ← depth 1
02-bar.html ← depth 1yields all four pages, and the rail/site-map render the deeper ones indented under their parent (📑 badge + 18–24px margin-left) so the hierarchy is visually obvious without a separate metadata file.
2. Parse. For each page, extract the <title> and first <h1>. The <h1> (when present) is what shows in the rail; the <title> is the fallback. This means the page itself is the source of truth for its own label — no separate metadata file.
3. Render + inject. Build a site-map.html listing every section and page, then write a self-contained nav rail HTML fragment into every page between the <!-- AUTO-NAV-START --> and <!-- AUTO-NAV-END --> markers. If the markers are missing, the script inserts them right after <body>.
Re-runs are idempotent. Running the script with no source changes mutates zero files (the print-out says "Injected nav into 0 page(s)").
Section ordering
The slug pattern YYYY-MM-DD-<rest> is detected automatically:
- If any section has a date prefix, all sections sort by date
newest-first (sections without a date sort to the bottom).
- If no sections have date prefixes, sections sort alphabetically by
slug.
Within a section, pages sort newest-first with an explicit pinned-pages escape hatch. Tier order, top → bottom:
1. The section's index.html (📋 badge — always first). 2. Pinned pages — any page that contains <!-- nav-pin --> (or <!-- nav-pin: N --> for an explicit priority where lower N sorts higher) in its HTML body. Ties broken by descending iter-N, then descending birthtime. 3. Unpinned `index_iter_<N>_<slug>.html` pages, sorted descending by N (iter_315 above iter_314 above iter_2 — integer compare so iter_10 correctly outranks iter_2). 📄 badge. 4. Other unpinned top-level pages, sorted by filesystem birthtime descending (newest first; st_birthtime on macOS, falls back to mtime on Linux). 📄 badge. 5. Nested pages — grouped by subdir, each subdir's index.html first, then alphabetical (📑 badge, indented).
Why newest-first by default: when a campaign produces iter_1 → iter_N, the operator's most-pressing question is "what's the latest?" That page should sit at the top of the rail, not buried hundreds of entries down. The previous chronological-ascending default forced the user to scroll past stale work to reach the active edge.
Why a `<!-- nav-pin -->` HTML comment, not a sidecar file: the marker lives WITH the page, so renaming, regenerating, or git-cloning the file never desynchronizes pin state from page identity. No manifest to keep in sync; no orphaned .nav-pin file to forget. Use it when one canonical page (a "researcher explainer" landing page, a glossary, a methodology charter) should stay at the top even when iter-999 ships next week. Use <!-- nav-pin: 0 --> for the primary anchor and <!-- nav-pin: 1 -->, <!-- nav-pin: 2 --> for secondary anchors to control their relative order.
Why birthtime, not mtime: every rebuild, lint pass, or find/replace across the section touches mtime — using it would re-order the rail every time someone edited anything. Birthtime is set once at file creation and never moves.
Why `iter-N` overrides birthtime when present: birthtime is local to one filesystem. git clone creates new inodes with birthtime = clone time, so two collaborators see different orderings of the same content. The iter_N integer in the filename is the durable cross-machine signal — encode "what step is this" in the name, and the order survives every clone, mirror, and CI build.
This matches the way most teams instinctively organize a site that grows over time (date-prefixed for journals/audits/post-mortems, plain slugs for evergreen content, iter_N slugs for iterative experiments). If you need a different order — manual ordering, priority groups beyond what pins cover, etc. — that's a Stance 3 change to build-nav.py's walk_site() function.
What the rail contains
Every page (except site-map.html, which gets its own custom render) gets the same three-section rail:
1. Site shortcuts — Home + Site map. For pages inside a section, the header row also carries compact ‹ › within-section Prev/Next buttons (firing-219 pattern): they sit on the SAME row as the "Site" label so they add zero vertical height. ‹ goes to the sibling immediately above in the rail's flat list (newer, since the list is newest-first), › to the one below (older). At the ends of the sequence the unavailable arrow renders greyed-out and non-clickable. 2. Current section — the section's name + every sibling page (with the current page highlighted). 3. Other sections — Prev / Next neighbors in the section ordering.
Top-level pages (pages directly in <site-root>, not in a subdirectory) get the home-page version of the rail: just the Site shortcuts, with a plain "Site" header (no ‹ › buttons). They have no "section siblings" because they aren't in a section.
Keyboard Prev/Next. On any page inside a section, the bare [ and ] keys navigate to the previous / next sibling (same targets as the ‹ › buttons). The bindings are Chrome-safe — only Cmd+[ / Cmd+] are the browser's Back/Forward on macOS, so bare brackets are free. The handler bails when any modifier is held or when focus is in an input, textarea, select, or contenteditable element, so it never hijacks typing in the search box. The URLs are surfaced as data-prev-url / data-next-url attributes on the <details class="auto-nav-rail"> element and read by auto-nav.js; they're absent at the sequence ends.
Rail width: auto-fit, drag, persist
The rail's width is dynamic at runtime, governed by auto-nav.js:
| Gesture | Effect |
|---|---|
| First load (no saved width) | Measures every .rail-link's intrinsic width via width: max-content, picks max + padding + scrollbar, clamps to [220, 760]px. |
| Drag the right-edge handle | Live resize within [220, 1200]px. The chosen width is saved to localStorage under autoNavWidth_universal_v1. |
| Double-click the handle | Clears the saved width and re-runs auto-fit. Mental model: "reset to smart default", not "force the maximum". |
| Return visit (saved present) | The saved width wins; auto-fit is skipped. Drag preferences persist across pages. |
| `[` / `]` keys | Navigate to the previous / next sibling page in the current section (same targets as the ‹ › header buttons). Chrome-safe; ignored while a modifier is held or focus is in an input. |
Why width: max-content matters: scrollWidth on a block-level link returns max(clientWidth, content-width). If the rail is currently 880 px, the link element fills its parent and scrollWidth collapses to the rail's content width — measuring text width via scrollWidth while the rail is wide returns junk. Forcing max-content per link sizes each one to its true intrinsic content, regardless of the rail's current width, so auto-fit converges on the same answer whether the rail is at 220 px or 1200 px when measurement runs.
The 14 px-wide handle has an always-visible 2 px indicator line and a hover tooltip that explains the dual gesture. The hit zone is wider than the visible indicator so double-clicks are easy to land.
The marker convention
<!-- AUTO-NAV-START -->
…rail HTML, regenerated on every nav build…
<!-- AUTO-NAV-END -->The markers are HTML comments, so they are invisible to readers and inert to browsers. The script uses them as a regex anchor: replace everything between them on each run.
Don't hand-edit between the markers. Your edit will survive exactly until the next build-nav.py run. If you want a structural change to the rail itself, edit AUTO_NAV_CSS_BODY or render_rail() in build-nav.py and re-run.
The asset links in <head>:
<link rel="stylesheet" id="auto-nav-css" href="auto-nav.css?v=1" />
<script id="auto-nav-js" src="auto-nav.js?v=1" defer></script>are also rewritten on every run (the id="auto-nav-…" attributes are how the script finds them). If you change the rail's CSS body inside build-nav.py, bump --asset-version so caches see new URLs.
Working with the rail
Add a page to an existing section
cp templates/index.html <site-root>/<section-slug>/<new-page>.html
# fill in {{ placeholders }}
python3 scripts/build-nav.py --root <site-root>The new page appears in:
<section-slug>/'s rail (it's a sibling of the existing pages),- the master
site-map.html, - every other section's "Other sections" cross-link if
<section-slug>
is now a Prev/Next neighbor of that section.
Add a new section
mkdir <site-root>/<new-section-slug>
cp templates/section-index.html <site-root>/<new-section-slug>/index.html
cp templates/index.html <site-root>/<new-section-slug>/<page>.html
python3 scripts/build-nav.py --root <site-root>Sections without index.html work fine — build-nav.py will treat the first alphabetically-sorted page as the section's entry point in the "Other sections" links. But a section landing page is the single best place to frame the section's purpose for readers, and the section-index.html template gives you that.
Rename a section
Renaming changes the slug. Update any HTML that linked into the old section by hand (lychee will catch the broken links), then re-run build-nav.py so the rail and site-map reflect the new slug.
Move a page between sections
Just mv the file. Re-run build-nav.py. The rail rewrites itself. Lychee will catch any external links that referenced the old path.
Why filesystem-as-sitemap
A handful of alternatives exist, and the trade-off matrix matters:
| Approach | Pros | Cons |
|---|---|---|
| Hand-written nav in every page | Maximum control | Drifts the moment you add a page; one of the most reliable sources of stale links |
| Hand-written `_nav.json` consumed by JS | Single edit point | Adds a JS dependency; breaks file://; has to be re-generated for static hosts; Principle 5 violated |
| Hand-written `_sections.toml` + a builder | Sections in author-defined order | Two SSoTs (filesystem + manifest); easy to desync when files move |
| Filesystem-as-sitemap (this skill) | Zero hand-written nav; layout = nav; idempotent builds | Section order is a function of slug naming, not free |
The cost of "section order is a function of slug naming" is small in practice — a date prefix (YYYY-MM-DD-) buys you chronological order for free, and slug ordering buys you alphabetical for free. Anything else is a custom sort, which lives in build-nav.py rather than in a manifest file.
Search via Pagefind
A Search section is mounted at the top of every rail and the master site-map.html. The implementation:
- The page
<head>gets four asset tags in strict order: pagefind CSS,
auto-nav CSS, pagefind JS (deferred), auto-nav JS (deferred). Order matters because auto-nav.js's mountSearch() calls new PagefindUI(...) which must exist on window by the time the rail initializes.
- The rail HTML contains
<div id="auto-nav-search"></div>— Pagefind
fills this in.
- The actual index lives in
<site-root>/pagefind/, generated by the
pagefind Rust CLI (pagefind --site <site-root>).
scripts/site.sh navinvokespagefindautomatically; install it
via brew install pagefind.
- If
pagefindisn't installed, the search input still renders but is
inert — mountSearch() short-circuits when window.PagefindUI is undefined. A friendly warning prints during the build.
Bumping --asset-version invalidates only the auto-nav.css/js URLs; Pagefind's own assets are version-managed by the pagefind CLI itself.
Push-as-hook auto-resync
install.sh --hook writes a .githooks/pre-push hook and points core.hooksPath at .githooks/. After that, every git push main:
1. Auto-detects every site directory in your repo (any dir containing site-map.html). 2. Runs scripts/site.sh nav <site-dir> (rebuilds rail + search index). 3. Runs scripts/site.sh push <site-dir> (rsync to bigblack via Tailscale).
The hook is non-blocking — failure logs a warning, the git push continues. Skip env vars: NO_HTMLSHOWCASE_HOOK, NO_HTMLSHOWCASE_SYNC, NO_HTMLSHOWCASE_SEARCH. Override auto-detection with explicit HTMLSHOWCASE_SITES="dir-a dir-b".
This makes "publish on push" the default workflow once you opt in via --hook. The drift model: the pagefind/ directory committed to git can lag what bigblack actually serves (the hook regenerates against current-on-disk HTML). Bigblack gets truth; git keeps the most-recently- committed snapshot. The next manual scripts/site.sh nav re-aligns them. The index is generated artifact, not code — drift is harmless.
Theming the rail
The rail is always dark. Same goes for the master site-map.html. Both pin color-scheme: dark and use a slate-950 surface + slate-300 text + indigo-400 accent palette regardless of the host page's theme.
This is an intentional design invariant. The rail is the _constant_ across every page in the system — a contractor showcase, a telemetry dashboard, a weekly digest, and a postmortem all overlay the same rail. If the rail's theme followed the page, then navigating between a light page and a dark page would flicker the rail; if the rail's theme was "whatever last loaded," then a user dropping into the middle of the site via a deep link would see arbitrary theming. Pinning dark is the only shape that delivers a consistent navigation surface.
Pages themselves are free to pick any theme. The AI authoring a page (dashboard, post-mortem, research explainer, contractor showcase) decides what visual register suits the content — light, dark, sepia, gradient, whatever. Pages do NOT need to coordinate with each other; only the rail is constrained. This deliberate asymmetry is what makes the system work: the navigation surface is predictable while the content surface is expressive.
The body gutter is part of the rail contract. The rail injects padding-left: 28px (rail collapsed) / 40px (rail open) plus padding-right: 28px and a clamped max-width on <body> via !important. Without the gutter, page content butts directly against the rail's right edge and the eye has nowhere to land between the two visual surfaces. The 28-40px buffer was added per user feedback 2026-05-26 (iter_315 PRESENTATION_REFACTOR). Pages declaring their own body { padding: ... } will be overridden; that's intentional — the buffer is non-negotiable.
The rail's appearance lives entirely in AUTO_NAV_CSS_BODY inside build-nav.py. It's intentionally not part of the showcase kernel because:
- The rail is fixed-position infrastructure, not page content; mixing it
with kernel components would muddy the kernel's role.
- A repo can adopt the rail without adopting the kernel CSS (e.g., a
legacy site with its own design system can drop in build-nav.py for navigation only) and still get the consistent dark rail.
- The rail's overlay surface needs different visual weight than the
page's content surface — keeping them separate avoids cascade fights.
If you genuinely need a light variant of the rail in the future (e.g., embedding into a public marketing site whose brand mandates light navigation), do it by toggling a class on the <details class="auto-nav-rail"> element and forking the CSS body under that class — never by letting the host page's color-scheme leak in, which is how rail-theme drift gets reintroduced. Bump --asset-version after any change so caches see the new URL.
When NOT to use the rail
The rail is the right shape for multi-page mini-sites (2 to ~50 pages, organized in 1 to ~10 sections). It's the wrong shape for:
- A single-page showcase. No nav is needed; remove the markers and
the link/script tags. The kernel still works without the rail.
- A 500-page documentation site. At that scale you want a real
static-site generator (Zola, Hugo, MkDocs, Astro) with proper search, collections, and TOC. The auto-nav is a deliberately simple tool.
- A page that must stay layout-pristine for screenshot/archival.
The rail occupies left margin space; if the page is meant to be pixel-comparable to a previous render, omit the rail.
For these edge cases, you can author HTML pages with the kernel and without the markers; build-nav.py simply skips pages that don't have the markers and never inserts them (the insertion uses the <body> tag as anchor, but only if the markers don't already exist). To opt a page out, remove the markers from the file and add a comment so a future maintainer knows it's intentional.
Implementation map (for fixers)
Inside scripts/build-nav.py:
| Concern | Function / constant |
|---|---|
| Constants & marker strings | top of file |
| Section/page discovery | walk_site() |
| Per-page rail HTML | render_rail() |
| Site-map page HTML | render_site_map() |
| Idempotent injection | inject_into_page() + inject_block() |
| Asset link rewriting in head | ensure_nav_assets() |
| Rail CSS body | AUTO_NAV_CSS_BODY |
| Rail JS body | AUTO_NAV_JS_BODY |
Edit one place per concern. The script is small enough to read end-to-end in five minutes; don't split it without a reason.
#!/usr/bin/env python3
"""Orphan-page detector for static HTML mini-sites.
Walks every ``*.html`` under a given directory, parses ``<a href>`` links,
builds a directed reachability graph, and reports any HTML file not reachable
from the entry-point ``index.html``. Designed to complement Lychee, which
catches broken links but does not flag unreferenced files.
Usage::
python scripts/check-orphan-pages.py docs/contractor-site/
Exit codes:
0 No orphans (all HTML reachable from index.html)
1 One or more orphan files detected
2 Bad invocation (missing arg, no index.html, etc.)
Implementation notes:
Pure stdlib only (html.parser) so it has no install footprint and works
against any clean Python 3.10+ environment. Run-time on a 100-page site
is well under a second.
"""
from __future__ import annotations
import argparse
import sys
from html.parser import HTMLParser
from pathlib import Path
from urllib.parse import urlparse
class _AnchorExtractor(HTMLParser):
"""Collect every ``href`` value from ``<a>`` tags in document order."""
def __init__(self) -> None:
super().__init__()
self.hrefs: list[str] = []
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
if tag.lower() != "a":
return
for name, value in attrs:
if name.lower() == "href" and value:
self.hrefs.append(value)
def _is_local_html(href: str) -> bool:
"""True if href targets a local HTML file (not an absolute URL or anchor only)."""
parsed = urlparse(href)
if parsed.scheme in {"http", "https", "mailto", "tel", "javascript"}:
return False
path = parsed.path
if not path:
return False # anchor-only ('#section') doesn't claim ownership of a file
return path.endswith(".html") or path.endswith("/")
def _resolve(source: Path, href: str, root: Path) -> Path | None:
"""Resolve ``href`` (relative to ``source``) to an absolute path inside ``root``.
Returns None if the resolved path escapes ``root`` or doesn't end in .html
after directory-index normalization.
"""
parsed = urlparse(href)
raw_path = parsed.path or ""
if raw_path.startswith("/"):
# Treat absolute paths as rooted at site root
target = (root / raw_path.lstrip("/")).resolve()
else:
target = (source.parent / raw_path).resolve()
# Directory references default to /index.html
if target.is_dir() or raw_path.endswith("/"):
target = target / "index.html"
try:
target.relative_to(root.resolve())
except ValueError:
return None # escaped the site root
return target if target.suffix == ".html" else None
def _collect_html_files(root: Path) -> set[Path]:
return {p.resolve() for p in root.rglob("*.html") if p.is_file()}
def _build_outgoing(html_files: set[Path], root: Path) -> dict[Path, set[Path]]:
out: dict[Path, set[Path]] = {p: set() for p in html_files}
for source in html_files:
try:
text = source.read_text(encoding="utf-8")
except UnicodeDecodeError:
text = source.read_text(encoding="utf-8", errors="replace")
parser = _AnchorExtractor()
parser.feed(text)
for href in parser.hrefs:
if not _is_local_html(href):
continue
target = _resolve(source, href, root)
if target and target in html_files:
out[source].add(target)
return out
def _walk_reachable(
entry: Path,
outgoing: dict[Path, set[Path]],
) -> set[Path]:
reachable: set[Path] = {entry}
stack: list[Path] = [entry]
while stack:
node = stack.pop()
for child in outgoing.get(node, ()):
if child not in reachable:
reachable.add(child)
stack.append(child)
return reachable
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0])
parser.add_argument(
"directory",
type=Path,
help="Site root containing index.html and other *.html pages",
)
parser.add_argument(
"--entry",
default="index.html",
help="Entry file relative to <directory> (default: index.html)",
)
args = parser.parse_args()
root = args.directory.resolve()
if not root.is_dir():
print(f"FATAL: {root} is not a directory", file=sys.stderr)
return 2
entry = (root / args.entry).resolve()
if not entry.is_file():
print(f"FATAL: entry file not found: {entry}", file=sys.stderr)
return 2
html_files = _collect_html_files(root)
if not html_files:
print(f"No HTML files under {root}", file=sys.stderr)
return 0
outgoing = _build_outgoing(html_files, root)
reachable = _walk_reachable(entry, outgoing)
orphans = sorted(html_files - reachable)
print(f"Checked {len(html_files)} HTML file(s) under {root}")
print(f"Entry: {entry.relative_to(root)}")
print(f"Reachable: {len(reachable)}")
if orphans:
print(f"\nORPHAN PAGES ({len(orphans)}):")
for o in orphans:
print(f" - {o.relative_to(root)}")
print(
"\nFix: add an <a href> from at least one reachable page, or "
"delete the orphan file.",
file=sys.stderr,
)
return 1
print("\nOK — every HTML file is reachable from the entry page.")
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env bash
# install.sh — one-shot bootstrap for the html-showcase pipeline.
#
# Drops the three pipeline scripts (build-nav.py, check-orphan-pages.py,
# site.sh) into <repo>/scripts/, ensures **/.published.json is gitignored,
# and optionally seeds a starter site directory with index.html +
# overrides.css.example + lychee.toml.
#
# Idempotent: re-running with no changes is a no-op. Non-destructive:
# never overwrites an existing file unless --force is passed.
#
# Usage:
# bash install.sh [--repo <path>] [--site <name>] [--force] [--check] [--hook]
#
# --repo <path> Target repo root (default: $PWD or `git rev-parse
# --show-toplevel` if invoked inside a git tree)
# --site <name> Also scaffold <repo>/<name>/ with starter HTML files
# (default: skip; pass e.g. `--site contractor-site`
# to seed one)
# --force Overwrite existing scripts/templates if they differ
# --check Preflight only: report what's already installed and
# what would be installed, exit 0 if everything is
# already in place, exit 10 if anything is missing.
# Pairs with the /html-showcase:setup skill.
# --hook Also install a pre-push git hook that regenerates the
# sitemap + Pagefind search index and rsyncs every
# tracked site dir to bigblack on `git push main`.
# Wires <repo>/.githooks/ via `git config
# core.hooksPath .githooks`. Non-blocking; failure
# never blocks the push.
#
# After install, the workflow is:
# scripts/site.sh nav <site-dir> # regenerate sitemap + auto-nav
# scripts/site.sh check <site-dir> # nav + lychee + orphan-page
# scripts/site.sh push <site-dir> # nav + check + rsync to bigblack
set -euo pipefail
# ----------------------------------------------------------------------
# Resolve the source skill directory regardless of how install.sh was
# invoked (via $CLAUDE_PLUGIN_ROOT, via a clone of cc-skills, or via a
# direct path).
#
# Use $0 (works under both bash and zsh) instead of BASH_SOURCE[0]
# (bash-only) so users who accidentally run `zsh install.sh` still get
# a working bootstrap.
# ----------------------------------------------------------------------
SKILL_DIR="$(cd "$(dirname "$0")/.." && pwd)"
SCRIPTS_SRC="$SKILL_DIR/scripts"
TEMPLATES_SRC="$SKILL_DIR/templates"
# ----------------------------------------------------------------------
# Parse args
# ----------------------------------------------------------------------
REPO=""
SITE=""
FORCE=0
CHECK_ONLY=0
INSTALL_HOOK=0
while [[ $# -gt 0 ]]; do
case "$1" in
--repo) REPO="$2"; shift 2 ;;
--site) SITE="$2"; shift 2 ;;
--force) FORCE=1; shift ;;
--check) CHECK_ONLY=1; shift ;;
--hook) INSTALL_HOOK=1; shift ;;
-h|--help)
sed -n '2,/^$/p' "$0" | sed 's/^# *//'
exit 0 ;;
*)
echo "unknown flag: $1 (use --help)" >&2
exit 2 ;;
esac
done
# Resolve repo root — prefer git, fall back to PWD.
if [[ -z "$REPO" ]]; then
if REPO="$(git rev-parse --show-toplevel 2>/dev/null)"; then
:
else
REPO="$PWD"
fi
fi
REPO="$(cd "$REPO" && pwd)"
if [[ ! -d "$REPO" ]]; then
echo "✗ target repo does not exist: $REPO" >&2
exit 1
fi
# ----------------------------------------------------------------------
# Toolchain preflight: python3 must be ≥ 3.10 (build-nav.py uses
# pathlib's walk_up=True on 3.12+ with a 3.10/3.11 fallback). Failing
# loudly here beats a cryptic TypeError on first nav rebuild.
# ----------------------------------------------------------------------
if command -v python3 >/dev/null 2>&1; then
if ! python3 -c 'import sys; sys.exit(0 if sys.version_info >= (3, 10) else 1)' 2>/dev/null; then
py_ver="$(python3 --version 2>&1 || echo unknown)"
echo "✗ python3 is too old: ${py_ver}. Need Python 3.10 or newer." >&2
echo " Install: brew install python@3.14 (or: mise use python@3.14)" >&2
exit 1
fi
else
echo "✗ python3 not on PATH. Install: brew install python@3.14" >&2
exit 1
fi
if [[ $CHECK_ONLY -eq 1 ]]; then
echo "→ preflight check for $REPO"
else
echo "→ installing html-showcase pipeline into $REPO"
fi
# Tracks "missing or out-of-date" count for --check exit code.
MISSING_COUNT=0
# ----------------------------------------------------------------------
# Helper: copy a file unless it already exists with identical content.
# Honors --force for a clean overwrite. Under --check, never copies —
# only reports.
# ----------------------------------------------------------------------
copy_if_new() {
local src="$1" dst="$2" label="$3"
if [[ ! -f "$src" ]]; then
echo " ✗ source missing: $src" >&2
return 1
fi
if [[ -f "$dst" ]]; then
if cmp -s "$src" "$dst"; then
echo " = $label (unchanged)"
return 0
fi
if [[ $CHECK_ONLY -eq 1 ]]; then
echo " ✗ $label differs from canonical (would update on install)"
MISSING_COUNT=$((MISSING_COUNT + 1))
return 0
fi
if [[ $FORCE -ne 1 ]]; then
echo " ⚠ $label exists and differs — pass --force to overwrite"
return 0
fi
elif [[ $CHECK_ONLY -eq 1 ]]; then
echo " ✗ $label not installed"
MISSING_COUNT=$((MISSING_COUNT + 1))
return 0
fi
# Explicit 0755 — don't trust the source file's bits (the user might
# have a tight umask or a checkout with stripped exec bits) and don't
# trust stat to be portable across filesystems.
install -m 0755 "$src" "$dst"
echo " ✓ $label"
}
# ----------------------------------------------------------------------
# 1. Pipeline scripts → <repo>/scripts/
# ----------------------------------------------------------------------
[[ $CHECK_ONLY -eq 1 ]] || mkdir -p "$REPO/scripts"
copy_if_new "$SCRIPTS_SRC/build-nav.py" "$REPO/scripts/build-nav.py" "scripts/build-nav.py"
copy_if_new "$SCRIPTS_SRC/check-orphan-pages.py" "$REPO/scripts/check-orphan-pages.py" "scripts/check-orphan-pages.py"
copy_if_new "$SCRIPTS_SRC/site.sh" "$REPO/scripts/site.sh" "scripts/site.sh"
# ----------------------------------------------------------------------
# 2. .gitignore — ensure provenance + Pagefind index aren't committed.
#
# Two patterns:
# **/.published.json — per-push provenance stamp (regenerated each push)
# **/pagefind/ — Pagefind binary's static index (regenerated each
# nav build; committing it causes diff churn when
# team members have different pagefind versions)
#
# We use a BOM-aware variant of grep -qxF: if a user's .gitignore was
# saved with a UTF-8 BOM, `grep -qxF` against the raw line "**/foo" can
# fail-to-find an entry that's actually present. Strip BOM via sed first.
# ----------------------------------------------------------------------
GITIGNORE="$REPO/.gitignore"
PUBLISHED_PATTERN='**/.published.json'
PAGEFIND_PATTERN='**/pagefind/'
# Test for a pattern's presence in .gitignore, BOM-tolerant.
gitignore_has() {
local pat="$1"
[[ -f "$GITIGNORE" ]] || return 1
sed '1s/^\xEF\xBB\xBF//' "$GITIGNORE" | grep -qxF "$pat"
}
# Single-pass append: collect all missing patterns, write them all in
# one append operation. Atomic enough that a SIGINT between the first
# and second append doesn't leave the repo in a "I already added one
# pattern, re-run won't add the second" state.
missing_patterns=()
missing_comments=()
for pair in \
"$PUBLISHED_PATTERN|published-page provenance manifests" \
"$PAGEFIND_PATTERN|Pagefind static search index (regenerated per nav build)"
do
pat="${pair%%|*}"
comment="${pair#*|}"
if [[ ! -f "$GITIGNORE" ]] || ! gitignore_has "$pat"; then
missing_patterns+=("$pat")
missing_comments+=("$comment")
else
echo " = .gitignore already lists $pat"
fi
done
if [[ ${#missing_patterns[@]} -gt 0 ]]; then
if [[ $CHECK_ONLY -eq 1 ]]; then
for pat in "${missing_patterns[@]}"; do
echo " ✗ .gitignore missing entry $pat"
MISSING_COUNT=$((MISSING_COUNT + 1))
done
else
# Build the full block in memory FIRST, then write in one shot.
# Resolving "does the file exist?" BEFORE opening the append
# redirect avoids the shellcheck SC2094 read+write-in-same-pipeline
# warning, and also lets us either-write-everything-or-nothing on
# SIGINT (no half-applied state across the two patterns).
gitignore_existed=0
[[ -f "$GITIGNORE" ]] && gitignore_existed=1
block=""
if [[ $gitignore_existed -eq 1 ]]; then
block=$'\n'
fi
hash_tag='#'
for i in "${!missing_patterns[@]}"; do
block+="${hash_tag} html-showcase: ${missing_comments[$i]}"$'\n'
block+="${missing_patterns[$i]}"$'\n'
done
printf '%s' "$block" >> "$GITIGNORE"
for pat in "${missing_patterns[@]}"; do
echo " ✓ appended $pat to .gitignore"
done
fi
fi
# If `pagefind/` is already TRACKED by git (committed before being
# gitignored), gitignoring it doesn't untrack the existing copies. The
# user will keep seeing diff churn every time the index regenerates.
# Surfaced in BOTH --check and install modes so users running --check
# (the most common upgrade pre-flight) actually see the warning.
#
# Pathspec note: `**/pagefind/` is gitignore-glob syntax, NOT git
# pathspec syntax. ls-files needs a real pathspec — `*pagefind/*`
# matches any path containing pagefind/ (verified empirically; the
# previous --error-unmatch with gitignore syntax never matched).
if command -v git >/dev/null 2>&1; then
if git -C "$REPO" rev-parse --git-dir >/dev/null 2>&1; then
tracked_pagefind="$(git -C "$REPO" ls-files '*pagefind/*' 2>/dev/null | head -1)"
if [[ -n "$tracked_pagefind" ]]; then
echo " ⚠ pagefind/ is already tracked by git (e.g., $tracked_pagefind)." >&2
echo " To stop committing the index, run:" >&2
echo " git -C $REPO rm -r --cached '*pagefind/*' && git commit -m 'untrack pagefind index'" >&2
[[ $CHECK_ONLY -eq 1 ]] && MISSING_COUNT=$((MISSING_COUNT + 1))
fi
fi
fi
# ----------------------------------------------------------------------
# 3. (optional) Seed a starter site dir with index.html + overrides + lychee
# ----------------------------------------------------------------------
if [[ -n "$SITE" ]]; then
SITE_DIR="$REPO/$SITE"
[[ $CHECK_ONLY -eq 1 ]] || mkdir -p "$SITE_DIR"
echo "→ seeding site directory: $SITE_DIR"
copy_if_new "$TEMPLATES_SRC/index.html" "$SITE_DIR/index.html" "$SITE/index.html"
copy_if_new "$TEMPLATES_SRC/overrides.css.example" "$SITE_DIR/overrides.css" "$SITE/overrides.css"
copy_if_new "$TEMPLATES_SRC/lychee.toml" "$SITE_DIR/lychee.toml" "$SITE/lychee.toml"
if [[ $CHECK_ONLY -ne 1 ]]; then
echo " → fill {{ PLACEHOLDERS }} in $SITE/index.html, then:"
echo " python3 $REPO/scripts/build-nav.py --root $SITE_DIR"
fi
fi
# ----------------------------------------------------------------------
# 3.5. Pre-push hook validation/install.
#
# When --hook is passed: install/refresh the hook in .githooks/pre-push
# and wire core.hooksPath. When --check is passed AND a hook is already
# present at .githooks/pre-push, validate it even WITHOUT --hook so a
# user upgrading the plugin can detect a stale committed hook without
# having to remember to add --hook each time.
# ----------------------------------------------------------------------
EXISTING_HOOK="$REPO/.githooks/pre-push"
if [[ $INSTALL_HOOK -ne 1 && $CHECK_ONLY -eq 1 && -f "$EXISTING_HOOK" ]]; then
HOOK_SRC="$SCRIPTS_SRC/pre-push.template"
if [[ -f "$HOOK_SRC" ]] && ! cmp -s "$HOOK_SRC" "$EXISTING_HOOK"; then
echo "→ pre-push hook present at .githooks/pre-push but differs from canonical:"
echo " ✗ .githooks/pre-push is stale (would update on install --hook --force)"
MISSING_COUNT=$((MISSING_COUNT + 1))
fi
fi
if [[ $INSTALL_HOOK -eq 1 ]]; then
HOOK_SRC="$SCRIPTS_SRC/pre-push.template"
HOOK_DST="$REPO/.githooks/pre-push"
echo "→ installing pre-push hook: $HOOK_DST"
if [[ ! -f "$HOOK_SRC" ]]; then
echo " ✗ template missing at $HOOK_SRC" >&2
[[ $CHECK_ONLY -eq 1 ]] && MISSING_COUNT=$((MISSING_COUNT + 1))
else
if [[ -f "$HOOK_DST" ]] && cmp -s "$HOOK_SRC" "$HOOK_DST"; then
echo " = .githooks/pre-push (unchanged)"
elif [[ -f "$HOOK_DST" && $FORCE -ne 1 && $CHECK_ONLY -ne 1 ]]; then
echo " ⚠ .githooks/pre-push exists and differs — pass --force to overwrite"
elif [[ $CHECK_ONLY -eq 1 ]]; then
if [[ -f "$HOOK_DST" ]]; then
echo " ✗ .githooks/pre-push differs from canonical (would update on install)"
else
echo " ✗ .githooks/pre-push not installed"
fi
MISSING_COUNT=$((MISSING_COUNT + 1))
else
mkdir -p "$REPO/.githooks"
install -m 755 "$HOOK_SRC" "$HOOK_DST"
echo " ✓ .githooks/pre-push"
fi
fi
# Wire core.hooksPath so the hook actually fires. Idempotent and
# collision-aware: if another hook engine (Husky, lefthook, pre-commit)
# already pointed core.hooksPath somewhere else, refuse to clobber it
# silently. The user gets a clear error and can pass --force to
# override after they've decided whether to migrate.
if [[ $CHECK_ONLY -ne 1 ]]; then
# `--local` reads ONLY the repo's .git/config, NOT global/system. A
# global `core.hooksPath = .husky` (set in ~/.config/git/config) is
# the user's environment choice and shouldn't false-positive a
# collision warning for every repo they install into.
current_path="$(git -C "$REPO" config --local --get core.hooksPath 2>/dev/null || echo '')"
if [[ "$current_path" == ".githooks" ]]; then
echo " = git config core.hooksPath already set to .githooks"
elif [[ -n "$current_path" ]]; then
if [[ $FORCE -eq 1 ]]; then
git -C "$REPO" config core.hooksPath .githooks
echo " ✓ git config core.hooksPath = .githooks (overrode '$current_path' via --force)"
else
echo " ⚠ core.hooksPath is already set to '$current_path' (Husky/lefthook/pre-commit?)" >&2
echo " Refusing to override silently. Options:" >&2
echo " • Re-run with --force to set core.hooksPath = .githooks" >&2
echo " • Manually merge: copy $REPO/.githooks/pre-push into '$current_path/pre-push'" >&2
echo " • Skip the hook: use scripts/site.sh push manually instead" >&2
exit 3
fi
else
git -C "$REPO" config core.hooksPath .githooks
echo " ✓ git config core.hooksPath = .githooks"
fi
fi
fi
# ----------------------------------------------------------------------
# 4. Summary
# ----------------------------------------------------------------------
if [[ $CHECK_ONLY -eq 1 ]]; then
if [[ $MISSING_COUNT -eq 0 ]]; then
echo
echo "✓ everything in place — no install needed"
exit 0
fi
echo
echo "$MISSING_COUNT item(s) need installation. Re-run without --check to apply."
exit 10
fi
cat <<EOF
✓ install complete
Next steps:
1. (Optional) Add shorthand mise tasks: drop a .mise/tasks/site.toml that
wraps scripts/site.sh — see references/publishing.md for an example.
2. Author HTML files in <site-dir>/ and any <site-dir>/<section-slug>/.
Each subdir of the site root that contains *.html becomes a "section".
3. Build the sitemap + auto-nav rail:
python3 scripts/build-nav.py --root <site-dir>
or, equivalently:
scripts/site.sh nav <site-dir>
4. Validate locally:
scripts/site.sh check <site-dir>
5. Publish to bigblack via Tailscale:
scripts/site.sh push <site-dir>
Skill: html-showcase:page-template (cc-skills marketplace)
SSoT for the architecture: $SKILL_DIR/SKILL.md
EOF
#!/usr/bin/env bash
# pre-push hook — regenerate sitemap + search index and resync sites to
# bigblack via Tailscale before pushing to the remote.
#
# Installed by: bash <plugin>/skills/page-template/scripts/install.sh --hook
# Activated when: $REPO_ROOT/.githooks is wired via `git config
# core.hooksPath .githooks` (install.sh sets this for you).
#
# What runs on `git push <remote> main`:
# 1. For each tracked site dir (env HTMLSHOWCASE_SITES, or
# auto-detected by finding directories that contain a
# site-map.html generated by build-nav.py):
# a. scripts/site.sh nav <site-dir> ← rebuild rail + search index
# b. scripts/site.sh push <site-dir> ← rsync to bigblack
# 2. The actual `git push` continues regardless of hook status —
# sync failure must never block the push (matches opendeviationbar
# pattern: bigblack is a delivery surface, not a quality gate).
#
# Skip knobs:
# NO_HTMLSHOWCASE_HOOK=1 skip everything (hook is a no-op)
# NO_HTMLSHOWCASE_SYNC=1 run nav + search but skip rsync
# NO_HTMLSHOWCASE_SEARCH=1 skip the pagefind regen step
# HTMLSHOWCASE_SITES="…" explicit space-separated site dirs, overrides
# auto-detection (relative to repo root)
#
# git push --dry-run still triggers this hook (git can't tell us about
# --dry-run via stdin). To preview a push without rsync:
# NO_HTMLSHOWCASE_SYNC=1 git push --dry-run …
#
# Non-blocking: every failure path logs a warning and exits 0. Errors
# from rsync ARE surfaced to stderr so the user sees what went wrong;
# only the hook's exit status is forced to 0.
set -uo pipefail
if [[ "${NO_HTMLSHOWCASE_HOOK:-0}" == "1" ]]; then
echo "[html-showcase] hook skipped (NO_HTMLSHOWCASE_HOOK=1)."
exit 0
fi
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
SITE_SH="${REPO_ROOT}/scripts/site.sh"
if [[ ! -x "$SITE_SH" ]]; then
echo "[html-showcase] scripts/site.sh not found — hook is a no-op." >&2
exit 0
fi
# Detect whether main is in the push set. The pre-push hook receives ref
# pairs on stdin: "<local_ref> <local_oid> <remote_ref> <remote_oid>".
# Both refs must be main/master so a `git push origin HEAD:main` from a
# feature branch doesn't accidentally publish the feature branch's
# state. (Caught by the edge-case audit; HIGH-severity finding.)
pushing_main=0
while IFS=' ' read -r local_ref _local_oid remote_ref _remote_oid; do
local_main=0
remote_main=0
case "${local_ref:-}" in
refs/heads/main|refs/heads/master) local_main=1 ;;
esac
case "${remote_ref:-}" in
refs/heads/main|refs/heads/master) remote_main=1 ;;
esac
if [[ "$local_main" -eq 1 && "$remote_main" -eq 1 ]]; then
pushing_main=1
break
fi
done
if [[ "${pushing_main}" -eq 0 ]]; then
# Silent exit was confusing (round-3 audit: user pushes a feature
# branch and sees no [html-showcase] output, can't tell if hook ran).
# One-line log makes it explicit that the skip was intentional.
echo "[html-showcase] skipping nav rebuild + rsync (push is not to main/master)"
exit 0
fi
# Resolve which site dirs to process. Two modes:
# 1. Explicit list via $HTMLSHOWCASE_SITES (space-separated relative paths)
# 2. Auto-detect: any directory under REPO_ROOT that contains a
# site-map.html generated by build-nav.py (we look for the marker).
sites=()
if [[ -n "${HTMLSHOWCASE_SITES:-}" ]]; then
# shellcheck disable=SC2206 # word-split env var on purpose
sites=($HTMLSHOWCASE_SITES)
else
while IFS= read -r site_map; do
# Each found site-map.html implies the directory containing it
# is a site-root. Prune list covers:
# - VCS / package manager state: .git, node_modules
# - Generated artifacts: pagefind, dist, build, .next, .nuxt
# - Hook engines and Python venvs that might have stray HTML:
# .husky, .venv, venv
site_dir="$(dirname "$site_map")"
sites+=("${site_dir#${REPO_ROOT}/}")
done < <(
find "$REPO_ROOT" \
-type d \( \
-name node_modules -o -name .git -o -name pagefind \
-o -name .husky -o -name .venv -o -name venv \
-o -name dist -o -name build \
-o -name .next -o -name .nuxt \
\) -prune -o \
-type f -name 'site-map.html' -print 2>/dev/null
)
fi
if [[ "${#sites[@]}" -eq 0 ]]; then
echo "[html-showcase] no site dirs found (looked for any dir containing site-map.html)."
exit 0
fi
echo "[html-showcase] processing ${#sites[@]} site dir(s): ${sites[*]}"
for site in "${sites[@]}"; do
site_path="${REPO_ROOT}/${site}"
if [[ ! -d "$site_path" ]]; then
echo "[html-showcase] ⚠ skipping $site (not a directory)" >&2
continue
fi
echo "[html-showcase] → $site"
# nav rebuild — pass through the search-skip flag so the user's
# NO_HTMLSHOWCASE_SEARCH=1 actually does what it says. (Earlier
# version of this hook had two identical branches; caught by the
# edge-case audit.)
nav_args=("nav" "$site_path")
if [[ "${NO_HTMLSHOWCASE_SEARCH:-0}" == "1" ]]; then
nav_args+=("--skip-search")
fi
bash "$SITE_SH" "${nav_args[@]}" 2>&1 | sed 's/^/[html-showcase] /' || \
echo "[html-showcase] ⚠ nav rebuild failed for $site" >&2
if [[ "${NO_HTMLSHOWCASE_SYNC:-0}" == "1" ]]; then
echo "[html-showcase] skip rsync (NO_HTMLSHOWCASE_SYNC=1)"
continue
fi
# Capture the actual push exit code via PIPESTATUS so SSH/rsync
# failures surface to the user. The sed pipe was previously
# swallowing them, leaving "publish succeeded" UX even when bigblack
# rejected the connection.
bash "$SITE_SH" push "$site_path" 2>&1 | sed 's/^/[html-showcase] /'
push_status=${PIPESTATUS[0]}
if [[ "$push_status" -ne 0 ]]; then
echo "[html-showcase] ⚠ push FAILED (exit $push_status) for $site — your local changes still pushed to git, but the live site at bigblack was NOT updated." >&2
echo "[html-showcase] To retry manually: bash $SITE_SH push $site_path" >&2
fi
done
# Always exit 0 — sync failure must never block git push.
exit 0
#!/usr/bin/env bash
# site.sh — publish a static HTML directory to bigblack via Tailscale.
# Modeled on scripts/blob.sh from opendeviationbar-patterns. Adapt freely
# into other repos: copy this file, copy .mise/tasks/site.toml, copy
# scripts/check-orphan-pages.py, and you're done.
#
# Subcommands:
# nav <local-dir> Regenerate site-map.html + auto-nav rail + search index
# search <local-dir> Rebuild Pagefind search index only (no nav rebuild)
# push <local-dir> Build nav + validate + rsync to bigblack:~/sites/<project>/<page>/
# check <local-dir> Build nav + validate only (lychee + orphan-page detector)
# url <local-dir> Print the URL where <local-dir> would publish to
# list List all published projects/pages on bigblack
# unpublish <local-dir> Remove the page from bigblack (asks for confirmation)
#
# URL format:
# https://bigblack.tail0f299b.ts.net:8448/<project>/<page>/
# <project> derived from `git remote get-url origin` basename (or
# $SITE_PROJECT_NAME override)
# <page> basename of <local-dir>
#
# Gate: every push runs lychee + orphan-page check FIRST. If either fails,
# nothing reaches bigblack. The validation is the only gate — there is no
# semantic-release here. Push-side gating, by design.
set -euo pipefail
# Repo detection: prefer git toplevel; fall back to PWD so site.sh works
# in non-git directories too (e.g. a one-off site assembled in /tmp).
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
BIGBLACK_SSH="${SITE_BIGBLACK_SSH:-bigblack}"
BIGBLACK_ROOT="${SITE_BIGBLACK_ROOT:-/home/tca/sites}"
SERVER_BASE_URL="${SITE_BASE_URL:-https://bigblack.tail0f299b.ts.net:8448}"
# Project namespace: from env or git remote
project_name() {
if [[ -n "${SITE_PROJECT_NAME:-}" ]]; then
echo "$SITE_PROJECT_NAME"
return
fi
local remote
remote="$(git -C "$REPO_ROOT" remote get-url origin 2>/dev/null || true)"
if [[ -z "$remote" ]]; then
basename "$REPO_ROOT"
return
fi
basename "$remote" .git
}
# Page namespace: basename of local dir
page_name() {
local local_dir="$1"
basename "$(cd "$local_dir" && pwd)"
}
# Resolve the URL where a local dir publishes to
build_url() {
local local_dir="$1"
local project page
project="$(project_name)"
page="$(page_name "$local_dir")"
echo "$SERVER_BASE_URL/$project/$page/"
}
# Resolve the bigblack remote path
build_remote_path() {
local local_dir="$1"
local project page
project="$(project_name)"
page="$(page_name "$local_dir")"
echo "$BIGBLACK_ROOT/$project/$page"
}
# require <command> [install-hint]
# Aborts with a friendly error + brew/install hint when the command is missing.
require() {
local cmd="$1" hint="${2:-}"
if ! command -v "$cmd" >/dev/null 2>&1; then
echo "✗ missing: $cmd" >&2
[[ -n "$hint" ]] && echo " Install: $hint" >&2
exit 1
fi
}
# Resolve build-nav.py: prefer scripts/build-nav.py in the consuming repo;
# fall back to the canonical copy that ships with this skill so a repo can
# call site.sh before it has copied the script in. Resolution order:
# 1. <repo>/scripts/build-nav.py
# 2. $CLAUDE_PLUGIN_ROOT/skills/page-template/scripts/build-nav.py
# (set automatically by Claude Code when the skill is invoked)
# 3. Canonical install path: ~/.claude/plugins/marketplaces/cc-skills/...
resolve_build_nav() {
if [[ -f "$REPO_ROOT/scripts/build-nav.py" ]]; then
echo "$REPO_ROOT/scripts/build-nav.py"
return
fi
local candidate
for candidate in \
"${CLAUDE_PLUGIN_ROOT:-}/skills/page-template/scripts/build-nav.py" \
"$HOME/.claude/plugins/marketplaces/cc-skills/plugins/html-showcase/skills/page-template/scripts/build-nav.py"
do
if [[ -n "$candidate" && -f "$candidate" ]]; then
echo "$candidate"
return
fi
done
echo ""
}
cmd_nav() {
# Accepts: <local-dir> [--skip-search]
# The --skip-search flag is the wired-up consumer for the
# NO_HTMLSHOWCASE_SEARCH=1 env knob in pre-push.template.
local local_dir=""
local skip_search=0
while [[ $# -gt 0 ]]; do
case "$1" in
--skip-search) skip_search=1; shift ;;
*) local_dir="$1"; shift ;;
esac
done
[[ -n "$local_dir" ]] || { echo "usage: site.sh nav <local-dir> [--skip-search]" >&2; exit 1; }
[[ -d "$local_dir" ]] || { echo "not a directory: $local_dir" >&2; exit 1; }
local build_nav
build_nav="$(resolve_build_nav)"
if [[ -z "$build_nav" ]]; then
echo "✗ build-nav.py not found." >&2
echo " Easiest fix: invoke /html-showcase:setup from Claude Code." >&2
echo " Manual fix: bash \$CLAUDE_PLUGIN_ROOT/skills/page-template/scripts/install.sh" >&2
exit 1
fi
echo "→ regenerating site-map + auto-nav for $local_dir"
python3 "$build_nav" --root "$local_dir"
if [[ $skip_search -eq 1 ]]; then
echo " (skipping search-index rebuild via --skip-search)"
else
# Always re-build the search index too — search is a default
# feature, not an opt-in. cmd_search degrades gracefully if pagefind
# is missing.
cmd_search "$local_dir"
fi
}
# pagefind is fetched separately (Rust single-binary release). Resolution
# order: $PAGEFIND_BIN, then $HOME/.local/bin/pagefind, then PATH lookup.
resolve_pagefind() {
if [[ -n "${PAGEFIND_BIN:-}" && -x "$PAGEFIND_BIN" ]]; then
echo "$PAGEFIND_BIN"
return
fi
if [[ -x "$HOME/.local/bin/pagefind" ]]; then
echo "$HOME/.local/bin/pagefind"
return
fi
command -v pagefind 2>/dev/null || echo ""
}
cmd_search() {
local local_dir="${1:?search <local-dir>}"
[[ -d "$local_dir" ]] || { echo "not a directory: $local_dir" >&2; exit 1; }
local pagefind
pagefind="$(resolve_pagefind)"
if [[ -z "$pagefind" ]]; then
# Search is a default feature, but a missing binary should NOT block
# the publish flow. Warn loudly and continue with whatever index is
# already at <local-dir>/pagefind/ (or none, in which case the search
# box renders inert until the user installs pagefind and re-runs).
echo "⚠ pagefind not found — skipping search-index rebuild." >&2
echo " Install: brew install pagefind" >&2
echo " (or: curl -fsSL https://github.com/CloudCannon/pagefind/releases/latest \\" >&2
echo " | … see https://pagefind.app/docs/installation/ )" >&2
return 0
fi
# Sanity-check the binary actually runs before invoking it. ABI
# mismatches (e.g., user upgraded macOS 13→15, or downloaded the
# arm64 binary on an Intel Mac) would otherwise show up as a buried
# "killed" message swallowed by our grep filter downstream.
if ! "$pagefind" --version >/dev/null 2>&1; then
echo "⚠ pagefind binary at $pagefind failed --version check." >&2
echo " Likely cause: architecture mismatch or corrupted download." >&2
echo " Reinstall: brew reinstall pagefind (or rm $pagefind and re-fetch)" >&2
return 0
fi
echo "→ rebuilding Pagefind search index for $local_dir"
# Pre-clean any stale or half-built index from a previous killed run.
# Without this, a SIGINT mid-pagefind leaves orphan chunks that the
# next run won't auto-overwrite — bigblack would serve a half-broken
# search until the user noticed and intervened.
#
# SYMLINK GUARD: refuse to follow a symlinked pagefind/ — `rm -rf`
# on a symlink-to-directory deletes the TARGET's contents, not the
# link. If a user symlinked pagefind/ to a shared cache outside the
# site root, that cache would be wiped on every nav rebuild. (Caught
# by the round-3 adversarial audit.)
pf_dir="${local_dir%/}/pagefind"
if [[ -L "$pf_dir" ]]; then
echo "✗ $pf_dir is a symlink — refusing to rm -rf (would delete the link's target)." >&2
echo " Resolve manually: replace the symlink with a real directory before running search." >&2
return 1
fi
rm -rf "$pf_dir"
if ! "$pagefind" --site "${local_dir%/}" 2>&1 | grep -E '(Indexed|Finished|error|Error)'; then
: # captured output already piped
fi
# Sanity: pagefind didn't write its UI assets → search will 404.
if [[ ! -f "${local_dir%/}/pagefind/pagefind-ui.js" ]]; then
echo "⚠ pagefind ran but didn't produce pagefind-ui.js — index may be incomplete." >&2
return 1
fi
}
cmd_check() {
local local_dir="${1:?check <local-dir>}"
[[ -d "$local_dir" ]] || { echo "not a directory: $local_dir" >&2; exit 1; }
# Always regenerate nav before validating — sitemap is the SSoT for the
# page graph, and lychee/orphan-check both depend on it being fresh.
cmd_nav "$local_dir"
echo "→ validating $local_dir"
# Lychee link check.
# Disable pipefail for the lychee pipe: a non-zero lychee exit (broken
# links) interacts with `set -e` to abort the whole script BEFORE we
# reach the if-block that prints remediation hints. By dropping
# pipefail just for this pipe, we capture the exit code via
# PIPESTATUS and print our own actionable diagnosis.
require lychee "brew install lychee (or: cargo install lychee)"
local lychee_config="$local_dir/lychee.toml"
local lychee_status=0
set +o pipefail
if [[ -f "$lychee_config" ]]; then
echo " lychee (config: $lychee_config)"
lychee --config "$lychee_config" "$local_dir"/**/*.html 2>&1 | tail -20
else
echo " lychee (no config; using defaults)"
lychee "$local_dir"/**/*.html 2>&1 | tail -20
fi
lychee_status=${PIPESTATUS[0]}
set -o pipefail
if [[ $lychee_status -ne 0 ]]; then
echo "✗ lychee found broken links — aborting" >&2
echo " Review the report above, fix the offending URLs / file paths," >&2
echo " then re-run: scripts/site.sh check $local_dir" >&2
echo " (To allowlist a host, add it to the [exclude] section of $local_dir/lychee.toml.)" >&2
exit 1
fi
# Orphan page detector
if [[ -f "$REPO_ROOT/scripts/check-orphan-pages.py" ]]; then
echo " orphan-page check"
python3 "$REPO_ROOT/scripts/check-orphan-pages.py" "$local_dir"
else
echo " (skipped: scripts/check-orphan-pages.py not found)"
fi
echo "✓ validation passed"
}
cmd_push() {
local local_dir="${1:?push <local-dir>}"
[[ -d "$local_dir" ]] || { echo "not a directory: $local_dir" >&2; exit 1; }
# 1. Validate first — push-side gate
cmd_check "$local_dir"
# 2. Compute provenance values (write the file ONLY after rsync
# succeeds; otherwise a torn rsync would leave a `.published.json`
# claiming the publish completed when bigblack was actually mid-write).
local commit_sha timestamp project page url remote_path source_repo dirty
commit_sha="$(git -C "$REPO_ROOT" rev-parse --short=12 HEAD 2>/dev/null || echo unknown)"
dirty=""
if ! git -C "$REPO_ROOT" diff --quiet -- "$local_dir" 2>/dev/null; then
dirty="-dirty"
fi
timestamp="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
project="$(project_name)"
page="$(page_name "$local_dir")"
url="$(build_url "$local_dir")"
remote_path="$(build_remote_path "$local_dir")"
source_repo="$(git -C "$REPO_ROOT" remote get-url origin 2>/dev/null || echo unknown)"
# 3. Rsync to bigblack — abort on SSH or rsync failure BEFORE stamping
# provenance. A failure here means the live site wasn't updated.
#
# The default $BIGBLACK_SSH (`bigblack`) resolves via Tailscale
# MagicDNS. If Tailscale isn't running, the SSH attempt will fail
# with a name-resolution error after a brief delay. The post-failure
# message below mentions Tailscale explicitly so the user knows
# what's missing rather than chasing SSH-key debug paths.
echo "→ rsync $local_dir/ → $BIGBLACK_SSH:$remote_path/"
# shellcheck disable=SC2029 # intentional client-side expansion of $remote_path
if ! ssh -o ConnectTimeout=15 -o BatchMode=yes "$BIGBLACK_SSH" "mkdir -p '$remote_path'"; then
echo "✗ SSH to $BIGBLACK_SSH failed — site not published." >&2
echo " Common causes:" >&2
echo " • Tailscale not running (this script publishes via MagicDNS)" >&2
echo " • SSH keys not loaded (BatchMode=yes prevents interactive auth)" >&2
echo " • bigblack offline" >&2
echo " Quick diagnose: ssh $BIGBLACK_SSH echo ok" >&2
echo " See references/publishing.md for the bigblack/Tailscale setup." >&2
return 1
fi
if ! rsync -av --delete \
--timeout=30 \
-e "ssh -o ConnectTimeout=15 -o BatchMode=yes" \
--exclude '.git/' \
--exclude '.DS_Store' \
--exclude '*.swp' \
--exclude 'node_modules/' \
--exclude '__pycache__/' \
--exclude '.venv/' \
--exclude '.published.json.tmp.*' \
"$local_dir/" "$BIGBLACK_SSH:$remote_path/"; then
echo "✗ rsync failed — site may be partially updated. Re-run scripts/site.sh push <site> to retry." >&2
return 1
fi
# 4. Stamp provenance only AFTER rsync confirmed success. Atomic via
# tmp + mv so a SIGINT during this step doesn't leave an unparseable
# `.published.json`. Write to local; rsync's already done — the
# next push (or a fresh re-run) will mirror this stamp to bigblack.
local tmpfile
tmpfile=$(mktemp "$local_dir/.published.json.tmp.XXXXXX")
cat > "$tmpfile" <<JSON
{
"project": "$project",
"page": "$page",
"commit": "${commit_sha}${dirty}",
"published_utc": "$timestamp",
"source_repo": "$source_repo",
"url": "$url"
}
JSON
mv -f "$tmpfile" "$local_dir/.published.json"
echo ""
echo "✓ published"
echo " URL: $url"
echo " Path: $BIGBLACK_SSH:$remote_path"
echo " Commit: ${commit_sha}${dirty}"
}
cmd_url() {
local local_dir="${1:?url <local-dir>}"
[[ -d "$local_dir" ]] || { echo "not a directory: $local_dir" >&2; exit 1; }
build_url "$local_dir"
}
cmd_list() {
echo "=== $SERVER_BASE_URL ==="
# Single SSH call: print "<project>/<page>" for each two-level entry under sites root.
# Process substitution avoids SC2095 (ssh swallowing stdin in pipeline).
local last_project=""
# shellcheck disable=SC2029 # intentional client-side expansion of $BIGBLACK_ROOT
while IFS=/ read -r proj page; do
[[ -z "$proj" ]] && continue
if [[ "$proj" != "$last_project" ]]; then
echo ""
echo " /$proj/"
last_project="$proj"
fi
[[ -n "$page" ]] && echo " /$page"
done < <(ssh "$BIGBLACK_SSH" "find '$BIGBLACK_ROOT' -mindepth 1 -maxdepth 2 -type d -printf '%P\n' 2>/dev/null | sort")
}
cmd_unpublish() {
local local_dir="${1:?unpublish <local-dir>}"
[[ -d "$local_dir" ]] || { echo "not a directory: $local_dir" >&2; exit 1; }
local remote_path url
remote_path="$(build_remote_path "$local_dir")"
url="$(build_url "$local_dir")"
echo "About to remove: $BIGBLACK_SSH:$remote_path"
echo "URL that will 404: $url"
read -r -p "Confirm unpublish? (yes/NO) " ans
[[ "$ans" == "yes" ]] || { echo "aborted"; exit 0; }
# shellcheck disable=SC2029 # intentional client-side expansion of $remote_path
ssh "$BIGBLACK_SSH" "rm -rf '$remote_path'"
echo "✓ unpublished"
}
case "${1:-}" in
nav) shift; cmd_nav "$@" ;;
search) shift; cmd_search "$@" ;;
push) shift; cmd_push "$@" ;;
check) shift; cmd_check "$@" ;;
url) shift; cmd_url "$@" ;;
list) cmd_list ;;
unpublish) shift; cmd_unpublish "$@" ;;
*)
cat <<EOF
Usage: $0 <command> [args]
Commands:
nav <local-dir> Regenerate site-map + auto-nav + search index
search <local-dir> Rebuild Pagefind search index only
push <local-dir> Build nav + validate + rsync to bigblack
check <local-dir> Build nav + validate (lychee + orphan-page check)
url <local-dir> Print the URL where <local-dir> publishes to
list List all published pages on bigblack
unpublish <local-dir> Remove the page from bigblack
Environment overrides:
SITE_PROJECT_NAME Project namespace (default: from git remote, or
basename of the working tree when not in a git repo)
SITE_BIGBLACK_SSH SSH alias (default: bigblack)
SITE_BIGBLACK_ROOT Remote root (default: /home/tca/sites)
SITE_BASE_URL Public URL (default: https://bigblack.tail0f299b.ts.net:8448)
CLAUDE_PLUGIN_ROOT Plugin install path (set automatically by Claude
Code when this script is invoked via the skill;
used as a fallback when scripts/build-nav.py is
not present in the consuming repo)
PAGEFIND_BIN Override path to the pagefind binary (default:
~/.local/bin/pagefind, falling back to PATH)
EOF
exit 1 ;;
esac
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ PAGE TITLE }} — {{ SUBJECT }}</title>
<meta name="description" content="{{ ONE-LINE DESCRIPTION }}">
<!--
Two-hierarchy stylesheet pattern:
H1 (kernel) — canonical design system, served from jsDelivr CDN
H2 (overrides) — optional per-page tweaks (colors, density). Tiny file.
Order matters: kernel first, overrides last so cascade lets you win.
-->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/terrylica/cc-skills@main/plugins/html-showcase/assets/showcase.css">
<link rel="stylesheet" href="overrides.css">
<!--
The auto-nav rail's <link> and <script> are injected here by
scripts/build-nav.py — do not hand-edit the lines marked id="auto-nav-css"
and id="auto-nav-js"; they're rewritten on every nav build.
-->
</head>
<body>
<!--
Sitemap-driven navigation rail.
The rail HTML between AUTO-NAV-START and AUTO-NAV-END is generated
by scripts/build-nav.py; do not hand-edit. Re-running the script
after adding/renaming pages refreshes every page's rail in place.
If these markers are missing, build-nav.py inserts them right after
<body>.
-->
<!-- AUTO-NAV-START -->
<!-- AUTO-NAV-END -->
<!-- ============================================================== -->
<!-- 1. HERO -->
<!-- ============================================================== -->
<header class="hero">
<div class="shell hero__inner">
<div class="hero__eyebrow">
<span class="hero__pulse" aria-hidden="true"></span>
<span>{{ EYEBROW LABEL · MAX ~70 chars }}</span>
</div>
<h1 class="hero__title">{{ ONE-LINE HEADLINE }}</h1>
<p class="hero__lede">
{{ 2–3 SENTENCE LEDE THAT SUMMARIZES WHAT THIS PAGE PRESENTS.
Mention the most concrete metric or finding up front. }}
</p>
<div class="hero__cta-row">
<a class="chip chip--solid" href="#section-anchor">{{ PRIMARY CTA }}</a>
<a class="chip chip--ghost" href="#another-anchor">{{ SECONDARY }}</a>
<a class="chip chip--ghost" href="https://example.com" rel="noopener">{{ EXTERNAL ↗ }}</a>
</div>
</div>
</header>
<main class="shell">
<!-- ========================================================== -->
<!-- 2. AT-A-GLANCE METRICS — 3 to 5 cards in a responsive grid -->
<!-- ========================================================== -->
<section class="section" id="at-a-glance" aria-labelledby="metrics-h">
<div class="section-head">
<h2 id="metrics-h">{{ SECTION TITLE }}</h2>
<span class="section-head__hint">{{ DATE RANGE OR PROVENANCE }}</span>
</div>
<div class="metric-grid">
<article class="metric-card">
<p class="metric-card__label">{{ LABEL }}</p>
<p class="metric-card__value">{{ NUMBER }}</p>
<p class="metric-card__hint">{{ ONE-LINE CONTEXT }}</p>
</article>
<article class="metric-card metric-card--accent">
<p class="metric-card__label">{{ LABEL }}</p>
<p class="metric-card__value">{{ NUMBER }}</p>
<p class="metric-card__hint">{{ ONE-LINE CONTEXT }}</p>
</article>
<article class="metric-card metric-card--success">
<p class="metric-card__label">{{ LABEL }}</p>
<p class="metric-card__value">{{ NUMBER }}</p>
<p class="metric-card__hint">{{ ONE-LINE CONTEXT }}</p>
</article>
<article class="metric-card metric-card--warning">
<p class="metric-card__label">{{ LABEL }}</p>
<p class="metric-card__value">{{ NUMBER }}</p>
<p class="metric-card__hint">{{ ONE-LINE CONTEXT }}</p>
</article>
</div>
</section>
<!-- ========================================================== -->
<!-- 3. ITEM QUEUE — many small cards (bugs, items, links, etc.) -->
<!-- ========================================================== -->
<section class="section" id="queue" aria-labelledby="queue-h">
<div class="section-head">
<h2 id="queue-h">{{ SECTION TITLE }}</h2>
<span class="section-head__hint">{{ COUNT OR PROVENANCE }}</span>
</div>
<p class="section-intro">{{ ONE-PARAGRAPH FRAMING (optional, can omit). }}</p>
<div class="bug-grid">
<article class="bug-card bug-card--high">
<div class="bug-card__head">
<a class="bug-card__id" href="https://example.com/123" rel="noopener">#123</a>
<span class="badge badge--high">High</span>
</div>
<p class="bug-card__title">{{ ONE-LINE ROOT CAUSE OR STATUS }}</p>
</article>
<article class="bug-card">
<div class="bug-card__head">
<a class="bug-card__id" href="https://example.com/124" rel="noopener">#124</a>
<span class="badge badge--medium">Medium</span>
</div>
<p class="bug-card__title">{{ ONE-LINE ROOT CAUSE OR STATUS }}</p>
</article>
<article class="bug-card">
<div class="bug-card__head">
<a class="bug-card__id" href="https://example.com/125" rel="noopener">#125</a>
<span class="badge badge--info">Info</span>
</div>
<p class="bug-card__title">{{ ONE-LINE ROOT CAUSE OR STATUS }}</p>
</article>
</div>
</section>
</main>
<!-- ============================================================== -->
<!-- FOOTER · PROVENANCE — every claim should link to its source -->
<!-- ============================================================== -->
<footer class="site-footer">
<div class="shell">
<h2 class="visually-hidden">Provenance</h2>
<div class="site-footer__grid">
<div>
<h3>{{ GROUP A }}</h3>
<ul>
<li><a href="https://example.com/source-1" rel="noopener">{{ SOURCE 1 }}</a></li>
<li><a href="https://example.com/source-2" rel="noopener">{{ SOURCE 2 }}</a></li>
</ul>
</div>
<div>
<h3>{{ GROUP B }}</h3>
<ul>
<li><a href="https://example.com/source-3" rel="noopener">{{ SOURCE 3 }}</a></li>
</ul>
</div>
</div>
<div class="site-footer__legal">
<span>All claims verifiable via the linked artifacts. Lychee-checked, orphan-page-validated.</span>
<span>v1 · {{ YYYY-MM-DD }}</span>
</div>
</div>
</footer>
</body>
</html>
# Lychee link-checker config for the contractor showcase mini-site.
# Run via: mise run site:check
# Docs: https://lychee.cli.rs/
# Only follow http(s) and same-directory relative links
include = ["^https?://", "^/", "^\\."]
# Skip schemes / private hosts that lychee can't validate
exclude = [
"^mailto:",
"^tel:",
"127\\.0\\.0\\.1",
"localhost",
"0\\.0\\.0\\.0",
]
# Network behaviour
max_redirects = 5
timeout = 15
max_concurrency = 4
retry_wait_time = 2
accept = [200, 206, 301, 302, 304]
# GitHub rate-limit etiquette: cache 7d so re-runs are fast and rarely 429
cache = true
max_cache_age = "7d"
# Verbose output for the human; CI can switch via --format json
verbose = "info"
no_progress = false
/* =============================================================================
* overrides.css — per-page customization on top of the canonical kernel.
*
* Copy this file to `overrides.css` next to your `index.html`. It must be
* linked AFTER showcase.css so the cascade lets your overrides win.
*
* The kernel exposes ~30 CSS custom properties; the most useful ones are
* shown below. Set only the ones you need; leave everything else inheriting
* from the kernel.
*
* To find the full list of overridable tokens, view the kernel:
* https://cdn.jsdelivr.net/gh/terrylica/cc-skills@main/plugins/html-showcase/assets/showcase.css
*
* Search inside it for `:root {` — every variable defined there is overridable.
* ============================================================================= */
:root {
/* === DENSITY KNOBS — universal layout multipliers ====================== */
/* --density: 1.00; */ /* baseline; lower = tighter, higher = airier */
/* --font-scale: 1.00; */ /* base font size multiplier */
/* === BRAND ============================================================ */
/* --brand-primary: #2563eb; */
/* --brand-primary-strong: #1d4ed8; */
/* --brand-primary-deep: #1e3a8a; */
/* --brand-accent: #7c3aed; */
/* --brand-accent-deep: #4c1d95; */
/* --brand-amber: #f59e0b; */
/* --brand-emerald: #10b981; */
/* === SURFACE / TEXT (rarely needs overriding) ========================= */
/* --surface-page: #0b1120; */
/* --surface-card: #111c33; */
/* --text-headline: #e2e8f0; */
/* --text-body: #cbd5e1; */
/* --text-muted: #94a3b8; */
/* === FONTS ============================================================ */
/* --font-display: "Inter Tight", "Inter", system-ui, sans-serif; */
/* --font-text: "Inter", system-ui, sans-serif; */
/* --font-mono-stack: "JetBrains Mono", ui-monospace, monospace; */
/* === LAYOUT WIDTH ===================================================== */
/* --shell-max: 1200px; */
}
/* You may also add page-specific component classes here — but resist the urge.
* If a pattern repeats across pages, it belongs in the kernel CSS instead.
*/
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ SECTION TITLE }} — {{ SITE NAME }}</title>
<meta name="description" content="{{ ONE-LINE DESCRIPTION OF THIS SECTION }}">
<!--
Section landing page — sits at <site-root>/<section-slug>/index.html.
Sibling pages live next to this file as *.html. Sections sort
newest-first when slugs match YYYY-MM-DD-<rest>; otherwise A-Z.
-->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/terrylica/cc-skills@main/plugins/html-showcase/assets/showcase.css">
<link rel="stylesheet" href="../overrides.css">
<!-- auto-nav.css/js are injected here by scripts/build-nav.py -->
</head>
<body>
<!-- AUTO-NAV-START -->
<!-- AUTO-NAV-END -->
<header class="hero">
<div class="shell hero__inner">
<div class="hero__eyebrow">
<span class="hero__pulse" aria-hidden="true"></span>
<span>Section · {{ DATE OR LABEL }}</span>
</div>
<h1 class="hero__title">{{ SECTION HEADLINE }}</h1>
<p class="hero__lede">
{{ 1–2 SENTENCES THAT FRAME WHAT THIS SECTION COVERS AND WHY IT EXISTS. }}
</p>
</div>
</header>
<main class="shell">
<!--
Section index = a list of sibling pages. Keep it semantic; the
auto-nav rail also lists them, but the body links here let the
page stand alone (e.g. when shared as a single URL with no rail).
-->
<section class="section">
<div class="section-head">
<h2>Pages in this section</h2>
<span class="section-head__hint">{{ COUNT }} pages</span>
</div>
<ul class="rail-list">
<li><a class="rail-link" href="{{ PAGE-1.html }}">📄 {{ PAGE 1 TITLE }}</a></li>
<li><a class="rail-link" href="{{ PAGE-2.html }}">📄 {{ PAGE 2 TITLE }}</a></li>
</ul>
</section>
</main>
<footer class="site-footer">
<div class="shell">
<div class="site-footer__legal">
<span>Section index · auto-discovered by <code>scripts/build-nav.py</code></span>
<span>{{ YYYY-MM-DD }}</span>
</div>
</div>
</footer>
</body>
</html>