From 616e0b6bbb3172bad1f7c359e2e642154e196770 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Vila=C3=A7a=20de=20Jesus?= Date: Sat, 24 Oct 2026 23:00:16 -0300 Subject: [PATCH 1/2] chore: start #549 From 559576b8ceb542452edd285492548f665ffdf385 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Vila=C3=A7a=20de=20Jesus?= Date: Sat, 24 Oct 2026 23:19:37 -0300 Subject: [PATCH 2/2] feat: reset layer gives every clickable control a pointer cursor (#549) Fixes #549 --- .changeset/reset-pointer-cursor.md | 5 ++ AGENTS.md | 2 + packages/ui/src/lib/styles/materials.css | 3 - .../lib/styles/reset-cursor.svelte.test.ts | 76 +++++++++++++++++++ packages/ui/src/lib/styles/reset.css | 2 + packages/ui/src/lib/styles/tokens.css | 1 - packages/ui/src/lib/styles/utilities.css | 4 +- .../content/docs/getting-started/theming.mdx | 9 ++- .../docs/getting-started/workarounds.mdx | 1 + 9 files changed, 95 insertions(+), 8 deletions(-) create mode 100644 .changeset/reset-pointer-cursor.md create mode 100644 packages/ui/src/lib/styles/reset-cursor.svelte.test.ts diff --git a/.changeset/reset-pointer-cursor.md b/.changeset/reset-pointer-cursor.md new file mode 100644 index 00000000..2861f55d --- /dev/null +++ b/.changeset/reset-pointer-cursor.md @@ -0,0 +1,5 @@ +--- +"@codeatlasdev/ui": minor +--- + +The reset layer (`@layer ca.reset`) now gives every interactive shape a pointer cursor: `button`, `a[href]`, `label[for]`, `summary`, `[role=button]`, `[role=tab]`, `[role=option]` and `[role=menuitem*]`. `:disabled` and `[aria-disabled=true]` get `not-allowed`. A custom clickable tile no longer shows the arrow cursor; an app overrides the rule with an unlayered `cursor` declaration, no `!important`. The stylesheet drops the now redundant `cursor: pointer` of `.glass-btn*` and `.ca-rail-btn`, the unused `.ca-bar__actions` rule and the unused `--ca-grid-columns` token to stay inside its 17250 B budget. diff --git a/AGENTS.md b/AGENTS.md index 58b73204..cfa2f5ef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -139,6 +139,8 @@ Reading failures: locators fail with an ARIA snapshot; traces are in `__traces__ 10. **Mobile**: heights use `var(--ca-vh)` (never `100vh`/`100dvh`); `fixed` UI uses `var(--ca-safe-*)`; a form with a keyboard uses `use:keyboardAware` (`@codeatlasdev/ui/actions`); a full-screen mobile view is `MobilePane`, an action bar is `StickyActions`; the 44px touch target is global under `(pointer: coarse)`, so do not write an override in the app. App viewport: `width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content`. +10a. **Cursor**: never write `cursor: pointer` for a clickable element. The reset (`@layer ca.reset`) gives `pointer` to `button`, `a[href]`, `label[for]`, `summary`, `[role=button|tab|option|menuitem*]` and `not-allowed` to `:disabled` / `[aria-disabled=true]`; a custom clickable tile is ``, + "role=button": `
t
`, + "role=tab": `
t
`, + "role=option": `
o
`, + "role=menuitem": `
m
`, + "role=menuitemcheckbox": `
m
`, + "role=menuitemradio": `
m
`, + summary: `
sx
`, + "label[for]": ``, + "a[href]": `a`, + }; + for (const [name, html] of Object.entries(interactive)) + it(name, () => { + host.innerHTML = html; + document.body.append(host); + const target = host.querySelector("summary, label, a, button, [role]") as Element; + expect(getComputedStyle(target).cursor).toBe("pointer"); + }); + + it("a bare without href and a plain
keep the default", () => { + expect(cursor(`a`)).toBe("auto"); + expect(cursor(`
d
`)).toBe("auto"); + }); +}); + +describe("the glass and rail classes rely on the reset (no own cursor rule)", () => { + it("glass-btn*, ca-rail-btn on `, + ``, + ``, + ``, + `a`, + ]) + expect(cursor(html), html).toBe("pointer"); + }); +}); + +describe("reset marks disabled controls not-allowed", () => { + it("disabled button, aria-disabled role=button, aria-disabled menuitem", () => { + expect(cursor(``)).toBe("not-allowed"); + expect(cursor(`
t
`)).toBe("not-allowed"); + expect(cursor(`
m
`)).toBe("not-allowed"); + }); +}); + +describe("apps override the reset freely", () => { + it("an unlayered app rule beats it with no !important", () => { + appCss.textContent = `[role="button"] { cursor: grab; }`; + document.head.append(appCss); + expect(cursor(`
t
`)).toBe("grab"); + }); +}); diff --git a/packages/ui/src/lib/styles/reset.css b/packages/ui/src/lib/styles/reset.css index 07f86cd0..21b6ed2a 100644 --- a/packages/ui/src/lib/styles/reset.css +++ b/packages/ui/src/lib/styles/reset.css @@ -1 +1,3 @@ button,input,select,textarea{font:inherit} +:is(button,[role=button],[role=tab],[role=option],[role^=menuitem],summary,label[for],a[href]){cursor:pointer} +:disabled:disabled,[aria-disabled=true][aria-disabled=true]{cursor:not-allowed} diff --git a/packages/ui/src/lib/styles/tokens.css b/packages/ui/src/lib/styles/tokens.css index c1be7b69..79111d47 100644 --- a/packages/ui/src/lib/styles/tokens.css +++ b/packages/ui/src/lib/styles/tokens.css @@ -143,7 +143,6 @@ * scripts/lint-tokens.ts rejects any other breakpoint value. Fluid gutter and section * rhythm are derived below. */ --ca-content-max: 76rem; - --ca-grid-columns: 12; /* ── Component roles ─────────────────────────────────────────────────── */ --ca-control-height: 36px; diff --git a/packages/ui/src/lib/styles/utilities.css b/packages/ui/src/lib/styles/utilities.css index 4877dada..5e2e93ea 100644 --- a/packages/ui/src/lib/styles/utilities.css +++ b/packages/ui/src/lib/styles/utilities.css @@ -162,7 +162,6 @@ tr:focus-within .ca-row-actions { border-radius: var(--ca-radius-md); border: none; background: transparent; - cursor: pointer; color: var(--ca-text-hint); text-decoration: none; transition: @@ -213,12 +212,11 @@ tr:focus-within .ca-row-actions { .ca-pricing-cta { width: 100%; margin-top: auto; } -/* Bar: one height per row; lead truncates, actions never wrap, subtitle drops first. */ +/* Bar: one height per row; lead truncates, subtitle drops first. */ .ca-bar{display:flex;align-items:center;gap:var(--ca-space-3);min-height:var(--ca-bar-h);padding-inline:var(--ca-space-4);border-bottom:var(--ca-border-width) solid var(--ca-outline-subtle);flex-shrink:0;min-width:0;container-type:inline-size} .ca-bar__lead{display:flex;align-items:baseline;gap:var(--ca-space-2);flex:1;min-width:0} .ca-bar__title,.ca-bar__sub{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .ca-bar__sub{flex:1;font-size:var(--ca-font-size-caption);color:var(--ca-text-secondary)} -.ca-bar__actions{display:flex;align-items:center;gap:var(--ca-space-2);flex-shrink:0;margin-inline-start:auto} @container (max-width:599.98px){.ca-bar__sub{display:none}} /* ─── Skeleton state (`skeleton` prop) ─────────────────────────────── */ diff --git a/site/src/content/docs/getting-started/theming.mdx b/site/src/content/docs/getting-started/theming.mdx index 1551c242..b9dba84f 100644 --- a/site/src/content/docs/getting-started/theming.mdx +++ b/site/src/content/docs/getting-started/theming.mdx @@ -307,7 +307,14 @@ Every component stylesheet is wrapped in `@layer ca.components`, the last kit la .ca-dialog__body { padding: 0; } ``` -`ca.reset` is the first layer and holds one rule: `button, input, select, textarea { font: inherit }`, so native controls use the page font instead of the browser's (Arial on Chromium/Windows). Every component and every app rule outranks it. +`ca.reset` is the first layer and holds three rules. Native controls use the page font instead of the browser's (Arial on Chromium/Windows): `button, input, select, textarea { font: inherit }`. Every interactive shape gets a pointer cursor: `button`, `a[href]`, `label[for]`, `summary`, `[role=button]`, `[role=tab]`, `[role=option]` and `[role=menuitem*]`, so a custom clickable tile never shows the arrow. `:disabled` and `[aria-disabled=true]` get `not-allowed`. Every component and every app rule outranks all three, so `.tile { cursor: grab }` just works: + +```css +/* app.css: a tile that is not a button */ +.tile { cursor: grab; } +``` + +Give a clickable tile `role="button"` (or make it a `