diff --git a/.cursor/rules/create-flow.mdc b/.cursor/rules/create-flow.mdc
index cf8d0a2..0d872bb 100644
--- a/.cursor/rules/create-flow.mdc
+++ b/.cursor/rules/create-flow.mdc
@@ -33,7 +33,7 @@ Reach for these before writing new markup:
| `[– value +]` numeric stepper (± label) | `app/components/controls/Incrementer` / `IncrementerBlock` |
| Mid-paragraph "expand / see all" link button | `app/components/buttons/InlineTextButton` |
| Help-icon + label above a control | `app/components/type/InputLabel` (`helpIcon` prop) |
-| Toggle chip (dim-but-clickable) | `Chip` with `state="Disabled" disabled={false}` |
+| Toggle chip | `Chip` with `state="selected"` / `"unselected"` (Chip sets `aria-pressed`; selected includes a check mark) |
| Card-click → structured creation modal | `Create` with `backdropVariant="blurredYellow"` |
If a screen grows a 2nd inline copy of any pattern above, **extract a shared
diff --git a/app/(app)/create/components/ApplicableScopeField.tsx b/app/(app)/create/components/ApplicableScopeField.tsx
index bdb0635..61d9467 100644
--- a/app/(app)/create/components/ApplicableScopeField.tsx
+++ b/app/(app)/create/components/ApplicableScopeField.tsx
@@ -74,11 +74,16 @@ function ApplicableScopeFieldComponent({
{scopes.map((scope) => {
const isSelected = selectedScopes.includes(scope);
+ const chipState = isSelected
+ ? "selected"
+ : readOnly
+ ? "disabled"
+ : "unselected";
return (
coreValueOptions.filter((o) => o.state === "selected").length,
+ [coreValueOptions],
+ );
+ const atSelectionLimit = selectedCount >= MAX_CORE_VALUES;
+ const selectionCountText = cv.multiSelect.selectionCount
+ .replace("{count}", String(selectedCount))
+ .replace("{max}", String(MAX_CORE_VALUES));
+
const kebabMenuItems = useMemo(() => {
if (!modalSession || !activeModalChipId) return [];
- const selectedCount = coreValueOptions.filter(
- (o) => o.state === "selected",
- ).length;
return buildCustomRuleModalKebabMenu(modalKebabMenu, {
showCustomize: true,
onCustomize: handleCustomize,
onDuplicate:
(state.editingPublishedRuleId?.trim() ?? "") !== "" ||
- selectedCount >= MAX_CORE_VALUES
+ atSelectionLimit
? undefined
: handleDuplicateCoreChip,
showRemove: modalSession === "editing",
@@ -605,7 +611,7 @@ export function CoreValuesSelectScreen() {
});
}, [
activeModalChipId,
- coreValueOptions,
+ atSelectionLimit,
handleCustomize,
handleDuplicateCoreChip,
handleRemoveFromKebab,
@@ -695,7 +701,8 @@ export function CoreValuesSelectScreen() {
@@ -730,6 +737,9 @@ export function CoreValuesSelectScreen() {
onCustomChipClose={addHandlers.onCustomChipClose}
addButton
addButtonText={cv.multiSelect.addButtonText}
+ maxSelections={MAX_CORE_VALUES}
+ selectionCountText={selectionCountText}
+ limitReachedAnnouncement={cv.multiSelect.limitReached}
/>
{detailModal && (
diff --git a/app/components/controls/Chip/Chip.types.ts b/app/components/controls/Chip/Chip.types.ts
index 2a082a6..f0d2974 100644
--- a/app/components/controls/Chip/Chip.types.ts
+++ b/app/components/controls/Chip/Chip.types.ts
@@ -29,10 +29,9 @@ export interface ChipProps {
className?: string;
/**
* Whether the chip should be non-interactive. Defaults to `true` when
- * `state === "disabled"` to preserve historical behavior. Pass
- * `disabled={false}` alongside `state="disabled"` to render the dimmed
- * "disabled" visual while keeping the chip clickable — useful for toggle
- * groups where the unselected state is the disabled visual.
+ * `state === "disabled"`. Toggle groups use `selected` / `unselected`
+ * (Chip sets `aria-pressed`); pass `disabled` only when the chip cannot
+ * be activated.
*/
disabled?: boolean;
onClick?: (event: React.MouseEvent) => void;
diff --git a/app/components/controls/Chip/Chip.view.tsx b/app/components/controls/Chip/Chip.view.tsx
index 5658f59..7b5f7c8 100644
--- a/app/components/controls/Chip/Chip.view.tsx
+++ b/app/components/controls/Chip/Chip.view.tsx
@@ -1,8 +1,81 @@
"use client";
-import { memo } from "react";
+import { memo, type CSSProperties } from "react";
import type { ChipViewProps } from "./Chip.types";
+function chipSurfaceStyle(
+ state: ChipViewProps["state"],
+ palette: ChipViewProps["palette"],
+ size: ChipViewProps["size"],
+): CSSProperties {
+ const borderWidth = size === "s" ? "1.25px" : "2px";
+ const reset: CSSProperties = {
+ appearance: "none",
+ WebkitAppearance: "none",
+ backgroundImage: "none",
+ };
+
+ const paint = (
+ backgroundColor: string,
+ color: string,
+ borderColor?: string,
+ ): CSSProperties => ({
+ ...reset,
+ backgroundColor,
+ color,
+ WebkitTextFillColor: color,
+ ...(borderColor
+ ? { borderWidth, borderStyle: "solid", borderColor }
+ : { borderWidth: 0, borderStyle: "none", borderColor: "transparent" }),
+ });
+
+ if (palette === "inverse") {
+ if (state === "disabled") {
+ return paint(
+ "var(--color-surface-inverse-tertiary)",
+ "var(--color-content-inverse-primary)",
+ );
+ }
+ if (state === "selected") {
+ return paint(
+ "var(--color-surface-default-semi-opaque)",
+ "var(--color-content-inverse-primary)",
+ "var(--color-border-default-primary)",
+ );
+ }
+ return paint(
+ "transparent",
+ "var(--color-content-inverse-primary)",
+ "var(--color-border-default-primary)",
+ );
+ }
+
+ if (state === "custom") {
+ return paint(
+ "var(--color-surface-default-secondary)",
+ "var(--color-content-default-tertiary)",
+ );
+ }
+ if (state === "disabled") {
+ return paint(
+ "var(--color-surface-default-secondary)",
+ "var(--color-content-invert-tertiary)",
+ );
+ }
+ if (state === "selected") {
+ return paint(
+ "var(--color-surface-invert-brand-primary, #fefcc9)",
+ "var(--color-content-invert-primary, #000)",
+ "var(--color-border-default-brand-primary, #fdfaa8)",
+ );
+ }
+ return paint(
+ "transparent",
+ "var(--color-content-default-brand-primary, #fefcc9)",
+ "var(--color-border-default-tertiary, #464646)",
+ );
+}
+
function ChipView({
label,
state,
@@ -23,17 +96,15 @@ function ChipView({
typeToAddPlaceholder,
closeAriaLabel,
}: ChipViewProps) {
- // The container is the source of truth for `disabled`. This allows
- // `state="disabled"` to be used purely as a visual (for toggle-group chips
- // that look dimmed while remaining clickable) by passing `disabled={false}`.
+ // The container is the source of truth for `disabled`. `state="disabled"`
+ // is the non-interactive visual only — toggle groups use selected/unselected.
const isDisabled = disabled ?? false;
const isSelected = state === "selected";
const isCustom = state === "custom";
-
- const isInverse = palette === "inverse";
- const isDefault = palette === "default";
+ const isToggle = !isCustom;
const isSmall = size === "s";
+ const surfaceStyle = chipSurfaceStyle(state, palette, size);
// Size-based styles from Figma tokens
// Custom state has different padding
@@ -45,59 +116,8 @@ function ChipView({
? "h-[30px] px-[var(--measures-spacing-200,8px)] gap-[var(--measures-spacing-050,2px)] text-x-small-label"
: "px-[var(--measures-spacing-300,12px)] py-[var(--measures-spacing-300,12px)] gap-[var(--measures-spacing-150,6px)] text-medium-label";
- // Palette + state styling based on Figma examples
- // Use consistent border width to prevent layout shift
- const borderWidth = isSmall ? "border-[1.25px]" : "border-2";
-
- let background =
- "bg-[var(--color-surface-default-transparent,rgba(0,0,0,0))]";
- let border = `${borderWidth} border-[var(--color-border-default-tertiary,#464646)]`;
- let textColor =
- "text-[color:var(--color-content-default-brand-primary,#fefcc9)]";
-
- if (isDefault) {
- if (state === "custom") {
- background = "bg-[var(--color-surface-default-secondary,#141414)]"; // dark background for custom
- border = "border-none";
- textColor = "text-[color:var(--color-content-default-tertiary,#b4b4b4)]";
- } else if (state === "disabled") {
- background = "bg-[var(--color-surface-default-secondary,#141414)]"; // dark background
- border = "border-none";
- // Per Figma (node 19839:13842) disabled uses invert-tertiary for the
- // strongly dimmed look, not default-tertiary.
- textColor = "text-[color:var(--color-content-invert-tertiary,#2d2d2d)]";
- } else if (isSelected) {
- background = "bg-[var(--color-surface-invert-brand-primary,#fefcc9)]"; // yellow selected
- border = `${borderWidth} border-[var(--color-border-default-brand-primary,#fdfaa8)]`;
- textColor = "text-[color:var(--color-content-invert-primary,black)]";
- } else {
- // Unselected default
- background =
- "bg-[var(--color-surface-default-transparent,rgba(0,0,0,0))]";
- border = `${borderWidth} border-[var(--color-border-default-tertiary,#464646)]`;
- textColor =
- "text-[color:var(--color-content-default-brand-primary,#fefcc9)]";
- }
- } else if (isInverse) {
- if (state === "disabled") {
- background = "bg-[var(--color-surface-inverse-tertiary,#d2d2d2)]";
- border = "border-none";
- textColor = "text-[color:var(--color-content-inverse-primary,black)]";
- } else if (isSelected) {
- background =
- "bg-[var(--color-surface-default-semi-opaque,rgba(0,0,0,0.05))]";
- border = `${borderWidth} border-[var(--color-border-default-primary,#141414)]`;
- textColor = "text-[color:var(--color-content-inverse-primary,black)]";
- } else {
- // Unselected / custom inverse
- background =
- "bg-[var(--color-surface-default-transparent,rgba(0,0,0,0))]";
- border = `${borderWidth} border-[var(--color-border-default-primary,#141414)]`;
- textColor = "text-[color:var(--color-content-inverse-primary,black)]";
- }
- }
-
const baseClasses = `
+ appearance-none
inline-flex
max-w-full
items-center
@@ -107,7 +127,7 @@ function ChipView({
box-border
focus:outline-none
focus-visible:ring-2
- focus-visible:ring-[var(--color-border-default-primary,#141414)]
+ focus-visible:ring-[var(--color-border-default-primary)]
focus-visible:ring-offset-2
focus-visible:ring-offset-transparent
transition-[background,border-color,color,box-shadow]
@@ -121,15 +141,7 @@ function ChipView({
? "cursor-not-allowed opacity-60"
: "cursor-pointer";
- const combinedClasses = [
- baseClasses,
- sizeClasses,
- background,
- border,
- textColor,
- stateClasses,
- className,
- ]
+ const combinedClasses = [baseClasses, sizeClasses, stateClasses, className]
.filter(Boolean)
.join(" ");
@@ -145,6 +157,7 @@ function ChipView({
const sharedA11y = {
"aria-label": ariaLabel,
+ ...(isToggle ? { "aria-pressed": isSelected } : {}),
};
// Custom state rendering with check/close buttons
@@ -152,6 +165,7 @@ function ChipView({
return (
-
{label}
+ {isSelected ? (
+
+ ) : null}
+
+ {label}
+
{onRemove && !isDisabled && (