Skip to content
Draft
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
13 changes: 13 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 11 additions & 2 deletions src/components/Gradient.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -323,8 +323,14 @@ import GradientImportDialog from './GradientImportDialog.svelte'
let p1 = s.position1
let p2 = s.position2

// Omit values equal to tool defaults only if explicitly stored in stop.auto
if (p1 != null && s.auto != null && p1 == s.auto) p1 = null
// Omit values equal to tool defaults only if auto-assigned (number === number).
// String positions from presets/user input are preserved even when numerically equal.
if (p1 != null && s.auto != null && p1 === s.auto) {
p1 = null
// Also suppress auto-assigned p2 for the first stop so a
// no-position leading color renders cleanly without a trailing 0%.
if (i === firstStopIdx && p2 != null && p2 === s.auto) p2 = null
}

// Omit browser default edges only when explicitly percentages
if (i === firstStopIdx && isPctZero(p1)) p1 = null
Expand Down Expand Up @@ -356,6 +362,9 @@ import GradientImportDialog from './GradientImportDialog.svelte'
}
else if (s.kind === 'hint') {
if (s.percentage == null) return null
// Skip auto-computed midpoints (same as gradientString.ts) – they are
// the browser default and add noise to the output without changing rendering.
if (s.auto != null && s.percentage == s.auto) return null
// s.percentage is unitless; add % here
return s.percentage + '%'
}
Expand Down
61 changes: 61 additions & 0 deletions src/lib/roundtrip.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import { buildGradientStrings } from '../utils/gradientString'

type Stop = { kind: 'stop'; color: string; auto?: any; position1?: any; position2?: any }
const stop = (color: string, p1?: any, p2?: any): Stop => ({ kind: 'stop', color, auto: null as any, position1: p1 ?? null, position2: p2 ?? null })
// stopWithAuto simulates the output of updateStops: auto is a number, positions may be strings (preset) or numbers (auto-assigned)
const stopWithAuto = (color: string, auto: number, p1?: any, p2?: any): Stop => ({ kind: 'stop', color, auto, position1: p1 ?? null, position2: p2 ?? null })
const hint = (pct: string | number) => ({ kind: 'hint' as const, auto: null as any, percentage: String(pct) })
// hintWithAuto simulates what updateStops produces for auto-managed hints
const hintWithAuto = (pct: number) => ({ kind: 'hint' as const, auto: pct, percentage: pct })

const makeLayer = (overrides: Partial<import('../utils/gradientString').LayerSnapshot>) => ({
type: 'linear',
Expand Down Expand Up @@ -76,4 +80,61 @@ describe('round-trip: builder output parses back into app format', () => {
expect(() => parseGradient(c.modern)).not.toThrow()
expect(() => parseGradient(c.classic)).not.toThrow()
})

it('stripes preset: auto-assigned first stop renders without trailing 0%', () => {
// After updateStops the no-position leading stop gets auto=0, position1=0, position2=0 (numbers).
// It should render as just the color name with no position suffix.
const stripes = makeLayer({
stops: [
stopWithAuto('#fff', 0, 0, 0), // auto-assigned both positions = 0
hintWithAuto(10),
stopWithAuto('#000', 20, '0', '20'), // explicit string positions from preset
hintWithAuto(30),
stopWithAuto('#fff', 40, '0', '40'),
hintWithAuto(50),
stopWithAuto('#000', 60, '0', '60'),
hintWithAuto(70),
stopWithAuto('#fff', 80, '0', '80'),
hintWithAuto(90),
stopWithAuto('#000', 100, '0', '100'),
],
})

const { modern } = buildGradientStrings(stripes as any)
const flat = modern.replace(/\s+/g, ' ')

// Leading stop should have no position (not "#fff 0%")
expect(flat).toMatch(/\(\s*to right in oklab,\s*#fff,/)

// All span positions must be preserved
expect(flat).toContain('#000 0% 20%')
expect(flat).toContain('#fff 0% 40%')
expect(flat).toContain('#000 0% 60%')
expect(flat).toContain('#fff 0% 80%')
expect(flat).toContain('#000 0% 100%')

// Auto-computed midpoint hints (pct === auto) must not appear
expect(flat).not.toContain('10%')
expect(flat).not.toContain('30%')
expect(flat).not.toContain('90%')
})

it('explicit string 0% on first stop span is preserved (not stripped as auto)', () => {
// Neon Stripe-style: first stop has explicit position1='0' and position2='12' from a preset.
// auto=0 for the first stop. With strict equality the string '0' must NOT be stripped.
const neonStripe = makeLayer({
stops: [
stopWithAuto('#0ff', 0, '0', '12'), // explicit string '0', auto=0 (number)
hintWithAuto(6),
stopWithAuto('#111', 13, '0', '24'),
],
})

const { modern } = buildGradientStrings(neonStripe as any)
const flat = modern.replace(/\s+/g, ' ')

// The explicit 0% start must be preserved so the span is fully described
expect(flat).toContain('#0ff 0% 12%')
expect(flat).toContain('#111 0% 24%')
})
})
20 changes: 12 additions & 8 deletions src/utils/gradientString.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,15 +106,19 @@ function stopsToStrings(stops: any[], { convert_colors, new_lines }: { convert_c
let p1: any = s.position1
let p2: any = s.position2

// Only ever suppress the "auto" value for the primary position.
// Secondary positions are kept even when they equal the auto value so
// that explicit spans from presets/URL imports (like the Stripes preset)
// are preserved.
if (p1 != null && s.auto != null && String(p1) == String(s.auto)) p1 = null

// Omit browser default *leading* endpoint only when explicitly authored as 0%.
// Only ever suppress the "auto" value for the primary position when it was
// auto-assigned (a number). String positions from presets/user input are kept
// even when they numerically equal the auto value, preserving explicit spans.
if (p1 != null && s.auto != null && p1 === s.auto) p1 = null

// Omit browser default *leading* endpoint only when explicitly authored as 0%
// and the stop is a point (no span end). Preserve the 0% start when p2 also
// exists so that a span like "0% 12%" stays fully described.
if (i === firstStopIdx) {
if (isPctZero(p1)) p1 = null
if (isPctZero(p1) && p2 == null) p1 = null
// Also suppress an auto-assigned p2 when p1 was already suppressed,
// so a no-position first stop renders cleanly without a trailing 0%.
if (p1 == null && p2 != null && p2 === s.auto) p2 = null
}

const colorStr = maybeConvertColor(s.color, convert_colors)
Expand Down