Feat: documentation, UI/UX

This commit is contained in:
2026-03-01 19:09:52 +01:00
parent ecb559e556
commit db44f9b98e
57 changed files with 1531 additions and 615 deletions

305
website/public/docs.css Normal file
View File

@@ -0,0 +1,305 @@
body:has(.docs-layout) {
max-width: 100vw;
padding: 1rem 2rem;
overflow: hidden;
height: 100vh;
}
.docs-layout {
display: flex;
gap: 2rem;
height: calc(100vh - 3.5rem);
}
.docs-sidebar {
width: 200px;
flex-shrink: 0;
overflow-y: auto;
}
.docs-sidebar h3 {
font-size: 0.8rem;
color: var(--text-muted);
margin: 1.25rem 0 0.25rem;
padding: 0;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.docs-sidebar h3:first-child {
margin-top: 0;
}
.docs-sidebar button {
display: block;
width: 100%;
text-align: left;
font-family: 'VCR OSD Mono', monospace;
font-size: 0.8rem;
background: none;
border: none;
border-left: 2px solid transparent;
color: var(--text-dim);
padding: 0.2rem 0.5rem;
cursor: pointer;
}
.docs-sidebar button:hover {
color: var(--text);
}
.docs-sidebar button.active {
color: var(--text);
border-left-color: var(--text);
}
.docs-content {
flex: 1;
min-width: 0;
max-width: none;
overflow-wrap: break-word;
overflow-y: auto;
}
.docs-content article {
display: none;
}
.docs-content article.visible {
display: block;
}
.docs-content h1 {
font-size: 1.3rem;
margin: 0 0 1rem;
border-bottom: 1px solid var(--text-muted);
padding-bottom: 0.5rem;
}
.docs-content h2,
.docs-content h3,
.docs-content h4,
.docs-content h5 {
background: var(--surface);
margin: 1.5rem 0 0.5rem;
padding: 0.3rem 0.5rem;
}
.docs-content h2 {
font-size: 1.1rem;
}
.docs-content h3 {
font-size: 1rem;
}
.docs-content h4 {
font-size: 0.95rem;
}
.docs-content h5 {
font-size: 0.9rem;
}
.docs-content p {
margin: 0.5rem 0;
text-align: left;
line-height: 1.5;
}
.docs-content pre {
background: var(--surface);
border-left: 2px solid var(--text-muted);
padding: 0.75rem 1rem;
margin: 1rem 0;
overflow-x: auto;
font-size: 0.85em;
line-height: 1.4;
}
.docs-content code {
font-family: 'VCR OSD Mono', monospace;
}
.docs-content p code,
.docs-content li code,
.docs-content td code {
background: var(--surface);
padding: 0.1rem 0.3rem;
font-size: 0.9em;
}
.docs-content table {
border-collapse: collapse;
margin: 1rem 0;
width: 100%;
table-layout: auto;
overflow-x: auto;
display: block;
}
.docs-content th,
.docs-content td {
padding: 0.25rem 0.75rem;
text-align: left;
}
.docs-content th {
color: var(--text);
}
.docs-content td:first-child {
color: var(--text-muted);
}
.docs-content tr:nth-child(even) {
background: var(--surface);
}
.docs-content ul,
.docs-content ol {
padding-left: 1.5rem;
margin: 0.5rem 0;
}
.docs-content ul {
list-style-type: "- ";
}
.docs-content ol {
list-style-type: decimal;
}
.docs-content ul ul {
list-style-type: none;
border-left: 1px solid var(--text-muted);
padding-left: 1rem;
margin: 0.15rem 0;
}
.docs-content li {
margin: 0.3rem 0;
line-height: 1.5;
text-align: left;
}
.docs-content ul ul li {
color: var(--text-dim);
margin: 0.15rem 0;
}
.docs-content a {
color: var(--text-dim);
}
.docs-content strong {
color: var(--text);
}
.docs-content em {
font-style: italic;
color: var(--text-dim);
}
.docs-content del {
opacity: 0.5;
}
.docs-content blockquote {
border-left: 2px solid var(--text-muted);
padding: 0.25rem 1rem;
margin: 1rem 0;
color: var(--text-dim);
}
.docs-content blockquote p {
margin: 0.25rem 0;
}
.docs-content hr {
border: none;
border-top: 1px solid var(--text-muted);
margin: 1.5rem 0;
}
.docs-topbar {
display: flex;
align-items: center;
gap: 1.5rem;
margin-bottom: 1rem;
}
.docs-back {
font-family: 'VCR OSD Mono', monospace;
font-size: 0.85rem;
color: var(--text-muted);
text-decoration: none;
white-space: nowrap;
}
.docs-back:hover {
color: var(--text);
}
.docs-notice {
display: flex;
align-items: center;
gap: 1rem;
font-size: 0.8rem;
color: var(--text-dim);
}
.docs-notice.hidden {
display: none;
}
.docs-notice button {
font-family: 'VCR OSD Mono', monospace;
font-size: 0.75rem;
background: none;
border: 1px solid var(--text-muted);
color: var(--text);
padding: 0.15rem 0.5rem;
cursor: pointer;
white-space: nowrap;
}
.docs-notice button:hover {
border-color: var(--text);
}
/* Forth syntax highlighting — monochrome via weight/opacity */
.f-emit { font-weight: bold; }
.f-com { opacity: 0.5; font-style: italic; }
.f-num { opacity: 0.6; }
.f-note { font-weight: bold; }
.f-snd { text-decoration: underline; }
.f-par { opacity: 0.6; font-style: italic; }
.f-stack { opacity: 0.6; }
.f-prob { opacity: 0.6; }
.f-ctx { opacity: 0.5; }
.f-var { opacity: 0.6; }
@media (max-width: 768px) {
body:has(.docs-layout) {
height: auto;
overflow: visible;
}
.docs-layout {
flex-direction: column;
height: auto;
}
.docs-sidebar {
width: 100%;
overflow-y: visible;
border-bottom: 1px solid var(--text-muted);
padding-bottom: 1rem;
margin-bottom: 1rem;
}
.docs-content {
overflow-y: visible;
}
}

27
website/public/docs.js Normal file
View File

@@ -0,0 +1,27 @@
function dismissNotice() {
document.getElementById('docs-notice').classList.add('hidden');
}
function showTopic(s, t) {
document.querySelectorAll('.docs-content article').forEach(a => a.classList.remove('visible'));
document.querySelectorAll('.docs-sidebar button').forEach(b => b.classList.remove('active'));
const article = document.getElementById('topic-s' + s + 't' + t);
const btn = document.querySelector('[data-s="' + s + '"][data-t="' + t + '"]');
if (article) article.classList.add('visible');
if (btn) btn.classList.add('active');
history.replaceState(null, '', '#s' + s + 't' + t);
document.querySelector('.docs-content').scrollTop = 0;
}
function parseHash() {
const m = location.hash.match(/^#s(\d+)t(\d+)$/);
return m ? [parseInt(m[1]), parseInt(m[2])] : [0, 0];
}
document.querySelectorAll('.docs-sidebar button').forEach(btn => {
btn.addEventListener('click', () => showTopic(+btn.dataset.s, +btn.dataset.t));
});
const [s, t] = parseHash();
showTopic(s, t);
window.addEventListener('hashchange', () => { const [s, t] = parseHash(); showTopic(s, t); });

View File

@@ -0,0 +1,338 @@
---
import fs from 'node:fs';
// --- Forth syntax highlighter ---
const EMIT = new Set(['.', '.!']);
const SOUNDS = new Set([
'sound', 's', 'saw', 'sine', 'kick', 'hat', 'snare', 'modal', 'noise',
'square', 'tri', 'pulse', 'clap', 'rim', 'crash', 'fm', 'sample', 'plaits',
'analog', 'waveshaping', 'granular', 'string', 'chord', 'speech', 'sub',
'super', 'wt', 'input', 'hh',
]);
const PARAMS = new Set([
'freq', 'note', 'gain', 'decay', 'attack', 'release', 'lpf', 'hpf', 'bpf',
'verb', 'delay', 'pan', 'orbit', 'harmonics', 'distort', 'speed', 'voice',
'dur', 'sustain', 'delaytime', 'delayfb', 'chorus', 'phaser', 'flanger',
'crush', 'fold', 'wrap', 'resonance', 'begin', 'end', 'velocity', 'chan',
'dev', 'ccnum', 'ccout', 'bend', 'pressure', 'program', 'tilt', 'slope',
'sub_gain', 'sub_oct', 'feedback', 'depth', 'sweep', 'comb', 'damping',
'detune', 'timbre', 'morph', 'color', 'model', 'cutoff',
]);
const STACK = new Set([
'dup', 'drop', 'swap', 'over', 'rot', 'nip', 'tuck', '2dup', '2drop',
]);
const PROB = new Set([
'chance', 'prob', 'choose', 'wchoose', 'cycle', 'pcycle', 'bounce',
'rand', 'exprand', 'logrand', 'coin', 'seed', 'often', 'sometimes',
'rarely', 'always', 'never', 'almostAlways', 'almostNever', 'every',
'except', 'bjork', 'pbjork', 'shuffle',
]);
const KEYWORDS = new Set([
':', ';', 'if', 'then', 'else', 'do', 'loop', '?', '!?', 'ifelse',
'select', 'apply', 'times', 'forget', 'all', 'noall', 'clear', 'm.',
]);
const CONTEXT = new Set([
'step', 'beat', 'pattern', 'tempo', 'phase', 'runs', 'iter', 'fill', 'stepdur',
]);
const NOTE_RE = /^[a-g][#sb]?\d+$/i;
const NUM_RE = /^-?\d+(\.\d+)?$/;
function classifyToken(word: string): string | null {
if (EMIT.has(word)) return 'f-emit';
if (SOUNDS.has(word)) return 'f-snd';
if (PARAMS.has(word)) return 'f-par';
if (STACK.has(word)) return 'f-stack';
if (PROB.has(word)) return 'f-prob';
if (KEYWORDS.has(word)) return 'f-kw';
if (CONTEXT.has(word)) return 'f-ctx';
if (NOTE_RE.test(word)) return 'f-note';
if (NUM_RE.test(word)) return 'f-num';
if (word.length > 1 && (word[0] === '@' || word[0] === '!' || word[0] === ',')) return 'f-var';
return null;
}
function highlightForth(code: string): string {
return code.split('\n').map(line => {
let result = '';
let i = 0;
while (i < line.length) {
if (line[i] === ' ' || line[i] === '\t') { result += line[i]; i++; continue; }
if (line[i] === ';' && line[i + 1] === ';') {
result += `<span class="f-com">${escapeHtml(line.slice(i))}</span>`;
break;
}
if (line[i] === '"') {
let end = line.indexOf('"', i + 1);
if (end === -1) end = line.length - 1;
result += escapeHtml(line.slice(i, end + 1));
i = end + 1;
continue;
}
if (line[i] === '(' || line[i] === ')') { result += line[i]; i++; continue; }
let end = i;
while (end < line.length && line[end] !== ' ' && line[end] !== '\t') end++;
const word = line.slice(i, end);
const cls = classifyToken(word);
result += cls ? `<span class="${cls}">${escapeHtml(word)}</span>` : escapeHtml(word);
i = end;
}
return result;
}).join('\n');
}
// --- Markdown renderer ---
function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function inline(text: string): string {
const codes: string[] = [];
text = text.replace(/`([^`]+)`/g, (_, code) => { codes.push(code); return `\x00${codes.length - 1}\x00`; });
text = escapeHtml(text);
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>');
text = text.replace(/~~([^~]+)~~/g, '<del>$1</del>');
text = text.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
text = text.replace(/_([^_]+)_/g, '<em>$1</em>');
text = text.replace(/\x00(\d+)\x00/g, (_, i) => `<code>${escapeHtml(codes[+i])}</code>`);
return text;
}
function parseTableCells(line: string, tag: string): string {
return line.split('|').slice(1, -1).map(cell => `<${tag}>${inline(cell.trim())}</${tag}>`).join('');
}
function renderMarkdown(md: string): string {
const lines = md.split('\n');
let html = '';
let i = 0;
while (i < lines.length) {
const line = lines[i];
if (line.trim() === '') { i++; continue; }
if (line.startsWith('### ')) { html += `<h3>${inline(line.slice(4))}</h3>`; i++; continue; }
if (line.startsWith('## ')) { html += `<h2>${inline(line.slice(3))}</h2>`; i++; continue; }
if (line.startsWith('# ')) { html += `<h1>${inline(line.slice(2))}</h1>`; i++; continue; }
if (/^---+$|^\*\*\*+$/.test(line.trim())) { html += '<hr>'; i++; continue; }
if (line.startsWith('> ')) {
html += '<blockquote>';
while (i < lines.length && lines[i].startsWith('> ')) {
html += `<p>${inline(lines[i].slice(2))}</p>`;
i++;
}
html += '</blockquote>';
continue;
}
if (line.startsWith('```')) {
const lang = line.slice(3).trim();
i++;
let code = '';
while (i < lines.length && !lines[i].startsWith('```')) {
code += (code ? '\n' : '') + lines[i];
i++;
}
i++;
html += lang === 'forth'
? `<pre><code>${highlightForth(code)}</code></pre>`
: `<pre><code>${escapeHtml(code)}</code></pre>`;
continue;
}
if (line.startsWith('|')) {
html += '<table>';
html += `<tr>${parseTableCells(line, 'th')}</tr>`;
i++;
if (i < lines.length && /^\|[\s:|-]+\|$/.test(lines[i])) i++;
while (i < lines.length && lines[i].startsWith('|')) {
html += `<tr>${parseTableCells(lines[i], 'td')}</tr>`;
i++;
}
html += '</table>';
continue;
}
if (/^\d+\.\s/.test(line)) {
html += '<ol>';
while (i < lines.length && (/^\d+\.\s/.test(lines[i]) || /^\s+[*-]\s/.test(lines[i]))) {
if (/^\d+\.\s/.test(lines[i])) {
html += `<li>${inline(lines[i].replace(/^\d+\.\s/, ''))}`;
i++;
if (i < lines.length && /^\s+[*-]\s/.test(lines[i])) {
html += '<ul>';
while (i < lines.length && /^\s+[*-]\s/.test(lines[i])) {
html += `<li>${inline(lines[i].replace(/^\s*[*-]\s/, ''))}</li>`;
i++;
}
html += '</ul>';
}
html += '</li>';
} else {
i++;
}
}
html += '</ol>';
continue;
}
if (/^(\s*)[*-]\s/.test(line)) {
let depth = 0;
let first = true;
html += '<ul>';
while (i < lines.length && /^(\s*)[*-]\s/.test(lines[i])) {
const indent = lines[i].match(/^(\s*)/)![1].length;
const level = Math.floor(indent / 2);
if (level > depth) {
while (level > depth) { html += '<ul>'; depth++; }
} else if (level < depth) {
html += '</li>';
while (level < depth) { html += '</ul></li>'; depth--; }
} else if (!first) {
html += '</li>';
}
html += `<li>${inline(lines[i].replace(/^\s*[*-]\s/, ''))}`;
first = false;
i++;
}
while (depth > 0) { html += '</li></ul>'; depth--; }
html += '</li></ul>';
continue;
}
let para = '';
while (i < lines.length && lines[i].trim() !== ''
&& !lines[i].startsWith('#') && !lines[i].startsWith('```')
&& !lines[i].startsWith('|') && !/^(\s*)[*-]\s/.test(lines[i])
&& !/^\d+\.\s/.test(lines[i])) {
para += (para ? ' ' : '') + lines[i];
i++;
}
if (para) html += `<p>${inline(para)}</p>`;
}
return html;
}
// --- Section/topic manifest — mirrors src/model/docs.rs (keep in sync!) ---
const SECTIONS = [
{
title: "Getting Started",
topics: [
{ title: "Welcome", file: "welcome.md" },
{ title: "Navigation", file: "getting-started/navigation.md" },
{ title: "The Big Picture", file: "getting-started/big_picture.md" },
{ title: "Banks & Patterns", file: "getting-started/banks_patterns.md" },
{ title: "Stage / Commit", file: "getting-started/staging.md" },
{ title: "Using the Sequencer", file: "getting-started/grid.md" },
{ title: "Editing a Step", file: "getting-started/editing.md" },
{ title: "The Audio Engine", file: "getting-started/engine.md" },
{ title: "Options", file: "getting-started/options.md" },
{ title: "Saving & Loading", file: "getting-started/saving.md" },
{ title: "The Sample Browser", file: "getting-started/samples.md" },
],
},
{
title: "Cagire's Forth",
topics: [
{ title: "About Forth", file: "forth/about_forth.md" },
{ title: "The Dictionary", file: "forth/dictionary.md" },
{ title: "The Stack", file: "forth/stack.md" },
{ title: "Creating Words", file: "forth/definitions.md" },
{ title: "Control Flow", file: "forth/control_flow.md" },
{ title: "The Prelude", file: "forth/prelude.md" },
{ title: "Cagire vs Classic", file: "forth/oddities.md" },
],
},
{
title: "Building sounds",
topics: [
{ title: "Introduction", file: "engine/intro.md" },
{ title: "Settings", file: "engine/settings.md" },
{ title: "Sources", file: "engine/sources.md" },
{ title: "Samples", file: "engine/samples.md" },
{ title: "Wavetables", file: "engine/wavetable.md" },
{ title: "Filters", file: "engine/filters.md" },
{ title: "Modulation", file: "engine/modulation.md" },
{ title: "Distortion", file: "engine/distortion.md" },
{ title: "Space & Time", file: "engine/space.md" },
{ title: "Audio-Rate Mod", file: "engine/audio_modulation.md" },
{ title: "Words & Sounds", file: "engine/words.md" },
],
},
{
title: "Branching out",
topics: [
{ title: "Introduction", file: "midi/intro.md" },
{ title: "MIDI Output", file: "midi/output.md" },
{ title: "MIDI Input", file: "midi/input.md" },
],
},
{
title: "Tutorials",
topics: [
{ title: "Randomness", file: "tutorials/randomness.md" },
{ title: "Notes & Harmony", file: "tutorials/harmony.md" },
{ title: "Generators", file: "tutorials/generators.md" },
{ title: "Timing with at", file: "tutorials/at.md" },
{ title: "Using Variables", file: "tutorials/variables.md" },
{ title: "Recording", file: "tutorials/recording.md" },
{ title: "Soundfonts", file: "tutorials/soundfont.md" },
{ title: "Sharing", file: "tutorials/sharing.md" },
],
},
];
// Read all doc files at build time
for (const section of SECTIONS) {
for (const topic of section.topics) {
const raw = fs.readFileSync(`../docs/${topic.file}`, 'utf-8');
(topic as any).html = renderMarkdown(raw);
}
}
---
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cagire - Documentation</title>
<meta name="description" content="Cagire documentation — Forth-based live coding sequencer">
<link rel="icon" href="/favicon.ico">
<link rel="stylesheet" href="/style.css">
<link rel="stylesheet" href="/docs.css">
</head>
<body>
<div class="docs-topbar">
<a class="docs-back" href="/">&larr; Back to Cagire</a>
<div class="docs-notice" id="docs-notice">
<span>You are reading Cagire's built-in documentation. Here it is static, but inside the app the examples are runnable and the docs are interactive.</span>
<button onclick="dismissNotice()">Got it</button>
</div>
</div>
<div class="docs-layout">
<nav class="docs-sidebar">
{SECTIONS.map((section, si) => (
<Fragment>
<h3>{section.title}</h3>
{section.topics.map((topic, ti) => (
<button data-s={si} data-t={ti}>{topic.title}</button>
))}
</Fragment>
))}
</nav>
<main class="docs-content">
{SECTIONS.map((section, si) =>
section.topics.map((topic, ti) => (
<article id={`topic-s${si}t${ti}`} set:html={(topic as any).html} />
))
)}
</main>
</div>
<script is:inline src="/docs.js"></script>
</body>
</html>

