+
(functio
variant = "ghost",
size = "sm",
pressed,
- onMedia = false,
+ round = false,
loading = false,
noTitle = false,
type,
@@ -102,7 +103,7 @@ export const IconButton = forwardRef(functio
className={clsx(
"inline-grid shrink-0 place-items-center transition touch-target aria-expanded:text-accent disabled:opacity-50 disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:cursor-not-allowed",
VARIANTS[variant],
- onMedia || variant === "media" ? "rounded-full" : "rounded-lg",
+ round || variant === "media" ? "rounded-full" : "rounded-lg",
pressed !== undefined && PRESSED,
SIZES[size],
className,
diff --git a/frontend/src/components/ui/Input.dom.test.tsx b/frontend/src/components/ui/Input.dom.test.tsx
index 61ec3530..f069bd1c 100644
--- a/frontend/src/components/ui/Input.dom.test.tsx
+++ b/frontend/src/components/ui/Input.dom.test.tsx
@@ -4,6 +4,7 @@
// back values, and the dropdown trigger by context.
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { fieldClass } from "./fieldStyles";
import { Input, Textarea } from "./Input";
import { CheckRow, Slider } from "./NativeControls";
import { SelectTrigger } from "./SelectTrigger";
@@ -29,6 +30,15 @@ describe("Input", () => {
expect(search.getAttribute("aria-invalid")).toBe("true");
});
+ it("dims a wrapper frame for its disabled FIELD, never for a disabled button beside it", () => {
+ // The header search holds a Go button that is disabled while the query is empty; a bare
+ // `has-disabled:` faded the whole search box to 50% on the feed.
+ const frame = fieldClass({ size: "free", pill: true, floating: true });
+ expect(frame).not.toMatch(/(^| )has-disabled:/);
+ expect(frame).not.toMatch(/(^| )has-aria-invalid:/);
+ expect(frame).toContain("has-[input:disabled]:opacity-50");
+ });
+
it("lets a caller lay it out but not re-paint its edge", () => {
render();
const body = screen.getByRole("textbox", { name: "body" });
diff --git a/frontend/src/components/ui/fieldStyles.ts b/frontend/src/components/ui/fieldStyles.ts
index 26039608..1c80e27d 100644
--- a/frontend/src/components/ui/fieldStyles.ts
+++ b/frontend/src/components/ui/fieldStyles.ts
@@ -6,6 +6,7 @@ import clsx from "clsx";
// itself. Its own file so `Input.tsx` exports components only (react-refresh).
export type FieldSize =
+ | "lg" // 40 — the page's one hero field: the header search, the command palette
| "md" // 32 — a form field, lines up with a `md` Button
| "sm" // 28 — a toolbar search, a field in a popover or an inline rename, lines up with the toolbar row
| "xs" // 24 — inside a table row or a pager
@@ -15,6 +16,7 @@ export type FieldSize =
// On a coarse pointer the text is 1rem, so a tablet browser does not zoom the page on focus (not on a
// heading, whose type is already larger).
const SIZES: Record = {
+ lg: "h-10 px-3 text-sm pointer-coarse:text-base",
md: "h-8 px-3 text-sm pointer-coarse:text-base",
sm: "h-7 px-2.5 text-sm pointer-coarse:text-base",
xs: "h-6 px-2 text-xs pointer-coarse:text-base",
@@ -24,18 +26,31 @@ const SIZES: Record = {
// The edge is --edge-strong (the fg mixed 55% into the bg, ≥ 3:1 on every scheme — WCAG 1.4.11); focus
// turns it into a 2px accent edge (the border plus a 1px ring), the outlined-field convention. Invalid
-// is the danger edge, focused or not. On a coarse pointer the box grows to the 44 touch target.
+// is the danger edge, focused or not. A wrapper frame keys both states to its own INPUT, not to any
+// descendant: the header search's Go button is disabled while the query is empty, and a bare
+// `has-disabled:` faded the whole box. On a coarse pointer the box grows to the 44 touch target.
const BASE =
- "min-w-0 border edge-strong bg-transparent text-fg placeholder:text-muted transition-colors " +
+ "min-w-0 border edge-strong text-fg placeholder:text-muted transition-colors " +
"focus-within:border-accent focus-within:ring-1 focus-within:ring-accent focus-within:outline-hidden " +
- "aria-invalid:border-danger aria-invalid:ring-danger has-aria-invalid:border-danger has-aria-invalid:ring-danger " +
- "disabled:cursor-not-allowed disabled:opacity-50 has-disabled:cursor-not-allowed has-disabled:opacity-50 " +
+ "aria-invalid:border-danger aria-invalid:ring-danger " +
+ "has-[input[aria-invalid=true]]:border-danger has-[input[aria-invalid=true]]:ring-danger " +
+ "disabled:cursor-not-allowed disabled:opacity-50 " +
+ "has-[input:disabled]:cursor-not-allowed has-[input:disabled]:opacity-50 " +
"pointer-coarse:min-h-(--touch-target)";
+// `floating`: the field hovers over the scrolling page (the header search), so it keeps the glass-menu
+// surface under its edge instead of a see-through one (A2: what floats over content is glass). The
+// shadow is a utility so the focus ring's box-shadow composes with it instead of dropping it.
export function fieldClass({
size = "md",
pill = false,
-}: { size?: FieldSize; pill?: boolean } = {}) {
- // A4: a toolbar search is a pill; every other field r8.
- return clsx(BASE, SIZES[size], pill ? "rounded-full" : "rounded-lg");
+ floating = false,
+}: { size?: FieldSize; pill?: boolean; floating?: boolean } = {}) {
+ // A4: a search is a pill; every other field r8.
+ return clsx(
+ BASE,
+ SIZES[size],
+ pill ? "rounded-full" : "rounded-lg",
+ floating ? "glass-menu shadow-[0_18px_44px_-16px_rgba(0,0,0,0.6)]" : "bg-transparent",
+ );
}