mirror of
https://github.com/bretzel-app/crumbs.git
synced 2026-10-12 12:06:57 +02:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3178d65b35 | ||
|
|
7bf06edd97 | ||
|
|
af7186675e | ||
|
|
b0ecc6b74d | ||
|
|
521acc718c | ||
|
|
082b8632c6 | ||
|
|
6bdcc3c214 | ||
|
|
e8c30c518c | ||
|
|
84145de3b1 | ||
|
|
ea66e23178 | ||
|
|
feda983393 | ||
|
|
bf5bfdd98e | ||
|
|
12ff6d9c4d | ||
|
|
44963bf684 | ||
|
|
dfaf1c474d | ||
|
|
881a85a09e | ||
|
|
27c3e7dccd | ||
|
|
64f96b21a3 | ||
|
|
ff24fb6d5b |
@@ -171,7 +171,8 @@ A `Makefile` wraps all common tasks for tool-agnostic usage. Run `make help` to
|
||||
- **Conventional commits**: Use `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`, `test:` prefixes
|
||||
- **Commit regularly**: Bundle related changes into logical commits after each step or feature slice — don't accumulate a large diff
|
||||
- **Main branch**: `main` — use as base for all PRs and feature branches
|
||||
- **Feature branches**: Use `feat/<name>` branches with worktrees for isolation
|
||||
- **Always work in a worktree**: Never edit files in the main checkout. Before touching any file, create a dedicated worktree (`EnterWorktree` or `git worktree add .worktrees/<name> -b <branch>`) on a fresh branch from `origin/main`, do all work, commits, and PR creation from there. This keeps the main checkout clean and lets several tasks run side by side without clobbering each other's uncommitted changes.
|
||||
- **Feature branches**: Use `feat/<name>` (or `fix/<name>`) branches, one per worktree
|
||||
- **Rebase before PR**: Always `git rebase origin/main` (not merge) before pushing or creating a PR to keep a clean linear history and avoid merge conflicts in the PR
|
||||
|
||||
## CI/CD
|
||||
@@ -218,6 +219,7 @@ Write tests first or alongside features. Tests serve as living documentation of
|
||||
- Config: `playwright.config.ts` (Chromium only, builds + previews app on port 4173)
|
||||
- Test database: `./data/test-crumbs.db` (cleaned via `global-setup.ts`)
|
||||
- Auth fixture: `tests/e2e/helpers/fixtures.ts` provides `authenticatedPage` (handles setup/login race conditions across parallel workers)
|
||||
- **Shared login state**: `tests/e2e/auth.setup.ts` (project `login-state`) logs in once and saves the admin session cookie to `data/test-storage-state.json`; the `app` project loads it into every context so tests start authenticated instead of submitting the login form each time. A spec that needs an anonymous baseline opts out with `test.use({ storageState: { cookies: [], origins: [] } })` (see `pwa.spec.ts`, `mcp.spec.ts`). Never write a test that revokes or logs out the admin's own session — it would break every test in other workers
|
||||
- Auth tests use `test.describe.serial` because they depend on sequential database state
|
||||
- **Offline/online tests**: After `setOffline(false)`, always manually dispatch the `online` event via `page.evaluate(() => window.dispatchEvent(new Event('online')))` — Playwright doesn't reliably emit it in headless/CI environments, causing flaky `waitForResponse` timeouts
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
crumbs:
|
||||
image: ghcr.io/bretzel-app/crumbs:0.33.0
|
||||
image: ghcr.io/bretzel-app/crumbs:0.34.2
|
||||
container_name: crumbs
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
@@ -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.
|
||||
+29
-27
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "crumbs",
|
||||
"private": true,
|
||||
"version": "0.34.0",
|
||||
"version": "0.34.2",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@9.15.9",
|
||||
"license": "MIT",
|
||||
@@ -36,55 +36,56 @@
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
"@sveltejs/adapter-node": "^5.5.7",
|
||||
"@tiptap/core": "^3.29.2",
|
||||
"@tiptap/extension-link": "^3.29.2",
|
||||
"@tiptap/extension-placeholder": "^3.29.2",
|
||||
"@tiptap/extension-table": "^3.29.2",
|
||||
"@tiptap/extension-table-cell": "^3.29.2",
|
||||
"@tiptap/extension-table-header": "^3.29.2",
|
||||
"@tiptap/extension-table-row": "^3.29.2",
|
||||
"@tiptap/extension-task-item": "^3.29.2",
|
||||
"@tiptap/extension-task-list": "^3.29.2",
|
||||
"@tiptap/extension-text-align": "^3.29.2",
|
||||
"@tiptap/extension-underline": "^3.29.2",
|
||||
"@tiptap/pm": "^3.29.2",
|
||||
"@tiptap/starter-kit": "^3.29.2",
|
||||
"@tiptap/core": "^3.31.0",
|
||||
"@tiptap/extension-link": "^3.31.0",
|
||||
"@tiptap/extension-placeholder": "^3.31.0",
|
||||
"@tiptap/extension-table": "^3.31.0",
|
||||
"@tiptap/extension-table-cell": "^3.31.0",
|
||||
"@tiptap/extension-table-header": "^3.31.0",
|
||||
"@tiptap/extension-table-row": "^3.31.0",
|
||||
"@tiptap/extension-task-item": "^3.31.0",
|
||||
"@tiptap/extension-task-list": "^3.31.0",
|
||||
"@tiptap/extension-text-align": "^3.31.0",
|
||||
"@tiptap/extension-underline": "^3.31.0",
|
||||
"@tiptap/pm": "^3.31.0",
|
||||
"@tiptap/starter-kit": "^3.31.0",
|
||||
"arctic": "^3.7.0",
|
||||
"argon2": "^0.43.1",
|
||||
"argon2": "^0.45.1",
|
||||
"better-sqlite3": "^11.10.0",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"idb": "^8.0.3",
|
||||
"lucide-svelte": "^0.577.0",
|
||||
"markdown-it": "^14.3.0",
|
||||
"markdown-it": "^15.0.1",
|
||||
"nanoid": "^5.1.16",
|
||||
"node-diff3": "^3.2.1",
|
||||
"nodemailer": "^9.0.3",
|
||||
"svelte-dnd-action": "^0.9.77",
|
||||
"nodemailer": "^9.1.0",
|
||||
"sanitize-html": "^2.17.7",
|
||||
"svelte-dnd-action": "^0.9.79",
|
||||
"tiptap-markdown": "^0.9.0",
|
||||
"uuid": "^11.1.1",
|
||||
"zod": "^4.4.3",
|
||||
"uuid": "^14.0.2",
|
||||
"zod": "^4.5.4",
|
||||
"zxcvbn": "^4.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.13.0",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@sveltejs/kit": "^2.70.2",
|
||||
"@sveltejs/kit": "^2.70.3",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/typography": "^0.5.20",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/sanitize-html": "^2.16.1",
|
||||
"@types/zxcvbn": "^4.4.5",
|
||||
"@vite-pwa/sveltekit": "^1.1.0",
|
||||
"@vitest/coverage-v8": "^3.2.7",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"jsdom": "^29.1.1",
|
||||
"nyc": "^18.0.0",
|
||||
"svelte": "^5.56.8",
|
||||
"svelte-check": "^4.7.4",
|
||||
"svelte": "^5.57.0",
|
||||
"svelte-check": "^4.7.6",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"tsx": "^4.23.5",
|
||||
"tsx": "^4.23.13",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.6",
|
||||
"vite-plugin-istanbul": "^7.2.1",
|
||||
@@ -106,7 +107,8 @@
|
||||
"hono": ">=4.12.7",
|
||||
"devalue": ">=5.8.1",
|
||||
"path-to-regexp@8": ">=8.4.0",
|
||||
"fast-uri": ">=3.1.2"
|
||||
"fast-uri": ">=3.1.2",
|
||||
"postcss>nanoid": ">=3.3.18 <4"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -1,4 +1,5 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
import { STORAGE_STATE_FILE } from './tests/e2e/global-setup';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
@@ -22,10 +23,20 @@ export default defineConfig({
|
||||
use: { ...devices['Desktop Chrome'] }
|
||||
},
|
||||
{
|
||||
name: 'app',
|
||||
testIgnore: 'auth.spec.ts',
|
||||
// Logs in once and saves the session cookie for the `app` project.
|
||||
name: 'login-state',
|
||||
testMatch: 'auth.setup.ts',
|
||||
dependencies: ['auth-setup'],
|
||||
use: { ...devices['Desktop Chrome'] }
|
||||
},
|
||||
{
|
||||
name: 'app',
|
||||
testIgnore: ['auth.spec.ts', 'auth.setup.ts'],
|
||||
dependencies: ['login-state'],
|
||||
// Every context starts with the admin already logged in. Specs that
|
||||
// need an unauthenticated baseline opt out with
|
||||
// `test.use({ storageState: { cookies: [], origins: [] } })`.
|
||||
use: { ...devices['Desktop Chrome'], storageState: STORAGE_STATE_FILE }
|
||||
}
|
||||
],
|
||||
webServer: {
|
||||
|
||||
Generated
+1001
-796
File diff suppressed because it is too large
Load Diff
+17
-2
@@ -88,14 +88,29 @@ body::before {
|
||||
to { transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Prose overrides for parchment theme */
|
||||
/* Prose overrides for parchment theme.
|
||||
The typography plugin gives every element type its own --tw-prose-* variable,
|
||||
each defaulting to a fixed grey. Anything not remapped here stays that grey in
|
||||
dark mode (bold text was near-black on ink — see issue #86), so map them all. */
|
||||
.prose {
|
||||
--tw-prose-body: var(--text);
|
||||
--tw-prose-headings: var(--text);
|
||||
--tw-prose-lead: var(--text-muted);
|
||||
--tw-prose-links: var(--primary);
|
||||
--tw-prose-bold: var(--text);
|
||||
--tw-prose-counters: var(--text-muted);
|
||||
--tw-prose-bullets: var(--text-muted);
|
||||
--tw-prose-hr: var(--border-subtle);
|
||||
--tw-prose-quotes: var(--text);
|
||||
--tw-prose-quote-borders: var(--border-subtle);
|
||||
--tw-prose-captions: var(--text-muted);
|
||||
--tw-prose-kbd: var(--text);
|
||||
--tw-prose-kbd-shadows: color-mix(in oklab, var(--text) 10%, transparent);
|
||||
--tw-prose-code: var(--text);
|
||||
--tw-prose-pre-bg: var(--bg-surface);
|
||||
--tw-prose-pre-code: var(--text);
|
||||
--tw-prose-pre-bg: var(--bg-surface);
|
||||
--tw-prose-th-borders: var(--border-subtle);
|
||||
--tw-prose-td-borders: var(--border-subtle);
|
||||
}
|
||||
|
||||
.prose :where(code):not(:where([class~="not-prose"] *)) {
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import Underline from '@tiptap/extension-underline';
|
||||
import TextAlign from '@tiptap/extension-text-align';
|
||||
import Placeholder from '@tiptap/extension-placeholder';
|
||||
import { Table } from '@tiptap/extension-table';
|
||||
import { Table } from './tiptap/Table.js';
|
||||
import { TableRow } from '@tiptap/extension-table-row';
|
||||
import { TableHeader } from '@tiptap/extension-table-header';
|
||||
import { TableCell } from '@tiptap/extension-table-cell';
|
||||
@@ -107,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
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Editor, type JSONContent } from '@tiptap/core';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import { TableRow } from '@tiptap/extension-table-row';
|
||||
import { TableHeader } from '@tiptap/extension-table-header';
|
||||
import { TableCell } from '@tiptap/extension-table-cell';
|
||||
import { Markdown } from 'tiptap-markdown';
|
||||
import { Table } from './Table.js';
|
||||
import { renderMarkdown } from '$lib/utils/markdown.js';
|
||||
|
||||
function createEditor(content: string | object, markdownOptions?: { html?: boolean }) {
|
||||
return new Editor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Table.configure({ resizable: false }),
|
||||
TableRow,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
Markdown.configure(markdownOptions ?? {})
|
||||
],
|
||||
content
|
||||
});
|
||||
}
|
||||
|
||||
function getMarkdown(editor: Editor): string {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (editor.storage as Record<string, any>).markdown.getMarkdown();
|
||||
}
|
||||
|
||||
function docWithListInCell(): JSONContent {
|
||||
return {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'table',
|
||||
content: [
|
||||
{
|
||||
type: 'tableRow',
|
||||
content: [
|
||||
{
|
||||
type: 'tableHeader',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Header A' }] }]
|
||||
},
|
||||
{
|
||||
type: 'tableHeader',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Header B' }] }]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'tableRow',
|
||||
content: [
|
||||
{
|
||||
type: 'tableCell',
|
||||
content: [
|
||||
{
|
||||
type: 'bulletList',
|
||||
content: [
|
||||
{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'First' }] }] },
|
||||
{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Second' }] }] }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'tableCell',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Plain cell' }] }]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
// crumbs#83: a bulleted list inside a table cell survives in the live editor
|
||||
// (never touches markdown) but breaks once the note is saved and reopened,
|
||||
// because saving/reopening round-trips the doc through markdown text.
|
||||
describe('Table markdown round-trip with a bullet list in a cell', () => {
|
||||
it('preserves a bullet list inside a table cell after saving and reopening', () => {
|
||||
const editor = createEditor(docWithListInCell());
|
||||
|
||||
// "Save": serialize the crumb to markdown, the way onUpdate() does.
|
||||
const savedMarkdown = getMarkdown(editor);
|
||||
|
||||
// "Reopen": load that markdown into a fresh editor, the way opening the
|
||||
// crumb again does (content is passed in as a markdown string).
|
||||
const reopened = createEditor(savedMarkdown);
|
||||
const json = reopened.getJSON() as JSONContent;
|
||||
|
||||
const table = json.content?.find((node) => node.type === 'table');
|
||||
expect(table).toBeDefined();
|
||||
|
||||
const rows = table!.content!;
|
||||
expect(rows).toHaveLength(2);
|
||||
|
||||
const [headerRow, bodyRow] = rows;
|
||||
expect(headerRow.content!.map((cell) => cell.content?.[0]?.content?.[0]?.text)).toEqual(['Header A', 'Header B']);
|
||||
|
||||
const [listCell, plainCell] = bodyRow.content!;
|
||||
expect(listCell.content?.[0]?.type).toBe('bulletList');
|
||||
const items = listCell.content?.[0]?.content ?? [];
|
||||
expect(items.map((item) => item.content?.[0]?.content?.[0]?.text)).toEqual(['First', 'Second']);
|
||||
|
||||
expect(plainCell.content?.[0]?.content?.[0]?.text).toBe('Plain cell');
|
||||
});
|
||||
|
||||
it('still renders as a real table (not escaped HTML) on the read-only preview/share path', () => {
|
||||
const editor = createEditor(docWithListInCell());
|
||||
const savedMarkdown = getMarkdown(editor);
|
||||
|
||||
const html = renderMarkdown(savedMarkdown);
|
||||
|
||||
expect(html).toContain('<table>');
|
||||
expect(html).toContain('<ul');
|
||||
expect(html).toContain('First');
|
||||
expect(html).toContain('Second');
|
||||
expect(html).not.toContain('<table>');
|
||||
});
|
||||
|
||||
it('still serializes a table with only plain-text cells as GFM markdown, not HTML', () => {
|
||||
const editor = createEditor({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'table',
|
||||
content: [
|
||||
{
|
||||
type: 'tableRow',
|
||||
content: [
|
||||
{ type: 'tableHeader', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'A' }] }] },
|
||||
{ type: 'tableHeader', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'B' }] }] }
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'tableRow',
|
||||
content: [
|
||||
{ type: 'tableCell', content: [{ type: 'paragraph', content: [{ type: 'text', text: '1' }] }] },
|
||||
{ type: 'tableCell', content: [{ type: 'paragraph', content: [{ type: 'text', text: '2' }] }] }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const savedMarkdown = getMarkdown(editor);
|
||||
|
||||
expect(savedMarkdown).not.toContain('<table');
|
||||
expect(savedMarkdown).toContain('| A | B |');
|
||||
expect(savedMarkdown).toContain('| 1 | 2 |');
|
||||
});
|
||||
|
||||
it('falls back to a placeholder instead of raw HTML when the editor has html mode disabled', () => {
|
||||
const editor = createEditor(docWithListInCell(), { html: false });
|
||||
|
||||
const savedMarkdown = getMarkdown(editor);
|
||||
|
||||
expect(savedMarkdown).not.toContain('<table');
|
||||
expect(savedMarkdown).toContain('[table]');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { elementFromString, getHTMLFromFragment } from '@tiptap/core';
|
||||
import { Table as BaseTable } from '@tiptap/extension-table';
|
||||
import { Fragment, type Node as ProseMirrorNode } from '@tiptap/pm/model';
|
||||
|
||||
interface MarkdownSerializerState {
|
||||
write(text: string): void;
|
||||
ensureNewLine(): void;
|
||||
closeBlock(node: ProseMirrorNode): void;
|
||||
renderInline(node: ProseMirrorNode): void;
|
||||
inTable: boolean;
|
||||
}
|
||||
|
||||
interface SerializeContext {
|
||||
editor: { storage: { markdown: { options: { html: boolean } } } };
|
||||
}
|
||||
|
||||
function childNodes(node: ProseMirrorNode): ProseMirrorNode[] {
|
||||
const nodes: ProseMirrorNode[] = [];
|
||||
node.forEach((child) => nodes.push(child));
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function hasSpan(cell: ProseMirrorNode): boolean {
|
||||
return cell.attrs.colspan > 1 || cell.attrs.rowspan > 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* A cell can only be squeezed onto a single `| cell |` markdown line via
|
||||
* `state.renderInline()` if its content is itself a textblock (a paragraph,
|
||||
* whose children are inline text runs). A block child instead - e.g. a
|
||||
* bullet list - has to be rejected here, or renderInline() misreads its
|
||||
* block-level children as inline ones and corrupts the row (crumbs#83).
|
||||
*/
|
||||
function hasOnlyInlineContent(cell: ProseMirrorNode): boolean {
|
||||
const content = cell.firstChild;
|
||||
return content === null || content.type.isTextblock;
|
||||
}
|
||||
|
||||
function isMarkdownSerializable(node: ProseMirrorNode): boolean {
|
||||
const rows = childNodes(node);
|
||||
const firstRow = rows[0];
|
||||
const bodyRows = rows.slice(1);
|
||||
|
||||
if (
|
||||
childNodes(firstRow).some(
|
||||
(cell) => cell.type.name !== 'tableHeader' || hasSpan(cell) || cell.childCount > 1 || !hasOnlyInlineContent(cell)
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !bodyRows.some((row) =>
|
||||
childNodes(row).some(
|
||||
(cell) => cell.type.name === 'tableHeader' || hasSpan(cell) || cell.childCount > 1 || !hasOnlyInlineContent(cell)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** Mirrors tiptap-markdown's internal HTML-block fallback (its HTMLNode spec isn't exported). */
|
||||
function formatBlock(html: string): string {
|
||||
const element = elementFromString(html).firstElementChild!;
|
||||
element.innerHTML = element.innerHTML.trim() ? `\n${element.innerHTML}\n` : '\n';
|
||||
return element.outerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as tiptap-markdown's HTMLNode fallback: when the editor's `html`
|
||||
* option is off, other node types degrade to a `[nodename]` placeholder
|
||||
* instead of emitting HTML. A table with non-inline cell content has to
|
||||
* follow the same rule, or it'd be the only node type still leaking raw HTML
|
||||
* with html mode disabled.
|
||||
*/
|
||||
function serializeAsHTML(
|
||||
context: SerializeContext,
|
||||
state: MarkdownSerializerState,
|
||||
node: ProseMirrorNode,
|
||||
parent: ProseMirrorNode | Fragment
|
||||
) {
|
||||
if (!context.editor.storage.markdown.options.html) {
|
||||
state.write(`[${node.type.name}]`);
|
||||
state.closeBlock(node);
|
||||
return;
|
||||
}
|
||||
|
||||
const schema = node.type.schema;
|
||||
const html = getHTMLFromFragment(Fragment.from(node), schema);
|
||||
const isTopLevel = parent instanceof Fragment || parent.type.name === schema.topNodeType.name;
|
||||
state.write(isTopLevel ? formatBlock(html) : html);
|
||||
state.closeBlock(node);
|
||||
}
|
||||
|
||||
/**
|
||||
* Overrides @tiptap/extension-table's markdown storage (tiptap-markdown ships
|
||||
* a default for it, but only guards against multi-child/spanning cells, not
|
||||
* a single block child - see hasOnlyInlineContent above).
|
||||
*/
|
||||
export const Table = BaseTable.extend({
|
||||
addStorage() {
|
||||
return {
|
||||
markdown: {
|
||||
serialize(
|
||||
this: SerializeContext,
|
||||
state: MarkdownSerializerState,
|
||||
node: ProseMirrorNode,
|
||||
parent: ProseMirrorNode | Fragment
|
||||
) {
|
||||
if (!isMarkdownSerializable(node)) {
|
||||
serializeAsHTML(this, state, node, parent);
|
||||
return;
|
||||
}
|
||||
|
||||
state.inTable = true;
|
||||
node.forEach((row, _rowOffset, rowIndex) => {
|
||||
state.write('| ');
|
||||
row.forEach((cell, _cellOffset, cellIndex) => {
|
||||
if (cellIndex) {
|
||||
state.write(' | ');
|
||||
}
|
||||
const cellContent = cell.firstChild;
|
||||
if (cellContent && cellContent.textContent.trim()) {
|
||||
state.renderInline(cellContent);
|
||||
}
|
||||
});
|
||||
state.write(' |');
|
||||
state.ensureNewLine();
|
||||
if (!rowIndex) {
|
||||
const delimiterRow = Array.from({ length: row.childCount }, () => '---').join(' | ');
|
||||
state.write(`| ${delimiterRow} |`);
|
||||
state.ensureNewLine();
|
||||
}
|
||||
});
|
||||
state.closeBlock(node);
|
||||
state.inTable = false;
|
||||
},
|
||||
parse: {
|
||||
// handled by markdown-it, same as tiptap-markdown's default table spec
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -62,6 +62,58 @@ describe('renderMarkdown', () => {
|
||||
const html = renderMarkdown('line 1\nline 2');
|
||||
expect(html).toContain('<br>');
|
||||
});
|
||||
|
||||
it('should render a raw HTML table block (the table-with-a-list-in-a-cell fallback) instead of escaping it', () => {
|
||||
// crumbs#83: a bulleted list inside a table cell can't be expressed in
|
||||
// plain GFM, so the editor falls back to embedding the whole table as
|
||||
// raw HTML in the saved markdown. The preview/share renderer must
|
||||
// actually render that block, not show it as literal escaped text.
|
||||
const html = renderMarkdown(
|
||||
'<table><tbody><tr><td><ul><li>First</li><li>Second</li></ul></td><td>Plain cell</td></tr></tbody></table>'
|
||||
);
|
||||
expect(html).toContain('<table>');
|
||||
expect(html).toContain('<li>First</li>');
|
||||
expect(html).toContain('<li>Second</li>');
|
||||
expect(html).not.toContain('<table>');
|
||||
});
|
||||
|
||||
it('should strip a script tag from raw HTML content', () => {
|
||||
// Note content isn't only ever written through the Tiptap editor - the
|
||||
// MCP server and the notes API accept a raw markdown string directly,
|
||||
// so this renderer can't trust that any HTML it's asked to render is
|
||||
// safe just because rendering HTML at all is now allowed.
|
||||
const html = renderMarkdown('<p>hello</p><script>alert(1)</script><img src=x onerror="alert(2)">');
|
||||
expect(html).not.toContain('<script');
|
||||
expect(html).not.toContain('onerror');
|
||||
expect(html).toContain('hello');
|
||||
});
|
||||
|
||||
it('should still render task-list checkboxes after sanitization', () => {
|
||||
const html = renderMarkdown('[ ] todo\n\n[x] done');
|
||||
expect(html).toContain('type="checkbox"');
|
||||
expect(html).toContain('checked');
|
||||
expect(html).toContain('disabled');
|
||||
expect(html).toContain('class="task-checkbox"');
|
||||
});
|
||||
|
||||
it('should render an empty task item as a checkbox, not literal brackets', () => {
|
||||
const html = renderMarkdown('- [ ] Train tickets\n- [ ]');
|
||||
expect(html).not.toContain('[ ]');
|
||||
expect(html.match(/type="checkbox"/g)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should render an empty checked task item as a checked checkbox', () => {
|
||||
const html = renderMarkdown('- [x]');
|
||||
expect(html).not.toContain('[x]');
|
||||
expect(html).toContain('<input type="checkbox" checked disabled class="task-checkbox">');
|
||||
expect(html).toContain('class="task-list"');
|
||||
});
|
||||
|
||||
it('should not treat brackets glued to text as a task marker', () => {
|
||||
const html = renderMarkdown('- [ ]x');
|
||||
expect(html).not.toContain('type="checkbox"');
|
||||
expect(html).toContain('[ ]x');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripMarkdown', () => {
|
||||
|
||||
@@ -1,24 +1,49 @@
|
||||
import MarkdownIt from 'markdown-it';
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
|
||||
const md = new MarkdownIt({
|
||||
html: false,
|
||||
html: true,
|
||||
linkify: true,
|
||||
typographer: true,
|
||||
breaks: true
|
||||
});
|
||||
|
||||
// Raw HTML has to render (not just be escaped as visible text): a table with
|
||||
// a list in a cell can't be expressed in plain GFM, so the editor falls back
|
||||
// to embedding it as an HTML block (see tiptap/Table.ts). Note content also
|
||||
// isn't only ever written through that editor - the MCP server and the notes
|
||||
// API accept a raw markdown string directly - so any HTML this renders has to
|
||||
// be sanitized regardless of where it came from.
|
||||
const SANITIZE_OPTIONS: sanitizeHtml.IOptions = {
|
||||
allowedTags: [...sanitizeHtml.defaults.allowedTags, 'input'],
|
||||
allowedAttributes: {
|
||||
...sanitizeHtml.defaults.allowedAttributes,
|
||||
input: ['type', 'checked', 'disabled', 'class'],
|
||||
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
|
||||
// begin with.
|
||||
selfClosing: []
|
||||
};
|
||||
|
||||
// Add task list support
|
||||
md.core.ruler.after('inline', 'task-lists', (state) => {
|
||||
const tokens = state.tokens;
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
if (tokens[i].type !== 'inline') continue;
|
||||
const content = tokens[i].content;
|
||||
if (content.startsWith('[ ] ') || content.startsWith('[x] ')) {
|
||||
const checked = content.startsWith('[x] ');
|
||||
// markdown-it trims trailing whitespace from inline content, so an item
|
||||
// with no text after the marker arrives as exactly "[ ]" — match the
|
||||
// marker followed by a space or end-of-content.
|
||||
const marker = /^\[( |x)\](?: |$)/.exec(content);
|
||||
if (marker) {
|
||||
const checked = marker[1] === 'x';
|
||||
const checkbox = checked
|
||||
? '<input type="checkbox" checked disabled class="task-checkbox" /> '
|
||||
: '<input type="checkbox" disabled class="task-checkbox" /> ';
|
||||
tokens[i].content = content.slice(4);
|
||||
tokens[i].content = content.slice(marker[0].length);
|
||||
tokens[i].children = md.parseInline(tokens[i].content, state.env)[0].children;
|
||||
// Prepend checkbox
|
||||
const token = new state.Token('html_inline', '', 0);
|
||||
@@ -28,7 +53,7 @@ md.core.ruler.after('inline', 'task-lists', (state) => {
|
||||
// Add task-list class to parent <ul> (once)
|
||||
for (let j = i - 1; j >= 0; j--) {
|
||||
if (tokens[j].type === 'bullet_list_open') {
|
||||
const cls = tokens[j].attrGet('class') ?? '';
|
||||
const cls = String(tokens[j].attrGet('class') ?? '');
|
||||
if (!cls.includes('task-list')) {
|
||||
tokens[j].attrJoin('class', 'task-list');
|
||||
}
|
||||
@@ -46,7 +71,7 @@ md.core.ruler.after('task-lists', 'note-links', (state) => {
|
||||
const children = token.children;
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
if (children[i].type !== 'link_open') continue;
|
||||
const href = children[i].attrGet('href') ?? '';
|
||||
const href = String(children[i].attrGet('href') ?? '');
|
||||
if (!href.startsWith('crumb-note://')) continue;
|
||||
|
||||
let closeIndex = i + 1;
|
||||
@@ -58,8 +83,22 @@ md.core.ruler.after('task-lists', 'note-links', (state) => {
|
||||
}
|
||||
});
|
||||
|
||||
export function renderMarkdown(content: string): string {
|
||||
return md.render(content);
|
||||
// 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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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('');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { test as setup } from '@playwright/test';
|
||||
import { setupAndLogin } from './helpers/fixtures';
|
||||
import { STORAGE_STATE_FILE } from './global-setup';
|
||||
|
||||
/**
|
||||
* Log in as the admin once and persist the session cookie. The `app` project
|
||||
* loads this storage state into every browser context, so tests start already
|
||||
* authenticated instead of each one submitting the login form (~0.8s per test,
|
||||
* roughly half of a typical test's duration).
|
||||
*
|
||||
* The admin's own session is never revoked by any test: admin-users only
|
||||
* resets/revokes victim accounts, and auth.spec's logout scenario runs in the
|
||||
* auth-setup project with its own context before this setup executes.
|
||||
*/
|
||||
setup('authenticate as admin and save storage state', async ({ page }) => {
|
||||
await setupAndLogin(page);
|
||||
await page.context().storageState({ path: STORAGE_STATE_FILE });
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect, createNote } from './helpers/fixtures.js';
|
||||
import { test, expect, createNote, noteCard } from './helpers/fixtures.js';
|
||||
|
||||
test.describe.serial('Dark mode', () => {
|
||||
test('Scenario: Theme toggle persists selection across page reload', async ({
|
||||
@@ -110,4 +110,38 @@ test.describe.serial('Dark mode', () => {
|
||||
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
});
|
||||
|
||||
test('Scenario: Bold text in notes uses the theme text colour in dark mode', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
// Given a note with bold content exists
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await createNote(page, 'Bold Contrast Note', 'Plain then **bold words** here');
|
||||
|
||||
// And dark theme is active
|
||||
await page.goto('/settings/preferences');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.getByTestId('pref-theme-dark').click();
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
||||
|
||||
// When viewing the note on the notes page
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
const preview = noteCard(page, 'Bold Contrast Note').getByTestId('note-content-preview');
|
||||
const boldInCard = preview.locator('strong');
|
||||
await expect(boldInCard).toHaveText('bold words');
|
||||
|
||||
// Then the bold text is rendered in the dark theme's text colour (--text = #ece3d3),
|
||||
// not the typography plugin's hardcoded near-black
|
||||
await expect(boldInCard).toHaveCSS('color', 'rgb(236, 227, 211)');
|
||||
|
||||
// And the same holds inside the editor
|
||||
await noteCard(page, 'Bold Contrast Note').click();
|
||||
const boldInEditor = page.getByTestId('tiptap-editor').locator('.tiptap strong');
|
||||
await expect(boldInEditor).toHaveText('bold words');
|
||||
await expect(boldInEditor).toHaveCSS('color', 'rgb(236, 227, 211)');
|
||||
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,8 @@ import { writeFileSync } from 'fs';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
export const TEST_CREDENTIALS_FILE = './data/test-credentials.json';
|
||||
/** Admin session cookie captured by auth.setup.ts and shared by the `app` project. */
|
||||
export const STORAGE_STATE_FILE = './data/test-storage-state.json';
|
||||
|
||||
export default function globalSetup() {
|
||||
// The test database is wiped before the webServer starts (see the
|
||||
|
||||
@@ -9,6 +9,10 @@ import { setupAndLogin } from './helpers/fixtures.js';
|
||||
* 3. Test tool invocations via JSON-RPC over Streamable HTTP
|
||||
*/
|
||||
|
||||
// Start unauthenticated: the "rejects requests without API key" scenario must
|
||||
// not carry the shared admin session cookie, and the apiKey fixture logs in itself.
|
||||
base.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
const test = base.extend<{ apiKey: string }>({
|
||||
apiKey: async ({ page }, use) => {
|
||||
await setupAndLogin(page);
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// These scenarios describe what an anonymous visitor receives, so drop the
|
||||
// shared admin session the `app` project injects.
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test.describe('PWA', () => {
|
||||
test('Scenario: Web manifest provides correct app metadata', async ({ page }) => {
|
||||
// When the browser requests the web manifest
|
||||
|
||||
+1
-1
@@ -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.34.0</span>
|
||||
<span class="footer-version">v0.34.2</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user