Files
klarbild-demo/src/lib/recipe.ts
T
tillandClaude Opus 5 71ffedfaa4 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:42:22 +00:00

149 lines
5.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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();
}