Compare commits

...
2 Commits
Author SHA1 Message Date
Claude 880644e026 German page, and a section for the image module
/ and /de/ are now two pages from one shared component and one copy file, with
hreflang between them and a language button in the header. Separate URLs rather
than a runtime switch: each language is linkable and indexable, and the page
still works without JavaScript. The sheet playground takes its labels from the
page, so its readout says "29,7 cm" in German and "29.7 cm" in English.

New section for the `ai` module, because the page so far read as if Klarbild
were print-only. It says plainly what the module costs and that the endpoint is
configurable, and it carries a preset builder: pick tasks and a target size,
see the exact pixels and the recipe that gets saved. It composes; it does not
generate. A generator here would need a key on a public page and would show a
picture from a model rather than anything about Klarbild — the part worth
showing is the recipe.

The target pixels come from engine/format.ts, copied verbatim from the
application like the layout files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nh4A79bbqT9dcd2inzYUdg
2026-09-15 15:40:07 +00:00
Claude 5ef5dd53a3 Re-sync the engine, point everything at GitHub
The vendored copies of printlayout.ts, paper.ts, printoversize.ts and
tokens.css now match Klarbild 1.0.0 as published. Source links go to GitHub
only — the self-hosted Gitea is the origin, not the public face. Screenshots
and the two stated counts refreshed from the current build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nh4A79bbqT9dcd2inzYUdg
2026-09-15 15:30:49 +00:00
15 changed files with 1128 additions and 375 deletions

No files matched your search

