diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3c745cb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,61 @@ +name: CI + +on: + pull_request: + workflow_dispatch: {} + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + # mirrors the build job of deploy.yml (minus the dispatch payload, which + # only exists on releases) so a green check means the deploy build passes + # on the same inputs - keep the two in sync + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + # An empty tag would make the checkout below silently use the default + # branch, so fail loudly instead - same guard as in deploy.yml. + - name: Resolve flashtrace release ref + id: ref + run: | + tag=$(gh release view --repo flashtrace/flashtrace --json tagName --jq .tagName) || true + if [ -z "$tag" ]; then + echo "::error::no release tag: flashtrace/flashtrace has no published release" + exit 1 + fi + echo "tag=$tag" >> "$GITHUB_OUTPUT" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + repository: flashtrace/flashtrace + ref: ${{ steps.ref.outputs.tag }} + path: flashtrace + + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 22 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - run: pnpm build + env: + FLASHTRACE_REF: ${{ steps.ref.outputs.tag }} + + # also exercised here so artifact packaging failures surface in CI, + # not first during a deploy + - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: + path: dist + retention-days: 1 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..f82b503 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,80 @@ +name: Deploy site + +on: + push: + branches: [main] + workflow_dispatch: {} + # fired by flashtrace/flashtrace's release workflow after every release, + # with client_payload.tag = the new release tag + repository_dispatch: + types: [flashtrace-release] + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + # docs are built from the released tool, not its main branch: take the + # tag from the dispatch payload, or fall back to the latest release. + # An empty tag would make the checkout below silently use the default + # branch, so fail loudly instead. + - name: Resolve flashtrace release ref + id: ref + run: | + tag="$RAW_TAG" + if [ -z "$tag" ]; then + tag=$(gh release view --repo flashtrace/flashtrace --json tagName --jq .tagName) || true + fi + if [ -z "$tag" ]; then + echo "::error::no release tag: dispatch payload was empty and flashtrace/flashtrace has no published release" + exit 1 + fi + echo "tag=$tag" >> "$GITHUB_OUTPUT" + env: + # env, not inline ${{ }}: the dispatch payload is untrusted input and + # must reach the shell as data, never as script text + RAW_TAG: ${{ github.event.client_payload.tag }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + repository: flashtrace/flashtrace + ref: ${{ steps.ref.outputs.tag }} + path: flashtrace + + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 22 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - run: pnpm build + env: + FLASHTRACE_REF: ${{ steps.ref.outputs.tag }} + + - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: + path: dist + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deploy.outputs.page_url }} + steps: + - id: deploy + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..06ad4d5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +flashtrace/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..967f23e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,64 @@ +# Agent Guide + +This repository contains the representative and documentation website for [flashtrace](https://github.com/flashtrace/flashtrace), a "lightning-fast, reference-based requirement tracing" suite. +In the following you will be introduced to some helpful structural guidance as well as hard constraints. + +First of all: +Work professionally, remember to use modern day best practices and stay focused. +Feel free to tell a user when their tasks seems unscoped or ambiguous. +Ask refining questions before you start writing. + +## The Repository Structure + +| Folder | Purpose | +|---|---| +| src/ | Source code, used from 'build.mjs' to create 'dist/' | +| public/ | Assets that should be included exactly as they are in the final 'dist/' | +| dist/ | Uncommitted, generated build output | +| flashtrace/ | Uncommitted, gitignored clone of [flashtrace/flashtrace](https://github.com/flashtrace/flashtrace); its 'docs/' are a required build input | +| .github/ | Continuous integration/deployment workflows | + +'build.mjs' renders the tool repo's 'docs/' into the site, so a build needs access to them. +It looks for the docs at `$FLASHTRACE_DOCS`, then './flashtrace/docs', then '../flashtrace/docs' - if none exist, clone the tool repo first: `git clone https://github.com/flashtrace/flashtrace`. + +Keep dev dependencies to a minimum. +Keep (runtime) dependencies to zero. + +## The Commands + +| Cmd | Purpose | +|---|---| +| `pnpm build` | Build the website with 'build.mjs' (which is using 'src/' and 'marked' being the sole dependency) into 'dist/'. | +| `pnpm dev` | Live-rebuilds on changes and serves the website from 'dist/' for local testing. | +| `pnpm clean` | Wipes 'dist/' gracefully. | + +## Your workflow + +Work in small chunks. +Commit regularly on proper (preliminary) results. +Follow conventional commits, that means use the format `: ` for every commit. +You can add a descriptive body too. +Adapt a similar pattern for branch naming. + +We are using merging over Pull Requests from feature-branches. +Every PR is being merged in as a commit; we do not squash the commits nor do we rebase anything directly on top of main without a merge commit. +Remember to have one branch focused on one change. +Suggest to split into multiple if applicable. + +`dist/` stays uncommitted, it should however always be buildable without issues before committing any source. +Note that the output can change without any local changes, as this also depends on `flashtrace/flashtrace`'s version. +On production of this website, this is automatically updated through the `deploy.yml` workflow being dispatched from the release workflow of `flashtrace/flashtrace`. + +We do NOT maintain a `package.json` version here, as this repository is not published as a package anywhere; deployments happen on either a merge into main or automatically on a new `flashtrace/flashtrace` release (to update the docs and versioning). + +## Parallel work with git worktrees + +One task = one branch = one worktree = one session. +All rules apply unchanged inside every worktree. + +- Create worktrees as siblings of the main checkout: `git worktree add ..\flashtrace.github.io-wt\ -b / origin/main` +- Run `pnpm install` in a fresh worktree before building or testing; node_modules is per-worktree (pnpm's store makes this fast). +- The flashtrace clone is not shared into worktrees ('../flashtrace' resolves to the worktree's parent, not the main checkout). Point `FLASHTRACE_DOCS` at the main checkout's clone (e.g. `$env:FLASHTRACE_DOCS = "C:\your\path\to\flashtrace.github.io\flashtrace\docs"`) or clone it next to the worktree before building. +- Branch only from up-to-date `origin/main`. Never commit to `main`, and never check out or modify a branch owned by another worktree. +- Before opening a PR: Verify `pnpm build` passes. +- After your PR merges: `git worktree remove ` and delete the branch. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ce13719 --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +# flashtrace.github.io + +The website of [flashtrace](https://github.com/flashtrace/flashtrace) - a landing page plus +documentation rendered from the tool repository's `docs/`, served by GitHub Pages at +[flashtrace.github.io](https://flashtrace.github.io/). + +Built in the flashtrace spirit: plain static HTML/CSS/JS, no runtime framework, and a single +build-time dependency ([marked](https://github.com/markedjs/marked)). + +## How it works + +- `build.mjs` reads the tool repository's `docs/` (nav order derived from `docs/index.md`), + renders every page through `marked`, rewrites inter-doc links to clean URLs and emits a + fully static site into `dist/`. +- The landing page (`src/landing.mjs`) is hand-written HTML: a CSS-only IDE mock in the hero, + feature grid, curated interactive examples (`src/examples.mjs`, captured from real + `flashtrace` runs) and the install snippets. +- `.github/workflows/deploy.yml` builds and deploys on every push to `main`, on manual + `workflow_dispatch`, and on a `repository_dispatch` of type `flashtrace-release` that the + tool repository fires after each release. Docs are always checked out at the release tag + (from the dispatch payload, or the latest release otherwise) - the site documents released + behavior, not `main`. + +## Local development + +```sh +git clone https://github.com/flashtrace/flashtrace ../flashtrace # docs source, once +pnpm install +pnpm build # emits dist/ +pnpm dev # build + watch + serve on http://localhost:8788 +``` + +The docs directory is resolved in this order: `$FLASHTRACE_DOCS` → `./flashtrace/docs` +(the CI checkout location) → `../flashtrace/docs` (local sibling checkout). Set +`FLASHTRACE_REF` to override the version label rendered in the header and footer. + +`dist/` is never committed; CI builds it fresh on every deploy. + +## License + +[Apache 2.0](LICENSE), like flashtrace itself. diff --git a/build.mjs b/build.mjs new file mode 100644 index 0000000..af8e426 --- /dev/null +++ b/build.mjs @@ -0,0 +1,197 @@ +// Static site generator: renders the flashtrace tool repo's docs/ plus the +// hand-written landing page into dist/. Pure Node + marked, no framework. +import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +import { Marked } from 'marked'; + +import { docShell, esc, GITHUB_URL, highlightTokens, SITE_URL } from './src/layout.mjs'; +import { renderLanding } from './src/landing.mjs'; +import { renderImpressum } from './src/impressum.mjs'; + +const root = path.dirname(fileURLToPath(import.meta.url)); +const dist = path.join(root, 'dist'); + +// --- locate the tool repo's docs (env → CI checkout → local sibling) ------- + +function locateDocs() { + const candidates = [ + process.env.FLASHTRACE_DOCS, + path.join(root, 'flashtrace', 'docs'), + path.join(root, '..', 'flashtrace', 'docs'), + ].filter(Boolean); + for (const c of candidates) if (existsSync(path.join(c, 'index.md'))) return c; + console.error( + 'error: flashtrace docs not found. Set FLASHTRACE_DOCS, or clone the tool repo:\n' + + ' git clone https://github.com/flashtrace/flashtrace ../flashtrace', + ); + process.exit(1); +} + +const docsDir = locateDocs(); + +// --- version: release tag from env, else the tool repo's package.json ------ + +function readVersion() { + const ref = process.env.FLASHTRACE_REF; + if (ref) return ref.startsWith('v') ? ref : `v${ref}`; + try { + const pkg = JSON.parse(readFileSync(path.join(docsDir, '..', 'package.json'), 'utf8')); + if (pkg.version) return `v${pkg.version}`; + } catch { + /* fall through */ + } + return ''; +} + +const resolvedVersion = readVersion(); +const gitRef = resolvedVersion || 'main'; // for links into the tool repo on github.com +const version = resolvedVersion || 'dev'; // display label (header badge, footer note) + +// --- nav order: derived from docs/index.md, the single source of truth ----- + +const indexMd = readFileSync(path.join(docsDir, 'index.md'), 'utf8'); +const specPages = [...indexMd.matchAll(/\[([^\]]+)\]\(docs\/([A-Za-z0-9_-]+)\.md\)/g)].map( + (m) => ({ title: m[1], slug: m[2], file: `${m[2]}.md` }), +); +if (specPages.length === 0) { + console.error('error: no doc links found in docs/index.md - nav derivation failed.'); + process.exit(1); +} + +const pages = [ + { title: 'Usage Guide', slug: 'usage', file: 'USAGE.md' }, + { title: 'Overview', slug: '', file: 'index.md' }, + ...specPages, +]; +const slugByName = new Map(pages.map((p) => [p.file.replace(/\.md$/, ''), p.slug])); + +// --- markdown rendering ------------------------------------------------------ + +function slugify(html) { + return html + .replace(/<[^>]*>/g, '') + .replace(/&[a-z]+;|&#\d+;/gi, '') + .toLowerCase() + .trim() + .replace(/[^\w\- ]/g, '') + .replace(/ /g, '-'); +} + +// Rewrite the tool repo's relative links to the site's clean URLs. index.md +// links as docs/.md; USAGE.md and the spec pages link bare .md - +// both forms are handled. Other repo-relative paths go to github.com. +function rewriteHref(href) { + if (/^(https?:|mailto:|#)/.test(href)) return href; + const clean = href.replace(/^\.\//, ''); + const m = clean.match(/^(?:docs\/)?([A-Za-z0-9_-]+)\.(?:md|markdown)(#.*)?$/); + if (m) { + const [, name, anchor = ''] = m; + if (name === 'index') return `/docs/${anchor}`; + const slug = slugByName.get(name); + if (slug !== undefined) return slug === '' ? `/docs/${anchor}` : `/docs/${slug}/${anchor}`; + } + return `${GITHUB_URL}/blob/${gitRef}/${clean}`; +} + +// Per-page render state (marked renderer hooks close over this). +const state = { toc: [], slugCounts: new Map() }; + +// Docs are trusted first-party input, so we don't sanitize marked's output +// (raw HTML passes through). Revisit before rendering any untrusted markdown here. + +const marked = new Marked({ + gfm: true, + renderer: { + heading({ tokens, depth }) { + const text = this.parser.parseInline(tokens); + let id = slugify(text); + const n = state.slugCounts.get(id) ?? 0; + state.slugCounts.set(id, n + 1); + if (n > 0) id = `${id}-${n}`; + if (depth === 2 || depth === 3) state.toc.push({ id, text, level: depth }); + return `${text}#\n`; + }, + link({ href, title, tokens }) { + const text = this.parser.parseInline(tokens); + const t = title ? ` title="${esc(title)}"` : ''; + const url = rewriteHref(href); + const ext = /^https?:/.test(url) ? ' rel="external"' : ''; + return `${text}`; + }, + code({ text, lang }) { + const cls = lang ? ` class="language-${esc(lang)}"` : ''; + return `
${highlightTokens(esc(text))}
\n`; + }, + codespan({ text }) { + return `${highlightTokens(esc(text))}`; + }, + }, +}); + +function renderDoc(page) { + state.toc = []; + state.slugCounts = new Map(); + const md = readFileSync(path.join(docsDir, page.file), 'utf8'); + const content = marked.parse(md); + const navGroups = [ + { + label: 'Getting started', + items: [{ title: 'Usage Guide', href: '/docs/usage/', current: page.slug === 'usage' }], + }, + { + label: 'Specification', + items: [ + { title: 'Overview', href: '/docs/', current: page.slug === '' }, + ...specPages.map((p) => ({ + title: p.title, + href: `/docs/${p.slug}/`, + current: p.slug === page.slug, + })), + ], + }, + ]; + return docShell({ + title: `${page.title} · flashtrace`, + description: `flashtrace documentation - ${page.title}.`, + path: page.slug ? `/docs/${page.slug}/` : '/docs/', + version, + navGroups, + toc: state.toc, + content, + }); +} + +// --- emit -------------------------------------------------------------------- + +rmSync(dist, { recursive: true, force: true }); +mkdirSync(dist, { recursive: true }); + +writeFileSync(path.join(dist, 'index.html'), renderLanding({ version, gitRef })); + +mkdirSync(path.join(dist, 'impressum'), { recursive: true }); +writeFileSync(path.join(dist, 'impressum', 'index.html'), renderImpressum({ version })); + +for (const page of pages) { + const dir = page.slug ? path.join(dist, 'docs', page.slug) : path.join(dist, 'docs'); + mkdirSync(dir, { recursive: true }); + writeFileSync(path.join(dir, 'index.html'), renderDoc(page)); +} + +const sitePaths = ['/', ...pages.map((p) => (p.slug ? `/docs/${p.slug}/` : '/docs/')), '/impressum/']; +writeFileSync( + path.join(dist, 'sitemap.xml'), + ` + +${sitePaths.map((p) => ` ${SITE_URL}${p}`).join('\n')} + +`, +); + +cpSync(path.join(root, 'public'), dist, { recursive: true }); +cpSync(path.join(root, 'src', 'styles', 'site.css'), path.join(dist, 'site.css')); +cpSync(path.join(root, 'src', 'scripts', 'site.js'), path.join(dist, 'site.js')); + +console.log(`built ${pages.length + 2} pages into dist/ (flashtrace ${version || 'unknown version'})`); diff --git a/package.json b/package.json new file mode 100644 index 0000000..af4419c --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "flashtrace-website", + "private": true, + "type": "module", + "packageManager": "pnpm@11.10.0", + "engines": { + "node": ">=18" + }, + "scripts": { + "build": "node build.mjs", + "dev": "node serve.mjs", + "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"" + }, + "devDependencies": { + "marked": "^18.0.6" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..447fd92 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,24 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + marked: + specifier: ^18.0.6 + version: 18.0.6 + +packages: + + marked@18.0.6: + resolution: {integrity: sha512-MrV5puXBfuiy6wl6DLaq3BtIJQAJToAd5zt/ZKhRfGRAuFPALE7/4Y7jnxRQoEgK/pBgurGqLyAuRgZ2xOjr6w==} + engines: {node: '>= 20'} + hasBin: true + +snapshots: + + marked@18.0.6: {} diff --git a/public/.nojekyll b/public/.nojekyll new file mode 100644 index 0000000..e69de29 diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..8264ebe --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/logo.svg b/public/logo.svg new file mode 100644 index 0000000..374c683 --- /dev/null +++ b/public/logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..96b9e41 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://flashtrace.github.io/sitemap.xml diff --git a/serve.mjs b/serve.mjs new file mode 100644 index 0000000..55fccd7 --- /dev/null +++ b/serve.mjs @@ -0,0 +1,63 @@ +// Tiny dev server: builds once, rebuilds on changes to build inputs, serves +// dist/ with clean URLs. Node stdlib only - not used in CI or production. +import { spawnSync } from 'node:child_process'; +import { existsSync, readFileSync, watch } from 'node:fs'; +import http from 'node:http'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.dirname(fileURLToPath(import.meta.url)); +const dist = path.join(root, 'dist'); +const port = Number(process.env.PORT) || 8788; + +const types = { + '.html': 'text/html; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.ico': 'image/x-icon', + '.json': 'application/json', + '.txt': 'text/plain; charset=utf-8', +}; + +function build() { + const res = spawnSync(process.execPath, [path.join(root, 'build.mjs')], { stdio: 'inherit' }); + if (res.status !== 0) console.error('build failed - still serving the previous output'); +} + +build(); + +let pending = null; +for (const dir of ['src', 'public']) { + watch(path.join(root, dir), { recursive: true }, () => { + clearTimeout(pending); + pending = setTimeout(build, 100); + }); +} +watch(path.join(root, 'build.mjs'), () => { + clearTimeout(pending); + pending = setTimeout(build, 100); +}); + +http + .createServer((req, res) => { + const url = new URL(req.url, `http://localhost:${port}`); + let file = path.join(dist, path.normalize(decodeURIComponent(url.pathname))); + if (!file.startsWith(dist)) { + res.writeHead(403).end('forbidden'); + return; + } + if (url.pathname.endsWith('/')) file = path.join(file, 'index.html'); + else if (!path.extname(file) && existsSync(path.join(file, 'index.html'))) + file = path.join(file, 'index.html'); + if (!existsSync(file)) { + res.writeHead(404, { 'content-type': 'text/plain' }).end('404 not found'); + return; + } + res.writeHead(200, { + 'content-type': types[path.extname(file)] ?? 'application/octet-stream', + }); + res.end(readFileSync(file)); + }) + .listen(port, () => console.log(`serving dist/ on http://localhost:${port}`)); diff --git a/src/examples.mjs b/src/examples.mjs new file mode 100644 index 0000000..d6b1548 --- /dev/null +++ b/src/examples.mjs @@ -0,0 +1,174 @@ +// Curated interactive examples: real inputs and terminal output captured from +// actual `flashtrace` runs (v0.7.0). No in-browser execution - the CLI needs +// node:fs and git, so v1 ships pre-computed, trustworthy captures. +import { esc } from './layout.mjs'; + +// Re-create the CLI's coloring (src/report.mjs in the tool repo) as HTML +// spans on the captured plain-text output. +export function colorizeReport(text) { + let s = esc(text); + // status line: colored mark + bold item ID + s = s.replace(/^([✔✘~]) (\S+)/gm, (m, mark, id) => { + const cls = mark === '✔' ? 't-green' : mark === '✘' ? 't-red' : 't-yellow'; + return `${mark} ${id}`; + }); + s = s.replace(/✘ missing/g, '✘ missing'); + s = s.replace(/\((→ [^)]*)\)/g, '($1)'); + s = s.replace(/(?)✔/g, ''); + s = s.replace(/(?)✘/g, ''); + s = s.replace(/→/g, ''); + s = s.replace(/⚠/g, ''); + s = s.replace(/•/g, ''); + s = s.replace(/\[deep-covered\]/g, '[deep-covered]'); + s = s.replace(/\[shallow-covered\]/g, '[shallow-covered]'); + s = s.replace(/\[defective\]/g, '[defective]'); + s = s.replace(/"[^&]*"/g, (m) => `${m}`); + s = s.replace(/(^|\s)([\w./-]+\.(?:md|markdown|ts|js|mjs|cjs|tsx|py|rb|go|rs|java|cs|sql|lua|html|vue)(?::\d+)?)(?=\s|$)/gm, '$1$2'); + s = s.replace(/^( )(needs|covers|wanted by)( )/gm, '$1$2$3'); + s = s.replace(/^Summary$/m, 'Summary'); + s = s.replace(/^( items +\d+ )(\(.*\))$/m, '$1$2'); + s = s.replace(/^( ok +)(\d+)$/m, '$1$2'); + s = s.replace(/^( defective +)([1-9]\d*)$/m, '$1$2'); + s = s.replace(/^( )(of the ok items.*)$/m, '$1$2'); + s = s.replace(/^ok$/m, 'ok'); + s = s.replace(/^not ok$/m, 'not ok'); + return s; +} + +export const heroTerminal = { + command: 'npx flashtrace', + output: `Summary + items 2 (1 from markdown, 1 from code) + ok 2 + defective 0 + +ok`, +}; + +export const examples = [ + { + id: 'clean', + title: 'A clean, deep-covered trace', + blurb: + 'A requirement needs an implementation at any 2.x revision; the wildcard resolves to impl:login#2.4 and the whole chain is deep-covered.', + command: 'flashtrace -v', + files: [ + { + name: 'spec.md', + body: `## Login + +\`req:login#1\` + +Users can sign in with a session token. + +Needs: impl:login#2.x`, + }, + { + name: 'login.ts', + body: `// [impl:login#2.4] +export function login(token: SessionToken) { + return openSession(token); +}`, + }, + ], + output: `✔ impl:login#2.4 login.ts:1 [deep-covered] + wanted by req:login#1 spec.md:3 + +✔ req:login#1 "Login" spec.md:3 [deep-covered] + needs impl:login#2.x (→ impl:login#2.4) ✔ login.ts:1 + +Summary + items 2 (1 from markdown, 1 from code) + ok 2 + defective 0 + +ok`, + }, + { + id: 'uncovered', + title: 'An uncovered defect', + blurb: + 'The spec needs test:auth/login#2, but only revision 1 exists - flashtrace flags the revision mismatch, and the outdated test as unwanted.', + command: 'flashtrace', + files: [ + { + name: 'spec.md', + body: `## Login requirement + +\`req:auth/login#1\` + +Users must be able to log in with email and password. + +Needs: impl:auth/login#1, test:auth/login#2`, + }, + { + name: 'login.ts', + body: `// [impl:auth/login#1] +export function login(email: string, password: string) { + return session.open(email, password); +} + +// [test:auth/login#1] +test('login opens a session', () => { ... });`, + }, + ], + output: `✘ test:auth/login#1 login.ts:6 + • unwanted: no item needs test:auth/login#1 + +✘ req:auth/login#1 "Login requirement" spec.md:3 + • uncovered: needs test:auth/login#2, which does not exist (revision mismatch: existing revision(s) of test:auth/login: 1) + +Summary + items 3 (1 from markdown, 2 from code) + ok 1 + defective 2 + +not ok`, + }, + { + id: 'forwarding', + title: 'Forwarding a requirement', + blurb: + 'req:login#1 delegates its coverage obligation to the auth design with a --> tag; it is deep-covered exactly when dsn:auth#2 is.', + command: 'flashtrace -v', + files: [ + { + name: 'spec.md', + body: `## Login + +\`req:login#1\` + +Login is specified in detail by the auth design. + +\`[req:login#1 --> dsn:auth#2]\` + +## Auth design + +\`dsn:auth#2\` + +Sessions are opened through the central auth service. + +Needs: impl:auth#1`, + }, + { + name: 'auth.ts', + body: `// [impl:auth#1] +export function openSession(token: SessionToken) { ... }`, + }, + ], + output: `✔ impl:auth#1 auth.ts:1 [deep-covered] + wanted by dsn:auth#2 spec.md:11 + +✔ req:login#1 "Login" spec.md:3 [deep-covered] + → dsn:auth#2 ✔ spec.md:11 +✔ dsn:auth#2 "Auth design" spec.md:11 [deep-covered] + needs impl:auth#1 ✔ auth.ts:1 + +Summary + items 3 (2 from markdown, 1 from code) + ok 3 + defective 0 + +ok`, + }, +]; diff --git a/src/impressum.mjs b/src/impressum.mjs new file mode 100644 index 0000000..d40d5ee --- /dev/null +++ b/src/impressum.mjs @@ -0,0 +1,38 @@ +// Legal notice (Impressum) - a fully static, hand-written page. Required to be +// reachable from every page under German law (§5 DDG); linked from the footer. +import { esc, pageShell } from './layout.mjs'; + +export function renderImpressum({ version }) { + const body = `
+ +
`; + return pageShell({ + title: 'Legal Notice · flashtrace', + description: 'Legal notice (Impressum) for the flashtrace website.', + path: '/impressum/', + version, + active: '', + body, + bodyClass: 'page-legal', + }); +} diff --git a/src/landing.mjs b/src/landing.mjs new file mode 100644 index 0000000..12ccb6f --- /dev/null +++ b/src/landing.mjs @@ -0,0 +1,256 @@ +// The landing page: hero with a CSS-built IDE mock, how-it-works, features, +// curated interactive examples, install snippets. All static HTML. +import { colorizeReport, examples, heroTerminal } from './examples.mjs'; +import { esc, GITHUB_URL, highlightTokens, pageShell } from './layout.mjs'; + +// --- hero IDE mock ----------------------------------------------------------- + +const heroSpecPane = `
## Login requirement
+
+\`req:auth/login#1\`
+
+Users must be able to log in
+with email and password.
+
+Needs: impl:auth/login#1
`; + +const heroCodePane = `
// [impl:auth/login#1]
+export function login(
+  email: string,
+  password: string,
+) {
+  return session.open(
+    email, password);
+}
`; + +function windowDots() { + return ``; +} + +function ideMock() { + return ``; +} + +// --- how it works ------------------------------------------------------------- + +const steps = [ + { + title: 'Write specs in Markdown', + text: 'An item is a line holding only its ID in backticks. The heading above becomes its title, the paragraph below its description and Needs lists the IDs that must cover it.', + snippet: `## Login requirement + +\`req:auth/login#1\` + +Users must be able to log in. + +Needs: impl:auth/login#1`, + file: 'spec.md', + }, + { + title: 'Tag your code', + text: 'Drop the ID into a comment - flashtrace understands the comment syntax of dozens of languages, from C-Like (incl. JS/TS) to SQL to Powershell.', + snippet: `// [impl:auth/login#1] +// [>>utest:auth/login#1] +export function login(token) { + … +}`, + file: 'login.ts', + }, + { + title: 'Run flashtrace', + text: 'One command traces every requirement to its coverage - transitively - and reports what is missing, orphaned, unwanted, outdated or duplicated.', + // captured from a real run over the two files above plus the demanded test + snippet: `$ npx flashtrace + +Summary + items 3 (1 from markdown, 2 from code) + ok 3 + defective 0 + +ok`, + file: 'terminal', + terminal: true, + }, +]; + +function stepSnippet(s) { + if (!s.terminal) return highlightSnippet(s.snippet); + const report = s.snippet.replace(/^\$ .*\n\n/, ''); + return `$ npx flashtrace\n\n${colorizeReport(report)}`; +} + +function howItWorks() { + return `
+

