Skip to content

Commit 7b9b135

Browse files
committed
more tests, more resilient imports, fix 'start new' action
1 parent 88a68ca commit 7b9b135

6 files changed

Lines changed: 73 additions & 12 deletions

File tree

src/components/GradientImportDialog.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@
9393
inputId={inputId}
9494
bind:this={textareaEl}
9595
/>
96-
<ImportActions {canImport} primaryType="submit" on:cancel={close} on:import={onImportClick} />
96+
<ImportActions {canImport} primaryType="submit" on:cancel={close} />
9797
</form>
9898
</section>
9999
</dialog>

src/components/import/ImportActions.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
<div class="actions">
99
<button type="reset" class="btn secondary" on:click={() => dispatch('cancel')}>Cancel</button>
10-
<button type={primaryType} class="btn primary" disabled={!canImport} aria-disabled={!canImport} on:click={() => canImport && dispatch('import')}>Import</button>
10+
<button type={primaryType} class="btn primary" disabled={!canImport} aria-disabled={!canImport}>Import</button>
1111
</div>
1212

1313
<style>

src/lib/importGradient.ts

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ function toDegreesString(valueWithUnit: string): string {
2121
// assume degrees if unitless numeric
2222
deg = parseFloat(v)
2323
}
24+
if (isNaN(deg)) return '0'
2425
// format: trim insignificant decimals
2526
const fixed = deg.toFixed(4)
2627
return String(parseFloat(fixed))
@@ -43,11 +44,16 @@ export function applyParsedToStores(parsed: ParsedGradient) {
4344

4445
if (parsed.type === 'linear') {
4546
if (parsed.linear?.angleKeyword) {
47+
// Just set the named angle - the store's subscriber will automatically
48+
// sync the numeric angle based on the nameToDeg mapping
4649
linear_named_angle.set(parsed.linear.angleKeyword)
47-
linear_angle.set(null)
4850
} else if (parsed.linear?.angleDeg) {
51+
// For numeric angles, set the angle value
52+
// The store will determine if it matches a named direction
4953
linear_angle.set(toDegreesString(parsed.linear.angleDeg))
50-
linear_named_angle.set('--')
54+
} else {
55+
// No angle specified, use default "to bottom" (180deg)
56+
linear_named_angle.set('to bottom')
5157
}
5258
} else if (parsed.type === 'radial') {
5359
radial_shape.set(parsed.radial?.shape ?? 'circle')
@@ -71,11 +77,28 @@ export function applyParsedToStores(parsed: ParsedGradient) {
7177
}
7278
}
7379

80+
// Normalize position values: strip % suffix to get numeric strings
81+
const normalizedStops = (parsed.stops as any[]).map(s => {
82+
if (s.kind === 'stop') {
83+
return {
84+
...s,
85+
position1: s.position1 ? String(s.position1).replace(/%$/, '') : null,
86+
position2: s.position2 ? String(s.position2).replace(/%$/, '') : null,
87+
}
88+
} else if (s.kind === 'hint') {
89+
return {
90+
...s,
91+
percentage: s.percentage ? String(s.percentage).replace(/%$/, '') : null,
92+
}
93+
}
94+
return s
95+
})
96+
7497
// Ensure a hint exists between each adjacent pair of color stops when none were provided
75-
const hasAnyHints = (parsed.stops as any[]).some(s => s.kind === 'hint')
76-
let stopsWithHints = parsed.stops as any[]
98+
const hasAnyHints = normalizedStops.some(s => s.kind === 'hint')
99+
let stopsWithHints = normalizedStops
77100
if (!hasAnyHints) {
78-
const colors = (parsed.stops as any[]).filter(s => s.kind === 'stop')
101+
const colors = normalizedStops.filter(s => s.kind === 'stop')
79102
const rebuilt: any[] = []
80103
colors.forEach((st, idx) => {
81104
rebuilt.push({ ...st })

src/lib/parseGradient.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@ const valid = [
1919
'conic-gradient(from 0deg at 0% 100% in oklab,#fff, 2%, #f00 0%, 8%, #fff 0%, 13%, #f00 0%, 18%, #fff 0%, 21%, #f00 0%, 24%, #fff 0%)',
2020
'linear-gradient(to right in oklab,#0ff 12%, #111 0% 24%, #ff0 0% 36%, #111 0% 48%, #f0f 0% 60%, #111 0% 72%, #0ff 0%, #111 0% 100%)',
2121
'radial-gradient(farthest-corner circle at 0% 0% in oklch,oklch(95% .25 160), 26%, oklch(75% .5 180) 0%, 46%, oklch(75% .5 210) 0%, 60%, oklch(75% .5 230) 0%, 82%, oklch(75% .5 260) 0%)',
22+
// Multi-line gradient with semicolon and directional keyword
23+
`linear-gradient(
24+
to right in oklab,
25+
oklch(70% 0.5 340),
26+
oklch(80% 0.3 89) 64%,
27+
oklch(90% 0.5 200)
28+
);`,
2229
]
2330

2431
const invalid = [
@@ -64,5 +71,34 @@ describe('parseGradient', () => {
6471
expect(stopsB[1].position1).toBeNull()
6572
expect(stopsB[1].position2).toBeNull()
6673
})
74+
75+
it('strips trailing semicolons and correctly parses directional keywords', () => {
76+
const withSemi = parseGradient('linear-gradient(to right, red, blue);')
77+
expect(withSemi.type).toBe('linear')
78+
expect(withSemi.linear?.angleKeyword).toBe('to right')
79+
expect(withSemi.stops.length).toBeGreaterThanOrEqual(2)
80+
81+
const multiline = parseGradient(`linear-gradient(
82+
to right in oklab,
83+
oklch(70% 0.5 340),
84+
oklch(80% 0.3 89) 64%,
85+
oklch(90% 0.5 200)
86+
);`)
87+
expect(multiline.type).toBe('linear')
88+
expect(multiline.linear?.angleKeyword).toBe('to right')
89+
expect(multiline.space).toBe('oklab')
90+
expect(multiline.stops.length).toBe(3)
91+
})
92+
93+
it('correctly parses all directional keywords', () => {
94+
const keywords = [
95+
'to top', 'to top right', 'to right', 'to bottom right',
96+
'to bottom', 'to bottom left', 'to left', 'to top left'
97+
]
98+
keywords.forEach(kw => {
99+
const parsed = parseGradient(`linear-gradient(${kw}, red, blue)`)
100+
expect(parsed.linear?.angleKeyword).toBe(kw)
101+
})
102+
})
67103
})
68104

src/lib/parseGradient.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,10 @@ function innerContent(input: string): string {
8787
}
8888

8989
export function parseGradient(input: string): ParsedGradient {
90-
const type = classifyFunction(input)
91-
const body = innerContent(input)
90+
// Strip trailing semicolons that may be present when copying from CSS rules
91+
const cleanedInput = input.trim().replace(/;+$/, '')
92+
const type = classifyFunction(cleanedInput)
93+
const body = innerContent(cleanedInput)
9294

9395
// split by top-level commas
9496
const segments = splitTopLevel(body, ',')
@@ -156,9 +158,9 @@ export function parseGradient(input: string): ParsedGradient {
156158
if (/(circle|ellipse|closest-|farthest-| at )/.test(lowerPrelude)) {
157159
throw new ParseError('Invalid radial/conic tokens in linear-gradient prelude')
158160
}
159-
// angle keyword
161+
// angle keyword - match "to <direction>" patterns
160162
const kw = prelude.match(/\bto\s+(top|bottom|left|right)(?:\s+(left|right|top|bottom))?/i)
161-
const ang = prelude.match(/([-+]?\d*\.?\d+(?:deg|turn|grad|rad))/i)
163+
const ang = prelude.match(/(?:^|\s)([-+]?\d*\.?\d+(?:deg|turn|grad|rad))(?:\s|$)/i)
162164
linear = { angleKeyword: null, angleDeg: null }
163165
if (kw) {
164166
linear.angleKeyword = kw[0].toLowerCase()

src/store/layers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,7 @@ export function defaultLayer(): GradientLayer {
263263
{ kind: 'hint', auto: '50', percentage: '50' },
264264
{ kind: 'stop', color: '#fff', auto: '100', position1: '100', position2: '100' },
265265
],
266-
linear: { named_angle: 'to right', angle: null },
266+
linear: { named_angle: 'to right', angle: '90' },
267267
radial: { shape: 'circle', size: 'farthest-corner', named_position: 'center', position: { x: null, y: null } },
268268
conic: { angle: '0', named_position: 'center', position: { x: null, y: null } },
269269
}

0 commit comments

Comments
 (0)