+12 -2
View File
@@ -3,7 +3,10 @@
The product and demo page for [Klarbild](https://github.com/tillheidrich/klarbild),
live at **klarbild-demo.heidrich-digital.de**.
A single static Astro page. The interesting part is `src/lib/playground.ts`: it
Two static Astro pages — `/` in English and `/de/` in German, from one shared
component and one copy file (`src/lib/copy.ts`). The languages are separate URLs
rather than a runtime switch, so each one is linkable, indexable and works without
JavaScript; the button in the header is an ordinary link. The interesting part is `src/lib/playground.ts`: it
imports `src/engine/printlayout.ts` and `src/engine/paper.ts` **verbatim from the
Klarbild repository** and runs them in the visitor's browser. So the sheet you see
being laid out is not a mock-up — it is the application's own arithmetic, and the
@@ -11,6 +14,12 @@ PDF the page hands you is drawn from the same placements (with pdf-lib in the
browser rather than sharp on a server, which is why it carries outlines instead of
photographs).
The preset builder in the image-generation section does the same with
`src/engine/format.ts`: it composes a real Klarbild recipe and shows the exact
target pixels, but it never calls a model. Generating on a public page would mean
a key on that page and a bill for every curious visitor — and the picture would
say something about the model rather than about Klarbild.
Nothing is uploaded and there is no backend.
```bash
@@ -23,7 +32,8 @@ npm run dev
`src/engine/*` are copies. When the print engine changes upstream, copy them again:
```bash
cp ../klarbild/src/lib/{printlayout,paper,printoversize}.ts src/engine/
cp ../klarbild/src/lib/{printlayout,paper,printoversize,format}.ts src/engine/
cp ../klarbild/src/styles/tokens.css src/styles/tokens.css
# printoversize.ts imports './printlayout.ts'; drop the extension for the bundler
sed -i "s#from './printlayout.ts'#from './printlayout'#" src/engine/printoversize.ts
```
Binary file not shown.

Before

Width:  |  Height:  |  Size: 229 KiB

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

+415
View File
@@ -0,0 +1,415 @@
---
import '@fontsource/space-grotesk/400.css';
import '@fontsource/space-grotesk/500.css';
import '@fontsource/space-grotesk/700.css';
import '@fontsource/space-mono/400.css';
import '@fontsource/space-mono/700.css';
import '../styles/tokens.css';
import '../styles/site.css';
import type { Copy } from '../lib/copy';
interface Props { copy: Copy; canonical: string }
const { copy: c, canonical } = Astro.props;
const GITHUB = 'https://github.com/tillheidrich/klarbild';
const SITE = 'https://klarbild-demo.heidrich-digital.de';
// The output sizes the preset builder offers, straight from the application's
// own table. `sticker` carries its own 5 × 5 cm.
const FORMATS = [
{ id: 'keep', label: c.htmlLang === 'de' ? 'Original behalten' : 'Keep original' },
{ id: '9x13', label: '9 × 13 cm' }, { id: '10x15', label: '10 × 15 cm' },
{ id: '13x18', label: '13 × 18 cm' }, { id: '15x20', label: '15 × 20 cm' },
{ id: '20x30', label: '20 × 30 cm' }, { id: '30x40', label: '30 × 40 cm' },
{ id: '30x45', label: '30 × 45 cm' }, { id: '40x50', label: '40 × 50 cm' },
{ id: '40x60', label: '40 × 60 cm' }, { id: '50x70', label: '50 × 70 cm' },
{ id: '60x90', label: '60 × 90 cm' },
{ id: 'A4', label: 'DIN A4' }, { id: 'A3', label: 'DIN A3' }, { id: 'A2', label: 'DIN A2' },
{ id: '20x20', label: '20 × 20 cm' }, { id: '30x30', label: '30 × 30 cm' },
{ id: 'sticker', label: c.htmlLang === 'de' ? 'Sticker 5 × 5 cm' : 'Sticker 5 × 5 cm' },
];
const dpiLine = c.htmlLang === 'de' ? '{w} × {h} px bei {dpi} dpi' : '{w} × {h} px at {dpi} dpi';
---
<!doctype html>
<html lang={c.htmlLang}>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#EAEAE3" />
<title>{c.title}</title>
<meta name="description" content={c.description} />
<link rel="canonical" href={canonical} />
<link rel="alternate" hreflang="en" href={`${SITE}/`} />
<link rel="alternate" hreflang="de" href={`${SITE}/de/`} />
<link rel="alternate" hreflang="x-default" href={`${SITE}/`} />
<meta property="og:type" content="website" />
<meta property="og:title" content={c.title} />
<meta property="og:description" content={c.description} />
<meta property="og:image" content={`${SITE}/shots/print-studio.png`} />
<meta property="og:url" content={canonical} />
<meta property="og:locale" content={c.htmlLang === 'de' ? 'de_DE' : 'en_GB'} />
<meta name="twitter:card" content="summary_large_image" />
</head>
<body>
<header class="top">
<div class="shell top-in">
<a class="brand" href={c.htmlLang === 'de' ? '/de/' : '/'}><span class="regmark"></span>klarbild</a>
<nav>
<a class="hide-s" href="#try">{c.nav.try}</a>
<a class="hide-s" href="#ai">{c.nav.ai}</a>
<a class="hide-m" href="#modules">{c.nav.modules}</a>
<a class="hide-m" href="#install">{c.nav.install}</a>
<a class="langswitch" href={c.otherHref} hreflang={c.otherLabel.toLowerCase()}
title={c.otherLabel === 'DE' ? 'Diese Seite auf Deutsch' : 'This page in English'}>{c.otherLabel}</a>
<a class="cta" href={GITHUB} rel="noopener">{c.nav.source}</a>
</nav>
</div>
</header>
<main>
<div class="hero shell">
<div class="hero-grid">
<div>
<span class="kicker">{c.hero.kicker}</span>
<h1>{c.hero.h1a}<span>{c.hero.h1accent}</span>.</h1>
<p class="lead">{c.hero.lead}</p>
<div class="btnrow">
<a class="btn primary" href="#try">{c.hero.ctaPrimary}</a>
<a class="btn" href={GITHUB} rel="noopener">{c.hero.ctaSecondary}</a>
</div>
</div>
<aside class="hero-side">
<dl>
{c.hero.stats.map((s) => (<><dt class="num">{s.n}</dt><dd>{s.t}</dd></>))}
</dl>
</aside>
</div>
</div>
<!-- ----------------------------------------------------- sheet playground -->
<div class="pg shell" id="try">
<div class="pg-shell" id="playground"
data-busy={c.pg.downloadBusy}
data-all-fit={c.pg.noteAllFit}
data-all-fit-multi={c.pg.noteAllFitMulti}
data-partial={c.pg.notePartial}
data-none={c.pg.noteNone}>
<div class="pg-head">
<h2>{c.pg.title}</h2>
<p class="fine">{c.pg.fine}</p>
</div>
<div class="pg-body">
<div class="pg-controls">
<div class="field">
<label for="paper">{c.pg.paper}</label>
<select id="paper"></select>
</div>
<div class="field">
<label>{c.pg.orientation}</label>
<div class="seg">
<button type="button" data-set="orientation:portrait" aria-pressed="true">{c.pg.portrait}</button>
<button type="button" data-set="orientation:landscape" aria-pressed="false">{c.pg.landscape}</button>
</div>
</div>
<div class="field">
<label for="size">{c.pg.size}</label>
<select id="size"></select>
</div>
<div class="row2">
<div class="field">
<label for="count">{c.pg.count}</label>
<input id="count" type="number" min="1" max="200" value="8" />
</div>
<div class="field">
<label>{c.pg.marks}</label>
<div class="seg">
<button type="button" data-set="marks:none" aria-pressed="false">{c.pg.marksNone}</button>
<button type="button" data-set="marks:corner" aria-pressed="true">{c.pg.marksCorner}</button>
<button type="button" data-set="marks:grid" aria-pressed="false">{c.pg.marksGrid}</button>
</div>
</div>
</div>
<div class="field">
<div class="slider-head"><label for="bleed">{c.pg.bleed}</label><span data-val="bleed">0 mm</span></div>
<input id="bleed" type="range" min="0" max="10" step="1" value="0" />
</div>
<div class="field">
<div class="slider-head"><label for="margin">{c.pg.margin}</label><span data-val="margin">5 mm</span></div>
<input id="margin" type="range" min="0" max="20" step="1" value="5" />
</div>
<div class="toggle">
<button type="button" data-set="rotate:toggle" aria-pressed="true" aria-label={c.pg.rotate}></button>
<span>{c.pg.rotate}</span>
</div>
</div>
<div class="pg-stage">
<div class="stage-sheet">
<svg id="sheet" viewBox="0 0 210 297" role="img" aria-label={c.pg.title}></svg>
</div>
<dl class="readout">
<div><dt>{c.pg.outFit}</dt><dd data-out="fits">–</dd></div>
<div><dt>{c.pg.outSheets}</dt><dd data-out="pages">–</dd></div>
<div><dt>{c.pg.outCell}</dt><dd data-out="cell">–</dd></div>
<div><dt>{c.pg.outPaper}</dt><dd data-out="paper">–</dd></div>
<div><dt>{c.pg.outGap}</dt><dd data-out="gap">–</dd></div>
</dl>
<p class="note" data-out="note" data-tone="ok"></p>
</div>
</div>
<div class="pg-foot">
<button class="btn primary" id="download" type="button">{c.pg.download}</button>
<p>{c.pg.footnote}</p>
</div>
</div>
</div>
<!-- ----------------------------------------------------------------- what -->
<section id="what">
<div class="shell">
<span class="kicker">{c.what.kicker}</span>
<h2>{c.what.h2}</h2>
<div class="facts">
{c.what.facts.map((f) => (<div><h3>{f.h}</h3><p>{f.p}</p></div>))}
</div>
</div>
</section>
<section>
<div class="shell zig">
<div class="prose">
<span class="kicker">{c.shot1.kicker}</span>
<h2>{c.shot1.h2}</h2>
<p class="lead">{c.shot1.lead}</p>
<p>{c.shot1.body}</p>
</div>
<figure>
<img src="/shots/print-studio.png" width="2800" height="1800" loading="lazy" alt={c.shot1.h2} />
<figcaption>{c.shot1.caption}</figcaption>
</figure>
</div>
</section>
<!-- --------------------------------------------------- image generation -->
<section id="ai">
<div class="shell">
<span class="kicker">{c.ai.kicker}</span>
<h2 class="prose">{c.ai.h2}</h2>
<div class="zig" style="margin-top:26px;">
<div class="prose">
<p class="lead">{c.ai.lead}</p>
<p>{c.ai.body}</p>
</div>
<figure>
<img src="/shots/studio.png" width="2800" height="1800" loading="lazy" alt={c.ai.caption} />
<figcaption>{c.ai.caption}</figcaption>
</figure>
</div>
<div class="pg-shell recipe" id="recipe"
data-keep={c.ai.resultKeep}
data-dpi-line={dpiLine}
data-presets={JSON.stringify(c.ai.presets)}>
<div class="pg-head">
<h2>{c.ai.builderTitle}</h2>
<p class="fine">{c.ai.builderFine}</p>
</div>
<div class="recipe-body">
<div class="recipe-controls">
<div class="field">
<label>{c.ai.presetLabel}</label>
<div class="chips">
{c.ai.presets.map((p, i) => (
<button type="button" data-preset={i} aria-pressed="false">{p.name}</button>
))}
</div>
</div>
<div class="field">
<label>{c.ai.tasksLabel}</label>
<div class="tasklist">
{c.ai.tasks.map((t) => (
<button type="button" data-task={t.id} aria-pressed="false">
<b>{t.name}</b><em>{t.hint}</em>
</button>
))}
</div>
</div>
<div class="row2">
<div class="field">
<label for="recipe-format">{c.ai.formatLabel}</label>
<select id="recipe-format">
{FORMATS.map((f) => (<option value={f.id}>{f.label}</option>))}
</select>
</div>
<div class="field">
<label>{c.ai.orientationLabel}</label>
<div class="seg">
<button type="button" data-orient="portrait" aria-pressed="false">{c.pg.portrait}</button>
<button type="button" data-orient="landscape" aria-pressed="true">{c.pg.landscape}</button>
</div>
</div>
</div>
<div class="field">
<label>{c.ai.extLabel}</label>
<div class="seg">
<button type="button" data-ext="" aria-pressed="true">Auto</button>
<button type="button" data-ext="png" aria-pressed="false">PNG</button>
<button type="button" data-ext="jpg" aria-pressed="false">JPG</button>
</div>
</div>
</div>
<div class="recipe-out">
<div class="readout single">
<div><dt>{c.ai.resultLabel}</dt><dd data-r="result">–</dd></div>
</div>
<p class="note" data-r="note" data-tone="ok"></p>
<div class="field">
<label>{c.ai.recipeLabel}</label>
<pre data-r="recipe"></pre>
</div>
</div>
</div>
<div class="pg-foot">
<p>{c.ai.honest}</p>
</div>
</div>
</div>
</section>
<section>
<div class="shell zig flip">
<div class="prose">
<span class="kicker">{c.lang.kicker}</span>
<h2>{c.lang.h2}</h2>
<p class="lead">{c.lang.lead}</p>
<p>{c.lang.body}</p>
</div>
<figure>
<img src="/shots/print-de.png" width="2800" height="1800" loading="lazy" alt={c.lang.h2} />
<figcaption>{c.lang.caption}</figcaption>
</figure>
</div>
</section>
<!-- -------------------------------------------------------------- modules -->
<section id="modules">
<div class="shell">
<span class="kicker">{c.modules.kicker}</span>
<h2>{c.modules.h2}</h2>
<p class="lead prose">{c.modules.lead}</p>
<div class="mods">
{c.modules.rows.map((m) => (
<div class:list={['mod', { core: m.core }]}>
<code>{m.id}</code>
<p><strong>{m.title}</strong> — {m.what}</p>
<span class="need">{m.need}</span>
</div>
))}
</div>
<div class="mod-after">
<p class="prose">{c.modules.off}</p>
<pre><span class="c">{c.modules.envComment1}</span>
KLARBILD_MODULES=print
<span class="c">{c.modules.envComment2}</span>
KLARBILD_MODULES=print,ai,share
<span class="c">{c.modules.envComment3}</span>
KLARBILD_MODULES=all</pre>
</div>
</div>
</section>
<!-- -------------------------------------------------------------- install -->
<section id="install">
<div class="shell split-code">
<div class="prose">
<span class="kicker">{c.install.kicker}</span>
<h2>{c.install.h2}</h2>
<p>{c.install.p1}</p>
<p>{c.install.p2}</p>
<div class="btnrow">
<a class="btn primary" href={GITHUB} rel="noopener">{c.install.cta1}</a>
<a class="btn" href={`${GITHUB}/releases`} rel="noopener">{c.install.cta2}</a>
</div>
</div>
<div>
<pre>git clone {GITHUB}.git
cd klarbild
cp .env.example .env <span class="c">{c.install.comment1}</span>
docker compose up -d
docker compose logs app <span class="c">{c.install.comment2}</span></pre>
<p class="stackline">{c.install.stack}</p>
</div>
</div>
</section>
<section>
<div class="shell prose">
<span class="kicker">{c.privacy.kicker}</span>
<h2>{c.privacy.h2}</h2>
<p class="lead">{c.privacy.lead}</p>
<p>{c.privacy.body}</p>
</div>
</section>
</main>
<footer>
<div class="shell foot-in">
<a class="brand" href={c.htmlLang === 'de' ? '/de/' : '/'}><span class="regmark"></span>klarbild</a>
<a href={GITHUB} rel="noopener">GitHub</a>
<a href={`${GITHUB}/blob/main/LICENSE`} rel="noopener">AGPL-3.0</a>
<a href={c.otherHref}>{c.otherLabel === 'DE' ? 'Deutsch' : 'English'}</a>
<span class="fine">
{c.footer.fine} <a href="https://heidrich-digital.de" rel="noopener">Heidrich Digital</a>.
</span>
</div>
</footer>
<script>
import { mountPlayground } from '../lib/playground';
import { mountRecipe } from '../lib/recipe';
const pg = document.getElementById('playground');
if (pg) {
mountPlayground(pg, {
downloadBusy: pg.dataset.busy!,
noteAllFit: pg.dataset.allFit!,
noteAllFitMulti: pg.dataset.allFitMulti!,
notePartial: pg.dataset.partial!,
noteNone: pg.dataset.none!,
});
}
const rc = document.getElementById('recipe');
if (rc) {
mountRecipe(rc, JSON.parse(rc.dataset.presets!), {
resultKeep: rc.dataset.keep!,
contourNeedsCutout: '',
dpiLine: rc.dataset.dpiLine!,
});
}
</script>
</body>
</html>
+77
View File
@@ -0,0 +1,77 @@
// Target sizes & exact pixel calculation (see 01 §5, 06 §3).
// px = round(cm / 2.54 * dpi)
export type Orientation = 'portrait' | 'landscape';
export interface Dimensions { w: number; h: number; cm?: [number, number]; label: string; }
// cm sizes: [width, height] in the portrait convention (short side × long side)
const CM: Record<string, [number, number]> = {
'9x13': [9, 13], '10x15': [10, 15], '13x18': [13, 18], '15x20': [15, 20],
'20x30': [20, 30], '30x40': [30, 40], '30x45': [30, 45], '40x50': [40, 50],
'40x60': [40, 60], '50x70': [50, 70], '60x90': [60, 90],
A4: [21, 29.7], A3: [29.7, 42], A2: [42, 59.4],
'20x20': [20, 20], '30x30': [30, 30],
};
// Pure screen sizes (fixed pixels, no cm/dpi)
const SCREEN: Record<string, [number, number]> = {
tv169: [3840, 2160], // 16:9 landscape
portrait916: [2160, 3840], // 9:16 portrait
};
export const cmToPx = (cm: number, dpi: number) => Math.round((cm / 2.54) * dpi);
export interface ResolveInput {
format: string; // a key from above, 'sticker', 'custom' or 'keep'
orientation?: Orientation;
dpi?: number;
customCm?: [number, number]; // for 'sticker'/'custom'
}
/** Returns exact target pixels + label. `keep` → null (keep the original). */
export function resolveDimensions(input: ResolveInput): Dimensions | null {
const { format } = input;
const dpi = input.dpi ?? 300;
const orient = input.orientation ?? 'portrait';
if (format === 'keep') return null;
if (format in SCREEN) {
const [w, h] = SCREEN[format];
return { w, h, label: format === 'tv169' ? 'TV 16:9' : 'Portrait' };
}
let cm: [number, number] | undefined;
if (format in CM) cm = CM[format];
else if (format === 'sticker' || format === 'custom') cm = input.customCm ?? [5, 5];
else {
// Read freely defined sizes straight out of the key — no database migration needed:
// "sticker5" / "sticker-7,5" → square N×N cm; "25x35" / "25×35" → W×H cm.
const sq = /^sticker[-_ ]?(\d+(?:[.,]\d+)?)$/i.exec(format);
const wh = /^(\d+(?:[.,]\d+)?)\s*[x×*]\s*(\d+(?:[.,]\d+)?)$/i.exec(format);
const num = (s: string) => parseFloat(s.replace(',', '.'));
if (sq) { const n = num(sq[1]); cm = [n, n]; }
else if (wh) cm = [num(wh[1]), num(wh[2])];
}
if (!cm || !cm[0] || !cm[1] || cm[0] > 300 || cm[1] > 300)
throw new Error(`Unknown format: ${format}`);
// Portrait convention → apply the requested orientation
let [wCm, hCm] = cm;
if (orient === 'landscape') [wCm, hCm] = [hCm, wCm];
return { w: cmToPx(wCm, dpi), h: cmToPx(hCm, dpi), cm: [wCm, hCm],
label: `${cm[0]}×${cm[1]} cm` };
}
/** Simplified aspect ratio as "w:h" for the image API. */
export function aspectRatio(w: number, h: number): string {
const g = gcd(w, h);
return `${Math.round(w / g)}:${Math.round(h / g)}`;
}
function gcd(a: number, b: number): number { return b === 0 ? a : gcd(b, a % b); }
/** Is the native resolution enough for real 300 dpi? (A hint in the UI.) */
export function upscaleWarning(srcW: number, srcH: number, target: Dimensions): boolean {
return srcW < target.w * 0.95 || srcH < target.h * 0.95;
}
+1 -1
View File
@@ -128,7 +128,7 @@ export const PHOTO_SIZES: PhotoSize[] = [
{ id: 'C70x100', label: 'Bookmark 7 × 21 cm', w: 70, h: 210, group: 'Cards' },
// --- Aspect ratios as a print size (worked out on 30 cm) ---
{ id: 'W16x9', label: '16:9 "The Frame" (30 cm wide)', w: 300, h: 168.75, group: 'Aspect ratio' },
{ id: 'W16x9', label: '16:9 TV 16:9 (30 cm wide)', w: 300, h: 168.75, group: 'Aspect ratio' },
{ id: 'W9x16', label: '9:16 portrait (30 cm tall)', w: 168.75, h: 300, group: 'Aspect ratio' },
{ id: 'W21x9', label: '21:9 cinema (30 cm wide)', w: 300, h: 128.57, group: 'Aspect ratio' },
{ id: 'W2x1', label: '2:1 panorama (30 cm wide)', w: 300, h: 150, group: 'Aspect ratio' },
+394
View File
@@ -0,0 +1,394 @@
/**
* Every sentence on this page, in both languages.
*
* The site is static, so the two languages are two pages (`/` and `/de/`)
* rather than a runtime switch. That keeps the page working without
* JavaScript, gives each language its own URL to link and to index, and means
* the language button is an ordinary link.
*/
export type Lang = 'en' | 'de';
export interface Copy {
htmlLang: string;
otherHref: string;
otherLabel: string;
title: string;
description: string;
nav: { try: string; ai: string; modules: string; install: string; source: string };
hero: {
kicker: string;
h1a: string;
h1accent: string;
lead: string;
ctaPrimary: string;
ctaSecondary: string;
stats: { n: string; t: string }[];
};
pg: {
title: string;
fine: string;
paper: string;
orientation: string;
portrait: string;
landscape: string;
size: string;
count: string;
marks: string;
marksNone: string;
marksCorner: string;
marksGrid: string;
bleed: string;
margin: string;
rotate: string;
outFit: string;
outSheets: string;
outCell: string;
outPaper: string;
outGap: string;
download: string;
downloadBusy: string;
footnote: string;
noteAllFit: string;
noteAllFitMulti: string;
notePartial: string;
noteNone: string;
};
what: { kicker: string; h2: string; facts: { h: string; p: string }[] };
shot1: { kicker: string; h2: string; lead: string; body: string; caption: string };
ai: {
kicker: string;
h2: string;
lead: string;
body: string;
caption: string;
builderTitle: string;
builderFine: string;
presetLabel: string;
tasksLabel: string;
tasks: { id: string; name: string; hint: string }[];
formatLabel: string;
orientationLabel: string;
extLabel: string;
resultLabel: string;
resultKeep: string;
recipeLabel: string;
examplesLabel: string;
presets: { name: string; tasks: string[]; format: string; orientation: 'portrait' | 'landscape'; ext?: string; note: string }[];
honest: string;
};
lang: { kicker: string; h2: string; lead: string; body: string; caption: string };
modules: {
kicker: string; h2: string; lead: string; off: string;
rows: { id: string; title: string; what: string; need: string; core?: boolean }[];
envComment1: string; envComment2: string; envComment3: string;
};
install: { kicker: string; h2: string; p1: string; p2: string; cta1: string; cta2: string; comment1: string; comment2: string; stack: string };
privacy: { kicker: string; h2: string; lead: string; body: string };
footer: { fine: string };
}
const TASKS_EN = [
{ id: 'clean', name: 'Clean up', hint: 'Frame, wall and shop interface removed.' },
{ id: 'cutout', name: 'Cut out', hint: 'Subject only, background transparent.' },
{ id: 'format', name: 'Format', hint: 'Brought to an exact size.' },
{ id: 'contour', name: 'Contour', hint: 'White sticker edge. Needs Cut out.' },
];
const TASKS_DE = [
{ id: 'clean', name: 'Bereinigen', hint: 'Rahmen, Wand und Shop-Oberfläche weg.' },
{ id: 'cutout', name: 'Freistellen', hint: 'Nur das Motiv, Hintergrund transparent.' },
{ id: 'format', name: 'Format', hint: 'Auf ein exaktes Maß gebracht.' },
{ id: 'contour', name: 'Kontur', hint: 'Weißer Stickerrand. Braucht Freistellen.' },
];
/** The examples that ship as seeded recipes, plus two the page adds for range. */
const PRESETS_EN = [
{ name: 'Clean up only', tasks: ['clean'], format: 'keep', orientation: 'landscape' as const,
note: 'A marketplace screenshot with the frame, the wall and the shop chrome taken out. Size untouched.' },
{ name: 'Poster 30 × 40', tasks: ['clean', 'format'], format: '30x40', orientation: 'portrait' as const,
note: 'Cleaned and brought to poster size — 3543 × 4724 px at 300 dpi, ready for the print studio.' },
{ name: 'Cut out, transparent PNG', tasks: ['clean', 'cutout'], format: 'keep', orientation: 'landscape' as const, ext: 'png',
note: 'Subject free of its background. PNG, because JPG has no transparency.' },
{ name: 'Sticker 5 cm', tasks: ['clean', 'cutout', 'format', 'contour'], format: 'sticker', orientation: 'landscape' as const,
note: 'Cut out, squared off and given a 3 mm white edge, so a cutting machine has something to follow.' },
{ name: 'Square 30 × 30 for social', tasks: ['clean', 'format'], format: '30x30', orientation: 'landscape' as const,
note: 'One source, one square, no guesswork about where the crop lands.' },
{ name: 'DIN A3 for the wall', tasks: ['clean', 'format'], format: 'A3', orientation: 'portrait' as const,
note: 'The same picture at 29.7 × 42 cm. Change one line of the preset and it is A2.' },
];
const PRESETS_DE = [
{ name: 'Nur bereinigen', tasks: ['clean'], format: 'keep', orientation: 'landscape' as const,
note: 'Ein Marktplatz-Screenshot ohne Rahmen, Wand und Shop-Oberfläche. Maß bleibt, wie es war.' },
{ name: 'Poster 30 × 40', tasks: ['clean', 'format'], format: '30x40', orientation: 'portrait' as const,
note: 'Bereinigt und auf Postermaß gebracht — 3543 × 4724 px bei 300 dpi, fertig fürs Druckstudio.' },
{ name: 'Freistellen, transparentes PNG', tasks: ['clean', 'cutout'], format: 'keep', orientation: 'landscape' as const, ext: 'png',
note: 'Motiv ohne Hintergrund. PNG, weil JPG keine Transparenz kann.' },
{ name: 'Sticker 5 cm', tasks: ['clean', 'cutout', 'format', 'contour'], format: 'sticker', orientation: 'landscape' as const,
note: 'Freigestellt, quadratisch gesetzt und mit 3 mm weißem Rand, damit der Schneideplotter etwas zum Folgen hat.' },
{ name: 'Quadrat 30 × 30 für Social', tasks: ['clean', 'format'], format: '30x30', orientation: 'landscape' as const,
note: 'Eine Quelle, ein Quadrat, kein Raten, wo der Beschnitt landet.' },
{ name: 'DIN A3 für die Wand', tasks: ['clean', 'format'], format: 'A3', orientation: 'portrait' as const,
note: 'Dasselbe Bild auf 29,7 × 42 cm. Eine Zeile im Preset ändern und es ist A2.' },
];
const MODULES_EN = [
{ id: 'print', title: 'Print studio', core: true,
what: 'Exact physical sizes, sheet layout with crop marks, bleed, tiling, 1:1 PDF.', need: 'Nothing. Always on.' },
{ id: 'ai', title: 'Image generation',
what: 'Clean up a screenshot, convert a photo, combine several, generate from text.', need: 'A key for an images API. Paid per image.' },
{ id: 'delivery', title: 'Delivery',
what: 'Push finished files to an SFTP or FTPS server, plus extra targets per folder.', need: 'A server you already have.' },
{ id: 'mirror', title: 'Backup mirror',
what: 'A second copy of every result, filed by month.', need: 'A second SFTP/FTPS target.' },
{ id: 'share', title: 'Share links',
what: 'Public links that expire, can be revoked, can carry a passphrase, and ZIP up a whole set.', need: 'A publicly reachable address.' },
{ id: 'mail', title: 'Email',
what: 'Password reset, job notices, sending share links.', need: 'Your own SMTP mailbox.' },
{ id: 'telegram', title: 'Telegram bot',
what: 'The whole tool from a chat window instead of a browser.', need: 'A bot token from BotFather.' },
];
const MODULES_DE = [
{ id: 'print', title: 'Druckstudio', core: true,
what: 'Exakte physische Maße, Bogen mit Schnittmarken, Beschnittzugabe, Kachelung, 1:1-PDF.', need: 'Nichts. Immer an.' },
{ id: 'ai', title: 'Bildgenerierung',
what: 'Screenshot bereinigen, Foto umwandeln, mehrere kombinieren, aus Text neu erzeugen.', need: 'Ein Schlüssel für eine Bild-API. Kostet je Bild.' },
{ id: 'delivery', title: 'Auslieferung',
what: 'Fertige Dateien auf einen SFTP- oder FTPS-Server schieben, mit Zusatzzielen je Ordner.', need: 'Einen Server, den du schon hast.' },
{ id: 'mirror', title: 'Sicherungsspiegel',
what: 'Eine zweite Kopie jedes Ergebnisses, nach Monat abgelegt.', need: 'Ein zweites SFTP-/FTPS-Ziel.' },
{ id: 'share', title: 'Teilen-Links',
what: 'Öffentliche Links, die ablaufen, widerrufbar sind, ein Zugangswort tragen können und eine ganze Auswahl als ZIP packen.', need: 'Eine öffentlich erreichbare Adresse.' },
{ id: 'mail', title: 'E-Mail',
what: 'Passwort zurücksetzen, Auftragshinweise, Teilen-Links verschicken.', need: 'Ein eigenes Postfach.' },
{ id: 'telegram', title: 'Telegram-Bot',
what: 'Das ganze Werkzeug aus einem Chatfenster statt aus dem Browser.', need: 'Einen Bot-Token von BotFather.' },
];
export const EN: Copy = {
htmlLang: 'en',
otherHref: '/de/',
otherLabel: 'DE',
title: 'Klarbild — print pictures at the size you actually asked for',
description: 'Self-hosted print studio. Put pictures on paper at exact physical sizes, with crop marks, bleed and tiling, and a PDF page that measures what you asked for. Open source, AGPL-3.0. Image generation and delivery are optional modules.',
nav: { try: 'Try it', ai: 'Image generation', modules: 'Modules', install: 'Install', source: 'Source' },
hero: {
kicker: 'Self-hosted · AGPL-3.0',
h1a: 'Print pictures at the size you ',
h1accent: 'actually asked for',
lead: 'Your printer driver has a checkbox called “fit to page”. Tick it and a 35 × 45 mm passport photo comes out 34 × 44. Untick it and you are guessing. Klarbild lays the sheet out in millimetres and hands you a PDF that measures what it says.',
ctaPrimary: 'Lay out a sheet',
ctaSecondary: 'Read the source',
stats: [
{ n: '210.00', t: '× 297.00 mm on the page when you ask for A4 — measured, not assumed' },
{ n: '0', t: 'outbound requests from the print studio. It is arithmetic, sharp and pdf-lib' },
{ n: '124', t: 'tests over the layout engine, the naming scheme and the path checks' },
{ n: '1', t: 'container and a database for the default install' },
],
},
pg: {
title: 'Lay out a sheet',
fine: "Runs the application's own layout code, here in your browser. Nothing is uploaded.",
paper: 'Paper', orientation: 'Sheet orientation', portrait: 'Portrait', landscape: 'Landscape',
size: 'Picture size', count: 'How many', marks: 'Cutting aid',
marksNone: 'None', marksCorner: 'Corner', marksGrid: 'Lines',
bleed: 'Bleed', margin: 'Printer margin', rotate: 'Rotate pictures to save space',
outFit: 'Fit', outSheets: 'Sheets', outCell: 'Cell', outPaper: 'Paper', outGap: 'Gap',
download: 'Download this sheet as a PDF', downloadBusy: 'Building the PDF …',
footnote: 'The demo draws outlines instead of photographs — the real thing puts your pictures in these boxes at the resolution you choose. Measure the PDF: the page and every box are exactly the millimetres shown above.',
noteAllFit: 'All {n} fit on one sheet. Nothing is scaled.',
noteAllFitMulti: 'All {n} fit across {pages} sheets. Nothing is scaled.',
notePartial: '{placed} of {n} fit on {pages} sheet(s).',
noteNone: '{size} does not fit on this sheet at 100 %. In the application this is where you choose overflow or tiling.',
},
what: {
kicker: 'The print studio',
h2: "Everything a photo shop's software does, minus the photo shop.",
facts: [
{ h: '86 image sizes', p: 'In ten groups — passport photo, small prints, photo formats, US inches, instant-camera, poster and frame sizes, squares, DIN, cards, plain aspect ratios. Or type your own: 25x35, 13x18, 10x7.5.' },
{ h: '19 paper formats', p: 'A6 to A1 including A3+, photo paper from 9 × 13 to 20 × 30 cm, US Letter and Legal — or a custom sheet.' },
{ h: 'Crop marks', p: 'Corner marks at 0.25 pt outside the trim, or continuous lines when every cell is the same size. Bleed 0 to 10 mm.' },
{ h: 'Oversize, handled', p: 'A 20 × 30 print on A4 is not an error. Fit tells you it will not go; overflow centres it over the whole sheet and quantifies the loss first; tile spreads it over several sheets with a glue flap and a page number.' },
{ h: 'Crop or letterbox', p: 'When a picture does not match the target ratio you choose per picture — crop with a zoom-and-pan preview, or a border in a colour you pick. Nothing is ever stretched.' },
{ h: 'One engine', p: 'The browser preview and the server-side PDF run the same arithmetic. That file is printlayout.ts, it has no dependencies, and it has its own tests.' },
],
},
shot1: {
kicker: 'In the application',
h2: 'Eight passport photos, one sheet, one cut line each.',
lead: 'Pick the size, say how many, and the sheet fills itself. Equal sizes snap to an exact grid; mixed sizes go through a packer. Every cell carries its measurement as a caption, and the caption gets out of the way when there is no room at that edge.',
body: 'The same picture may appear twice at two different sizes. Layouts you repeat become presets. The last hundred runs are kept as instructions rather than as PDFs — one to three kilobytes each instead of eighty megabytes — so “print that again” costs nothing.',
caption: 'The print studio. Print-only install — no library, no queue, no model.',
},
ai: {
kicker: 'Optional module: ai',
h2: 'And if you do want a model, it is one switch and your own key.',
lead: 'Image generation is a module. It is off until you turn it on, it needs a key you bring yourself, and it costs money per image — which is exactly why it is not the default. With it on, the studio gets four tasks and every combination of them can be saved as a preset.',
body: 'Requests go to {base}/images in the OpenAI image shape. The default base is OpenRouter — one key, many models, and the model list fills itself in. Point IMAGE_API_BASE_URL at a LiteLLM gateway, a company proxy or a server of your own and Klarbild talks to that instead. Either way the pictures go to exactly one endpoint, and you chose which.',
caption: 'The studio with the module on: three modes, four tasks, saved presets, exact output size.',
builderTitle: 'Build a preset',
builderFine: 'Not a generator — this composes the recipe and shows what it would run. No key, no request, no picture leaves this page.',
presetLabel: 'Start from an example',
tasksLabel: 'What should happen?',
tasks: TASKS_EN,
formatLabel: 'Output size',
orientationLabel: 'Orientation',
extLabel: 'File format',
resultLabel: 'Result',
resultKeep: 'Original size kept',
recipeLabel: 'The recipe this saves',
examplesLabel: 'What it is for',
presets: PRESETS_EN,
honest: 'The demo stops at the recipe on purpose. Generating here would mean a key on a public page and a bill for whoever is curious — and it would show you a picture from a model rather than anything about Klarbild. What Klarbild does is the part above: the tasks, the exact target size, and the preset you save once and reuse.',
},
lang: {
kicker: 'Two languages',
h2: 'English by default, German all the way through.',
lead: 'Not a half-translated interface: 1,067 entries, covering every translatable string in the source. The English sentence is the translation key, so a missing entry falls back to readable English rather than to print.sheet.title.',
body: 'One environment variable sets the default; visitors switch with the button in the corner and the choice sticks. Adding a third language means copying one JSON file and translating its values.',
caption: 'The same screen, ?lang=de.',
},
modules: {
kicker: 'Modules',
h2: 'You decide what this install is.',
lead: 'Klarbild is one codebase but not one product. The print studio is the core. Everything that needs an account somewhere else is a module, and every module is off until you switch it on.',
off: 'A module that is off is off: no navigation entry, no settings card, the page redirects, and its API routes answer 404 rather than 403 — a disabled instance does not advertise what it could have done.',
rows: MODULES_EN,
envComment1: '# the default: the print studio and nothing else',
envComment2: '# pick what you want',
envComment3: '# everything',
},
install: {
kicker: 'Install',
h2: 'Four lines, then a password in the log.',
p1: 'The default compose file runs the application and a PostgreSQL, with files on a volume. No object store, no API key, no account anywhere. A second compose file adds MinIO and turns every module on, for people who want that.',
p2: 'There are no built-in accounts. The first boot creates one administrator — from ADMIN_USERNAME and ADMIN_PASSWORD if you set them, and otherwise with a generated password printed to the log exactly once.',
cta1: 'Source on GitHub', cta2: 'Releases',
comment1: '# four values at the top; the file says which',
comment2: '# your admin password, printed once',
stack: 'Astro on Node, PostgreSQL, sharp and pdf-lib. About 13,700 lines across 117 files. Sixteen forward-only migrations that run themselves on boot. Type check, tests and a secret scan run in CI on every push.',
},
privacy: {
kicker: 'Why self-hosted',
h2: 'These are passport photos and family pictures.',
lead: 'The print studio never makes an outbound request at all. With the image module on, only what you hand the model leaves the building, and the key is yours.',
body: 'Share links are built to be careful rather than convenient: they expire after thirty days by default, can be revoked, can carry a passphrase, and are noindex. Access hangs on the link rather than on the picture, so guessing an image id returns a 404. The view counter records when and with what, never who — the address is hashed with a server secret and the link id, one row per visitor per hour, deleted after ninety days.',
},
footer: { fine: 'A demo page. The layout engine is real; the pictures are outlines. Built by' },
};
export const DE: Copy = {
htmlLang: 'de',
otherHref: '/',
otherLabel: 'EN',
title: 'Klarbild — Bilder drucken in genau dem Maß, das du wolltest',
description: 'Selbst gehostetes Druckstudio. Bilder in exakten physischen Maßen aufs Papier bringen — mit Schnittmarken, Beschnittzugabe und Kachelung, und einer PDF-Seite, die misst, was draufsteht. Open Source, AGPL-3.0. Bildgenerierung und Auslieferung sind abschaltbare Module.',
nav: { try: 'Ausprobieren', ai: 'Bildgenerierung', modules: 'Module', install: 'Installieren', source: 'Quellcode' },
hero: {
kicker: 'Selbst gehostet · AGPL-3.0',
h1a: 'Bilder drucken in genau dem Maß, ',
h1accent: 'das du wolltest',
lead: 'Im Druckertreiber gibt es ein Häkchen namens „an Seite anpassen". Mit Häkchen kommt ein 35 × 45 mm Passbild als 34 × 44 heraus. Ohne Häkchen rätst du. Klarbild rechnet den Bogen in Millimetern und gibt dir ein PDF, das misst, was draufsteht.',
ctaPrimary: 'Bogen setzen',
ctaSecondary: 'Quellcode lesen',
stats: [
{ n: '210,00', t: '× 297,00 mm auf der Seite, wenn du A4 sagst — nachgemessen, nicht angenommen' },
{ n: '0', t: 'Anfragen nach außen aus dem Druckstudio. Es ist Rechnen, sharp und pdf-lib' },
{ n: '124', t: 'Tests über Layout-Engine, Namensschema und Pfadprüfungen' },
{ n: '1', t: 'Container und eine Datenbank für die Standardinstallation' },
],
},
pg: {
title: 'Bogen setzen',
fine: 'Rechnet mit dem Layout-Code der Anwendung, hier in deinem Browser. Nichts wird hochgeladen.',
paper: 'Papier', orientation: 'Bogenlage', portrait: 'Hochformat', landscape: 'Querformat',
size: 'Bildmaß', count: 'Wie viele', marks: 'Schnitthilfe',
marksNone: 'Keine', marksCorner: 'Eckmarken', marksGrid: 'Linien',
bleed: 'Beschnittzugabe', margin: 'Druckerrand', rotate: 'Bilder drehen, um Platz zu sparen',
outFit: 'Passen', outSheets: 'Bögen', outCell: 'Zelle', outPaper: 'Papier', outGap: 'Abstand',
download: 'Diesen Bogen als PDF laden', downloadBusy: 'PDF wird gebaut …',
footnote: 'Die Demo zeichnet Umrisse statt Fotos — im Original stehen deine Bilder in diesen Kästen, in der Auflösung, die du wählst. Miss das PDF nach: Seite und jeder Kasten sind genau die Millimeter von oben.',
noteAllFit: 'Alle {n} passen auf einen Bogen. Nichts wird skaliert.',
noteAllFitMulti: 'Alle {n} passen auf {pages} Bögen. Nichts wird skaliert.',
notePartial: '{placed} von {n} passen auf {pages} Bogen.',
noteNone: '{size} passt bei 100 % nicht auf diesen Bogen. In der Anwendung wählst du hier Überstehen oder Kacheln.',
},
what: {
kicker: 'Das Druckstudio',
h2: 'Alles, was die Software im Fotoladen kann, ohne den Fotoladen.',
facts: [
{ h: '86 Bildmaße', p: 'In zehn Gruppen — Passbild, kleine Abzüge, Fotoformate, US-Zoll, Sofortbild, Poster- und Rahmenmaße, Quadrate, DIN, Karten, reine Seitenverhältnisse. Oder selbst tippen: 25x35, 13x18, 10x7,5.' },
{ h: '19 Papierformate', p: 'A6 bis A1 inklusive A3+, Fotopapier von 9 × 13 bis 20 × 30 cm, US Letter und Legal — oder ein freies Maß.' },
{ h: 'Schnittmarken', p: 'Eckmarken mit 0,25 pt außerhalb des Endformats, oder durchgehende Linien, wenn alle Zellen gleich groß sind. Beschnittzugabe 0 bis 10 mm.' },
{ h: 'Größer als der Bogen', p: 'Ein 20 × 30-Abzug auf A4 ist kein Fehler. „Fit" sagt dir, dass es nicht geht; „Überstehen" setzt ihn mittig aufs ganze Blatt und beziffert den Verlust vorher; „Kacheln" verteilt ihn auf mehrere Blätter, mit Klebefalz und Blattnummer.' },
{ h: 'Zuschneiden oder Rand lassen', p: 'Passt ein Bild nicht zum Zielverhältnis, entscheidest du je Bild — zuschneiden mit Zoom-und-Schieben-Vorschau, oder ein Rand in einer Farbe deiner Wahl. Verzerrt wird nie.' },
{ h: 'Ein Rechenkern', p: 'Browser-Vorschau und Server-PDF rechnen dasselbe. Die Datei heißt printlayout.ts, hat keine Abhängigkeiten und eigene Tests.' },
],
},
shot1: {
kicker: 'In der Anwendung',
h2: 'Acht Passbilder, ein Bogen, je eine Schnittlinie.',
lead: 'Maß wählen, Anzahl sagen, der Bogen füllt sich selbst. Gleiche Größen rasten in ein exaktes Raster, gemischte laufen durch einen Packer. Jede Zelle trägt ihr Maß als Beschriftung — und die verschwindet von selbst, wenn an der Kante kein Platz ist.',
body: 'Dasselbe Bild darf zweimal in zwei Maßen vorkommen. Layouts, die sich wiederholen, werden Vorlagen. Die letzten hundert Läufe liegen als Anleitung statt als PDF — ein bis drei Kilobyte statt achtzig Megabyte —, damit „nochmal drucken" nichts kostet.',
caption: 'Das Druckstudio. Reine Druck-Installation — keine Bibliothek, keine Warteschlange, kein Modell.',
},
ai: {
kicker: 'Abschaltbares Modul: ai',
h2: 'Und wenn du ein Modell willst, ist es ein Schalter und dein eigener Schlüssel.',
lead: 'Bildgenerierung ist ein Modul. Es ist aus, bis du es anschaltest, es braucht einen Schlüssel, den du selbst mitbringst, und es kostet Geld je Bild — genau deshalb ist es nicht der Standard. Ist es an, bekommt das Studio vier Aufgaben, und jede Kombination davon lässt sich als Vorlage speichern.',
body: 'Die Anfragen gehen an {base}/images im OpenAI-Bildformat. Voreingestellt ist OpenRouter — ein Schlüssel, viele Modelle, und die Modellliste füllt sich selbst. Zeigt IMAGE_API_BASE_URL auf ein LiteLLM-Gateway, einen Firmen-Proxy oder einen eigenen Server, redet Klarbild mit dem. So oder so gehen die Bilder an genau einen Endpunkt, und den hast du ausgesucht.',
caption: 'Das Studio mit eingeschaltetem Modul: drei Modi, vier Aufgaben, gespeicherte Vorlagen, exaktes Zielmaß.',
builderTitle: 'Eine Vorlage bauen',
builderFine: 'Kein Generator — hier entsteht das Rezept, und es zeigt, was es ausführen würde. Kein Schlüssel, keine Anfrage, kein Bild verlässt diese Seite.',
presetLabel: 'Von einem Beispiel ausgehen',
tasksLabel: 'Was soll passieren?',
tasks: TASKS_DE,
formatLabel: 'Zielmaß',
orientationLabel: 'Ausrichtung',
extLabel: 'Dateiformat',
resultLabel: 'Ergebnis',
resultKeep: 'Originalmaß bleibt',
recipeLabel: 'Das Rezept, das dabei gespeichert wird',
examplesLabel: 'Wofür das gut ist',
presets: PRESETS_DE,
honest: 'Die Demo hört beim Rezept auf, und zwar mit Absicht. Hier zu generieren hieße: ein Schlüssel auf einer öffentlichen Seite und eine Rechnung für jeden, der neugierig ist — und es würde dir ein Bild aus einem Modell zeigen statt irgendetwas über Klarbild. Was Klarbild macht, ist der Teil darüber: die Aufgaben, das exakte Zielmaß und die Vorlage, die du einmal speicherst und dann wiederverwendest.',
},
lang: {
kicker: 'Zwei Sprachen',
h2: 'Englisch als Standard, Deutsch vollständig daneben.',
lead: 'Keine halb übersetzte Oberfläche: 1.067 Einträge, jeder übersetzbare String im Quelltext ist abgedeckt. Der englische Satz ist der Schlüssel — fehlt ein Eintrag, steht da lesbares Englisch und nicht print.sheet.title.',
body: 'Eine Umgebungsvariable setzt den Standard; Besucher schalten mit dem Knopf in der Ecke um, und die Wahl bleibt. Eine dritte Sprache heißt: eine JSON-Datei kopieren und ihre Werte übersetzen.',
caption: 'Derselbe Bildschirm, ?lang=de.',
},
modules: {
kicker: 'Module',
h2: 'Du entscheidest, was diese Installation ist.',
lead: 'Klarbild ist eine Codebasis, aber nicht ein Produkt. Das Druckstudio ist der Kern. Alles, was ein Konto woanders braucht, ist ein Modul — und jedes Modul ist aus, bis du es anschaltest.',
off: 'Ein abgeschaltetes Modul ist wirklich aus: kein Navigationseintrag, keine Einstellungskarte, die Seite leitet um, und seine API-Routen antworten 404 statt 403 — eine abgeschaltete Installation verrät nicht, was sie könnte.',
rows: MODULES_DE,
envComment1: '# der Standard: das Druckstudio und sonst nichts',
envComment2: '# nimm, was du willst',
envComment3: '# alles',
},
install: {
kicker: 'Installieren',
h2: 'Vier Zeilen, dann steht ein Passwort im Log.',
p1: 'Die Standard-Compose-Datei startet die Anwendung und ein PostgreSQL, Dateien liegen auf einem Volume. Kein Objektspeicher, kein API-Schlüssel, kein Konto irgendwo. Eine zweite Compose-Datei nimmt MinIO dazu und schaltet alle Module an — für die, die das wollen.',
p2: 'Es gibt keine eingebauten Konten. Der erste Start legt genau einen Administrator an — aus ADMIN_USERNAME und ADMIN_PASSWORD, wenn du sie setzt, sonst mit einem erzeugten Passwort, das genau einmal ins Log geschrieben wird.',
cta1: 'Quellcode auf GitHub', cta2: 'Releases',
comment1: '# vier Werte ganz oben; die Datei sagt, welche',
comment2: '# dein Admin-Passwort, einmalig ausgegeben',
stack: 'Astro auf Node, PostgreSQL, sharp und pdf-lib. Rund 13.700 Zeilen in 117 Dateien. Sechzehn vorwärts-nur-Migrationen, die beim Start selbst laufen. Typprüfung, Tests und ein Secret-Scan laufen in der CI bei jedem Push.',
},
privacy: {
kicker: 'Warum selbst gehostet',
h2: 'Das sind Passbilder und Familienfotos.',
lead: 'Das Druckstudio macht überhaupt keine Anfrage nach außen. Mit eingeschaltetem Bildmodul verlässt nur das Haus, was du dem Modell gibst — und der Schlüssel gehört dir.',
body: 'Teilen-Links sind auf Vorsicht gebaut, nicht auf Bequemlichkeit: Sie laufen standardmäßig nach dreißig Tagen ab, lassen sich widerrufen, können ein Zugangswort tragen und sind noindex. Der Zugriff hängt am Link, nicht am Bild — eine geratene Bildkennung ergibt 404. Der Zähler hält fest, wann und womit, nie wer: Die Adresse wird mit einem Server-Geheimnis und der Link-Kennung gehasht, eine Zeile je Besucher und Stunde, gelöscht nach neunzig Tagen.',
},
footer: { fine: 'Eine Demoseite. Der Layout-Kern ist echt, die Bilder sind Umrisse. Gebaut von' },
};
export const COPY: Record<Lang, Copy> = { en: EN, de: DE };
+30 -9
View File
@@ -14,6 +14,11 @@
import { layout, cutMarks, effectiveGap, recommendedGap, type PlaceSpec, type SheetSpec, type MarkMode } from '../engine/printlayout';
import { PAPERS, PHOTO_SIZES, labelMm } from '../engine/paper';
/** German writes 29,7 cm where English writes 29.7 cm. Only the separator differs. */
function localiseNumbers(text: string, lang: string): string {
return lang === 'de' ? text.replace(/(\d),(?=\d)/g, '$1\u2009').replace(/(\d)\.(?=\d)/g, '$1,') : text;
}
const MM_PT = 72 / 25.4;
type State = {
@@ -174,7 +179,17 @@ async function buildPdf(): Promise<Blob> {
/* ---------------------------------------------------------------------- wire */
export function mountPlayground(root: HTMLElement) {
interface PgLabels {
downloadBusy: string;
noteAllFit: string;
noteAllFitMulti: string;
notePartial: string;
noteNone: string;
}
export function mountPlayground(root: HTMLElement, labels: PgLabels) {
const lang = document.documentElement.lang || 'en';
const mm = (w: number, h: number) => localiseNumbers(labelMm(w, h), lang);
const svg = root.querySelector<SVGSVGElement>('#sheet')!;
const out = {
fits: root.querySelector<HTMLElement>('[data-out=fits]')!,
@@ -193,7 +208,7 @@ export function mountPlayground(root: HTMLElement) {
for (const p of PAPERS.filter((x) => x.group === group)) {
const o = document.createElement('option');
o.value = p.id;
o.textContent = `${p.label} · ${labelMm(p.w, p.h)}`;
o.textContent = `${p.label} · ${mm(p.w, p.h)}`;
og.appendChild(o);
}
paperSel.appendChild(og);
@@ -222,18 +237,24 @@ export function mountPlayground(root: HTMLElement) {
out.fits.textContent = String(placed);
out.pages.textContent = String(result.pages.length);
out.cell.textContent = labelMm(s.w, s.h);
out.paper.textContent = labelMm(sheet.wMm, sheet.hMm);
out.gap.textContent = `${effectiveGap(sheet).toFixed(1)} mm`;
out.cell.textContent = mm(s.w, s.h);
out.paper.textContent = mm(sheet.wMm, sheet.hMm);
out.gap.textContent = localiseNumbers(`${effectiveGap(sheet).toFixed(1)} mm`, lang);
const fill = (t: string) => t
.replace('{n}', String(wanted))
.replace('{placed}', String(placed))
.replace('{pages}', String(result.pages.length))
.replace('{size}', s.label);
if (placed === 0) {
out.note.textContent = `${s.label} does not fit on this sheet at 100 %. In the application this is where you choose overflow or tiling.`;
out.note.textContent = fill(labels.noteNone);
out.note.dataset.tone = 'warn';
} else if (placed < wanted) {
out.note.textContent = `${placed} of ${wanted} fit on ${result.pages.length} sheet${result.pages.length > 1 ? 's' : ''}.`;
out.note.textContent = fill(labels.notePartial);
out.note.dataset.tone = 'warn';
} else {
out.note.textContent = `All ${placed} fit${result.pages.length > 1 ? ` across ${result.pages.length} sheets` : ' on one sheet'}. Nothing is scaled.`;
out.note.textContent = fill(result.pages.length > 1 ? labels.noteAllFitMulti : labels.noteAllFit);
out.note.dataset.tone = 'ok';
}
}
@@ -269,7 +290,7 @@ export function mountPlayground(root: HTMLElement) {
dl.addEventListener('click', async () => {
const label = dl.textContent;
dl.disabled = true;
dl.textContent = 'Building the PDF …';
dl.textContent = labels.downloadBusy;
try {
const blob = await buildPdf();
const url = URL.createObjectURL(blob);
+148
View File
@@ -0,0 +1,148 @@
/**
* The preset builder.
*
* This composes a Klarbild recipe and shows what it would run — it does not
* run anything. Generating on a public page would mean putting an API key on
* that page and paying for every curious visitor, and the picture it produced
* would tell you about the model rather than about Klarbild. What Klarbild
* decides is the part this does show: which tasks, which exact target size,
* and the preset you save once and reuse.
*
* The target pixels come from `engine/format.ts`, copied verbatim from the
* application, so the numbers here are the numbers it would produce.
*/
import { resolveDimensions } from '../engine/format';
export interface PresetExample {
name: string;
tasks: string[];
format: string;
orientation: 'portrait' | 'landscape';
ext?: string;
note: string;
}
interface Labels {
resultKeep: string;
contourNeedsCutout: string;
dpiLine: string; // "{w} × {h} px at {dpi} dpi"
}
const ALL_TASKS = ['clean', 'cutout', 'format', 'contour'];
export function mountRecipe(root: HTMLElement, presets: PresetExample[], labels: Labels) {
const state = {
tasks: new Set(presets[0]?.tasks ?? ['clean']),
format: presets[0]?.format ?? 'keep',
orientation: (presets[0]?.orientation ?? 'landscape') as 'portrait' | 'landscape',
ext: presets[0]?.ext ?? '',
name: presets[0]?.name ?? '',
note: presets[0]?.note ?? '',
};
const out = {
result: root.querySelector<HTMLElement>('[data-r=result]')!,
note: root.querySelector<HTMLElement>('[data-r=note]')!,
recipe: root.querySelector<HTMLElement>('[data-r=recipe]')!,
};
function dims() {
if (state.format === 'keep') return null;
const customCm: [number, number] | undefined = state.format === 'sticker' ? [5, 5] : undefined;
try {
return resolveDimensions({ format: state.format, orientation: state.orientation, dpi: 300, customCm });
} catch {
return null;
}
}
function render() {
// Contour without Cut out is refused by the application, so the builder
// refuses it too rather than composing a recipe that would be rejected.
if (state.tasks.has('contour') && !state.tasks.has('cutout')) state.tasks.delete('contour');
for (const b of root.querySelectorAll<HTMLButtonElement>('button[data-task]')) {
const id = b.dataset.task!;
b.setAttribute('aria-pressed', String(state.tasks.has(id)));
b.disabled = id === 'contour' && !state.tasks.has('cutout');
}
for (const b of root.querySelectorAll<HTMLButtonElement>('button[data-orient]')) {
b.setAttribute('aria-pressed', String(b.dataset.orient === state.orientation));
}
for (const b of root.querySelectorAll<HTMLButtonElement>('button[data-ext]')) {
b.setAttribute('aria-pressed', String((b.dataset.ext || '') === state.ext));
}
const sel = root.querySelector<HTMLSelectElement>('#recipe-format');
if (sel && sel.value !== state.format) sel.value = state.format;
const d = dims();
out.result.textContent = d
? labels.dpiLine.replace('{w}', String(d.w)).replace('{h}', String(d.h)).replace('{dpi}', '300')
: labels.resultKeep;
out.note.textContent = state.note;
const recipe: Record<string, unknown> = {
name: state.name || '…',
tasks: ALL_TASKS.filter((t) => state.tasks.has(t)),
output_format: state.format,
orientation: state.orientation,
crop_mode: 'crop',
dpi: 300,
};
if (state.ext) recipe.output_ext = state.ext;
if (state.tasks.has('contour')) recipe.contour_mm = 3;
out.recipe.textContent = JSON.stringify(recipe, null, 2);
}
root.addEventListener('click', (e) => {
const el = e.target as HTMLElement;
const task = el.closest<HTMLButtonElement>('button[data-task]');
if (task && !task.disabled) {
const id = task.dataset.task!;
state.tasks.has(id) ? state.tasks.delete(id) : state.tasks.add(id);
state.name = '';
state.note = '';
return render();
}
const orient = el.closest<HTMLButtonElement>('button[data-orient]');
if (orient) {
state.orientation = orient.dataset.orient as 'portrait' | 'landscape';
return render();
}
const ext = el.closest<HTMLButtonElement>('button[data-ext]');
if (ext) {
state.ext = ext.dataset.ext || '';
return render();
}
const preset = el.closest<HTMLButtonElement>('button[data-preset]');
if (preset) {
const p = presets[Number(preset.dataset.preset)];
state.tasks = new Set(p.tasks);
state.format = p.format;
state.orientation = p.orientation;
state.ext = p.ext ?? '';
state.name = p.name;
state.note = p.note;
for (const sib of root.querySelectorAll<HTMLButtonElement>('button[data-preset]')) {
sib.setAttribute('aria-pressed', String(sib === preset));
}
return render();
}
});
root.addEventListener('change', (e) => {
const sel = e.target as HTMLSelectElement;
if (sel.id === 'recipe-format') {
state.format = sel.value;
state.name = '';
state.note = '';
render();
}
});
const first = root.querySelector<HTMLButtonElement>('button[data-preset="0"]');
first?.setAttribute('aria-pressed', 'true');
render();
}
+5
View File
@@ -0,0 +1,5 @@
---
import Page from '../../components/Page.astro';
import { DE } from '../../lib/copy';
---
<Page copy={DE} canonical="https://klarbild-demo.heidrich-digital.de/de/" />
+3 -363
View File
@@ -1,365 +1,5 @@
---
import '@fontsource/space-grotesk/400.css';
import '@fontsource/space-grotesk/500.css';
import '@fontsource/space-grotesk/700.css';
import '@fontsource/space-mono/400.css';
import '@fontsource/space-mono/700.css';
import '../styles/tokens.css';
import '../styles/site.css';
const GITHUB = 'https://github.com/tillheidrich/klarbild';
const GITEA = 'https://git.heidrich-digital.de/till/klarbild-oss';
const modules = [
{ id: 'print', title: 'Print studio', core: true,
what: 'Exact physical sizes, sheet layout with crop marks, bleed, tiling, 1:1 PDF.',
need: 'Nothing. Always on.' },
{ id: 'ai', title: 'Image generation',
what: 'Clean up a screenshot, convert a photo, combine several, generate from text.',
need: 'An OpenRouter key. Paid per image.' },
{ id: 'delivery', title: 'Delivery',
what: 'Push finished files to an SFTP or FTPS server, plus extra targets per folder.',
need: 'A server you already have.' },
{ id: 'mirror', title: 'Backup mirror',
what: 'A second copy of every result, filed by month.',
need: 'A second SFTP/FTPS target.' },
{ id: 'share', title: 'Share links',
what: 'Public links that expire, can be revoked, can carry a passphrase, and ZIP up a whole set.',
need: 'A publicly reachable address.' },
{ id: 'mail', title: 'Email',
what: 'Password reset, job notices, sending share links.',
need: 'Your own SMTP mailbox.' },
{ id: 'telegram', title: 'Telegram bot',
what: 'The whole tool from a chat window instead of a browser.',
need: 'A bot token from BotFather.' },
];
const facts = [
{ h: '86 image sizes', p: 'In ten groups — passport photo, small prints, photo formats, US inches, instant-camera, poster and frame sizes, squares, DIN, cards, plain aspect ratios. Or type your own: 25x35, 13x18, 10x7.5.' },
{ h: '19 paper formats', p: 'A6 to A2 including A3+, photo paper from 9 × 13 to 20 × 30 cm, US Letter and Legal — or a custom sheet.' },
{ h: 'Crop marks', p: 'Corner marks at 0.25 pt outside the trim, or continuous lines when every cell is the same size. Bleed 0 to 10 mm.' },
{ h: 'Oversize, handled', p: 'A 20 × 30 print on A4 is not an error. Fit tells you it will not go; overflow centres it over the whole sheet and quantifies the loss first; tile spreads it over several sheets with a glue flap and a page number.' },
{ h: 'Crop or letterbox', p: 'When a picture does not match the target ratio you choose per picture — crop with a zoom-and-pan preview, or a border in a colour you pick. Nothing is ever stretched.' },
{ h: 'One engine', p: 'The browser preview and the server-side PDF run the same arithmetic. That file is printlayout.ts, it has no dependencies, and it has its own tests.' },
];
import Page from '../components/Page.astro';
import { EN } from '../lib/copy';
---
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#EAEAE3" />
<title>Klarbild — print pictures at the size you actually asked for</title>
<meta name="description" content="Self-hosted print studio. Put pictures on paper at exact physical sizes, with crop marks, bleed and tiling, and a PDF page that measures what you asked for. Open source, AGPL-3.0. Image generation and delivery are optional modules." />
<link rel="canonical" href="https://klarbild-demo.heidrich-digital.de/" />
<meta property="og:type" content="website" />
<meta property="og:title" content="Klarbild — print pictures at the size you actually asked for" />
<meta property="og:description" content="A self-hosted print studio with a sheet layout engine you can try in your browser. Open source, AGPL-3.0." />
<meta property="og:image" content="https://klarbild-demo.heidrich-digital.de/shots/print-studio.png" />
<meta property="og:url" content="https://klarbild-demo.heidrich-digital.de/" />
<meta name="twitter:card" content="summary_large_image" />
</head>
<body>
<header class="top">
<div class="shell top-in">
<a class="brand" href="/"><span class="regmark"></span>klarbild</a>
<nav>
<a class="hide-s" href="#try">Try it</a>
<a class="hide-s" href="#modules">Modules</a>
<a class="hide-s" href="#install">Install</a>
<a class="cta" href={GITHUB} rel="noopener">Source</a>
</nav>
</div>
</header>
<main>
<div class="hero shell">
<div class="hero-grid">
<div>
<span class="kicker">Self-hosted · AGPL-3.0</span>
<h1>Print pictures at the size you <span>actually asked for</span>.</h1>
<p class="lead">
Your printer driver has a checkbox called “fit to page”. Tick it and a
35 × 45 mm passport photo comes out 34 × 44. Untick it and you are guessing.
Klarbild lays the sheet out in millimetres and hands you a PDF that measures
what it says.
</p>
<div class="btnrow">
<a class="btn primary" href="#try">Lay out a sheet</a>
<a class="btn" href={GITHUB} rel="noopener">Read the source</a>
</div>
</div>
<aside class="hero-side">
<dl>
<dt class="num">210.00</dt><dd>× 297.00 mm on the page when you ask for A4 — measured, not assumed</dd>
<dt class="num">0</dt><dd>outbound requests from the print studio. It is arithmetic, sharp and pdf-lib</dd>
<dt class="num">124</dt><dd>tests over the layout engine, the naming scheme and the path checks</dd>
<dt class="num">1</dt><dd>container and a database for the default install</dd>
</dl>
</aside>
</div>
</div>
<!-- ------------------------------------------------------- playground -->
<div class="pg shell" id="try">
<div class="pg-shell" id="playground">
<div class="pg-head">
<h2>Lay out a sheet</h2>
<p class="fine">Runs the application's own layout code, here in your browser. Nothing is uploaded.</p>
</div>
<div class="pg-body">
<div class="pg-controls">
<div class="field">
<label for="paper">Paper</label>
<select id="paper"></select>
</div>
<div class="field">
<label>Sheet orientation</label>
<div class="seg">
<button type="button" data-set="orientation:portrait" aria-pressed="true">Portrait</button>
<button type="button" data-set="orientation:landscape" aria-pressed="false">Landscape</button>
</div>
</div>
<div class="field">
<label for="size">Picture size</label>
<select id="size"></select>
</div>
<div class="row2">
<div class="field">
<label for="count">How many</label>
<input id="count" type="number" min="1" max="200" value="8" />
</div>
<div class="field">
<label>Cutting aid</label>
<div class="seg">
<button type="button" data-set="marks:none" aria-pressed="false">None</button>
<button type="button" data-set="marks:corner" aria-pressed="true">Corner</button>
<button type="button" data-set="marks:grid" aria-pressed="false">Lines</button>
</div>
</div>
</div>
<div class="field">
<div class="slider-head"><label for="bleed">Bleed</label><span data-val="bleed">0 mm</span></div>
<input id="bleed" type="range" min="0" max="10" step="1" value="0" />
</div>
<div class="field">
<div class="slider-head"><label for="margin">Printer margin</label><span data-val="margin">5 mm</span></div>
<input id="margin" type="range" min="0" max="20" step="1" value="5" />
</div>
<div class="toggle">
<button type="button" data-set="rotate:toggle" aria-pressed="true" aria-label="Rotate pictures to save space"></button>
<span>Rotate pictures to save space</span>
</div>
</div>
<div class="pg-stage">
<div class="stage-sheet">
<svg id="sheet" viewBox="0 0 210 297" role="img" aria-label="Sheet preview"></svg>
</div>
<dl class="readout">
<div><dt>Fit</dt><dd data-out="fits">–</dd></div>
<div><dt>Sheets</dt><dd data-out="pages">–</dd></div>
<div><dt>Cell</dt><dd data-out="cell">–</dd></div>
<div><dt>Paper</dt><dd data-out="paper">–</dd></div>
<div><dt>Gap</dt><dd data-out="gap">–</dd></div>
</dl>
<p class="note" data-out="note" data-tone="ok"></p>
</div>
</div>
<div class="pg-foot">
<button class="btn primary" id="download" type="button">Download this sheet as a PDF</button>
<p>
The demo draws outlines instead of photographs — the real thing puts your
pictures in these boxes at the resolution you choose. Measure the PDF: the page
and every box are exactly the millimetres shown above.
</p>
</div>
</div>
</div>
<!-- ---------------------------------------------------------- what -->
<section id="what">
<div class="shell">
<span class="kicker">The print studio</span>
<h2>Everything a photo shop's software does, minus the photo shop.</h2>
<div class="facts">
{facts.map((f) => (
<div><h3>{f.h}</h3><p>{f.p}</p></div>
))}
</div>
</div>
</section>
<section>
<div class="shell zig">
<div class="prose">
<span class="kicker">In the application</span>
<h2>Eight passport photos, one sheet, one cut line each.</h2>
<p class="lead">
Pick the size, say how many, and the sheet fills itself. Equal sizes snap to an
exact grid; mixed sizes go through a packer. Every cell carries its measurement
as a caption, and the caption gets out of the way when there is no room at that edge.
</p>
<p>
The same picture may appear twice at two different sizes. Layouts you repeat become
presets. The last hundred runs are kept as <em>instructions</em> rather than as PDFs —
one to three kilobytes each instead of eighty megabytes — so “print that again” costs
nothing.
</p>
</div>
<figure>
<img src="/shots/print-studio.png" width="2800" height="1800"
alt="The Klarbild print studio with eight passport photos laid out on an A4 sheet, each with corner marks" />
<figcaption>The print studio. Print-only install — no library, no queue, no model.</figcaption>
</figure>
</div>
</section>
<section>
<div class="shell zig flip">
<div class="prose">
<span class="kicker">Two languages</span>
<h2>English by default, German all the way through.</h2>
<p class="lead">
Not a half-translated interface: 1,038 strings, both complete. The English
sentence is the translation key, so a missing entry falls back to readable
English rather than to <code>print.sheet.title</code>.
</p>
<p>
One environment variable sets the default; visitors switch with the button in the
corner and the choice sticks. Adding a third language means copying one JSON file
and translating its values.
</p>
</div>
<figure>
<img src="/shots/print-de.png" width="2800" height="1800"
alt="The same print studio screen rendered in German" />
<figcaption>The same screen, <code>?lang=de</code>.</figcaption>
</figure>
</div>
</section>
<!-- ------------------------------------------------------- modules -->
<section id="modules">
<div class="shell">
<span class="kicker">Modules</span>
<h2>You decide what this install is.</h2>
<p class="lead prose">
Klarbild is one codebase but not one product. The print studio is the core.
Everything that needs an account somewhere else is a module, and every module is
off until you switch it on. A module that is off has no navigation entry, no
settings card, and its API routes answer 404 rather than 403 — a disabled instance
does not advertise what it could have done.
</p>
<div class="mods">
{modules.map((m) => (
<div class:list={['mod', { core: m.core }]}>
<code>{m.id}</code>
<p><strong>{m.title}</strong> — {m.what}</p>
<span class="need">{m.need}</span>
</div>
))}
</div>
<div style="margin-top:28px; max-width:56ch;">
<pre><span class="c"># the default: the print studio and nothing else</span>
KLARBILD_MODULES=print
<span class="c"># pick what you want</span>
KLARBILD_MODULES=print,ai,share
<span class="c"># everything</span>
KLARBILD_MODULES=all</pre>
</div>
</div>
</section>
<!-- ------------------------------------------------------- install -->
<section id="install">
<div class="shell split-code">
<div class="prose">
<span class="kicker">Install</span>
<h2>Four lines, then a password in the log.</h2>
<p>
The default compose file runs the application and a PostgreSQL, with files on a
volume. No object store, no API key, no account anywhere. A second compose file
adds MinIO and turns every module on, for people who want that.
</p>
<p>
There are no built-in accounts. The first boot creates one administrator — from
<code>ADMIN_USERNAME</code> and <code>ADMIN_PASSWORD</code> if you set them, and
otherwise with a generated password printed to the log exactly once.
</p>
<div class="btnrow">
<a class="btn primary" href={GITHUB} rel="noopener">GitHub</a>
<a class="btn" href={GITEA} rel="noopener">Gitea</a>
</div>
</div>
<div>
<pre>git clone {GITHUB}.git
cd klarbild
cp .env.example .env <span class="c"># three secrets, the file says which</span>
docker compose up -d
docker compose logs app <span class="c"># your admin password, printed once</span></pre>
<p style="font-size:.84rem; color:var(--ink-soft); margin-top:14px;">
Astro 5 on Node, PostgreSQL, sharp and pdf-lib. About 13,600 lines across 116
files. Sixteen forward-only migrations that run themselves on boot. Type check,
tests and a secret scan run in CI on every push.
</p>
</div>
</div>
</section>
<section>
<div class="shell prose">
<span class="kicker">Why self-hosted</span>
<h2>These are passport photos and family pictures.</h2>
<p class="lead">
The print studio never makes an outbound request at all. With the AI module on, only
what you hand the model leaves the building, and the key is yours.
</p>
<p>
Share links are built to be careful rather than convenient: they expire after thirty
days by default, can be revoked, can carry a passphrase, and are <code>noindex</code>.
Access hangs on the link rather than on the picture, so guessing an image id returns
a 404. The view counter records <em>when</em> and <em>with what</em>, never <em>who</em> —
the address is hashed with a server secret and the link id, one row per visitor per
hour, deleted after ninety days.
</p>
</div>
</section>
</main>
<footer>
<div class="shell foot-in">
<a class="brand" href="/"><span class="regmark"></span>klarbild</a>
<a href={GITHUB} rel="noopener">GitHub</a>
<a href={GITEA} rel="noopener">Gitea</a>
<a href={`${GITHUB}/blob/main/LICENSE`} rel="noopener">AGPL-3.0</a>
<span class="fine">
A demo page. The layout engine is real; the pictures are outlines. Built by
<a href="https://heidrich-digital.de" rel="noopener">Heidrich Digital</a>.
</span>
</div>
</footer>
<script>
import { mountPlayground } from '../lib/playground';
const root = document.getElementById('playground');
if (root) mountPlayground(root);
</script>
</body>
</html>
<Page copy={EN} canonical="https://klarbild-demo.heidrich-digital.de/" />
+43
View File
@@ -202,3 +202,46 @@ footer a { color: var(--ink-soft); text-decoration: none; font-size: 0.88rem; }
footer a:hover { color: var(--accent); }
footer .fine { font-size: 0.8rem; color: var(--ink-soft); margin-left: auto; }
@media (max-width: 620px) { footer .fine { margin-left: 0; width: 100%; } }
/* ------------------------------------------------- language switch & nav bits */
.top nav a.langswitch { font-family: var(--font-mono); font-size: 0.72rem; letter-spacing: .08em;
border: 1px solid var(--line); padding: 6px 9px; border-radius: 999px; line-height: 1; }
.top nav a.langswitch:hover { color: var(--accent); border-color: var(--accent); background: transparent; }
@media (max-width: 980px) { .top nav a.hide-m { display: none; } }
/* ------------------------------------------------------------ preset builder */
.recipe { margin-top: clamp(28px, 4vw, 48px); }
.recipe-body { display: grid; grid-template-columns: minmax(0, 1fr); }
@media (min-width: 900px) { .recipe-body { grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr); } }
.recipe-controls { padding: 20px 18px; display: grid; gap: 18px; align-content: start; }
@media (min-width: 900px) { .recipe-controls { border-right: 1px solid var(--line); } }
.recipe-out { padding: 20px 18px; display: grid; gap: 14px; align-content: start;
background: repeating-linear-gradient(45deg, transparent 0 11px, oklch(21% 0.01 95 / 0.015) 11px 22px); }
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
.chips button { font-family: inherit; font-size: 0.82rem; padding: 7px 11px; cursor: pointer;
border: 1px solid var(--line); border-radius: 999px; background: var(--paper); color: var(--ink-soft);
transition: border-color .15s, color .15s, background .15s; }
.chips button:hover { color: var(--ink); border-color: var(--ink-soft); }
.chips button[aria-pressed=true] { background: var(--accent-bg); border-color: var(--accent);
color: var(--accent); font-weight: 600; }
.tasklist { display: grid; gap: 6px; }
@media (min-width: 560px) { .tasklist { grid-template-columns: 1fr 1fr; } }
.tasklist button { display: grid; gap: 2px; text-align: left; font-family: inherit; cursor: pointer;
border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--paper);
padding: 10px 12px; transition: border-color .15s, background .15s; }
.tasklist button b { font-size: 0.92rem; color: var(--ink); font-weight: 600; }
.tasklist button em { font-style: normal; font-size: 0.78rem; color: var(--ink-soft); line-height: 1.4; }
.tasklist button[aria-pressed=true] { background: var(--accent-bg); border-color: var(--accent); }
.tasklist button[aria-pressed=true] b { color: var(--accent); }
.tasklist button[aria-pressed=true] b::after { content: ' ✓'; }
.tasklist button:disabled { opacity: .45; cursor: not-allowed; }
.readout.single { grid-template-columns: minmax(0, 1fr); }
.recipe-out pre { font-size: 0.78rem; line-height: 1.65; max-height: 280px; }
.mod-after { display: grid; gap: 22px; margin-top: 26px; align-items: start; }
@media (min-width: 880px) { .mod-after { grid-template-columns: minmax(0, 1fr) minmax(0, 0.8fr); gap: 40px; } }
.stackline { font-size: .84rem; color: var(--ink-soft); margin-top: 14px; }