Skip to content

Commit 71e68ab

Browse files
bejarcodeclaude
andcommitted
release: v1.0.3 - Fix smoothing algorithm and Chrome detection
## Bug Fixes - Fixed 100% smoothing spike artifact with proportional scaling - Fixed Chrome false-positive native CSS detection (disabled until browsers render it) - Disabled Houdini Paint API detection (waiting for Phase 2) - Improved FOUC prevention for demo website ## Enhancements - Implemented preserveSmoothing mode in Figma squircle algorithm - Proportionally scales bezier handles when space is constrained - Maintains smooth iOS-style S-curves even with large radius values ## Changes - Updated detector tests to reflect disabled native/Houdini detection - Increased demo preview height for better large radius demonstration 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 86b267e commit 71e68ab

File tree

9 files changed

+225
-37
lines changed

9 files changed

+225
-37
lines changed

packages/core/CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [1.0.3] - 2025-11-17
11+
12+
### Fixed
13+
- Fixed 100% smoothing spike artifact - corners no longer show thin lines at maximum smoothing
14+
- Implemented proportional scaling for large radius values - maintains smooth S-curves when space is constrained
15+
- Fixed Chrome false-positive native CSS detection - disabled corner-shape: squircle detection until browsers actually render it
16+
- Disabled Houdini Paint API detection - waiting for actual paint worklet implementation in Phase 2
17+
- Improved FOUC (Flash of Unstyled Content) prevention for demo website
18+
19+
### Changed
20+
- Enhanced Figma squircle algorithm with preserveSmoothing mode (default: true)
21+
- When corner radius is large relative to element dimensions, bezier handles are proportionally scaled instead of reducing smoothing
22+
- This maintains the characteristic iOS-style continuous S-curve even when space is limited
23+
1024
## [1.0.2] - 2025-11-16
1125

1226
### Fixed

packages/core/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@cornerkit/core",
3-
"version": "1.0.2",
3+
"version": "1.0.3",
44
"description": "Lightweight library for iOS-style squircle corners",
55
"type": "module",
66
"sideEffects": false,

