Skip to content
Merged
7 changes: 6 additions & 1 deletion .agents/skills/review-pr/scripts/post-review.js
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,12 @@ function archiveUiScreenshots(reviewJson, repoArg, prNum) {
return cdnMap;
}

const screenshotCdnMap = archiveUiScreenshots(reviewJson, repo, prNumber);
let screenshotCdnMap = {};
try {
screenshotCdnMap = archiveUiScreenshots(reviewJson, repo, prNumber);
} catch (e) {
console.warn('Screenshot archiving failed — continuing without CDN screenshots:', e.message);
}

// ---------------------------------------------------------------------------
// Build payload
Expand Down
10 changes: 10 additions & 0 deletions .changeset/fix-svelte-button-black-and-input-focus.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@razorpay/blade-core': minor
'@razorpay/blade-svelte': minor
---

feat: add `black` color variant to Button

fix: focus-ring transition and offset flash on inputs

fix: use `bladeTheme` as default Storybook theme in blade-svelte
49 changes: 31 additions & 18 deletions packages/blade-core/src/styles/Button/button.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -129,15 +129,30 @@
--btn-accent-border-highlighted: var(--interactive-border-negative-highlighted);
}

/*
* Neutral reuses the filled pattern. `--btn-accent-bevel` overrides the top/bottom
* white bevel with the higher-contrast faded-highlighted token the design specifies
* for a dark surface.
*/
.color-neutral {
--btn-accent-bg-default: var(--interactive-background-neutral-default);
--btn-accent-bg-highlighted: var(--interactive-background-neutral-highlighted);
--btn-accent-bg-disabled: var(--interactive-background-neutral-disabled);
--btn-accent-border-default: var(--interactive-border-neutral-default);
--btn-accent-border-highlighted: var(--interactive-border-neutral-highlighted);
--btn-accent-bevel: var(--interactive-border-static-white-faded-highlighted);
}

