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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ Run all commands from the repo root (`package.json` there wraps both Angular and

- `cargo test` from `src-tauri/` — unit tests are inline `#[cfg(test)] mod tests` blocks at the bottom of the file they cover; `src-tauri/tests/` holds the integration binaries (`notes`, `spaces`, `folders`, `transfer`, `ipc_contract`), which see only the crate's public API. No extra setup: they run against an in-memory SQLite database.

- `npm run test:scripts` — `node --test` on the release-notes generator. Node's own runner: no dependency, no config, and it cannot be swept up by the Angular builder, which only sees `src/**/*.spec.ts`. ⚠️ The test **files are named one by one**, not discovered from `scripts/`: Node 24 (what `.nvmrc` pins, and what CI installs) does not expand a bare directory positional the way newer versions do — it tries to load it as the entry module and dies on `MODULE_NOT_FOUND`. A new test file has to be added to the script.
- `npm run test:scripts` — `node --test` on the release-notes generator and on the palette's contrast ratios. Node's own runner: no dependency, no config, and it cannot be swept up by the Angular builder, which only sees `src/**/*.spec.ts` — which is also the point, since both of these read a **shipped file off disk** and the builder compiles for a browser. ⚠️ The test **files are named one by one**, not discovered from `scripts/`: Node 24 (what `.nvmrc` pins, and what CI installs) does not expand a bare directory positional the way newer versions do — it tries to load it as the entry module and dies on `MODULE_NOT_FOUND`. A new test file has to be added to the script.

- **Releasing is a `workflow_dispatch`.** Bump the version in `src-tauri/Cargo.toml`, `package.json`, both lockfiles, merge to `main`, then Actions → Release, `dry_run` first. `release.yml` writes the changelog section, commits, tags, builds and publishes in one run. See `docs/architecture.md` → "Releasing".

Expand Down Expand Up @@ -126,7 +126,7 @@ These are the non-obvious constraints; the rest of the architecture is in `docs/
- **A shortcut is captured, not typed.** `acceleratorFromEvent` reads `KeyboardEvent.code` (the physical key, so a combination set on AZERTY stays put on QWERTY) and refuses a keystroke with no modifier — a _global_ shortcut without one would swallow that key in every application on the machine. That refusal is also what leaves Tab and Escape working inside the field.
- **`NotesQuery.pinnedFirst` hoists pinned notes in both shapes of view**: their own section when the view is chronological, the head of the list when it is flat. The canvas always sends `true` (so its search results now show pinned first, which they did not before); the quick-paste palette is the only caller that ever sends `false`.
- **A global variable is a _proposed_ value, never a typed one.** `global_placeholders` (migration 7) holds `{{field}}` values for the whole corpus; `notes::placeholder::resolve` layers them as **note value → global → default written in the text**. They reach a card as the field's `default_value` (`model::apply_global_defaults`, a pass of its own like the attachment counter), so the editor shows them in grey — copying one into `value` would let `set_placeholder_values` freeze it the day the variable changes. Consequence: `fill_placeholders` now reads the database and returns a `Result`.
- **The light theme has its own amber.** `:root` stays the dark palette and `:root[data-theme='light']` redefines the colours only — dark is the base because the preference lives in a file nothing can read before Angular boots, and any other order would flash white at launch. `--amber` (#e8a33d) falls to 2:1 on white, so the light block carries a darker hue and flips `--amber-ink` with it. Density is four variables and nothing else (`--space-card`, `--space-grid`, `--space-section`, `--space-canvas`): shrinking the type would be a zoom, not a density.
- **The light theme has its own amber.** `:root` stays the dark palette and `:root[data-theme='light']` redefines the colours only — dark is the base because the preference lives in a file nothing can read before Angular boots, and any other order would flash white at launch. `--amber` (#e8a33d) falls to 2:1 on white, so the light block carries a darker hue and flips `--amber-ink` with it. ⚠️ **Every colour drawn as text clears 4.5:1 on all four surfaces, and `scripts/palette.test.mjs` holds it** — a `node --test` file and not a `*.spec.ts`, because it reads the shipped stylesheet and the Angular builder compiles its specs for a browser, where neither `node:fs` nor `?raw` works. A colour added to the palette fails that file until it is classified as text or not. What it deliberately does **not** cover is `tint-badge`, which draws a hue on a tint of itself and loses about a point (#191). Density is four variables and nothing else (`--space-card`, `--space-grid`, `--space-section`, `--space-canvas`): shrinking the type would be a zoom, not a density.
- **A rule lives on one side of the bridge.** A todo list's Markdown is `notes::checklist::to_markdown`'s, and reaches the front as `DisplayNote.copy_text` — `Some(markdown)` for a checklist, `None` for a snippet whose `content` is already on the wire. The front end held a second copy of the `- [x] ` syntax. `isVariableName` (`core/model/variable.model.ts`) still refuses a bad `{{field}}` name before saving — the back end refuses it either way, this only says so before a row vanishes silently — but it no longer carries a copy of the rule: it builds its `RegExp` from `FIELD_NAME_PATTERN`, which crosses from `notes::placeholder`. ⚠️ Rust checks characters rather than matching a regex, so the pattern and `is_field_name` are held together by `the_pattern_and_the_rule_agree`, and by nothing else.
- **A todo list has items, not a body.** `notes.kind` (`snippet` / `checklist`) picks the shape and `note_items` holds the list, keyed `(note_id, position)` — the position _is_ the identity, so every write replaces the whole list, exactly like `note_tags`. Both fields carry `#[serde(default)]`: `transfer::Bundle` deserialises `Note` itself, and a required key would make every export file written before todo lists unreadable. Three places would go quietly wrong without a thought for the kind: `matches_search` also scans item texts (a checklist has no `content` to find it by), `transfer::to_markdown` emits `- [x] …` instead of an empty fenced block, and language detection is skipped — there is no body to guess from.
- **⚠️ HTML5 drag & drop does not work in this WebView.** `dragDropEnabled` defaults to `true` and `core/services/window/file-drop.service.ts` depends on it for attachments, so the WebView never sees `dragstart` / `drop`. Reordering checklist items is written in **pointer events** (`pointerdown` + `setPointerCapture` + `pointermove` + `pointerup`), doubled by `Alt+↑/↓` — which the linter requires anyway (`click-events-have-key-events`). Turning the flag off to get the DOM events back would break the file drop.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"test": "ng test",
"test:watch": "ng test --watch",
"test:coverage": "ng test --coverage",
"test:scripts": "node --test scripts/release-notes.test.mjs",
"test:scripts": "node --test scripts/release-notes.test.mjs scripts/palette.test.mjs",
"build:e2e": "ng build --configuration e2e",
"e2e:build": "tauri build --debug --no-bundle --features e2e --config src-tauri/tauri.e2e.conf.json",
"test:e2e": "tsx e2e/reset-profile.ts && wdio run e2e/wdio.conf.ts",
Expand Down
112 changes: 112 additions & 0 deletions scripts/palette.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { describe, it } from 'node:test';

/**
* ⚠️ `node --test` and not a `*.spec.ts`, for the same reason the release-notes tests are
* here: this reads a shipped file off disk, and the Angular builder compiles its specs for
* a browser, where `node:fs` does not exist and `?raw` has no loader. Both were tried.
*/
const STYLESHEET = readFileSync('src/styles/styles.scss', 'utf8');

/** WCAG 2.2 AA for text below 18.66px, which is what every one of these is drawn at. */
const AA = 4.5;

/**
* The four plain surfaces. ⚠️ **Not** every background text lands on: the tint-badge mixin
* draws a hue on an 8–12% tint of itself, which moves the background toward the text and
* costs about a point of contrast. Those composites are measured in #191, not here.
*/
const SURFACES = ['--bg-0', '--bg-1', '--bg-2', '--bg-3'];

/** Drawn as text somewhere, so each has to clear AA on every plain surface above. */
const TEXT = ['--text-0', '--text-1', '--text-2', '--amber', '--green', '--blue', '--red', '--purple'];

/**
* Never text: hairlines, the dimmed accent behind a ring, the ink drawn *on* the accent,
* and the titlebar's decorative dots. ⚠️ Listed rather than skipped, so a colour added to
* the palette fails the last test here until somebody says which of the three it is.
*/
const NOT_TEXT = [
'--line',
'--line-soft',
'--line-no',
'--amber-dim',
'--amber-ink',
'--dot-red',
'--dot-yellow',
'--dot-green',
];

/** The dark palette is the bare `:root`, deliberately — see the comment above it. */
function block(theme) {
const opening = theme === 'dark' ? ':root {' : ":root[data-theme='light'] {";
const start = STYLESHEET.indexOf(opening);
assert.ok(start >= 0, `no ${theme} block in styles.scss`);

const body = STYLESHEET.slice(start + opening.length, STYLESHEET.indexOf('\n}', start));
return Object.fromEntries(
[...body.matchAll(/(--[a-z0-9-]+):\s*(#[0-9a-f]{6})\b/g)].map((m) => [m[1], m[2]]),
);
}

function luminance(hex) {
const value = Number.parseInt(hex.slice(1), 16);
const channels = [(value >> 16) & 255, (value >> 8) & 255, value & 255].map((raw) => {
const part = raw / 255;
return part <= 0.03928 ? part / 12.92 : ((part + 0.055) / 1.055) ** 2.4;
});

return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
}

function contrast(a, b) {
const [lighter, darker] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (lighter + 0.05) / (darker + 0.05);
}

for (const theme of ['dark', 'light']) {
describe(`the ${theme} palette`, () => {
const declared = block(theme);
// ⚠️ The light block redefines only what changes, so what it does not name it inherits
// from the dark one — reading it from the wrong block would test a colour twice and
// check another never.
const of = (name) => declared[name] ?? block('dark')[name];

it('draws every text colour legibly on every plain surface', () => {
for (const name of TEXT) {
for (const surface of SURFACES) {
const ratio = contrast(of(name), of(surface));

assert.ok(
ratio >= AA,
`${name} (${of(name)}) on ${surface} (${of(surface)}) is ${ratio.toFixed(2)}:1, AA asks ${AA}:1`,
);
}
}
});

/** The one pairing that is not text on a surface: the label inside the amber fill. */
it('draws the ink legibly on solid amber', () => {
const ratio = contrast(of('--amber-ink'), of('--amber'));

assert.ok(ratio >= AA, `the ink on amber is ${ratio.toFixed(2)}:1`);
});

/**
* ⚠️ What stops the two lists above going quietly out of date. `--text-2` was under AA
* on all four surfaces for as long as it existed, across 129 declarations, and nothing
* anywhere said so.
*/
it('has a verdict on every colour it declares', () => {
const classified = new Set([...SURFACES, ...TEXT, ...NOT_TEXT]);
const unclassified = Object.keys(declared).filter((name) => !classified.has(name));

assert.deepEqual(
unclassified,
[],
'each of these is text or it is not — say which in palette.test.mjs',
);
});
});
}
16 changes: 8 additions & 8 deletions src/styles/styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,21 @@
--line-soft: #282c37;
--text-0: #eef0f4;
--text-1: #aab0c0;
--text-2: #6e7488;
--text-2: #9298ac;
--amber: #e8a33d;
--amber-dim: #8a662a;
--amber-ink: #1a1508; // text on solid amber (the "new note" button, say)
--green: #6fbf8b;
--blue: #6f9bd1;
--red: #d1706f;
--red: #dc8281;
--purple: #a98fd1;

// RGB triplets of the colours above, so no hex is duplicated behind an opacity.
--text-1-rgb: 170, 176, 192;
--amber-rgb: 232, 163, 61;
--green-rgb: 111, 191, 139;
--blue-rgb: 111, 155, 209;
--red-rgb: 209, 111, 111;
--red-rgb: 220, 130, 129;
--purple-rgb: 169, 143, 209;
--black-rgb: 0, 0, 0;
--backdrop-rgb: 10, 11, 14;
Expand Down Expand Up @@ -77,18 +77,18 @@
--line-soft: #e1e5ec;
--text-0: #1a1d24;
--text-1: #4a5163;
--text-2: #767d90;
--amber: #9c6410;
--text-2: #636a7c;
--amber: #8f5b0d;
--amber-dim: #d9b070;
--amber-ink: #fff8ec;
--green: #2c7a4b;
--green: #297044;
--blue: #2a5d99;
--red: #b03a39;
--purple: #6a4aa6;

--text-1-rgb: 74, 81, 99;
--amber-rgb: 156, 100, 16;
--green-rgb: 44, 122, 75;
--amber-rgb: 143, 91, 13;
--green-rgb: 41, 112, 68;
--blue-rgb: 42, 93, 153;
--red-rgb: 176, 58, 57;
--purple-rgb: 106, 74, 166;
Expand Down