How it works

+
    + ${steps + .map( + (s, i) => `
  1. +
    ${i + 1}

    ${esc(s.title)}

    +

    ${esc(s.text)}

    +
    ${windowDots()}${esc(s.file)}
    ${stepSnippet(s)}
    +
  2. `, + ) + .join('\n ')} +
+
`; +} + +// Token coloring for landing snippets - same rules as the docs build. +const highlightSnippet = (text) => highlightTokens(esc(text)); + +// --- features ----------------------------------------------------------------- + +const features = [ + ['Zero runtime dependencies', 'One self-contained script. Vendor it or install it - nothing else comes along.', null], + ['Runs anywhere', 'Everything Node.js ≥ 18 runs on: your laptop, your CI, your air-gapped build box.', null], + ['Many languages', 'Tags live in ordinary comments - C-family, Python, Ruby, shell, SQL, Lua, HTML, Vue and more.', '/docs/code-tags/'], + ['Deep, transitive coverage', 'An item is only deep-covered when its whole tracing chain is. Broken links show up wherever they hide.', '/docs/coverage-rules/'], + ['Wildcards & forwarding', 'Accept any 2.x revision, or delegate a requirement’s obligation to another item with a forwarding tag.', '/docs/forwarding/'], + ['Git-aware scanning', 'Files ignored by git are excluded automatically - no config to keep in sync.', '/docs/usage/'], + ['CI-friendly', 'Plain-text report, meaningful exit codes: 0 clean, 1 defects found, 2 usage error.', '/docs/command-line/'], +]; + +function featureGrid() { + return `
+