packages/core/src/core/detector.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,18 @@ export class CapabilityDetector {
8686
/**
8787
* FR-009: Detect Native CSS corner-shape: squircle support
8888
* Chrome 139+ (when available)
89+
*
90+
* Note: As of 2025, no browser actually renders corner-shape: squircle yet.
91+
* Chrome falsely reports CSS.supports() as true, so we disable native detection
92+
* until browsers actually implement rendering.
8993
*/
9094
private detectNative(): boolean {
95+
// Disabled until browsers actually render corner-shape: squircle
96+
// Chrome reports CSS.supports('corner-shape', 'squircle') as true but doesn't render it
97+
// TODO: Re-enable when Chrome 139+ is released with actual rendering support
98+
return false;
99+
100+
/* Original detection (re-enable when browsers support rendering):
91101
if (typeof CSS === 'undefined' || !CSS.supports) {
92102
return false;
93103
}
@@ -97,13 +107,22 @@ export class CapabilityDetector {
97107
} catch {
98108
return false;
99109
}
110+
*/
100111
}
101112

102113
/**
103114
* FR-010: Detect CSS Houdini Paint API support
104115
* Chrome 65+, Edge 79+
116+
*
117+
* Note: Paint API support doesn't mean squircle worklet is registered.
118+
* Disabled until we have an actual paint worklet implementation (Phase 2).
105119
*/
106120
private detectHoudini(): boolean {
121+
// Disabled until we have an actual squircle paint worklet (Phase 2)
122+
// Detecting paintWorklet support doesn't mean squircle rendering is available
123+
return false;
124+
125+
/* Original detection (re-enable with paint worklet implementation):
107126
if (typeof CSS === 'undefined') {
108127
return false;
109128
}
@@ -113,6 +132,7 @@ export class CapabilityDetector {
113132
} catch {
114133
return false;
115134
}
135+
*/
116136
}
117137

118138
/**

packages/core/src/math/figma-squircle.ts

Lines changed: 80 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,29 @@ function toRadians(degrees: number): number {
3232
*
3333
* @param cornerRadius - Corner radius in pixels
3434
* @param cornerSmoothing - Smoothing factor 0-1 (0.6 = iOS squircle)
35+
* @param roundingAndSmoothingBudget - Maximum space available for the corner path
36+
* @param preserveSmoothing - If true, maintain smoothing aesthetic by adjusting bezier handles instead of reducing smoothing
3537
* @returns Path parameters for drawing the corner
3638
*/
3739
export function getPathParamsForCorner(
3840
cornerRadius: number,
39-
cornerSmoothing: number
41+
cornerSmoothing: number,
42+
roundingAndSmoothingBudget: number = Infinity,
43+
preserveSmoothing: boolean = true
4044
): CornerPathParams {
4145
// Total corner path length
42-
const p = (1 + cornerSmoothing) * cornerRadius;
46+
let p = (1 + cornerSmoothing) * cornerRadius;
47+
48+
// When there's not enough space (p > budget), we have two options:
49+
// 1. preserveSmoothing = false (Figma's default): reduce smoothing to fit budget
50+
// This changes the arcMeasure angle, making corners look like circular arcs
51+
// 2. preserveSmoothing = true: keep original smoothing, adjust bezier handles
52+
// This maintains the squircle aesthetic even when space is limited
53+
if (!preserveSmoothing && p > roundingAndSmoothingBudget) {
54+
const maxCornerSmoothing = roundingAndSmoothingBudget / cornerRadius - 1;
55+
cornerSmoothing = Math.max(0, Math.min(cornerSmoothing, maxCornerSmoothing));
56+
p = Math.min(p, roundingAndSmoothingBudget);
57+
}
4358

4459
// Arc angle in degrees (90° when smoothing=0, 0° when smoothing=1)
4560
const arcMeasure = 90 * (1 - cornerSmoothing);
@@ -63,8 +78,36 @@ export function getPathParamsForCorner(
6378

6479
// Bezier handle lengths
6580
// The remaining length after arc and transitions is split into 3 parts (1/3 for b, 2/3 for a)
66-
const b = (p - arcSectionLength - c - d) / 3;
67-
const a = 2 * b;
81+
let b = (p - arcSectionLength - c - d) / 3;
82+
let a = 2 * b;
83+
84+
// Adjust bezier handles if preserveSmoothing is enabled and we exceed the budget
85+
// This maintains the squircle S-curve characteristic while fitting within space constraints
86+
if (preserveSmoothing && p > roundingAndSmoothingBudget) {
87+
// Calculate how much we need to scale down
88+
const scaleFactor = roundingAndSmoothingBudget / p;
89+
90+
// Proportionally scale all components to maintain smooth curve character
91+
// This keeps the relative proportions between a, b, c, d the same
92+
// This approach maintains the smooth S-curve without kinks
93+
const scaledA = a * scaleFactor;
94+
const scaledB = b * scaleFactor;
95+
const scaledC = c * scaleFactor;
96+
const scaledD = d * scaleFactor;
97+
const scaledArcLength = arcSectionLength * scaleFactor;
98+
99+
// Use proportional scaling - this maintains smooth curves
100+
// by keeping the same relative proportions between all parameters
101+
return {
102+
a: scaledA,
103+
b: scaledB,
104+
c: scaledC,
105+
d: scaledD,
106+
p: roundingAndSmoothingBudget,
107+
arcSectionLength: scaledArcLength,
108+
cornerRadius,
109+
};
110+
}
68111

69112
return {
70113
a,
@@ -95,11 +138,15 @@ export function drawTopRightCorner(params: CornerPathParams): string {
95138
}
96139

97140
// First bezier curve (horizontal to arc transition)
98-
// Then arc
141+
// Then arc (skip if degenerate to avoid rendering artifacts)
99142
// Then second bezier curve (arc to vertical transition)
143+
const arcCommand = arcSectionLength > 0.01
144+
? `a ${round(cornerRadius)} ${round(cornerRadius)} 0 0 1 ${round(arcSectionLength)} ${round(arcSectionLength)}`
145+
: '';
146+
100147
return `
101148
c ${round(a)} 0 ${round(a + b)} 0 ${round(a + b + c)} ${round(d)}
102-
a ${round(cornerRadius)} ${round(cornerRadius)} 0 0 1 ${round(arcSectionLength)} ${round(arcSectionLength)}
149+
${arcCommand}
103150
c ${round(d)} ${round(c)} ${round(d)} ${round(b + c)} ${round(d)} ${round(a + b + c)}
104151
`.trim().replace(/\s+/g, ' ');
105152
}
@@ -114,9 +161,13 @@ export function drawBottomRightCorner(params: CornerPathParams): string {
114161
return `l 0 ${round(params.p)}`;
115162
}
116163

164+
const arcCommand = arcSectionLength > 0.01
165+
? `a ${round(cornerRadius)} ${round(cornerRadius)} 0 0 1 ${round(-arcSectionLength)} ${round(arcSectionLength)}`
166+
: '';
167+
117168
return `
118169
c 0 ${round(a)} 0 ${round(a + b)} ${round(-d)} ${round(a + b + c)}
119-
a ${round(cornerRadius)} ${round(cornerRadius)} 0 0 1 ${round(-arcSectionLength)} ${round(arcSectionLength)}
170+
${arcCommand}
120171
c ${round(-c)} ${round(d)} ${round(-b - c)} ${round(d)} ${round(-a - b - c)} ${round(d)}
121172
`.trim().replace(/\s+/g, ' ');
122173
}
@@ -131,9 +182,13 @@ export function drawBottomLeftCorner(params: CornerPathParams): string {
131182
return `l ${round(-params.p)} 0`;
132183
}
133184

185+
const arcCommand = arcSectionLength > 0.01
186+
? `a ${round(cornerRadius)} ${round(cornerRadius)} 0 0 1 ${round(-arcSectionLength)} ${round(-arcSectionLength)}`
187+
: '';
188+
134189
return `
135190
c ${round(-a)} 0 ${round(-a - b)} 0 ${round(-a - b - c)} ${round(-d)}
136-
a ${round(cornerRadius)} ${round(cornerRadius)} 0 0 1 ${round(-arcSectionLength)} ${round(-arcSectionLength)}
191+
${arcCommand}
137192
c ${round(-d)} ${round(-c)} ${round(-d)} ${round(-b - c)} ${round(-d)} ${round(-a - b - c)}
138193
`.trim().replace(/\s+/g, ' ');
139194
}
@@ -148,9 +203,13 @@ export function drawTopLeftCorner(params: CornerPathParams): string {
148203
return `l 0 ${round(-params.p)}`;
149204
}
150205

206+
const arcCommand = arcSectionLength > 0.01
207+
? `a ${round(cornerRadius)} ${round(cornerRadius)} 0 0 1 ${round(arcSectionLength)} ${round(-arcSectionLength)}`
208+
: '';
209+
151210
return `
152211
c 0 ${round(-a)} 0 ${round(-a - b)} ${round(d)} ${round(-a - b - c)}
153-
a ${round(cornerRadius)} ${round(cornerRadius)} 0 0 1 ${round(arcSectionLength)} ${round(-arcSectionLength)}
212+
${arcCommand}
154213
c ${round(c)} ${round(-d)} ${round(b + c)} ${round(-d)} ${round(a + b + c)} ${round(-d)}
155214
`.trim().replace(/\s+/g, ' ');
156215
}
@@ -176,11 +235,18 @@ export function generateFigmaSquirclePath(
176235
// Clamp smoothing to valid range
177236
const clampedSmoothing = Math.max(0, Math.min(1, smoothing));
178237

179-
// Calculate path parameters for all corners (assume uniform corners for now)
180-
const topLeftParams = getPathParamsForCorner(clampedRadius, clampedSmoothing);
181-
const topRightParams = getPathParamsForCorner(clampedRadius, clampedSmoothing);
182-
const bottomRightParams = getPathParamsForCorner(clampedRadius, clampedSmoothing);
183-
const bottomLeftParams = getPathParamsForCorner(clampedRadius, clampedSmoothing);
238+
// Calculate the budget for rounding and smoothing
239+
// Each corner's path extends along the edge, so budget is half the edge length
240+
const horizontalBudget = width / 2;
241+
const verticalBudget = height / 2;
242+
243+
// Calculate path parameters for all corners with budget constraints
244+
// Top-left and top-right share the top edge, so they compete for width/2
245+
// Left and right edges have height/2 budget
246+
const topLeftParams = getPathParamsForCorner(clampedRadius, clampedSmoothing, Math.min(horizontalBudget, verticalBudget));
247+
const topRightParams = getPathParamsForCorner(clampedRadius, clampedSmoothing, Math.min(horizontalBudget, verticalBudget));
248+
const bottomRightParams = getPathParamsForCorner(clampedRadius, clampedSmoothing, Math.min(horizontalBudget, verticalBudget));
249+
const bottomLeftParams = getPathParamsForCorner(clampedRadius, clampedSmoothing, Math.min(horizontalBudget, verticalBudget));
184250

185251
// Build the complete path
186252
// Start from top-right corner, move counter-clockwise

packages/core/tests/unit/detector.test.ts

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ describe('CapabilityDetector', () => {
1818

1919
describe('Native CSS detection (FR-009)', () => {
2020
it('should detect native corner-shape: squircle support', () => {
21+
// Native CSS detection is disabled because Chrome falsely reports support
2122
// Mock CSS.supports to return true for corner-shape: squircle
2223
global.CSS = {
2324
supports: vi.fn((property: string, value: string) => {
@@ -28,8 +29,8 @@ describe('CapabilityDetector', () => {
2829
const detector = CapabilityDetector.getInstance();
2930
const support = detector.supports();
3031

31-
expect(support.native).toBe(true);
32-
expect(CSS.supports).toHaveBeenCalledWith('corner-shape', 'squircle');
32+
// Native detection is disabled until browsers actually render corner-shape: squircle
33+
expect(support.native).toBe(false);
3334
});
3435

3536
it('should return false when CSS.supports is unavailable', () => {
@@ -56,6 +57,7 @@ describe('CapabilityDetector', () => {
5657

5758
describe('Houdini detection (FR-010)', () => {
5859
it('should detect paintWorklet support', () => {
60+
// Houdini detection is disabled until we have an actual paint worklet (Phase 2)
5961
global.CSS = {
6062
paintWorklet: {},
6163
supports: vi.fn(() => false),
@@ -64,7 +66,8 @@ describe('CapabilityDetector', () => {
6466
const detector = CapabilityDetector.getInstance();
6567
const support = detector.supports();
6668

67-
expect(support.houdini).toBe(true);
69+
// Houdini detection is disabled until paint worklet is implemented
70+
expect(support.houdini).toBe(false);
6871
});
6972

7073
it('should return false when paintWorklet is unavailable', () => {
@@ -168,32 +171,38 @@ describe('CapabilityDetector', () => {
168171
CapabilityDetector.supports();
169172
CapabilityDetector.supports();
170173

171-
// Detection should only run once (2 checks: native, clippath)
172-
// Note: Houdini detection uses 'paintWorklet' in CSS, not CSS.supports
173-
expect(supportsSpy).toHaveBeenCalledTimes(2);
174+
// Detection should only run once (1 check: clippath only)
175+
// Note: Native and Houdini detection are disabled
176+
expect(supportsSpy).toHaveBeenCalledTimes(1);
174177
});
175178
});
176179

177180
describe('detectTier()', () => {
178181
it('should return NATIVE when native CSS is supported', () => {
182+
// Native detection is disabled, so even if CSS.supports returns true,
183+
// it will fall through to ClipPath
179184
global.CSS = {
180185
supports: vi.fn((property: string, value: string) => {
181186
return property === 'corner-shape' && value === 'squircle';
182187
}),
183188
} as any;
184189

185190
const detector = CapabilityDetector.getInstance();
186-
expect(detector.detectTier()).toBe(RendererTier.NATIVE);
191+
// Native is disabled, so it returns CLIPPATH (via runtime DOM test)
192+
expect(detector.detectTier()).toBe(RendererTier.CLIPPATH);
187193
});
188194

189195
it('should return HOUDINI when paintWorklet is supported but not native', () => {
196+
// Houdini detection is disabled, so even if paintWorklet exists,
197+
// it will fall through to ClipPath
190198
global.CSS = {
191199
paintWorklet: {},
192200
supports: vi.fn(() => false),
193201
} as any;
194202

195203
const detector = CapabilityDetector.getInstance();
196-
expect(detector.detectTier()).toBe(RendererTier.HOUDINI);
204+
// Houdini is disabled, so it returns CLIPPATH (via runtime DOM test)
205+
expect(detector.detectTier()).toBe(RendererTier.CLIPPATH);
197206
});
198207

199208
it('should return CLIPPATH when clip-path is supported', () => {

website/app.js

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -231,17 +231,11 @@ function showCopyFeedback(button, status) {
231231
* @returns {string} Browser tier (Tier 1, Tier 2, Tier 3, or Tier 4)
232232
*/
233233
function detectBrowserTier() {
234-
// Check for Native CSS corner-shape (Tier 1)
235-
if (CSS.supports && CSS.supports('corner-shape', 'squircle')) {
236-
return 'Tier 1: Native CSS';
237-
}
238-
239-
// Check for Houdini Paint API (Tier 2)
240-
if ('paintWorklet' in CSS) {
241-
return 'Tier 2: Houdini Paint API';
242-
}
234+
// Note: Native CSS corner-shape (Tier 1) and Houdini Paint API (Tier 2) are disabled
235+
// Chrome falsely reports CSS.supports('corner-shape', 'squircle') as true but doesn't render it
236+
// Houdini detection only checks for paintWorklet API, not actual squircle worklet registration
243237

244-
// Check for SVG clip-path (Tier 3)
238+
// Check for SVG clip-path (Tier 3) - Current primary implementation
245239
// Safari has issues with CSS.supports() for path(), so test both methods
246240
if (testClipPathSupport()) {
247241
return 'Tier 3: SVG ClipPath';
@@ -752,6 +746,13 @@ function initializeDemo() {
752746
// Apply squircle to playground preview
753747
ck.apply('#playground-preview', { radius: initialRadius, smoothing: initialSmoothing });
754748

749+
// Mark as ready to prevent FOUC - swap pending class for ready class
750+
const preview = document.getElementById('playground-preview');
751+
if (preview) {
752+
preview.classList.remove('squircle-pending');
753+
preview.classList.add('squircle-ready');
754+
}
755+
755756
// Initialize code snippets with default values
756757
updateAllCodeSnippets(initialRadius, initialSmoothing);
757758

@@ -817,6 +818,13 @@ function initializeHero() {
817818
smoothing: 0.85
818819
});
819820

821+
// Mark as ready to prevent FOUC
822+
const heroDemo = document.getElementById('hero-demo');
823+
if (heroDemo) {
824+
heroDemo.classList.remove('squircle-pending');
825+
heroDemo.classList.add('squircle-ready');
826+
}
827+
820828
// Setup smooth scroll for CTA buttons
821829
const playgroundCTA = document.querySelector('a[href="#playground"]');
822830
if (playgroundCTA) {

0 commit comments

Comments
 (0)