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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions docs/superpowers/specs/2026-07-31-image-stamp-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# Image Stamp Tool — Design

**Date:** 2026-07-31
**Tool:** Image → Stamp (`/tools/image-stamp`) — NEW
**Type:** New tool
**Icon:** `Stamp` (lucide-react)

## Problem

Users want to slap a document-status **stamp** onto an image — the classic bordered
"rubber stamp" mark (CONFIDENTIAL, PAID, …), not a subtle repeated watermark. It should
be customizable: text, bold, italic, font family, color, placement, and an optional
border box.

This is distinct from the existing **Watermark** tool (subtle, tiled/diagonal, protective
overlay). A stamp is a single bold status mark.

## Goal

A new client-side tool that composites a rubber-stamp mark onto an uploaded/pasted image
and returns it via the shared `ImageResult` (Download / Copy / Edit in Annotator).

## Design

### Files

- `src/tools/image/stamp.lib.ts` — pure logic (helpers + geometry + `stampImage`).
- `src/tools/image/stamp.lib.test.ts` — unit tests for the pure helpers/geometry.
- `src/islands/image/ImageStamp.tsx` — thin island (default export).
- `src/registry/tools.ts` — register `image-stamp` (Image, `Stamp` icon, `status: 'beta'`).

### Controls (island)

- **Dropzone** + paste (`usePasteImage`) — same pattern as Watermark.
- **Preset chips** — clicking one fills the text and sets a sensible default color; text
stays editable. Presets:
`Confidential` (red), `Paid` (green), `Draft` (gray), `Approved` (green), `Void` (red),
`Urgent` (red), `Copy` (blue), `Original` (blue), `Sample` (orange), `For Review` (orange).
- **Text** input (free text).
- **Font family** dropdown: Sans / Serif / Mono / Condensed.
- **Bold** toggle, **Italic** toggle.
- **Color** picker.
- **Border box** toggle (default ON) — the bordered rubber-stamp look; off = plain text.
- **Placement**: Center (diagonal) / Top-left / Top-right / Bottom-left / Bottom-right.
- **Scale** slider (1–100%) and **Opacity** slider (1–100%, default 85%).
- **Apply stamp** / **Clear** buttons → `ImageResult`.

### Library API (`stamp.lib.ts`)

```ts
export type StampFont = 'sans' | 'serif' | 'mono' | 'condensed';
export type StampPlacement = 'center' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';

export interface StampOptions {
text: string;
color: string; // hex
bold: boolean;
italic: boolean;
font: StampFont;
bordered: boolean;
placement: StampPlacement;
scale: number; // 1–100 percent
opacity: number; // 1–100 percent
}

// Pure, unit-tested:
export const STAMP_PRESETS: { label: string; color: string }[];
export function fontStackFor(font: StampFont): string;
export function stampFontScale(percent: number): number; // 1/16..1/3, clamped
export interface StampGeometry { cx: number; cy: number; boxW: number; boxH: number; rotation: number; }
export function stampGeometry(args: {
canvasW: number; canvasH: number; textW: number; fontSize: number; placement: StampPlacement;
}): StampGeometry;

// Canvas draw (build + manual smoke, like watermarkImage):
export function stampImage(file: File, options: StampOptions): Promise<ProcessedImage>;
```

**Geometry rules** (`stampGeometry`, pure):
- `padding = fontSize * 0.4`; `boxW = textW + padding*2`; `boxH = fontSize + padding*2`.
- `center`: `cx=W/2, cy=H/2, rotation = -20°` (radians). This is the classic diagonal look.
- corners: `margin = fontSize * 0.6`; box centered `margin` in from the chosen corner;
`rotation = 0`.

**`stampImage` draw** (in canvas):
1. `createImageBitmap(file)` → draw onto a canvas of the same size.
2. Compute `fontSize = max(14, round(min(W,H) * stampFontScale(scale)))`, set
`ctx.font = \`${italic?'italic ':''}${bold?'bold ':''}${fontSize}px ${fontStackFor(font)}\``.
3. `textW = ctx.measureText(text).width`; `g = stampGeometry(...)`.
4. `ctx.globalAlpha = opacity/100`; translate to `(g.cx,g.cy)`, rotate `g.rotation`.
5. If `bordered`: stroke a rounded-rect of `g.boxW × g.boxH` centered at origin, with
`lineWidth = max(2, fontSize*0.1)`, same color.
6. Draw text centered (`textAlign='center'`, `textBaseline='middle'`) in `color`.
7. Restore alpha; `encodeCanvas` preserving the input format (`keepFormat`).

