From 44777cadf47511c1a5d22e050f5543a6eebaaaa5 Mon Sep 17 00:00:00 2001 From: nhyiramante1 <196460314+nhyiramante1@users.noreply.github.com> Date: Sun, 1 Nov 2026 10:50:46 -0500 Subject: [PATCH 01/10] feat(next): quote checks ignore punctuation Dictated text has little punctuation, and the model tends to add some when it quotes, so "nobody sees the small wins." was refused against the spoken "nobody sees the small wins". The matching rule now compares letters, numbers and their order only: apostrophes are dropped (don't = dont), any other punctuation counts as a space (well-known = well known). Every letter still has to match on word boundaries ("now" is not "not", "ike" is not "like"), and fillers are not removed: the model quotes around an "um" with two pieces. Side effects, both intended: a card that differs only in punctuation is a duplicate, and a writer punctuating a card keeps its link and nesting evidence. Co-Authored-By: Claude Opus 5.5 --- docs/tools-first-pass.md | 11 +++++++-- next/src/state/match.ts | 11 ++++++--- next/src/tools/tools.test.ts | 48 ++++++++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 6 deletions(-) diff --git a/docs/tools-first-pass.md b/docs/tools-first-pass.md index c9efbd5..4c37235 100644 --- a/docs/tools-first-pass.md +++ b/docs/tools-first-pass.md @@ -90,8 +90,15 @@ All tools return a result to the model: ### Matching rule (used by every check) "Verbatim" means a plain substring match after mechanical normalization: Unicode NFC, -lower-case, whitespace runs collapsed to one space, and straight/curly quotes treated -alike. No other transformation. +lower-case, punctuation ignored, and whitespace runs collapsed to one space. Apostrophes +are dropped ("don't" matches "dont"); any other punctuation counts as a space +("well-known" matches "well known"). No other transformation: every letter and number +still has to match, in order, on word boundaries. + +- **Punctuation (Nhyira, 11-01):** ignored because dictated text has little of it and the + model tends to add some when it quotes, which cost refused attempts. It also lets a + writer punctuate a card without losing the link and nesting evidence for it. Fillers + ("um") are not removed: the model quotes around them with two pieces. When a piece matches in more than one message, record the most recent match, and all matches. Each accepted card keeps these `sources` (piece, most recent turn, every turn, diff --git a/next/src/state/match.ts b/next/src/state/match.ts index 7606b10..48dc59a 100644 --- a/next/src/state/match.ts +++ b/next/src/state/match.ts @@ -2,15 +2,18 @@ import type { Level, WriterRecord } from "./types"; /** * The matching rule every handler uses (spec §5): a mechanical comparison, never an - * interpretation. Unicode NFC, lower-case, curly quotes treated as straight, and - * whitespace runs collapsed to one space. + * interpretation. Unicode NFC, lower-case, punctuation ignored, and whitespace runs + * collapsed to one space, so only the letters, numbers and their order are compared. + * Apostrophes are dropped ("don't" matches "dont"); any other punctuation counts as a + * space ("well-known" matches "well known"). Dictated text has little punctuation and + * the model tends to add some when it quotes (Nhyira, 11-01). */ export function norm(text: string): string { return text .normalize("NFC") .toLowerCase() - .replace(/[‘’]/g, "'") - .replace(/[“”]/g, '"') + .replace(/['‘’]/g, "") + .replace(/[^\p{L}\p{N}\p{M}\s]/gu, " ") .replace(/\s+/g, " ") .trim(); } diff --git a/next/src/tools/tools.test.ts b/next/src/tools/tools.test.ts index 3390904..d142300 100644 --- a/next/src/tools/tools.test.ts +++ b/next/src/tools/tools.test.ts @@ -232,3 +232,51 @@ describe("typos (Nhyira, option A: quote exactly as typed)", () => { expect(proposeCard.description(0)).toContain("typos included"); }); }); + +describe("punctuation is ignored (Nhyira, 11-01: for dictated text)", () => { + // Dictation gives words with little or no punctuation. + const spoken = session(["so i think the main problem with remote work is that nobody sees the small wins um and that makes people feel invisible"]); + + it("accepts punctuation the writer didn't type", () => { + expect(run(proposeCard, { pieces: ["nobody sees the small wins."] }, spoken).ok).toBe(true); + expect(run(proposeCard, { pieces: ["the main problem with remote work, is that nobody sees"] }, spoken).ok).toBe(true); + expect(run(proposeCard, { pieces: ["“the small wins”"] }, spoken).ok).toBe(true); + }); + + it("accepts a piece without the punctuation the writer did type", () => { + const s = session(["Cost, mostly. And time; the well-known kind."]); + expect(run(proposeCard, { pieces: ["cost mostly and time"] }, s).ok).toBe(true); + expect(run(proposeCard, { pieces: ["the well known kind"] }, s).ok).toBe(true); + }); + + it("drops apostrophes, so don't and dont match both ways", () => { + expect(run(proposeCard, { pieces: ["I don't like it"] }, session(["i dont like it"])).ok).toBe(true); + expect(run(proposeCard, { pieces: ["I dont like it"] }, session(["I don’t like it"])).ok).toBe(true); + }); + + it("still compares every letter, word boundaries and fillers", () => { + expect(run(proposeCard, { pieces: ["I do now like it"] }, session(["I do not like it."])).ok).toBe(false); + expect(run(proposeCard, { pieces: ["ike it"] }, session(["I like it."])).ok).toBe(false); + // Leaving out a filler still needs two pieces around it. + expect(run(proposeCard, { pieces: ["the small wins and that makes people feel invisible"] }, spoken).ok).toBe(false); + expect(run(proposeCard, { pieces: ["the small wins", "and that makes people feel invisible"] }, spoken).ok).toBe(true); + }); + + it("refuses a piece that is only punctuation", () => { + const r = run(proposeCard, { pieces: ["..."] }, spoken); + expect(!r.ok && r.reason).toBe("Every piece needs at least one word."); + }); + + it("treats a card that differs only in punctuation as a duplicate", () => { + let s = session(["the real cost is time"]); + s = { ...s, map: { cards: [{ id: "c1", text: "The real cost is time.", parentId: null, provenance: { kind: "user" }, x: 0, y: 0 }], links: [] } }; + const r = run(proposeCard, { pieces: ["the real cost is time"] }, s); + expect(!r.ok && r.reason).toContain("already on the map"); + }); + + it("still links a card after the writer adds punctuation to it", () => { + let s = spoken; + s = { ...s, map: { cards: [{ id: "c1", text: "Remote work", parentId: null, provenance: { kind: "user" }, x: 0, y: 0 }, { id: "c2", text: "Nobody sees the small wins.", parentId: null, provenance: { kind: "user" }, x: 0, y: 0 }], links: [] } }; + expect(run(proposeLink, { from_card: "c1", to_card: "c2", label: "is that", evidence: ["remote work is that nobody sees the small wins"] }, s).ok).toBe(true); + }); +}); From f796e4f740c2d75b154533bc8aa1839fe8a0ea31 Mon Sep 17 00:00:00 2001 From: nhyiramante1 <196460314+nhyiramante1@users.noreply.github.com> Date: Sun, 1 Nov 2026 10:56:12 -0500 Subject: [PATCH 02/10] feat(next): voice input in the message box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A mic button above Send, using the browser's own speech recognition: the current app's src/ui/useSpeechToText.ts copied with its tests (jsdom added as a dev dependency for them). Spoken words go into the message box, after whatever was already there, and the writer reads, edits and sends them like typed text; nothing is sent by voice alone. - While listening the button turns red, the hint says so, and the box keeps the newest words in view as it grows (up to 40% of the screen, then it scrolls). - Typing or Send stops dictation. One change to the copied hook: after reset, results are ignored until the next start, because stop() still delivers a final result for the words heard so far, which would otherwise refill the box just sent, or overwrite what was just typed. - Firefox has no speech recognition: the button is disabled with a tooltip. - Better transcription through the backend is postponed (spec §8). Co-Authored-By: Claude Opus 5.5 --- docs/tools-first-pass.md | 7 +- next/package-lock.json | 533 +++++++++++++++++++++++++++ next/package.json | 1 + next/src/styles.css | 10 +- next/src/ui/Chat.tsx | 71 +++- next/src/ui/useSpeechToText.test.tsx | 217 +++++++++++ next/src/ui/useSpeechToText.ts | 145 ++++++++ 7 files changed, 976 insertions(+), 8 deletions(-) create mode 100644 next/src/ui/useSpeechToText.test.tsx create mode 100644 next/src/ui/useSpeechToText.ts diff --git a/docs/tools-first-pass.md b/docs/tools-first-pass.md index 4c37235..0388bfe 100644 --- a/docs/tools-first-pass.md +++ b/docs/tools-first-pass.md @@ -39,7 +39,7 @@ the rules are stated, and enforced only when an action executes. | `suggest_idea` below L2 | Offered, always refused, so the model learns the boundary and draws the idea out of the writer instead. The instructions and the tool description also say up front that it is refused (Nhyira, 10-28): the prompt tells the model it won't work, so it doesn't waste an attempt, and the handler's refusal stays as the real check in case it tries anyway or ignores the prompt. So the coach declining in words at L0 without trying the tool is expected. | | Memory between calls | None. Every call is stateless and sends everything (section 7), ordered so the provider's prompt cache covers the repeated part. No provider-side conversation storage. | | Suggestion adoption | Behind an on/off setting. Measured by shared runs of three or more words, not today's common-word list (see §9). The list is the fallback if runs prove problematic. | -| Out of v1 | i18n, a restating tool, working memory, the fuzz harness (after v1), L3 (after v1), the Think/Map slider, steering buttons, voice, Control Room diagnostics, sign-in and deploy. | +| Out of v1 | i18n, a restating tool, working memory, the fuzz harness (after v1), L3 (after v1), the Think/Map slider, steering buttons, voice (added 11-01, see §8), Control Room diagnostics, sign-in and deploy. | ## 3. Levels @@ -245,6 +245,11 @@ sessionless path, so v1 needs no sign-in. - **Level picker:** a change applies from the next turn. - **Draft panel:** a box to paste the draft into. - **Copy map** and **copy draft** out. +- **Voice input (Nhyira, 11-01):** a mic button above Send, using the browser's own + speech recognition (the current app's `useSpeechToText`; Chrome, Edge and Safari, not + Firefox). Spoken words go into the message box, never straight to the coach: the + writer reads, edits and sends them like typed text. Typing or Send stops dictation. + Better transcription through the backend (punctuation, fewer fillers) is postponed. Keep it simple: no extra panels or diagnostics in v1. diff --git a/next/package-lock.json b/next/package-lock.json index 2b0e555..b4c1ded 100644 --- a/next/package-lock.json +++ b/next/package-lock.json @@ -16,11 +16,63 @@ "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.4", + "jsdom": "^29.1.1", "typescript": "^5.6.3", "vite": "^6.0.3", "vitest": "^3.2.4" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -303,6 +355,159 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.3.tgz", + "integrity": "sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.2.1" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz", + "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.3.tgz", + "integrity": "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.7.tgz", + "integrity": "sha512-zgm9yjQz5iJB4VL8RyVwd71TWB8Q89FSxt/NzXoWqPSkEBOVtn9L6vAPtSc9hpKfB0Awo/AL2VKFHjYetBLFHQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.2", + "@csstools/css-calc": "^3.4.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.2.tgz", + "integrity": "sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.16.tgz", + "integrity": "sha512-LsDbJPP/B8cPasUJyTA5S99F7C4G93ngW8hUluXz8CFeOaaRnBPtBiIlvgcPYU0HmHNz8CXg+1vwFrIB4F6Dgw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz", + "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -745,6 +950,24 @@ "node": ">=18" } }, + "node_modules/@exodus/bytes": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", + "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1559,6 +1782,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/browserslist": { "version": "4.29.5", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.5.tgz", @@ -1664,6 +1897,20 @@ "dev": true, "license": "MIT" }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1776,6 +2023,20 @@ "node": ">=12" } }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1794,6 +2055,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -1811,6 +2079,19 @@ "dev": true, "license": "ISC" }, + "node_modules/entities": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-9.0.0.tgz", + "integrity": "sha512-oJFNDJg3kTPwAD1cNn2+WmUq4wFoo09npc3Ydf1D7ED4smRiWrsKnaLxykrTWgyGEcusc87P5dxlAleol9kpkg==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -1933,12 +2214,83 @@ "node": ">=6.9.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -2004,6 +2356,13 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2040,6 +2399,19 @@ "node": ">=18" } }, + "node_modules/parse5": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.1.0.tgz", + "integrity": "sha512-sq3qBFpee5jeocbFbO48VHlGr8sT7mbXS0vxlKN6sottBtJzrOG3998sr0llSZcQAGgfpdmEYAIZE0P9z0dE8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^9.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -2106,6 +2478,16 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -2141,6 +2523,16 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rollup": { "version": "4.66.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.66.0.tgz", @@ -2187,6 +2579,19 @@ "fsevents": "~2.3.2" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -2257,6 +2662,13 @@ "dev": true, "license": "MIT" }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -2318,6 +2730,52 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.4.19", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.19.tgz", + "integrity": "sha512-48dH6aSpQnpbzDUc4vIQZ1VjB8tkK+e8c1yx05/SZK+rkssgjEOlxDd/yU8/a4vGYQ9vRgJr9dwMq9TJ0b3X2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.19" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.20", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.20.tgz", + "integrity": "sha512-C630TWXZCZmLq0F2bW4QqwnaaxCQ/svpAqvJwnC4JvgEM5ohhBnGsEzTLSOXcN+xjz+kEaHS5uoWIw2klnqd0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -2332,6 +2790,16 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "7.30.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.1.tgz", + "integrity": "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/update-browserslist-db": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", @@ -2543,6 +3011,54 @@ } } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -2560,6 +3076,23 @@ "node": ">=8" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/next/package.json b/next/package.json index 82cf14a..9b9f493 100644 --- a/next/package.json +++ b/next/package.json @@ -19,6 +19,7 @@ "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.4", + "jsdom": "^29.1.1", "typescript": "^5.6.3", "vite": "^6.0.3", "vitest": "^3.2.4" diff --git a/next/src/styles.css b/next/src/styles.css index 713bd12..8ad7988 100644 --- a/next/src/styles.css +++ b/next/src/styles.css @@ -92,8 +92,16 @@ button:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2p .tool-why > div { padding: 4px 0 2px 14px; } .composer { display: flex; gap: 8px; padding: 10px 12px 4px; border-top: 1px solid var(--line); } .composer textarea { flex: 1; min-height: 76px; max-height: 40vh; overflow-y: auto; resize: none; border: 1px solid var(--line); border-radius: 10px; padding: 8px 10px; line-height: 1.45; } -.composer .send { align-self: flex-end; background: var(--blue); border-color: var(--blue); color: #fff; } +/* Mic above Send, both at the bottom of the box as it grows. */ +.composer-buttons { display: flex; flex-direction: column; justify-content: flex-end; gap: 6px; } +.composer .send { background: var(--blue); border-color: var(--blue); color: #fff; } .composer .send:hover:not(:disabled) { background: #155d89; } +.composer .mic { display: grid; place-items: center; padding: 5px 10px; } +.composer .mic svg { width: 18px; height: 18px; stroke-width: 1.8; } +.composer .mic.live { background: var(--red); border-color: var(--red); color: #fff; animation: mic-live 1.6s ease-in-out infinite; } +.composer .mic.live:hover:not(:disabled) { background: #8a2f22; } +@keyframes mic-live { 50% { box-shadow: 0 0 0 4px rgba(163, 58, 42, 0.18); } } +@media (prefers-reduced-motion: reduce) { .composer .mic.live { animation: none; } } .hint { padding: 0 12px 8px; font-size: 11px; } /* Map */ diff --git a/next/src/ui/Chat.tsx b/next/src/ui/Chat.tsx index 9cc0e6c..04831ca 100644 --- a/next/src/ui/Chat.tsx +++ b/next/src/ui/Chat.tsx @@ -2,6 +2,7 @@ import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "re import { proposalLabel } from "../state/labels"; import { blockedReason } from "../state/map"; import type { Proposal, Session, TranscriptItem } from "../state/types"; +import { useSpeechToText } from "./useSpeechToText"; interface Props { session: Session; @@ -59,6 +60,18 @@ function ToolLine({ item, session, busy, onAccept, onReject }: { item: Extract