View File

@@ -94,6 +94,9 @@ const DL = 'https://dlcagire.raphaelforment.fr';
<p class="note">Source code and issue tracker on <a href="https://github.com/Bubobubobubobubo/cagire">GitHub</a>. You can also compile the software yourself from source!</p>
<h2>Documentation</h2>
<p>Cagire ships with built-in interactive documentation — browse it inside the app with runnable examples, or <a href="/docs">read the static version here</a>.</p>
<h2>About</h2>
<p>Cagire is a step sequencer where each step contains a Forth script instead of typical note data. When the sequencer reaches a step, it runs the associated script. Scripts can produce sound, trigger samples, apply effects, or do nothing at all. You are free to define what your scripts will do. Cagire includes a built-in audio engine called <a href="https://doux.livecoding.fr">Doux</a>. No external software is needed to make sound. It comes with oscillators, sample players, filters, reverb, delay, distortion, and more.</p>
@@ -125,7 +128,7 @@ const DL = 'https://dlcagire.raphaelforment.fr';
<video src="/mono_cagire.mp4" autoplay muted loop playsinline></video>
<p class="colophon">
<a href="https://raphaelforment.fr">BuboBubo</a> · Audio engine: <a href="https://doux.livecoding.fr">Doux</a> · <a href="https://github.com/Bubobubobubobubo/cagire">GitHub</a> · AGPL-3.0 </p>
<a href="https://raphaelforment.fr">BuboBubo</a> · Audio engine: <a href="https://doux.livecoding.fr">Doux</a> · <a href="https://github.com/Bubobubobubobubo/cagire">GitHub</a> · <a href="/docs">Docs</a> · AGPL-3.0 </p>
<script is:inline src="/script.js"></script>
</body>