@posuta/posuta 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alvaro Gaona
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,6 @@
1
- # Temporary Holding Version
1
+ # posuta
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Research posters for the web and for print, built with Astro. Write MDX, deploy a static page
4
+ and export a vector PDF at any paper size. Start a poster with `pnpm create posuta`.
5
+
6
+ Read the docs at https://github.com/alvgaona/posuta.
package/bin/posuta.mjs ADDED
@@ -0,0 +1,124 @@
1
+ #!/usr/bin/env node
2
+ // posuta pdf: export a built poster (dist/) to a one-page vector PDF at the paper size, plus
3
+ // og.png, the preview image for social sharing, and with --handout, handout.pdf: the same page
4
+ // shrunk onto A4, for readers to take away.
5
+ //
6
+ // posuta pdf [--dist dist] [--page /] [--out dist/poster.pdf] [--print "A1 landscape"] [--base /repo/] [--no-png] [--handout]
7
+ // posuta install download the browser posuta pdf uses (Playwright's Chromium)
8
+ //
9
+ // Fails if content runs past the printed canvas, naming the innermost block that does: an element
10
+ // led by a heading (h1–h4) or with a data-name attribute.
11
+ import { spawnSync } from "node:child_process";
12
+ import { createReadStream, statSync } from "node:fs";
13
+ import { createRequire } from "node:module";
14
+ import { mkdir } from "node:fs/promises";
15
+ import { createServer } from "node:http";
16
+ import { dirname, extname, join, resolve, sep } from "node:path";
17
+ import { parseArgs } from "node:util";
18
+ import { chromium } from "playwright";
19
+ import { overflow } from "../src/overflow.js";
20
+ import { printCss } from "../src/print.js";
21
+
22
+ const usage = `usage: posuta pdf [--dist dist] [--page /] [--out <dist>/<page>/poster.pdf] [--print "A1 landscape"] [--base /] [--no-png] [--handout]
23
+ posuta install [--with-deps]`;
24
+ const [cmd, ...rest] = process.argv.slice(2);
25
+ if (cmd === "install") {
26
+ const cli = join(dirname(createRequire(import.meta.url).resolve("playwright/package.json")), "cli.js");
27
+ const r = spawnSync(process.execPath, [cli, "install", "--only-shell", "chromium", ...rest], { stdio: "inherit" });
28
+ process.exit(r.status ?? 1);
29
+ }
30
+ if (cmd !== "pdf") { console.error(usage); process.exit(cmd ? 1 : 0); }
31
+ const { values: opt } = parseArgs({
32
+ args: rest,
33
+ allowNegative: true,
34
+ options: {
35
+ png: { type: "boolean", default: true },
36
+ handout: { type: "boolean", default: false },
37
+ dist: { type: "string", default: "dist" },
38
+ page: { type: "string", default: "/" },
39
+ out: { type: "string" },
40
+ print: { type: "string" },
41
+ base: { type: "string", default: process.env.BASE_PATH ?? "/" },
42
+ },
43
+ });
44
+ const dist = resolve(opt.dist);
45
+ const pageDir = join(dist, opt.page); // og.png goes next to the page, where its og:image tag points
46
+ const out = resolve(opt.out ?? join(pageDir, "poster.pdf"));
47
+ const base = ("/" + opt.base + "/").replace(/\/+/g, "/");
48
+
49
+ const TYPES = { ".html": "text/html", ".css": "text/css", ".js": "text/javascript", ".mjs": "text/javascript",
50
+ ".svg": "image/svg+xml", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp",
51
+ ".woff2": "font/woff2", ".woff": "font/woff", ".json": "application/json" };
52
+
53
+ // A static server for dist/, so absolute asset paths (/_astro/…) resolve as on the real host.
54
+ const server = createServer((req, res) => {
55
+ let path = decodeURIComponent(new URL(req.url, "http://x").pathname);
56
+ if (path.startsWith(base)) path = "/" + path.slice(base.length);
57
+ let file = join(dist, path);
58
+ if (!file.startsWith(dist + sep) && file !== dist) { res.writeHead(403).end(); return; }
59
+ try {
60
+ if (statSync(file).isDirectory()) file = join(file, "index.html");
61
+ statSync(file);
62
+ } catch { res.writeHead(404).end(); return; }
63
+ res.writeHead(200, { "content-type": TYPES[extname(file)] ?? "application/octet-stream" });
64
+ createReadStream(file).pipe(res);
65
+ });
66
+ await new Promise((r) => server.listen(0, "127.0.0.1", r));
67
+ const url = `http://127.0.0.1:${server.address().port}${base}${opt.page.replace(/^\//, "")}`;
68
+
69
+ const browser = await chromium.launch();
70
+ try {
71
+ const page = await browser.newPage();
72
+ await page.emulateMedia({ media: "print", colorScheme: "light" });
73
+ const res = await page.goto(url, { waitUntil: "networkidle" });
74
+ if (!res?.ok()) throw new Error(`could not open ${url} (${res?.status()}); did you run astro build?`);
75
+ if (opt.print) await page.addStyleTag({ content: `@media print{${printCss(opt.print)}}` });
76
+
77
+ // Lay the page out at the paper size before measuring.
78
+ const size = await page.evaluate(() => {
79
+ const s = getComputedStyle(document.documentElement);
80
+ return { w: s.getPropertyValue("--paper-w"), h: s.getPropertyValue("--paper-h") };
81
+ });
82
+ const mm = (v) => parseFloat(v);
83
+ if (!size.w) throw new Error(`${url} has no print size (--paper-w): wrap it in <Canvas>, or pass --print`);
84
+ const px = (v) => Math.round((mm(v) * 96) / 25.4);
85
+ await page.setViewportSize({ width: px(size.w), height: px(size.h) });
86
+ await page.evaluate(() => document.fonts.ready);
87
+
88
+ const found = await page.evaluate(overflow);
89
+ if (!found) throw new Error(`${url} is not a posuta page: wrap it in <Canvas>`);
90
+ if (found.length) {
91
+ throw new Error(`content does not fit on the ${size.w} × ${size.h} canvas:\n ${found.join("\n ")}\n` +
92
+ "Shorten it, change the column spans, or pick a larger print size.");
93
+ }
94
+
95
+ await mkdir(dirname(out), { recursive: true });
96
+ // tagged: readable by screen readers and text extraction; outline: the headings as bookmarks.
97
+ await page.pdf({ path: out, preferCSSPageSize: true, printBackground: true, tagged: true, outline: true });
98
+ console.log(`${out}: ${size.w} × ${size.h}`);
99
+
100
+ if (opt.handout) {
101
+ // shortcut: A4 only; add a paper option if a conference wants US letter.
102
+ const [w, h] = mm(size.w) <= mm(size.h) ? [210, 297] : [297, 210];
103
+ // Chromium shrinks the poster's page box (@page size) onto the sheet and centres it.
104
+ const handout = join(dirname(out), "handout.pdf");
105
+ await page.pdf({ path: handout, width: `${w}mm`, height: `${h}mm`, margin: { top: 0, right: 0, bottom: 0, left: 0 },
106
+ pageRanges: "1", printBackground: true, tagged: true, outline: true });
107
+ console.log(`${handout}: A4`);
108
+ }
109
+
110
+ if (opt.png) {
111
+ // The screen layout's first 1200 × 630 px: the size link previews use.
112
+ const png = join(pageDir, "og.png");
113
+ await page.emulateMedia({ media: "screen" });
114
+ await page.setViewportSize({ width: 1200, height: 630 });
115
+ await page.screenshot({ path: png });
116
+ console.log(png);
117
+ }
118
+ } catch (e) {
119
+ console.error(`posuta pdf: ${e.message}`);
120
+ process.exitCode = 1;
121
+ } finally {
122
+ await browser.close();
123
+ server.close();
124
+ }
package/package.json CHANGED
@@ -1,6 +1,89 @@
1
1
  {
2
2
  "name": "@posuta/posuta",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "Research posters for the web and for print, built with Astro",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": "./src/index.js",
9
+ "./print": "./src/print.js",
10
+ "./data": "./src/data.js",
11
+ "./plot": "./src/plot.js",
12
+ "./posuta.mplstyle": "./posuta.mplstyle",
13
+ "./Row.astro": "./src/Row.astro",
14
+ "./Col.astro": "./src/Col.astro",
15
+ "./Grid.astro": "./src/Grid.astro",
16
+ "./Cell.astro": "./src/Cell.astro",
17
+ "./Chart.astro": "./src/Chart.astro",
18
+ "./Figure.astro": "./src/Figure.astro",
19
+ "./Figure": "./src/figure/index.js",
20
+ "./Equation.astro": "./src/Equation.astro",
21
+ "./base.css": "./src/base.css",
22
+ "./Canvas.astro": "./src/Canvas.astro",
23
+ "./QR.astro": "./src/QR.astro",
24
+ "./qr": "./src/qr.js",
25
+ "./tex": "./src/tex.js",
26
+ "./cite": "./src/remark-cite.js",
27
+ "./md": "./src/md.js",
28
+ "./bibtex": "./src/bibtex.js",
29
+ "./References": "./src/references/index.js"
30
+ },
31
+ "peerDependencies": {
32
+ "astro": "^7.4.0"
33
+ },
34
+ "dependencies": {
35
+ "@astrojs/markdown-remark": "^7.3.6",
36
+ "@astrojs/mdx": "^8.1.3",
37
+ "@citation-js/core": "^0.9.0",
38
+ "@citation-js/plugin-bibtex": "^0.9.0",
39
+ "@citation-js/plugin-csl": "^0.9.0",
40
+ "@observablehq/plot": "^0.6.19",
41
+ "d3-dsv": "^3.0.2",
42
+ "katex": "^0.16.47",
43
+ "linkedom": "^0.18.15",
44
+ "playwright": "^1.65.0",
45
+ "qrcode": "^1.5.4",
46
+ "rehype-katex": "^7.0.1",
47
+ "remark-math": "^6.0.0"
48
+ },
49
+ "bin": {
50
+ "posuta": "./bin/posuta.mjs"
51
+ },
52
+ "imports": {
53
+ "#dom": {
54
+ "browser": "./src/dom-browser.js",
55
+ "default": "./src/dom-server.js"
56
+ }
57
+ },
58
+ "files": [
59
+ "src",
60
+ "bin",
61
+ "posuta.mplstyle"
62
+ ],
63
+ "repository": {
64
+ "type": "git",
65
+ "url": "git+https://github.com/alvgaona/posuta.git",
66
+ "directory": "packages/posuta"
67
+ },
68
+ "keywords": [
69
+ "astro-integration",
70
+ "withastro",
71
+ "astro",
72
+ "poster",
73
+ "research-poster",
74
+ "scientific-poster",
75
+ "academic",
76
+ "research",
77
+ "mdx",
78
+ "print",
79
+ "pdf",
80
+ "observable-plot",
81
+ "katex"
82
+ ],
83
+ "engines": {
84
+ "node": ">=22"
85
+ },
86
+ "scripts": {
87
+ "test": "node --test \"test/*.test.mjs\""
88
+ }
6
89
  }
