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
7 changes: 7 additions & 0 deletions .changeset/code-line-height-token.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@codeatlasdev/ui": minor
---

New token `--ca-code-line-height` (20px): an integer px line height for code surfaces. `CodeSnippet`, `.ca-code pre` and a bare Markdown `pre` inside `.ca-prose` use it instead of the unitless 1.6 / 1.7, which rounded to a different pixel height in a text layer and in an overlay (a caret and an active-line highlight drifting apart). The `code` inside a `CodeSnippet` also pins `--ca-font-mono` now: it used the UA's bare `monospace`, resolved per lookup. The docs gain "Code surfaces: integer line-height, one font stack" for apps that build an editor.

The stylesheet also drops `--ca-action-focus`, a token no component, doc or template ever read, to stay inside its size budget.
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,8 @@ Reading failures: locators fail with an ARIA snapshot; traces are in `__traces__

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.

10c. **Code surfaces** (an editor, a line highlight, a gutter, a custom `pre`): `line-height: var(--ca-code-line-height)` (an integer px; a fractional one makes a caret and an active-line highlight drift apart) and `font-family: var(--ca-font-mono)` on every layer, `code` included. `CodeSnippet`, `.ca-code` and `.ca-prose pre` already do.

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
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@
margin: 0;
overflow-x: auto;
font-size: var(--ca-font-size-caption);
line-height: var(--ca-line-height-prose);
line-height: var(--ca-code-line-height);
}

/* Shiki renders a <pre> with inline background, neutralise it so glass shows through */
Expand All @@ -122,6 +122,11 @@
line-height: inherit;
}

/* The UA sheet gives <code> a bare `monospace`, resolved per lookup: pin the stack (#468). */
.ca-code-snippet__pre :global(pre.shiki code) {
font: inherit;
}

.ca-code-snippet__pre :global(.shiki span) {
color: light-dark(var(--shiki-light), var(--shiki-dark));
}
Expand Down
70 changes: 70 additions & 0 deletions packages/ui/src/lib/styles/code-surfaces.svelte.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { afterEach, describe, expect, it } from "vitest";
import { render } from "vitest-browser-svelte";
import CodeSnippet from "../components/CodeSnippet/CodeSnippet.svelte";

/**
* #551: an editor's caret and active-line highlight drifted apart because a fractional line-height
* (`1.7 * 13px = 22.1px`, `1.6 * 12px = 19.2px`) rounds differently in the text layer and in the
* overlay that paints the highlight. The kit ships no editor, but its code surfaces are what apps
* copy: they use one integer px token, `--ca-code-line-height`, so an overlay can share the metric.
*/
const host = document.createElement("div");
afterEach(() => {
host.remove();
document.documentElement.style.removeProperty("--ca-code-line-height");
});

const lineHeight = (el: Element) => getComputedStyle(el).lineHeight;

describe("--ca-code-line-height", () => {
it("is an integer px value", () => {
const token = getComputedStyle(document.documentElement)
.getPropertyValue("--ca-code-line-height")
.trim();
expect(token).toMatch(/^\d+px$/);
});

it("`.ca-code pre` and a bare prose `pre` use it", () => {
host.innerHTML = `<div class="ca-prose"><div class="ca-code"><pre><code>a\nb</code></pre></div><pre><code>c</code></pre></div>`;
document.body.append(host);
const token = getComputedStyle(document.documentElement)
.getPropertyValue("--ca-code-line-height")
.trim();
for (const pre of host.querySelectorAll("pre")) {
expect(lineHeight(pre)).toBe(token);
expect(lineHeight(pre.querySelector("code") as Element)).toBe(token);
}
});

it("CodeSnippet uses it, raw and highlighted, and follows an app override", async () => {
document.documentElement.style.setProperty("--ca-code-line-height", "24px");
const screen = await render(CodeSnippet, {
code: "const a = 1;\nconst b = 2;",
language: "typescript",
});
const raw = screen.container.querySelector(".ca-code-snippet__pre--raw") as Element | null;
if (raw) expect(lineHeight(raw)).toBe("24px");
await expect
.poll(() => screen.container.querySelector("pre.shiki"), { timeout: 15_000 })
.not.toBeNull();
const shiki = screen.container.querySelector("pre.shiki") as Element;
expect(lineHeight(shiki)).toBe("24px");
expect(lineHeight(shiki.querySelector("span") as Element)).toBe("24px");
});

it("the monospace stack is the one token everywhere in a code surface", async () => {
const screen = await render(CodeSnippet, { code: "x", language: "typescript" });
await expect
.poll(() => screen.container.querySelector("pre.shiki"), { timeout: 15_000 })
.not.toBeNull();
// Quoting of family names differs between the declared and the computed value: compare bare names.
const mono = getComputedStyle(document.documentElement)
.getPropertyValue("--ca-font-mono")
.trim()
.replaceAll('"', "");
const shiki = screen.container.querySelector("pre.shiki") as Element;
const bare = (el: Element) => getComputedStyle(el).fontFamily.replaceAll('"', "");
expect(bare(shiki)).toBe(mono);
expect(bare(shiki.querySelector("code") as Element)).toBe(mono);
});
});
8 changes: 5 additions & 3 deletions packages/ui/src/lib/styles/prose.css
Original file line number Diff line number Diff line change
Expand Up @@ -232,16 +232,18 @@
border: var(--ca-border-width) solid var(--ca-outline-subtle);
border-radius: var(--ca-radius-lg);
background: var(--ca-surface-raised) !important;
font: var(--ca-font-size-body2) / var(--ca-line-height-prose) var(--ca-font-mono);
tab-size: 2;
}

.ca-code pre {
margin: 0;
padding: 1rem 1.25rem;
overflow-x: auto;
background: transparent !important;
font: 0.8125rem / 1.7 var(--ca-font-mono);
}

.ca-prose > pre,
.ca-code pre {
font: 0.8125rem / var(--ca-code-line-height) var(--ca-font-mono);
tab-size: 2;
}

Expand Down
6 changes: 4 additions & 2 deletions packages/ui/src/lib/styles/tokens.css
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
--ca-font-mono: ui-monospace, "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace;
--ca-font-display: var(--ca-font-sans);
--ca-font-scale: 1;
/* Code surfaces (CodeSnippet, .ca-code, prose pre, and any editor you build): an INTEGER px line
height. A fractional one (1.7 * 13px = 22.1px) rounds differently in a text layer and in the
overlay that paints the caret, active line or gutter, so they drift apart line by line. */
--ca-code-line-height: 20px;

/* Fluid display tier for marketing/editorial headlines (viewport-scaled, rem-bounded). */
--ca-font-size-display-1: clamp(2.5rem, 1.6rem + 4.5vw, 5.5rem);
Expand Down Expand Up @@ -294,7 +298,6 @@
--ca-action-selected: oklch(0.2 0 0 / 0.09);
--ca-action-disabled: oklch(0.2 0 0 / 0.30);
--ca-action-disabled-bg: oklch(0.2 0 0 / 0.08);
--ca-action-focus: oklch(0.2 0 0 / 0.12);

/* ── Glass optical constants (light) ─────────────────────────────────── */
--ca-glass-rim: oklch(1 0 0 / 0.70);
Expand Down Expand Up @@ -335,7 +338,6 @@
--ca-action-selected: oklch(1 0 0 / 0.13);
--ca-action-disabled: oklch(1 0 0 / 0.30);
--ca-action-disabled-bg: oklch(1 0 0 / 0.10);
--ca-action-focus: oklch(1 0 0 / 0.14);

/* Glass optical constants */
--ca-glass-rim: oklch(1 0 0 / 0.22);
Expand Down
23 changes: 23 additions & 0 deletions site/src/content/docs/display/code-snippet.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,29 @@ The theme switches between a light and a dark high-contrast Shiki theme when the

<Demo title="Languages" description={"Svelte, TypeScript and CSS."} src={LanguagesSrc}><Languages client:visible /></Demo>

## Code surfaces: integer line-height, one font stack

CodeSnippet is read-only: it ships no editor and no active-line highlight. If you build one (a textarea over a highlighted `pre`, an active-line band, a line-number gutter, a caret you paint yourself), every layer of it has to agree on two things, or the caret and the highlighted line drift apart a little more on every line:

1. **An integer px line height.** `line-height: 1.7` on 13px text is 22.1px. The text layer and the overlay each round that value on their own, so the highlight slips by a fraction of a pixel per line and is a whole line off by line 50. Use `var(--ca-code-line-height)` (20px) on the text layer, on the overlay and on the gutter, and size the highlight band to the same token.
2. **One font stack.** Set `font-family: var(--ca-font-mono)` on the `pre`, the `code` inside it, the textarea and the gutter. The UA default for `<code>` is a bare `monospace`, which the browser resolves separately from the stack on its parent and can resolve to a font with different metrics.

```css
.editor-text,
.editor-overlay,
.editor-gutter {
font: var(--ca-font-size-body2) / var(--ca-code-line-height) var(--ca-font-mono);
tab-size: 2;
letter-spacing: 0;
}
.editor-active-line {
block-size: var(--ca-code-line-height);
inset-block-start: calc(var(--line) * var(--ca-code-line-height));
}
```

`CodeSnippet`, `.ca-code pre` and a bare Markdown fence inside `.ca-prose` already use both. Override the token on `:root` (or on one editor) to change the density, and keep it an integer px value: `--ca-code-line-height: 24px`.

<Props rows={[
{ name: "code", type: "string", description: "Source code to display. Required." },
{ name: "language", type: "string", default: "'svelte'", description: "Shiki language identifier." },
Expand Down
2 changes: 2 additions & 0 deletions site/src/content/docs/getting-started/workarounds.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ Imports are from `@codeatlasdev/ui` unless the last column names a subpath.
| `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) |
||||||| parent of 17966ab8 (feat: code surfaces get an integer px line-height token (#551))
| `line-height: 1.7` (or any unitless value) on a code font, or a highlight layer whose line height does not equal the text layer's, so a caret and an active-line band drift apart | `line-height: var(--ca-code-line-height)` (integer px) and `font-family: var(--ca-font-mono)` on every layer of the code surface | [CodeSnippet](/docs/display/code-snippet/#code-surfaces-integer-line-height-one-font-stack) |
| 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