Reuse `keepFormat`, `encodeCanvas`, `ProcessedImage` from `canvas.lib.ts`.

## Testing (`stamp.lib.test.ts`, jsdom — no real canvas)

- `fontStackFor` returns the right stack for each family (e.g. `mono` → contains `monospace`).
- `stampFontScale(1)` ≈ 1/16; `(100)` === 1/3; monotonic; clamps `[1,100]`.
- `STAMP_PRESETS` includes all 10 labels; each has a valid `#hex` color.
- `stampGeometry`:
- `center` → `cx=W/2, cy=H/2`, rotation ≈ `-Math.PI/9` (-20°).
- each corner → correct `cx/cy` given margin & box size, rotation `0`.
- `boxW/boxH` derived from `textW/fontSize` + padding.

`stampImage` and the island: build + manual smoke (upload → stamp → download).

## Out of scope

- Image-based/logo stamps (text only for now).
- Multiple stamps at once.
- Per-preset font/rotation presets (all presets share the same style controls).
234 changes: 234 additions & 0 deletions src/islands/image/ImageStamp.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
import { useState } from 'react';
import { Dropzone } from '@/components/ui/Dropzone';
import { Button } from '@/components/ui/Button';
import { Alert } from '@/components/ui/Alert';
import { ImageResult } from '@/components/ui/ImageResult';
import { keepFormat } from '@/tools/image/canvas.lib';
import {
stampImage,
STAMP_PRESETS,
type StampFont,
type StampPlacement,
} from '@/tools/image/stamp.lib';
import { usePasteImage } from '@/hooks/usePasteImage';

const FONTS: { value: StampFont; label: string }[] = [
{ value: 'sans', label: 'Sans' },
{ value: 'serif', label: 'Serif' },
{ value: 'mono', label: 'Mono' },
{ value: 'condensed', label: 'Condensed' },
];

const PLACEMENTS: { value: StampPlacement; label: string }[] = [
{ value: 'center', label: 'Center' },
{ value: 'top-left', label: 'Top left' },
{ value: 'top-right', label: 'Top right' },
{ value: 'bottom-left', label: 'Bottom left' },
{ value: 'bottom-right', label: 'Bottom right' },
];