@@ -0,0 +1,24 @@
1
+ # matplotlib style that matches posuta's default theme.
2
+ # import matplotlib.pyplot as plt; plt.style.use("node_modules/@posuta/posuta/posuta.mplstyle")
3
+ # plt.savefig("figures/plot.svg") # then <Figure src={...} /> in the poster
4
+ svg.fonttype: none # keep text as text, so it uses the poster's fonts
5
+ font.family: IBM Plex Sans, sans-serif
6
+ font.size: 11
7
+ text.color: 282a30
8
+ axes.labelcolor: 282a30
9
+ axes.edgecolor: 6f6e77
10
+ axes.linewidth: 0.8
11
+ axes.spines.top: False
12
+ axes.spines.right: False
13
+ axes.grid: True
14
+ grid.color: e9e8ea
15
+ grid.linewidth: 0.8
16
+ axes.prop_cycle: cycler('color', ['1348dc', 'c2410c', '0f766e', '7c3aed', 'a16207', '6f6e77'])
17
+ xtick.color: 6f6e77
18
+ ytick.color: 6f6e77
19
+ legend.frameon: False
20
+ lines.linewidth: 1.8
21
+ figure.facecolor: none
22
+ axes.facecolor: none
23
+ savefig.facecolor: none
24
+ savefig.bbox: tight
@@ -0,0 +1,62 @@
1
+ ---
2
+ // The page every poster is drawn on: <html>, <head> and one canvas element, nothing visual.
3
+ //
4
+ // <Canvas title="…" description="…" print="A1 landscape" theme="dark">
5
+ // <link slot="head" rel="icon" href="…" />
6
+ // …your poster…
7
+ // </Canvas>
8
+ //
9
+ // It sets the print page and type scale (base.css + `print`), link-preview tags, and theme
10
+ // tokens or a fixed colour scheme. posuta pdf measures the canvas (.posuta-canvas) for overflow;
11
+ // in astro dev, ?print on the URL previews the printed page and the same check.
12
+ import "./base.css";
13
+ import { printCss } from "./print.js";
14
+
15
+ type Print = string | { size?: string; orientation?: "portrait" | "landscape"; width?: string; height?: string; scale?: number };
16
+ interface Props {
17
+ title: string;
18
+ /** For search results and link previews. */
19
+ description?: string;
20
+ /** Paper: "A0", "A1 landscape", { width: "900mm", height: "36in" }… Default A0 portrait.
21
+ `scale` multiplies the printed type size: { size: "A0", scale: 0.8 }. */
22
+ print?: Print;
23
+ /** "light" or "dark" to fix the colour scheme, or tokens such as { accent: "#0a7" }. */
24
+ theme?: "light" | "dark" | Record<string, string>;
25
+ lang?: string;
26
+ class?: string;
27
+ [attr: string]: unknown;
28
+ }
29
+ const { title, description, print, theme, lang = "en", class: cls, ...rest } = Astro.props;
30
+ const pageUrl = Astro.site ? new URL(Astro.url.pathname, Astro.site).href : undefined;
31
+ const scheme = typeof theme === "string" ? theme : undefined;
32
+ // Declared first, so it holds whatever order the stylesheets load in. theme/base/components/
33
+ // utilities are Tailwind v4's layers; they are empty without it.
34
+ const LAYERS = "@layer theme, base, posuta.base, posuta.theme, components, utilities;";
35
+ const tokens = theme && typeof theme === "object"
36
+ ? `:root{${Object.entries(theme).map(([k, v]) => `--${k.replace(/^--/, "")}:${v}`).join(";")}}` : "";
37
+ ---
38
+ <!doctype html>
39
+ <html lang={lang} data-theme={scheme}>
40
+ <head>
41
+ <meta charset="utf-8">
42
+ <meta name="viewport" content="width=device-width, initial-scale=1">
43
+ <title>{title}</title>
44
+ {description && <meta name="description" content={description}>}
45
+ <meta property="og:title" content={title}>
46
+ {description && <meta property="og:description" content={description}>}
47
+ {pageUrl && <meta property="og:url" content={pageUrl}>}
48
+ {pageUrl && <meta property="og:image" content={new URL("og.png", pageUrl).href}>}
49
+ <meta name="twitter:card" content="summary_large_image">
50
+ <!-- Layer order: resets (Tailwind's base), then posuta, then utilities. Unlayered CSS beats all. -->
51
+ <style is:inline set:html={`${LAYERS}@media print{${printCss(print)}}${tokens}`} />
52
+ <slot name="head" />
53
+ </head>
54
+ <body>
55
+ <div class:list={["posuta-canvas", cls]} {...rest}><slot /></div>
56
+ {import.meta.env.DEV && <script>
57
+ // astro dev: add ?print to the URL to see the page as it will print, overflow marked.
58
+ // The DEV check here too, so the build drops preview.js instead of emitting it unused.
59
+ if (import.meta.env.DEV && new URLSearchParams(location.search).has("print")) import("./preview.js").then((m) => m.preview());
60
+ </script>}
61
+ </body>
62
+ </html>
package/src/Cell.astro ADDED
@@ -0,0 +1,6 @@
1
+ ---
2
+ // A cell in <Grid>. `span`: columns it covers (default 1); `rows`: rows it covers (default 1).
3
+ interface Props { span?: number; rows?: number; class?: string; [attr: string]: unknown }
4
+ const { span = 1, rows = 1, class: cls, style, ...rest } = Astro.props;
5
+ ---
6
+ <div class:list={["posuta-cell", cls]} style={[`--span:${span};--rows:${rows}`, style].filter(Boolean).join(";")} {...rest}><slot /></div>
@@ -0,0 +1,65 @@
1
+ ---
2
+ // An Observable Plot chart, drawn to SVG at build time (prints as vectors, works without JS).
3
+ //
4
+ // <Chart data="step.csv" plot="step" alt="…" /> charts/step.js: export default (d, Plot, state) => options
5
+ // <Chart … interactive /> tooltip and crosshair in the browser
6
+ // <Chart … slider={{ name: "t", min: 0, max: 5, step: 0.1, value: 1, label: "Time" }} />
7
+ //
8
+ // `plot` may also be an inline function, (d, Plot) => options, for a static chart.
9
+ // The slider's `value` is the state the chart prints in: the build-time drawing is what prints,
10
+ // whatever the slider or a script shows on screen.
11
+ import * as Plot from "@observablehq/plot";
12
+ import { load } from "./data.js";
13
+ import { renderPlot } from "./plot.js";
14
+
15
+ type ChartFn = (data: any[], plot: typeof Plot, state: Record<string, number>) => Plot.PlotOptions;
16
+ interface Slider { name: string; min: number; max: number; step?: number; value: number; label?: string }
17
+ interface Props {
18
+ /** A file under data/, or the rows themselves. */
19
+ data?: string | object[];
20
+ /** A chart file name in charts/ (without .js), or an inline function for a static chart. */
21
+ plot: string | ChartFn;
22
+ /** What the chart shows, for screen readers. Required. */
23
+ alt: string;
24
+ caption?: string;
25
+ /** Thin series longer than this (rows). */
26
+ max?: number;
27
+ /** Tooltip and crosshair in the browser. */
28
+ interactive?: boolean;
29
+ /** A range input that feeds `state[name]` to the chart function. */
30
+ slider?: Slider;
31
+ class?: string;
32
+ /** Any other attribute goes on the <figure>. */
33
+ [attr: string]: unknown;
34
+ }
35
+ const { data = [], plot, alt, caption, max, interactive = false, slider, class: cls, ...rest } = Astro.props;
36
+ if (!alt) throw new Error("posuta <Chart>: `alt` is required (what the chart shows, for screen readers)");
37
+ const live = interactive || !!slider;
38
+ if (live && typeof plot !== "string") {
39
+ throw new Error('posuta <Chart>: an interactive chart or one with a slider needs its chart in a file: plot="name" for charts/name.js');
40
+ }
41
+ let chart: ChartFn;
42
+ if (typeof plot === "string") {
43
+ const files = import.meta.glob<{ default: ChartFn }>("/charts/*.js");
44
+ const file = files[`/charts/${plot}.js`];
45
+ if (!file) throw new Error(`posuta <Chart>: no charts/${plot}.js (found: ${Object.keys(files).join(", ") || "none"})`);
46
+ chart = (await file()).default;
47
+ } else chart = plot;
48
+ const rows = typeof data === "string" ? await load(data, { max }) : data;
49
+ const state = slider ? { [slider.name]: slider.value } : {};
50
+ const svg = renderPlot({ ariaLabel: alt, ...chart(rows, Plot, state) });
51
+ ---
52
+ <figure class:list={["posuta-figure posuta-chart", cls]} {...rest} data-chart={live ? plot : undefined} data-alt={live ? alt : undefined}
53
+ data-interactive={interactive || undefined} data-slider={slider && JSON.stringify(slider)}>
54
+ {slider && (
55
+ <label class="posuta-slider" data-print="hide" hidden>
56
+ <span>{slider.label ?? slider.name}</span>
57
+ <input type="range" min={slider.min} max={slider.max} step={slider.step ?? 1} value={slider.value} />
58
+ <output>{slider.value}</output>
59
+ </label>
60
+ )}
61
+ <div class="posuta-plot"><Fragment set:html={svg} /></div>
62
+ {live && <script type="application/json" set:html={JSON.stringify(rows)} />}
63
+ {caption && <figcaption>{caption}</figcaption>}
64
+ </figure>
65
+ {live && <script>import "./chart-client.js";</script>}
package/src/Col.astro ADDED
@@ -0,0 +1,6 @@
1
+ ---
2
+ // A column inside <Row>. `span` is its width relative to its siblings (default 1).
3
+ interface Props { span?: number; class?: string; [attr: string]: unknown }
4
+ const { span = 1, class: cls, style, ...rest } = Astro.props;
5
+ ---
6
+ <div class:list={["posuta-col", cls]} style={[`--span:${span}`, style].filter(Boolean).join(";")} {...rest}><slot /></div>
@@ -0,0 +1,9 @@
1
+ ---
2
+ // A display equation, rendered with KaTeX at build time: <Equation tex="E = mc^2" n={1} />
3
+ // (Inline math is plain $…$ in the text.)
4
+ import katex from "katex";
5
+ interface Props { tex: string; n?: number | string; class?: string; [attr: string]: unknown }
6
+ const { tex, n, class: cls, ...rest } = Astro.props;
7
+ const html = katex.renderToString(tex, { displayMode: true, throwOnError: true, strict: "error" });
8
+ ---
9
+ <div class:list={["posuta-eq", cls]} {...rest}><Fragment set:html={html} />{n != null && <span class="posuta-eq-n">({n})</span>}</div>
@@ -0,0 +1,14 @@
1
+ ---
2
+ // An image, an SVG or a video, with a caption: <Figure src={plot} alt="…" caption="…" />.
3
+ // Built from Figure.Root, Figure.Media and Figure.Caption; use those to arrange it your way.
4
+ // `poster` (a still frame) is required for a video. Other attributes go on the <figure>.
5
+ import Caption from "./figure/Caption.astro";
6
+ import Media from "./figure/Media.astro";
7
+ import Root from "./figure/Root.astro";
8
+ interface Props { src: any; alt: string; caption?: string; poster?: any; class?: string; [attr: string]: unknown }
9
+ const { src, alt, caption, poster, ...rest } = Astro.props;
10
+ ---
11
+ <Root {...rest}>
12
+ <Media src={src} alt={alt} poster={poster} />
13
+ {caption && <Caption>{caption}</Caption>}
14
+ </Root>
package/src/Grid.astro ADDED
@@ -0,0 +1,8 @@
1
+ ---
2
+ // A grid of cells: <Grid cols={3}><Cell>…</Cell><Cell span={2} rows={2}>…</Cell></Grid>.
3
+ // Unlike Row/Col, cells line up across rows and can span rows. On screen it narrows to two
4
+ // columns, then one; in print it keeps the grid as written.
5
+ interface Props { cols?: number; class?: string; [attr: string]: unknown }
6
+ const { cols = 3, class: cls, style, ...rest } = Astro.props;
7
+ ---
8
+ <div class:list={["posuta-grid", cls]} style={[`--cols:${cols}`, style].filter(Boolean).join(";")} {...rest}><slot /></div>
package/src/QR.astro ADDED
@@ -0,0 +1,13 @@
1
+ ---
2
+ // A QR code as inline SVG: <QR /> points at this page when `site` is set, or <QR url="…" />.
3
+ // Renders nothing when there is no URL. Hook: svg.posuta-qr. Unstyled: size it with CSS.
4
+ import { qrSvg } from "./qr.js";
5
+ interface Props { url?: string; class?: string; [attr: string]: unknown }
6
+ const { url, class: cls, ...rest } = Astro.props;
7
+ const target = url ?? (Astro.site ? new URL(Astro.url.pathname, Astro.site).href : undefined);
8
+ const attrs = Object.entries(rest).map(([k, v]) => ` ${k}="${String(v).replace(/"/g, "&quot;")}"`).join("");
9
+ const svg = target
10
+ ? (await qrSvg(target)).replace("<svg ", `<svg class="${["posuta-qr", cls].filter(Boolean).join(" ")}" role="img" aria-label="QR code: ${target}"${attrs} `)
11
+ : "";
12
+ ---
13
+ <Fragment set:html={svg} />
package/src/Row.astro ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ // A row of columns: <Row><Col>…</Col><Col span={2}>…</Col></Row>.
3
+ // On screen it reflows by width (three → two → one); in print it keeps the columns as written.
4
+ // Any other attribute (class, id, style, data-*) goes on the element.
5
+ const { class: cls, ...rest } = Astro.props;
6
+ ---
7
+ <div class:list={["posuta-row", cls]} {...rest}><slot /></div>
package/src/base.css ADDED
@@ -0,0 +1,62 @@
1
+ /* posuta base: only what the components need to work. No colours, fonts, borders or spacing;
2
+ the look is yours (the template's is in its src/styles/theme.css). Everything is in @layer posuta.base, so any CSS you
3
+ write outside a layer wins without !important. */
4
+ @layer posuta.base, posuta.theme;
5
+ @import "katex/dist/katex.min.css" layer(posuta.base);
6
+
7
+ @layer posuta.base {
8
+ /* The print canvas: one page at the paper size (--paper-w/-h come from <Canvas>'s print CSS). */
9
+ .posuta-canvas { box-sizing: border-box; }
10
+ @media print {
11
+ /* The type scale: body text is 24 pt at A0, follows the paper's area, never below 12 pt, then
12
+ times the author's print scale (--print-text). Size everything in rem and the whole poster
13
+ scales with the paper. */
14
+ :root { font-size: calc(max(calc(24pt * var(--print-scale, 1)), 12pt) * var(--print-text, 1)); -webkit-print-color-adjust: exact; print-color-adjust: exact; }
15
+ body { margin: 0; }
16
+ .posuta-canvas { width: var(--paper-w); height: var(--paper-h); max-width: none; overflow: hidden; }
17
+ }
18
+
19
+ /* Rows and columns: weights from --span; on screen they wrap three → two → one, in print they
20
+ keep the columns as written. --posuta-gap sets the space between columns. */
21
+ .posuta-row { display: flex; gap: 0 var(--posuta-gap, 2rem); align-items: flex-start; }
22
+ .posuta-col { flex: var(--span, 1) 1 0; min-width: 0; }
23
+ @media screen and (max-width: 1100px) {
24
+ .posuta-row { flex-wrap: wrap; }
25
+ .posuta-col { flex: 1 1 calc(50% - var(--posuta-gap, 2rem) / 2); }
26
+ }
27
+ @media screen and (max-width: 700px) { .posuta-col { flex-basis: 100%; } }
28
+
29
+ /* Grids: --cols columns; a cell covers --span columns and --rows rows. On screen they narrow to
30
+ two columns, then one, like rows; in print they keep the grid as written. */
31
+ .posuta-grid { display: grid; grid-template-columns: repeat(var(--cols, 3), minmax(0, 1fr)); gap: 0 var(--posuta-gap, 2rem); align-items: start; }
32
+ .posuta-cell { grid-column: span var(--span, 1); grid-row: span var(--rows, 1); min-width: 0; }
33
+ @media screen and (max-width: 1100px) {
34
+ .posuta-grid { grid-template-columns: repeat(min(var(--cols, 3), 2), minmax(0, 1fr)); }
35
+ .posuta-cell { grid-column: span min(var(--span, 1), 2); grid-row: auto; }
36
+ }
37
+ @media screen and (max-width: 700px) {
38
+ .posuta-grid { grid-template-columns: minmax(0, 1fr); }
39
+ .posuta-cell { grid-column: auto; }
40
+ }
41
+
42
+ /* Screen-only and print-only content. */
43
+ @media screen { [data-print="only"] { display: none !important; } }
44
+ @media print { [data-print="hide"] { display: none !important; } }
45
+
46
+ /* Figures and charts scale to their container; KaTeX's own SVGs (\sqrt) keep their size. */
47
+ .posuta-media, .posuta-figure svg[viewBox]:not(.katex svg), .posuta-plot svg[viewBox]:not(.katex svg) {
48
+ display: block; width: 100%; height: auto; max-width: 100%;
49
+ }
50
+ /* Plot ships an unlayered <style> forcing system-ui; a layered !important is what beats it. */
51
+ .posuta-figure [class*="plot-"] { font-family: inherit !important; }
52
+
53
+ /* Live charts: the tooltip sits above the point and never takes the pointer. */
54
+ .posuta-plot { position: relative; }
55
+ .posuta-tip { position: absolute; z-index: 1; pointer-events: none; white-space: nowrap; transform: translate(-50%, calc(-100% - 12px)); }
56
+ .posuta-tip[hidden] { display: none; }
57
+
58
+ /* KaTeX inside SVG text (charts) and display equations with a number. */
59
+ .posuta-tex { display: inline-block; width: max-content; white-space: nowrap; line-height: 1; }
60
+ .posuta-eq { display: flex; align-items: center; overflow-x: auto; overflow-y: hidden; }
61
+ .posuta-eq .katex-display { flex: 1; }
62
+ }
package/src/bibtex.js ADDED
@@ -0,0 +1,24 @@
1
+ // A BibTeX entry for the poster itself, so the web page can be cited.
2
+ // bibtex({ title, authors: ["Ada Lovelace", "Babbage, Charles"], event: "ICRA 2026", year: 2026, doi, url })
3
+
4
+ const esc = (s) => String(s).replace(/[&%#_$]/g, "\\$&");
5
+ const last = (name) => (name.includes(",") ? name.split(",")[0] : name.trim().split(/\s+/).at(-1));
6
+ const ascii = (s) => s.normalize("NFD").replace(/[^A-Za-z0-9]/g, "").toLowerCase();
7
+
8
+ /**
9
+ * @param {{ title: string, authors: string[], year: number | string, event?: string, doi?: string, url?: string, key?: string }} c
10
+ * authors as "First Last" or "Last, First"; event: where it was presented
11
+ */
12
+ export function bibtex({ title, authors, year, event, doi, url, key }) {
13
+ if (!title || !authors?.length || !year) throw new Error("posuta bibtex: needs title, authors and year");
14
+ key ??= ascii(last(authors[0])) + year + ascii(title.split(/\s+/).find((w) => w.length > 3) ?? "");
15
+ const fields = [
16
+ ["author", authors.map(esc).join(" and ")],
17
+ ["title", `{${esc(title)}}`], // double braces keep its capitals
18
+ ["howpublished", event ? `Poster, ${esc(event)}` : "Poster"],
19
+ ["year", year],
20
+ doi && ["doi", doi],
21
+ url && ["url", url],
22
+ ].filter(Boolean);
23
+ return `@misc{${key},\n${fields.map(([k, v]) => ` ${k.padEnd(12)} = {${v}}`).join(",\n")}\n}`;
24
+ }
@@ -0,0 +1,46 @@
1
+ // Live charts: redraw each <Chart plot="name" interactive|slider> in the browser, from the same
2
+ // chart file in charts/ and the rows embedded at build time. Loaded only on pages that have one.
3
+ const charts = import.meta.glob("/charts/*.js");
4
+
5
+ async function hydrate(fig) {
6
+ const load = charts[`/charts/${fig.dataset.chart}.js`];
7
+ if (!load) throw new Error(`posuta: charts/${fig.dataset.chart}.js is missing from the build`);
8
+ // Plot downloads only once a live chart scrolls into view.
9
+ const [{ Plot, plotLive, withPointer, attachTip }, { default: chart }] = await Promise.all([import("./plot.js"), load()]);
10
+ const rows = JSON.parse(fig.querySelector("script[type='application/json']").textContent);
11
+ const slider = fig.dataset.slider && JSON.parse(fig.dataset.slider);
12
+ const input = fig.querySelector(".posuta-slider input");
13
+ const out = fig.querySelector(".posuta-slider output");
14
+ const box = fig.querySelector(".posuta-plot");
15
+ // The build-time drawing stays as the print frame, whatever the slider or a script shows on screen.
16
+ const still = box.cloneNode(true);
17
+ still.dataset.print = "only";
18
+ box.dataset.print = "hide";
19
+ box.after(still);
20
+ let latest = 0;
21
+ const render = async () => {
22
+ const n = ++latest;
23
+ const state = slider ? { [slider.name]: +input.value } : {};
24
+ if (out) out.value = input.value;
25
+ let options = { ariaLabel: fig.dataset.alt, ...chart(rows, Plot, state) };
26
+ if (fig.hasAttribute("data-interactive")) options = withPointer(options);
27
+ const el = await plotLive(options);
28
+ if (n !== latest) return; // a newer slider position finished first
29
+ box.replaceChildren(el);
30
+ if (fig.hasAttribute("data-interactive")) attachTip(el, options, box);
31
+ };
32
+ if (input) {
33
+ input.closest(".posuta-slider").hidden = false;
34
+ input.addEventListener("input", render);
35
+ }
36
+ await render();
37
+ }
38
+
39
+ // Watch the plot box, not the figure: a figure with display: contents has no box to intersect.
40
+ const seen = new IntersectionObserver((entries) => {
41
+ for (const e of entries) if (e.isIntersecting) {
42
+ seen.unobserve(e.target);
43
+ hydrate(e.target.closest(".posuta-chart")).catch((err) => console.error(err));
44
+ }
45
+ });
46
+ document.querySelectorAll(".posuta-chart[data-chart] > .posuta-plot").forEach((b) => seen.observe(b));
package/src/data.js ADDED
@@ -0,0 +1,28 @@
1
+ // Data files for charts: CSV or JSON from the project's data/ folder, read at build.
2
+ import { readFile } from "node:fs/promises";
3
+ import { extname, isAbsolute, join } from "node:path";
4
+ import { autoType, csvParse } from "d3-dsv";
5
+
6
+ /**
7
+ * Load `data/<file>` as an array of rows. Series longer than `max` rows are thinned.
8
+ * @param {string} file path under data/ (or absolute)
9
+ * @param {{ max?: number }} [opts]
10
+ */
11
+ export async function load(file, { max = 2000 } = {}) {
12
+ const root = globalThis.__posutaRoot ?? process.cwd(); // set by the integration
13
+ const path = isAbsolute(file) ? file : join(root, "data", file);
14
+ const ext = extname(path).toLowerCase();
15
+ let rows;
16
+ if (ext === ".csv") rows = csvParse(await readFile(path, "utf8"), autoType);
17
+ else if (ext === ".json") rows = JSON.parse(await readFile(path, "utf8"));
18
+ else throw new Error(`posuta: cannot load ${file}; use .csv or .json`);
19
+ if (!Array.isArray(rows)) throw new Error(`posuta: ${file} must hold an array of rows`);
20
+ return downsample(rows, max);
21
+ }
22
+
23
+ // shortcut: keeps every k-th row (plus the last), which can drop narrow peaks; switch to LTTB if a plot needs them.
24
+ export function downsample(rows, max) {
25
+ if (!max || rows.length <= max) return rows;
26
+ const k = (rows.length - 1) / (max - 1);
27
+ return Array.from({ length: max }, (_, i) => rows[Math.round(i * k)]);
28
+ }
@@ -0,0 +1,4 @@
1
+ /** In the browser Plot draws into the page's own document. */
2
+ export const dom = () => document;
3
+ /** KaTeX loads on demand in the browser (see plotLive), so pages without chart math skip it. */
4
+ export const katex = null;