feat: add button press feedback

This commit is contained in:
2026-07-21 15:10:08 +02:00
parent 801f847ec4
commit 4c9424f1e3
6 changed files with 178 additions and 1 deletions
+33
View File
@@ -0,0 +1,33 @@
# Product
## Register
product
## Users
ThothII serves technical and clinical analysts who build and review datamarts through a guided natural-language-to-SQL workflow. They work in a dense operational interface, often moving repeatedly between model output, persisted artifacts, and human review gates.
## Product Purpose
The product turns analytical questions into traceable, reviewable SQL work. It should make model activity, workflow state, and human decisions legible while keeping the analyst in control of every consequential transition.
## Brand Personality
Calm, precise, and trustworthy. Interaction feedback should feel as immediate and disciplined as Linear or Raycast, with enough character to acknowledge an action but no decorative spectacle.
## Anti-references
Avoid generic SaaS ornament, conspicuous ripples, bounce or elastic motion, long choreographed transitions, and effects that compete with the analytical task. Controls must not feel playful, sluggish, or visually unstable.
## Design Principles
1. Make state changes unmistakable without interrupting flow.
2. Prefer consistent, familiar controls over novel affordances.
3. Keep the persisted workflow and reviewer decisions visually authoritative.
4. Use motion only to explain feedback, progress, or spatial relationships.
5. Preserve information density while maintaining clear hierarchy.
## Accessibility & Inclusion
Keyboard focus must remain explicit, color cannot be the sole carrier of meaning, and motion must respect `prefers-reduced-motion`. Reduced-motion users should retain immediate non-kinetic visual feedback for every action.
@@ -0,0 +1,19 @@
# Button Press Feedback Design
## Context
Buttons currently change on hover, but many provide little or no visible acknowledgment while the pointer is pressed. The interface mixes a shared Base UI button with native buttons, so changing only the shared component would leave inconsistent behavior.
## Chosen interaction
Apply one CSS press vocabulary to every enabled native button. During `:active`, the button compresses to `scale(0.97)`, loses raised shadow, and receives a restrained brightness change. The transition lasts 140 ms and uses an ease-out-quint curve (`cubic-bezier(0.22, 1, 0.36, 1)`). This reads as a physical press without bounce, ripple, layout movement, or JavaScript state.
The existing one-pixel translation on the shared button is removed so shared and native buttons do not combine two motion patterns.
## Accessibility
Under `prefers-reduced-motion: reduce`, scale is disabled. The brightness and shadow change remain, providing a clear pressed state without kinetic motion. Disabled buttons receive no press treatment.
## Verification
A focused contract test checks the global enabled-button selector, scale, timing, easing, and reduced-motion override. The full frontend suite and typecheck guard regressions. Playwright then holds a real button in the active state and confirms its computed transform, followed by a visual screenshot/snapshot check.
@@ -0,0 +1,72 @@
# Button Press Feedback Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Give every enabled button immediate, consistent click acknowledgment while preserving a non-kinetic reduced-motion alternative.
**Architecture:** Define the interaction once in the global Tailwind base layer so both Base UI and native buttons inherit it. Remove the shared button's older translation-only active state to avoid compounded transforms. Verify the CSS contract first, then exercise the real interaction in Playwright.
**Tech Stack:** React 18, Tailwind CSS 3, Vitest, Playwright CLI.
---
### Task 1: Specify the global press contract
**Files:**
- Create: `frontend/src/button-press-feedback.test.ts`
- Test: `frontend/src/button-press-feedback.test.ts`
**Step 1: Write the failing test**
Read `src/index.css` and assert the enabled-button active selector, `scale(0.97)`, 140 ms duration, ease-out-quint curve, disabled exclusion, and reduced-motion transform override. Assert that `components/ui/button.tsx` no longer contains the legacy translation active class.
**Step 2: Run test to verify it fails**
Run: `npx vitest run src/button-press-feedback.test.ts`
Expected: FAIL because the global press rules do not exist and the shared button still uses translation.
### Task 2: Implement the press feedback
**Files:**
- Modify: `frontend/src/index.css`
- Modify: `frontend/src/components/ui/button.tsx`
- Test: `frontend/src/button-press-feedback.test.ts`
**Step 1: Add the minimal CSS**
Add a global enabled-button transition and active state using only transform, filter, and shadow. Add a `prefers-reduced-motion` override that removes scale while preserving non-kinetic contrast feedback.
**Step 2: Remove the legacy shared-button translation**
Delete `active:not-aria-[haspopup]:translate-y-px` from the shared variant base string.
**Step 3: Run the focused test**
Run: `npx vitest run src/button-press-feedback.test.ts`
Expected: PASS.
### Task 3: Verify regressions and real-browser behavior
**Files:**
- Verify: `frontend/src/index.css`
- Verify: `frontend/src/components/ui/button.tsx`
**Step 1: Run frontend verification**
Run: `npx vitest run`
Run: `npx tsc -b`
Expected: all tests pass and typecheck exits 0.
**Step 2: Verify in Playwright**
Open `http://localhost:5173`, hold pointer-down on an enabled button, and inspect its computed transform and filter before release. Repeat with reduced motion emulation and confirm transform remains `none` while contrast feedback remains.
**Step 3: Review the final diff**
Run: `git diff --check` and inspect `git diff --stat`.
Expected: no whitespace errors and only the intended product/design, CSS, component, and test files changed.
@@ -0,0 +1,26 @@
import { readFileSync } from "node:fs";
import { describe, expect, test } from "vitest";
const css = readFileSync("src/index.css", "utf8");
const sharedButton = readFileSync("src/components/ui/button.tsx", "utf8");
describe("button press feedback", () => {
test("gives every enabled button a short physical press state", () => {
expect(css).toMatch(/button:not\(:disabled\)\s*\{[^}]*transition-duration:\s*140ms;/s);
expect(css).toMatch(
/button:not\(:disabled\):active\s*\{[^}]*transform:\s*scale\(0\.97\);[^}]*filter:\s*brightness\(0\.94\);/s,
);
expect(css).toContain("cubic-bezier(0.22, 1, 0.36, 1)");
expect(sharedButton).not.toContain("active:not-aria-[haspopup]:translate-y-px");
});
test("keeps non-kinetic feedback when reduced motion is requested", () => {
expect(css).toMatch(
/@media \(prefers-reduced-motion: reduce\)\s*\{[\s\S]*?button:not\(:disabled\)\s*\{[^}]*transition-duration:\s*0\.01ms;/,
);
expect(css).toMatch(
/@media \(prefers-reduced-motion: reduce\)\s*\{[\s\S]*?button:not\(:disabled\):active\s*\{[^}]*transform:\s*none;/,
);
});
});
+1 -1
View File
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-md border border-transparent bg-clip-padding text-sm font-semibold tracking-[0.005em] whitespace-nowrap transition-all outline-none select-none focus-visible:ring-3 focus-visible:ring-ring/25 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group/button inline-flex shrink-0 items-center justify-center rounded-md border border-transparent bg-clip-padding text-sm font-semibold tracking-[0.005em] whitespace-nowrap transition-all outline-none select-none focus-visible:ring-3 focus-visible:ring-ring/25 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
+27
View File
@@ -95,6 +95,33 @@
@apply border-border outline-ring/50;
}
/* A click should read as a physical state change, not only as a hover ending.
Keep the response short and compositor-friendly so repeated actions stay fast. */
button:not(:disabled) {
transform-origin: center;
transition-property: color, background-color, border-color, box-shadow, filter, transform;
transition-duration: 140ms;
transition-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
}
button:not(:disabled):active {
transform: scale(0.97);
filter: brightness(0.94);
box-shadow: none;
}
@media (prefers-reduced-motion: reduce) {
button:not(:disabled) {
transition-duration: 0.01ms;
}
button:not(:disabled):active {
transform: none;
filter: brightness(0.9);
box-shadow: none;
}
}
html {
@apply font-sans;
}