Skip to content
Open
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
8 changes: 8 additions & 0 deletions .agents/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,14 @@ The choices that the code does not explain, with the options that were rejected.
- **A callout is a sibling of its line**, so line tints and blur do not cover it. It takes the highlight of the lines around it when both sides match. Its width uses `cqi` of the `pre`, except in a side annotation grid, where containment would collapse the code column.
- **A callout's text is selectable only in a selection that starts in its bubble.** A `pointerdown` in a bubble turns on `user-select: text` for that bubble until the next `pointerdown`, so a drag across the code still leaves every bubble out, and what readers see selected is what they copy. *Rejected:* `user-select: contain` (no browser support); a selectable bubble with a `copy` handler that removes callout text (the paste would differ from the selection, as for hidden lines).

### Squiggles

- **Squiggles mimic VS Code diagnostics**: `[!error /text/]`, `[!warning /text/]` and `[!info /text/]` (with `warn` and `note`, as in line states), placed like callouts. The bare names keep them apart from line states, which use `[!code error]`. *Rejected:* `[!squiggle error]` (longer for no gain); VS Code's hint severity (three faint dots, below 3:1 at any size) and its unnecessary and deprecated tags (fade and strikethrough are not squiggles); a separate syntax for the source and code, such as `ts(2552)` (authors put it in the message).
- **The wave is VS Code's own 6 × 3 px SVG, as a CSS mask over a background colour**, so the colour comes from a theme variable. *Rejected:* `text-decoration: wavy` (a different, rounder shape in each browser); an SVG background (a data URI cannot read CSS variables).
- **The squiggle annotation renders in the `latest` phase**, so EC merges the tokens of the range into one element and the wave has no breaks. A later annotation that overlaps only part of the range still splits it.
- **Every squiggle has a hover, also with no message**: the icon and the name of the severity tell error from warning without colour (WCAG 1.4.1). The message is a `popover="manual"` inside the squiggle, so hover over the message keeps it open. It opens after 300 ms, as in VS Code, at once on keyboard focus, and Escape closes it (WCAG 1.4.13). Screen readers read the severity and the message from `scb-sr-only` text after the code.
- **The script moves a message back inside the window**: when the block scrolls the text past the window edge, the text's position area reaches past it too, so `position-try` sees no overflow.

### Hidden lines, expandable blocks

- **Expandable blocks collapse only in the client**, with `hidden="until-found"`, so a block is complete without JavaScript and find in page reveals lines. Collapsed lines use `content-visibility: hidden` because EC's `all: revert` removes the browser's until-found style. `auto` skips code tabs variants, runnable blocks and collapsible sections; the components remove an `auto` cap.
Expand Down
Binary file added .github/assets/readme/squiggles.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Agent instructions

