/* ============================================================================ BUTTON - figma frame 14:487 (Variant x Size x State) ---------------------------------------------------------------------------- Only TIER-2 semantic tokens are referenced here. The `--sh-btn-*` custom properties are component-local plumbing: each variant block binds them to a tier-2 token once, and the state rules below read them, so the whole 5x3x3 matrix is expressed as five small blocks instead of forty-five selectors. Everything lives in @layer components so a Tailwind utility passed through `className` (`w-full`, `mt-lg`, `text-text-secondary`, ...) still wins. The layer statement is a defensive no-op when app.css has already declared the same order, and makes this file correct on its own if it ever loads first. HEIGHTS. Figma draws the stroke *inside* the frame, so a Secondary button is the same height as a Primary one. Borders are therefore subtracted from the padding, which lands every size on its designed box: sm 6.5*2 + 20 + 1.5*2 = 36 md 10.5*2 + 24 + 1.5*2 = 48 lg 10.5*2 + 28 + 1.5*2 = 52 The 20/24/28 line-heights come from the type role the component puts on the element (body-sm-bold / body-md-bold / body-lg-bold). DEVIATIONS FROM FIGMA, deliberate: * Focus-visible is not drawn in Figma at all. WCAG 2.4.7 requires it, so the semantic focus ring is applied here; filled variants switch to the inverse ring colour because a green ring on a green fill is invisible. * "Ghost / Large / Default" is the one cell in the matrix with no padding while its own Hover and Disabled cells have it - an authoring slip that would make the button jump on hover. Padding is kept on all ghost states. * Disabled uses the opaque `-bg-disabled` / `-fg-disabled` tokens rather than Figma's 40% opacity, so the page never shows through a disabled control. `--control-opacity-disabled` still holds the 0.4 if that is ever wanted. ========================================================================= */ @layer theme, base, components, utilities; @layer components { /* --------------------------------------------------------------------- Base - shared by every variant and size. A one-cell grid stacks the label and the loading spinner without either transforms or absolute offsets, so it stays correct under dir="rtl". ------------------------------------------------------------------ */ .sh-btn { display: inline-grid; grid-template-areas: "sh-btn-stack"; place-items: center; border-style: solid; border-width: var(--control-border-width); border-radius: var(--radius-control); background-color: var(--sh-btn-bg); color: var(--sh-btn-fg); border-color: var(--sh-btn-border); text-align: center; text-decoration: none; cursor: pointer; -webkit-user-select: none; user-select: none; transition: background-color var(--motion-duration-fast) var(--motion-ease-standard), border-color var(--motion-duration-fast) var(--motion-ease-standard), color var(--motion-duration-fast) var(--motion-ease-standard), transform var(--motion-duration-base) var(--motion-ease-entrance); } /* The Claude Design drafts lift a button 3px under the pointer and set it back down on press. Pointer devices only - a touch "hover" would leave the control raised after the tap - and never while disabled or busy, where the control must not look live. app.css collapses the transition under prefers-reduced-motion, so the lift becomes an instant, harmless offset. The drafts also put a coloured glow behind the lifted button; that is deliberately not shipped (client decision). */ @media (hover: hover) { .sh-btn:hover:not(:disabled):not([aria-disabled="true"]):not([data-loading]) { transform: translateY(-3px); } } .sh-btn:active:not(:disabled):not([aria-disabled="true"]):not([data-loading]) { transform: none; } .sh-btn--block { display: grid; inline-size: 100%; } .sh-btn__content { grid-area: sh-btn-stack; display: inline-flex; align-items: center; justify-content: center; gap: var(--control-gap); transition: opacity var(--motion-duration-fast) var(--motion-ease-standard); } /* The 24px box the icon is drawn inside in Figma. Kept on the slot rather than on the icon so a consumer's own glyph inherits the same geometry. */ .sh-btn__icon { display: inline-flex; flex: none; align-items: center; justify-content: center; inline-size: var(--control-icon-size); block-size: var(--control-icon-size); } /* --------------------------------------------------------------------- Sizes - Figma: sm 16/8 (space.md/space.xs), md + lg 24/12 (space.lg/sm) ------------------------------------------------------------------ */ .sh-btn[data-size="sm"] { padding-inline: calc(var(--control-padding-inline-sm) - var(--control-border-width)); padding-block: calc(var(--control-padding-block-sm) - var(--control-border-width)); } .sh-btn[data-size="md"] { padding-inline: calc(var(--control-padding-inline-md) - var(--control-border-width)); padding-block: calc(var(--control-padding-block-md) - var(--control-border-width)); } .sh-btn[data-size="lg"] { padding-inline: calc(var(--control-padding-inline-lg) - var(--control-border-width)); padding-block: calc(var(--control-padding-block-lg) - var(--control-border-width)); } /* --------------------------------------------------------------------- Variants - the only place a token name appears twice. ------------------------------------------------------------------ */ .sh-btn[data-variant="primary"] { --sh-btn-bg: var(--interactive-primary-bg); --sh-btn-bg-hover: var(--interactive-primary-bg-hover); --sh-btn-bg-active: var(--interactive-primary-bg-active); --sh-btn-bg-disabled: var(--interactive-primary-bg-disabled); --sh-btn-fg: var(--interactive-primary-fg); --sh-btn-fg-hover: var(--interactive-primary-fg-hover); --sh-btn-fg-disabled: var(--interactive-primary-fg-disabled); --sh-btn-border: var(--interactive-primary-border); --sh-btn-border-hover: var(--interactive-primary-border-hover); --sh-btn-border-disabled: var(--interactive-primary-border-disabled); } .sh-btn[data-variant="secondary"] { --sh-btn-bg: var(--interactive-secondary-bg); --sh-btn-bg-hover: var(--interactive-secondary-bg-hover); --sh-btn-bg-active: var(--interactive-secondary-bg-active); --sh-btn-bg-disabled: var(--interactive-secondary-bg-disabled); --sh-btn-fg: var(--interactive-secondary-fg); --sh-btn-fg-hover: var(--interactive-secondary-fg-hover); --sh-btn-fg-disabled: var(--interactive-secondary-fg-disabled); --sh-btn-border: var(--interactive-secondary-border); --sh-btn-border-hover: var(--interactive-secondary-border-hover); --sh-btn-border-disabled: var(--interactive-secondary-border-disabled); } .sh-btn[data-variant="dark"] { --sh-btn-bg: var(--interactive-dark-bg); --sh-btn-bg-hover: var(--interactive-dark-bg-hover); --sh-btn-bg-active: var(--interactive-dark-bg-active); --sh-btn-bg-disabled: var(--interactive-dark-bg-disabled); --sh-btn-fg: var(--interactive-dark-fg); --sh-btn-fg-hover: var(--interactive-dark-fg-hover); --sh-btn-fg-disabled: var(--interactive-dark-fg-disabled); --sh-btn-border: var(--interactive-dark-border); --sh-btn-border-hover: var(--interactive-dark-border-hover); --sh-btn-border-disabled: var(--interactive-dark-border-disabled); } .sh-btn[data-variant="danger"] { --sh-btn-bg: var(--interactive-danger-bg); --sh-btn-bg-hover: var(--interactive-danger-bg-hover); --sh-btn-bg-active: var(--interactive-danger-bg-active); --sh-btn-bg-disabled: var(--interactive-danger-bg-disabled); --sh-btn-fg: var(--interactive-danger-fg); --sh-btn-fg-hover: var(--interactive-danger-fg-hover); --sh-btn-fg-disabled: var(--interactive-danger-fg-disabled); --sh-btn-border: var(--interactive-danger-border); --sh-btn-border-hover: var(--interactive-danger-border-hover); --sh-btn-border-disabled: var(--interactive-danger-border-disabled); } .sh-btn[data-variant="ghost"] { --sh-btn-bg: var(--interactive-ghost-bg); --sh-btn-bg-hover: var(--interactive-ghost-bg-hover); --sh-btn-bg-active: var(--interactive-ghost-bg-active); --sh-btn-bg-disabled: var(--interactive-ghost-bg-disabled); --sh-btn-fg: var(--interactive-ghost-fg); --sh-btn-fg-hover: var(--interactive-ghost-fg-hover); --sh-btn-fg-disabled: var(--interactive-ghost-fg-disabled); --sh-btn-border: var(--interactive-ghost-border); --sh-btn-border-hover: var(--interactive-ghost-border-hover); --sh-btn-border-disabled: var(--interactive-ghost-border-disabled); } /* Figma gives the ghost md/lg cells radius.md while every other cell uses radius.sm - reproduced rather than normalised. */ .sh-btn[data-variant="ghost"][data-size="md"], .sh-btn[data-variant="ghost"][data-size="lg"] { border-radius: var(--radius-control-lg); } /* --------------------------------------------------------------------- States ------------------------------------------------------------------ */ @media (hover: hover) { .sh-btn:hover:not(:disabled, [aria-disabled="true"]) { background-color: var(--sh-btn-bg-hover); color: var(--sh-btn-fg-hover); border-color: var(--sh-btn-border-hover); } } /* No `-fg-active` / `-border-active` exists in the token contract, so the pressed state deepens the fill only. */ .sh-btn:active:not(:disabled, [aria-disabled="true"]) { background-color: var(--sh-btn-bg-active); } .sh-btn:focus-visible { outline: var(--focus-ring-width) solid var(--focus-ring-color); outline-offset: var(--focus-ring-offset); } /* A green ring on a green (or red, or near-black) fill reads as nothing. */ .sh-btn[data-variant="primary"]:focus-visible, .sh-btn[data-variant="danger"]:focus-visible, .sh-btn[data-variant="dark"]:focus-visible { outline-color: var(--focus-ring-color-inverse); } .sh-btn:disabled, .sh-btn[aria-disabled="true"] { background-color: var(--sh-btn-bg-disabled); color: var(--sh-btn-fg-disabled); border-color: var(--sh-btn-border-disabled); cursor: not-allowed; } /* --------------------------------------------------------------------- Loading - the label keeps its box (no reflow) and stays in the accessibility tree, which `visibility: hidden` would not allow. ------------------------------------------------------------------ */ .sh-btn[data-loading="true"] { cursor: progress; } .sh-btn[data-loading="true"] .sh-btn__content { opacity: 0; } .sh-btn__spinner { grid-area: sh-btn-stack; inline-size: 1em; block-size: 1em; border: var(--control-border-width) solid currentColor; border-block-start-color: transparent; border-radius: var(--radius-pill); /* The contract publishes fast/base/slow only; two slows is the ~640ms a spinner wants without inventing a duration token. */ animation: sh-btn-spin calc(var(--motion-duration-slow) * 2) linear infinite; } } @keyframes sh-btn-spin { to { rotate: 1turn; } }