From 1d561ac9c6a511249ea75cf1d654d04ef2f85716 Mon Sep 17 00:00:00 2001 From: yousef kadah Date: Mon, 27 Apr 2026 09:56:13 +0300 Subject: [PATCH 1/4] fix(Label): align celebration animation stroke radius with small size The celebration animation hardcoded a 4px corner radius, which overflowed the 2px-radius corners of small labels. Thread the actual border-radius into the SVG path generator so the animated stroke matches the label's rendered shape. Closes #3128 Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/core/src/components/Label/Label.tsx | 9 ++++++++- .../Label/LabelCelebrationAnimation.tsx | 17 +++++++++++++---- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/packages/core/src/components/Label/Label.tsx b/packages/core/src/components/Label/Label.tsx index af3b67e785..0b62a49953 100644 --- a/packages/core/src/components/Label/Label.tsx +++ b/packages/core/src/components/Label/Label.tsx @@ -171,8 +171,15 @@ const Label = forwardRef( // Celebration animation is applied only for line kind if (isCelebrationAnimation && kind === "line") { + // Small labels use a 2px border-radius (see Label.module.scss `.label.small`), + // while medium labels use --border-radius-small (4px). Pass the matching value so the + // animated stroke follows the label's actual rounded corners. + const labelBorderRadius = size === "small" ? 2 : 4; return ( - setIsCelebrationAnimation(false)}> + setIsCelebrationAnimation(false)} + > {label} ); diff --git a/packages/core/src/components/Label/LabelCelebrationAnimation.tsx b/packages/core/src/components/Label/LabelCelebrationAnimation.tsx index 70e635eea1..926d0e2de4 100644 --- a/packages/core/src/components/Label/LabelCelebrationAnimation.tsx +++ b/packages/core/src/components/Label/LabelCelebrationAnimation.tsx @@ -15,9 +15,18 @@ export interface LabelCelebrationAnimationProps { * Callback fired when the celebration animation ends. */ onAnimationEnd: () => void; + /** + * Border radius of the wrapped label, in pixels. Used to draw the animated stroke so it follows + * the label's actual rounded corners (e.g. small labels use a 2px radius, medium labels 4px). + */ + borderRadius?: number; } -function LabelCelebrationAnimation({ children, onAnimationEnd }: LabelCelebrationAnimationProps) { +function LabelCelebrationAnimation({ + children, + onAnimationEnd, + borderRadius = DEFAULT_BORDER_RADIUS +}: LabelCelebrationAnimationProps) { const wrapperRef = useRef(); const childRef = useRef(); @@ -28,14 +37,14 @@ function LabelCelebrationAnimation({ children, onAnimationEnd }: LabelCelebratio const { blockSize: height, inlineSize: width } = borderBoxSize || {}; if (wrapperRef.current) { - const d = getPath({ width, height }); + const d = getPath({ width, height, borderRadius }); setPath(d); - const perimeter = getPerimeter({ width, height }); + const perimeter = getPerimeter({ width, height, borderRadius }); wrapperRef.current.style.setProperty("--container-perimeter", String(perimeter)); } }, - [] + [borderRadius] ); useResizeObserver({ From ae9c031173ffe6516473c81c70fb35f7a13043ef Mon Sep 17 00:00:00 2001 From: yousef kadah Date: Tue, 28 Apr 2026 01:03:58 +0300 Subject: [PATCH 2/4] fix(Label): derive celebration stroke radii from rendered element Read the four corner radii from the child via getComputedStyle instead of hardcoding values in TS. This keeps the stroke aligned with the label's shape when CSS tokens are overridden, and correctly handles labels with a leg, where one corner is squared off. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/core/src/components/Label/Label.tsx | 9 +- .../Label/LabelCelebrationAnimation.tsx | 88 ++++++++++--------- 2 files changed, 48 insertions(+), 49 deletions(-) diff --git a/packages/core/src/components/Label/Label.tsx b/packages/core/src/components/Label/Label.tsx index 0b62a49953..af3b67e785 100644 --- a/packages/core/src/components/Label/Label.tsx +++ b/packages/core/src/components/Label/Label.tsx @@ -171,15 +171,8 @@ const Label = forwardRef( // Celebration animation is applied only for line kind if (isCelebrationAnimation && kind === "line") { - // Small labels use a 2px border-radius (see Label.module.scss `.label.small`), - // while medium labels use --border-radius-small (4px). Pass the matching value so the - // animated stroke follows the label's actual rounded corners. - const labelBorderRadius = size === "small" ? 2 : 4; return ( - setIsCelebrationAnimation(false)} - > + setIsCelebrationAnimation(false)}> {label} ); diff --git a/packages/core/src/components/Label/LabelCelebrationAnimation.tsx b/packages/core/src/components/Label/LabelCelebrationAnimation.tsx index 926d0e2de4..1cd0417b41 100644 --- a/packages/core/src/components/Label/LabelCelebrationAnimation.tsx +++ b/packages/core/src/components/Label/LabelCelebrationAnimation.tsx @@ -3,7 +3,6 @@ import cx from "classnames"; import { useResizeObserver } from "@vibe/hooks"; import styles from "./LabelCelebrationAnimation.module.scss"; -const DEFAULT_BORDER_RADIUS = 4; const DEFAULT_STROKE_WIDTH = 1; export interface LabelCelebrationAnimationProps { @@ -15,18 +14,27 @@ export interface LabelCelebrationAnimationProps { * Callback fired when the celebration animation ends. */ onAnimationEnd: () => void; - /** - * Border radius of the wrapped label, in pixels. Used to draw the animated stroke so it follows - * the label's actual rounded corners (e.g. small labels use a 2px radius, medium labels 4px). - */ - borderRadius?: number; } -function LabelCelebrationAnimation({ - children, - onAnimationEnd, - borderRadius = DEFAULT_BORDER_RADIUS -}: LabelCelebrationAnimationProps) { +interface CornerRadii { + tl: number; + tr: number; + br: number; + bl: number; +} + +function readCornerRadii(element: HTMLElement | null | undefined): CornerRadii { + if (!element) return { tl: 0, tr: 0, br: 0, bl: 0 }; + const cs = getComputedStyle(element); + return { + tl: parseFloat(cs.borderTopLeftRadius) || 0, + tr: parseFloat(cs.borderTopRightRadius) || 0, + br: parseFloat(cs.borderBottomRightRadius) || 0, + bl: parseFloat(cs.borderBottomLeftRadius) || 0 + }; +} + +function LabelCelebrationAnimation({ children, onAnimationEnd }: LabelCelebrationAnimationProps) { const wrapperRef = useRef(); const childRef = useRef(); @@ -36,15 +44,17 @@ function LabelCelebrationAnimation({ ({ borderBoxSize }: { borderBoxSize: { blockSize: number; inlineSize: number } }) => { const { blockSize: height, inlineSize: width } = borderBoxSize || {}; - if (wrapperRef.current) { - const d = getPath({ width, height, borderRadius }); - setPath(d); + if (!wrapperRef.current || !width || !height) return; + + // Read the actual rendered radii from the child so the stroke matches the label's + // shape regardless of size or token overrides, and handles non-uniform corners + // (e.g. labels with a leg, where one corner is squared off). + const radii = readCornerRadii(childRef.current); - const perimeter = getPerimeter({ width, height, borderRadius }); - wrapperRef.current.style.setProperty("--container-perimeter", String(perimeter)); - } + setPath(getPath({ width, height, radii })); + wrapperRef.current.style.setProperty("--container-perimeter", String(getPerimeter({ width, height, radii }))); }, - [borderRadius] + [] ); useResizeObserver({ @@ -77,38 +87,34 @@ export default LabelCelebrationAnimation; function getPath({ width, height, - borderRadius = DEFAULT_BORDER_RADIUS, + radii, strokeWidth = DEFAULT_STROKE_WIDTH }: { width: number; height: number; - borderRadius?: number; + radii: CornerRadii; strokeWidth?: number; }) { + const { tl, tr, br, bl } = radii; const offset = strokeWidth / 2; - return `M ${width - strokeWidth / 2}, ${borderRadius} V ${ - height - borderRadius - } A ${borderRadius} ${borderRadius} 0 0 1 ${width - borderRadius} ${height - strokeWidth / 2} H ${ - borderRadius + offset - } A ${borderRadius} ${borderRadius} 0 0 1 ${strokeWidth / 2} ${height - borderRadius} V ${ - borderRadius + offset - } A ${borderRadius} ${borderRadius} 0 0 1 ${borderRadius} ${strokeWidth / 2} L ${width - borderRadius}, ${ + // Trace the perimeter clockwise: start on the right edge below the top-right corner, + // arc through each corner using that corner's own radius. + return `M ${width - strokeWidth / 2}, ${tr} V ${height - br} A ${br} ${br} 0 0 1 ${width - br} ${ + height - strokeWidth / 2 + } H ${bl + offset} A ${bl} ${bl} 0 0 1 ${strokeWidth / 2} ${height - bl} V ${ + tl + offset + } A ${tl} ${tl} 0 0 1 ${tl} ${strokeWidth / 2} L ${width - tr}, ${ strokeWidth / 2 - } A ${borderRadius} ${borderRadius} 0 0 1 ${width - strokeWidth / 2} ${borderRadius} Z`; + } A ${tr} ${tr} 0 0 1 ${width - strokeWidth / 2} ${tr} Z`; } -function getPerimeter({ - width, - height, - borderRadius = DEFAULT_BORDER_RADIUS -}: { - width: number; - height: number; - borderRadius?: number; -}) { - const straightWidth = width - 2 * borderRadius; - const straightHeight = height - 2 * borderRadius; - const cornerCircumference = 2 * Math.PI * borderRadius; - return cornerCircumference + 2 * straightWidth + 2 * straightHeight; +function getPerimeter({ width, height, radii }: { width: number; height: number; radii: CornerRadii }) { + const { tl, tr, br, bl } = radii; + const totalRadius = tl + tr + br + bl; + // Straight segments: full perimeter minus the radius slice taken from each end of every edge. + const straightEdges = 2 * width + 2 * height - 2 * totalRadius; + // Each corner contributes a quarter-circle of its own radius. + const corners = (Math.PI / 2) * totalRadius; + return straightEdges + corners; } From d340035d617fefc4f2cec2d5d680e09e589c221f Mon Sep 17 00:00:00 2001 From: yousef kadah Date: Tue, 28 Apr 2026 01:11:34 +0300 Subject: [PATCH 3/4] fix(Label): read celebration radii from styled inner element childRef points to the outer wrapper span, but border-radius is applied to the nested Text element marked with data-celebration-text. Resolve that inner element via querySelector before reading computed radii so the SVG stroke matches the rendered shape. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/components/Label/LabelCelebrationAnimation.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/core/src/components/Label/LabelCelebrationAnimation.tsx b/packages/core/src/components/Label/LabelCelebrationAnimation.tsx index 1cd0417b41..1192308290 100644 --- a/packages/core/src/components/Label/LabelCelebrationAnimation.tsx +++ b/packages/core/src/components/Label/LabelCelebrationAnimation.tsx @@ -46,10 +46,12 @@ function LabelCelebrationAnimation({ children, onAnimationEnd }: LabelCelebratio if (!wrapperRef.current || !width || !height) return; - // Read the actual rendered radii from the child so the stroke matches the label's - // shape regardless of size or token overrides, and handles non-uniform corners - // (e.g. labels with a leg, where one corner is squared off). - const radii = readCornerRadii(childRef.current); + // Border-radius is applied to the inner Text element (marked with data-celebration-text), + // not the outer wrapper that childRef points to. Resolve the inner element so the stroke + // matches the label's actual shape regardless of size, token overrides, or non-uniform + // corners (e.g. labels with a leg, where one corner is squared off). + const shapeElement = childRef.current?.querySelector("[data-celebration-text]") ?? childRef.current; + const radii = readCornerRadii(shapeElement); setPath(getPath({ width, height, radii })); wrapperRef.current.style.setProperty("--container-perimeter", String(getPerimeter({ width, height, radii }))); From 79d06e679613c73e93c56590b4ac3fd66c88f227 Mon Sep 17 00:00:00 2001 From: yousef kadah Date: Sun, 31 May 2026 03:38:43 +0300 Subject: [PATCH 4/4] fix(Label): match celebration stroke length to drawn path Inset every edge of the celebration path by half the stroke width consistently, and subtract that inset from the perimeter so stroke-dasharray equals the actual path length. Fixes the slightly-off top edge and the jumpy looping stroke. --- .../Label/LabelCelebrationAnimation.tsx | 38 ++++++++++++------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/packages/core/src/components/Label/LabelCelebrationAnimation.tsx b/packages/core/src/components/Label/LabelCelebrationAnimation.tsx index 1192308290..c8bc241f15 100644 --- a/packages/core/src/components/Label/LabelCelebrationAnimation.tsx +++ b/packages/core/src/components/Label/LabelCelebrationAnimation.tsx @@ -98,25 +98,37 @@ function getPath({ strokeWidth?: number; }) { const { tl, tr, br, bl } = radii; + // Inset every edge by half the stroke width so the 1px stroke stays fully inside the label box. const offset = strokeWidth / 2; - // Trace the perimeter clockwise: start on the right edge below the top-right corner, - // arc through each corner using that corner's own radius. - return `M ${width - strokeWidth / 2}, ${tr} V ${height - br} A ${br} ${br} 0 0 1 ${width - br} ${ - height - strokeWidth / 2 - } H ${bl + offset} A ${bl} ${bl} 0 0 1 ${strokeWidth / 2} ${height - bl} V ${ - tl + offset - } A ${tl} ${tl} 0 0 1 ${tl} ${strokeWidth / 2} L ${width - tr}, ${ - strokeWidth / 2 - } A ${tr} ${tr} 0 0 1 ${width - strokeWidth / 2} ${tr} Z`; + // Trace the rounded rectangle clockwise, starting just below the top-right corner on the right + // edge. Each edge endpoint is inset by `offset` and each corner is a clean quarter-circle of its + // own radius, so the drawn length matches getPerimeter() exactly and the stroke loops smoothly. + return `M ${width - offset} ${offset + tr} V ${height - offset - br} A ${br} ${br} 0 0 1 ${width - offset - br} ${ + height - offset + } H ${offset + bl} A ${bl} ${bl} 0 0 1 ${offset} ${height - offset - bl} V ${offset + tl} A ${tl} ${tl} 0 0 1 ${ + offset + tl + } ${offset} H ${width - offset - tr} A ${tr} ${tr} 0 0 1 ${width - offset} ${offset + tr} Z`; } -function getPerimeter({ width, height, radii }: { width: number; height: number; radii: CornerRadii }) { +function getPerimeter({ + width, + height, + radii, + strokeWidth = DEFAULT_STROKE_WIDTH +}: { + width: number; + height: number; + radii: CornerRadii; + strokeWidth?: number; +}) { const { tl, tr, br, bl } = radii; const totalRadius = tl + tr + br + bl; - // Straight segments: full perimeter minus the radius slice taken from each end of every edge. - const straightEdges = 2 * width + 2 * height - 2 * totalRadius; - // Each corner contributes a quarter-circle of its own radius. + // Mirror getPath(): every edge loses 2*offset to the stroke inset at its two ends (4 edges -> + // 4 * strokeWidth total) plus its two corner radii, and each corner adds a quarter-circle of its + // own radius. Keeping this equal to the drawn path length makes stroke-dasharray land exactly on + // the seam, so the looping stroke doesn't overshoot and jump. + const straightEdges = 2 * width + 2 * height - 4 * strokeWidth - 2 * totalRadius; const corners = (Math.PI / 2) * totalRadius; return straightEdges + corners; }