Compare commits

..
1 Commits
Author SHA1 Message Date
Claude 3178d65b35 feat(a11y): RGAA 4.1 audit report, axe e2e gate and first fixes
Adds docs/ACCESSIBILITY.md (findings F1-F10 mapped to RGAA 4.1 and WCAG
2.1, what passed, what tooling cannot test) and tests/e2e/accessibility.spec.ts,
which runs axe-core WCAG 2.x A/AA scans over every screen in both themes
plus scripted checks for titles, landmarks, skip link, focus visibility
and keyboard reach. The spec is red until the remaining fixes land.

Fixes that need no design sign-off:
- F1: the tooltip action now exposes its text as aria-label on icon-only
  controls; removing `title` had left ~570 buttons unnamed.
- F5/F6/F7: checklist checkboxes and inputs, the editor textbox and the
  users-page role select get accessible names.
- F8: scrollable code blocks are keyboard reachable (tabindex=0), except
  in card previews which clip instead of scroll.
- F9: each settings tab has its own page title.
- F10: skip link to main content.

Still open, pending sign-off: F2 colour tokens, F3 card structure, F4
focus-visible styling.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014u5WimmZvshm9RJSd1c6wx
2026-09-18 06:08:16 +00:00
35 changed files with 499 additions and 807 deletions
-16
View File
@@ -125,21 +125,5 @@ jobs:
push: false
provenance: false
tags: crumbs:ci
load: true
cache-from: type=gha,scope=ci-docker
cache-to: type=gha,scope=ci-docker,mode=max
# Regression for #93: a host folder bind-mounted at /data is owned by the
# host user (the runner, uid 1001), not the container's node user (1000)
- name: Start with a host-owned bind mount
run: |
mkdir -p "$RUNNER_TEMP/crumbs-data"
docker run -d --name crumbs -p 3000:3000 -v "$RUNNER_TEMP/crumbs-data:/data" crumbs:ci
for i in $(seq 1 30); do
if curl -fsS -o /dev/null http://localhost:3000/login; then
test -f "$RUNNER_TEMP/crumbs-data/crumbs.db" && exit 0
fi
sleep 1
done
docker logs crumbs
exit 1
-2
View File
@@ -39,8 +39,6 @@ All colors are CSS variables in `src/app.css`. Never use hardcoded Tailwind colo
| `--card-shadow-hover` | `3px 3px 0px` | Hover shadow in primary gold |
| `--accent-check` | `var(--primary)` (light) / `#c8a44a` (dark) | Checkbox accent color — dimmed gold in dark mode so a long done-list doesn't shout |
| `--hover-wash` | `var(--border)` (light) / `#ece3d3` (dark) | Tint source for hover/active washes (`bg-[var(--hover-wash)]/N`) — kept cream in dark mode since `--border`'s mid-grey is invisible at low opacity |
| `--code-bg` | `#dfd5c1` (light) / `#141312` (dark) | Code block and inline code surface — one step below the page so it stands out on every note colour, including the default card which is `--bg-surface` itself |
| `--hl-keyword` … `--hl-tag` | six token colours per theme | Syntax highlighting roles (keyword, string, number, comment, title, tag). Must keep AA contrast against `--code-bg`; asserted in `src/lib/utils/highlight.test.ts` |
Note colors for cards are defined in `src/lib/utils/colors.ts`.
+2 -4
View File
@@ -37,10 +37,8 @@ COPY --from=build /app/package.json ./
# Create data directory
RUN mkdir -p /data && chown -R node:node /data
# Runs as root only long enough to hand the data folder (often a host bind
# mount) to the node user, then drops privileges — see crumbs-entrypoint.sh
COPY crumbs-entrypoint.sh /usr/local/bin/crumbs-entrypoint.sh
ENTRYPOINT ["crumbs-entrypoint.sh"]
# Use non-root user
USER node
# Configuration
ENV NODE_ENV=production
+1 -3
View File
@@ -7,9 +7,7 @@
<img src="static/favicon-96x96.png" alt="Crumbs logo" width="96" height="96">
</p>
# Crumbs by Bretzel (fork)
Original: https://github.com/bretzel-app/crumbs
# Crumbs by Bretzel
A self-hostable, offline-first note-taking app that respects your privacy. Part of the [Bretzel](https://bretzel.app) app universe.
-20
View File
@@ -1,20 +0,0 @@
#!/bin/sh
# Starts as root so a bind-mounted data folder owned by the host user can be
# handed to the unprivileged `node` user, then drops privileges before running
# the app. Without this, a host folder mounted at /data is not writable by
# uid 1000 and SQLite fails with SQLITE_CANTOPEN.
set -e
if [ "$(id -u)" = "0" ]; then
for dir in "${DATA_DIR:-/data}" "$(dirname "${DATABASE_URL:-/data/crumbs.db}")"; do
mkdir -p "$dir"
# Only touch entries not already owned by node, so restarts on a large
# attachment folder stay fast.
find "$dir" \! -user node -exec chown node:node {} + ||
echo "warning: could not change ownership of $dir; it must be writable by uid 1000" >&2
done
exec setpriv --reuid=node --regid=node --init-groups "$@"
fi
# Started with an explicit non-root user (e.g. `user:` in compose): run as-is.
exec "$@"
+1 -1
View File
@@ -1,6 +1,6 @@
services:
crumbs:
image: ghcr.io/bretzel-app/crumbs:0.35.2
image: ghcr.io/bretzel-app/crumbs:0.34.2
container_name: crumbs
ports:
- "3000:3000"
+144
View File
@@ -0,0 +1,144 @@
# Accessibility audit — RGAA 4.1
**Date:** 2026-09-17 · **Build audited:** `main` at `7bf06ed` (before #91) · **Status:** findings open, fixes in progress on `feat/a11y-audit`
RGAA 4.1 (Référentiel général d'amélioration de l'accessibilité) is the French application of WCAG 2.1 level AA: 106 criteria in 13 topics. This document records what was tested, what failed, what passed, and what has not been tested. It is **not** a compliance declaration: roughly a third of RGAA can be verified by tooling and scripted checks, and the remainder needs a manual pass with assistive technology (see [Not tested](#not-tested)).
## Method
| Layer | Tool | What it covers |
|---|---|---|
| Automated rules | axe-core 4.13 via `@axe-core/playwright`, rule tags `wcag2a`, `wcag2aa`, `wcag21a`, `wcag21aa` | Contrast, names and roles, form labels, language, nesting, scroll regions, ARIA validity |
| Scripted checks | `tests/e2e/accessibility.spec.ts` | Page titles, landmarks, skip link, keyboard reach, visible focus, Escape handling, card action naming |
| Manual reading | Source review of the components the scans pointed at | Root causes and fix proposals |
Screens scanned, each in **light and dark** theme: notes grid seeded with all 12 note colours plus checklist, tagged and code notes; open editor; editor with colour picker; share dialog; editor on a checklist note; archive; trash; tag view; search results; settings (profile, preferences, MCP, users, about); login; public share page.
The spec doubles as the CI gate: every scan asserts zero violations and writes its raw axe output to `test-results/a11y/<screen>-<theme>.json`. Run it alone with:
```
pnpm playwright test accessibility.spec.ts
```
## Findings
Severity: **Blocking** = prevents a group of users from completing a core task · **Major** = criterion fails on most screens · **Minor** = localised failure.
Counts are raw axe node counts across all scans; the grid repeats every card, so one root cause shows up as hundreds of nodes.
### F1 · Icon buttons have no accessible name — Blocking
- **RGAA 11.9** (button label relevance), **RGAA 7.1** (script compatibility with assistive tech) · WCAG 4.1.2
- **Where:** card actions (pin, archive, trash, restore, delete) on every card; editor footer (colour picker, image, share, archive, overflow menu); new-checklist button. 574 nodes on 7 screens, both themes.
- **Evidence:** axe `button-name`: "Element does not have inner text that is visible to screen readers; aria-label attribute does not exist or is empty".
- **Root cause:** `src/lib/utils/tooltip.ts` was introduced as a drop-in replacement for the `title` attribute. It renders a visual tooltip but sets no `aria-label`, so every icon-only button that adopted it lost its name for screen readers.
- **Fix:** the tooltip action sets `aria-label` from its text when the element has no accessible name of its own. One change repairs every instance.
### F2 · Insufficient text contrast — Blocking (light theme), Minor (dark theme)
- **RGAA 3.2** (text contrast), **RGAA 3.3** (UI component contrast) · WCAG 1.4.3, 1.4.11
- **Where and how far off** (AA requires 4.5:1 for body text, 3:1 for large text and component boundaries):
| Element | Colours | Ratio | Screens |
|---|---|---|---|
| Brand title "Crumbs" (18px) | `#C8860A` on `#faf5eb` | 2.81 | all app screens, login |
| Active sidebar item | `#C8860A` on gold wash `#f3e4c9` | 2.43 | all app screens |
| Active settings tab | `#C8860A` on `#ead8b5` | 2.18 | all settings tabs |
| Links inside note previews | `#C8860A` on each note colour | 1.46 (peach) to 2.81 (default) | notes, archive, trash, search, editor |
| Note preview body text | muted `#6b6272` on coral / peach / storm / dusk / sage / fog | 3.25 / 2.77 / 3.44 / 3.38 / 3.93 / 4.45 | same |
| Primary buttons (login, save, invite, tag filter) | white on `#C8860A` | 3.05 | login, settings, tag |
| Primary buttons, dark theme | white on `#e0a030` | 2.27 | login, settings, tag |
- **What passes:** in dark mode every note colour passes for both `--text` (worst 9.3:1 on mint) and `--text-muted` (worst 4.9:1 on mint). The code-block palette added in #91 passes by construction (unit-tested). The only dark-theme failure is white text on gold buttons.
- **Root cause:** the brand gold `--primary` is used for text, not only for accents, and it is too light on parchment. `--text-muted` was tuned for `--bg-surface` and never checked against the coloured cards.
- **Fix (needs design sign-off, see below):**
1. Add `--primary-text`, a darker gold for text and icons on light chrome (`#7d5306` is already in use as the code keyword colour; it reaches 6.2:1 on `--bg-surface`, 5.5:1 on `--bg-base`, 5.4:1 on the sidebar's active wash and 4.8:1 on the settings tab wash). Dark theme keeps `#e0a030`, which passes on every dark surface (worst 5.2:1 on mint). Brand title, sidebar and settings active states switch to it. `--primary` stays for borders, shadows, washes and the checkbox accent.
2. Links inside note content use `--text` with an underline instead of gold. No gold passes on the coral, peach, sage, storm or dusk cards (`#7d5306` bottoms out at 3.2:1 on peach), and the underline keeps links identifiable without relying on colour (RGAA 3.1).
3. Primary buttons use ink text (`--text`) on gold in both themes: 5.6:1 light, 7.5:1 dark.
4. Note preview body text uses `--text` instead of `--text-muted`. It is the note's content, not secondary information, and `--text` passes on every card colour (worst 8.2:1 on peach).
### F3 · Note cards nest interactive controls and expose two tab stops per card — Blocking
- **RGAA 7.1**, **RGAA 12.8** (tab order), **RGAA 7.3** (keyboard operability) · WCAG 4.1.2, 2.1.1, 2.4.3
- **Where:** every card on notes, archive, trash, tag and search screens. 182 axe `nested-interactive` nodes.
- **Evidence:** the card is `<article role="button" tabindex="0">` and contains the action `<button>`s, which is invalid: a button cannot contain buttons, and screen readers flatten the card's content into the button's name ("A11y coral Body text on the coral card…"). On the main grid each card is additionally wrapped by a `svelte-dnd-action` item that has its own `tabindex="0"` and `role="listitem"`, with `outline-none`. Tabbing stops on that wrapper first, with no visible focus, and pressing **Enter** there starts keyboard drag mode instead of opening the note. The scripted "Enter opens the note" scenario failed for this reason.
- **Fix (needs sign-off):** make the card a plain `<article>` and make its title a `<button>` that opens the note. Mouse users keep click-anywhere via the article's click handler; keyboard and screen-reader users get one clean button with the note title as its name, followed by the named action buttons. Set the drag zone's `zoneItemTabIndex: -1` so the wrapper is no longer a tab stop; keyboard reordering was unusable anyway without a visible focus and is not required by RGAA 4.1.
### F4 · Focus is not visible — Major
- **RGAA 10.7** · WCAG 2.4.7
- **Where:** cards and drag wrappers use `outline-none` with no replacement; there is no `:focus-visible` style anywhere in `src/app.css`. Buttons and inputs keep the browser default ring, which is inconsistent with the design and disappears where `outline-none` is set (inputs rely on a 1px border colour change to gold, which itself fails contrast).
- **Fix:** a global `:focus-visible` rule in the retro idiom (2px hard outline in `--primary-text`, 2px offset, no blur), and removal of `outline-none` where nothing replaces it.
### F5 · Checklist checkboxes have no label — Major
- **RGAA 11.1** (each field has a label) · WCAG 1.3.1, 4.1.2
- **Where:** checklist item checkboxes in the editor (`checklist-checkbox`, `checklist-done-checkbox`) and the read-only checkboxes in card previews. 28 nodes.
- **Fix:** associate each checkbox with its item text (`aria-labelledby` pointing at the text span, or wrap both in a `<label>`). Preview checkboxes are decorative duplicates of the text and can be `aria-hidden` instead.
### F6 · The note content editor has no name — Major
- **RGAA 11.1** · WCAG 4.1.2
- **Where:** the TipTap `contenteditable` (`role="textbox"`) and the checklist item `contenteditable` inputs. 6 nodes.
- **Fix:** `aria-label="Note content"` on the ProseMirror root via `editorProps.attributes`; checklist inputs labelled by their position or "Checklist item".
### F7 · Role select on the users page has no label — Minor
- **RGAA 11.1** · WCAG 1.3.1, 4.1.2
- **Where:** `/settings/users`, the role `<select>` in the create-user form. 2 nodes.
- **Fix:** a visible `<label for>`.
### F8 · Scrollable code blocks cannot be reached by keyboard — Minor
- **RGAA 7.3** · WCAG 2.1.1
- **Where:** `<pre>` elements with `overflow-x: auto` in the editor and share page (12 nodes). After #91, card previews clip instead of scroll and drop out of this finding.
- **Fix:** `tabindex="0"` on rendered and editor code blocks so the region can be scrolled with arrow keys.
### F9 · Settings tabs share one page title — Minor
- **RGAA 8.5, 8.6** (page title present and relevant) · WCAG 2.4.2
- **Where:** profile, preferences, MCP, users and about all render `Settings - Crumbs`. Other routes are distinct (`Archive - Crumbs`, `Trash - Crumbs`, `#tag - Crumbs`).
- **Fix:** per-tab titles (`Profile settings - Crumbs`, …).
### F10 · No skip link — Minor
- **RGAA 12.7** (skip link to main content) · WCAG 2.4.1
- **Where:** the first Tab stop on every app page is the sidebar toggle; header and sidebar (up to a dozen stops) precede the content on every page. Landmarks exist (`header`, `nav`, `main`, `footer`), which satisfies **RGAA 12.6**, but RGAA 12.7 additionally requires a skip link when blocks repeat across pages.
- **Fix:** a visually hidden "Skip to content" link as the first focusable element, revealed on focus, targeting `main`.
## Passed
Verified by the scans or scripted checks, on every screen unless noted:
- **8.3 / 8.4** document language declared and valid.
- **8.5** every page has a title; **8.6** titles are relevant outside settings (F9).
- **9.2 / 12.6** header, navigation, main and footer landmarks present.
- **1.1** no image without a text alternative on the audited screens (thumbnails carry the attachment filename).
- **6.1** no link without a name.
- **3.2** in dark mode for all note colours, body and muted text; code-block palette in both themes.
- **11.1** on login, profile, preferences and MCP forms apart from F6 and F7.
- axe reported no ARIA misuse (`aria-*` validity and allowed roles) and no duplicate ids.
## Not tested
These need a person with assistive technology or a manual protocol. They are listed so nobody reads the passes above as a compliance claim.
- **Screen reader behaviour** (NVDA + Firefox, VoiceOver + Safari): announcement of toasts and sync status (**7.x**, WCAG 4.1.3 status messages), reading order of the editor, dialog focus trapping and restoration in the share dialog and image lightbox (**7.3**, **12.9**).
- **Setup page** (`/setup`): redirects once an account exists, so the scans cannot reach it. It shares components with the login page, which was scanned.
- **Information by colour alone** (**3.1**): note colours, tag chips, collaborator indicators.
- **Reflow and zoom** at 320px width and 200% (**10.11**, **10.4**), text spacing override (**10.12**), content on hover/focus (**10.13**) for the custom tooltip.
- **Forms**: error identification and correction guidance (**11.10**, **11.11**), autocomplete purposes (**11.13**).
- **Time limits and motion** (**13.1**, **13.8**): auto-save, session expiry, drag animations.
- **Alternative to drag and drop** for reordering: not required by RGAA 4.1 (WCAG 2.2 criterion), noted for the future.
- **Heading hierarchy relevance** (**9.1**) beyond the structural check.
- Mobile layouts and the installed PWA.
## Fix plan
Order reflects impact per change:
1. F1 tooltip `aria-label` (one file).
2. F2 colour tokens, F4 focus style (design sign-off first).
3. F3 card structure and drag wrapper tab index (sign-off first).
4. F5, F6, F7 labels; F8 `tabindex`; F9 titles; F10 skip link.
5. Re-run `accessibility.spec.ts` until green, then it stays in CI as the gate.
-3
View File
@@ -82,9 +82,6 @@ For a single-user app with multiple devices:
- Built-in dark mode support (`dark:` variants)
- New v4 uses Vite plugin (faster builds)
### lowlight (highlight.js)
Code blocks are highlighted by one shared lowlight instance (`src/lib/utils/highlight.ts`) that feeds both the TipTap editor (`@tiptap/extension-code-block-lowlight`, live decorations) and the markdown-it renderer used for card previews and the share page, so a fence looks the same everywhere. Only the highlight.js "common" grammar set is bundled. Language auto-detection is deliberately disabled: it misreads note-sized snippets too often, so an unlabelled fence renders as plain text and the language comes from the fence info string or the toolbar picker. Token colours are the six `--hl-*` variables in `src/app.css`; a unit test asserts AA contrast against `--code-bg` in both themes.
### Argon2
- Memory-hard password hashing (resists GPU attacks)
- See [AUTH.md](AUTH.md) for authentication details
-4
View File
@@ -26,10 +26,6 @@ services:
restart: unless-stopped
```
The app runs as the image's `node` user (uid 1000). On startup the container hands ownership of a
bind-mounted data folder to uid 1000, so a folder created by any host user works as-is. If you set
`user:` in compose, that step is skipped and the folder must already be writable by that user.
### Reverse Proxy (nginx)
```nginx
+1 -1
View File
@@ -23,7 +23,7 @@
- Blockquotes (`> quote`)
- Unordered lists (`- item`)
- Ordered lists (`1. item`)
- Code blocks (triple backtick) with syntax highlighting for 30+ languages — set the language in the fence (```ts) or from the toolbar picker while the cursor is inside the block; unlabelled blocks stay plain rather than guessing
- Code blocks (triple backtick)
- Inline code (single backtick)
- Links — insert, edit, and remove with URL input
- Tables (insert, add/delete rows and columns)
+2 -3
View File
@@ -1,7 +1,7 @@
{
"name": "crumbs",
"private": true,
"version": "0.35.2",
"version": "0.34.2",
"type": "module",
"packageManager": "pnpm@9.15.9",
"license": "MIT",
@@ -37,7 +37,6 @@
"@modelcontextprotocol/sdk": "^1.30.0",
"@sveltejs/adapter-node": "^5.5.7",
"@tiptap/core": "^3.31.0",
"@tiptap/extension-code-block-lowlight": "^3.31.0",
"@tiptap/extension-link": "^3.31.0",
"@tiptap/extension-placeholder": "^3.31.0",
"@tiptap/extension-table": "^3.31.0",
@@ -55,7 +54,6 @@
"better-sqlite3": "^11.10.0",
"drizzle-orm": "^0.45.2",
"idb": "^8.0.3",
"lowlight": "^3.3.0",
"lucide-svelte": "^0.577.0",
"markdown-it": "^15.0.1",
"nanoid": "^5.1.16",
@@ -69,6 +67,7 @@
"zxcvbn": "^4.4.2"
},
"devDependencies": {
"@axe-core/playwright": "^4.13.0",
"@playwright/test": "^1.62.1",
"@sveltejs/kit": "^2.70.3",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
+19 -63
View File
@@ -27,9 +27,6 @@ importers:
'@tiptap/core':
specifier: ^3.31.0
version: 3.31.0(@tiptap/pm@3.31.0)
'@tiptap/extension-code-block-lowlight':
specifier: ^3.31.0
version: 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/extension-code-block@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0)(highlight.js@11.11.2)(lowlight@3.3.0)
'@tiptap/extension-link':
specifier: ^3.31.0
version: 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0)
@@ -81,9 +78,6 @@ importers:
idb:
specifier: ^8.0.3
version: 8.0.3
lowlight:
specifier: ^3.3.0
version: 3.3.0
lucide-svelte:
specifier: ^0.577.0
version: 0.577.0(svelte@5.57.0)
@@ -118,6 +112,9 @@ importers:
specifier: ^4.4.2
version: 4.4.2
devDependencies:
'@axe-core/playwright':
specifier: ^4.13.0
version: 4.13.0(playwright-core@1.62.1)
'@playwright/test':
specifier: ^1.62.1
version: 1.62.1
@@ -221,6 +218,11 @@ packages:
'@asamuzakjp/nwsapi@2.3.9':
resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==}
'@axe-core/playwright@4.13.0':
resolution: {integrity: sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg==}
peerDependencies:
playwright-core: '>= 1.0.0'
'@babel/code-frame@7.29.7':
resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
engines: {node: '>=6.9.0'}
@@ -1569,15 +1571,6 @@ packages:
peerDependencies:
'@tiptap/extension-list': 3.31.0
'@tiptap/extension-code-block-lowlight@3.31.0':
resolution: {integrity: sha512-fFwXqwh0CwJkQzdqgv4ObcS70m0Muq1RbAZUTQNZFqCDcu1krDJW0PWNmua7+nZdmXwOyA2PDUTAtWYFQ+srpw==}
peerDependencies:
'@tiptap/core': 3.31.0
'@tiptap/extension-code-block': 3.31.0
'@tiptap/pm': 3.31.0
highlight.js: ^11
lowlight: ^2 || ^3
'@tiptap/extension-code-block@3.31.0':
resolution: {integrity: sha512-y/A6N96gBPjEHqVYZ3u/yO/Anyo92iDWgYqShMXAvHprn2Ipmn32wIkX05Kv983fa6j6ySXF7A1nJdHcGCl5aQ==}
peerDependencies:
@@ -1746,9 +1739,6 @@ packages:
'@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
'@types/hast@3.0.5':
resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==}
'@types/linkify-it@3.0.5':
resolution: {integrity: sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw==}
@@ -1782,9 +1772,6 @@ packages:
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
'@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
'@types/zxcvbn@4.4.5':
resolution: {integrity: sha512-FZJgC5Bxuqg7Rhsm/bx6gAruHHhDQ55r+s0JhDh8CQ16fD7NsJJ+p8YMMQDhSQoIrSmjpqqYWA96oQVMNkjRyA==}
@@ -1939,6 +1926,10 @@ packages:
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
engines: {node: '>= 0.4'}
axe-core@4.13.0:
resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==}
engines: {node: '>=4'}
axobject-query@4.1.0:
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
engines: {node: '>= 0.4'}
@@ -2204,10 +2195,6 @@ packages:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'}
dequal@2.0.3:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
@@ -2215,9 +2202,6 @@ packages:
devalue@5.9.2:
resolution: {integrity: sha512-po4PAY5c53tw5XMocSnf8A/5OHhbbUftpr93aEN6BBoAdntUmK7vu7wOATqvt7cXO7m1Cl4gMVn6p7n6n4mj0w==}
devlop@1.1.0:
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
dom-serializer@2.0.0:
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
@@ -2692,10 +2676,6 @@ packages:
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
engines: {node: '>= 0.4'}
highlight.js@11.11.2:
resolution: {integrity: sha512-oaXMACAU0kzOMXBjWpNcX+vlwSBCIAiZ9BHa7gA15NOTtT2L/l8OSZDuqS2XppOhZBPJ7hm4o8ep2kyuip2uEQ==}
engines: {node: '>=12.0.0'}
hono@4.13.5:
resolution: {integrity: sha512-O6+/eCYRkzzzy0rPWwKLiGBR1nFuUPZynnwjxN1MBA62NNqbT0wQEzQyK2gSO5yDIDB336sXQleAhOHrzlYyKw==}
engines: {node: '>=16.9.0'}
@@ -3105,9 +3085,6 @@ packages:
loupe@3.2.1:
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
lowlight@3.3.0:
resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==}
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
@@ -4309,6 +4286,11 @@ snapshots:
'@asamuzakjp/nwsapi@2.3.9': {}
'@axe-core/playwright@4.13.0(playwright-core@1.62.1)':
dependencies:
axe-core: 4.13.0
playwright-core: 1.62.1
'@babel/code-frame@7.29.7':
dependencies:
'@babel/helper-validator-identifier': 7.29.7
@@ -5595,14 +5577,6 @@ snapshots:
dependencies:
'@tiptap/extension-list': 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0)
'@tiptap/extension-code-block-lowlight@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/extension-code-block@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0)(highlight.js@11.11.2)(lowlight@3.3.0)':
dependencies:
'@tiptap/core': 3.31.0(@tiptap/pm@3.31.0)
'@tiptap/extension-code-block': 3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0)
'@tiptap/pm': 3.31.0
highlight.js: 11.11.2
lowlight: 3.3.0
'@tiptap/extension-code-block@3.31.0(@tiptap/core@3.31.0(@tiptap/pm@3.31.0))(@tiptap/pm@3.31.0)':
dependencies:
'@tiptap/core': 3.31.0(@tiptap/pm@3.31.0)
@@ -5782,10 +5756,6 @@ snapshots:
'@types/estree@1.0.9': {}
'@types/hast@3.0.5':
dependencies:
'@types/unist': 3.0.3
'@types/linkify-it@3.0.5': {}
'@types/linkify-it@5.0.0': {}
@@ -5820,8 +5790,6 @@ snapshots:
'@types/trusted-types@2.0.7': {}
'@types/unist@3.0.3': {}
'@types/zxcvbn@4.4.5': {}
'@vite-pwa/sveltekit@1.1.0(@sveltejs/kit@2.70.3(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.57.0)(vite@7.3.6(@types/node@26.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)))(svelte@5.57.0)(typescript@5.9.3)(vite@7.3.6(@types/node@26.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)))(vite@7.3.6(@types/node@26.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0))(workbox-build@7.4.0)(workbox-window@7.4.1)':
@@ -5996,6 +5964,8 @@ snapshots:
dependencies:
possible-typed-array-names: 1.1.0
axe-core@4.13.0: {}
axobject-query@4.1.0: {}
babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.7):
@@ -6262,16 +6232,10 @@ snapshots:
depd@2.0.0: {}
dequal@2.0.3: {}
detect-libc@2.1.2: {}
devalue@5.9.2: {}
devlop@1.1.0:
dependencies:
dequal: 2.0.3
dom-serializer@2.0.0:
dependencies:
domelementtype: 2.3.0
@@ -6781,8 +6745,6 @@ snapshots:
dependencies:
function-bind: 1.1.2
highlight.js@11.11.2: {}
hono@4.13.5: {}
html-encoding-sniffer@6.0.0:
@@ -7186,12 +7148,6 @@ snapshots:
loupe@3.2.1: {}
lowlight@3.3.0:
dependencies:
'@types/hast': 3.0.5
devlop: 1.1.0
highlight.js: 11.11.2
lru-cache@10.4.3: {}
lru-cache@11.5.2: {}
+2 -116
View File
@@ -26,17 +26,6 @@
light styling is byte-identical; dark mode keeps the cream that --border
used to supply, because a mid-grey wash is invisible on a dark surface. */
--hover-wash: var(--border);
/* Code surface: an inset one step below the page so it reads as a panel on
every note colour (the default card is --bg-surface itself, so neither
surface nor base would stand out on it). Token colours must keep AA
contrast against it — see src/lib/utils/highlight.test.ts. */
--code-bg: #dfd5c1;
--hl-keyword: #7d5306;
--hl-string: #325d28;
--hl-number: #1f5c7a;
--hl-comment: #5d5462;
--hl-title: #6a2c7a;
--hl-tag: #8b2e1f;
}
[data-theme="dark"] {
@@ -60,15 +49,6 @@
/* Dimmed so a long done-list of gold checkboxes doesn't shout. */
--accent-check: #c8a44a;
--hover-wash: #ece3d3;
/* Dark code sinks to page level (the ink behind the cards) rather than
lifting, so a snippet on a dark card reads as a cut-out, not a slab. */
--code-bg: #141312;
--hl-keyword: #e0a030;
--hl-string: #8fc97a;
--hl-number: #7fb8e0;
--hl-comment: #a89b8c;
--hl-title: #cfa3e6;
--hl-tag: #e2705c;
}
html, body {
@@ -128,109 +108,15 @@ body::before {
--tw-prose-kbd-shadows: color-mix(in oklab, var(--text) 10%, transparent);
--tw-prose-code: var(--text);
--tw-prose-pre-code: var(--text);
--tw-prose-pre-bg: var(--code-bg);
--tw-prose-pre-bg: var(--bg-surface);
--tw-prose-th-borders: var(--border-subtle);
--tw-prose-td-borders: var(--border-subtle);
}
/* Code — the body font is already monospace, so the typography plugin's
"shrink code to 86%" default only makes snippets harder to read. Code keeps
the surrounding text size and gets a distinct surface instead. */
.prose :where(code):not(:where([class~="not-prose"] *)) {
background-color: var(--code-bg);
background-color: var(--bg-surface);
border-radius: 2px;
padding: 0.125rem 0.25rem;
font-size: inherit;
}
/* The chip is the signal now; the plugin's decorative backticks doubled it. */
.prose :where(code):not(:where([class~="not-prose"] *))::before,
.prose :where(code):not(:where([class~="not-prose"] *))::after {
content: none;
}
.prose :where(pre):not(:where([class~="not-prose"] *)) {
border: 1px solid var(--border-subtle);
border-radius: 2px;
font-size: inherit;
line-height: 1.6;
padding: 0.625rem 0.75rem;
tab-size: 4;
}
.prose :where(pre code):not(:where([class~="not-prose"] *)) {
background-color: transparent;
border-radius: 0;
padding: 0;
}
/* Card previews are a glance, not a workspace: clip long lines instead of
growing a scrollbar inside the card. The editor and share page keep the
typography default of scrolling horizontally. */
.card-preview :where(pre) {
overflow: hidden;
/* line-clamp is a legacy -webkit-box; without this a wide <pre> takes its
content width and runs out past the card edge instead of being clipped. */
max-width: 100%;
}
/* Syntax highlighting (highlight.js classes via lowlight). Six roles are
enough to tell structure apart without turning a snippet into confetti;
the colours live in --hl-* above so the dark theme can swap them. */
.hljs-keyword,
.hljs-selector-tag,
.hljs-built_in,
.hljs-type,
.hljs-literal {
color: var(--hl-keyword);
}
.hljs-string,
.hljs-regexp,
.hljs-quote,
.hljs-addition {
color: var(--hl-string);
}
.hljs-number,
.hljs-symbol,
.hljs-bullet,
.hljs-link,
.hljs-attr,
.hljs-attribute,
.hljs-variable,
.hljs-template-variable,
.hljs-selector-attr,
.hljs-selector-pseudo {
color: var(--hl-number);
}
.hljs-comment,
.hljs-meta,
.hljs-doctag {
color: var(--hl-comment);
font-style: italic;
}
.hljs-title,
.hljs-section,
.hljs-selector-id,
.hljs-selector-class {
color: var(--hl-title);
}
.hljs-tag,
.hljs-name,
.hljs-deletion {
color: var(--hl-tag);
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: 600;
}
.prose :where(a):not(:where([class~="not-prose"] *)) {
+4 -1
View File
@@ -313,6 +313,7 @@
type="checkbox"
checked={item.checked}
onchange={() => toggleItem(item.id)}
aria-label={item.text ? `Done: ${item.text}` : 'Done'}
class="h-4 w-4 rounded border-[var(--border-subtle)] text-[var(--primary)] focus:ring-[var(--primary)]"
data-testid="checklist-checkbox"
/>
@@ -335,6 +336,7 @@
class="flex-1 min-w-0 bg-transparent text-sm outline-none break-words {item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}"
data-placeholder="List item"
aria-placeholder="List item"
aria-label="Checklist item"
data-testid="checklist-input"
data-item-id={item.id}
role="textbox"
@@ -377,7 +379,7 @@
{#each doneSectionGroups as group}
{#if group.parentLabel}
<div class="flex items-center gap-2 py-1.5 pl-5" data-testid="checklist-done-parent-label">
<input type="checkbox" disabled class="h-4 w-4 rounded border-[var(--border-subtle)] opacity-50" />
<input type="checkbox" disabled tabindex="-1" aria-hidden="true" class="h-4 w-4 rounded border-[var(--border-subtle)] opacity-50" />
<span class="text-sm text-[var(--text-muted)] break-words min-w-0">{@html linkifyText(group.parentLabel.text)}</span>
</div>
{/if}
@@ -387,6 +389,7 @@
type="checkbox"
checked={item.checked}
onchange={() => toggleItem(item.id)}
aria-label={item.text ? `Done: ${item.text}` : 'Done'}
class="h-4 w-4 rounded border-[var(--border-subtle)] text-[var(--primary)] focus:ring-[var(--primary)]"
data-testid="checklist-done-checkbox"
/>
@@ -1,6 +1,5 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import { CODE_LANGUAGES } from '$lib/utils/highlight.js';
import {
Undo2,
Redo2,
@@ -48,18 +47,6 @@
function isActive(nameOrAttrs: any, attrs?: Record<string, any>): boolean { void tick; return (!editor?.isDestroyed && editor?.isActive(nameOrAttrs, attrs)) ?? false; }
function getAttrs(type: string) { void tick; return (!editor?.isDestroyed && editor?.getAttributes(type)) || {}; }
// The fence info string may carry an alias (```js) or a language the picker
// doesn't list; surface it as-is rather than silently showing "Plain text".
function codeBlockLanguage(): string {
const lang = getAttrs('codeBlock').language;
return typeof lang === 'string' ? lang : '';
}
function setCodeBlockLanguage(event: Event) {
const value = (event.currentTarget as HTMLSelectElement).value;
editor?.chain().focus().updateAttributes('codeBlock', { language: value || null }).run();
}
let openDropdown: string | null = $state(null);
let dropdownAnchorEl: HTMLElement | null = $state(null);
let linkUrl: string = $state('');
@@ -391,25 +378,6 @@
>
<CodeXml size={iconSize} />
</button>
{#if isActive('codeBlock')}
{@const currentLanguage = codeBlockLanguage()}
<select
value={currentLanguage}
onchange={setCodeBlockLanguage}
class="shrink-0 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] px-1.5 py-1 text-xs text-[var(--text)] outline-none focus:border-[var(--primary)]"
title="Code block language"
aria-label="Code block language"
data-testid="format-code-language"
>
<option value="">Plain text</option>
{#if currentLanguage && !CODE_LANGUAGES.some((l) => l.id === currentLanguage)}
<option value={currentLanguage}>{currentLanguage}</option>
{/if}
{#each CODE_LANGUAGES as lang (lang.id)}
<option value={lang.id}>{lang.label}</option>
{/each}
</select>
{/if}
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => handleCommandClick(() => editor?.chain().focus().setHorizontalRule().run())}
+4 -2
View File
@@ -37,7 +37,7 @@
let lightboxSrc = $state<string | null>(null);
let lightboxAlt = $state('');
const renderedContent = $derived(renderMarkdown(note.content));
const renderedContent = $derived(renderMarkdown(note.content, { focusableCode: false }));
const checklistItems = $derived<ChecklistItem[]>(
note.checklistMode
@@ -143,6 +143,8 @@
type="checkbox"
checked={item.checked}
disabled
tabindex="-1"
aria-hidden="true"
class="mt-0.5 h-3.5 w-3.5 shrink-0 rounded border-[var(--border-subtle)] text-[var(--primary)]"
data-testid="card-checklist-checkbox"
/>
@@ -154,7 +156,7 @@
{/if}
</ul>
{:else if note.content}
<div class="card-preview prose prose-sm line-clamp-6 max-w-none text-sm text-[var(--text-muted)]" data-testid="note-content-preview">
<div class="prose prose-sm line-clamp-6 max-w-none text-sm text-[var(--text-muted)]" data-testid="note-content-preview">
{@html renderedContent}
</div>
{/if}
+1 -1
View File
@@ -60,7 +60,7 @@
let showColorPicker = $state(false);
let showImageUpload = $state(false);
// svelte-ignore state_referenced_locally
let rawMarkdownMode = $state(prefs.defaultNoteMode === 'markdown');
let rawMarkdownMode = $state(note ? (note.checklistMode ?? false) : prefs.defaultNoteMode === 'markdown');
let textareaEl: HTMLTextAreaElement | undefined = $state();
let tiptapEditor: Editor | undefined = $state();
let editorTick = $state(0);
+3 -4
View File
@@ -13,9 +13,7 @@
import TaskList from '@tiptap/extension-task-list';
import TaskItem from '@tiptap/extension-task-item';
import { Markdown } from 'tiptap-markdown';
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
import { NoteLink } from './tiptap/NoteLink.js';
import { editorLowlight } from '$lib/utils/highlight.js';
import { getAllNotes } from '$lib/sync/idb.js';
interface Props {
@@ -94,8 +92,7 @@
editorInstance = new Editor({
element: element!,
extensions: [
StarterKit.configure({ link: false, underline: false, codeBlock: false }),
CodeBlockLowlight.configure({ lowlight: editorLowlight }),
StarterKit.configure({ link: false, underline: false }),
Link.configure({ openOnClick: false }),
Underline,
TextAlign.configure({ types: ['heading', 'paragraph'] }),
@@ -110,6 +107,8 @@
Markdown
],
content,
// The contenteditable is exposed as role="textbox"; give it a name (RGAA 11.1).
editorProps: { attributes: { 'aria-label': 'Note content' } },
onUpdate: ({ editor: e }) => {
if (destroyed) return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
-156
View File
@@ -1,156 +0,0 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { CODE_LANGUAGES, editorLowlight, highlightCode, isKnownLanguage, lowlight } from './highlight.js';
describe('highlightCode', () => {
it('wraps tokens in hljs-* spans for a known language', () => {
const html = highlightCode('const x = 1;', 'javascript');
expect(html).toContain('<span class="hljs-keyword">const</span>');
expect(html).toContain('<span class="hljs-number">1</span>');
});
it('accepts highlight.js aliases the way a fence info string carries them', () => {
expect(highlightCode('let a = 1', 'js')).toContain('hljs-keyword');
expect(highlightCode('print(1)', 'py')).toContain('hljs-built_in');
expect(highlightCode('<div></div>', 'html')).toContain('hljs-tag');
});
it('returns null for an absent or unknown language so the caller falls back to plain text', () => {
expect(highlightCode('SELECT 1', null)).toBeNull();
expect(highlightCode('SELECT 1', '')).toBeNull();
expect(highlightCode('SELECT 1', 'not-a-language')).toBeNull();
});
it('escapes HTML in the source so a snippet cannot inject markup', () => {
const html = highlightCode('const s = "<img src=x onerror=alert(1)>";', 'javascript');
expect(html).not.toContain('<img');
expect(html).toContain('&lt;img src=x onerror=alert(1)&gt;');
});
it('keeps highlight.js sub-scope classes alongside the hljs-* class', () => {
const html = highlightCode('function foo() {}', 'javascript');
expect(html).toContain('<span class="hljs-title function_">foo</span>');
});
it('round-trips the source text exactly once tags are stripped', () => {
const source = 'if (a < b && c > d) {\n\treturn "x&y";\n}';
const html = highlightCode(source, 'typescript')!;
const text = html
.replace(/<[^>]+>/g, '')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&amp;/g, '&');
expect(text).toBe(source);
});
});
describe('CODE_LANGUAGES', () => {
it('lists only languages the shared lowlight instance can actually highlight', () => {
for (const { id } of CODE_LANGUAGES) {
expect(isKnownLanguage(id), id).toBe(true);
}
});
it('has no duplicate ids', () => {
const ids = CODE_LANGUAGES.map((l) => l.id);
expect(new Set(ids).size).toBe(ids.length);
});
});
describe('editorLowlight', () => {
it('never auto-detects: an unlabelled block comes back as a single plain text node', () => {
const result = editorLowlight.highlightAuto('SELECT * FROM users;');
expect(result.children).toEqual([{ type: 'text', value: 'SELECT * FROM users;' }]);
});
it('highlights a labelled block with the same grammars as the renderer', () => {
const viaEditor = editorLowlight.highlight('python', 'def f(): pass');
const viaRenderer = lowlight.highlight('python', 'def f(): pass');
expect(viaEditor.children).toEqual(viaRenderer.children);
});
it('reports the languages the picker offers as registered', () => {
for (const { id } of CODE_LANGUAGES) {
expect(editorLowlight.registered(id), id).toBe(true);
}
expect(editorLowlight.listLanguages()).toContain('javascript');
});
});
// The palette lives in CSS, so the contrast guarantees are checked against the
// stylesheet itself rather than a TypeScript mirror that could drift.
describe('code palette in src/app.css', () => {
const css = readFileSync(resolve(__dirname, '../../app.css'), 'utf8');
const light = readVars(css.slice(css.indexOf(':root {'), css.indexOf('[data-theme="dark"] {')));
const dark = readVars(css.slice(css.indexOf('[data-theme="dark"] {')));
const tokenNames = ['--hl-keyword', '--hl-string', '--hl-number', '--hl-comment', '--hl-title', '--hl-tag'];
it('defines the code surface and every token colour in both themes', () => {
for (const name of ['--code-bg', ...tokenNames]) {
expect(light[name], `${name} light`).toMatch(/^#[0-9a-f]{6}$/i);
expect(dark[name], `${name} dark`).toMatch(/^#[0-9a-f]{6}$/i);
}
});
it('keeps AA contrast (4.5:1) between every token and the code surface', () => {
for (const [theme, vars] of [
['light', light],
['dark', dark]
] as const) {
for (const name of tokenNames) {
const ratio = contrastRatio(vars[name], vars['--code-bg']);
expect(ratio, `${theme} ${name} ${vars[name]} on ${vars['--code-bg']}`).toBeGreaterThanOrEqual(4.5);
}
}
});
it('keeps AA contrast between body text and the code surface', () => {
expect(contrastRatio(light['--text'], light['--code-bg'])).toBeGreaterThanOrEqual(4.5);
expect(contrastRatio(dark['--text'], dark['--code-bg'])).toBeGreaterThanOrEqual(4.5);
});
it('keeps token colours distinguishable from each other', () => {
for (const vars of [light, dark]) {
for (let i = 0; i < tokenNames.length; i++) {
for (let j = i + 1; j < tokenNames.length; j++) {
const distance = rgbDistance(vars[tokenNames[i]], vars[tokenNames[j]]);
expect(distance, `${tokenNames[i]} vs ${tokenNames[j]}`).toBeGreaterThanOrEqual(40);
}
}
}
});
});
function readVars(block: string): Record<string, string> {
const vars: Record<string, string> = {};
for (const match of block.matchAll(/(--[\w-]+):\s*(#[0-9a-fA-F]{6})\s*;/g)) {
vars[match[1]] = match[2].toLowerCase();
}
return vars;
}
function toRgb(hex: string): [number, number, number] {
const n = parseInt(hex.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function relativeLuminance(hex: string): number {
const channels = toRgb(hex).map((v) => {
const s = v / 255;
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
});
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
}
function contrastRatio(a: string, b: string): number {
const [l1, l2] = [relativeLuminance(a), relativeLuminance(b)];
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
function rgbDistance(a: string, b: string): number {
const [ra, ga, ba] = toRgb(a);
const [rb, gb, bb] = toRgb(b);
return Math.hypot(ra - rb, ga - gb, ba - bb);
}
-108
View File
@@ -1,108 +0,0 @@
import { common, createLowlight } from 'lowlight';
/**
* One lowlight instance shared by the TipTap editor (live decorations) and the
* markdown-it renderer (card previews, share page), so a fence is coloured
* identically wherever it shows up.
*
* There is deliberately no language auto-detection. highlight.js guesses
* wrongly on note-sized snippets often enough (JavaScript read as CSS, SQL as
* VB.NET) that a wrong palette is worse than none. An unlabelled fence renders
* as plain text on the code surface; the language comes from the fence info
* string (```ts) or the toolbar picker.
*/
export const lowlight = createLowlight(common);
type HighlightRoot = ReturnType<typeof lowlight.highlight>;
type HighlightNode = HighlightRoot['children'][number];
/** Languages offered by the editor's picker. Aliases (js, py, sh…) still highlight. */
export const CODE_LANGUAGES: readonly { id: string; label: string }[] = [
{ id: 'bash', label: 'Bash' },
{ id: 'c', label: 'C' },
{ id: 'cpp', label: 'C++' },
{ id: 'csharp', label: 'C#' },
{ id: 'css', label: 'CSS' },
{ id: 'diff', label: 'Diff' },
{ id: 'go', label: 'Go' },
{ id: 'graphql', label: 'GraphQL' },
{ id: 'xml', label: 'HTML / XML' },
{ id: 'ini', label: 'INI / TOML' },
{ id: 'java', label: 'Java' },
{ id: 'javascript', label: 'JavaScript' },
{ id: 'json', label: 'JSON' },
{ id: 'kotlin', label: 'Kotlin' },
{ id: 'less', label: 'Less' },
{ id: 'lua', label: 'Lua' },
{ id: 'makefile', label: 'Makefile' },
{ id: 'markdown', label: 'Markdown' },
{ id: 'objectivec', label: 'Objective-C' },
{ id: 'perl', label: 'Perl' },
{ id: 'php', label: 'PHP' },
{ id: 'python', label: 'Python' },
{ id: 'r', label: 'R' },
{ id: 'ruby', label: 'Ruby' },
{ id: 'rust', label: 'Rust' },
{ id: 'scss', label: 'SCSS' },
{ id: 'shell', label: 'Shell session' },
{ id: 'sql', label: 'SQL' },
{ id: 'swift', label: 'Swift' },
{ id: 'typescript', label: 'TypeScript' },
{ id: 'vbnet', label: 'VB.NET' },
{ id: 'wasm', label: 'WebAssembly' },
{ id: 'yaml', label: 'YAML' }
];
export function isKnownLanguage(lang: string | null | undefined): lang is string {
return typeof lang === 'string' && lang.length > 0 && lowlight.registered(lang);
}
/**
* Highlighted HTML (spans with `hljs-*` classes, text escaped) for a code
* block, or `null` when the language is absent or unknown so the caller can
* fall back to plain escaped text.
*/
export function highlightCode(code: string, lang: string | null | undefined): string | null {
if (!isKnownLanguage(lang)) return null;
return toHtml(lowlight.highlight(lang, code).children);
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// lowlight emits only `element` (span with a className list) and `text`
// nodes, so a full hast serializer would be dead weight here.
function toHtml(nodes: HighlightNode[]): string {
let html = '';
for (const node of nodes) {
if (node.type === 'text') {
html += escapeHtml(node.value);
} else if (node.type === 'element') {
const classes = node.properties?.className;
const classAttr = Array.isArray(classes) && classes.length ? ` class="${classes.join(' ')}"` : '';
html += `<span${classAttr}>${toHtml(node.children as HighlightNode[])}</span>`;
}
}
return html;
}
/**
* What `@tiptap/extension-code-block-lowlight` needs from a lowlight instance,
* with auto-detection stubbed out so an unlabelled block stays plain (see the
* note at the top of this file).
*/
export const editorLowlight = {
highlight: (language: string, value: string) => lowlight.highlight(language, value),
highlightAuto: (value: string): HighlightRoot => ({
type: 'root',
children: [{ type: 'text', value }],
data: { language: undefined, relevance: 0 }
}),
listLanguages: () => lowlight.listLanguages(),
registered: (aliasOrLanguage: string) => lowlight.registered(aliasOrLanguage)
};
-34
View File
@@ -35,40 +35,6 @@ describe('renderMarkdown', () => {
expect(html).toContain('const x = 1;');
});
it('should syntax-highlight a fenced block that names its language', () => {
const html = renderMarkdown('```ts\nconst x: number = 1;\n```');
expect(html).toContain('<code class="language-ts">');
expect(html).toContain('<span class="hljs-keyword">const</span>');
expect(html).toContain('<span class="hljs-number">1</span>');
});
it('should leave an unlabelled fence as plain text rather than guessing a language', () => {
const html = renderMarkdown('```\nSELECT * FROM users;\n```');
expect(html).toContain('<pre><code>SELECT * FROM users;');
expect(html).not.toContain('hljs-');
});
it('should leave a fence with an unknown language as plain text but keep its label', () => {
const html = renderMarkdown('```brainfuck\n+++\n```');
expect(html).toContain('<code class="language-brainfuck">+++');
expect(html).not.toContain('hljs-');
});
it('should keep code in a highlighted fence escaped', () => {
const html = renderMarkdown('```html\n<script>alert(1)</script>\n```');
expect(html).not.toContain('<script>');
expect(html).toContain('&lt;<span class="hljs-name">script</span>&gt;');
});
it('should strip classes other than highlighting classes from raw HTML spans and code', () => {
const html = renderMarkdown(
'<p><span class="hljs-keyword">kept</span> <span class="evil">dropped</span> <code class="language-js other">c</code></p>'
);
expect(html).toContain('<span class="hljs-keyword">kept</span>');
expect(html).toContain('<span>dropped</span>');
expect(html).toContain('<code class="language-js">c</code>');
});
it('should render inline code', () => {
const html = renderMarkdown('Use `npm install`');
expect(html).toContain('<code>npm install</code>');
+19 -15
View File
@@ -1,15 +1,11 @@
import MarkdownIt from 'markdown-it';
import sanitizeHtml from 'sanitize-html';
import { highlightCode } from './highlight.js';
const md = new MarkdownIt({
html: true,
linkify: true,
typographer: true,
breaks: true,
// Returning '' makes markdown-it escape and wrap the block itself, so an
// unlabelled or unknown-language fence still renders as plain <pre><code>.
highlight: (code, lang) => highlightCode(code, lang) ?? ''
breaks: true
});
// Raw HTML has to render (not just be escaped as visible text): a table with
@@ -23,14 +19,8 @@ const SANITIZE_OPTIONS: sanitizeHtml.IOptions = {
allowedAttributes: {
...sanitizeHtml.defaults.allowedAttributes,
input: ['type', 'checked', 'disabled', 'class'],
ul: ['class']
},
// Syntax highlighting is carried by classes: `language-*` on the <code>
// of a fence and `hljs-*` (plus highlight.js sub-scopes like `function_`)
// on the token spans. Nothing else may put a class on these tags.
allowedClasses: {
code: [/^language-[\w+#.-]+$/],
span: [/^hljs-[\w-]+$/, /^language-[\w+#.-]+$/, /^[a-z]+_{1,2}$/]
ul: ['class'],
pre: ['tabindex']
},
// Keep markdown-it's bare void-element style (<br>, not <br />) so
// sanitizing doesn't change output for content that had no raw HTML to
@@ -93,8 +83,22 @@ md.core.ruler.after('task-lists', 'note-links', (state) => {
}
});
export function renderMarkdown(content: string): string {
return sanitizeHtml(md.render(content), SANITIZE_OPTIONS);
// A <pre> that scrolls horizontally is a scrollable region and must be
// reachable by keyboard (RGAA 7.3 / WCAG 2.1.1). Card previews clip instead of
// scrolling and turn this off so a card does not gain a pointless tab stop.
const defaultFence = md.renderer.rules.fence!;
md.renderer.rules.fence = (tokens, idx, options, env, self) => {
const html = defaultFence(tokens, idx, options, env, self);
return env?.focusableCode === false ? html : html.replace(/^<pre>/, '<pre tabindex="0">');
};
export interface RenderMarkdownOptions {
/** Whether scrollable code blocks get `tabindex="0"`. Default true. */
focusableCode?: boolean;
}
export function renderMarkdown(content: string, options: RenderMarkdownOptions = {}): string {
return sanitizeHtml(md.render(content, { focusableCode: options.focusableCode ?? true }), SANITIZE_OPTIONS);
}
export function stripMarkdown(content: string): string {
+11
View File
@@ -68,15 +68,26 @@ export function tooltip(node: HTMLElement, value: string | TooltipOptions) {
const nativeTitle = node.getAttribute('title');
if (nativeTitle) node.removeAttribute('title');
// Removing `title` also removed the only accessible name most icon-only
// buttons had (RGAA 11.9 / WCAG 4.1.2). The tooltip text is that name, so
// expose it to assistive tech unless the element already names itself.
const ownsLabel = !node.hasAttribute('aria-label') && !node.hasAttribute('aria-labelledby') && !node.textContent?.trim();
function syncLabel() {
if (ownsLabel) node.setAttribute('aria-label', opts.text);
}
syncLabel();
return {
update(newValue: string | TooltipOptions) {
opts = parseOptions(newValue);
syncLabel();
},
destroy() {
hide();
node.removeEventListener('mouseenter', show);
node.removeEventListener('mouseleave', hide);
node.removeEventListener('pointerdown', hide);
if (ownsLabel) node.removeAttribute('aria-label');
if (nativeTitle) node.setAttribute('title', nativeTitle);
}
};
+9 -1
View File
@@ -33,10 +33,18 @@
</script>
<div class="flex min-h-screen flex-col bg-[var(--bg-base)] text-[var(--text)]">
<!-- RGAA 12.7: header and sidebar repeat on every page, so keyboard users get a way past them. -->
<a
href="#main-content"
class="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-50 focus:rounded-sm focus:border focus:border-[var(--border)] focus:bg-[var(--bg-surface)] focus:px-4 focus:py-2 focus:text-sm focus:text-[var(--text)] focus:shadow-[var(--card-shadow)]"
data-testid="skip-link"
>
Skip to content
</a>
<Header onMenuToggle={() => (sidebarOpen = !sidebarOpen)} />
<Sidebar open={sidebarOpen} onClose={() => (sidebarOpen = false)} />
<main class="flex-1 pt-4 transition-all {sidebarOpen ? 'lg:ml-64' : ''}">
<main id="main-content" tabindex="-1" class="flex-1 pt-4 outline-none transition-all {sidebarOpen ? 'lg:ml-64' : ''}">
<div class="mx-auto max-w-7xl px-4">
{@render children()}
</div>
-3
View File
@@ -6,9 +6,6 @@
let pathname = $derived(page.url.pathname);
</script>
<svelte:head>
<title>Settings - Crumbs</title>
</svelte:head>
<div class="mx-auto max-w-4xl px-4 py-8">
<h1 class="mb-6 text-2xl font-bold text-[var(--text)]">Settings</h1>
@@ -4,6 +4,10 @@
let appVersion = $derived($page.data.appVersion);
</script>
<svelte:head>
<title>About - Crumbs</title>
</svelte:head>
<div class="space-y-8">
<div class="flex items-center gap-3">
<h2 class="text-xl font-bold text-[var(--text)]">About Crumbs</h2>
@@ -115,6 +115,10 @@ ${mcpJsonConfig}`);
});
</script>
<svelte:head>
<title>MCP settings - Crumbs</title>
</svelte:head>
<!-- API Keys -->
<section class="mb-6 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-6 shadow-[var(--card-shadow)]">
<div class="mb-4 flex items-center gap-2">
@@ -6,6 +6,10 @@
const prefs = $derived(getPreferences());
</script>
<svelte:head>
<title>Preferences - Crumbs</title>
</svelte:head>
<div class="space-y-8">
<h2 class="text-xl font-bold text-[var(--text)]">Preferences</h2>
@@ -176,6 +176,10 @@
});
</script>
<svelte:head>
<title>Profile settings - Crumbs</title>
</svelte:head>
<!-- Profile -->
<section class="mb-6 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-6 shadow-[var(--card-shadow)]">
<h2 class="mb-4 text-lg font-semibold text-[var(--text)]">Profile</h2>
@@ -365,6 +365,10 @@
}
</script>
<svelte:head>
<title>User management - Crumbs</title>
</svelte:head>
<!-- Create User -->
<section class="mb-6 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-6 shadow-[var(--card-shadow)]">
<h2 class="mb-4 text-lg font-semibold text-[var(--text)]">Create User</h2>
@@ -403,6 +407,7 @@
{/if}
<select
bind:value={newRole}
aria-label="Role"
class="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-2 text-sm text-[var(--text)] outline-none focus:border-[var(--primary)]"
>
<option value="user">User</option>
+252
View File
@@ -0,0 +1,252 @@
import { test, expect, noteCard } from './helpers/fixtures.js';
import AxeBuilder from '@axe-core/playwright';
import { mkdirSync, writeFileSync } from 'node:fs';
import type { Browser, Page } from '@playwright/test';
/**
* Automated accessibility gate. RGAA 4.1 is the French application of
* WCAG 2.1 AA, so the scans run axe-core's WCAG 2.x A/AA rule set over every
* screen in both themes, and the structural scenarios cover the RGAA
* criteria that tooling can prove (page titles, landmarks, keyboard reach,
* visible focus). Tooling covers roughly a third of RGAA; the rest needs a
* manual pass with assistive technology and is documented in
* docs/ACCESSIBILITY.md.
*
* Each scan also writes its raw violations to test-results/a11y/ so a
* failing run doubles as the audit's evidence.
*/
const WCAG_TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'];
const REPORT_DIR = 'test-results/a11y';
const THEMES = ['light', 'dark'] as const;
type Theme = (typeof THEMES)[number];
const COLORS = ['default', 'coral', 'peach', 'sand', 'mint', 'sage', 'fog', 'storm', 'dusk', 'blossom', 'clay', 'chalk'];
async function scan(page: Page, name: string, theme: Theme) {
const results = await new AxeBuilder({ page }).withTags(WCAG_TAGS).analyze();
mkdirSync(REPORT_DIR, { recursive: true });
writeFileSync(`${REPORT_DIR}/${name}-${theme}.json`, JSON.stringify(results.violations, null, 2));
const summary = results.violations
.map(
(v) =>
`${v.id} [${v.impact}] x${v.nodes.length}: ${v.help}\n` +
v.nodes
.slice(0, 4)
.map((n) => ` ${n.target.join(' ')} — ${n.failureSummary?.split('\n').slice(1).join('; ')}`)
.join('\n')
)
.join('\n');
expect(results.violations, `axe violations on "${name}" in ${theme} mode:\n${summary}`).toEqual([]);
}
/** Seed one note per colour plus checklist, tagged, code, archived and trashed notes. */
async function seedNotes(page: Page) {
const existing = await (await page.request.get('/api/notes')).json();
if (Array.isArray(existing) && existing.some((n) => n.title === 'A11y coral')) return;
for (const color of COLORS) {
await page.request.post('/api/notes', {
data: { title: `A11y ${color}`, content: `Body text on the ${color} card with a [link](https://example.com) and *emphasis*.`, color }
});
}
await page.request.post('/api/notes', {
data: { title: 'A11y checklist', content: '- [ ] Open item\n- [x] Done item', checklistMode: true }
});
await page.request.post('/api/notes', { data: { title: 'A11y tagged', content: 'Filed under #audit' } });
await page.request.post('/api/notes', {
data: { title: 'A11y code', content: 'Inline `code` and a block:\n\n```ts\nconst answer: number = 42; // meaning\n```' }
});
const archived = await (await page.request.post('/api/notes', { data: { title: 'A11y archived', content: 'Archived' } })).json();
await page.request.patch(`/api/notes/${archived.id}`, { data: { archived: true } });
const trashed = await (await page.request.post('/api/notes', { data: { title: 'A11y trashed', content: 'Trashed' } })).json();
await page.request.patch(`/api/notes/${trashed.id}`, { data: { trashed: true } });
}
async function visit(page: Page, path: string) {
await page.goto(path);
await page.waitForLoadState('networkidle');
}
async function anonymousPage(browser: Browser, theme: Theme) {
const context = await browser.newContext({ colorScheme: theme, storageState: { cookies: [], origins: [] } });
return { context, page: await context.newPage() };
}
for (const theme of THEMES) {
test.describe(`WCAG 2.1 AA scan (${theme} theme)`, () => {
test.beforeEach(async ({ authenticatedPage: page }) => {
await page.emulateMedia({ colorScheme: theme });
await seedNotes(page);
});
test(`Scenario: The notes grid with every note colour has no WCAG AA violations`, async ({ authenticatedPage: page }) => {
await visit(page, '/');
await expect(noteCard(page, 'A11y chalk')).toBeVisible();
await scan(page, 'notes', theme);
});
test(`Scenario: The open editor, its colour picker and share dialog have no WCAG AA violations`, async ({
authenticatedPage: page
}) => {
await visit(page, '/');
await noteCard(page, 'A11y code').click();
await expect(page.getByTestId('tiptap-editor')).toBeVisible();
await scan(page, 'editor', theme);
await page.getByTestId('color-picker-toggle').click();
await expect(page.getByTestId('color-picker')).toBeVisible();
await scan(page, 'editor-color-picker', theme);
await page.keyboard.press('Escape');
await page.getByTestId('share-toggle').click();
await expect(page.getByTestId('share-dialog')).toBeVisible();
await scan(page, 'share-dialog', theme);
});
test(`Scenario: A checklist note open in the editor has no WCAG AA violations`, async ({ authenticatedPage: page }) => {
await visit(page, '/');
await noteCard(page, 'A11y checklist').click();
await expect(page.getByTestId('note-editor')).toBeVisible();
await scan(page, 'editor-checklist', theme);
});
for (const [name, path] of [
['archive', '/archive'],
['trash', '/trash'],
['tag', '/tag/audit'],
['settings-profile', '/settings/profile'],
['settings-preferences', '/settings/preferences'],
['settings-mcp', '/settings/mcp'],
['settings-users', '/settings/users'],
['settings-about', '/settings/about']
] as const) {
test(`Scenario: The ${name} page has no WCAG AA violations`, async ({ authenticatedPage: page }) => {
await visit(page, path);
await scan(page, name, theme);
});
}
test(`Scenario: Search results have no WCAG AA violations`, async ({ authenticatedPage: page }) => {
await visit(page, '/');
await page.getByTestId('search-input').fill('A11y');
await expect(noteCard(page, 'A11y coral')).toBeVisible();
await scan(page, 'search', theme);
});
test(`Scenario: The login page has no WCAG AA violations`, async ({ browser }) => {
const { context, page } = await anonymousPage(browser, theme);
await visit(page, '/login');
await expect(page.getByTestId('login-btn')).toBeVisible();
await scan(page, 'login', theme);
await context.close();
});
test(`Scenario: A publicly shared note has no WCAG AA violations`, async ({ authenticatedPage: page, browser }) => {
const notes = await (await page.request.get('/api/notes')).json();
const code = notes.find((n: { title: string }) => n.title === 'A11y code');
const { token } = await (await page.request.post(`/api/notes/${code.id}/share`)).json();
const { context, page: anon } = await anonymousPage(browser, theme);
await visit(anon, `/s/${token}`);
await expect(anon.getByTestId('shared-note-content')).toBeVisible();
await scan(anon, 'share', theme);
await context.close();
});
});
}
test.describe('Structure and keyboard (RGAA 8, 10, 12)', () => {
test.beforeEach(async ({ authenticatedPage: page }) => {
await seedNotes(page);
});
test('Scenario: Every page has a distinct, descriptive title (RGAA 8.5, 8.6)', async ({ authenticatedPage: page }) => {
const titles = new Map<string, string>();
for (const path of ['/', '/archive', '/trash', '/tag/audit', '/settings/profile', '/settings/preferences']) {
await visit(page, path);
titles.set(path, await page.title());
}
for (const [path, title] of titles) {
expect(title.trim(), `title of ${path}`).not.toBe('');
}
expect(new Set(titles.values()).size, `titles must differ per page: ${JSON.stringify([...titles])}`).toBe(titles.size);
});
test('Scenario: The app shell exposes header, navigation, main and footer landmarks (RGAA 12.6, 9.2)', async ({
authenticatedPage: page
}) => {
await visit(page, '/');
for (const selector of ['header, [role="banner"]', 'nav, [role="navigation"]', 'main, [role="main"]', 'footer, [role="contentinfo"]']) {
await expect(page.locator(selector).first(), selector).toBeAttached();
}
});
test('Scenario: A skip link lets keyboard users jump past the header and sidebar (RGAA 12.7)', async ({
authenticatedPage: page
}) => {
await visit(page, '/');
await page.keyboard.press('Tab');
const first = await page.evaluate(() => {
const el = document.activeElement as HTMLElement | null;
return { tag: el?.tagName, text: el?.textContent?.trim() ?? '', href: el?.getAttribute('href') ?? '' };
});
expect(first, 'first Tab stop should be a skip link to the main content').toMatchObject({ tag: 'A' });
expect(first.href).toMatch(/^#/);
});
test('Scenario: Every Tab stop on the notes page shows a visible focus indicator (RGAA 10.7)', async ({
authenticatedPage: page
}) => {
await visit(page, '/');
await expect(noteCard(page, 'A11y chalk')).toBeVisible();
const invisible: string[] = [];
const seen = new Set<string>();
for (let i = 0; i < 40; i++) {
await page.keyboard.press('Tab');
const info = await page.evaluate(() => {
const el = document.activeElement as HTMLElement | null;
if (!el || el === document.body) return null;
const s = getComputedStyle(el);
const key = el.tagName + '#' + (el.dataset.testid ?? el.getAttribute('aria-label') ?? el.textContent?.trim().slice(0, 20));
const hasOutline = s.outlineStyle !== 'none' && parseFloat(s.outlineWidth) > 0;
const hasRing = s.boxShadow !== 'none';
const hasBorderChange = el.matches(':focus-visible') && s.borderColor !== getComputedStyle(el.parentElement!).borderColor;
return { key, visible: hasOutline || hasRing || hasBorderChange };
});
if (!info || seen.has(info.key)) break;
seen.add(info.key);
if (!info.visible) invisible.push(info.key);
}
expect(seen.size, 'Tab must reach interactive elements').toBeGreaterThan(3);
expect(invisible, 'focused elements without a visible indicator').toEqual([]);
});
test('Scenario: Escape closes the editor and returns focus to the card that opened it (RGAA 7.3, 12.8)', async ({
authenticatedPage: page
}) => {
await visit(page, '/');
const card = noteCard(page, 'A11y coral');
await card.focus();
await page.keyboard.press('Enter');
await expect(page.getByTestId('note-editor')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.getByTestId('note-editor')).toBeHidden();
await expect(card).toBeFocused();
});
test('Scenario: Card action buttons are reachable and named for keyboard and screen-reader users (RGAA 7.1, 11.9)', async ({
authenticatedPage: page
}) => {
await visit(page, '/');
const card = noteCard(page, 'A11y coral');
const buttons = card.getByRole('button');
const count = await buttons.count();
expect(count, 'card should expose its actions as buttons').toBeGreaterThan(0);
for (let i = 0; i < count; i++) {
const name = await buttons.nth(i).evaluate((el) => {
const b = el as HTMLElement;
return b.getAttribute('aria-label') || b.textContent?.trim() || b.getAttribute('title') || '';
});
expect(name, `button ${i} on the card needs an accessible name`).not.toBe('');
}
});
});
-164
View File
@@ -1,164 +0,0 @@
import { test, expect, noteCard } from './helpers/fixtures.js';
import type { Page } from '@playwright/test';
async function waitForTiptapEditor(page: Page) {
await page.getByTestId('tiptap-editor').waitFor({ state: 'visible' });
await page.waitForFunction(() => {
const el = document.querySelector('[data-testid="tiptap-editor"]');
return el && (el as any).__tiptapEditor != null;
});
}
async function runTiptapCommand(page: Page, commandFn: string) {
await page.getByTestId('tiptap-editor').evaluate(
(el, fn) => {
const editor = (el as any).__tiptapEditor;
if (!editor) throw new Error('TipTap editor not found on element');
new Function('editor', fn)(editor);
},
commandFn
);
}
async function toggleMarkdownMode(page: Page) {
await page.getByTestId('overflow-menu-btn').click();
await page.getByTestId('markdown-toggle').click();
}
/** Write note content as markdown, then return to the rich editor. */
async function typeViaMarkdown(page: Page, content: string) {
await toggleMarkdownMode(page);
await page.getByTestId('note-content-input').fill(content);
await toggleMarkdownMode(page);
}
test.describe('Code snippets', () => {
test('Scenario: A fenced block with a language is syntax highlighted in the editor and on the card', async ({
authenticatedPage: page
}) => {
// Given a note whose content is a JavaScript code block
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('JS Snippet');
await typeViaMarkdown(page, '```js\nconst answer = 42;\n```');
await waitForTiptapEditor(page);
// Then the editor colours the keyword and number as separate tokens
const editor = page.getByTestId('tiptap-editor');
await expect(editor.locator('pre .hljs-keyword')).toHaveText('const');
await expect(editor.locator('pre .hljs-number')).toHaveText('42');
// When the note is closed
await page.getByTestId('close-editor-btn').click();
// Then the card preview shows the same highlighting
const preview = noteCard(page, 'JS Snippet').getByTestId('note-content-preview');
await expect(preview.locator('pre .hljs-keyword')).toHaveText('const');
await expect(preview.locator('pre .hljs-number')).toHaveText('42');
});
test('Scenario: An unlabelled code block stays plain instead of guessing a language', async ({
authenticatedPage: page
}) => {
// Given a note whose content is a code block with no language
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Plain Snippet');
await typeViaMarkdown(page, '```\nSELECT * FROM users;\n```');
await waitForTiptapEditor(page);
// Then the editor shows the block without any token colouring
const editor = page.getByTestId('tiptap-editor');
await expect(editor.locator('pre code')).toHaveText('SELECT * FROM users;');
await expect(editor.locator('pre [class*="hljs-"]')).toHaveCount(0);
// And so does the card preview
await page.getByTestId('close-editor-btn').click();
const preview = noteCard(page, 'Plain Snippet').getByTestId('note-content-preview');
await expect(preview.locator('pre code')).toHaveText('SELECT * FROM users;');
await expect(preview.locator('pre [class*="hljs-"]')).toHaveCount(0);
});
test('Scenario: Choosing a language for a code block is saved in the markdown fence', async ({
authenticatedPage: page
}) => {
// Given the user is editing a note with a plain code block
await page.getByTestId('new-note-btn').click();
await typeViaMarkdown(page, '```\ndef greet():\n return "hi"\n```');
await waitForTiptapEditor(page);
// And the language picker is offered only while the cursor is in the block
await runTiptapCommand(page, 'editor.chain().focus("start").run()');
const picker = page.getByTestId('format-code-language');
await expect(picker).toBeVisible();
await expect(picker).toHaveValue('');
// When the user picks Python
await picker.selectOption('python');
// Then the block is highlighted as Python
await expect(page.getByTestId('tiptap-editor').locator('pre .hljs-keyword').first()).toHaveText('def');
await expect(picker).toHaveValue('python');
// And the markdown fence carries the language
await toggleMarkdownMode(page);
await expect(page.getByTestId('note-content-input')).toHaveValue('```python\ndef greet():\n return "hi"\n```');
});
test('Scenario: The language picker is hidden outside a code block', async ({ authenticatedPage: page }) => {
// Given the user is editing a note with a paragraph
await page.getByTestId('new-note-btn').click();
await typeViaMarkdown(page, 'just prose');
await waitForTiptapEditor(page);
await runTiptapCommand(page, 'editor.chain().focus("end").run()');
// Then no language picker is shown
await expect(page.getByTestId('format-code-language')).toHaveCount(0);
});
test('Scenario: Code keeps the body text size instead of shrinking', async ({ authenticatedPage: page }) => {
// Given a note with a code block and a paragraph
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Sized Snippet');
await typeViaMarkdown(page, 'Some prose\n\n```js\nconst a = 1;\n```');
await waitForTiptapEditor(page);
// Then the code block is set at the same size as the prose around it
const editor = page.getByTestId('tiptap-editor');
const fontSize = (locator: ReturnType<Page['locator']>) =>
locator.evaluate((el) => getComputedStyle(el).fontSize);
expect(await fontSize(editor.locator('pre code'))).toBe(await fontSize(editor.locator('p').first()));
// And the same holds on the card preview
await page.getByTestId('close-editor-btn').click();
const preview = noteCard(page, 'Sized Snippet').getByTestId('note-content-preview');
expect(await fontSize(preview.locator('pre code'))).toBe(await fontSize(preview.locator('p').first()));
});
test('Scenario: A long code line is clipped on the card instead of adding a scrollbar', async ({
authenticatedPage: page
}) => {
// Given a note with a code line far wider than a card
const longLine = 'const url = "https://example.com/' + 'segment/'.repeat(30) + '";';
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Wide Snippet');
await typeViaMarkdown(page, '```js\n' + longLine + '\n```');
await page.getByTestId('close-editor-btn').click();
// Then the card's code block stays inside the card and does not scroll horizontally
const preview = noteCard(page, 'Wide Snippet').getByTestId('note-content-preview');
const pre = preview.locator('pre');
await expect(pre).toBeVisible();
const overflow = await pre.evaluate((el) => {
const style = getComputedStyle(el);
const box = el.getBoundingClientRect();
const container = el.parentElement!.getBoundingClientRect();
return {
x: style.overflowX,
wider: el.scrollWidth > el.clientWidth,
spill: box.right - container.right
};
});
expect(overflow.wider, 'the line must actually be wider than the card for this test to mean anything').toBe(true);
expect(overflow.x).toBe('hidden');
expect(overflow.spill).toBeLessThanOrEqual(1);
});
});
+1 -25
View File
@@ -1,4 +1,4 @@
import { test, expect, noteCard } from './helpers/fixtures.js';
import { test, expect } from './helpers/fixtures.js';
import type { Page } from '@playwright/test';
const DEFAULT_PREFS = {
@@ -81,30 +81,6 @@ test.describe('Settings — Preferences', () => {
await expect(page.getByTestId('note-content-input')).toBeVisible();
});
test('Scenario: Default note mode applies when reopening an existing note', async ({
authenticatedPage: page
}) => {
await resetPreferences(page);
// Given a note titled "Existing Markdown Note" exists
const title = `Existing Markdown Note ${Date.now()}`;
await page.request.post('/api/notes', { data: { title, content: 'Some **bold** text' } });
// And the user's default note mode is Markdown
await gotoWithStablePrefs(page, '/settings/preferences');
const putResponse = page.waitForResponse((res) => res.url().includes('/api/preferences') && res.request().method() === 'PUT');
await page.getByTestId('pref-mode-markdown').click();
await putResponse;
// When the user opens the note
await gotoWithStablePrefs(page, '/');
await noteCard(page, title).click();
// Then the note editor opens in markdown mode showing the raw source
await expect(page.getByTestId('note-content-input')).toBeVisible();
await expect(page.getByTestId('note-content-input')).toHaveValue('Some **bold** text');
});
test('Scenario: Footer toggle hides and shows footer', async ({
authenticatedPage: page
}) => {
+1 -24
View File
@@ -2,36 +2,13 @@ import { sveltekit } from '@sveltejs/kit/vite';
import { SvelteKitPWA } from '@vite-pwa/sveltekit';
import tailwindcss from '@tailwindcss/vite';
import istanbul from 'vite-plugin-istanbul';
import { defineConfig, type Plugin } from 'vite';
/**
* The e2e suite drives `vite preview` and its API helpers reuse keep-alive
* sockets. Node's http server drops an idle keep-alive socket after 5s by
* default, and a request that lands on a socket the server is closing at that
* instant fails client-side with "socket hang up" (seen on main CI in
* public-sharing.spec). Keeping the server's idle timeout well above any
* client's means the client always closes first, so the race cannot happen.
*/
function previewKeepAlive(): Plugin {
return {
name: 'crumbs:preview-keep-alive',
configurePreviewServer({ httpServer }) {
// HTTP/2 servers have no keep-alive timeout; preview is HTTP/1 here.
if ('keepAliveTimeout' in httpServer) {
httpServer.keepAliveTimeout = 65_000;
// Node requires headersTimeout to exceed keepAliveTimeout.
httpServer.headersTimeout = 66_000;
}
}
};
}
import { defineConfig } from 'vite';
export default defineConfig({
build: {
chunkSizeWarningLimit: 850
},
plugins: [
previewKeepAlive(),
tailwindcss(),
sveltekit(),
...(process.env.VITE_COVERAGE === 'true'
+1 -1
View File
@@ -292,7 +292,7 @@
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/docs/DEPLOYMENT.md" target="_blank" rel="noopener">Docs</a></li>
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/LICENSE" target="_blank" rel="noopener">License</a></li>
</ul>
<span class="footer-version">v0.35.2</span>
<span class="footer-version">v0.34.2</span>
</div>
</footer>