From 439bb4f0be854bdb4233b77fa0bff8a473918f98 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Feb 2026 06:07:14 +0000 Subject: [PATCH 1/3] Initial plan From af4a5fc48acc9ef2c4109e6ab88a2f57db316542 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Feb 2026 06:26:26 +0000 Subject: [PATCH 2/3] Initial plan for fixing Stripes preset output (issue #119) Co-authored-by: argyleink <1134620+argyleink@users.noreply.github.com> --- package-lock.json | 13 +++++ src/lib/stripes_debug.test.ts | 106 ++++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 src/lib/stripes_debug.test.ts diff --git a/package-lock.json b/package-lock.json index 201ded7..a8d92bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -177,6 +177,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -200,6 +201,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -1068,6 +1070,7 @@ "integrity": "sha512-xgKtpjQ6Ry4mdShd01ht5AODUsW7+K1iValPDq7QX8zI1hWOKREH9GjG8SRCN5tC4K7UXmMhuQam7gbLByVcnw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", @@ -1114,6 +1117,7 @@ "integrity": "sha512-3pppgIeIZs6nrQLazzKcdnTJ2IWiui/UucEPXKyFG35TKaHQrfkWBnv6hyJcLxFuR90t+LaoecrqTs8rJKWfSQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "debug": "^4.4.1", @@ -1279,6 +1283,7 @@ "integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.10.0" } @@ -1404,6 +1409,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1883,6 +1889,7 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -2080,6 +2087,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -2122,6 +2130,7 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -2391,6 +2400,7 @@ "integrity": "sha512-Fn2mCc3XX0gnnbBYzWOTrZHi5WnF9KvqmB1+KGlUWoJkdioPmFYtg2ALBr6xl2dcnFTz3Vi7/mHpbKSVg/imVg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", @@ -2578,6 +2588,7 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -2599,6 +2610,7 @@ "integrity": "sha512-X5QFK4SGynAeeIt+A7ZWnApdUyHYm+pzv/8/A57LqSGcI88U6R6ipOs3uCesdc6yl7nl+zNO0t8LmqAdXcQihw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -2717,6 +2729,7 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", diff --git a/src/lib/stripes_debug.test.ts b/src/lib/stripes_debug.test.ts new file mode 100644 index 0000000..f9fd71a --- /dev/null +++ b/src/lib/stripes_debug.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect } from 'vitest' +import { updateStops } from '../utils/stops.ts' + +// Simulate the stopsToStrings function from Gradient.svelte +function stopsToStrings(gradient_stops: any[], {convert_colors = false, new_lines = true} = {}) { + const stopIndices = gradient_stops + .map((s, i) => (s?.kind === 'stop' ? i : null)) + .filter(i => i !== null) + const firstStopIdx = stopIndices.at(0) + const lastStopIdx = stopIndices.at(-1) + + function fmtPos(p: any) { + if (p == null) return null + const str = String(p) + if (/[a-z%]/i.test(str)) return str + return str + '%' + } + + function isPctZero(p: any) { + if (p == null) return false + const m = String(p).match(/^(-?\d+(?:\.\d+)?)%$/) + return !!(m && Number(m[1]) === 0) + } + + function isPctHundred(p: any) { + if (p == null) return false + const m = String(p).match(/^(-?\d+(?:\.\d+)?)%$/) + return !!(m && Number(m[1]) === 100) + } + + return gradient_stops + .map((s: any, i: number) => { + if (s.kind === 'stop') { + let p1 = s.position1 + let p2 = s.position2 + + if (p1 != null && s.auto != null && p1 == s.auto) p1 = null + + if (i === firstStopIdx && isPctZero(p1)) p1 = null + if (i === lastStopIdx && isPctHundred(p2)) p2 = null + + if (p1 != null && p2 != null) { + const a = fmtPos(p1) + const b = fmtPos(p2) + if (a !== b) return s.color + ' ' + a + ' ' + b + return s.color + ' ' + a + } + + if (p1 == null && p2 != null) { + const b = fmtPos(p2) + return s.color + ' ' + b + } + + if (p1 != null && p2 == null) { + const a = fmtPos(p1) + return s.color + ' ' + a + } + + return s.color + } + else if (s.kind === 'hint') { + if (s.percentage == null) return null + return s.percentage + '%' + } + return null + }) + .filter(Boolean) + .join(', ') +} + +describe('Stripes gradient stopsToStrings', () => { + it('should render Stripes preset correctly', () => { + const presetStops = [ + {color: '#fff'}, + {kind: 'hint', auto: null, percentage: null}, + {color: '#000', position1: '0', position2: '20'}, + {kind: 'hint', auto: null, percentage: null}, + {color: '#fff', position1: '0', position2: '40'}, + {kind: 'hint', auto: null, percentage: null}, + {color: '#000', position1: '0', position2: '60'}, + {kind: 'hint', auto: null, percentage: null}, + {color: '#fff', position1: '0', position2: '80'}, + {kind: 'hint', auto: null, percentage: null}, + {color: '#000', position1: '0', position2: '100'}, + ] + + const convertedStops = presetStops.map((stop: any) => { + if (stop.kind !== 'hint') { + return { + kind: 'stop', + color: stop.color, + auto: null, + position1: (stop.position1 ?? null), + position2: (stop.position2 ?? null), + } + } + else return stop + }) + + const stops = updateStops(convertedStops as any) + const output = stopsToStrings(stops) + + console.log('After updateStops stops:', JSON.stringify(stops, null, 2)) + console.log('stopsToStrings output:', output) + }) +}) From 6d3c7287177f3e3be26b76fa940b96e21e1354e9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Feb 2026 06:30:07 +0000 Subject: [PATCH 3/3] Fix Stripes preset output: strict equality for auto-position suppression, clean first stop, skip auto hints Co-authored-by: argyleink <1134620+argyleink@users.noreply.github.com> --- src/components/Gradient.svelte | 13 +++- src/lib/roundtrip.test.ts | 61 +++++++++++++++++++ src/lib/stripes_debug.test.ts | 106 --------------------------------- src/utils/gradientString.ts | 20 ++++--- 4 files changed, 84 insertions(+), 116 deletions(-) delete mode 100644 src/lib/stripes_debug.test.ts diff --git a/src/components/Gradient.svelte b/src/components/Gradient.svelte index 5baaaa9..27575f3 100644 --- a/src/components/Gradient.svelte +++ b/src/components/Gradient.svelte @@ -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 @@ -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 + '%' } diff --git a/src/lib/roundtrip.test.ts b/src/lib/roundtrip.test.ts index 653615c..6570a0a 100644 --- a/src/lib/roundtrip.test.ts +++ b/src/lib/roundtrip.test.ts @@ -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) => ({ type: 'linear', @@ -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%') + }) }) diff --git a/src/lib/stripes_debug.test.ts b/src/lib/stripes_debug.test.ts deleted file mode 100644 index f9fd71a..0000000 --- a/src/lib/stripes_debug.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { updateStops } from '../utils/stops.ts' - -// Simulate the stopsToStrings function from Gradient.svelte -function stopsToStrings(gradient_stops: any[], {convert_colors = false, new_lines = true} = {}) { - const stopIndices = gradient_stops - .map((s, i) => (s?.kind === 'stop' ? i : null)) - .filter(i => i !== null) - const firstStopIdx = stopIndices.at(0) - const lastStopIdx = stopIndices.at(-1) - - function fmtPos(p: any) { - if (p == null) return null - const str = String(p) - if (/[a-z%]/i.test(str)) return str - return str + '%' - } - - function isPctZero(p: any) { - if (p == null) return false - const m = String(p).match(/^(-?\d+(?:\.\d+)?)%$/) - return !!(m && Number(m[1]) === 0) - } - - function isPctHundred(p: any) { - if (p == null) return false - const m = String(p).match(/^(-?\d+(?:\.\d+)?)%$/) - return !!(m && Number(m[1]) === 100) - } - - return gradient_stops - .map((s: any, i: number) => { - if (s.kind === 'stop') { - let p1 = s.position1 - let p2 = s.position2 - - if (p1 != null && s.auto != null && p1 == s.auto) p1 = null - - if (i === firstStopIdx && isPctZero(p1)) p1 = null - if (i === lastStopIdx && isPctHundred(p2)) p2 = null - - if (p1 != null && p2 != null) { - const a = fmtPos(p1) - const b = fmtPos(p2) - if (a !== b) return s.color + ' ' + a + ' ' + b - return s.color + ' ' + a - } - - if (p1 == null && p2 != null) { - const b = fmtPos(p2) - return s.color + ' ' + b - } - - if (p1 != null && p2 == null) { - const a = fmtPos(p1) - return s.color + ' ' + a - } - - return s.color - } - else if (s.kind === 'hint') { - if (s.percentage == null) return null - return s.percentage + '%' - } - return null - }) - .filter(Boolean) - .join(', ') -} - -describe('Stripes gradient stopsToStrings', () => { - it('should render Stripes preset correctly', () => { - const presetStops = [ - {color: '#fff'}, - {kind: 'hint', auto: null, percentage: null}, - {color: '#000', position1: '0', position2: '20'}, - {kind: 'hint', auto: null, percentage: null}, - {color: '#fff', position1: '0', position2: '40'}, - {kind: 'hint', auto: null, percentage: null}, - {color: '#000', position1: '0', position2: '60'}, - {kind: 'hint', auto: null, percentage: null}, - {color: '#fff', position1: '0', position2: '80'}, - {kind: 'hint', auto: null, percentage: null}, - {color: '#000', position1: '0', position2: '100'}, - ] - - const convertedStops = presetStops.map((stop: any) => { - if (stop.kind !== 'hint') { - return { - kind: 'stop', - color: stop.color, - auto: null, - position1: (stop.position1 ?? null), - position2: (stop.position2 ?? null), - } - } - else return stop - }) - - const stops = updateStops(convertedStops as any) - const output = stopsToStrings(stops) - - console.log('After updateStops stops:', JSON.stringify(stops, null, 2)) - console.log('stopsToStrings output:', output) - }) -}) diff --git a/src/utils/gradientString.ts b/src/utils/gradientString.ts index 99353fc..f84534d 100644 --- a/src/utils/gradientString.ts +++ b/src/utils/gradientString.ts @@ -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)