/* ===== Filled button (primary variant) — shared across accent colors ===== */
.color-primary.primary,
.color-positive.primary,
.color-negative.primary {
.color-negative.primary,
.color-neutral.primary {
background-color: var(--btn-accent-bg-default);
box-shadow: inset 0 -1.5px 0 0 var(--btn-accent-border-highlighted),
inset 0 0 0 0.5px var(--btn-accent-border-default),
inset 0 1.5px 0 0 var(--interactive-border-static-white-faded),
inset 0 -2px 0 0 var(--interactive-border-static-white-faded);
inset 0 1.5px 0 0 var(--btn-accent-bevel, var(--interactive-border-static-white-faded)),
inset 0 -2px 0 0 var(--btn-accent-bevel, var(--interactive-border-static-white-faded));

&::before {
content: '';
Expand All @@ -161,9 +176,8 @@
background-color: var(--btn-accent-bg-highlighted);
box-shadow: inset 0 -1.5px 0 0 var(--btn-accent-border-highlighted),
inset 0 0 0 0.5px var(--btn-accent-border-highlighted),
inset 0 1.5px 0 0 var(--interactive-border-static-white-faded),
inset 0 -2px 0 0 var(--interactive-border-static-white-faded);

inset 0 1.5px 0 0 var(--btn-accent-bevel, var(--interactive-border-static-white-faded)),
inset 0 -2px 0 0 var(--btn-accent-bevel, var(--interactive-border-static-white-faded));
&::before {
opacity: 0;
}
Expand All @@ -173,9 +187,8 @@
background-color: var(--btn-accent-bg-highlighted);
box-shadow: inset 0 -1.5px 0 0 var(--btn-accent-border-highlighted),
inset 0 0 0 0.5px var(--btn-accent-border-highlighted),
inset 0 1.5px 0 0 var(--interactive-border-static-white-faded),
inset 0 -2px 0 0 var(--interactive-border-static-white-faded);

inset 0 1.5px 0 0 var(--btn-accent-bevel, var(--interactive-border-static-white-faded)),
inset 0 -2px 0 0 var(--btn-accent-bevel, var(--interactive-border-static-white-faded));
&::before {
opacity: 0;
}
Expand All @@ -187,9 +200,8 @@
box-shadow: 0px 0px 0px 4px var(--surface-border-primary-muted),
inset 0 -1.5px 0 0 var(--btn-accent-border-highlighted),
inset 0 0 0 0.5px var(--btn-accent-border-highlighted),
inset 0 1.5px 0 0 var(--interactive-border-static-white-faded),
inset 0 -2px 0 0 var(--interactive-border-static-white-faded);

inset 0 1.5px 0 0 var(--btn-accent-bevel, var(--interactive-border-static-white-faded)),
inset 0 -2px 0 0 var(--btn-accent-bevel, var(--interactive-border-static-white-faded));
&::before {
opacity: 0;
}
Expand All @@ -209,7 +221,9 @@
.color-primary.secondary,
.color-primary.tertiary,
.color-positive.secondary,
.color-negative.secondary {
.color-negative.secondary,
.color-neutral.secondary,
.color-neutral.tertiary {
background-color: var(--surface-background-gray-intense);
box-shadow: inset 0 -1px 0.5px 0 var(--interactive-border-static-black-faded-highlighted),
inset 0 0 0 1px var(--interactive-border-gray-default),
Expand Down Expand Up @@ -515,9 +529,8 @@
background-color: var(--btn-accent-bg-default);
box-shadow: inset 0 -1.5px 0 0 var(--btn-accent-border-highlighted),
inset 0 0 0 0.5px var(--btn-accent-border-default),
inset 0 1.5px 0 0 var(--interactive-border-static-white-faded),
inset 0 -2px 0 0 var(--interactive-border-static-white-faded);

inset 0 1.5px 0 0 var(--btn-accent-bevel, var(--interactive-border-static-white-faded)),
inset 0 -2px 0 0 var(--btn-accent-bevel, var(--interactive-border-static-white-faded));
&::before {
opacity: 1;
}
Expand Down Expand Up @@ -558,8 +571,8 @@
pointer-events: none;
box-shadow: inset 0 -1.5px 0 0 var(--btn-accent-border-highlighted),
inset 0 0 0 0.5px var(--btn-accent-border-default),
inset 0 1.5px 0 0 var(--interactive-border-static-white-faded),
inset 0 -2px 0 0 var(--interactive-border-static-white-faded);
inset 0 1.5px 0 0 var(--btn-accent-bevel, var(--interactive-border-static-white-faded)),
inset 0 -2px 0 0 var(--btn-accent-bevel, var(--interactive-border-static-white-faded));
}

/*
Expand Down
28 changes: 28 additions & 0 deletions packages/blade-core/src/styles/Button/button.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,21 @@ export function getButtonBackgroundColorToken({
}
}

if (color === 'neutral') {
if (variant === 'primary') {
return `interactive.${property}.staticBlack.${_state}`;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 [MAJOR] · code-quality-critique · confidence: 8/10

Problem: Neutral primary background token mismatch: getButtonBackgroundColorToken returns interactive.${property}.staticBlack.${_state} but the CSS .color-neutral class uses --interactive-background-neutral-* tokens. The disabled state values differ — staticBlack.disabled is hsla(0,0%,0%,0.56) while neutral.disabled is hsla(0,0%,0%,0.18). Since getButtonProgressRestColorToken calls this function with state:'disabled', the definite-loading rest cover paints at 0.56 opacity black instead of matching the actual disabled appearance at 0.18 opacity.

Suggestion: Change staticBlack to neutral in the neutral primary return: return interactive.${property}.neutral.${_state}; — this aligns the token with the CSS .color-neutral variables and fixes the loading rest color.

}
// secondary/tertiary render the same as the default (gray) buttons
if (variant === 'secondary') {
return isBorder ? 'interactive.border.primary.default' : 'surface.background.gray.intense';
}
if (variant === 'tertiary') {
return _state === 'disabled'
? 'interactive.background.staticWhite.ghost'
: 'surface.background.gray.intense';
}
}

if (color === 'transparent') {
if (variant !== 'tertiary') {
throw new Error(
Expand Down Expand Up @@ -175,6 +190,19 @@ export function getButtonTextColorToken({
}
}

if (color === 'neutral') {
if (variant === 'primary') {
return `interactive.${property}.staticWhite.${state === 'disabled' ? 'disabled' : 'normal'}`;
}
// secondary/tertiary render the same as the default (gray) buttons
if (variant === 'secondary') {
return `interactive.${property}.gray.${state === 'disabled' ? 'disabled' : 'normal'}`;
}
if (variant === 'tertiary') {
return `interactive.${property}.gray.${stateSuffix}`;
}
}

if (color === 'transparent') {
if (variant !== 'tertiary') {
throw new Error(
Expand Down
8 changes: 6 additions & 2 deletions packages/blade-core/src/styles/Input/baseInput.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,12 @@

.focus-ring-wrapper:focus-within {
outline: 4px solid var(--surface-border-primary-muted);
outline-offset: 0px;
transition-property: outline;
outline-offset: 1px;
/* Transition only outline-width: transitioning the full `outline` shorthand
* also animates outline-color from the resting `currentcolor` (dark) to the
* muted ring color, which flashes a solid dark outline on focus before it
* settles. Animating width alone grows the ring in cleanly (mirrors React). */
transition-property: outline-width;
transition-duration: var(--duration-xgentle);
transition-timing-function: var(--easing-emphasized);
}
Expand Down
20 changes: 14 additions & 6 deletions packages/blade-core/src/tokens/theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@
/* ===== THEME TOKENS - LIGHT MODE ===== */

/* Surface Colors */
--surface-background-gray-subtle: hsla(200, 10%, 94%, 1);
--surface-background-gray-subtle: hsla(0, 0%, 97%, 1);
--surface-background-gray-moderate: hsla(0, 0%, 97%, 1);
--surface-background-gray-intense: hsla(0, 0%, 100%, 1);
--surface-background-primary-faint: transparent;
Expand Down Expand Up @@ -261,9 +261,9 @@
--interactive-background-information-disabled: hsla(200, 100%, 41%, 0.09);
--interactive-background-information-faded: hsla(200, 100%, 41%, 0.09);
--interactive-background-information-faded-highlighted: hsla(200, 100%, 41%, 0.18);
--interactive-background-neutral-default: hsla(205, 10%, 24%, 1);
--interactive-background-neutral-highlighted: hsla(200, 10%, 18%, 1);
--interactive-background-neutral-disabled: hsla(206, 10%, 29%, 0.18);
--interactive-background-neutral-default: hsla(0, 0%, 0%, 1);
--interactive-background-neutral-highlighted: hsla(0, 0%, 0%, 0.88);
--interactive-background-neutral-disabled: hsla(0, 0%, 0%, 0.18);
--interactive-background-neutral-faded: hsla(206, 10%, 29%, 0.12);
--interactive-background-neutral-faded-highlighted: hsla(206, 10%, 29%, 0.18);
--interactive-background-gray-default: hsla(206, 10%, 29%, 0.06);
Expand Down Expand Up @@ -305,8 +305,8 @@
--interactive-border-information-highlighted: hsla(200, 100%, 33%, 1);
--interactive-border-information-disabled: hsla(200, 100%, 41%, 0.18);
--interactive-border-information-faded: hsla(200, 100%, 41%, 0.18);
--interactive-border-neutral-default: hsla(200, 11%, 11%, 1);
--interactive-border-neutral-highlighted: hsla(0, 0%, 2%, 1);
--interactive-border-neutral-default: hsla(0, 0%, 0%, 1);
--interactive-border-neutral-highlighted: hsla(0, 0%, 0%, 0.88);
--interactive-border-neutral-disabled: hsla(203, 8%, 80%, 1);
--interactive-border-neutral-faded: hsla(206, 10%, 29%, 0.12);
--interactive-border-gray-default: hsla(204, 8%, 88%, 1);
Expand Down Expand Up @@ -359,6 +359,10 @@
--interactive-text-on-primary-subtle: hsla(0, 0%, 100%, 0.8);
--interactive-text-on-primary-muted: hsla(0, 0%, 100%, 0.64);
--interactive-text-on-primary-disabled: hsla(0, 0%, 100%, 0.32);
--interactive-text-on-neutral-normal: hsla(0, 0%, 100%, 1);
--interactive-text-on-neutral-subtle: hsla(0, 0%, 100%, 0.8);
--interactive-text-on-neutral-muted: hsla(0, 0%, 100%, 0.64);
--interactive-text-on-neutral-disabled: hsla(0, 0%, 100%, 0.32);
--interactive-text-static-white-normal: hsla(0, 0%, 100%, 1);
--interactive-text-static-white-subtle: hsla(0, 0%, 100%, 0.8);
--interactive-text-static-white-muted: hsla(0, 0%, 100%, 0.64);
Expand Down Expand Up @@ -399,6 +403,10 @@
--interactive-icon-on-primary-subtle: hsla(0, 0%, 100%, 0.8);
--interactive-icon-on-primary-muted: hsla(0, 0%, 100%, 0.64);
--interactive-icon-on-primary-disabled: hsla(0, 0%, 100%, 0.32);
--interactive-icon-on-neutral-normal: hsla(0, 0%, 100%, 1);
--interactive-icon-on-neutral-subtle: hsla(0, 0%, 100%, 0.8);
--interactive-icon-on-neutral-muted: hsla(0, 0%, 100%, 0.64);
--interactive-icon-on-neutral-disabled: hsla(0, 0%, 100%, 0.32);
--interactive-icon-static-white-normal: hsla(0, 0%, 100%, 1);
--interactive-icon-static-white-subtle: hsla(0, 0%, 100%, 0.8);
--interactive-icon-static-white-muted: hsla(0, 0%, 100%, 0.64);
Expand Down
8 changes: 4 additions & 4 deletions packages/blade-svelte/.storybook/BladeThemeDecorator.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* Reads colorScheme / brandColor from Storybook globals via props.
*/
import type { Snippet } from 'svelte';
import { bladeNeutralTheme, createTheme } from '@razorpay/blade-core/tokens';
import { bladeTheme, createTheme } from '@razorpay/blade-core/tokens';
import type { ColorSchemeNamesInput, ThemeTokens } from '@razorpay/blade-core/tokens';
import BladeProvider from '../src/components/BladeProvider/BladeProvider.svelte';

Expand All @@ -24,17 +24,17 @@
const themeTokens = $derived.by((): ThemeTokens => {
if (brandColor) {
try {
return createTheme({ brandColor, baseTheme: bladeNeutralTheme }).theme;
return createTheme({ brandColor, baseTheme: bladeTheme }).theme;
} catch (error) {
console.warn(
'[BladeThemeDecorator]: Invalid brandColor, falling back to default theme',
brandColor,
error,
);
return bladeNeutralTheme;
return bladeTheme;
}
}
return bladeNeutralTheme;
return bladeTheme;
});
</script>

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import InteractiveTokensPlayground from './InteractiveTokensPlayground.svelte';

const { Story } = defineMeta({
title: 'Playground/Interactive Tokens',
component: InteractiveTokensPlayground,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen',
docs: {
description: {
component:
"Every Blade Svelte component that consumes `interactive.*` color tokens, rendered together. Pick a brand color to run it through Blade's real `createTheme` pipeline and feed `BladeProvider` — exactly like a themed app. The full `interactive.primary.*` state scale is derived by `createTheme` (positive / negative / notice / neutral / gray / static* are fixed Blade tokens with no app-facing override).",
},
},
},
});
</script>

<!-- Self-contained playground: owns its own BladeProvider, so the global decorator is skipped. -->
<Story
name="Playground"
asChild
parameters={{
controls: { disable: true },
skipBladeProviderDecorator: true,
}}
>
<InteractiveTokensPlayground />
</Story>
Loading
Loading