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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "univerlab-web",
"version": "1.19.6",
"version": "1.20.0",
"description": "<h1 align=\"center\">UniverLab — landing</h1>",
"scripts": {
"dev": "astro dev",
Expand Down
10 changes: 8 additions & 2 deletions public/_redirects
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@
/canopy/docs/adr/0001-recipes/index.md /canopy/docs/ 301
/canopy/docs/agents.md /canopy/docs/agents/ 301
/canopy/docs/cli-reference.md /canopy/docs/cli-reference/ 301
/canopy/docs/graphs-ensembles.md /canopy/docs/graphs-ensembles/ 301
/canopy/docs/graphs-execution.md /canopy/docs/graphs-execution/ 301
/canopy/docs/graphs-hooks-and-queues.md /canopy/docs/graphs-hooks-and-queues/ 301
/canopy/docs/graphs-isolation.md /canopy/docs/graphs-isolation/ 301
/canopy/docs/graphs-nodes.md /canopy/docs/graphs-nodes/ 301
/canopy/docs/graphs-operations.md /canopy/docs/graphs-operations/ 301
/canopy/docs/graphs-sharing.md /canopy/docs/graphs-sharing/ 301
/canopy/docs/graphs-templates.md /canopy/docs/graphs-templates/ 301
/canopy/docs/graphs.md /canopy/docs/graphs/ 301
/canopy/docs/index.md /canopy/docs/ 301
/canopy/docs/installation.md /canopy/docs/installation/ 301
Expand All @@ -41,9 +49,7 @@
/canopy/docs/mcp-tools.md /canopy/docs/mcp-tools/ 301
/canopy/docs/quickstart.md /canopy/docs/quickstart/ 301
/canopy/docs/rag.md /canopy/docs/rag/ 301
/canopy/docs/recipes.md /canopy/docs/recipes/ 301
/canopy/docs/tui.md /canopy/docs/tui/ 301
/canopy/docs/usage-patterns.md /canopy/docs/usage-patterns/ 301
/canopy/docs/workflows/ /canopy/docs/graphs/ 301
/demostage/docs/commands.md /demostage/docs/commands/ 301
/demostage/docs/decisions/browser-events.md /demostage/docs/ 301
Expand Down
12 changes: 9 additions & 3 deletions public/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,16 @@ Different problems, one method: take work that lives in fragile, manual procedur
- [Agents](https://univerlab.org/canopy/docs/agents/index.md): Interactive PTY agents, background cron/watcher agents, terminal sessions and seed identities. Canopy documentation — A graph engine for AI coding agents
- [Intelligence & Sync](https://univerlab.org/canopy/docs/intelligence-and-sync/index.md): The project-scoped knowledge graph and multi-agent workspace coordination. Canopy documentation — A graph engine for AI coding agents — every harness, one
- [Graphs](https://univerlab.org/canopy/docs/graphs/index.md): The DAG graph engine — specs, nodes, edges, gates and lifecycle. Canopy documentation — A graph engine for AI coding agents — every harness, one workflow.
- [Usage Patterns](https://univerlab.org/canopy/docs/usage-patterns/index.md): The shapes a graph can take, what each one is good at, and where each one breaks. Canopy documentation — A graph engine for AI coding agents — every harness
- [Recipes](https://univerlab.org/canopy/docs/recipes/index.md): Graphs built end to end — the actual calls, the actual prompts, and what breaks. Canopy documentation — A graph engine for AI coding agents — every harness, one
- [Template Variables](https://univerlab.org/canopy/docs/graphs-templates/index.md): Placeholders in agent prompts and check commands. Canopy documentation — A graph engine for AI coding agents — every harness, one workflow.
- [Ensembles](https://univerlab.org/canopy/docs/graphs-ensembles/index.md): Parallel, cascade and round-robin member groups, crews, and availability. Canopy documentation — A graph engine for AI coding agents — every harness, one
- [Execution](https://univerlab.org/canopy/docs/graphs-execution/index.md): Commit rights, self-reports, retries, the execution limit, lifecycle, deterministic commits and spec notes. Canopy documentation — A graph engine for AI coding
- [Isolation](https://univerlab.org/canopy/docs/graphs-isolation/index.md): Sandboxes, concurrency, and named mutexes. Canopy documentation — A graph engine for AI coding agents — every harness, one workflow.
- [Hooks and Queues](https://univerlab.org/canopy/docs/graphs-hooks-and-queues/index.md): Event-keyed hooks, the standalone spec backlog, and spec queues. Canopy documentation — A graph engine for AI coding agents — every harness, one workflow.
- [Sharing Graphs](https://univerlab.org/canopy/docs/graphs-sharing/index.md): Export and import, validate before you share, and install a graph. Canopy documentation — A graph engine for AI coding agents — every harness, one workflow.
- [Node Documents](https://univerlab.org/canopy/docs/graphs-nodes/index.md): Reusable node documents: install, list, add, remove. Canopy documentation — A graph engine for AI coding agents — every harness, one workflow.
- [Graph Operations](https://univerlab.org/canopy/docs/graphs-operations/index.md): Archive and restore, node run history, the event log, and engine tuning. Canopy documentation — A graph engine for AI coding agents — every harness, one
- [RAG Pipeline](https://univerlab.org/canopy/docs/rag/index.md): Local-first semantic search over your own documents. Canopy documentation — A graph engine for AI coding agents — every harness, one workflow.
- [MCP Tools](https://univerlab.org/canopy/docs/mcp-tools/index.md): All 117 MCP tools exposed by the canopy daemon, by category. Canopy documentation — A graph engine for AI coding agents — every harness, one workflow.
- [MCP Tools](https://univerlab.org/canopy/docs/mcp-tools/index.md): All 34 MCP tools exposed by the canopy daemon, by category. Canopy documentation — A graph engine for AI coding agents — every harness, one workflow.
- [CLI Reference](https://univerlab.org/canopy/docs/cli-reference/index.md): Every canopy command and flag. Canopy documentation — A graph engine for AI coding agents — every harness, one workflow.

### TeXForge
Expand Down
168 changes: 168 additions & 0 deletions src/__tests__/canopy-arrows.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
/**
* Canopy's plate arrowheads (FR4): every flow edge across the hero's shapes
* carries a marker-end, and the built /canopy/ pages ship the plate's marker
* vocabulary on the autonomous graph (ag) and the catalogue plate (cp).
*
* Part 1 dresses real SVG paths through the same shared module the hero's
* drawEdges calls (HERO_SHAPES + flowPath + markerFor) — the 18s loop is
* client script, so the unit level is where the shapes are assertable.
* Part 2 reads the built dist/ HTML with the repo's "run npm run build"
* convention (canopy-remote-catalogue.test.ts).
*/
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { EDGE, HERO_SHAPES, flowPath, markerDefs, markerFor, type E, type Shape } from '../lib/canopy-graph';

const root = join(__dirname, '..', '..');
const SVG_NS = 'http://www.w3.org/2000/svg';

function built(page: string): string {
const file = join(root, 'dist', page);
if (!existsSync(file)) {
throw new Error(`dist/${page} not found — run \`npm run build\` before \`npm test\`.`);
}
return readFileSync(file, 'utf8');
}

/** One svg's full markup, found by its class (Astro appends scope classes). */
function svgBlock(html: string, cls: string): string {
const start = html.indexOf(`class="${cls}`);
if (start === -1) throw new Error(`svg .${cls} not found`);
const open = html.lastIndexOf('<svg', start);
const close = html.indexOf('</svg>', start);
return html.slice(open, close + 6);
}

function markerBlock(svg: string, id: string): string {
const m = svg.match(new RegExp(`<marker id="${id}"[\\s\\S]*?</marker>`));
if (!m) throw new Error(`marker #${id} not found`);
return m[0];
}

/** The hero's drawEdges against a shape's declared coordinates. */
function dressedPath(shape: Shape, e: E): SVGPathElement {
const p = document.createElementNS(SVG_NS, 'path');
const at = (i: number) => (i >= 100 ? shape.pts![i - 100] : shape.nodes.find((n) => n.s === i)!);
const kind = e[1] < 100 ? shape.nodes.find((n) => n.s === e[1])?.k : undefined;
p.setAttribute('d', flowPath(at(e[0]), at(e[1]), e[2] === 'fail', kind));
const m = markerFor(e[2], 'gm');
if (m) p.setAttribute('marker-end', m);
return p;
}

describe('hero shapes: every flow edge carries an arrowhead (jsdom)', () => {
HERO_SHAPES.forEach((shape, i) => {
it(`shape ${i} (${shape.nodes[0].l}) marks exactly its flow edges`, () => {
const paths = shape.edges.map((e) => dressedPath(shape, e));
const flowCount = shape.edges.filter((e) => e[2] !== 'order').length;
expect(flowCount).toBeGreaterThan(0);
expect(paths.filter((p) => p.getAttribute('marker-end')).length).toBe(flowCount);
});
});

it('order guides never carry a marker (the plate rule)', () => {
const cascade = HERO_SHAPES[2];
expect(cascade.edges.filter((e) => e[2] === 'order').length).toBeGreaterThan(0);
expect(markerFor('order', 'gm')).toBeNull();
});

it('markers land short of the target border, on it for free points', () => {
// The path's final point, parsed from the d string (float-safe).
const endOf = (d: string) => {
const nums = d.split('C')[1]!.trim().split(/\s+/).map(Number);
return { x: nums[4], y: nums[5] };
};
// gated implement edges[0]: spec(7,31) → implement(28,31); the agent's
// half-extent is 3 and the plate margin 0.8 — the S-curve ends at 24.2.
const gated = HERO_SHAPES[0];
const end = endOf(dressedPath(gated, gated.edges[0]).getAttribute('d')!);
expect(end.x).toBeCloseTo(28 - 3 - 0.8, 6);
expect(end.y).toBe(31);
// Same-row fail arc into an agent: y pullback (half 3 + margin 0.8 - the
// arc's own 3-unit bias), and edgePath ends 3 above that.
const arc = endOf(flowPath({ x: 49, y: 31 }, { x: 28, y: 31 }, true, 'agent'));
expect(arc.x).toBe(28);
expect(arc.y).toBeCloseTo(31 - (3 + 0.8 - 3) - 3, 6);
// A fail that drops rows stays an S-curve, pulled back along x.
const drop = endOf(flowPath({ x: 37, y: 24 }, { x: 47, y: 52 }, true, 'agent'));
expect(drop.x).toBeCloseTo(47 - 3.8, 6);
expect(drop.y).toBe(52);
// A free point (the cascade's group exit) has no border to stop at.
const free = flowPath({ x: 63, y: 31 }, { x: 91, y: 31 }, false, undefined);
expect(free).toBe('M63 31 C77 31 77 31 91 31');
});
});

describe('shared marker vocabulary', () => {
it('markerDefs carries the plate geometry with inline EDGE fills', () => {
const defs = markerDefs('gm');
for (const id of ['gm-arrow', 'gm-arrow-fail', 'gm-arrow-error']) {
expect(defs).toContain(`id="${id}"`);
}
expect(defs).toContain('viewBox="0 0 10 10"');
expect(defs).toContain('refX="8"');
expect(defs).toContain('refY="5"');
expect(defs).toContain('markerWidth="1.3"');
expect(defs).toContain('markerHeight="1.3"');
expect(defs).toContain('markerUnits="userSpaceOnUse"');
expect(defs).toContain('orient="auto-start-reverse"');
expect(defs).toContain(`fill="${EDGE.pass.fill}" fill-opacity=".85"`);
expect(defs).toContain(`fill="${EDGE.fail.fill}" fill-opacity=".7"`);
expect(defs).toContain(`fill="${EDGE.error.fill}" fill-opacity=".45"`);
});

it('markerFor names the right marker per condition', () => {
expect(markerFor('pass', 'ag')).toBe('url(#ag-arrow)');
expect(markerFor('fail', 'ag')).toBe('url(#ag-arrow-fail)');
expect(markerFor('error', 'ag')).toBe('url(#ag-arrow-error)');
expect(markerFor('order', 'ag')).toBeNull();
});
});

describe.each([
['en', 'canopy/index.html'],
['es', 'es/canopy/index.html'],
] as const)('built %s /canopy/ page ships the plate arrows', (_lang, page) => {
const html = built(page);
const ag = svgBlock(html, 'ag-svg');
const cp = svgBlock(html, 'cp-svg');

it('the autonomous graph arrows all 12 of its flow edges', () => {
expect((ag.match(/class="ag-edge/g) ?? []).length).toBe(12);
expect((ag.match(/marker-end="url\(#ag-arrow/g) ?? []).length).toBe(12);
});

it('the catalogue plate arrows all 6 of its flow edges', () => {
expect((cp.match(/class="cp-edge/g) ?? []).length).toBe(6);
expect((cp.match(/marker-end="url\(#cp-arrow/g) ?? []).length).toBe(6);
});

it('carries the three namespaced markers with the plate geometry', () => {
for (const [block, prefix] of [[ag, 'ag'], [cp, 'cp']] as const) {
for (const suffix of ['', '-fail', '-error']) {
const marker = markerBlock(block, `${prefix}-arrow${suffix}`);
expect(marker).toContain('viewBox="0 0 10 10"');
expect(marker).toContain('refX="8"');
expect(marker).toContain('refY="5"');
expect(marker).toContain('markerWidth="1.3"');
expect(marker).toContain('markerUnits="userSpaceOnUse"');
expect(marker).toContain('orient="auto-start-reverse"');
}
}
});

it('the hero svg ships its three gm markers statically', () => {
const gm = svgBlock(html, 'gm-svg');
for (const suffix of ['', '-fail', '-error']) {
expect(gm).toContain(`id="gm-arrow${suffix}"`);
}
});
});

describe('provenance pin', () => {
it('canopy-graph.ts names its canopy-web plate source and commit', () => {
const source = readFileSync(join(root, 'src/lib/canopy-graph.ts'), 'utf8');
expect(source).toContain('packages/plate');
expect(source).toContain('d91e1b716741db9cf6e6027370f8b096417e954f');
});
});
95 changes: 95 additions & 0 deletions src/__tests__/canopy-cross-site.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/**
* The three sites, linked; the empty field notes, gone (FR2 ship-check + FR3).
*
* The built /canopy/ pages must carry the shared Canopy · Graphs · Remote row
* (Canopy localized per language, the other two off-site) and must not ship
* the old "no field notes yet" empty section. The sources pin the shared
* partial so later pages reuse it instead of duplicating the links.
* Reads dist/ with the repo's "run npm run build" convention.
*/
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { en } from '../i18n/en';
import { es } from '../i18n/es';

const root = join(__dirname, '..', '..');

function built(page: string): string {
const file = join(root, 'dist', page);
if (!existsSync(file)) {
throw new Error(`dist/${page} not found — run \`npm run build\` before \`npm test\`.`);
}
return readFileSync(file, 'utf8');
}

const canopyEn = built('canopy/index.html');
const canopyEs = built('es/canopy/index.html');

/** The site-nav row's markup. */
function siteNav(html: string): string {
const m = html.match(/<nav class="site-nav[^"]*"[\s\S]*?<\/nav>/);
if (!m) throw new Error('site-nav not found');
return m[0];
}

describe.each([
['en', canopyEn, '/canopy/', 'No field notes yet'],
['es', canopyEs, '/es/canopy/', 'Aún no hay notas de campo'],
] as const)('built %s /canopy/ page', (lang, html, canopyHref, emptySentence) => {
const nav = siteNav(html);
const ariaLabel = lang === 'en' ? en.sites.nav : es.sites.nav;

it('lists exactly Canopy, Graphs and Remote', () => {
const labels = [...nav.matchAll(/>(Canopy|Graphs|Remote)</g)].map((m) => m[1]);
expect(labels).toEqual(['Canopy', 'Graphs', 'Remote']);
});

it('carries the three site hrefs, the aria-label, and marks Canopy as here', () => {
expect(nav).toContain(`aria-label="${ariaLabel}"`);
expect(nav).toContain(`href="${canopyHref}"`);
expect(nav).toContain('href="https://graphs.univerlab.org"');
expect(nav).toContain('href="https://remote.univerlab.org"');
const here = nav.match(new RegExp(`<a href="${canopyHref.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')}"[^>]*aria-current="page"`));
expect(here).not.toBeNull();
});

it('opens the off-site links in a new tab, the local one in place', () => {
expect(nav.match(/target="_blank"/g) ?? []).toHaveLength(2);
expect(nav.match(/rel="noopener noreferrer"/g) ?? []).toHaveLength(2);
});

it('ships no empty field-notes section', () => {
expect(html).not.toContain('fn-empty');
expect(html).not.toContain('data-empty');
expect(html).not.toContain(emptySentence);
});
});

describe('source pins: the shared partial and its mount', () => {
const siteNavSrc = readFileSync(join(root, 'src/components/SiteNav.astro'), 'utf8');
const view = readFileSync(join(root, 'src/views/experiments/Canopy.astro'), 'utf8');
const layout = readFileSync(join(root, 'src/layouts/ExperimentLayout.astro'), 'utf8');
const notes = readFileSync(join(root, 'src/components/FieldNotes.astro'), 'utf8');

it('SiteNav lists the three sites with the spec-fixed hrefs', () => {
expect(siteNavSrc).toContain("localizePath('/canopy/', lang)");
expect(siteNavSrc).toContain("'https://graphs.univerlab.org'");
expect(siteNavSrc).toContain("'https://remote.univerlab.org'");
});

it('Canopy mounts the partial into the layout slot', () => {
expect(view).toContain('<SiteNav slot="site-nav" current="canopy" />');
expect(layout).toContain("Astro.slots.has('site-nav') && <slot name=\"site-nav\" />");
});

it('FieldNotes removes itself instead of rendering an empty section', () => {
expect(notes).toContain('section.remove()');
expect(notes).not.toContain('notes.empty');
});

it('carries the sites.nav aria-label in both languages', () => {
expect(en.sites.nav).toBe('UniverLab sites');
expect(es.sites.nav).toBe('Sitios de UniverLab');
expect(Object.keys(en.sites).sort()).toEqual(Object.keys(es.sites).sort());
});
});
Loading
Loading