Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/reset-pointer-cursor.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<button>` or carries a role from that list. Override it with an unlayered app rule.

11. **Theme**: a single `ThemeOptions` in `src/lib/theme.ts` (plain object, `import type` only: `vite.config`/`astro.config` load the file in Node, where the package root does not evaluate `.svelte`), passed to `caThemeHandle` + `caThemePlugin` (SvelteKit) or `caUI` (Astro). `defaultStyle` (the surface material, `solid` by default) is one field of it, not a separate setup. Override a token by declaring `--ca-*` on `:root` in the app, without `!important`. Local islands: `data-theme-scope="dark|light"`. Automatic setup: `bunx @codeatlasdev/ui init`.

12. **SEO**: a public page uses `<Seo>` from `@codeatlasdev/ui/seo` with a required `canonical`; a private page passes `noindex`.
Expand Down
3 changes: 0 additions & 3 deletions packages/ui/src/lib/styles/materials.css
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,6 @@
border: 0.5px solid var(--ca-glass-border);
box-shadow: inset 0 0.5px 0 var(--ca-glass-rim);
color: var(--ca-vibrancy-label);
cursor: pointer;
}
.glass-btn:hover { background-color: var(--ca-glass-tint); }
.glass-btn:active { background-color: var(--ca-glass-tint-thick); }
Expand All @@ -184,7 +183,6 @@
border: 0.5px solid var(--ca-glow-primary-strong);
box-shadow: inset 0 0.5px 0 var(--ca-glass-rim), 0 0 0 1px var(--ca-glow-primary);
color: var(--ca-vibrancy-label);
cursor: pointer;
}

.glass-btn-clear {
Expand All @@ -193,7 +191,6 @@
backdrop-filter: var(--ca-blur-ultra-thin);
border: 0.5px solid var(--ca-glass-border);
color: var(--ca-vibrancy-label);
cursor: pointer;
}
.glass-btn-clear:hover { background-color: var(--ca-glass-tint-ultra-thin); }

Expand Down
76 changes: 76 additions & 0 deletions packages/ui/src/lib/styles/reset-cursor.svelte.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { afterEach, describe, expect, it } from "vitest";

/**
* #549: a clickable element that is not a kit component (a tile with `role="button"`, a
* `<summary>`, a `<label for>`) showed the arrow cursor, because `cursor: pointer` was written per
* component. The reset layer now gives every interactive shape a pointer and every disabled one
* `not-allowed`; it lives in `@layer ca.reset`, so any app rule overrides it.
*/
const host = document.createElement("div");
const appCss = document.createElement("style");
afterEach(() => {
host.remove();
appCss.remove();
});

const cursor = (html: string) => {
host.innerHTML = html;
document.body.append(host);
return getComputedStyle(host.firstElementChild as Element).cursor;
};

describe("reset gives interactive elements a pointer cursor", () => {
const interactive: Record<string, string> = {
button: `<button type="button">b</button>`,
"role=button": `<div role="button" tabindex="0">t</div>`,
"role=tab": `<div role="tab">t</div>`,
"role=option": `<div role="option">o</div>`,
"role=menuitem": `<div role="menuitem">m</div>`,
"role=menuitemcheckbox": `<div role="menuitemcheckbox">m</div>`,
"role=menuitemradio": `<div role="menuitemradio">m</div>`,
summary: `<details><summary>s</summary>x</details>`,
"label[for]": `<label for="x">l</label><input id="x">`,
"a[href]": `<a href="/x">a</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 <a> without href and a plain <div> keep the default", () => {
expect(cursor(`<a>a</a>`)).toBe("auto");
expect(cursor(`<div>d</div>`)).toBe("auto");
});
});

describe("the glass and rail classes rely on the reset (no own cursor rule)", () => {
it("glass-btn*, ca-rail-btn on <button>, ca-rail-btn on <a href>", () => {
for (const html of [
`<button type="button" class="glass-btn">b</button>`,
`<button type="button" class="glass-btn-prominent">b</button>`,
`<button type="button" class="glass-btn-clear">b</button>`,
`<button type="button" class="ca-rail-btn">b</button>`,
`<a href="/x" class="ca-rail-btn">a</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(`<button type="button" disabled>b</button>`)).toBe("not-allowed");
expect(cursor(`<div role="button" aria-disabled="true">t</div>`)).toBe("not-allowed");
expect(cursor(`<div role="menuitem" aria-disabled="true">m</div>`)).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(`<div role="button" tabindex="0">t</div>`)).toBe("grab");
});
});
2 changes: 2 additions & 0 deletions packages/ui/src/lib/styles/reset.css
Original file line number Diff line number Diff line change
@@ -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}
1 change: 0 additions & 1 deletion packages/ui/src/lib/styles/tokens.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 1 addition & 3 deletions packages/ui/src/lib/styles/utilities.css
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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) ─────────────────────────────── */
Expand Down
9 changes: 8 additions & 1 deletion site/src/content/docs/getting-started/theming.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<button>`) rather than a bare `div` with `onclick`: it gets the pointer, the keyboard contract and the right role at once.

If your own CSS lives in a layer (for example Tailwind's `@layer components`), declare it after the kit's order statement so it ranks higher: `@layer ca.reset, ca.tokens, ca.themes, ca.base, ca.materials, ca.utilities, ca.components, app;`.

Expand Down
1 change: 1 addition & 0 deletions site/src/content/docs/getting-started/workarounds.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ Imports are from `@codeatlasdev/ui` unless the last column names a subpath.
| `@media (max-width: 767px) { .ca-btn__trailing { display: none } }` to hide a `Kbd` hint | `<Button trailingHint>` with the `Kbd` in `trailing` | [Button](/docs/inputs/button/) |
| `justify-content: flex-start` and `margin-left: auto` on `.ca-btn__trailing` | `<Button align="between" fullWidth>` | [Button](/docs/inputs/button/) |
| `100vh` or `100dvh` | `height: var(--ca-vh)` | [Mobile](/docs/getting-started/mobile/#safe-areas-and-viewport-height) |
| `cursor: pointer` on a clickable `div role="button"`, `summary` or `label for`, or `cursor: not-allowed` on a disabled one | Nothing: the reset layer (`@layer ca.reset`) already sets `pointer` on `button`, `a[href]`, `label[for]`, `summary` and `[role=button\|tab\|option\|menuitem*]`, and `not-allowed` on `:disabled` and `[aria-disabled=true]`. An unlayered app rule such as `.tile { cursor: grab }` overrides it with no `!important` | [Theming](/docs/getting-started/theming/#cascade-and-overriding-tokens) |
| A `min-height: 44px` touch override | Nothing: controls grow to 44px under `(pointer: coarse)` | [Mobile](/docs/getting-started/mobile/#native-feel) |
| `padding-bottom: env(safe-area-inset-bottom)` on fixed UI | `var(--ca-safe-bottom)` and the other `--ca-safe-*` tokens | [Mobile](/docs/getting-started/mobile/#safe-areas-and-viewport-height) |
| A keyboard-padding hack for a form or composer | `use:keyboardAware` | [Mobile](/docs/getting-started/mobile/#the-on-screen-keyboard), `@codeatlasdev/ui/actions` |
Expand Down