export default function ImageStamp() {
const [file, setFile] = useState<File | null>(null);
const [text, setText] = useState('CONFIDENTIAL');
const [color, setColor] = useState('#c0392b');
const [font, setFont] = useState<StampFont>('sans');
const [bold, setBold] = useState(true);
const [italic, setItalic] = useState(false);
const [bordered, setBordered] = useState(true);
const [placement, setPlacement] = useState<StampPlacement>('center');
const [scale, setScale] = useState(40);
const [opacity, setOpacity] = useState(85);
const [result, setResult] = useState<Blob | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');

const onDrop = (files: File[]) => {
setFile(files.find(f => f.type.startsWith('image/')) ?? null);
setResult(null);
setError('');
};

usePasteImage(f => onDrop([f]));

const applyPreset = (label: string, presetColor: string) => {
setText(label.toUpperCase());
setColor(presetColor);
setResult(null);
};

const outName = file
? file.name.replace(/\.[^.]+$/, '') + '-stamped.' + keepFormat(file.type).ext
: 'stamped.png';

const run = async () => {
if (!file || !text.trim()) return;
setBusy(true);
setError('');
setResult(null);
try {
const { blob } = await stampImage(file, {
text: text.trim(),
color,
bold,
italic,
font,
bordered,
placement,
scale,
opacity,
});
setResult(blob);
} catch (e) {
setError(e instanceof Error ? e.message : 'Stamp failed');
} finally {
setBusy(false);
}
};

return (
<div className="space-y-4">
<Dropzone onDrop={onDrop} accept="image/*" multiple={false}>
<div className="space-y-1">
<p className="text-lg font-bold">Drop an image or click to browse</p>
<p className="text-sm text-muted-foreground">Stamp a status mark onto an image · or paste (⌘V)</p>
</div>
</Dropzone>

{file && <p className="text-sm font-bold text-foreground">{file.name}</p>}

<div className="space-y-1.5">
<span className="block text-sm font-bold uppercase tracking-wide text-muted-foreground">
Presets
</span>
<div className="flex flex-wrap gap-2">
{STAMP_PRESETS.map(p => (
<Button key={p.label} variant="secondary" onClick={() => applyPreset(p.label, p.color)}>
{p.label}
</Button>
))}
</div>
</div>

<label className="block space-y-1.5">
<span className="block text-sm font-bold uppercase tracking-wide text-muted-foreground">
Stamp text
</span>
<input
value={text}
onChange={e => setText(e.target.value)}
className="w-full border-2 border-border bg-muted px-3 py-2 text-sm outline-none focus:shadow-brutal-sm"
/>
</label>

<div className="space-y-1.5">
<span className="block text-sm font-bold uppercase tracking-wide text-muted-foreground">
Placement
</span>
<div className="flex flex-wrap gap-2">
{PLACEMENTS.map(({ value, label }) => (
<Button
key={value}
variant={placement === value ? 'primary' : 'secondary'}
aria-pressed={placement === value}
onClick={() => setPlacement(value)}
>
{label}
</Button>
))}
</div>
</div>

<div className="flex flex-wrap items-end gap-6">
<div className="space-y-1.5">
<span className="block text-sm font-bold uppercase tracking-wide text-muted-foreground">
Font
</span>
<div className="flex flex-wrap gap-2">
{FONTS.map(({ value, label }) => (
<Button
key={value}
variant={font === value ? 'primary' : 'secondary'}
aria-pressed={font === value}
onClick={() => setFont(value)}
>
{label}
</Button>
))}
</div>
</div>

<div className="space-y-1.5">
<span className="block text-sm font-bold uppercase tracking-wide text-muted-foreground">
Style
</span>
<div className="flex flex-wrap gap-2">
<Button variant={bold ? 'primary' : 'secondary'} aria-pressed={bold} onClick={() => setBold(b => !b)}>
Bold
</Button>
<Button variant={italic ? 'primary' : 'secondary'} aria-pressed={italic} onClick={() => setItalic(i => !i)}>
Italic
</Button>
<Button variant={bordered ? 'primary' : 'secondary'} aria-pressed={bordered} onClick={() => setBordered(b => !b)}>
Border box
</Button>
</div>
</div>

<label className="space-y-1.5">
<span className="block text-sm font-bold uppercase tracking-wide text-muted-foreground">
Color
</span>
<input
type="color"
value={color}
onChange={e => setColor(e.target.value)}
className="h-11 w-16 cursor-pointer border-2 border-border bg-muted"
/>
</label>
</div>

<div className="flex flex-wrap items-end gap-6">
<label className="flex-1 space-y-1.5">
<span className="flex justify-between text-sm font-bold uppercase tracking-wide text-muted-foreground">
<span>Scale</span>
<span>{scale}%</span>
</span>
<input
type="range"
min={1}
max={100}
value={scale}
onChange={e => setScale(Number(e.target.value))}
className="w-full accent-accent"
/>
</label>
<label className="flex-1 space-y-1.5">
<span className="flex justify-between text-sm font-bold uppercase tracking-wide text-muted-foreground">
<span>Opacity</span>
<span>{opacity}%</span>
</span>
<input
type="range"
min={10}
max={100}
value={opacity}
onChange={e => setOpacity(Number(e.target.value))}
className="w-full accent-accent"
/>
</label>
</div>

<div className="flex flex-wrap gap-2">
<Button onClick={run} disabled={!file || !text.trim() || busy}>
{busy ? 'Stamping…' : 'Apply stamp'}
</Button>
<Button variant="ghost" onClick={() => { setFile(null); setResult(null); setError(''); }}>
Clear
</Button>
</div>

{error && <Alert variant="error">{error}</Alert>}
{result && <ImageResult blob={result} filename={outName} />}
</div>
);
}
11 changes: 11 additions & 0 deletions src/registry/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,17 @@ export const tools: ToolDef[] = [
load: () => import('@/islands/image/ImageWatermark'),
status: 'stable'
},
{
id: 'image-stamp',
name: 'Image Stamp',
category: 'Image',
route: '/tools/image-stamp',
keywords: ['image', 'stamp', 'confidential', 'paid', 'draft', 'approved', 'rubber stamp', 'status', 'mark'],
icon: Stamp,
summary: 'Stamp CONFIDENTIAL, PAID and other status marks onto an image',
load: () => import('@/islands/image/ImageStamp'),
status: 'beta'
},
{
id: 'image-merge',
name: 'Merge Images',
Expand Down
2 changes: 1 addition & 1 deletion src/tools/image/canvas.lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export function keepFormat(type: string): { mime: string; ext: string; quality?:
return { mime: 'image/png', ext: 'png' };
}

async function encodeCanvas(
export async function encodeCanvas(
canvas: HTMLCanvasElement,
mimeType: string,
quality?: number
Expand Down
Loading
Loading