`starlight-codeblocks` is a Starlight plugin that adds 26 code block features on top of Expressive Code (EC), which renders every code block in Starlight. `packages/starlight-codeblocks/` is the plugin, `docs/` its Starlight docs site (it uses the plugin through `workspace:*`, and defines each feature's syntax and behaviour), `examples/astro/` a minimal Astro site without Starlight, and `skills/starlight-codeblocks/` an agent skill for people who use the plugin.
`starlight-codeblocks` is a Starlight plugin that adds 27 code block features on top of Expressive Code (EC), which renders every code block in Starlight. `packages/starlight-codeblocks/` is the plugin, `docs/` its Starlight docs site (it uses the plugin through `workspace:*`, and defines each feature's syntax and behaviour), `examples/astro/` a minimal Astro site without Starlight, and `skills/starlight-codeblocks/` an agent skill for people who use the plugin.

- `.agents/DECISIONS.md`: design decisions and the rejected options. Search it before you change existing behaviour.
- `.agents/WRITING-STYLE.md`: how the docs read, the feature page template and the glossary.
Expand Down
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<img src="https://raw.githubusercontent.com/ewels/starlight-codeblocks/main/.github/assets/logotype.svg" width="800" alt="starlight-codeblocks">
</h1>

A Starlight and Astro plugin that adds 26 features to code blocks, such as focus, line states, annotations, links to API docs and runnable examples. It builds on Expressive Code, so the code blocks you already have keep working.
A Starlight and Astro plugin that adds 27 features to code blocks, such as focus, line states, annotations, links to API docs and runnable examples. It builds on Expressive Code, so the code blocks you already have keep working.

Using an agent? [Point it at the bundled skill](https://ewels.github.io/starlight-codeblocks/agent-skill/).

Expand Down Expand Up @@ -189,6 +189,22 @@ for name in sys.argv[1:] # [!code error] SyntaxError: expected ':'

</details>

<details>
<summary>Squiggles</summary>

Draw a wavy error, warning or info line under code, as a code editor does, with a message that shows on hover.

```ts
// [!error /readFle/] Cannot find name `readFle`. Did you mean `readFile`?
const config = JSON.parse(await readFle('config.json', 'utf8'));
```

<img src="https://raw.githubusercontent.com/ewels/starlight-codeblocks/main/.github/assets/readme/squiggles.png" alt="A TypeScript block with a yellow wavy line under the name join and a red wavy line under readFle.">

[Squiggles documentation](https://ewels.github.io/starlight-codeblocks/features/squiggles/)

</details>

<details>
<summary>Code mentions</summary>

Expand Down
2 changes: 1 addition & 1 deletion docs/e2e/api-links.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ test('hovering shows the card under the link, the pointer can move onto it, and
await expect(card(page).locator('.scb-api-card-head')).toHaveText('function json.loads');
// The summary comes from the live docs.python.org page, so its words can change.
await expect(card(page).locator('.scb-api-card-summary')).not.toBeEmpty();
await expect(card(page).locator('.scb-api-card-source')).toHaveText('Python 3.14 documentation');
await expect(card(page).locator('.scb-api-card-source')).toHaveText(/^Python 3\.\d+ documentation$/);
const l = await link.boundingBox();
const c = await card(page).boundingBox();
expect(c && l && c.y).toBeGreaterThanOrEqual((l?.y ?? 0) + (l?.height ?? 0));
Expand Down
17 changes: 8 additions & 9 deletions docs/e2e/carousel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ test('a button or a dot shows its example, announces it and marks itself, and th
await expect(page.locator('.carousel .slide:not([data-current])').first()).toBeHidden();
await expect(page.locator('.carousel .tile[aria-pressed="true"]')).toHaveCount(1);
await expect(tile(page, 'Word-level diff')).toHaveAttribute('aria-pressed', 'true');
await expect(page.locator('.carousel [data-status]')).toHaveText('Word-level diff, 17 of 25');
await expect(page.locator('.carousel [data-status]')).toHaveText('Word-level diff, 18 of 26');
// The current button differs by more than colour.
expect(await css(tile(page, 'Word-level diff'), 'fontWeight')).toBe('600');
expect(await css(tile(page, 'Footnotes'), 'fontWeight')).toBe('400');
Expand All @@ -159,11 +159,11 @@ test('a button or a dot shows its example, announces it and marks itself, and th
await expect(dot(page, 'Focus')).toHaveAttribute('aria-current', 'true');
await expect(dot(page, 'Word-level diff')).toHaveAttribute('aria-current', 'false');
await expect(tile(page, 'Focus')).toHaveAttribute('aria-pressed', 'true');
await expect(page.locator('.carousel [data-status]')).toHaveText(/^Focus, \d+ of 25$/);
await expect(page.locator('.carousel [data-status]')).toHaveText(/^Focus, \d+ of 26$/);
await expect(page.locator('.carousel .rotation')).toHaveAccessibleName('Play');
});

test('the buttons are a flat list in sidebar order, in even columns, with one dot each', async ({ page }) => {
test('the buttons are a flat list in sidebar order, in columns, with one dot each', async ({ page }) => {
await page.goto('./install/starlight/');
const sidebarIds = await page
.locator('nav[aria-label="Main"] a[href*="/features/"]')
Expand All @@ -173,8 +173,8 @@ test('the buttons are a flat list in sidebar order, in even columns, with one do
await page.goto('./');
await expect(page.locator('.carousel .group-label')).toHaveCount(0);
const tiles = page.locator('.carousel .tile');
await expect(tiles).toHaveCount(25);
await expect(page.locator('.carousel .tile svg[aria-hidden], .carousel .tile [aria-hidden] svg')).toHaveCount(25);
await expect(tiles).toHaveCount(26);
await expect(page.locator('.carousel .tile svg[aria-hidden], .carousel .tile [aria-hidden] svg')).toHaveCount(26);
const tileIds = await tiles.evaluateAll((els) => els.map((el) => el.getAttribute('data-feature')));
expect(tileIds).toEqual(sidebarIds.filter((id) => tileIds.includes(id)));

Expand All @@ -183,14 +183,13 @@ test('the buttons are a flat list in sidebar order, in even columns, with one do
);
const columns = [...new Set(boxes.map(([x]) => x))].map((x) => boxes.filter(([bx]) => bx === x).map(([, y]) => y));
const lengths = columns.map((c) => c.length);
expect(Math.max(...lengths) - Math.min(...lengths)).toBeLessThanOrEqual(1);
// Each column holds the next run of tiles in order, so the x of the tiles never goes back.
expect(boxes.every(([x], i) => i === 0 || x >= (boxes[i - 1]?.[0] ?? 0))).toBe(true);
for (const column of columns) expect(column).toEqual((columns[0] ?? []).slice(0, column.length));
// The phone viewport (360px) is too narrow for the auto column width, so it's forced to two.
if (phone()) expect(lengths).toEqual([13, 12]);
if (phone()) expect(lengths).toEqual([13, 13]);

await expect(page.locator('.carousel .dot')).toHaveCount(25);
await expect(page.locator('.carousel .dot')).toHaveCount(26);
const order = await page.locator('.carousel .controls > *').evaluateAll((els) => els.map((el) => el.className));
expect(order.at(-1)).toContain('rotation');
});
Expand Down Expand Up @@ -315,7 +314,7 @@ test.describe('without JavaScript', () => {
'href',
'/starlight-codeblocks/features/focus/',
);
await expect(page.locator('.carousel a.tile')).toHaveCount(25);
await expect(page.locator('.carousel a.tile')).toHaveCount(26);
const docsLink = page.getByRole('link', { name: 'Read docs : Annotations' });
await expect(docsLink).toBeVisible();
await expect(docsLink).toHaveAttribute('href', '/starlight-codeblocks/features/annotations/');
Expand Down
86 changes: 86 additions & 0 deletions docs/e2e/squiggles.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { expect, test } from '@playwright/test';
import { copyFromKeyboard, css, example, phone, reduced } from './helpers.ts';

test.beforeEach(async ({ page }) => {
await page.goto('./features/squiggles/');
});

test('draws the wave under the text in the severity colour, and the copy button leaves out the messages', async ({
page,
}) => {
const block = example(page);
const error = block.locator('.scb-squiggle-error');
await expect(error).toHaveCount(1);
const wave = await error.evaluate((el) => {
const after = getComputedStyle(el, '::after');
return { height: after.height, mask: after.maskImage, background: after.backgroundColor };
});
expect(wave.height).toBe('3px');
expect(wave.mask).toContain('svg');
expect(wave.background).not.toBe('rgba(0, 0, 0, 0)');
expect(await copyFromKeyboard(block)).not.toContain('Cannot find');
});

test('keyboard focus opens the message above the text at once, and Escape closes it', async ({ page }) => {
const block = example(page);
const squiggle = block.locator('.scb-squiggle-error');
const message = squiggle.locator('.scb-squiggle-popover');
await expect(message).toBeHidden();
await block.locator('.scb-squiggle-warning').focus();
await page.keyboard.press('Tab');
await expect(squiggle).toBeFocused();
await expect(message).toBeVisible();
await expect(message).toHaveText(/^ErrorCannot find name readFle\./);
const [text, box] = await Promise.all([squiggle.boundingBox(), message.boundingBox()]);
expect((box?.y ?? 0) + (box?.height ?? 0)).toBeLessThanOrEqual(text?.y ?? 0);
// From the start of the text, as in VS Code. On a phone, the window edge can move it.
if (!phone()) expect(Math.abs((box?.x ?? 0) - (text?.x ?? 0))).toBeLessThan(2);
expect(box?.x ?? -1).toBeGreaterThanOrEqual(12);
expect((box?.x ?? 0) + (box?.width ?? 0)).toBeLessThanOrEqual((page.viewportSize()?.width ?? 0) - 12);
expect(await css(squiggle, 'outlineStyle')).toBe('solid');
await page.keyboard.press('Escape');
await expect(message).toBeHidden();
await expect(squiggle).toBeFocused();
await page.keyboard.press('Shift+Tab');
await expect(block.locator('.scb-squiggle-warning .scb-squiggle-popover')).toBeVisible();
await expect(message).toBeHidden();
});

test.describe('hover', () => {
test.skip(({ isMobile }) => isMobile, 'Phones have no hover.');

test('hovering shows the message after a delay, and it stays while the pointer moves into it', async ({ page }) => {
const block = example(page);
const squiggle = block.locator('.scb-squiggle-warning');
const message = squiggle.locator('.scb-squiggle-popover');
await squiggle.hover();
await page.waitForTimeout(100);
await expect(message).toBeHidden();
await expect(message).toBeVisible();
if (reduced()) expect(await css(message, 'transitionDuration')).toBe('0s');
await message.hover();
await page.waitForTimeout(400);
await expect(message).toBeVisible();
await page.mouse.move(5, 5);
await expect(message).toBeHidden();
});

test('Escape closes a hovered message until the pointer leaves', async ({ page }) => {
const squiggle = example(page).locator('.scb-squiggle-error');
const message = squiggle.locator('.scb-squiggle-popover');
await squiggle.hover();
await expect(message).toBeVisible();
await page.keyboard.press('Escape');
await expect(message).toBeHidden();
await page.mouse.move(5, 5);
await squiggle.hover();
await expect(message).toBeVisible();
});
});

test('a squiggle without a message names its severity', async ({ page }) => {
const squiggle = example(page, 3).locator('.scb-squiggle-warning');
await squiggle.focus();
await expect(squiggle.locator('.scb-squiggle-popover')).toHaveText('Warning');
await expect(squiggle.locator('.scb-sr-only')).toHaveText(' (Warning)');
});
5 changes: 3 additions & 2 deletions docs/src/components/FeatureCarousel.astro
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const icons: Record<string, string> = {
'features/scrollycoding': lucide.ScrollText,
'features/focus': lucide.Focus,
'features/line-states': lucide.TriangleAlert,
'features/squiggles': lucide.Waves,
'features/code-mentions': lucide.AtSign,
'features/word-level-diff': lucide.Diff,
'features/code-walkthrough': lucide.Replace,
Expand Down Expand Up @@ -397,8 +398,8 @@ if (order.some((at, i) => i > 0 && at < (order[i - 1] ?? 0)))
height: 1rem;
}
/* Columns, so that the tiles read down each column in sidebar order, as the tab order goes.
"12rem" gives five even columns of five on a desktop screen. Below 44rem, it's forced to two
(still column-major, an even 13/12 split) up to where the auto column width would give three. */
"12rem" gives five columns on a desktop screen. Below 44rem, it's forced to two
(still column-major) up to where the auto column width would give three. */
.picker {
columns: 12rem;
column-gap: 0.5rem;
Expand Down
Loading
Loading