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
5 changes: 5 additions & 0 deletions .changeset/relative-tone-double-overshoot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tenphi/glaze': patch
---

Fix relative `tone` with `autoFlip`: when mirroring an overshooting delta still leaves `[0, 100]`, keep the original direction and clamp instead of pinning to the wrong extreme.
9 changes: 5 additions & 4 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -447,7 +447,8 @@ It gives an exact contrast step for neutrals and a stable visual progression for
chromatic colors. In dark mode with `mode: 'auto'`, it is anchored to the
base's per-scheme tone. If `base + delta` falls outside `[0, 100]`, the result
is clamped to the boundary, or — with `autoFlip` (default on) — mirrored to the
other side of the base.
other side of the base. If the mirrored target is also out of range, the original
delta is kept and clamped on the authored side.

**Extreme tone** (`'max'` / `'min'`) forces the color to the scheme's tone extreme without a contrast hack or a magic number. `'max'` resolves to author tone 100 and `'min'` to 0; both flow through scheme mapping like an absolute tone, so under `mode: 'auto'` they invert in dark (`'max'` is lightest in light, darkest in dark). Use `mode: 'static'` to pin the same extreme across schemes, or `mode: 'fixed'` to keep the same end without inverting. No `base` required.

Expand All @@ -457,7 +458,7 @@ A dependent color with `base` but no `tone` inherits the base's tone (equivalent

`autoFlip` governs what happens when a result would fall outside its valid range:

- **Relative `tone` overshoot:** when `base ± delta` exceeds `[0, 100]`, `autoFlip` mirrors the delta to the other side of the base (e.g. `'+30'` becomes `'-30'`) instead of clamping to the boundary.
- **Relative `tone` overshoot:** when `base ± delta` exceeds `[0, 100]`, `autoFlip` mirrors the delta to the other side of the base (e.g. `'+30'` becomes `'-30'`) instead of clamping to the boundary. If the mirrored target is also outside `[0, 100]`, the original delta is kept and clamped on the authored side.
- **`contrast` direction:** when the requested tone direction can't meet the floor, `autoFlip` lets the solver try the opposite side (the same behavior as the global `autoFlip`).

`autoFlip` defaults to the global `autoFlip` (`true`). Set `autoFlip: false` on a color to clamp instead of mirror — useful when you want a relative offset to stay on the authored side of the base, or to keep an unmet contrast pinned to one direction's extreme.
Expand Down Expand Up @@ -1390,7 +1391,7 @@ Light: accent-fill tone=52, accent-text tone='+20' → lighter than the fill
Dark: accent-fill maps into the dark window, sign preserved
```

Offsets that would push past `[0, 100]` clamp to the boundary, or — with `autoFlip` (default on) — mirror to the other side of the base. Set `autoFlip: false` to keep the authored side and clamp instead.
Offsets that would push past `[0, 100]` clamp to the boundary, or — with `autoFlip` (default on) — mirror to the other side of the base. If the mirror also overshoots, the original side is kept and clamped. Set `autoFlip: false` to keep the authored side and clamp instead.

**`static`** — no adaptation, same tone in every scheme.

Expand Down Expand Up @@ -1541,7 +1542,7 @@ available result.
| Relative `tone` without `base` in a **theme** color | Validation error |
| `contrast` without `base` in `glaze.color()` | Anchors against the literal seed (no error) |
| Relative `tone` without `base` in `glaze.color()` | Anchors against the literal seed (no error) |
| Relative `tone` overshoots `[0, 100]` | Mirror to the other side of the base (`autoFlip` on, default), or clamp to the boundary (`autoFlip` off) |
| Relative `tone` overshoots `[0, 100]` | Mirror to the other side of the base (`autoFlip` on, default), or clamp to the boundary (`autoFlip` off). If the mirror also overshoots, clamp on the authored side. |
| `tone` resolves outside 0–100 | Clamp silently |
| `'max'` / `'min'` without `base` | Allowed — resolves to the scheme's tone extreme (root color) |
| `saturation` outside 0–1 | Clamp silently |
Expand Down
22 changes: 22 additions & 0 deletions src/glaze.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,28 @@ describe('glaze', () => {
expect(chip.light.t).toBeLessThan(surface.light.t);
});

it('autoFlip: true clamps to the original side when the mirror also overshoots', () => {
const theme = glaze(0, 0);
theme.colors({
surface: { tone: 20 },
chip: { base: 'surface', tone: '+90', autoFlip: true },
});
const chip = theme.resolve().get('chip')!;
// 20+90 and 20-90 both leave [0,100] → keep +90 and clamp to 100
expect(chip.light.t).toBeCloseTo(1, 4);
});

it('autoFlip: true clamps negative double-overshoot to the original side', () => {
const theme = glaze(0, 0);
theme.colors({
surface: { tone: 80 },
chip: { base: 'surface', tone: '-90', autoFlip: true },
});
const chip = theme.resolve().get('chip')!;
// 80-90 and 80+90 both leave [0,100] → keep -90 and clamp to 0
expect(chip.light.t).toBeCloseTo(0, 4);
});

it('autoFlip does not change an in-range relative tone', () => {
const autoFlipOff = glaze(0, 0);
autoFlipOff.colors({
Expand Down
11 changes: 7 additions & 4 deletions src/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,15 +216,18 @@ function resolveContrastSpec(
/**
* Apply the relative-tone delta against a base, honoring `flip`.
*
* When `flip` is on and `base + delta` falls outside `[0, 100]`, mirror the
* delta to the other side of the base (so an offset that would clamp instead
* reflects back into range). When off, the caller clamps as usual.
* When `flip` is on and `base + delta` falls outside `[0, 100]`, try mirroring
* the delta to the other side of the base. If the mirrored target is also out
* of range, keep the original delta so the caller clamps on the authored side.
* When off, the caller clamps as usual.
*/
function applyToneFlip(delta: number, baseTone: number, flip: boolean): number {
if (!flip) return delta;
const target = baseTone + delta;
if (target >= 0 && target <= 100) return delta;
return -delta;
const mirrored = baseTone - delta;
if (mirrored >= 0 && mirrored <= 100) return -delta;
return delta;
}

function resolveRootColor(
Expand Down
3 changes: 2 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,8 @@ export interface RegularColorDef {
* Whether to flip out-of-bounds results to the opposite side instead of
* clamping to the extreme. Affects both:
* - relative `tone`: when `base ± delta` exceeds `[0, 100]`, mirror the
* delta to the other side of the base.
* delta to the other side of the base. If the mirrored target is also
* out of range, keep the original delta and clamp on the authored side.
* - `contrast`: when the requested direction can't meet the floor, try the
* opposite side (same as the global `autoFlip`).
*
Expand Down
Loading