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
72 changes: 72 additions & 0 deletions docs/superpowers/specs/2026-07-31-watermark-scale-slider-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Watermark Scale Slider — Design

**Date:** 2026-07-31
**Tool:** Image → Watermark (`/tools/image-watermark`)
**Type:** Improvement (UX control)

## Problem

The Watermark tool exposes size as three preset buttons (Small `1/16`, Medium `1/10`,
Large `1/6`). Users want continuous control over the watermark size, where a larger
value produces both a bigger font **and** a wider gap between tiled repetitions.

Note: color (font color picker) and transparency (opacity slider) **already exist** in
the tool — this change is scoped to the size control only.

## Goal

Replace the three Size buttons with a continuous **Scale** slider, matching the visual
style of the existing Opacity slider, with a live readout.

## Design

### UI (island: `src/islands/image/ImageWatermark.tsx`)

- Remove the `SIZES` preset buttons block.
- Add a range slider labelled **Scale** with a live `NN%` readout, styled like the
existing Opacity range input (`type="range"`, `accent-accent`).
- State: `const [scale, setScale] = useState(30)` (percent, 1–100). Default 30%.
- The slider value (percent) maps to the library's `fontScale` fraction:

```
const MIN_FS = 1 / 24; // ≈ 0.0417 (narrow)
const MAX_FS = 1 / 4; // 0.25 (big)
fontScale = MIN_FS + (scale / 100) * (MAX_FS - MIN_FS);
```

30% → ≈ 0.104 (close to the old "Medium" default, so behavior is familiar).

### Library (`src/tools/image/canvas.lib.ts`)

No signature change. `watermarkImage` already accepts a continuous `fontScale`, and the
tiled layout already derives its gap from font size:

```
const stepX = textWidth + fontSize * 2;
const stepY = fontSize * 4;
```

So a larger `fontScale` already yields a bigger font **and** proportionally wider gaps —
"smaller = narrow, bigger = big font & wider gap" is satisfied by the existing math.

We add a **helper + test** to lock the mapping so the contract can't silently regress:

- Export a pure `scaleToFontScale(percent: number): number` from `canvas.lib.ts`
implementing the mapping above (clamped to 1–100). This keeps the magic numbers in
the tested lib rather than the island.

## Testing

`src/tools/image/canvas.lib.test.ts` (extend):

- `scaleToFontScale(1)` ≈ `1/24`; `scaleToFontScale(100)` === `0.25`.
- Monotonic: `scaleToFontScale(20) < scaleToFontScale(80)`.
- Clamps: `scaleToFontScale(0)` === `scaleToFontScale(1)`; `scaleToFontScale(150)` === `scaleToFontScale(100)`.

Island is covered by build + manual smoke (slider moves, watermark grows, tiled gap widens).

## Out of scope

- Color and opacity controls (already shipped).
- Layout options (Diagonal / Tiled / Corner) — unchanged.
- No change to output format handling.
41 changes: 16 additions & 25 deletions src/islands/image/ImageWatermark.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ 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 { watermarkImage, keepFormat, type WatermarkLayout } from '@/tools/image/canvas.lib';
import { watermarkImage, keepFormat, scaleToFontScale, type WatermarkLayout } from '@/tools/image/canvas.lib';
import { usePasteImage } from '@/hooks/usePasteImage';

const LAYOUTS: { value: WatermarkLayout; label: string }[] = [
Expand All @@ -12,17 +12,11 @@ const LAYOUTS: { value: WatermarkLayout; label: string }[] = [
{ value: 'bottom-right', label: 'Corner' },
];

const SIZES = [
{ value: 1 / 16, label: 'Small' },
{ value: 1 / 10, label: 'Medium' },
{ value: 1 / 6, label: 'Large' },
];

export default function ImageWatermark() {
const [file, setFile] = useState<File | null>(null);
const [text, setText] = useState('© GoodWebTools');
const [layout, setLayout] = useState<WatermarkLayout>('diagonal');
const [fontScale, setFontScale] = useState(1 / 10);
const [scale, setScale] = useState(30);
const [opacity, setOpacity] = useState(60);
const [color, setColor] = useState('#808080');
const [result, setResult] = useState<Blob | null>(null);
Expand Down Expand Up @@ -50,7 +44,7 @@ export default function ImageWatermark() {
const { blob } = await watermarkImage(file, {
text: text.trim(),
layout,
fontScale,
fontScale: scaleToFontScale(scale),
opacity: opacity / 100,
color,
});
Expand Down Expand Up @@ -102,23 +96,20 @@ export default function ImageWatermark() {
</div>
</div>

<div className="space-y-1.5">
<span className="block text-sm font-bold uppercase tracking-wide text-muted-foreground">
Size
<label className="block 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>
<div className="flex flex-wrap gap-2">
{SIZES.map(({ value, label }) => (
<Button
key={label}
variant={fontScale === value ? 'primary' : 'secondary'}
aria-pressed={fontScale === value}
onClick={() => setFontScale(value)}
>
{label}
</Button>
))}
</div>
</div>
<input
type="range"
min={1}
max={100}
value={scale}
onChange={e => setScale(Number(e.target.value))}
className="w-full accent-accent"
/>
</label>

<div className="flex flex-wrap items-end gap-6">
<label className="flex-1 space-y-1.5">
Expand Down
17 changes: 16 additions & 1 deletion src/tools/image/canvas.lib.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { scaleToWidth, scaleToHeight, formatBytes } from './canvas.lib';
import { scaleToWidth, scaleToHeight, formatBytes, scaleToFontScale } from './canvas.lib';

describe('scaleToWidth', () => {
it('preserves aspect ratio when scaling by width', () => {
Expand All @@ -24,6 +24,21 @@ describe('scaleToHeight', () => {
});
});

describe('scaleToFontScale', () => {
it('maps 1% to the narrow bound (1/24) and 100% to the wide bound (1/4)', () => {
expect(scaleToFontScale(1)).toBeCloseTo(1 / 24, 5);
expect(scaleToFontScale(100)).toBeCloseTo(1 / 4, 5);
});
it('increases monotonically with the percent', () => {
expect(scaleToFontScale(20)).toBeLessThan(scaleToFontScale(80));
});
it('clamps out-of-range percents to [1, 100]', () => {
expect(scaleToFontScale(0)).toBe(scaleToFontScale(1));
expect(scaleToFontScale(-50)).toBe(scaleToFontScale(1));
expect(scaleToFontScale(150)).toBe(scaleToFontScale(100));
});
});

describe('formatBytes', () => {
it('formats bytes, KB, MB', () => {
expect(formatBytes(512)).toBe('512 B');
Expand Down
12 changes: 12 additions & 0 deletions src/tools/image/canvas.lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,18 @@ async function encodeCanvas(
);
}

/**
* Map a user-facing Scale percent (1–100) to a `fontScale` fraction of the
* image's shorter side. Larger percents yield a bigger font and — because the
* tiled layout derives its gap from font size — proportionally wider gaps.
*/
export function scaleToFontScale(percent: number): number {
const MIN_FS = 1 / 24; // narrow
const MAX_FS = 1 / 4; // big
const clamped = Math.min(100, Math.max(1, percent));
return MIN_FS + ((clamped - 1) / 99) * (MAX_FS - MIN_FS);
}

export type WatermarkLayout = 'diagonal' | 'tiled' | 'bottom-right';

export interface ImageWatermarkOptions {
Expand Down
Loading