acuvo-code 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +328 -0
- package/ENTERPRISE.md +927 -0
- package/LICENSE +120 -0
- package/README.md +1245 -0
- package/ROADMAP.md +556 -0
- package/bin/acuvo-mcp.mjs +208 -0
- package/bin/acuvo.mjs +3108 -0
- package/lib/acceptance-consent.mjs +168 -0
- package/lib/acceptance.mjs +859 -0
- package/lib/account.mjs +226 -0
- package/lib/acuvo-dir.mjs +72 -0
- package/lib/acuvo-models.mjs +141 -0
- package/lib/apply-patch.mjs +570 -0
- package/lib/ask-user.mjs +173 -0
- package/lib/audit.mjs +530 -0
- package/lib/auto-lease.mjs +174 -0
- package/lib/background.mjs +842 -0
- package/lib/best-of.mjs +334 -0
- package/lib/board.mjs +232 -0
- package/lib/breaker.mjs +93 -0
- package/lib/budget.mjs +1375 -0
- package/lib/builtin-skills.mjs +135 -0
- package/lib/cache-floor.mjs +204 -0
- package/lib/chain.mjs +303 -0
- package/lib/changed-paths.mjs +84 -0
- package/lib/chat.mjs +434 -0
- package/lib/checkpoint.mjs +637 -0
- package/lib/child-lifetime.mjs +71 -0
- package/lib/cli-args.mjs +1255 -0
- package/lib/code-review.mjs +1382 -0
- package/lib/colour.mjs +82 -0
- package/lib/command.mjs +2847 -0
- package/lib/compact.mjs +1151 -0
- package/lib/completion.mjs +515 -0
- package/lib/creative-engines.mjs +779 -0
- package/lib/db-inspect.mjs +1624 -0
- package/lib/delete.mjs +111 -0
- package/lib/design-loop.mjs +570 -0
- package/lib/diff-preview.mjs +1044 -0
- package/lib/doctor.mjs +2139 -0
- package/lib/dropped.mjs +216 -0
- package/lib/edit-diagnostics.mjs +277 -0
- package/lib/edit.mjs +460 -0
- package/lib/env-file.mjs +250 -0
- package/lib/escalate.mjs +702 -0
- package/lib/evaluate.mjs +284 -0
- package/lib/fetch-text.mjs +952 -0
- package/lib/fleet-budget.mjs +256 -0
- package/lib/gh.mjs +1536 -0
- package/lib/git.mjs +1341 -0
- package/lib/github.mjs +261 -0
- package/lib/h2.mjs +194 -0
- package/lib/handoff.mjs +417 -0
- package/lib/hooks.mjs +626 -0
- package/lib/http-probe.mjs +907 -0
- package/lib/image-director.mjs +322 -0
- package/lib/image-edit.mjs +522 -0
- package/lib/imagegen.mjs +998 -0
- package/lib/interrupt.mjs +234 -0
- package/lib/learned.mjs +353 -0
- package/lib/lease-watch.mjs +115 -0
- package/lib/lease.mjs +868 -0
- package/lib/localize.mjs +834 -0
- package/lib/log-tail.mjs +1052 -0
- package/lib/login.mjs +157 -0
- package/lib/lsp.mjs +1613 -0
- package/lib/mcp-consent.mjs +377 -0
- package/lib/mcp-defaults.mjs +780 -0
- package/lib/mcp-server.mjs +1343 -0
- package/lib/mcp.mjs +1263 -0
- package/lib/media.mjs +1283 -0
- package/lib/memory-workspace.mjs +179 -0
- package/lib/model-json.mjs +132 -0
- package/lib/model-tier.mjs +171 -0
- package/lib/model.mjs +1445 -0
- package/lib/parallel.mjs +144 -0
- package/lib/perchance.mjs +210 -0
- package/lib/plan-coherence.mjs +1461 -0
- package/lib/plan-ledger.mjs +981 -0
- package/lib/plan.mjs +461 -0
- package/lib/policy.mjs +783 -0
- package/lib/prefix-order.mjs +38 -0
- package/lib/project-memory.mjs +127 -0
- package/lib/prompt.mjs +109 -0
- package/lib/python.mjs +862 -0
- package/lib/rcfile.mjs +853 -0
- package/lib/read-window.mjs +743 -0
- package/lib/refute-tools.mjs +34 -0
- package/lib/refute.mjs +806 -0
- package/lib/repl-driver.mjs +264 -0
- package/lib/repl.mjs +324 -0
- package/lib/replay.mjs +1218 -0
- package/lib/repo-map.mjs +1101 -0
- package/lib/report.mjs +419 -0
- package/lib/search-rank.mjs +99 -0
- package/lib/search.mjs +659 -0
- package/lib/secret-paths.mjs +54 -0
- package/lib/session.mjs +1017 -0
- package/lib/skills.mjs +703 -0
- package/lib/slash.mjs +356 -0
- package/lib/spawn-argv.mjs +1151 -0
- package/lib/spend.mjs +250 -0
- package/lib/steer.mjs +280 -0
- package/lib/stream.mjs +253 -0
- package/lib/stuck.mjs +712 -0
- package/lib/subagent.mjs +749 -0
- package/lib/terminal-graphics.mjs +171 -0
- package/lib/tool-prefix.mjs +226 -0
- package/lib/tool-shortlist.mjs +162 -0
- package/lib/tools.mjs +2333 -0
- package/lib/tsserver.mjs +423 -0
- package/lib/turn.mjs +5672 -0
- package/lib/untrusted-block.mjs +271 -0
- package/lib/verify-claim.mjs +299 -0
- package/lib/vision.mjs +330 -0
- package/lib/voice-task.mjs +561 -0
- package/lib/warm-provider.mjs +255 -0
- package/lib/websearch.mjs +401 -0
- package/lib/workspace.mjs +928 -0
- package/lib/write-approval.mjs +235 -0
- package/lib/write-many.mjs +162 -0
- package/package.json +62 -0
- package/scripts/bundle.mjs +768 -0
- package/scripts/cache-floor.mjs +176 -0
- package/scripts/machine.mjs +226 -0
- package/scripts/test.mjs +139 -0
- package/skills/accessibility.md +87 -0
- package/skills/acuvo-design-system.md +123 -0
- package/skills/animation.md +84 -0
- package/skills/api-design.md +82 -0
- package/skills/auth-and-sessions.md +78 -0
- package/skills/build-with-a-framework.md +101 -0
- package/skills/colour-and-contrast.md +112 -0
- package/skills/creative-engines.md +81 -0
- package/skills/css-layout.md +85 -0
- package/skills/data-and-charts.md +77 -0
- package/skills/debugging.md +76 -0
- package/skills/designing-by-looking.md +84 -0
- package/skills/error-handling.md +78 -0
- package/skills/forms-and-validation.md +93 -0
- package/skills/nextjs-app-router.md +75 -0
- package/skills/page-composition.md +103 -0
- package/skills/performance.md +77 -0
- package/skills/plan-before-building.md +52 -0
- package/skills/planning-and-delegating.md +72 -0
- package/skills/refactoring.md +70 -0
- package/skills/security-basics.md +76 -0
- package/skills/state-management.md +73 -0
- package/skills/supabase-multitenant.md +72 -0
- package/skills/typescript-strict.md +90 -0
- package/skills/typography.md +135 -0
- package/skills/verify-your-own-work.md +62 -0
- package/skills/web-app-quality.md +62 -0
- package/skills/working-in-the-background.md +64 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ── ⭐⭐ MEASURE THE CACHE FLOOR. IT COSTS $0.00 AND IT IS THE MARGIN. ───────
|
|
3
|
+
*
|
|
4
|
+
* node scripts/cache-floor.mjs [repoA] [repoB] ...
|
|
5
|
+
*
|
|
6
|
+
* `PRICING.md` §5 states `cache floor = sharedHead ÷ typicalPrompt` and closes
|
|
7
|
+
* with the open item *"measure the achieved cache floor"*. This is that
|
|
8
|
+
* measurement, and it runs offline: the model is SCRIPTED, so what is being read
|
|
9
|
+
* is the bytes WE send — the half of the cache contract we control, and the only
|
|
10
|
+
* half that can be checked on every commit without a key or a bill.
|
|
11
|
+
*
|
|
12
|
+
* It prints two things a live `acuvo --json` reading cannot tell apart on its
|
|
13
|
+
* own:
|
|
14
|
+
*
|
|
15
|
+
* 1. **the floor across tenants** — how much of a brand-new user's very first
|
|
16
|
+
* request the provider has already seen, because some other tenant sent the
|
|
17
|
+
* same head. This is the number the ladder is sized on.
|
|
18
|
+
* 2. **the floor across configurations** — what one machine's `--no-run`, TTY
|
|
19
|
+
* or `MODAL_*` setting does to that head. Two tenants whose configs differ
|
|
20
|
+
* are two cache pools, however perfect the prefix is inside each.
|
|
21
|
+
*
|
|
22
|
+
* ⚠️ IT DOES NOT MEASURE WHAT THE PROVIDER DID. A perfect prefix still misses if
|
|
23
|
+
* the request lands on a cold upstream — `lib/model.mjs` measured 46.7% unpinned
|
|
24
|
+
* against 95.8% pinned on the same bytes. That reading comes from
|
|
25
|
+
* `acuvo --json`'s `.cache` block against a real key. ⭐ The point of this
|
|
26
|
+
* script is that it makes that reading INTERPRETABLE: a live 0% beside a
|
|
27
|
+
* measured 99.9% prefix is a routing finding, and a live 0% beside a measured
|
|
28
|
+
* 4% prefix is ours.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs';
|
|
32
|
+
import { tmpdir } from 'node:os';
|
|
33
|
+
import { join, basename } from 'node:path';
|
|
34
|
+
|
|
35
|
+
import { runSession } from '../lib/turn.mjs';
|
|
36
|
+
import { createLocalExecutor } from '../lib/workspace.mjs';
|
|
37
|
+
import { toolNamesForRounds, toolSchemasFor } from '../lib/tools.mjs';
|
|
38
|
+
import { orderForCachePrefix } from '../lib/tool-prefix.mjs';
|
|
39
|
+
import { sharedPrefixBytes, describeDivergence, wireBytes } from '../lib/cache-floor.mjs';
|
|
40
|
+
|
|
41
|
+
const ROUNDS = 8;
|
|
42
|
+
const made = [];
|
|
43
|
+
|
|
44
|
+
/** A throwaway repository, so the script has something to measure with no arguments. */
|
|
45
|
+
function scratchRepo(name, files) {
|
|
46
|
+
const root = mkdtempSync(join(tmpdir(), `acuvo-floor-${name}-`));
|
|
47
|
+
made.push(root);
|
|
48
|
+
writeFileSync(join(root, 'package.json'), `{"name":"${name}","version":"1.0.0"}\n`);
|
|
49
|
+
mkdirSync(join(root, 'src'), { recursive: true });
|
|
50
|
+
for (let i = 0; i < files; i += 1) {
|
|
51
|
+
writeFileSync(join(root, 'src', `mod${i}.js`), `export function fn${i}(x) {\n return x + ${i};\n}\n`);
|
|
52
|
+
}
|
|
53
|
+
return root;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Round 1 of a FRESH invocation, which is the round the floor is a claim about.
|
|
58
|
+
* ⚠️ Rounds 2+ are a different measurement — they append tool results the
|
|
59
|
+
* provider has by definition never seen, so their miss is arithmetic.
|
|
60
|
+
*/
|
|
61
|
+
async function firstRequest(root, task) {
|
|
62
|
+
let captured = null;
|
|
63
|
+
const callModelImpl = async (opts) => {
|
|
64
|
+
if (!captured) captured = { tools: opts.tools ?? null, messages: opts.messages };
|
|
65
|
+
return {
|
|
66
|
+
ok: true, content: 'done', toolCalls: [],
|
|
67
|
+
usage: { cost: 0, total_tokens: 0 }, finishReason: 'stop', model: 'scripted/none',
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
await runSession({
|
|
71
|
+
task,
|
|
72
|
+
executor: createLocalExecutor(root),
|
|
73
|
+
config: { apiKey: 'scripted', model: 'scripted/none' },
|
|
74
|
+
maxRounds: ROUNDS,
|
|
75
|
+
allowRun: false,
|
|
76
|
+
callModelImpl,
|
|
77
|
+
onEvent: () => {},
|
|
78
|
+
});
|
|
79
|
+
return captured;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const kb = (n) => `${(n / 1024).toFixed(1)}k`;
|
|
83
|
+
const pct = (n) => (n === null ? ' — ' : `${(n * 100).toFixed(1)}%`);
|
|
84
|
+
|
|
85
|
+
const args = process.argv.slice(2).filter((p) => existsSync(p));
|
|
86
|
+
const roots = args.length >= 2
|
|
87
|
+
? args.map((p) => [p, `describe what ${basename(p)} does`])
|
|
88
|
+
: [
|
|
89
|
+
[scratchRepo('alpha', 30), 'add a test for src/mod3.js'],
|
|
90
|
+
[scratchRepo('beta', 60), 'rename fn7 to seven and update the callers'],
|
|
91
|
+
[scratchRepo('gamma', 12), 'write a README'],
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
console.log('── 1. THE FLOOR ACROSS TENANTS ─────────────────────────────────────');
|
|
95
|
+
console.log(' round 1 of a fresh invocation, one scripted round, $0.00\n');
|
|
96
|
+
|
|
97
|
+
const caps = [];
|
|
98
|
+
for (const [root, task] of roots) {
|
|
99
|
+
const cap = await firstRequest(root, task);
|
|
100
|
+
if (!cap) { console.log(` ${root}: no request was made`); continue; }
|
|
101
|
+
const bytes = wireBytes(cap);
|
|
102
|
+
caps.push({ root, bytes, cap });
|
|
103
|
+
console.log(
|
|
104
|
+
` ${basename(root).padEnd(28)} prompt ${kb(bytes.length).padStart(7)}`
|
|
105
|
+
+ ` tools ${kb(JSON.stringify(cap.tools).length).padStart(7)} (${cap.tools.length})`
|
|
106
|
+
+ ` system ${kb(String(cap.messages[0]?.content ?? '').length).padStart(7)}`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
console.log('\n pair shared head floor');
|
|
111
|
+
let worst = null;
|
|
112
|
+
for (let i = 0; i < caps.length; i += 1) {
|
|
113
|
+
for (let j = i + 1; j < caps.length; j += 1) {
|
|
114
|
+
const d = describeDivergence(caps[i].bytes, caps[j].bytes);
|
|
115
|
+
const label = `${basename(caps[i].root)} vs ${basename(caps[j].root)}`;
|
|
116
|
+
console.log(` ${label.padEnd(52)} ${kb(d.sharedPrefix).padStart(8)} ${pct(d.floor)}`);
|
|
117
|
+
if (worst === null || d.floor < worst.floor) worst = { ...d, label };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (worst) {
|
|
121
|
+
console.log(`\n ⚠️ the head ends at byte ${worst.at} (${worst.label}), where the two requests read:`);
|
|
122
|
+
console.log(` A …${JSON.stringify(worst.aroundA.slice(100, 200))}`);
|
|
123
|
+
console.log(` B …${JSON.stringify(worst.aroundB.slice(100, 200))}`);
|
|
124
|
+
/**
|
|
125
|
+
* ⚠️ THE TWO NUMBERS ARE PRINTED WHETHER OR NOT THEY DISAGREE, and the reading
|
|
126
|
+
* says which case this is. Printing them only when they diverge would teach a
|
|
127
|
+
* reader that "shared" and "cacheable" are normally the same word, which is
|
|
128
|
+
* the exact confusion that turns a 98.9%-shared / 0%-cached probe into a
|
|
129
|
+
* hunt for a prefix bug that is not there.
|
|
130
|
+
*/
|
|
131
|
+
const drift = (worst.sharedFraction ?? 0) - (worst.floor ?? 0);
|
|
132
|
+
console.log(`\n shared bytes ${pct(worst.sharedFraction)} · cacheable prefix ${pct(worst.floor)} — `
|
|
133
|
+
+ (drift > 0.02
|
|
134
|
+
? '⚠️ SHARED BYTES ARE NOT A SHARED PREFIX. What they have in common is behind the divergence, so none of it bills as cache.'
|
|
135
|
+
: 'healthy: what they share is at the front, so the cache can reach all of it.'));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (args.length < 2) {
|
|
139
|
+
console.log('\n ⚠️ THESE ARE SCRATCH REPOSITORIES AND THEY FLATTER THE FLOOR. A repo map is');
|
|
140
|
+
console.log(' the per-tenant half of the prompt, and a 30-file scratch repo has almost none');
|
|
141
|
+
console.log(' of one. Measured on three REAL repositories in this tree the same day, the head');
|
|
142
|
+
console.log(' was 22.9k against a 41.9k prompt — a 54.6% floor. Pass real paths:');
|
|
143
|
+
console.log(' node scripts/cache-floor.mjs ../acuvo-code ../acuvo-gateway ../slopscore');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
console.log('\n── 2. THE FLOOR ACROSS CONFIGURATIONS ──────────────────────────────');
|
|
147
|
+
console.log(' one machine\'s settings against another\'s, tools block only\n');
|
|
148
|
+
|
|
149
|
+
const probeRoot = caps[0]?.root ?? roots[0][0];
|
|
150
|
+
const variants = [
|
|
151
|
+
['reference (bare machine)', { allowRun: true, env: {}, root: probeRoot }],
|
|
152
|
+
['--no-run', { allowRun: false, env: {}, root: probeRoot }],
|
|
153
|
+
['ACUVO_ALLOW_PUSH=1', { allowRun: true, env: { ACUVO_ALLOW_PUSH: '1' }, root: probeRoot }],
|
|
154
|
+
['MODAL_TTS_URL set', { allowRun: true, env: { MODAL_TTS_URL: 'https://x' }, root: probeRoot }],
|
|
155
|
+
['stdin is a TTY (ask_user)', { allowRun: true, env: {}, root: probeRoot, interactive: true }],
|
|
156
|
+
['running as a subagent', { allowRun: true, env: {}, root: probeRoot, subagent: true }],
|
|
157
|
+
];
|
|
158
|
+
|
|
159
|
+
const block = (opts, ordered) => {
|
|
160
|
+
const picked = toolSchemasFor(toolNamesForRounds(ROUNDS, opts));
|
|
161
|
+
return JSON.stringify(ordered ? orderForCachePrefix(picked, { maxRounds: ROUNDS }) : picked);
|
|
162
|
+
};
|
|
163
|
+
const refOpts = variants[0][1];
|
|
164
|
+
|
|
165
|
+
console.log(' difference from the reference registry order cache order');
|
|
166
|
+
for (const [label, opts] of variants) {
|
|
167
|
+
const before = sharedPrefixBytes(block(refOpts, false), block(opts, false));
|
|
168
|
+
const after = sharedPrefixBytes(block(refOpts, true), block(opts, true));
|
|
169
|
+
const gain = before > 0 ? ` (${(after / before).toFixed(1)}x)` : '';
|
|
170
|
+
console.log(` ${label.padEnd(34)} ${String(before).padStart(9)} B ${String(after).padStart(9)} B${gain}`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
console.log('\n ⭐ the cache order is a PERMUTATION — every tool above is still offered.');
|
|
174
|
+
console.log(' ⚠️ what a provider then did with these bytes is `acuvo --json` .cache, not this.\n');
|
|
175
|
+
|
|
176
|
+
for (const d of made) { try { rmSync(d, { recursive: true, force: true }); } catch { /* */ } }
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* ── ⚠️⚠️ THIS EXISTS BECAUSE A BENCHMARK RUN MADE THE OWNER'S LAPTOP UNUSABLE ─
|
|
4
|
+
*
|
|
5
|
+
* 2026-08-12, verbatim: *"we have to figure out a way to stop hammering my
|
|
6
|
+
* laptop dude, it's fucked. I cannot even watch Netflix, I can't even change
|
|
7
|
+
* tabs."* A Terminal-Bench run had twenty Docker containers on an 8-core machine
|
|
8
|
+
* with no `.wslconfig`, so WSL2 — which is what Docker Desktop runs on — helped
|
|
9
|
+
* itself to every logical processor and most of the RAM.
|
|
10
|
+
*
|
|
11
|
+
* ⭐ AND THE INSTRUCTION WAS NOT "DO LESS WORK." It was *"that doesn't mean
|
|
12
|
+
* workload goes lower, we need to be able to control my laptop."* So this is
|
|
13
|
+
* about a CEILING and a KILL SWITCH, never about scaling ambition down. Heavy
|
|
14
|
+
* work runs flat out inside a box it cannot climb out of.
|
|
15
|
+
*
|
|
16
|
+
* ── THE THREE CONTROLS, IN ORDER OF HOW MUCH THEY MATTER ────────────────────
|
|
17
|
+
*
|
|
18
|
+
* 1. `~/.wslconfig` — the real one. Caps the VM at 4 of 8 processors and 6GB.
|
|
19
|
+
* Without it there is no limit at all, which is the state this machine was
|
|
20
|
+
* in. ⚠️ Needs `wsl --shutdown` to take effect; editing it does nothing to
|
|
21
|
+
* a VM already running.
|
|
22
|
+
* 2. Priority — our long jobs run BELOW NORMAL, so the desktop always wins a
|
|
23
|
+
* contested core. A build that takes 10% longer and never stutters the UI
|
|
24
|
+
* is the correct trade on somebody's personal machine.
|
|
25
|
+
* 3. This script — `status` to see what we are costing, `stop` to end all of
|
|
26
|
+
* it in one command, without hunting pids.
|
|
27
|
+
*
|
|
28
|
+
* Zero dependencies, like everything else here.
|
|
29
|
+
*
|
|
30
|
+
* node scripts/machine.mjs status
|
|
31
|
+
* node scripts/machine.mjs stop
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
35
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
36
|
+
import { homedir } from 'node:os';
|
|
37
|
+
import { join } from 'node:path';
|
|
38
|
+
|
|
39
|
+
const WSLCONFIG = join(homedir(), '.wslconfig');
|
|
40
|
+
|
|
41
|
+
/** Processes that are OURS and heavy. Never the user's editor or browser. */
|
|
42
|
+
const OURS = /harbor|acuvo|terminal-bench/i;
|
|
43
|
+
|
|
44
|
+
function sh(file, args, { quiet = false } = {}) {
|
|
45
|
+
try {
|
|
46
|
+
return execFileSync(file, args, {
|
|
47
|
+
encoding: 'utf8', timeout: 20_000, windowsHide: true,
|
|
48
|
+
stdio: quiet ? ['ignore', 'pipe', 'ignore'] : ['ignore', 'pipe', 'inherit'],
|
|
49
|
+
});
|
|
50
|
+
} catch (e) {
|
|
51
|
+
return e?.stdout ?? '';
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function dockerContainers() {
|
|
56
|
+
const out = sh('docker', ['ps', '--format', '{{.ID}}\t{{.Names}}\t{{.Image}}'], { quiet: true });
|
|
57
|
+
return out.split('\n').map((l) => l.trim()).filter(Boolean).map((l) => {
|
|
58
|
+
const [id, name, image] = l.split('\t');
|
|
59
|
+
return { id, name, image };
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* ⚠️ `docker stats` WITHOUT `--no-stream`, ON WINDOWS, NEVER RETURNS. It streams
|
|
65
|
+
* until interrupted, and a script that hangs while you are trying to reclaim
|
|
66
|
+
* your machine is the opposite of this file's job.
|
|
67
|
+
*/
|
|
68
|
+
function dockerLoad() {
|
|
69
|
+
const out = sh('docker', ['stats', '--no-stream', '--format', '{{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}']);
|
|
70
|
+
return out.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function wslCap() {
|
|
74
|
+
if (!existsSync(WSLCONFIG)) {
|
|
75
|
+
return {
|
|
76
|
+
ok: false,
|
|
77
|
+
detail: 'NO ~/.wslconfig — WSL2 (and therefore Docker) may take EVERY core and most of your RAM. '
|
|
78
|
+
+ 'This is the state that made the machine unusable.',
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
const text = readFileSync(WSLCONFIG, 'utf8');
|
|
82
|
+
const procs = /processors\s*=\s*(\d+)/i.exec(text)?.[1] ?? null;
|
|
83
|
+
const mem = /memory\s*=\s*(\S+)/i.exec(text)?.[1] ?? null;
|
|
84
|
+
return {
|
|
85
|
+
ok: procs !== null && mem !== null,
|
|
86
|
+
procs,
|
|
87
|
+
mem,
|
|
88
|
+
detail: procs && mem
|
|
89
|
+
? `capped at ${procs} processors and ${mem}`
|
|
90
|
+
: 'present but does not set BOTH processors and memory — the unset one is uncapped',
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function heavyProcesses() {
|
|
95
|
+
// ⚠️ Windows only, and it fails quietly elsewhere rather than pretending.
|
|
96
|
+
if (process.platform !== 'win32') return [];
|
|
97
|
+
/**
|
|
98
|
+
* ⚠️⚠️ IT MATCHED ITSELF. The query's OWN command line contains the words it
|
|
99
|
+
* searches for, so every `status` reported one phantom powershell.exe and told
|
|
100
|
+
* the owner something of ours was still running when the machine was idle. A
|
|
101
|
+
* monitor that cannot report "all clear" is worse than no monitor — this one
|
|
102
|
+
* exists precisely to be believed.
|
|
103
|
+
*
|
|
104
|
+
* Excluded by pid (our own child) and by the give-away that only the detector
|
|
105
|
+
* mentions BOTH patterns in one command line.
|
|
106
|
+
*/
|
|
107
|
+
const ps = sh('powershell', ['-NoProfile', '-Command',
|
|
108
|
+
"Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -match 'harbor|terminal-bench' } "
|
|
109
|
+
+ '| Select-Object ProcessId,Name,CommandLine | ConvertTo-Json -Compress'], { quiet: true });
|
|
110
|
+
try {
|
|
111
|
+
const parsed = JSON.parse(ps || '[]');
|
|
112
|
+
const list = Array.isArray(parsed) ? parsed : [parsed];
|
|
113
|
+
return list.filter((p) => {
|
|
114
|
+
if (!p || p.ProcessId === process.pid) return false;
|
|
115
|
+
const cl = String(p.CommandLine ?? '');
|
|
116
|
+
// The detector is the only thing that names both patterns at once.
|
|
117
|
+
return !(cl.includes('harbor|terminal-bench'));
|
|
118
|
+
});
|
|
119
|
+
} catch { return []; }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function status() {
|
|
123
|
+
const cap = wslCap();
|
|
124
|
+
console.log('');
|
|
125
|
+
console.log(` WSL / Docker ceiling ${cap.ok ? '✔' : '✖'} ${cap.detail}`);
|
|
126
|
+
if (!cap.ok) {
|
|
127
|
+
console.log(' → write ~/.wslconfig with processors= and memory=, then `wsl --shutdown`');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const containers = dockerContainers();
|
|
131
|
+
console.log(` containers running ${containers.length === 0 ? '✔ none' : `⚠ ${containers.length}`}`);
|
|
132
|
+
for (const c of containers) console.log(` ${c.name} (${c.image})`);
|
|
133
|
+
|
|
134
|
+
if (containers.length > 0) {
|
|
135
|
+
for (const line of dockerLoad()) console.log(` ${line}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const procs = heavyProcesses();
|
|
139
|
+
console.log(` our heavy processes ${procs.length === 0 ? '✔ none' : `⚠ ${procs.length}`}`);
|
|
140
|
+
for (const p of procs) console.log(` pid ${p.ProcessId} ${p.Name}`);
|
|
141
|
+
|
|
142
|
+
const quiet = containers.length === 0 && procs.length === 0;
|
|
143
|
+
console.log('');
|
|
144
|
+
console.log(quiet
|
|
145
|
+
? ' Nothing of ours is running. The machine is yours.'
|
|
146
|
+
: ' Run `node scripts/machine.mjs stop` to end all of it.');
|
|
147
|
+
console.log('');
|
|
148
|
+
return quiet ? 0 : 1;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function stop() {
|
|
152
|
+
/**
|
|
153
|
+
* ⚠️ CONTAINERS FIRST, THEN THE ORCHESTRATOR. Killing harbor first leaves its
|
|
154
|
+
* containers running with nothing supervising them — orphans, which is the
|
|
155
|
+
* exact failure `background.mjs` was written to avoid, at a larger size.
|
|
156
|
+
*/
|
|
157
|
+
const containers = dockerContainers();
|
|
158
|
+
if (containers.length > 0) {
|
|
159
|
+
console.log(` killing ${containers.length} container${containers.length === 1 ? '' : 's'}…`);
|
|
160
|
+
spawnSync('docker', ['kill', ...containers.map((c) => c.id)], { stdio: 'ignore', timeout: 60_000, windowsHide: true });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const procs = heavyProcesses();
|
|
164
|
+
for (const p of procs) {
|
|
165
|
+
console.log(` killing pid ${p.ProcessId} (${p.Name})…`);
|
|
166
|
+
spawnSync('taskkill', ['/T', '/F', '/PID', String(p.ProcessId)], { stdio: 'ignore', timeout: 30_000, windowsHide: true });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* ⭐ AND THE VM ITSELF, because a shut-down WSL2 releases its RAM immediately
|
|
171
|
+
* instead of holding it until the next reboot. This is the line that actually
|
|
172
|
+
* gives the memory back.
|
|
173
|
+
*/
|
|
174
|
+
console.log(' shutting down the WSL VM to release its memory…');
|
|
175
|
+
spawnSync('wsl', ['--shutdown'], { stdio: 'ignore', timeout: 60_000, windowsHide: true });
|
|
176
|
+
|
|
177
|
+
console.log('');
|
|
178
|
+
console.log(' Done. The machine is yours.');
|
|
179
|
+
return 0;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* ── ⭐ RUN SOMETHING HEAVY WITHOUT IT OWNING THE MACHINE ────────────────────
|
|
184
|
+
*
|
|
185
|
+
* `machine.mjs run -- <command…>` starts a command at BELOW NORMAL priority, so
|
|
186
|
+
* the desktop wins every contested core. A benchmark that takes 10% longer and
|
|
187
|
+
* never stutters a video is the correct trade on somebody's personal laptop —
|
|
188
|
+
* and it is the difference between "workload goes lower" (which was explicitly
|
|
189
|
+
* NOT the instruction) and "workload stays out of your way".
|
|
190
|
+
*
|
|
191
|
+
* ⚠️ PRIORITY IS NOT A SUBSTITUTE FOR THE `.wslconfig` CAP. Docker's work does
|
|
192
|
+
* not happen in the child we launch; it happens inside the WSL VM, which this
|
|
193
|
+
* cannot reach. Both controls are needed and they cover different things:
|
|
194
|
+
* priority governs OUR processes, the cap governs the VM.
|
|
195
|
+
*/
|
|
196
|
+
function runLow(argv) {
|
|
197
|
+
if (argv.length === 0) {
|
|
198
|
+
console.error('usage: node scripts/machine.mjs run -- <command> [args…]');
|
|
199
|
+
return 64;
|
|
200
|
+
}
|
|
201
|
+
const cap = wslCap();
|
|
202
|
+
if (!cap.ok) {
|
|
203
|
+
// ⚠️ Refuse rather than warn. This is the exact configuration that made the
|
|
204
|
+
// machine unusable, and a warning at the top of a two-hour run is a warning
|
|
205
|
+
// nobody is present to read.
|
|
206
|
+
console.error(` ✖ refusing to start heavy work: ${cap.detail}`);
|
|
207
|
+
console.error(' Write ~/.wslconfig with processors= and memory=, then `wsl --shutdown`.');
|
|
208
|
+
return 1;
|
|
209
|
+
}
|
|
210
|
+
console.log(` starting at BELOW NORMAL priority (VM ${cap.detail})`);
|
|
211
|
+
const r = process.platform === 'win32'
|
|
212
|
+
? spawnSync('cmd', ['/c', 'start', '/b', '/belownormal', '/wait', ...argv], { stdio: 'inherit', windowsHide: true })
|
|
213
|
+
: spawnSync('nice', ['-n', '15', ...argv], { stdio: 'inherit' });
|
|
214
|
+
return r.status ?? 0;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const cmd = process.argv[2] ?? 'status';
|
|
218
|
+
if (cmd === 'stop' || cmd === 'panic' || cmd === 'kill') process.exit(stop());
|
|
219
|
+
else if (cmd === 'status') process.exit(status());
|
|
220
|
+
else if (cmd === 'run') {
|
|
221
|
+
const sep = process.argv.indexOf('--');
|
|
222
|
+
process.exit(runLow(sep === -1 ? process.argv.slice(3) : process.argv.slice(sep + 1)));
|
|
223
|
+
} else {
|
|
224
|
+
console.error('usage: node scripts/machine.mjs [status|stop|run -- <cmd>]');
|
|
225
|
+
process.exit(64);
|
|
226
|
+
}
|
package/scripts/test.mjs
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ── ⚠️⚠️ ZERO TESTS EXITED 0, AND THAT IS THE WORST POSSIBLE GREEN ──────────
|
|
3
|
+
*
|
|
4
|
+
* `SHAKEDOWN.md` §1.2, measured on an INSTALLED copy of this package:
|
|
5
|
+
*
|
|
6
|
+
* npm test
|
|
7
|
+
* # tests 0 / pass 0 / fail 0 — exit 0
|
|
8
|
+
*
|
|
9
|
+
* The old script was `node --test --test-timeout=180000 test/*.test.mjs`, and
|
|
10
|
+
* the glob is expanded by the SHELL. On a machine whose shell does not expand it
|
|
11
|
+
* (cmd.exe), or in an install where the pattern matches nothing, node is handed
|
|
12
|
+
* a literal `test/*.test.mjs`, finds no such file, runs **nothing**, and reports
|
|
13
|
+
* success. ⭐ Someone auditing this package — which `ENTERPRISE.md` explicitly
|
|
14
|
+
* invites, and which is the entire reason `test/` is in the published files
|
|
15
|
+
* allowlist — would run `npm test`, see green, and conclude the suite passed.
|
|
16
|
+
*
|
|
17
|
+
* ⭐ THIS IS THE SAME DEFECT CLASS AS "watch the total, not the failures": a
|
|
18
|
+
* test file that fails to COMPILE also contributes zero tests, and the run still
|
|
19
|
+
* says passed. Counting failures can never catch either one. **Only the total
|
|
20
|
+
* can**, so the total is what this asserts.
|
|
21
|
+
*
|
|
22
|
+
* ⚠️ AND IT DOES NOT GLOB. Node's own directory discovery is used, so the
|
|
23
|
+
* behaviour no longer depends on which shell invoked npm.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { spawnSync } from 'node:child_process';
|
|
27
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
28
|
+
import { cpus } from 'node:os';
|
|
29
|
+
import { fileURLToPath } from 'node:url';
|
|
30
|
+
import { dirname, join } from 'node:path';
|
|
31
|
+
|
|
32
|
+
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
33
|
+
const testDir = join(root, 'test');
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* ⚠️ A MISSING `test/` IS A FAILURE, NOT A SKIP. An install that dropped the
|
|
37
|
+
* directory is exactly the case this script exists to catch, and "there was
|
|
38
|
+
* nothing to run" must never be reported as "everything passed".
|
|
39
|
+
*/
|
|
40
|
+
if (!existsSync(testDir)) {
|
|
41
|
+
console.error(`✖ ${testDir} does not exist — this copy of acuvo-code ships no tests, so nothing was verified.`);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* ⚠️ A FLOOR, NOT AN EXACT COUNT. Pinning the precise number would fail every
|
|
47
|
+
* commit that adds a test — a check that fails correct work, which this repo has
|
|
48
|
+
* paid for repeatedly. The floor only ever catches the collapse this exists for:
|
|
49
|
+
* hundreds of tests becoming a handful because a glob broke or a file stopped
|
|
50
|
+
* compiling. Raise it deliberately, never automatically.
|
|
51
|
+
*/
|
|
52
|
+
const MINIMUM_TESTS = 500;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* ⚠️⚠️ THE FILES ARE ENUMERATED HERE, NOT GLOBBED AND NOT DISCOVERED.
|
|
56
|
+
*
|
|
57
|
+
* · A shell glob (`test/*.test.mjs`) is what broke: cmd.exe does not expand it,
|
|
58
|
+
* so node received the literal string, matched nothing, and exited 0.
|
|
59
|
+
* · Passing the DIRECTORY does not work either — measured on Node 22.17:
|
|
60
|
+
* `node --test test/` resolves `test/` as a MODULE and dies with
|
|
61
|
+
* `Cannot find module …\test`, which the runner then reports as one failing
|
|
62
|
+
* test. Directory discovery is not what that argument means.
|
|
63
|
+
*
|
|
64
|
+
* ⭐ `readdirSync` depends on neither the shell nor a node version's glob
|
|
65
|
+
* support, and the list it produces can be counted before anything runs.
|
|
66
|
+
*/
|
|
67
|
+
const files = readdirSync(testDir)
|
|
68
|
+
.filter((n) => n.endsWith('.test.mjs'))
|
|
69
|
+
.map((n) => `test/${n}`)
|
|
70
|
+
.sort();
|
|
71
|
+
|
|
72
|
+
if (files.length === 0) {
|
|
73
|
+
console.error(`✖ ${testDir} contains no *.test.mjs files — nothing was verified.`);
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* ── ⚠️⚠️ A CONCURRENCY CEILING, BECAUSE THIS RUNS ON SOMEBODY'S LAPTOP ──────
|
|
79
|
+
*
|
|
80
|
+
* `node --test` defaults to one worker PER CORE. With 189 test files that is
|
|
81
|
+
* eight processes on this machine — fine alone, and not fine at all in the
|
|
82
|
+
* configuration this repo actually runs in: **two terminals plus three
|
|
83
|
+
* background agents**, each firing the same suite. 8 cores, up to 24 workers.
|
|
84
|
+
*
|
|
85
|
+
* Measured consequence, 2026-08-16, from the person paying for the laptop:
|
|
86
|
+
* *"we are absolutely fucking the shit out of my laptop so hard I can't even
|
|
87
|
+
* open my Google tabs."* ⭐ THE INSTRUCTION WAS EXPLICITLY NOT "DO LESS WORK" —
|
|
88
|
+
* it was that the machine has to stay usable while the work happens. So this
|
|
89
|
+
* is a ceiling, not a reduction: the same tests run, they just cannot take
|
|
90
|
+
* every core at once.
|
|
91
|
+
*
|
|
92
|
+
* ⚠️ HALF THE CORES, MINUS ONE, FLOOR OF 2. Half leaves room for a second
|
|
93
|
+
* agent; the minus-one leaves a core for the interactive session, which is the
|
|
94
|
+
* thing a human actually notices. A floor of 2 stops a small machine
|
|
95
|
+
* serialising a 189-file suite into something nobody will wait for.
|
|
96
|
+
*
|
|
97
|
+
* ⭐ Override with `ACUVO_TEST_CONCURRENCY` when the machine is idle and you
|
|
98
|
+
* want the suite back at full speed — the ceiling exists for the shared case,
|
|
99
|
+
* not because more is wrong.
|
|
100
|
+
*/
|
|
101
|
+
const cores = Math.max(1, cpus().length);
|
|
102
|
+
const concurrency = (() => {
|
|
103
|
+
const asked = Number(process.env.ACUVO_TEST_CONCURRENCY);
|
|
104
|
+
if (Number.isInteger(asked) && asked > 0) return asked;
|
|
105
|
+
return Math.max(2, Math.floor(cores / 2) - 1);
|
|
106
|
+
})();
|
|
107
|
+
|
|
108
|
+
// ⚠️ TAP, not spec: the machine-readable `# tests N` line is the thing being
|
|
109
|
+
// asserted below, and the spec reporter writes a decorated `ℹ tests N` instead.
|
|
110
|
+
const args = ['--test', `--test-concurrency=${concurrency}`, '--test-timeout=180000', '--test-reporter=tap', ...files];
|
|
111
|
+
const run = spawnSync(process.execPath, args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
112
|
+
|
|
113
|
+
const out = `${run.stdout ?? ''}${run.stderr ?? ''}`;
|
|
114
|
+
process.stdout.write(out);
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* ⚠️ READ FROM THE REPORTER'S OWN TOTAL, never recomputed by counting lines.
|
|
118
|
+
* `# tests N` is what node itself concluded; a line count is our opinion about
|
|
119
|
+
* its output, and the two drift the moment the reporter changes.
|
|
120
|
+
*/
|
|
121
|
+
const total = Number(/^# tests (\d+)$/m.exec(out)?.[1] ?? NaN);
|
|
122
|
+
const failed = Number(/^# fail (\d+)$/m.exec(out)?.[1] ?? NaN);
|
|
123
|
+
|
|
124
|
+
if (!Number.isFinite(total)) {
|
|
125
|
+
console.error('\n✖ could not read a test total out of the runner output — treating that as a failure, '
|
|
126
|
+
+ 'because an unreadable result is not a passing one.');
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (total < MINIMUM_TESTS) {
|
|
131
|
+
console.error(`\n✖ only ${total} tests ran, and this package expects at least ${MINIMUM_TESTS}.`);
|
|
132
|
+
console.error(' Nothing here failed — that is the point. A suite that collects no tests reports success,');
|
|
133
|
+
console.error(' so the TOTAL is the check. Usually this means the test directory did not ship, or a test');
|
|
134
|
+
console.error(' file failed to compile and silently contributed zero tests.');
|
|
135
|
+
process.exit(1);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (Number.isFinite(failed) && failed > 0) process.exit(1);
|
|
139
|
+
process.exit(run.status === null ? 1 : run.status);
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: accessibility
|
|
3
|
+
description: Semantic HTML, keyboard paths, focus and contrast — the parts that break real usage
|
|
4
|
+
when: When building any interactive UI, a modal, a menu, or anything you can click
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Accessibility
|
|
8
|
+
|
|
9
|
+
Beyond the basics in `web-app-quality`. These are the failures that make an
|
|
10
|
+
interface genuinely unusable rather than merely imperfect.
|
|
11
|
+
|
|
12
|
+
## ⚠️⚠️ The right element does most of the work for free
|
|
13
|
+
|
|
14
|
+
```html
|
|
15
|
+
✗ <div class="btn" onclick="save()">Save</div>
|
|
16
|
+
✓ <button type="button" onclick="save()">Save</button>
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The `<div>` cannot be focused, cannot be triggered by Enter or Space, is not
|
|
20
|
+
announced as a button, and does not participate in forms. The `<button>` does
|
|
21
|
+
all four with no code. Recreating that with `tabindex`, `role` and key handlers
|
|
22
|
+
is a lot of work to arrive back where you started — and it is usually incomplete.
|
|
23
|
+
|
|
24
|
+
Same for `<a>` for navigation, `<label>` for inputs, `<nav>`/`<main>`/`<h1>`
|
|
25
|
+
for structure. **Reach for ARIA only when no element exists for the job.** Bad
|
|
26
|
+
ARIA is worse than none, because it overrides what the browser knew.
|
|
27
|
+
|
|
28
|
+
## Every path must work from the keyboard
|
|
29
|
+
|
|
30
|
+
Tab through the whole thing without touching the mouse. If you cannot reach a
|
|
31
|
+
control, open a menu, or close a dialog, it is broken — for keyboard users, for
|
|
32
|
+
screen readers, and for anyone whose trackpad died.
|
|
33
|
+
|
|
34
|
+
- `Escape` closes overlays
|
|
35
|
+
- `Enter`/`Space` activate
|
|
36
|
+
- Arrow keys move within a composite widget (menu, tabs), not between pages
|
|
37
|
+
|
|
38
|
+
⚠️ **Tab order follows the DOM, not the CSS.** Reordering visually with grid or
|
|
39
|
+
flex leaves the tab order where the markup put it. Never use positive
|
|
40
|
+
`tabindex` values to patch that — fix the markup order.
|
|
41
|
+
|
|
42
|
+
## Focus is not decoration
|
|
43
|
+
|
|
44
|
+
```css
|
|
45
|
+
✗ *:focus { outline: none; } /* the keyboard user is now lost */
|
|
46
|
+
✓ :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`:focus-visible` shows the ring for keyboard users and not for mouse clicks, so
|
|
50
|
+
there is no reason left to remove it.
|
|
51
|
+
|
|
52
|
+
⭐ **Move focus deliberately.** Open a dialog → focus inside it and trap it
|
|
53
|
+
there. Close it → return focus to the control that opened it. Delete a row →
|
|
54
|
+
move focus somewhere sensible, not to `<body>`.
|
|
55
|
+
|
|
56
|
+
## Contrast, and never colour alone
|
|
57
|
+
|
|
58
|
+
- Body text at **4.5:1**, large text at **3:1**. Grey-on-grey placeholder text
|
|
59
|
+
is the usual offender.
|
|
60
|
+
- About 1 in 12 men cannot separate red from green — so an error state needs an
|
|
61
|
+
icon or words, not just a red border. Same for chart series (`data-and-charts`).
|
|
62
|
+
|
|
63
|
+
## Images and icons
|
|
64
|
+
|
|
65
|
+
`alt` describes the *purpose*: `alt="Search"` on a magnifier, not
|
|
66
|
+
`alt="magnifying glass icon"`. Decorative images take `alt=""` so screen readers
|
|
67
|
+
skip them — omitting `alt` entirely makes them read the filename instead.
|
|
68
|
+
|
|
69
|
+
## ⚠️ Tell people when something changed
|
|
70
|
+
|
|
71
|
+
A screen reader does not notice a div appearing. Announce it:
|
|
72
|
+
|
|
73
|
+
```html
|
|
74
|
+
<div role="status" aria-live="polite">Invoice saved</div>
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Use `assertive` only for genuine interruptions — an error that stops the task.
|
|
78
|
+
|
|
79
|
+
## Respect the settings people already chose
|
|
80
|
+
|
|
81
|
+
```css
|
|
82
|
+
@media (prefers-reduced-motion: reduce) { *, *::before, *::after {
|
|
83
|
+
animation-duration: .01ms !important; transition-duration: .01ms !important; } }
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Also honour `prefers-color-scheme`. These are the user telling you what they
|
|
87
|
+
need; overriding them is a decision you do not have the information to make.
|