Built to stay out of your way

+
+ ${features + .map( + ([title, text, href]) => `
+

${title}

+

${text}${href ? ` Learn more` : ''}

+
`, + ) + .join('\n ')} +
+
`; +} + +// --- interactive examples ------------------------------------------------------- + +function exampleFiles(ex) { + return `
+ ${ex.files + .map( + (f) => `
${windowDots()}${esc(f.name)}
${highlightSnippet(f.body)}
`, + ) + .join('\n ')} +
`; +} + +function exampleOutput(ex) { + return `
${windowDots()}terminal
$ ${esc(ex.command)}
+${colorizeReport(ex.output)}
`; +} + +function examplesSection() { + const scenarioTabs = examples + .map( + (ex, i) => + ``, + ) + .join('\n '); + const scenarioPanels = examples + .map( + (ex, i) => `
+

${esc(ex.blurb)}

+
+
+ + +
+
${exampleFiles(ex)}
+ +
+
`, + ) + .join('\n '); + return `
+

See it trace

+

Three scenarios, captured from real flashtrace runs. Flip each between its input files and the report it produces.

+
+
+ ${scenarioTabs} +
+ ${scenarioPanels} +
+
`; +} + +// --- install ----------------------------------------------------------------- + +function installSection(version, gitRef) { + // raw file at the release ref, so a vendored download matches the docs shown + const rawScriptUrl = `${GITHUB_URL}/raw/${esc(gitRef)}/dist/flashtrace.mjs`; + return `
+

Install in seconds

+

Two ways in, both ending at the same single file. Current release: ${esc(version)}.

+
+
+

As a dev dependency

+
npm install flashtrace -D
+npx flashtrace
+
+
+

Vendored, zero install

+

Download dist/flashtrace.mjs into your repository and run it directly.

+
node flashtrace.mjs
+
+
+

Node.js ≥ 18 required. Read the Usage Guide for options and details.

+
`; +} + +// --- page --------------------------------------------------------------------- + +export function renderLanding({ version, gitRef }) { + const body = `
+
+
+

Lightning-fast, reference-based requirement tracing

+

flashtrace verifies that every requirement in your Markdown specs is covered by the code and tests it demands - with zero runtime dependencies, anywhere Node.js runs.

+ +
+ ${ideMock()} +
+${howItWorks()} +${featureGrid()} +${examplesSection()} +${installSection(version, gitRef)} +
`; + return pageShell({ + title: 'flashtrace - lightning-fast, reference-based requirement tracing', + description: + 'flashtrace traces requirement coverage between Markdown specs and code comments. Zero runtime dependencies, runs anywhere Node.js ≥ 18 does.', + path: '/', + version, + active: 'home', + body, + bodyClass: 'page-landing', + }); +} diff --git a/src/layout.mjs b/src/layout.mjs new file mode 100644 index 0000000..b97e930 --- /dev/null +++ b/src/layout.mjs @@ -0,0 +1,170 @@ +// Shared page shells: plain template-literal functions, no template engine. + +export const SITE_URL = 'https://flashtrace.github.io'; +export const GITHUB_URL = 'https://github.com/flashtrace/flashtrace'; +export const DISCUSSIONS_URL = 'https://github.com/flashtrace/flashtrace/discussions'; + +export function esc(s) { + return String(s) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); +} + +// Wrap flashtrace's own syntax (IDs, [tags], >>, -->) in spans; input must be +// HTML-escaped already. Generic code stays uncolored. +// +// Deliberately language-agnostic: it runs on every fenced block and codespan +// regardless of its info string, because flashtrace tokens appear inside +// blocks of any language (md, ts, sql, plain trace output, ...). The cost is +// that an unrelated string shaped like an ID (foo:bar#1) in, say, a bash or +// json block also gets colored - acceptable for these docs, where anything +// ID-shaped in a code block is in practice a flashtrace reference. +export function highlightTokens(escaped) { + return escaped.replace( + /(-->)|(>>)|([A-Za-z]+:[A-Za-z0-9_/.-]*#[0-9xyz]+(?:\.[0-9xyz]+){0,2})|(^ *(?:Needs|Covers|Tags):)/gm, + (m, fwd, need, id, kw) => { + if (fwd || need) return `${m}`; + if (id) return `${m}`; + return `${kw}`; + }, + ); +} + +const themeInit = `(function(){try{var t=localStorage.getItem('ft-theme');if(t!=='light'&&t!=='dark'){t=matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';}document.documentElement.dataset.theme=t;}catch(e){document.documentElement.dataset.theme='light';}})();`; + +const sunIcon = ``; +const moonIcon = ``; +const githubIcon = ``; +const menuIcon = ``; + +function topBar({ version, active, withSidebar }) { + const link = (href, label, key) => + `${label}`; + return `
+
+ ${withSidebar ? `` : ''} + + + flashtrace + + ${esc(version)} + +
+ ${githubIcon} + +
+
+
`; +} + +function footer({ version }) { + return ``; +} + +// Full HTML document. `body` is everything between top bar and footer. +export function pageShell({ title, description, path, version, active, body, bodyClass = '', withSidebar = false }) { + return ` + + + + +${esc(title)} + + + + + + + + +${topBar({ version, active, withSidebar })} +${body} +${footer({ version })} + + + +`; +} + +// Sidebar nav: `groups` is [{ label, items: [{ title, href, current }] }]. +function sidebar(groups) { + const groupHtml = groups + .map( + (g) => ``, + ) + .join('\n'); + return ` +`; +} + +// Right-rail "On this page" TOC from [{ id, text, level }] (h2/h3). +function tocRail(toc) { + if (!toc.length) return ''; + return ``; +} + +// Docs shell: top bar, left sidebar, centered article, right TOC rail. +export function docShell({ title, description, path, version, navGroups, toc, content }) { + const body = `
+${sidebar(navGroups)} +
+
+${content} +
+
+${tocRail(toc)} +
`; + return pageShell({ + title, + description, + path, + version, + active: 'docs', + body, + bodyClass: 'page-docs', + withSidebar: true, + }); +} diff --git a/src/scripts/site.js b/src/scripts/site.js new file mode 100644 index 0000000..52f91cb --- /dev/null +++ b/src/scripts/site.js @@ -0,0 +1,135 @@ +// flashtrace website - vanilla JS enhancements. Everything degrades gracefully. +(function () { + 'use strict'; + + // --- theme toggle (initial theme is set inline in ) --- + document.querySelectorAll('.theme-toggle').forEach(function (btn) { + btn.addEventListener('click', function () { + var next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'; + document.documentElement.dataset.theme = next; + try { + localStorage.setItem('ft-theme', next); + } catch (e) { + /* private mode etc. - theme just won't persist */ + } + }); + }); + + // --- mobile sidebar --- + var sidebar = document.getElementById('sidebar'); + var toggle = document.querySelector('.sidebar-toggle'); + var backdrop = document.querySelector('.sidebar-backdrop'); + function setSidebar(open) { + if (!sidebar) return; + sidebar.classList.toggle('is-open', open); + if (toggle) toggle.setAttribute('aria-expanded', String(open)); + if (backdrop) backdrop.hidden = !open; + } + if (toggle) { + toggle.addEventListener('click', function () { + setSidebar(!sidebar.classList.contains('is-open')); + }); + } + if (backdrop) { + backdrop.addEventListener('click', function () { + setSidebar(false); + }); + } + document.addEventListener('keydown', function (e) { + if (e.key === 'Escape') setSidebar(false); + }); + + // --- tabs (scenario switcher + input/output toggles) --- + document.querySelectorAll('[data-tabs]').forEach(function (container) { + var tablist = container.querySelector(':scope > [role="tablist"]'); + if (!tablist) return; + var tabs = Array.prototype.slice.call(tablist.querySelectorAll('[role="tab"]')); + function select(tab) { + tabs.forEach(function (t) { + var on = t === tab; + t.setAttribute('aria-selected', String(on)); + t.tabIndex = on ? 0 : -1; + var panel = document.getElementById(t.getAttribute('aria-controls')); + if (panel) panel.hidden = !on; + }); + } + tabs.forEach(function (tab, i) { + tab.addEventListener('click', function () { + select(tab); + }); + tab.addEventListener('keydown', function (e) { + var dir = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0; + if (!dir) return; + e.preventDefault(); + var next = tabs[(i + dir + tabs.length) % tabs.length]; + next.focus(); + select(next); + }); + }); + }); + + // --- copy buttons --- + function wireCopy(btn, getText) { + if (!navigator.clipboard) { + // non-secure context: no Clipboard API, so hide the control entirely + btn.hidden = true; + return; + } + btn.addEventListener('click', function () { + navigator.clipboard.writeText(getText()).then(function () { + btn.classList.add('is-copied'); + var label = btn.textContent; + btn.textContent = 'Copied'; + setTimeout(function () { + btn.classList.remove('is-copied'); + btn.textContent = label; + }, 1600); + }); + }); + } + document.querySelectorAll('.copy-btn[data-copy]').forEach(function (btn) { + wireCopy(btn, function () { + return btn.getAttribute('data-copy'); + }); + }); + // add a copy button to every docs code block + document.querySelectorAll('.doc-content pre').forEach(function (pre) { + var code = pre.querySelector('code'); + if (!code || !navigator.clipboard) return; + var btn = document.createElement('button'); + btn.className = 'copy-btn'; + btn.type = 'button'; + btn.textContent = 'Copy'; + btn.setAttribute('aria-label', 'Copy code'); + wireCopy(btn, function () { + return code.textContent; + }); + pre.appendChild(btn); + }); + + // --- right-rail TOC scrollspy --- + var tocLinks = document.querySelectorAll('.toc a[href^="#"]'); + if (tocLinks.length && 'IntersectionObserver' in window) { + var byId = {}; + tocLinks.forEach(function (a) { + byId[a.getAttribute('href').slice(1)] = a; + }); + var current = null; + var observer = new IntersectionObserver( + function (entries) { + entries.forEach(function (entry) { + if (!entry.isIntersecting) return; + var link = byId[entry.target.id]; + if (!link) return; + if (current) current.classList.remove('is-active'); + link.classList.add('is-active'); + current = link; + }); + }, + { rootMargin: '-56px 0px -70% 0px', threshold: 0 }, + ); + document.querySelectorAll('.doc-content h2[id], .doc-content h3[id]').forEach(function (h) { + observer.observe(h); + }); + } +})(); diff --git a/src/styles/site.css b/src/styles/site.css new file mode 100644 index 0000000..dfc08cf --- /dev/null +++ b/src/styles/site.css @@ -0,0 +1,1081 @@ +/* flashtrace website - single stylesheet, light + dark via [data-theme]. */ + +/* ---------- tokens ---------- */ + +:root { + --bg: #ffffff; + --bg-soft: #f7f6f3; + --bg-raised: #ffffff; + --text: #1c1917; + --muted: #57534e; + --border: #e7e5e4; + --accent: #d97d0e; + --accent-strong: #b45309; + --accent-soft: #fdf3e3; + --link: #b45309; + --code-bg: #f7f6f3; + --code-text: #292524; + --tk-id: #b45309; + --tk-kw: #92400e; + --tk-arrow: #0e7490; + --shadow: 0 1px 3px rgb(0 0 0 / 0.08), 0 8px 24px rgb(0 0 0 / 0.06); + --topbar-h: 3.5rem; + color-scheme: light; +} + +[data-theme='dark'] { + --bg: #151312; + --bg-soft: #1e1b19; + --bg-raised: #201d1b; + --text: #e7e5e4; + --muted: #a8a29e; + --border: #2e2a27; + --accent: #f9c21d; + --accent-strong: #fbbf24; + --accent-soft: #2c2317; + --link: #fbbf24; + --code-bg: #1e1b19; + --code-text: #d6d3d1; + --tk-id: #f9c21d; + --tk-kw: #e8964a; + --tk-arrow: #56b6c2; + --shadow: 0 1px 3px rgb(0 0 0 / 0.5), 0 8px 24px rgb(0 0 0 / 0.35); + color-scheme: dark; +} + +/* no-JS fallback: follow the OS preference */ +@media (prefers-color-scheme: dark) { + :root:not([data-theme]) { + --bg: #151312; + --bg-soft: #1e1b19; + --bg-raised: #201d1b; + --text: #e7e5e4; + --muted: #a8a29e; + --border: #2e2a27; + --accent: #f9c21d; + --accent-strong: #fbbf24; + --accent-soft: #2c2317; + --link: #fbbf24; + --code-bg: #1e1b19; + --code-text: #d6d3d1; + --tk-id: #f9c21d; + --tk-kw: #e8964a; + --tk-arrow: #56b6c2; + color-scheme: dark; + } +} + +[data-theme='dark'] .only-light, +:root:not([data-theme='dark']) .only-dark { + display: none; +} + +/* ---------- base ---------- */ + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + scroll-padding-top: calc(var(--topbar-h) + 1rem); +} + +body { + margin: 0; + overflow-x: clip; + font-family: ui-sans-serif, system-ui, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; + font-size: 16px; + line-height: 1.65; + color: var(--text); + background: var(--bg); + -webkit-text-size-adjust: 100%; +} + +code, +pre, +kbd { + font-family: ui-monospace, 'Cascadia Code', 'Segoe UI Mono', Menlo, Consolas, monospace; + font-size: 0.9em; +} + +a { + color: var(--link); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +img { + max-width: 100%; +} + +h1, +h2, +h3, +h4 { + line-height: 1.25; + font-weight: 650; +} + +button { + font: inherit; + color: inherit; +} + +.skip-link { + position: absolute; + left: -999px; + top: 0; + z-index: 100; + padding: 0.5rem 1rem; + background: var(--accent); + color: #1e1e1e; + border-radius: 0 0 6px 0; +} + +.skip-link:focus { + left: 0; +} + +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + +/* ---------- top bar ---------- */ + +.topbar { + position: sticky; + top: 0; + z-index: 40; + height: var(--topbar-h); + background: color-mix(in srgb, var(--bg) 88%, transparent); + backdrop-filter: blur(8px); + border-bottom: 1px solid var(--border); +} + +.topbar-inner { + max-width: 80rem; + height: 100%; + margin: 0 auto; + padding: 0 1rem; + display: flex; + align-items: center; + gap: 0.75rem; +} + +.brand { + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--text); + font-weight: 700; +} + +.brand:hover { + text-decoration: none; +} + +.version-badge { + font-size: 0.75rem; + font-family: ui-monospace, Consolas, monospace; + padding: 0.1rem 0.5rem; + border: 1px solid var(--border); + border-radius: 999px; + color: var(--muted); + background: var(--bg-soft); +} + +.topbar-nav { + display: flex; + gap: 0.25rem; + margin-left: 0.5rem; +} + +.topbar-link { + padding: 0.35rem 0.75rem; + border-radius: 6px; + color: var(--muted); + font-size: 0.95rem; +} + +.topbar-link:hover { + color: var(--text); + background: var(--bg-soft); + text-decoration: none; +} + +.topbar-link.is-active { + color: var(--text); + font-weight: 600; +} + +.topbar-actions { + margin-left: auto; + display: flex; + align-items: center; + gap: 0.25rem; +} + +.icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.25rem; + height: 2.25rem; + border: none; + border-radius: 8px; + background: transparent; + color: var(--muted); + cursor: pointer; +} + +.icon-btn:hover { + color: var(--text); + background: var(--bg-soft); +} + +.sidebar-toggle { + display: none; +} + +/* ---------- footer ---------- */ + +.footer { + border-top: 1px solid var(--border); + margin-top: 4rem; + background: var(--bg-soft); +} + +.footer-inner { + max-width: 80rem; + margin: 0 auto; + padding: 2rem 1rem; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 1rem 2rem; + font-size: 0.9rem; + color: var(--muted); +} + +.footer-brand { + display: flex; + align-items: center; + gap: 0.5rem; + font-weight: 650; + color: var(--text); +} + +.footer-links { + display: flex; + gap: 1.25rem; +} + +.footer-links a { + color: var(--muted); +} + +.footer-links a:hover { + color: var(--text); +} + +.footer-note { + margin: 0; + margin-left: auto; +} + +/* ---------- landing ---------- */ + +.page-landing main { + display: block; +} + +.hero { + max-width: 80rem; + margin: 0 auto; + padding: 4rem 1rem 3rem; + display: grid; + grid-template-columns: minmax(0, 5fr) minmax(0, 6fr); + gap: 3rem; + align-items: center; +} + +.hero-copy h1 { + font-size: clamp(2rem, 4.5vw, 2.9rem); + margin: 0 0 1rem; + letter-spacing: -0.02em; + background: linear-gradient(120deg, var(--text) 55%, var(--accent-strong)); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; +} + +.hero-sub { + color: var(--muted); + font-size: 1.1rem; + margin: 0 0 1.75rem; +} + +.hero-ctas { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.btn { + display: inline-flex; + align-items: center; + padding: 0.6rem 1.25rem; + border-radius: 8px; + font-weight: 600; + font-size: 0.95rem; +} + +.btn:hover { + text-decoration: none; +} + +.btn-primary { + background: var(--accent); + color: #1e1e1e; +} + +.btn-primary:hover { + filter: brightness(1.06); +} + +.btn-secondary { + border: 1px solid var(--border); + color: var(--text); + background: var(--bg-raised); +} + +.btn-secondary:hover { + background: var(--bg-soft); +} + +/* sections */ + +.section { + max-width: 80rem; + margin: 0 auto; + padding: 3.5rem 1rem 0; +} + +.section > h2 { + font-size: clamp(1.5rem, 3vw, 2rem); + margin: 0 0 0.5rem; + letter-spacing: -0.01em; +} + +.section-sub { + color: var(--muted); + margin: 0 0 1.75rem; + max-width: 46rem; +} + +/* editor windows (hero + snippets), always dark like a real editor */ + +.ide-mock, +.mini-window { + --code-bg: #1e1e1e; + --code-text: #d4d4d4; + --tk-id: #f9c21d; + --tk-kw: #e8964a; + --tk-arrow: #56b6c2; + border-radius: 10px; + overflow: hidden; + border: 1px solid #33302c; + background: #1e1e1e; + box-shadow: var(--shadow); + color: #d4d4d4; +} + +.dots { + display: inline-flex; + gap: 5px; + margin-right: 0.6rem; +} + +.dots i { + width: 10px; + height: 10px; + border-radius: 50%; + background: #ff5f57; +} + +.dots i:nth-child(2) { + background: #febc2e; +} + +.dots i:nth-child(3) { + background: #28c840; +} + +.ide-chrome, +.mini-bar, +.term-bar { + display: flex; + align-items: center; + padding: 0.5rem 0.75rem; + background: #161616; + border-bottom: 1px solid #33302c; + font-size: 0.75rem; + color: #8b8b8b; + font-family: ui-monospace, Consolas, monospace; +} + +.ide-title { + color: #8b8b8b; +} + +.ide-panes { + display: grid; + grid-template-columns: 1fr 1fr; + align-items: stretch; +} + +.ide-pane { + min-width: 0; +} + +.ide-pane + .ide-pane { + border-left: 1px solid #33302c; +} + +.pane-tab { + display: inline-block; + padding: 0.35rem 1rem; + font-size: 0.75rem; + font-family: ui-monospace, Consolas, monospace; + color: #d4d4d4; + background: #1e1e1e; + border-right: 1px solid #33302c; + border-bottom: 2px solid var(--accent); +} + +.ide-pane .code { + border-top: 1px solid #33302c; +} + +.code { + margin: 0; + padding: 0.9rem 1rem; + background: var(--code-bg); + color: var(--code-text); + font-size: 0.8rem; + line-height: 1.6; + overflow-x: auto; +} + +[data-trace] { + outline: 1px solid color-mix(in srgb, var(--accent) 65%, transparent); + outline-offset: 2px; + border-radius: 2px; + background: rgb(249 194 29 / 0.08); +} + +.ide-term .term-bar { + border-top: 1px solid #33302c; +} + +.term-body { + margin: 0; + padding: 0.9rem 1rem; + background: #141414; + color: #d4d4d4; + font-size: 0.8rem; + line-height: 1.55; + overflow-x: auto; +} + +/* editor token colors */ + +.cmt { + color: #6a9955; +} + +.kw { + color: #c586c0; +} + +.ty { + color: #4ec9b0; +} + +.md-h { + color: #569cd6; + font-weight: 600; +} + +.tk-id { + color: var(--tk-id); +} + +.tk-kw { + color: var(--tk-kw); + font-weight: 600; +} + +.tk-arrow { + color: var(--tk-arrow); + font-weight: 600; +} + +/* terminal report colors */ + +.t-green { + color: #3fb950; +} + +.t-red { + color: #f85149; +} + +.t-yellow { + color: #d29922; +} + +.t-cyan { + color: #56b6c2; +} + +.t-dim { + color: #8b8b8b; +} + +.t-bold { + font-weight: 700; +} + +/* how it works */ + +.steps { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1.5rem; +} + +/* grid/flex items must not let long code lines widen the page */ +.step, +.feature, +.install-card, +.hero > *, +.ex-files > .mini-window, +[role='tabpanel'] { + min-width: 0; +} + +.step-head { + display: flex; + align-items: center; + gap: 0.6rem; + margin-bottom: 0.25rem; +} + +.step-head h3 { + margin: 0; + font-size: 1.05rem; +} + +.step-no { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.6rem; + height: 1.6rem; + border-radius: 50%; + background: var(--accent-soft); + color: var(--accent-strong); + font-weight: 700; + font-size: 0.85rem; + flex: none; +} + +.step p { + color: var(--muted); + font-size: 0.92rem; + margin: 0 0 0.9rem; + min-height: 4.5em; +} + +/* features */ + +.feature-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); + gap: 1rem; +} + +.feature { + border: 1px solid var(--border); + border-radius: 10px; + padding: 1.1rem 1.25rem; + background: var(--bg-raised); +} + +.feature h3 { + margin: 0 0 0.35rem; + font-size: 1rem; +} + +.feature p { + margin: 0; + color: var(--muted); + font-size: 0.9rem; +} + +/* examples */ + +[role='tablist'] { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} + +[role='tab'] { + border: 1px solid var(--border); + background: var(--bg-raised); + color: var(--muted); + border-radius: 999px; + padding: 0.4rem 1rem; + font-size: 0.9rem; + cursor: pointer; +} + +[role='tab']:hover { + color: var(--text); +} + +[role='tab'][aria-selected='true'] { + background: var(--accent-soft); + border-color: var(--accent); + color: var(--accent-strong); + font-weight: 600; +} + +.example-tabs > [role='tabpanel'] { + margin-top: 1.25rem; +} + +.ex-blurb { + color: var(--muted); + max-width: 46rem; + margin: 0 0 1rem; +} + +.io-tabs [role='tablist'] { + margin-bottom: 0.9rem; +} + +.io-tabs [role='tab'] { + border-radius: 6px; + padding: 0.3rem 0.9rem; + font-size: 0.85rem; +} + +.ex-files { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr)); + gap: 1rem; +} + +/* install */ + +.install-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr)); + gap: 1rem; +} + +.install-card { + border: 1px solid var(--border); + border-radius: 10px; + padding: 1.25rem; + background: var(--bg-raised); +} + +.install-card h3 { + margin: 0 0 0.75rem; + font-size: 1rem; +} + +.install-note { + color: var(--muted); + font-size: 0.9rem; + margin: 0.9rem 0 0.75rem; +} + +.cmd-block { + position: relative; + border-radius: 8px; + background: #1e1e1e; + border: 1px solid #33302c; +} + +.cmd-block pre { + margin: 0; + padding: 0.8rem 5rem 0.8rem 1rem; + color: #d4d4d4; + font-size: 0.85rem; + overflow-x: auto; +} + +.copy-btn { + position: absolute; + top: 0.5rem; + right: 0.5rem; + border: 1px solid #44403c; + background: #2a2724; + color: #d4d4d4; + border-radius: 6px; + padding: 0.2rem 0.6rem; + font-size: 0.75rem; + cursor: pointer; +} + +.copy-btn:hover { + border-color: var(--accent); +} + +.copy-btn.is-copied { + color: #3fb950; + border-color: #3fb950; +} + +/* ---------- docs ---------- */ + +.doc-layout { + max-width: 90rem; + margin: 0 auto; + display: grid; + grid-template-columns: 16rem minmax(0, 1fr) 14rem; + gap: 2rem; + padding: 0 1rem; +} + +.sidebar { + position: sticky; + top: var(--topbar-h); + align-self: start; + max-height: calc(100vh - var(--topbar-h)); + overflow-y: auto; + padding: 1.5rem 0.5rem 2rem 0; + border-right: 1px solid var(--border); +} + +.nav-group + .nav-group { + margin-top: 1.5rem; +} + +.nav-group-label { + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted); + margin: 0 0 0.4rem 0.75rem; +} + +.nav-group ul { + list-style: none; + margin: 0; + padding: 0; +} + +.nav-group a { + display: block; + padding: 0.32rem 0.75rem; + border-radius: 6px; + color: var(--muted); + font-size: 0.92rem; +} + +.nav-group a:hover { + color: var(--text); + background: var(--bg-soft); + text-decoration: none; +} + +.nav-group a.is-current { + color: var(--accent-strong); + background: var(--accent-soft); + font-weight: 600; +} + +.sidebar-backdrop { + display: none; +} + +.doc-main { + padding: 2rem 0 3rem; + min-width: 0; +} + +.doc-content { + max-width: 46rem; +} + +.doc-content h1 { + font-size: 2rem; + margin: 0 0 1rem; + letter-spacing: -0.01em; +} + +.doc-content h2 { + font-size: 1.4rem; + margin: 2.25rem 0 0.75rem; + padding-top: 0.5rem; +} + +.doc-content h3 { + font-size: 1.1rem; + margin: 1.75rem 0 0.5rem; +} + +.heading-anchor { + margin-left: 0.4rem; + color: var(--muted); + opacity: 0; + font-weight: 400; +} + +h1:hover .heading-anchor, +h2:hover .heading-anchor, +h3:hover .heading-anchor, +.heading-anchor:focus-visible { + opacity: 1; +} + +.doc-content p, +.doc-content li { + color: var(--text); +} + +.doc-content li + li { + margin-top: 0.25rem; +} + +.doc-content pre { + position: relative; + margin: 1rem 0; + padding: 0.9rem 1rem; + border-radius: 8px; + background: var(--code-bg); + border: 1px solid var(--border); + overflow-x: auto; + font-size: 0.85rem; + line-height: 1.6; + color: var(--code-text); +} + +.doc-content :not(pre) > code { + background: var(--code-bg); + border: 1px solid var(--border); + border-radius: 4px; + padding: 0.08em 0.35em; + font-size: 0.85em; +} + +.doc-content table { + border-collapse: collapse; + display: block; + overflow-x: auto; + max-width: 100%; + margin: 1rem 0; + font-size: 0.9rem; +} + +.doc-content th, +.doc-content td { + border: 1px solid var(--border); + padding: 0.45rem 0.75rem; + text-align: left; + vertical-align: top; +} + +.doc-content th { + background: var(--bg-soft); +} + +.doc-content blockquote { + margin: 1rem 0; + padding: 0.25rem 1rem; + border-left: 3px solid var(--accent); + background: var(--bg-soft); + color: var(--muted); +} + +/* reveal-on-hover only where hover exists; touch devices get a + permanently visible button, as there is no way to discover it otherwise */ +@media (hover: hover) { + .doc-content pre .copy-btn { + opacity: 0; + } + + .doc-content pre:hover .copy-btn, + .doc-content pre .copy-btn:focus-visible { + opacity: 1; + } +} + +/* right rail */ + +.toc { + position: sticky; + top: var(--topbar-h); + align-self: start; + max-height: calc(100vh - var(--topbar-h)); + overflow-y: auto; + padding: 2rem 0 2rem 0.5rem; + font-size: 0.85rem; +} + +.toc-label { + font-weight: 700; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted); + margin: 0 0 0.5rem; +} + +.toc ul { + list-style: none; + margin: 0; + padding: 0; + border-left: 1px solid var(--border); +} + +.toc li a { + display: block; + padding: 0.2rem 0 0.2rem 0.75rem; + color: var(--muted); + border-left: 2px solid transparent; + margin-left: -1.5px; +} + +.toc li.toc-l3 a { + padding-left: 1.5rem; +} + +.toc li a:hover { + color: var(--text); + text-decoration: none; +} + +.toc li a.is-active { + color: var(--accent-strong); + border-left-color: var(--accent); +} + +/* ---------- legal notice ---------- */ + +.page-legal .section { + padding-bottom: 3.5rem; +} + +.legal address { + font-style: normal; + line-height: 1.7; + color: var(--text); +} + +/* ---------- responsive ---------- */ + +@media (max-width: 1100px) { + .toc { + display: none; + } + + .doc-layout { + grid-template-columns: 15rem minmax(0, 1fr); + } +} + +@media (max-width: 960px) { + .hero { + grid-template-columns: 1fr; + padding-top: 2.5rem; + } + + .steps { + grid-template-columns: 1fr; + } + + .step p { + min-height: 0; + } +} + +@media (max-width: 860px) { + .doc-layout { + grid-template-columns: minmax(0, 1fr); + } + + .sidebar-toggle { + display: inline-flex; + } + + .sidebar { + position: fixed; + inset: var(--topbar-h) auto 0 0; + z-index: 50; + width: min(18rem, 85vw); + background: var(--bg); + border-right: 1px solid var(--border); + padding: 1.25rem 1rem 2rem; + transform: translateX(-105%); + transition: transform 0.2s ease; + } + + .sidebar.is-open { + transform: translateX(0); + box-shadow: var(--shadow); + } + + .sidebar-backdrop { + display: block; + position: fixed; + inset: var(--topbar-h) 0 0 0; + z-index: 45; + background: rgb(0 0 0 / 0.4); + } + + .sidebar-backdrop[hidden] { + display: none; + } +} + +@media (max-width: 700px) { + .ide-panes { + grid-template-columns: 1fr; + } + + .ide-pane + .ide-pane { + border-left: none; + border-top: 1px solid #33302c; + } + + .topbar-nav .topbar-link[href='/'] { + display: none; + } +}