diff --git a/.agents/skills/clean-comments/PERMISSIONS.md b/.agents/skills/clean-comments/PERMISSIONS.md new file mode 100644 index 00000000..6bad7e57 --- /dev/null +++ b/.agents/skills/clean-comments/PERMISSIONS.md @@ -0,0 +1,62 @@ +# Permissions + +What `clean-comments` touches, and what it never touches. + +## Filesystem + +**Reads** + +- Source files inside the scope the user asked for, to find and judge comments. +- `.beads/config.yaml`, for the local issue prefix, so tracker IDs can be + recognised. Read only, never written. +- Agent instruction files (`CLAUDE.md`, `AGENTS.md`, `.cursorrules`, and the + rest of the list in `references/install.md`), during `install` only. + +**Writes** + +- Comment text in files inside the requested scope. Executable code is never + changed, and `scripts/verify.mjs` exists to check it. +- The fenced guidance block in agent instruction files — only under + `install-guidance.sh --write`, which is never the default. A dry run prints a + diff and writes nothing. +- Temporary files from `mktemp`, removed on the same run. + +**Never** + +- Files outside the requested scope, including vendored trees, build output, + and any file carrying a generated-code banner. +- `~/.ssh`, `~/.aws`, `~/.config`, keychains, environment files, or any path + outside the repository other than `mktemp` output. +- Its own files. The skill does not modify itself. + +## Network + +None. No script makes a network call, and none is needed: every check is local +and static. The GitHub Actions snippet in `references/check.md` runs in the +user's CI under their own credentials and is not invoked by the skill. + +## Subprocesses + +`git`, always through `execFileSync` with an argument array or a quoted shell +call, so no user value reaches a shell for interpretation; plus standard POSIX +text utilities (`grep`, `sed`, `awk`, `head`, `diff`, `cmp`, `mktemp`) in the +bash helpers. + +Git commands used: `rev-parse`, `ls-files`, `diff`, `show`, `cat-file`, +`symbolic-ref`. All are read-only. The skill never commits, stages, pushes, +checks out, or resets, and it does not install hooks. + +## Secrets + +None read, none written, none needed. + +## Tools required + +`git` and `node` (18 or newer). Both are used locally. + +## Blast radius + +Worst case is a bad comment edit inside the scope the user asked for, which +`git diff` shows and `git checkout` reverts. The skill takes no action that +leaves the working tree, so nothing it does can reach a remote, a registry, or +another machine. diff --git a/.agents/skills/clean-comments/SKILL.md b/.agents/skills/clean-comments/SKILL.md new file mode 100644 index 00000000..1305d526 --- /dev/null +++ b/.agents/skills/clean-comments/SKILL.md @@ -0,0 +1,60 @@ +--- +name: clean-comments +description: "Deletes agent commentary and rewrites what survives as one plain line. Use when cleaning comments in a diff or repo." +--- + +# Clean Comments + +Agents comment too much. They restate the code, narrate their own edits, carry +issue IDs no reader can resolve, and spend three lines where none were needed. +This skill removes that layer and rewrites what survives as one plain line. + +**Edit comment text only. Never change executable code.** If a comment can only +be fixed by changing the code, report it instead — see +[references/rules.md](references/rules.md). + +```text +/clean-comments [command | path] +``` + +| Command | Use for | Read | +| --- | --- | --- | +| `[path]` | Clean the current diff, or a file or directory when given a path | [references/cleanup.md](references/cleanup.md) | +| `all` | Clean every source file in the repository | [references/cleanup.md](references/cleanup.md) | +| `check [scope]` | Report violations without editing, for CI or PR review | [references/check.md](references/check.md) | +| `install` | Write the comment rules into the project's agent files | [references/install.md](references/install.md) | + +With no argument, clean the current diff. Treat an unrecognized argument as a +path. Never widen the scope the user asked for. + +## Triage + +Judge every comment in scope against this ladder and stop at the first match. +[references/rules.md](references/rules.md) holds the full test and examples for +each rung. + +| # | The comment… | Do | +|---|---|---| +| 1 | Is a **directive** the toolchain reads — `eslint-disable`, `# noqa`, `//go:generate`, `# type: ignore` — or a license header, shebang, or generated-file marker | **Keep exactly as written** | +| 2 | Is a **doc comment on a public interface** stating a contract | **Keep the contract**, drop padding that repeats the signature. The one-line rule does not apply | +| 3 | Is **commented-out code** | **Delete** — version control already has it | +| 4 | **Restates the code** the next line or the function name already says | **Delete** | +| 5 | **Narrates an edit** — "added", "updated", "now handles", "refactored", "as requested" | **Delete**, keeping any reason it carried | +| 6 | **Names the agent or session** — "I've", "as an AI", "Claude", "per your request" | **Delete the reference**, keep the technical content | +| 7 | **Cites an unshared tracker** a reader of this repo cannot resolve | **Delete the ID**, keep the substance | +| 8 | **Marks unfinished work** | **Rewrite** to `TODO:`, `FIXME:`, `HACK:`, or `XXX:` so tooling can find it | +| 9 | **Explains** a constraint, bug fix, unidiomatic code, or business rule | **Keep**, rewritten to one line in the style of [references/ste.md](references/ste.md) | +| 10 | **You cannot tell what it means** | **Flag it, do not delete it.** An unclear comment usually marks unclear code, which is the human's call | + +## Rules of thumb + +- One line, almost always. A comment that needs a paragraph is usually a sign + the code needs work — report that rather than writing the paragraph. +- Say why, never what. The code already says what. +- Delete freely when the code is self-evident, and never when you are unsure. + +## Validation + +Run `bash tests/selftest.sh` after changing the bundled scripts. After any +cleanup, run `node scripts/verify.mjs`: it checks that only comment text +changed. Its failure is authoritative; its pass is strong evidence, not proof. diff --git a/.agents/skills/clean-comments/agents/openai.yaml b/.agents/skills/clean-comments/agents/openai.yaml new file mode 100644 index 00000000..af3f7357 --- /dev/null +++ b/.agents/skills/clean-comments/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "Clean Comments" + short_description: "Delete agent commentary, keep the comments that earn their place" + default_prompt: "Use /clean-comments to clean comments in this diff, check without editing, or install the comment rules." + +policy: + allow_implicit_invocation: true diff --git a/.agents/skills/clean-comments/assets/agent-guidance.md b/.agents/skills/clean-comments/assets/agent-guidance.md new file mode 100644 index 00000000..e210194b --- /dev/null +++ b/.agents/skills/clean-comments/assets/agent-guidance.md @@ -0,0 +1,17 @@ +## Code comments + +Write a comment only when the code cannot say it. Then write one line. + +- Say why, not what. The code already says what. +- No edit narration: not "added", "updated", "now handles", "as requested". +- Never name the agent, the model, or the session. Attribution is git's job. +- No issue ID a reader of this repository cannot resolve — write the substance. +- Mark unfinished work `TODO:`, broken work `FIXME:`, a workaround `HACK:`. +- Keep: constraints, the reason for a bug fix, why unidiomatic code is + deliberate, business rules, and a link to the source of copied code. +- Delete: commented-out code, restatements of the next line, section banners, + and doc padding that repeats the signature. +- Plain English, active voice, twenty words or fewer. + +If a comment needs a paragraph, the code usually needs the work instead. Say so +rather than writing the paragraph. diff --git a/.agents/skills/clean-comments/references/check.md b/.agents/skills/clean-comments/references/check.md new file mode 100644 index 00000000..3ce72fd0 --- /dev/null +++ b/.agents/skills/clean-comments/references/check.md @@ -0,0 +1,81 @@ +# Checking without editing + +`/clean-comments check [scope]` reports violations and changes nothing. Use it +in CI, in PR review, or before deciding whether a cleanup is worth running. + +```bash +# tr|xargs -0 keeps filenames with spaces as one argument +bash scripts/scope.sh --branch | tr '\n' '\0' | xargs -0 -r node scripts/scan.mjs +bash scripts/scope.sh --branch | tr '\n' '\0' | xargs -0 -r node scripts/scan.mjs --ci +node scripts/scan.mjs --diff-only --base origin/main src/api/user.ts +``` + +`--ci` exits 1 only for `commented-code`, `agent-reference`, `edit-history`, +and `tracker-reference` — the four highest-confidence rules. `long-comment`, +`comment-block`, and `restates-name` always report and never fail, because +judging them needs the code around them. + +## Why this never rewrites + +A check that edits code during a commit or a push is a bad trade, and the skill +does not offer one: + +- It rewrites code the author already reviewed, at the moment they have + stopped looking. +- A model in the loop makes it slow and non-deterministic, so the same commit + can produce different files twice. +- A comment that reads as noise sometimes carries the only record of a + constraint. That call needs a human, or at least a session where one is + present. + +Flag in CI. Fix with `/clean-comments` while the author is still reading. + +## Precision over recall + +The scan flags patterns, not judgements. A check nobody trusts gets disabled +within a week, so keep it quiet: prefer missing a bad comment to failing a +build over a good one. If a pattern produces a false positive on your codebase, +narrow it rather than adding an ignore list. + +## GitHub Actions + +```yaml +name: comments +on: pull_request + +jobs: + clean-comments: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-node@v4 + with: + node-version: '20' + - name: Check comments on changed files + env: + SKILL: .claude/skills/clean-comments + run: | + bash "$SKILL/scripts/scope.sh" --base origin/${{ github.base_ref }} --branch \ + | tr '\n' '\0' | xargs -0 -r node "$SKILL/scripts/scan.mjs" --ci +``` + +Set `SKILL` to wherever `npx skills add gpu-cli/skills --skill clean-comments` +put the skill. Drop `--ci` to report without failing the build, which is the +right first step in a repository that has never been cleaned. + +## Local pre-push hook + +A pre-push hook is the one place a local check belongs: it runs after the +author is done, and it only reports. + +```bash +#!/usr/bin/env bash +# .git/hooks/pre-push +SKILL=.claude/skills/clean-comments +bash "$SKILL/scripts/scope.sh" --branch \ + | tr '\n' '\0' | xargs -0 -r node "$SKILL/scripts/scan.mjs" || true +``` + +Leave the `|| true`. A comment finding is not a reason to block a push. diff --git a/.agents/skills/clean-comments/references/cleanup.md b/.agents/skills/clean-comments/references/cleanup.md new file mode 100644 index 00000000..2fee15b9 --- /dev/null +++ b/.agents/skills/clean-comments/references/cleanup.md @@ -0,0 +1,124 @@ +# Cleaning comments + +The workflow behind `/clean-comments [path]` and `/clean-comments all`. + +## 1. Resolve the scope + +```bash +bash scripts/scope.sh # changed files: staged, unstaged, untracked +bash scripts/scope.sh --branch # everything this branch changed vs. its base +bash scripts/scope.sh src/api # a path, file or directory +bash scripts/scope.sh --all # every source file in the repository +``` + +The script prints one path per line. It excludes vendored trees, build output, +minified bundles, lockfiles, and files carrying a generated-code banner. + +Default to the changed-files scope. Widen only when the user asked for it. In +the changed-files scope, judge only comments the diff touched — a comment that +was already in the file and is untouched by the diff is out of scope, however +bad it looks. Report the worst of those in Flagged rather than editing them. + +If the scope resolves to nothing, say so and stop. + +## 2. Find candidates + +```bash +node scripts/scan.mjs ... # TSV findings on stdout +node scripts/scan.mjs --json ... +node scripts/scan.mjs --diff-only ... # only lines the working diff touched +``` + +Each finding is `filelineruletext`. The rules it detects +mechanically are `commented-code`, `agent-reference`, `edit-history`, +`tracker-reference`, `long-comment`, `comment-block`, and `restates-name`. + +The scan is a filter, not a verdict. It finds high-signal patterns cheaply so +you read less; it cannot judge whether a comment explains something real. Two +consequences: + +- Every finding still needs the triage ladder applied by reading the code. +- A comment the scan missed is still in scope. Read the diff or the file, not + just the scan output. + +## 3. Read before editing + +For each candidate, read enough surrounding code to answer one question: does +this comment tell the reader something the code does not? You cannot answer it +from the comment alone. Do not skip this for comments that look obviously +disposable — a line that reads like narration sometimes carries the only record +of a constraint. + +## 4. Apply the ladder + +Walk each comment down the triage ladder in `SKILL.md` and stop at the first +rung that matches. [rules.md](rules.md) has the full test for each rung, and +[ste.md](ste.md) has the style for anything you keep or rewrite. + +Edit comment text only. Never change executable code, even to fix something +obvious that you notice on the way — note it in Flagged instead. + +Delete a whole comment by deleting its lines, including the now-blank line if +one is left behind. Never leave an empty `//` or a bare `#` where a comment +was. + +## 5. Verify + +```bash +node scripts/verify.mjs # working tree vs. HEAD +node scripts/verify.mjs --base # vs. another ref +``` + +It strips comments from both versions of every changed file and compares what +is left. Identical means only comment text moved. + +A failure means an edit changed code. Revert that file and redo it. Never +report a cleanup whose verification failed. Files the check could not compare +are listed as unchecked — say so in the report rather than folding them into +the pass. + +The comment stripper is quote-aware but heuristic; it is a backstop against a +slipped edit, not a proof. A clean run does not excuse careless editing, and an +unexpected failure in a language with unusual comment syntax is worth reading +before you dismiss it. + +## 6. Report + +Show the user what changed before they commit. Group by outcome, not by file, +so the ratio is legible at a glance. + +```markdown +## clean-comments — + +Deleted 14 · Rewrote 6 · Kept 31 · Flagged 2 + +### Deleted +| Location | Comment | Rung | +| --- | --- | --- | +| `src/api/user.ts:42` | `// Loop through the users` | 4 restates the code | + +### Rewritten +| Location | Before | After | +| --- | --- | --- | +| `src/cache.py:88` | `# We use a lock here to make sure...` | `# Lock: concurrent writers corrupt the cache.` | + +### Flagged +| Location | Finding | +| --- | --- | +| `src/parse.go:19` | Comment contradicts the code: says "returns nil", returns an error. | +| `src/report.rs:204` | Needs four lines of comment to follow; consider splitting the function. | +``` + +Keep Flagged short and specific. It is the part a human must act on, and it is +where rules 2, 3, and 4 land: comments that excuse unclear code, comments you +could not make clear, and comments that confuse or contradict. + +State the counts even when nothing changed. "Reviewed 31 comments in 8 files, +changed none" is a useful result and a common one in a well-kept repository. + +## Scope reminders + +- Cleaning `all` in a repository nobody has cleaned before produces a large + diff. Say so up front, and offer to go directory by directory instead. +- Never combine a cleanup with any other edit in the same commit. A + comment-only diff is reviewable at a glance; a mixed one is not. diff --git a/.agents/skills/clean-comments/references/install.md b/.agents/skills/clean-comments/references/install.md new file mode 100644 index 00000000..7eac929e --- /dev/null +++ b/.agents/skills/clean-comments/references/install.md @@ -0,0 +1,56 @@ +# Installing the guidance + +`/clean-comments install` writes the comment rules into the project's agent +instruction files, so the next agent writes fewer comments worth deleting. + +This is the highest-value part of the skill. Cleaning is repair; the guidance +stops the mess being made. Offer it the first time you clean a repository. + +## Run it + +```bash +bash scripts/install-guidance.sh --list # which files were detected +bash scripts/install-guidance.sh # dry run: prints the diff +bash scripts/install-guidance.sh --write # apply +bash scripts/install-guidance.sh --write --file docs/AGENTS.md +``` + +It detects `CLAUDE.md`, `AGENTS.md`, `GEMINI.md`, `CONVENTIONS.md`, +`.cursorrules`, `.clinerules`, `.windsurfrules`, +`.github/copilot-instructions.md`, `.claude/CLAUDE.md`, and `docs/AGENTS.md`. + +## Show the diff first + +Dry run is the default, and it stays that way. These files are the user's, and +they steer every future session, so: + +1. Run without `--write` and show the diff. +2. Say which files it would touch. +3. Get agreement, then rerun with `--write`. + +Do not create an agent file that does not exist without asking which one the +project wants. When none is found, the script says so and stops. + +## Idempotence + +The block is fenced: + +```markdown + +... + +``` + +A rerun replaces what is between the markers, so the guidance updates in place +and never stacks up. Editing the text inside the markers is fine — the next +install overwrites it, so move anything you want to keep outside the fence. + +If only one marker is present the script refuses to guess and asks for a manual +fix. + +## The text + +`assets/agent-guidance.md` holds the installed block. It is deliberately about +fifteen lines: an instruction file that nobody finishes reading changes no +behaviour. Keep edits to it short, and bump the version in the markers if the +shape changes. diff --git a/.agents/skills/clean-comments/references/rules.md b/.agents/skills/clean-comments/references/rules.md new file mode 100644 index 00000000..aabdff01 --- /dev/null +++ b/.agents/skills/clean-comments/references/rules.md @@ -0,0 +1,228 @@ +# Comment rules + +The full test for each rung of the triage ladder in `SKILL.md`, then the +principles the ladder is built from, then the hard limits. + +## The ladder, in full + +### 1. Directives — keep exactly + +A directive is read by a tool, not a person. Changing one changes behaviour, so +it is out of scope even when it looks like noise. + +Keep: linter and formatter pragmas (`eslint-disable-next-line`, `# noqa`, +`# fmt: off`, `// nolint`), type-checker pragmas (`@ts-ignore`, +`# type: ignore`, `// @ts-expect-error`), compiler and build directives +(`#pragma`, `//go:build`, `//go:generate`, `#![allow(...)]`), coverage markers +(`# pragma: no cover`, `/* istanbul ignore next */`), shebangs, encoding +declarations, SPDX and license headers, and generated-file banners +(`Code generated by ... DO NOT EDIT`). + +A generated-file banner means the whole file is out of scope. Clean the +generator instead. + +### 2. Doc comments — keep the contract + +A doc comment on an exported function, class, module, or public field is API +documentation. It has a different job from an implementation comment, and the +one-line rule does not apply to it. + +Keep what states a contract: what the thing is for, what it returns, what it +raises, units, ranges, ownership, thread-safety, side effects. + +Cut what repeats the signature. A `@param userId The user ID` line adds +nothing that `userId: string` did not already say. Drop it and keep the +parameter lines that state a real constraint. + +```python +# Before +def retry(fn, attempts=3): + """ + Retry a function. + + Args: + fn: The function to retry. + attempts: The number of attempts. Defaults to 3. + + Returns: + The result. + """ + +# After +def retry(fn, attempts=3): + """Retry fn until it returns without raising. Re-raises the last error.""" +``` + +### 3. Commented-out code — delete + +Delete it. Git holds the history, and dead code in a comment goes stale without +anyone noticing. + +The one exception is a commented line that a reader is meant to switch on, and +that says so — a config example, a debug toggle with an instruction. Keep those +and make sure the instruction is explicit. + +### 4. Restates the code — delete + +Test: cover the comment and read the code. If you learn nothing new from +uncovering it, the comment is dead weight. + +```javascript +// Loop through the users and send each one an email +for (const user of users) sendEmail(user) + +// Increment the counter +counter += 1 + +// Constructor +constructor(config) { +``` + +All three go. So does a section banner that labels a block the code already +labels (`// --- helpers ---` above a run of obvious helpers). + +### 5. Narrates an edit — delete, keep any reason + +Edit narration is a message to a reviewer who has already moved on. It reads as +present tense in a file whose history is elsewhere. + +Signals: "added", "removed", "changed", "updated", "refactored", "renamed", +"moved", "now handles", "previously", "used to", "no longer", "as requested", +"per the review", "fixed this to". + +Delete the narration. If it carried a reason, keep the reason and drop the +chronology. + +```typescript +// Before +// Refactored this to use a Map instead of an array scan because the array +// version was showing up in profiles + +// After +// Map, not array: this lookup is on the request hot path. +``` + +### 6. Names the agent or the session — delete the reference + +No comment in a repository should mention who or what wrote it. Attribution +belongs in version control. + +Signals: first person about the edit ("I've added", "I changed", "let me"), +"as an AI", "as your assistant", "this agent", model or tool names (Claude, +GPT, Copilot, Cursor, Codex) used as the author, "generated by AI", +"per your request", "as you asked", "hope this helps". + +Delete the reference. Keep the technical content when there is any. + +```go +// Before +// I've added a mutex here as you asked, to prevent the race you spotted + +// After +// Guards concurrent writers: two goroutines can reach this map. +``` + +### 7. Cites an unshared tracker — delete the ID + +An issue ID is only useful to a reader who can resolve it. In a repository +where the tracker is not committed or not synced, a bare ID is a dead end. + +Resolve the ID's substance into the comment and drop the ID. Keep the ID only +when the tracker is genuinely shared with everyone who reads the code — a +public issue URL always qualifies, and so does an issue key for a tracker the +whole team uses. + +```rust +// Before +// TODO(skills-ftp.3): wire this up once the installer lands + +// After +// TODO: wire this to the guidance installer. +``` + +Ask before stripping IDs in bulk if you cannot tell whether the tracker is +shared. `scripts/scan.mjs` reads the local beads prefix and flags matching IDs +as candidates, not as certainties. + +### 8. Marks unfinished work — rewrite to a notation + +Unfinished work in prose is invisible. Unfinished work behind a standard +notation is greppable, and most editors and linters already index it. + +| Notation | Means | +| --- | --- | +| `TODO:` | Known missing work, no urgency claim. | +| `FIXME:` | Known broken or wrong, should not survive long. | +| `HACK:` | Deliberate workaround; explain what forced it. | +| `XXX:` | Danger — a trap for the next reader. | + +Write the notation, a colon, and one line saying what remains. Add a blocker +when there is one, in words rather than an ID: `TODO: enable once the upstream +fix ships.` + +### 9. Explains something real — keep, and tighten + +This is the comment worth having. It survives, rewritten to one line in the +style of [ste.md](ste.md). + +Five kinds earn their place: + +- **A constraint** the code cannot state: a hardware erratum, a protocol + requirement, a rate limit, an ordering dependency. +- **A bug fix.** Say what broke, so nobody reverts the fix by tidying it. + `// Trailing slash required; the CDN 404s without it.` +- **Unidiomatic code.** If a reader would reasonably ask "why not the normal + way?", answer it. +- **A business rule.** `// Invoices before 2024 used the old tax rate.` +- **A source or reference.** Copied code keeps a link to where it came from, + and a subtle algorithm keeps a link to the paper or issue that explains it. + A link may take a line of its own. + +### 10. You cannot tell what it means — flag it + +If you do not understand a comment, you cannot know whether deleting it loses +something. Leave it and report it. The same goes for a comment that seems to +contradict the code: that is a bug report, not a cleanup. + +## The principles behind the ladder + +These nine rules are the reason each rung exists. When the ladder does not +settle a case, decide from these. + +1. **Comments do not duplicate the code.** — rung 4. +2. **Good comments do not excuse unclear code.** A comment explaining a + confusing block is a patch over a design problem. This skill does not change + code, so report it: *"`parse()` needs four lines of comment to be followable; + consider splitting it."* +3. **If you cannot write a clear comment, the code may be the problem.** Same + move as rule 2 — report, do not paper over. +4. **Comments dispel confusion, never cause it.** A comment that is stale, + ambiguous, or contradicted by the code is worse than none. +5. **Explain unidiomatic code** — rung 9. +6. **Link the source of copied code.** Attribution and licence both depend on + it, and the reader gets the context you had. +7. **Link external references where they help most.** Put the link at the code + that needs it, not in a README nobody opens. +8. **Comment when fixing a bug.** The comment stops the fix being tidied away. +9. **Mark incomplete implementations** — rung 8. + +Rules 2, 3, and 4 produce findings, not edits. Collect them in the Flagged +section of the report. + +## Hard limits + +- **Never change executable code.** Not a rename, not a formatting fix, not an + obvious bug. Comment text and comment whitespace only. `verify.mjs` enforces + this and a failure means revert, not override. +- **Never touch a generated file.** Fix the generator. +- **Never delete a comment you do not understand.** Flag it. +- **Never widen scope.** A diff-scoped run leaves untouched comments in a + changed file alone. +- **Never add a comment** that only records that cleanup happened. + +## Placement + +Put a comment on its own line above the code it describes, at that code's +indentation. Move a trailing comment up when it is long enough to push the line +past the file's normal width; leave short ones where they are, since moving +every one of them creates diff noise for no gain. diff --git a/.agents/skills/clean-comments/references/ste.md b/.agents/skills/clean-comments/references/ste.md new file mode 100644 index 00000000..6d5a40de --- /dev/null +++ b/.agents/skills/clean-comments/references/ste.md @@ -0,0 +1,88 @@ +# Plain style for comments + +How to write the comments that survive triage. The rules are adapted from +ASD-STE100, a controlled-English standard written so that maintenance +technicians cannot misread an instruction. A comment has the same problem: one +reader, no author present, no chance to ask. + +Apply this only to comments you keep or rewrite. Never restyle a directive, a +license header, or a quoted error string. + +## Rules + +| Rule | Do | Not | +| --- | --- | --- | +| One idea per comment | `// Lock: concurrent writers corrupt the cache.` | `// Lock for thread safety, and note the cache is also cleared on logout.` | +| Twenty words or fewer | `// Retries only on 5xx; a 4xx here means a bad token.` | A sentence that runs past the end of the line. | +| Active voice | `// The scheduler drops stale jobs.` | `// Stale jobs are dropped by the scheduler.` | +| Simple tense | `// We moved this behind the flag.` | `// This has been moved behind the flag.` | +| One word per meaning | Use `check` everywhere for the same act. | Rotate `check`, `verify`, `validate`, `confirm`. | +| Three-word noun groups at most | `// the queue priority handler` | `// the agent task queue priority handler` | +| Keep the subject and article | `// The upstream API 404s without it.` | `// 404s without it.` | +| No hedging | `// Fails on empty input.` | `// This might possibly fail sometimes.` | +| Plain word over jargon | `// Runs before the request finishes.` | `// Executes prior to request culmination.` | + +## What "one line" means + +One line means one line in the file, at the file's normal width. Not a +paragraph wrapped to look short, and not a run of `//` lines that reads as a +paragraph. + +Three exceptions: + +- A doc comment stating a contract (see rung 2 of [rules.md](rules.md)). +- A link, which may sit on its own line when it does not fit beside the text. +- A genuine algorithm note that no shorter form can carry. This is rare. If you + reach for it twice in a file, the code is the problem — flag it. + +## Worked rewrites + +```python +# Before: 27 words, passive, two ideas +# We use a lock here to make sure that we don't have any race conditions when +# multiple threads are trying to write to the cache at the same time + +# After +# Lock: concurrent writers corrupt the cache. +``` + +```javascript +// Before: hedged, no fact +// This is a bit of a hack but it seems to work for now + +// After +// HACK: Safari fires resize before layout; one frame of delay fixes it. +``` + +```go +// Before: noun stack, passive +// The user session token refresh interval value is checked by this function + +// After +// Returns how often to refresh the session token. +``` + +```rust +// Before: narrates, hedges, names the author +// I've updated this to handle the empty case, which I think was causing the +// panic you saw + +// After +// Empty input panicked upstream: https://github.com/foo/bar/issues/123 +``` + +## Reporting a rewrite + +Show the before and after when you report, so the user can judge the loss. Keep +the longer form and flag it whenever shortening would drop a number, a +condition, a scope qualifier, or a safety warning. Losing precision to save +words is a bad trade. + +## Source + +ASD-STE100 Issue 9 (2025) — 53 writing rules and a controlled dictionary, +published by the AeroSpace and Defence Industries Association of Europe: +. The condensation above follows +, which applies the same standard +to agent output. Neither the official dictionary nor its word lists are +reproduced here. diff --git a/.agents/skills/clean-comments/scripts/install-guidance.sh b/.agents/skills/clean-comments/scripts/install-guidance.sh new file mode 100755 index 00000000..b5c6dd06 --- /dev/null +++ b/.agents/skills/clean-comments/scripts/install-guidance.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +# Writes the comment rules into a project's agent instruction files. +# +# Usage: +# install-guidance.sh show what would change (default) +# install-guidance.sh --write apply it +# install-guidance.sh --file target one file instead of detecting +# install-guidance.sh --list list detected agent files and exit +# +# Idempotent: the block is fenced by markers, so a second run replaces the +# block instead of appending a copy. Dry run is the default because these are +# the user's files, not the skill's. + +set -euo pipefail + +SELF_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=lib.sh +. "$SELF_DIR/lib.sh" + +GUIDE="$SELF_DIR/../assets/agent-guidance.md" +BEGIN='' +END='' +MARKER_RE='']], strings: [DQ, SQ, BT], regex: true }; +// CSS-family: an unquoted url(//cdn...) is not a comment. +const CSS = { line: [], block: [['/*', '*/']], strings: [DQ, SQ], urlParen: true }; +const SCSS = { line: ['//'], block: [['/*', '*/']], strings: [DQ, SQ], urlParen: true }; +// Shell and Ruby: heredoc bodies are data, whatever characters they hold. +const SH = { line: ['#'], block: [], strings: [DQ, SQ_RAW], lineNeedsBoundary: true, heredoc: 'sh' }; +const PY = { + line: ['#'], block: [], strings: [DQ, SQ], + triple: [['"""', '"""'], ["'''", "'''"]], +}; +const YAML = { line: ['#'], block: [], strings: [DQ, SQ_RAW], lineNeedsBoundary: true, blockScalar: true }; + +const SYNTAX = { + c: C_LIKE, cc: C_LIKE, cpp: C_LIKE, cxx: C_LIKE, h: C_LIKE, hpp: C_LIKE, + hh: C_LIKE, cs: C_LIKE, java: C_LIKE, kt: C_LIKE, kts: C_LIKE, scala: C_LIKE, + groovy: C_LIKE, swift: C_LIKE, m: C_LIKE, mm: C_LIKE, d: C_LIKE, zig: C_LIKE, + dart: C_LIKE, v: C_LIKE, proto: C_LIKE, tf: C_LIKE, hcl: C_LIKE, + gradle: C_LIKE, + + php: { line: ['//', '#'], block: [['/*', '*/']], strings: [DQ, SQ] }, + + js: JS, jsx: JSX, mjs: JS, cjs: JS, ts: JS, tsx: JSX, mts: JS, cts: JS, + vue: JSX, svelte: JSX, astro: JSX, + + go: { line: ['//'], block: [['/*', '*/']], strings: [DQ, BT_RAW] }, + // Rust: /* */ nests, and '"' is a char literal, not a string opener. + rs: { line: ['//'], block: [['/*', '*/']], strings: [DQ], nestedBlock: true, charLit: true, docLine: DOC_LINE }, + css: CSS, scss: SCSS, sass: SCSS, less: SCSS, styl: SCSS, + sql: { line: ['--'], block: [['/*', '*/']], strings: [DQ, SQ_RAW] }, + graphql: HASH, gql: HASH, + + py: PY, pyi: PY, + rb: { line: ['#'], block: [['=begin', '=end']], strings: [DQ, SQ], heredoc: 'rb' }, + rake: { line: ['#'], block: [], strings: [DQ, SQ] }, + sh: SH, bash: SH, zsh: SH, + fish: { line: ['#'], block: [], strings: [DQ, SQ_RAW], lineNeedsBoundary: true }, + ps1: { line: ['#'], block: [['<#', '#>']], strings: [DQ, SQ_RAW], lineNeedsBoundary: true }, + pl: HASH, pm: HASH, r: HASH, jl: HASH, ex: HASH, exs: HASH, cr: HASH, + yml: YAML, yaml: YAML, + toml: HASH, tfvars: HASH, cmake: HASH, mk: HASH, + nim: { line: ['#'], block: [['#[', ']#']], strings: [DQ, SQ], lineNeedsBoundary: true }, + + lua: { line: ['--'], block: [['--[[', ']]']], strings: [DQ, SQ] }, + // Haddock doc markers: -- | documents the item below, -- ^ the one before. + hs: { line: ['--'], block: [['{-', '-}']], strings: [DQ], nestedBlock: true, docLine: ['-- |', '-- ^'] }, + clj: { line: [';'], block: [], strings: [DQ] }, + cljs: { line: [';'], block: [], strings: [DQ] }, + cljc: { line: [';'], block: [], strings: [DQ] }, + edn: { line: [';'], block: [], strings: [DQ] }, + erl: { line: ['%'], block: [], strings: [DQ], lineNeedsBoundary: true }, + hrl: { line: ['%'], block: [], strings: [DQ], lineNeedsBoundary: true }, + ml: { line: [], block: [['(*', '*)']], strings: [DQ] }, + mli: { line: [], block: [['(*', '*)']], strings: [DQ] }, + fs: { line: ['//'], block: [['(*', '*)']], strings: [DQ] }, + fsx: { line: ['//'], block: [['(*', '*)']], strings: [DQ] }, +}; + +export function syntaxFor(file) { + const ext = path.extname(file).slice(1).toLowerCase(); + return SYNTAX[ext] ?? null; +} + +const at = (s, i, lit) => s.startsWith(lit, i); + +// A `#` in `${x#y}` or `$#` is not a comment. Where the language demands it, +// a hash or percent marker only opens a comment at the start of a line or +// after whitespace. +function boundaryOk(line, i) { + return i === 0 || /\s/.test(line[i - 1]); +} + +const indentOf = (line) => line.match(/^[ \t]*/)[0].length; + +// True when a line comment at i opens with one of the syntax's doc markers. +// Repeating the marker's last character (////, //!!) makes a divider, and a +// divider judged as documentation would dodge the comment-block rule. +function isDocLine(syn, line, i) { + return (syn.docLine ?? []).some( + (d) => at(line, i, d) && line[i + d.length] !== d[d.length - 1], + ); +} + +// A regex literal may follow an operator, an opening bracket, or a keyword — +// anywhere a value can start. After a value, a slash is division. +const REGEX_BEFORE = new Set('=(,:[!&|;{}?+-*%<>~^'.split('')); +const REGEX_KEYWORD = /(^|[^\w$])(return|typeof|case|instanceof|in|of|new|delete|void|do|else|yield|await)$/; + +// Index just past a regex literal starting at i, or -1 if none closes on this +// line (a real regex cannot span lines, so no close means division). +function scanRegex(line, i) { + let inClass = false; + for (let j = i + 1; j < line.length; j++) { + const ch = line[j]; + if (ch === '\\') { j++; continue; } + if (ch === '[') inClass = true; + else if (ch === ']') inClass = false; + else if (ch === '/' && !inClass) { + j++; + while (j < line.length && /[a-z]/i.test(line[j])) j++; + return j; + } + } + return -1; +} + +// Queue heredoc delimiters opened on this line. Matching on the +// comment-stripped code keeps `# use < scalar.indent) { + codeLines.push(line); + continue; + } + scalar = null; + } + + let code = ''; + let i = 0; + let strContinues = false; + + while (i < line.length) { + if (block) { + if (syn.nestedBlock) { + let advanced = false; + for (let j = i; j < line.length; j++) { + if (at(line, j, block.open)) { block.depth++; j += block.open.length - 1; continue; } + if (at(line, j, block.end)) { + block.depth--; + if (block.depth === 0) { + block.raw += line.slice(i, j + block.end.length); + block.endLine = ln; + comments.push(block); + i = j + block.end.length; + block = null; + advanced = true; + break; + } + j += block.end.length - 1; + } + } + if (!advanced && block) { block.raw += line.slice(i) + '\n'; i = line.length; } + continue; + } + const e = line.indexOf(block.end, i); + if (e === -1) { + block.raw += line.slice(i) + '\n'; + i = line.length; + } else { + block.raw += line.slice(i, e + block.end.length); + block.endLine = ln; + comments.push(block); + i = e + block.end.length; + block = null; + } + continue; + } + + if (str) { + if (str.esc && line[i] === '\\') { + // A backslash at end of line continues the string onto the next + // line in C and JS; dropping that would turn the continuation into + // fake code with fake comments. + if (i === line.length - 1) { code += '\\'; i++; strContinues = true; continue; } + code += line.slice(i, i + 2); + i += 2; + continue; + } + if (at(line, i, str.q)) { code += str.q; i += str.q.length; str = null; continue; } + code += line[i++]; + continue; + } + + let matched = false; + + for (const [open, close] of syn.triple ?? []) { + if (!at(line, i, open)) continue; + // A triple-quoted string is a docstring only as the first statement of + // a module, class, or function. Anywhere else it is a value, and + // editing it is a code change. A docstring starts its own line: any + // code before the quote (`query = """`) makes it a value. + const isDoc = line.slice(0, i).trim() === '' && looksLikeDocstring(lines, ln, i); + if (isDoc) { + block = { open, end: close, depth: 1, startLine: ln, col: i, raw: open, kind: 'doc' }; + i += open.length; + } else { + str = { q: close, esc: false, multiline: true }; + code += open; + i += open.length; + } + matched = true; + break; + } + if (matched) continue; + + if (syn.charLit && line[i] === "'") { + const m = /^'(?:\\.|[^'\\])'/.exec(line.slice(i)); + // No close within one char means a lifetime tick, plain code either way. + const len = m ? m[0].length : 1; + code += line.slice(i, i + len); + i += len; + continue; + } + + if (syn.urlParen && /^url\(\s*[^"')]/i.test(line.slice(i))) { + const close = line.indexOf(')', i); + const end = close === -1 ? line.length : close + 1; + code += line.slice(i, end); + i = end; + continue; + } + + if (syn.regex && line[i] === '/' && line[i + 1] !== '/' && line[i + 1] !== '*') { + const prev = code.replace(/\s+$/, ''); + const prevCh = prev.slice(-1); + if (prevCh === '' || REGEX_BEFORE.has(prevCh) || REGEX_KEYWORD.test(prev)) { + const end = scanRegex(line, i); + if (end !== -1) { code += line.slice(i, end); i = end; continue; } + } + } + + for (const [open, close] of syn.block) { + if (!at(line, i, open)) continue; + block = { + open, end: close, depth: 1, startLine: ln, col: i, raw: open, + kind: (open === '/*' && at(line, i, '/**')) + || (open === '(*' && at(line, i, '(**')) ? 'doc' : 'block', + }; + i += open.length; + matched = true; + break; + } + if (matched) continue; + + for (const marker of syn.line) { + if (!at(line, i, marker)) continue; + if (syn.lineNeedsBoundary && !boundaryOk(line, i)) continue; + comments.push({ + startLine: ln, endLine: ln, col: i, raw: line.slice(i), kind: 'line', + doc: isDocLine(syn, line, i), + }); + i = line.length; + matched = true; + break; + } + if (matched) continue; + + const s = syn.strings.find((d) => at(line, i, d.q)); + if (s) { + str = { q: s.q, esc: s.esc, multiline: s.multiline }; + code += s.q; + i += s.q.length; + continue; + } + + code += line[i++]; + } + + codeLines.push(code); + if (str && !str.multiline && !strContinues) str = null; + + if (syn.heredoc) queueHeredocs(code, syn.heredoc, heredocs); + if (syn.blockScalar && /(?:^|:|-)\s*[|>][+-]?\d*$/.test(code.trim())) { + scalar = { indent: indentOf(line) }; + } + } + + if (block) { block.endLine = lines.length - 1; comments.push(block); } + return { comments, codeLines }; +} + +// A docstring's home is the first statement after a def or class signature, +// or the top of the file. A line that merely ends with ':' (a dict key, an +// if) does not qualify: a bare string there is a value, and misreading it as +// a docstring hides edits from verify. +function looksLikeDocstring(lines, ln, col) { + for (let k = ln - 1; k >= 0; k--) { + const prev = lines[k].trim(); + if (!prev || prev.startsWith('#')) continue; + return /^(def|class|async\s+def)\b.*:\s*$/.test(prev) + || /^\)\s*(->[^:]*)?:\s*$/.test(prev); // close of a multi-line signature + } + return col === 0; // start of file: a module docstring +} + +/** Comment markers stripped, so detectors see prose. */ +export function commentBody(c) { + return c.raw + .replace(/^\/\*+|\*+\/$/g, '') + .replace(/^()|-->$/g, '') + .replace(/^("""|''')|("""|''')$/g, '') + .replace(/^(=begin|=end)|^\(\*|\*\)$|^\{-|-\}$|^<#|#>$|^#\[|\]#$/g, '') + .split('\n') + .map((l) => l.replace(/^\s*(\/\/+!?|#+|--+|;+|%+|\*)\s?/, '').trim()) + .join(' ') + .replace(/\s+/g, ' ') + .trim(); +} + +/** Code with comments removed, blank lines dropped: two files agree here iff + * only comment text differs. */ +export function codeSkeleton(source, syn) { + return tokenize(source, syn).codeLines + .map((l) => l.replace(/\s+$/, '')) + .filter((l) => l.trim() !== '') + .join('\n'); +} diff --git a/.agents/skills/clean-comments/scripts/lib.sh b/.agents/skills/clean-comments/scripts/lib.sh new file mode 100644 index 00000000..43bab0b0 --- /dev/null +++ b/.agents/skills/clean-comments/scripts/lib.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# Shared helpers for the clean-comments scripts. Sourcing has no side effects. +# +# Requires: git. + +cc_die() { printf 'clean-comments: %s\n' "$1" >&2; exit 1; } + +cc_require_repo() { + git rev-parse --show-toplevel >/dev/null 2>&1 || cc_die "not a git repository" +} + +# Base ref for --branch: the argument, then the upstream, then origin's default +# branch, then a local main or master. +cc_base_ref() { + local want="${1:-}" + if [ -n "$want" ]; then printf '%s' "$want"; return; fi + local up + up=$(git rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' 2>/dev/null) \ + && [ -n "$up" ] && { printf '%s' "$up"; return; } + local head + head=$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null) \ + && [ -n "$head" ] && { printf '%s' "$head"; return; } + for b in main master; do + git rev-parse --verify --quiet "$b" >/dev/null && { printf '%s' "$b"; return; } + done + printf '' +} + +CC_EXTENSIONS='c|cc|cpp|cxx|h|hpp|hh|cs|java|kt|kts|scala|groovy|swift|m|mm|go|rs|zig|d|js|jsx|mjs|cjs|ts|tsx|mts|cts|vue|svelte|astro|py|pyi|rb|rake|php|pl|pm|lua|r|jl|dart|ex|exs|erl|hrl|clj|cljs|cljc|edn|hs|ml|mli|fs|fsx|nim|cr|v|sh|bash|zsh|fish|ps1|sql|tf|hcl|proto|graphql|gql|css|scss|sass|less|styl|yml|yaml|toml|tfvars|gradle|cmake|mk' + +# Filters stdin paths to cleanable source files. +cc_filter_paths() { + local p + while IFS= read -r p; do + [ -f "$p" ] || continue + case "$p" in + */node_modules/*|node_modules/*|*/vendor/*|vendor/*|*/third_party/*|\ + */dist/*|dist/*|*/build/*|build/*|*/target/*|target/*|*/out/*|out/*|\ + */.next/*|*/.nuxt/*|*/coverage/*|*/__pycache__/*|*/.venv/*|*/venv/*|\ + */Pods/*|*/.git/*|*/.beads/*|.beads/*|*/migrations/*) continue ;; + *.min.js|*.min.css|*.bundle.js|*.map|*.snap|*.lock|*-lock.json|\ + package-lock.json|yarn.lock|pnpm-lock.yaml|go.sum) continue ;; + esac + printf '%s' "$p" | grep -Eq "\.($CC_EXTENSIONS)\$" || continue + cc_is_generated "$p" && continue + printf '%s\n' "$p" + done +} + +# A generated file declares itself in its opening lines. Its comments belong to +# the generator, so cleaning them here is lost work at the next regeneration. +cc_is_generated() { + head -n 8 "$1" 2>/dev/null \ + | grep -Eqi '@generated|do not edit|code generated by|autogenerated|auto-generated' +} + +# Issue prefix of a local beads tracker, empty when there is none. +cc_beads_prefix() { + local root cfg + root=$(git rev-parse --show-toplevel 2>/dev/null) || return 0 + cfg="$root/.beads/config.yaml" + if [ -f "$cfg" ]; then + local p + p=$(grep -E '^[[:space:]]*issue-prefix:' "$cfg" 2>/dev/null \ + | head -n1 | sed -E 's/.*issue-prefix:[[:space:]]*"?([^"#]*)"?.*/\1/' \ + | tr -d '[:space:]') + [ -n "$p" ] && { printf '%s' "$p"; return 0; } + fi + [ -d "$root/.beads" ] && basename "$root" +} diff --git a/.agents/skills/clean-comments/scripts/scan.mjs b/.agents/skills/clean-comments/scripts/scan.mjs new file mode 100755 index 00000000..f66cfe25 --- /dev/null +++ b/.agents/skills/clean-comments/scripts/scan.mjs @@ -0,0 +1,258 @@ +#!/usr/bin/env node +// Flags comments that probably break the clean-comments rules. +// +// Usage: scan.mjs [--json] [--ci] [--diff-only] [--base ] +// [--tracker-prefix

] ... +// +// Output is TSV: file, line, rule, text. Exit 0 unless --ci is set and a +// high-confidence rule fired. +// +// This is a filter, not a verdict. It finds cheap high-signal patterns so a +// reader can skip the rest of the file; judging whether a comment earns its +// place needs the code around it. + +import fs from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { syntaxFor, tokenize, commentBody } from './lib.mjs'; + +const HIGH_CONFIDENCE = new Set([ + 'commented-code', 'agent-reference', 'edit-history', 'tracker-reference', +]); + +// Tested against the first lines of a comment only: a directive or license +// marker deep inside a long prose comment must not exempt the prose around it. +const DIRECTIVE = new RegExp([ + 'eslint-|prettier-|tslint:|biome-ignore|oxlint-|stylelint-|jscpd:', + '@ts-(ignore|expect-error|nocheck)|type:\\s*ignore|mypy:|pyright:', + 'noqa|pylint:|flake8:|fmt:\\s*(off|on)|isort:|rustfmt::|clippy::', + 'nolint|golangci|go:(build|generate|embed|linkname|noinline)|\\+build', + 'pragma|istanbul ignore|c8 ignore|v8 ignore|codecov|sonar|checkstyle', + 'SuppressWarnings|shellcheck|SPDX-|Copyright|Licensed under|@license', + '@generated|DO NOT EDIT|coding[:=]|!\\[|eslint\\s|deno-lint', +].join('|'), 'i'); + +const AGENT = new RegExp([ + "\\bas an ai\\b|\\bas your (ai )?assistant\\b|\\bthis (agent|assistant)\\b", + "\\bi'?ve\\b|\\bi (have |had |will |just )?(added|updated|changed|removed|fixed|refactored|created|implemented|noticed|think)\\b", + '\\blet me \\b|\\bper your request\\b|\\bas (you )?requested\\b|\\bas you asked\\b', + '\\bhope this helps\\b|\\bfeel free to\\b|\\bai[- ]generated\\b', + '\\bgenerated by (ai|claude|gpt|chatgpt|copilot|cursor|codex)\\b', + '\\b(claude|chatgpt|copilot|cursor|codex|the model)\\b[^.]{0,40}\\b(added|generated|wrote|created|suggested|suggests)\\b', +].join('|'), 'i'); + +// A leading past-tense verb is narration in a sentence and a noun phrase in a +// label: "Updated the retry logic so 5xx backs off" vs. "changed files". +const EDIT_HISTORY_VERB = + /^(added|removed|deleted|changed|updated|refactored|renamed|moved|switched|replaced|migrated|converted)\b/i; + +const EDIT_HISTORY = new RegExp([ + '\\bnow (handles|returns|uses|supports)\\b|\\bno longer\\b|\\bused to (be|have|return)\\b', + '\\bpreviously\\b|\\bper the review\\b|\\baddressing (the )?(review|feedback|comments)\\b', + '\\bas discussed\\b|\\bas mentioned (above|earlier)\\b', +].join('|'), 'i'); + +// A comment above a declaration documents it, so the one-line rule does not +// apply. A comment before any code is a file header and does the same job. An +// attribute or decorator line sits between a doc comment and its item and +// counts the same. +const DECLARATION = + /\b(function|def|fn|func|class|struct|interface|type|enum|const|let|var|public|private|protected|static|async|module|package|pub|trait|impl)\b|^\s*[\w.$]+\s*\(\)\s*\{|^\s*[A-Za-z_$][\w$]*\s*=|^\s*(#\[|@\w)/; + +const DIVIDER = /[-=*_]{3,}/; + +const CODE_SHAPE = [ + /^(if|for|while|switch|return|import|from|export|const|let|var|def|class|func|fn|public|private|protected|static|package|use|require|elif|else|try|catch|throw|await|async)\b.*[({=:;]/, + /^[\w.$[\]]+\([^)]*\)\s*[;{]?$/, + /^[\w.$[\]]+\s*[-+*/]?=[^=]/, + /^[})\]];?$/, + /^(#include|#define|@\w+\()/, +]; + +const STOPWORDS = new Set([ + 'the', 'a', 'an', 'this', 'that', 'these', 'to', 'of', 'for', 'and', 'or', + 'is', 'are', 'be', 'it', 'in', 'on', 'with', 'from', 'by', 'we', 'you', + 'method', 'function', 'class', 'constructor', 'helper', 'field', 'property', + 'variable', 'value', 'returns', 'return', 'sets', 'set', 'gets', 'get', +]); + +function parseArgs(argv) { + const opts = { json: false, ci: false, diffOnly: false, base: 'HEAD', prefix: '', files: [] }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--json') opts.json = true; + else if (a === '--ci') opts.ci = true; + else if (a === '--diff-only') opts.diffOnly = true; + else if (a === '--base') opts.base = argv[++i]; + else if (a === '--tracker-prefix') opts.prefix = argv[++i]; + else if (a === '-h' || a === '--help') { usage(); process.exit(0); } + else if (a.startsWith('-')) { console.error(`clean-comments: unknown option: ${a}`); process.exit(2); } + else opts.files.push(a); + } + return opts; +} + +function usage() { + console.log(fs.readFileSync(new URL(import.meta.url), 'utf8') + .split('\n').slice(2, 12).map((l) => l.replace(/^\/\/ ?/, '')).join('\n')); +} + +// The prefix reaches us from a config file or a flag, so it is data, not a +// pattern. +const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +function beadsPrefix() { + try { + const root = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim(); + const cfg = `${root}/.beads/config.yaml`; + if (fs.existsSync(cfg)) { + const m = fs.readFileSync(cfg, 'utf8').match(/^\s*issue-prefix:\s*"?([\w-]+)"?/m); + if (m) return m[1]; + } + if (fs.existsSync(`${root}/.beads`)) return root.split('/').pop(); + } catch { /* no repo, no tracker */ } + return ''; +} + +// 1-based lines the working diff touched. Null means "treat every line as new". +function touchedLines(file, base) { + try { + execFileSync('git', ['ls-files', '--error-unmatch', file], { stdio: 'ignore' }); + } catch { + return null; + } + const diff = execFileSync('git', ['diff', '-U0', '--no-color', base, '--', file], { encoding: 'utf8' }); + const set = new Set(); + for (const m of diff.matchAll(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/gm)) { + const start = Number(m[1]); + const count = m[2] === undefined ? 1 : Number(m[2]); + for (let i = 0; i < count; i++) set.add(start + i); + } + return set; +} + +// Consecutive line comments at the same indent read as one comment. +function group(comments) { + const out = []; + for (const c of comments) { + const prev = out[out.length - 1]; + const shebang = /^#!/.test(c.raw) || (prev && /^#!/.test(prev.raw)); + if (prev && !shebang && c.kind === 'line' && prev.kind === 'line' + && !!c.doc === !!prev.doc + && c.startLine === prev.endLine + 1 && c.col === prev.col) { + prev.endLine = c.endLine; + prev.raw += '\n' + c.raw; + prev.lines++; + } else { + out.push({ ...c, lines: c.endLine - c.startLine + 1 }); + } + } + return out; +} + +function identifierWords(codeLine) { + const m = codeLine.match( + /\b(?:function|def|fn|func|class|struct|interface|type|enum|const|let|var|public|private|protected|static|async)\s+([A-Za-z_$][\w$]*)/, + ) ?? codeLine.match(/^\s*([A-Za-z_$][\w$]*)\s*[:=(]/); + if (!m) return null; + return m[1] + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/[_-]+/g, ' ') + .toLowerCase() + .split(/\s+/) + .filter(Boolean); +} + +function detect(g, ctx) { + const body = commentBody(g); + if (!body) return null; + const head = g.raw.split('\n').slice(0, 3).join('\n'); + if (DIRECTIVE.test(head) || /^#!/.test(g.raw)) return null; + if (body.replace(/[^a-z0-9]/gi, '').length < 3) return null; + + const words = body.split(/\s+/).filter(Boolean); + const isDoc = g.kind === 'doc' || g.doc === true; + const next = ctx.codeLines.slice(g.endLine + 1).find((l) => l.trim() !== ''); + const isHeader = !ctx.codeLines.slice(0, g.startLine) + .some((l) => l.trim() !== '' && !l.startsWith('#!')); + const documents = isDoc || isHeader || (next ? DECLARATION.test(next) : false); + + // Prose can look like an assignment ("width = height in square mode"), so + // code shape alone is not enough: real code carries code punctuation. + if (!isDoc && CODE_SHAPE.some((re) => re.test(body)) && !/[.!?]$/.test(body) + && /[;(){}[\]'"`]|\d/.test(body)) { + return { rule: 'commented-code', body }; + } + if (AGENT.test(body)) return { rule: 'agent-reference', body }; + if (EDIT_HISTORY.test(body) || (EDIT_HISTORY_VERB.test(body) && words.length >= 5)) { + return { rule: 'edit-history', body }; + } + if (!/https?:\/\//.test(body)) { + const jira = body.match(/\b[A-Z][A-Z0-9]{1,9}-\d+\b/); + const bead = ctx.prefix + ? body.match(new RegExp(`\\b${escapeRe(ctx.prefix)}-[a-z0-9]+(\\.\\d+)?\\b`, 'i')) + : null; + if (jira || bead) return { rule: 'tracker-reference', body }; + } + + const ident = next && !DIVIDER.test(body) ? identifierWords(next) : null; + if (ident) { + const said = words.map((w) => w.toLowerCase().replace(/[^\w]/g, '')) + .filter((w) => w && !STOPWORDS.has(w)); + if (said.length && said.every((w) => ident.includes(w))) { + return { rule: 'restates-name', body }; + } + } + + if (!documents) { + if (g.lines >= 3) return { rule: 'comment-block', body }; + if (words.length > 25) return { rule: 'long-comment', body }; + } + return null; +} + +function main() { + const opts = parseArgs(process.argv.slice(2)); + if (!opts.files.length) { + if (process.stdin.isTTY) { + console.error('clean-comments: no files given and stdin is a terminal.'); + usage(); + process.exit(2); + } + opts.files = fs.readFileSync(0, 'utf8').split('\n').map((s) => s.trim()).filter(Boolean); + } + const prefix = opts.prefix || beadsPrefix(); + const findings = []; + + for (const file of opts.files) { + const syn = syntaxFor(file); + if (!syn) continue; + let source; + try { source = fs.readFileSync(file, 'utf8'); } catch { continue; } + + const { comments, codeLines } = tokenize(source, syn); + const touched = opts.diffOnly ? touchedLines(file, opts.base) : null; + + for (const g of group(comments)) { + if (opts.diffOnly && touched) { + let hit = false; + for (let l = g.startLine; l <= g.endLine && !hit; l++) hit = touched.has(l + 1); + if (!hit) continue; + } + const hit = detect(g, { codeLines, prefix }); + if (hit) findings.push({ file, line: g.startLine + 1, rule: hit.rule, text: hit.body }); + } + } + + if (opts.json) { + console.log(JSON.stringify(findings, null, 2)); + } else { + for (const f of findings) { + const text = f.text.replace(/\t/g, ' ').slice(0, 160); + console.log(`${f.file}\t${f.line}\t${f.rule}\t${text}`); + } + } + + if (opts.ci && findings.some((f) => HIGH_CONFIDENCE.has(f.rule))) process.exit(1); +} + +main(); diff --git a/.agents/skills/clean-comments/scripts/scope.sh b/.agents/skills/clean-comments/scripts/scope.sh new file mode 100755 index 00000000..1c807a2c --- /dev/null +++ b/.agents/skills/clean-comments/scripts/scope.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# Resolves a cleanup scope to source files, one path per line. +# +# Usage: +# scope.sh changed files (staged, unstaged, untracked) +# scope.sh --branch files this branch changed vs. its base +# scope.sh --all every source file in the repository +# scope.sh ... the given files or directories +# +# Requires: git. Prints nothing and exits 0 when the scope is empty. + +set -euo pipefail + +SELF_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=lib.sh +. "$SELF_DIR/lib.sh" + +mode=changed +paths=() + +while [ $# -gt 0 ]; do + case "$1" in + --branch) mode=branch ;; + --all) mode=all ;; + --base) [ $# -ge 2 ] || cc_die "--base needs a ref"; shift; CC_BASE_REF="$1" ;; + -h|--help) sed -n '2,12p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;; + -*) cc_die "unknown option: $1" ;; + *) mode=paths; paths+=("$1") ;; + esac + shift +done + +cc_require_repo + +# quotepath=off keeps non-ASCII names literal; octal-escaped quoting would +# make every downstream file test fail and silently drop the file. +GIT="git -c core.quotepath=off" + +case "$mode" in + changed) + { $GIT diff --name-only --diff-filter=d HEAD 2>/dev/null || true + $GIT ls-files --others --exclude-standard; } ;; + branch) + base=$(cc_base_ref "${CC_BASE_REF:-}") + [ -z "$base" ] && cc_die "cannot resolve a base ref; pass --base " + $GIT diff --name-only --diff-filter=d "$base"...HEAD + $GIT diff --name-only --diff-filter=d HEAD + $GIT ls-files --others --exclude-standard ;; + all) + $GIT ls-files --cached --others --exclude-standard ;; + paths) + for p in "${paths[@]}"; do + [ -e "$p" ] || cc_die "no such path: $p" + if [ -d "$p" ]; then + $GIT ls-files --cached --others --exclude-standard "$p" + else + printf '%s\n' "$p" + fi + done ;; +esac | sort -u | cc_filter_paths diff --git a/.agents/skills/clean-comments/scripts/verify.mjs b/.agents/skills/clean-comments/scripts/verify.mjs new file mode 100755 index 00000000..3c85d3fc --- /dev/null +++ b/.agents/skills/clean-comments/scripts/verify.mjs @@ -0,0 +1,142 @@ +#!/usr/bin/env node +// Checks that a cleanup changed comment text and nothing else. +// +// Usage: verify.mjs [--base ] [--json] [...] +// +// Strips every comment from both versions of each changed file and compares +// what is left. Equal means only comment text moved. Exit 1 on any difference, +// and on any file whose base version could not be read. +// +// The stripper is quote-aware but still a heuristic, so this is a backstop +// against a slipped edit rather than a proof. A failure is authoritative; +// treat a pass as strong evidence. Changed files with no known comment syntax +// are listed as unchecked — the pass does not speak for them. + +import fs from 'node:fs'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { syntaxFor, codeSkeleton } from './lib.mjs'; + +function parseArgs(argv) { + const opts = { base: 'HEAD', json: false, files: [] }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--base') opts.base = argv[++i]; + else if (a === '--json') opts.json = true; + else if (a === '-h' || a === '--help') { usage(); process.exit(0); } + else if (a.startsWith('-')) { console.error(`clean-comments: unknown option: ${a}`); process.exit(2); } + else opts.files.push(a); + } + return opts; +} + +function usage() { + console.log(fs.readFileSync(new URL(import.meta.url), 'utf8') + .split('\n').slice(2, 8).map((l) => l.replace(/^\/\/ ?/, '')).join('\n')); +} + +// Never let git write to our stderr directly; a leaked "fatal:" line reads as +// our own failure. +const git = (args) => execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); + +function inBase(base, file) { + try { + execFileSync('git', ['cat-file', '-e', `${base}:${file}`], { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +function changedFiles(base) { + return git(['-c', 'core.quotepath=off', 'diff', '--name-only', base]).split('\n').filter(Boolean); +} + +function firstDifference(a, b) { + const x = a.split('\n'); + const y = b.split('\n'); + for (let i = 0; i < Math.max(x.length, y.length); i++) { + if (x[i] !== y[i]) { + return { index: i + 1, before: x[i] ?? '(end of file)', after: y[i] ?? '(end of file)' }; + } + } + return null; +} + +function main() { + const opts = parseArgs(process.argv.slice(2)); + + // Work from the repository root: git paths are root-relative, and running + // from a subdirectory must not turn "path exists in base" into "new file". + const cwd = process.cwd(); + const root = git(['rev-parse', '--show-toplevel']).trim(); + opts.files = opts.files.map((f) => path.relative(root, path.resolve(cwd, f))); + process.chdir(root); + + const files = opts.files.length ? opts.files : changedFiles(opts.base); + const results = []; + + for (const file of files) { + const syn = syntaxFor(file); + if (!syn) { + results.push({ file, status: 'unchecked', detail: 'no known comment syntax' }); + continue; + } + + if (!inBase(opts.base, file)) { + results.push({ file, status: 'added', detail: 'not in base; nothing to compare' }); + continue; + } + let before; + try { + before = git(['show', `${opts.base}:${file}`]); + } catch (e) { + // The file exists in base but could not be read: fail closed, never + // report a comparison that did not happen as a pass. + const msg = (e.stderr || String(e)).toString().trim().split('\n')[0]; + results.push({ file, status: 'fail', detail: `could not read base version (${msg})` }); + continue; + } + if (!fs.existsSync(file)) { + results.push({ file, status: 'fail', detail: 'file was deleted; that is a code change' }); + continue; + } + + const diff = firstDifference(codeSkeleton(before, syn), codeSkeleton(fs.readFileSync(file, 'utf8'), syn)); + results.push(diff + ? { file, status: 'fail', detail: `code line ${diff.index} changed`, before: diff.before, after: diff.after } + : { file, status: 'ok' }); + } + + const failed = results.filter((r) => r.status === 'fail'); + const unchecked = results.filter((r) => r.status === 'unchecked'); + + if (opts.json) { + console.log(JSON.stringify({ ok: failed.length === 0, results }, null, 2)); + } else if (!results.length) { + console.log('clean-comments: no changed files to compare.'); + } else { + if (!failed.length) { + const checked = results.filter((r) => r.status === 'ok').length; + const added = results.filter((r) => r.status === 'added').length; + console.log(`clean-comments: comment-only in ${checked} file(s)${added ? `, ${added} skipped as new` : ''}.`); + } else { + console.log(`clean-comments: CODE CHANGED in ${failed.length} file(s).\n`); + for (const f of failed) { + console.log(` ${f.file}: ${f.detail}`); + if (f.before !== undefined) { + console.log(` before: ${f.before.trim()}`); + console.log(` after: ${f.after.trim()}`); + } + } + console.log('\nRevert these files and redo the cleanup. Do not commit.'); + } + if (unchecked.length) { + console.log(`NOT checked (no known comment syntax) — review by hand: ${unchecked.map((r) => r.file).join(', ')}`); + } + } + + process.exit(failed.length ? 1 : 0); +} + +main(); diff --git a/.agents/skills/clean-comments/tests/selftest.sh b/.agents/skills/clean-comments/tests/selftest.sh new file mode 100755 index 00000000..f304e460 --- /dev/null +++ b/.agents/skills/clean-comments/tests/selftest.sh @@ -0,0 +1,543 @@ +#!/usr/bin/env bash +# clean-comments self-test: exercises the bundled scripts in a throwaway git +# repo. The cases that matter most are the ones where a naive comment stripper +# would let a real code change through — a `//` inside a URL string, a `#` +# inside a shell parameter expansion, a Python triple-quoted value that is not +# a docstring. +# +# Usage: bash selftest.sh (exits non-zero if any assertion fails) +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CORE_DIR="$(dirname "$SCRIPT_DIR")" +S="$CORE_DIR/scripts" + +pass=0; fail=0 +ok() { pass=$((pass+1)); printf ' ok %s\n' "$1"; } +bad() { fail=$((fail+1)); printf ' FAIL %s\n' "$1"; } +assert() { if eval "$2"; then ok "$1"; else bad "$1 -> [$2]"; fi; } +assert_grep() { if printf '%s' "$3" | grep -q "$2"; then ok "$1"; else bad "$1 (no match /$2/)"; fi; } +assert_not_grep() { if printf '%s' "$3" | grep -q "$2"; then bad "$1 (unexpected /$2/)"; else ok "$1"; fi; } + +TMP="$(mktemp -d 2>/dev/null || echo /tmp/cc-selftest.$$)" +trap 'rm -rf "$TMP"' EXIT +cd "$TMP" || exit 1 +git init -q -b main +git config user.name "Selftest Bot" +git config user.email "selftest@example.com" + +echo "clean-comments selftest in $TMP" + +# --- verify: only comment text may change ---------------------------------- + +cat > app.js <<'EOF' +// Loop through the users and send each one an email +const endpoint = "https://example.com/a"; +for (const u of users) send(u, endpoint); +EOF +git add -A && git commit -q -m init + +cat > app.js <<'EOF' +const endpoint = "https://example.com/a"; +for (const u of users) send(u, endpoint); +EOF +out=$(node "$S/verify.mjs" 2>&1); rc=$? +assert "verify passes a comment-only deletion" "[ $rc -eq 0 ]" +assert_grep "verify names the file count" "comment-only in 1 file" "$out" + +# A `//` inside a string must not read as a comment, or this change hides. +cat > app.js <<'EOF' +// Loop through the users and send each one an email +const endpoint = "https://example.com/CHANGED"; +for (const u of users) send(u, endpoint); +EOF +out=$(node "$S/verify.mjs" 2>&1); rc=$? +assert "verify catches a change inside a URL string" "[ $rc -eq 1 ]" +assert_grep "verify reports the changed line" "CODE CHANGED" "$out" +git checkout -q app.js + +# Trailing comments: removing one is fine, editing the code beside it is not. +cat > trail.go <<'EOF' +package main + +func main() { + x := compute(1) // this computes the thing + _ = x +} +EOF +git add -A && git commit -q -m trail +cat > trail.go <<'EOF' +package main + +func main() { + x := compute(1) + _ = x +} +EOF +out=$(node "$S/verify.mjs" 2>&1); rc=$? +assert "verify passes trailing-comment removal" "[ $rc -eq 0 ]" + +cat > trail.go <<'EOF' +package main + +func main() { + x := compute(2) + _ = x +} +EOF +out=$(node "$S/verify.mjs" 2>&1); rc=$? +assert "verify catches a code edit beside a removed comment" "[ $rc -eq 1 ]" +git checkout -q trail.go + +# Python: a docstring is a comment, a triple-quoted value is code. +cat > svc.py <<'EOF' +def fetch(url): + """ + Fetch a URL. + + Args: + url: The URL to fetch. + """ + query = """ + SELECT id FROM users WHERE active = 1 + """ + return run(query, url) +EOF +git add -A && git commit -q -m py + +cat > svc.py <<'EOF' +def fetch(url): + """Fetch url and return the active user ids.""" + query = """ + SELECT id FROM users WHERE active = 1 + """ + return run(query, url) +EOF +out=$(node "$S/verify.mjs" 2>&1); rc=$? +assert "verify passes a docstring rewrite" "[ $rc -eq 0 ]" + +cat > svc.py <<'EOF' +def fetch(url): + """Fetch url and return the active user ids.""" + query = """ + SELECT id FROM users WHERE active = 0 + """ + return run(query, url) +EOF +out=$(node "$S/verify.mjs" 2>&1); rc=$? +assert "verify catches an edit inside a triple-quoted value" "[ $rc -eq 1 ]" +git checkout -q svc.py + +# A triple-quoted value directly after a `:` line is still a value: `query =` +# before the quote means no docstring, whatever the previous line ends with. +cat > direct.py <<'EOF' +def fetch(url): + query = """ + SELECT id FROM users WHERE active = 1 + """ + return run(query, url) +EOF +git add -A && git commit -q -m directpy +sed_inplace() { sed -i.bak "$1" "$2" && rm -f "$2.bak"; } +sed_inplace 's/active = 1/active = 0/' direct.py +out=$(node "$S/verify.mjs" 2>&1); rc=$? +assert "verify catches an edit in a triple-quoted value after 'def f():'" "[ $rc -eq 1 ]" +git checkout -q direct.py + +# A template literal spans lines, so a // on a continuation line is string +# content, not a comment that hides the rest of the line from verify. +cat > tpl.ts <<'EOF' +const tpl = ` + visit https://example.com/v1 for docs +`; +EOF +git add -A && git commit -q -m tpl +sed_inplace 's|/v1|/v2|' tpl.ts +out=$(node "$S/verify.mjs" 2>&1); rc=$? +assert "verify catches an edit after // inside a multiline template literal" "[ $rc -eq 1 ]" +git checkout -q tpl.ts + +# Same shape in Go: a raw backtick string spans lines. +cat > raw.go <<'EOF' +package main + +const usage = ` + see https://example.com/v1 for docs +` +EOF +git add -A && git commit -q -m rawgo +sed_inplace 's|/v1|/v2|' raw.go +out=$(node "$S/verify.mjs" 2>&1); rc=$? +assert "verify catches an edit after // inside a Go raw string" "[ $rc -eq 1 ]" +git checkout -q raw.go + +# A JS regex literal can contain //; everything after it is still code. +cat > re.js <<'EOF' +const ok = /https:\/\//.test(u); +if (ok) grant(); else deny(); +EOF +git add -A && git commit -q -m re +sed_inplace 's/else deny()/else grant()/' re.js +out=$(node "$S/verify.mjs" 2>&1); rc=$? +assert "verify catches a change after a regex literal containing //" "[ $rc -eq 1 ]" +git checkout -q re.js + +# Heredoc bodies are data: a # line inside one is not a comment. +cat > hd.sh <<'EOF' +#!/usr/bin/env bash +cat <&1); rc=$? +assert "verify catches a change inside a shell heredoc" "[ $rc -eq 1 ]" +git checkout -q hd.sh + +cat > hd.rb <<'EOF' +CONF = <<~TXT + retries=3 # max 3 then abort +TXT +EOF +git add -A && git commit -q -m hdrb +sed_inplace 's/max 3/max 9/' hd.rb +out=$(node "$S/verify.mjs" 2>&1); rc=$? +assert "verify catches a change inside a ruby heredoc" "[ $rc -eq 1 ]" +git checkout -q hd.rb + +# YAML block scalars are strings: a # inside one is not a comment. +cat > ci.yml <<'EOF' +steps: + - name: fetch + script: | + curl -fsSL https://example.com/install.sh # v1 pinned +EOF +git add -A && git commit -q -m yml +sed_inplace 's/v1 pinned/v2 unpinned/' ci.yml +out=$(node "$S/verify.mjs" 2>&1); rc=$? +assert "verify catches a change inside a YAML block scalar" "[ $rc -eq 1 ]" +git checkout -q ci.yml + +# A triple-quoted string under a dict key is a value, not a docstring. +cat > q.py <<'EOF' +QUERIES = { + 'active': + """SELECT id FROM users WHERE active = 1""", +} +EOF +git add -A && git commit -q -m qpy +sed_inplace 's/active = 1/active = 0/' q.py +out=$(node "$S/verify.mjs" 2>&1); rc=$? +assert "verify catches an edit in a triple-quoted dict value" "[ $rc -eq 1 ]" +git checkout -q q.py + +# Rust: '"' is a char literal and must not flip string state. +cat > ch.rs <<'EOF' +fn main() { + let q = '"'; + let url = "https://x/v1"; +} +EOF +git add -A && git commit -q -m chrs +sed_inplace 's|/v1|/v2|' ch.rs +out=$(node "$S/verify.mjs" 2>&1); rc=$? +assert "verify catches a change after a rust '\"' char literal" "[ $rc -eq 1 ]" +git checkout -q ch.rs + +# A backslash at end of line continues the string onto the next line. +printf 'const s = "abc\\\nxyz // limit is 5";\n' > cont.js +git add -A && git commit -q -m cont +printf 'const s = "abc\\\nxyz // limit is 9";\n' > cont.js +out=$(node "$S/verify.mjs" 2>&1); rc=$? +assert "verify catches a change on a backslash-continued string line" "[ $rc -eq 1 ]" +git checkout -q cont.js + +# The shebang is executable metadata, not a removable comment. +cat > she.py <<'EOF' +#!/usr/bin/env python3 +print("hi") +EOF +git add -A && git commit -q -m she +sed_inplace 's/python3/python2/' she.py +out=$(node "$S/verify.mjs" 2>&1); rc=$? +assert "verify catches a shebang edit" "[ $rc -eq 1 ]" +git checkout -q she.py + +# PHP hash comments are comments; cleaning one passes. +cat > w.php <<'EOF' + w.php <<'EOF' +&1); rc=$? +assert "verify passes a PHP hash-comment removal" "[ $rc -eq 0 ]" +git checkout -q w.php + +# Python allows a comment with no space before the #. +cat > tight.py <<'EOF' +x=1# legacy default +EOF +git add -A && git commit -q -m tight +cat > tight.py <<'EOF' +x=1 +EOF +out=$(node "$S/verify.mjs" 2>&1); rc=$? +assert "verify passes removing a no-space python comment" "[ $rc -eq 0 ]" +git checkout -q tight.py + +# Running from a subdirectory must not misread files as new. +mkdir -p sub && cat > sub/deep.js <<'EOF' +// note +const a = 1; +EOF +git add -A && git commit -q -m sub +sed_inplace 's/a = 1/a = 2/' sub/deep.js +out=$(cd sub && node "$S/verify.mjs" deep.js 2>&1); rc=$? +assert "verify works from a subdirectory" "[ $rc -eq 1 ]" +git checkout -q sub/deep.js + +# Verify names files it has no syntax for instead of silently passing them. +printf 'all:\n\techo hi\n' > Makefile +cat > ok.js <<'EOF' +// note +const b = 1; +EOF +git add -A && git commit -q -m mk +printf 'all:\n\techo BYE\n' > Makefile +cat > ok.js <<'EOF' +const b = 1; +EOF +out=$(node "$S/verify.mjs" 2>&1); rc=$? +assert "verify still passes the checkable file" "[ $rc -eq 0 ]" +assert_grep "verify names the unchecked file" "NOT checked.*Makefile" "$out" +git checkout -q Makefile ok.js + +# Shell: `#` inside ${x#y} is not a comment. +cat > tool.sh <<'EOF' +#!/usr/bin/env bash +# strip the prefix +rel=${path#/opt/} +echo "$rel" +EOF +git add -A && git commit -q -m sh +cat > tool.sh <<'EOF' +#!/usr/bin/env bash +rel=${path#/usr/} +echo "$rel" +EOF +out=$(node "$S/verify.mjs" 2>&1); rc=$? +assert "verify catches a change inside a shell expansion" "[ $rc -eq 1 ]" +git checkout -q tool.sh + +# --- scan: detectors -------------------------------------------------------- + +cat > fixture.ts <<'EOF' +// eslint-disable-next-line no-console +console.log("keep me"); + +// I've added a mutex here as you asked +const lock = new Mutex(); + +// Refactored this to use a Map instead of an array scan +const index = new Map(); + +// const old = users.filter(u => u.active); + +// Trailing slash required; the CDN 404s without it. +const base = "https://cdn.example.com/"; + +// TODO(proj-ftp.3): wire this to the installer +function pending() {} + +// Get user name +function getUserName() {} + +function handle(req) { + // This block takes the incoming request and then validates every single one + // of the fields on it before it hands the whole thing off to the persistence + // layer for storage in the database. + persist(validate(req)); +} + +// Defaults (override before calling anything) +const timeout = 30; +EOF + +out=$(node "$S/scan.mjs" --tracker-prefix proj fixture.ts 2>&1) +assert_grep "scan flags an agent reference" "agent-reference" "$out" +assert_grep "scan flags edit narration" "edit-history" "$out" +assert_grep "scan flags commented-out code" "commented-code" "$out" +assert_grep "scan flags a tracker id" "tracker-reference" "$out" +assert_grep "scan flags a name restatement" "restates-name" "$out" +assert_grep "scan flags a comment block" "comment-block" "$out" +assert_not_grep "scan leaves the eslint directive alone" "no-console" "$out" +assert_not_grep "scan leaves a real constraint alone" "CDN 404s" "$out" +assert_not_grep "scan reads prose with parentheses as prose" "commented-code" "$(node "$S/scan.mjs" clean.ts 2>&1; node "$S/scan.mjs" fixture.ts 2>&1 | grep Defaults)" + +# A long comment documenting a declaration, or heading a file, is doing its job. +cat > documented.sh <<'EOF' +#!/usr/bin/env bash +# Renders collected rows as Markdown tables. One table per stream, the current +# checkout first, then a summary. Callers pass JSON on stdin, and an empty +# array renders the empty-state line rather than an empty table. + +# Resolves a branch name to a usable git ref. Prints HEAD when the branch is +# the current checkout. Fails when the name resolves to nothing, because the +# caller can report that better than this function can. +resolve_ref() { + echo "$1" +} + +run() { + # This inner block explains at length something that the reader could have + # worked out from four lines of very ordinary shell code sitting right below + # it, which is exactly the shape the rule is meant to catch. + echo hello +} +EOF +out=$(node "$S/scan.mjs" documented.sh 2>&1) +assert_not_grep "scan exempts a file header" "Renders collected rows" "$out" +assert_not_grep "scan exempts a comment documenting a declaration" "Resolves a branch" "$out" +assert_grep "scan still flags a block inside a body" "comment-block" "$out" + +# Rustdoc (///, //!) states a contract: rung 2, never an ordinary comment +# block. A //// divider is not documentation and stays subject to the rule. +cat > doc.rs <<'EOF' +/// Expands to the boilerplate impls, at some length across multiple +/// lines, so a detector that reads rustdoc as an ordinary comment +/// block would flag it even though it documents the macro below. +macro_rules! boilerplate { + () => {}; +} + +/// Parses a config file into a Config. Returns an error when the file +/// is missing or malformed, because the caller decides whether a +/// default configuration is an acceptable substitute. +#[derive(Debug)] +pub struct ConfigParser {} + +mod tests { + //! Verifies the parser against the fixtures directory, spanning + //! enough lines that the block detector would fire if inner docs + //! were judged as ordinary commentary rather than documentation. + use super::*; +} + +fn after() { + do_thing(); + //// -------------------- + //// A slash divider is not documentation and reads as an ordinary + //// comment block when it rambles on for this many lines in a body. + do_more(); +} +EOF +out=$(node "$S/scan.mjs" doc.rs 2>&1) +assert_not_grep "scan exempts /// rustdoc above a macro" "boilerplate impls" "$out" +assert_not_grep "scan exempts /// rustdoc above an attribute" "Parses a config file" "$out" +assert_not_grep "scan exempts //! inner docs" "fixtures directory" "$out" +assert_grep "scan judges a //// divider as an ordinary comment" "slash divider" "$out" + +cat > label.sh <<'EOF' +#!/usr/bin/env bash +list() { + # changed files (whole range) + git diff --name-only +} +EOF +out=$(node "$S/scan.mjs" label.sh 2>&1) +assert_not_grep "scan reads a noun-phrase label as a label" "edit-history" "$out" + +out=$(node "$S/scan.mjs" --json --tracker-prefix proj fixture.ts 2>&1) +assert_grep "scan emits json" '"rule":' "$out" + +node "$S/scan.mjs" --ci --tracker-prefix proj fixture.ts >/dev/null 2>&1 +assert "scan --ci fails on a high-confidence hit" "[ $? -eq 1 ]" + +cat > clean.ts <<'EOF' +// Lock: concurrent writers corrupt the cache. +const lock = new Mutex(); +EOF +node "$S/scan.mjs" --ci clean.ts >/dev/null 2>&1 +assert "scan --ci passes a clean file" "[ $? -eq 0 ]" + +# A directive word buried in prose is prose; a directive is at the top. +cat > prose.ts <<'EOF' +// I've painted the button black as you asked +const btn = paint(); + +// width = height in square mode +const sq = resize(); +EOF +out=$(node "$S/scan.mjs" prose.ts 2>&1) +assert_grep "scan flags an agent reference despite a formatter's name" "agent-reference" "$out" +assert_not_grep "scan reads a prose assignment as prose" "commented-code" "$out" + +# A URL keeps a tracker id resolvable, so it is not a finding. +cat > linked.ts <<'EOF' +// Upstream panics on empty input: https://github.com/foo/bar/issues/123 +const guard = true; +EOF +out=$(node "$S/scan.mjs" --tracker-prefix proj linked.ts 2>&1) +assert_not_grep "scan keeps a linked reference" "tracker-reference" "$out" + +# --- scope ------------------------------------------------------------------ + +mkdir -p node_modules/pkg src +echo "// junk" > node_modules/pkg/index.js +echo "const a = 1;" > src/real.js +printf '// Code generated by tool. DO NOT EDIT.\nconst b = 2;\n' > src/gen.js +echo "binary" > src/data.bin + +out=$(bash "$S/scope.sh" --all 2>&1) +assert_grep "scope includes a source file" "src/real.js" "$out" +assert_not_grep "scope excludes node_modules" "node_modules" "$out" +assert_not_grep "scope excludes generated files" "src/gen.js" "$out" +assert_not_grep "scope excludes unknown extensions" "data.bin" "$out" + +out=$(bash "$S/scope.sh" src 2>&1) +assert_grep "scope accepts a directory" "src/real.js" "$out" + +git add -A && git commit -q -m fixtures +echo "const c = 3;" >> src/real.js +out=$(bash "$S/scope.sh" 2>&1) +assert_grep "scope defaults to changed files" "src/real.js" "$out" +git checkout -q src/real.js + +# --- install-guidance ------------------------------------------------------- + +printf '# Agent Instructions\n\nBe careful.\n' > CLAUDE.md +out=$(bash "$S/install-guidance.sh" 2>&1) +assert_grep "install dry-runs by default" "would append" "$out" +assert_not_grep "install writes nothing on a dry run" "BEGIN clean-comments" "$(cat CLAUDE.md)" + +out=$(bash "$S/install-guidance.sh" --write 2>&1) +assert_grep "install appends the block" "appended" "$out" +assert_grep "install wrote the marker" "BEGIN clean-comments" "$(cat CLAUDE.md)" +assert_grep "install wrote the rules" "Say why, not what" "$(cat CLAUDE.md)" +assert_grep "install kept the existing content" "Be careful" "$(cat CLAUDE.md)" + +out=$(bash "$S/install-guidance.sh" --write 2>&1) +assert_grep "install is idempotent" "already current" "$out" +count=$(grep -c "BEGIN clean-comments" CLAUDE.md) +assert "install did not duplicate the block" "[ $count -eq 1 ]" + +printf 'changed\n' >> CLAUDE.md +sed -i.bak 's/Say why, not what./Say why./' CLAUDE.md && rm -f CLAUDE.md.bak +out=$(bash "$S/install-guidance.sh" --write 2>&1) +assert_grep "install updates a stale block" "updated" "$out" +assert_grep "install restored the text" "Say why, not what" "$(cat CLAUDE.md)" +assert_grep "install kept trailing content" "changed" "$(cat CLAUDE.md)" + +out=$(bash "$S/install-guidance.sh" --list 2>&1) +assert_grep "install lists targets" "CLAUDE.md" "$out" + +# --- results ---------------------------------------------------------------- + +printf '\n%s passed, %s failed\n' "$pass" "$fail" +[ "$fail" -eq 0 ] diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index 8fe18eb1..ca5929a6 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -720,3 +720,95 @@ {"id":"int-6f9c6e644baf2449e91869a17729241e","kind":"field_change","created_at":"2026-08-13T19:32:36.780624Z","actor":"Angus Bezzina","issue_id":"attn-yqun.1","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Implemented and verified alongside its blocker attn-yqun.2 (both live in the same file and were delivered together)."}} {"id":"int-51676f7b5d9d3e056f3a43635b805b57","kind":"field_change","created_at":"2026-08-13T19:32:37.451815Z","actor":"Angus Bezzina","issue_id":"attn-yqun","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"All four children delivered and verified."}} {"id":"int-a2fa5464ca29380354e1e2caca3d7554","kind":"field_change","created_at":"2026-08-14T14:22:37.059751Z","actor":"Angus Bezzina","issue_id":"attn-yqun.7","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Fixed and covered by e2e 'renders anchors that arrive as reactive proxies' (verified to fail when the fix is reverted)."}} +{"id":"int-63b5b341ca01bfd1024c351a513b0876","kind":"field_change","created_at":"2026-08-15T15:55:07.724068Z","actor":"Angus Bezzina","issue_id":"attn-is2m.1","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}} +{"id":"int-b7831404343599cb2a2794bc52489792","kind":"field_change","created_at":"2026-08-15T15:55:08.826035Z","actor":"Angus Bezzina","issue_id":"attn-is2m.2","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}} +{"id":"int-9aa7be538d9acc792cdda4fe35fee3d4","kind":"field_change","created_at":"2026-08-15T15:55:09.587723Z","actor":"Angus Bezzina","issue_id":"attn-is2m.3","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}} +{"id":"int-6ecf865572dcdd84a78c53342d94ca55","kind":"field_change","created_at":"2026-08-15T15:55:10.281545Z","actor":"Angus Bezzina","issue_id":"attn-is2m","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}} +{"id":"int-f49a4948bf0e5664dccc4add6a9cff69","kind":"field_change","created_at":"2026-08-15T16:35:50.732294Z","actor":"Angus Bezzina","issue_id":"attn-bb6t.1","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Implemented: the 3px left ::before strip is replaced by a uniform 2px border in the author identity color (--rmc-accent), 6px radius kept, padding rebalanced to 9px/11px so content sits at the same inset. Active/selected no longer overrides border-color (that would erase identity) — it is an outer ring instead; hover gets a softer accent-colored halo. Stale/ambiguous keep their state colors. The pseudo-element, its isolate/z-index stacking context and the attn-bw2h.1 -1px offsets are gone with it; rationale comments rewritten rather than deleted."}} +{"id":"int-9cd9f1f20be41675fb3caa559f52c125","kind":"field_change","created_at":"2026-08-15T16:35:51.413165Z","actor":"Angus Bezzina","issue_id":"attn-bb6t.2","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Implemented: excerpt (.rmc-quote) removed from anchored cards and the quotePreview prop/quotePreviewFor helper deleted; stale cards keep .rmc-stale-quote as decided. Bidirectional hover: doc->card already existed (strengthened to a visible halo); card->doc added via applyReviewHoverHighlight(view, eventId) which toggles an is-hovered class straight on the mark DOM — no decoration rebuild, honoring the existing perf rule. Wired in all three hosts (App, BrowserReviewApp, EditorShell) in an effect separate from the rebuild effect, re-applied after PM redraws. Also implemented is-focused styling in app.css, which was emitted by the plugin but had no CSS anywhere."}} +{"id":"int-0d09951006f6675db3b6386d95050743","kind":"field_change","created_at":"2026-08-15T16:35:52.194978Z","actor":"Angus Bezzina","issue_id":"attn-bb6t.4","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Implemented across the stack. Rust: ReviewEventBody::CommentReopened{thread_id,reopened_by}, authz arm in inbound.rs mirroring resolve (non-agent, self-attributed), ReviewCommand::ReopenComment + reopen_comment() minting via the outbox, IPC review_reopen_comment, command/event name maps, stub arm. Web: CommentReopenedBody + union, browser-session.reopenComment() with the view-tier guard, browser-ws eventAllowedForRole mirror, review-counts fold, and reopenComment mirrored through the whole session facade chain (share-session, share-production, owner-authority, owner-workspace-runtime, hosted EditingSession, real/mock services). Projection: reconstructThreads now folds resolve/reopen LAST-WRITER-WINS by compareEvents instead of a one-way Set — array order is not log order, so out-of-order delivery was the real trap. Test vector 6 (comment_reopened) added to the shared signing corpus and replayed green. Tests: 3 selector cases (reopen, resolve/reopen/resolve, out-of-order), 3 count cases, 2 Rust authz cases."}} +{"id":"int-a2942bf8a7cd1405bb00f3f08f94828b","kind":"field_change","created_at":"2026-08-15T16:36:10.093999Z","actor":"Angus Bezzina","issue_id":"attn-bb6t.3","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Implemented both directions. Card->frame: added 'hovered' to AnchorRenderState (distinct tier from 'active', which means focused) plus a third ::highlight(attn-text-hover) bucket in repaintHighlights and [data-state=hovered] overlay/pin styles. The restore-on-unhover problem is owned by the bridge, not the shells: setHoveredAnchor() reads the retained #rendered specs for the base state, so a resolved anchor cannot be left painted as unresolved. Frame->shell: new additive doc->shell anchorHover{anchorId|null} message with a parseDocMessage validator arm (unvalidated messages are dropped silently), reported from onPointerMove BEFORE the inspect gate so hover linking works on a document whose click-to-comment mode is off. Element anchors hit-test via data-anchor-id chrome + element.contains (innermost wins); text ranges via Range.getClientRects since a CSS Custom Highlight is not a DOM node and gets no events. mouseleave clears. DOC_PROTOCOL_VERSION stays 1 (additive). Generated runtime artifacts rebuilt. Tests: 4 protocol validator cases + 2 Playwright E2E (real range geometry hit-test, and hovered-vs-active painting); the geometry test was verified to fail with the reporting call disabled."}} +{"id":"int-19846f31e9e1c73b9d27947e00e09039","kind":"field_change","created_at":"2026-08-15T16:36:10.70758Z","actor":"Angus Bezzina","issue_id":"attn-bb6t.5","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Implemented. Lucide check replaces the literal glyph in all three places (card meta tick, labeled resolved chip, gutter icon chip) — first icon import in the rail; 'check' confirmed present in the pinned @lucide/svelte 0.561 and imported by deep path per the bundle-guard convention. Unresolve: the deliberately-empty resolved footer branch (attn-42y) now carries one action, gated like resolve (readOnly/reviewerAuthoring); ReviewMargin.unresolveThread() mirrors resolveThread including optimism — new store.restoreThreadLocally() is the inverse of dismissThreadLocally, plus collapseResolvedThread so the reopened card leaves read-only presentation. Wired natively (reviewReopenComment IPC) and on both hosted surfaces (reopenBrowserComment / reopenReview). Full-width badge: the labeled chip now spans the rail (left+right 12px, matching card insets) instead of hugging its content; the 48px gutter icon variant explicitly opts back out, and the stacked/mobile variant uses align-self stretch."}} +{"id":"int-e50eee5dc1a5a6429d5316e23da52da5","kind":"field_change","created_at":"2026-08-15T16:36:21.955958Z","actor":"Angus Bezzina","issue_id":"attn-bb6t","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"All five children implemented and verified. Cross-cutting notes for whoever picks up attn-is2m: (1) the 48px collapsed gutter still renders resolved icon-chips — attn-is2m.3 retires that gutter, so those chips go with it; (2) CommentReopened is a new event variant, so receivers on 0.9.0 and earlier reject it and will keep showing a reopened thread as resolved — same compatibility family as attn-mz25, worth folding into that bead's rollout note. Verification: 561 Rust tests, 125 web test files, 29 html-annotation E2E, svelte-check clean, clippy clean, production web build OK."}} +{"id":"int-9d718eaa1c3bb84d6ecb1eda71416d70","kind":"field_change","created_at":"2026-08-19T03:49:53.384885Z","actor":"Angus Bezzina","issue_id":"attn-08fa.1","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Decided: license bullets via DESIGN.md amendment; implementation in attn-08fa.4"}} +{"id":"int-c5be3d3a22c77a63eda2a143c89a2fa1","kind":"field_change","created_at":"2026-08-19T03:49:54.548196Z","actor":"Angus Bezzina","issue_id":"attn-08fa.2","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Decided: adopt accent plane on all /app routes; implementation in attn-08fa.13/.10"}} +{"id":"int-55983ad964702263a6431a00caf76225","kind":"field_change","created_at":"2026-08-19T04:26:44.411119Z","actor":"Angus Bezzina","issue_id":"attn-08fa.3","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Ruled and applied: hero lede, PRODUCT.md positioning, and the Hero comment quoting it all now say 'never leave your machine in the clear'"}} +{"id":"int-9418724347666d5d6cb0ffd311fc7309","kind":"field_change","created_at":"2026-08-19T04:27:03.292667Z","actor":"Angus Bezzina","issue_id":"attn-08fa.4","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"DESIGN.md now licenses ul::marker as a third labelling exception with the budget consequence stated; CURRENT pill de-accented to rail-chip + ink. Parity test passes."}} +{"id":"int-8ecdd4f549e3490d8b2fcfce5bf14f15","kind":"field_change","created_at":"2026-08-19T04:27:03.901708Z","actor":"Angus Bezzina","issue_id":"attn-08fa.5","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"8 base-chrome greens neutralised to ink (local-badge x2, share ready-mark/progress/feedback/stopped, meter, status-box); only .review-suggestions keeps green. --green now aliases new canonical --suggestion-ink in tokens.css, documented in DESIGN.md + TOKEN_MAP."}} +{"id":"int-37dac23770e7c3aa9145eb7440460698","kind":"field_change","created_at":"2026-08-19T04:27:04.597681Z","actor":"Angus Bezzina","issue_id":"attn-08fa.6","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Share meta + advanced summary + tier value + /open formats moved mono->sans meta step; /open and entry-card measures capped. In-page detector: zero tiny-text and zero line-length across all five routes (verified)."}} +{"id":"int-5b1fdada9f1aaece73efecceb44a1307","kind":"field_change","created_at":"2026-08-19T04:27:05.291789Z","actor":"Angus Bezzina","issue_id":"attn-08fa.7","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"StoragePage: all styling inline styles removed (only the data-driven --meter-fill remains), sizes on the ramp, glyphs moved to aria-hidden spans, mono prose -> sans, raw error.message replaced with title + next step + demoted detail."}} +{"id":"int-719aca91e2030b04b8fcc119783d7368","kind":"field_change","created_at":"2026-08-19T04:27:05.957616Z","actor":"Angus Bezzina","issue_id":"attn-08fa.9","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Active row measured at 19% ink fill + 2px accent bar, matching DESIGN.md. Root cause was shadcn's bg-sidebar-accent utility (10%) beating the layered rule; fix moved to the sanctioned unlayered sidebar hard-override block."}} +{"id":"int-531d8d687779c720474419424d7425f5","kind":"field_change","created_at":"2026-08-19T04:27:06.612559Z","actor":"Angus Bezzina","issue_id":"attn-08fa.10","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"NotFound.svelte is now the one recovery surface, parameterised; workspace-not-found renders through it. Both get the accent-plane header, fixed 2rem display type (clamp removed), capped measure, two real buttons, matching punctuation."}} +{"id":"int-de19fa9b6da81d05f3db851dab26b8f3","kind":"field_change","created_at":"2026-08-19T04:27:07.258448Z","actor":"Angus Bezzina","issue_id":"attn-08fa.11","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Hosted theme is now 3-state (System/Paper/Ink) with live OS tracking, matching native; palette gained Appearance, Keyboard shortcuts, Add files, Go to your desk, Storage & recovery; new ShortcutsSheet reachable via ? and the palette. Verified live: 10 palette commands, sheet opens."}} +{"id":"int-f09e81fa9cd63ec0dd1db4b5dc72a0f1","kind":"field_change","created_at":"2026-08-19T04:27:07.908238Z","actor":"Angus Bezzina","issue_id":"attn-08fa.13","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Accent plane adopted on desk/open/storage via data-slot=app-shell-header. Root cause found: the re-pointing block lived in app.css, which the hosted entry does not import — moved to tokens.css so one definition serves both bundles. Badge/button on-plane treatments added; verified both themes."}} +{"id":"int-95c38dd1147fd7dbc58cd5af26eb64ff","kind":"field_change","created_at":"2026-08-19T04:27:39.680684Z","actor":"Angus Bezzina","issue_id":"attn-08fa.8","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Verified: zero 'invite link' in hosted surfaces; 'View-only' now only the share tier (header badge renamed 'Storage blocked'); lifecycle titles recast off sentence-initial 'Attn'; 'Your Desk'->'Your desk'; crypto-erases, attn-account, Import handoff, .attn-workspace (soon), garbled Join card, and 'Hybrid delivery' all replaced. Raw error.message passthrough replaced on both /open and storage."}} +{"id":"int-e28f7f115fceeeeec0ad464f94827fcd","kind":"field_change","created_at":"2026-08-19T04:33:01.181694Z","actor":"Angus Bezzina","issue_id":"attn-a9f7.1.1","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Implemented on angus/attn-web-polish-2 (commit 3f0f563); verified by svelte-check, 125/126 web tests, route-bundle gate, detector, and browser probes against the production bundle."}} +{"id":"int-83463e3631a283650a876273b98a3176","kind":"field_change","created_at":"2026-08-19T04:33:01.894549Z","actor":"Angus Bezzina","issue_id":"attn-a9f7.1.2","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Implemented on angus/attn-web-polish-2 (commit 3f0f563); verified by svelte-check, 125/126 web tests, route-bundle gate, detector, and browser probes against the production bundle."}} +{"id":"int-da19765cd81b5bebf602154f89b9ad96","kind":"field_change","created_at":"2026-08-19T04:33:02.570514Z","actor":"Angus Bezzina","issue_id":"attn-a9f7.1.3","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Implemented on angus/attn-web-polish-2 (commit 3f0f563); verified by svelte-check, 125/126 web tests, route-bundle gate, detector, and browser probes against the production bundle."}} +{"id":"int-28dc5f9d1ebb51b777eb876cc7ce75d6","kind":"field_change","created_at":"2026-08-19T04:33:03.294584Z","actor":"Angus Bezzina","issue_id":"attn-a9f7.1.4","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Implemented on angus/attn-web-polish-2 (commit 3f0f563); verified by svelte-check, 125/126 web tests, route-bundle gate, detector, and browser probes against the production bundle."}} +{"id":"int-f37e3e6393259460c525bc351434b1f5","kind":"field_change","created_at":"2026-08-19T04:33:04.025659Z","actor":"Angus Bezzina","issue_id":"attn-a9f7.1.5","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Implemented on angus/attn-web-polish-2 (commit 3f0f563); verified by svelte-check, 125/126 web tests, route-bundle gate, detector, and browser probes against the production bundle."}} +{"id":"int-33d485016d993b51c392cc034c5f049d","kind":"field_change","created_at":"2026-08-19T04:33:04.753477Z","actor":"Angus Bezzina","issue_id":"attn-a9f7.1.6","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Implemented on angus/attn-web-polish-2 (commit 3f0f563); verified by svelte-check, 125/126 web tests, route-bundle gate, detector, and browser probes against the production bundle."}} +{"id":"int-bf4402d9c5efb796539046fbd9b07aff","kind":"field_change","created_at":"2026-08-19T04:33:05.501893Z","actor":"Angus Bezzina","issue_id":"attn-a9f7.1.7","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Implemented on angus/attn-web-polish-2 (commit 3f0f563); verified by svelte-check, 125/126 web tests, route-bundle gate, detector, and browser probes against the production bundle."}} +{"id":"int-efa22ce3aad596647044511928d2a314","kind":"field_change","created_at":"2026-08-19T04:33:06.171196Z","actor":"Angus Bezzina","issue_id":"attn-a9f7.1.8","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Implemented on angus/attn-web-polish-2 (commit 3f0f563); verified by svelte-check, 125/126 web tests, route-bundle gate, detector, and browser probes against the production bundle."}} +{"id":"int-fafff2fa715d061794cadb3abd29bec5","kind":"field_change","created_at":"2026-08-19T04:33:06.895002Z","actor":"Angus Bezzina","issue_id":"attn-a9f7.2.1","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Implemented on angus/attn-web-polish-2 (commit 3f0f563); verified by svelte-check, 125/126 web tests, route-bundle gate, detector, and browser probes against the production bundle."}} +{"id":"int-00a040efb5b222b18790d35af493f753","kind":"field_change","created_at":"2026-08-19T04:33:07.628073Z","actor":"Angus Bezzina","issue_id":"attn-a9f7.2.2","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Implemented on angus/attn-web-polish-2 (commit 3f0f563); verified by svelte-check, 125/126 web tests, route-bundle gate, detector, and browser probes against the production bundle."}} +{"id":"int-ba83791ef79df424b5c7392264bcc40f","kind":"field_change","created_at":"2026-08-19T04:33:08.384062Z","actor":"Angus Bezzina","issue_id":"attn-a9f7.3.1","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Implemented on angus/attn-web-polish-2 (commit 3f0f563); verified by svelte-check, 125/126 web tests, route-bundle gate, detector, and browser probes against the production bundle."}} +{"id":"int-a62d1dc5d17f30c76fd4743a569172df","kind":"field_change","created_at":"2026-08-19T04:33:09.078065Z","actor":"Angus Bezzina","issue_id":"attn-a9f7.3.2","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Implemented on angus/attn-web-polish-2 (commit 3f0f563); verified by svelte-check, 125/126 web tests, route-bundle gate, detector, and browser probes against the production bundle."}} +{"id":"int-65334fa529a4c533bcc4675e0e76065d","kind":"field_change","created_at":"2026-08-19T04:33:09.76716Z","actor":"Angus Bezzina","issue_id":"attn-a9f7.3.3","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Implemented on angus/attn-web-polish-2 (commit 3f0f563); verified by svelte-check, 125/126 web tests, route-bundle gate, detector, and browser probes against the production bundle."}} +{"id":"int-c141e7cfcc12a056447f46fc7253b629","kind":"field_change","created_at":"2026-08-19T04:33:10.52225Z","actor":"Angus Bezzina","issue_id":"attn-a9f7.3.4","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Implemented on angus/attn-web-polish-2 (commit 3f0f563); verified by svelte-check, 125/126 web tests, route-bundle gate, detector, and browser probes against the production bundle."}} +{"id":"int-599edc6faed2732cc59c3a5969550162","kind":"field_change","created_at":"2026-08-19T04:33:11.420156Z","actor":"Angus Bezzina","issue_id":"attn-a9f7.1","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"All child tasks complete in commit 3f0f563."}} +{"id":"int-104c5cc7b98d5ab2c74ca8cd208eb9d0","kind":"field_change","created_at":"2026-08-19T04:33:11.815793Z","actor":"Angus Bezzina","issue_id":"attn-a9f7.2","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"All child tasks complete in commit 3f0f563."}} +{"id":"int-31bd37459de8add177b1a24aeb2a4479","kind":"field_change","created_at":"2026-08-19T04:33:24.885681Z","actor":"Angus Bezzina","issue_id":"attn-a9f7.2.3","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Type-ramp half implemented and enforced by a new test; motion half assessed and deliberately declined (see notes)."}} +{"id":"int-f61ceac47a4b0e28b8384bbfd9b3f716","kind":"field_change","created_at":"2026-08-19T04:33:35.834157Z","actor":"Angus Bezzina","issue_id":"attn-a9f7.3","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"All Phase 3 tasks complete in commit 3f0f563."}} +{"id":"int-5e1e438c93b254b76b469bf6a154d3e8","kind":"field_change","created_at":"2026-08-19T11:36:58.622581Z","actor":"Angus Bezzina","issue_id":"attn-a9f7","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Implemented, verified, and merged into angus/comment-card-redesign (merge 80b10a9; branch commit 3f0f563)."}} +{"id":"int-fd16031102d0266eba49a7369ee16b57","kind":"field_change","created_at":"2026-08-19T15:45:04.055744Z","actor":"Angus Bezzina","issue_id":"attn-mkmz","extra":{"field":"status","new_value":"in_progress","old_value":"open"}} +{"id":"int-aa27fc9d1350ac43b6908ec65405689e","kind":"field_change","created_at":"2026-08-19T15:45:04.563608Z","actor":"Angus Bezzina","issue_id":"attn-mkmz.1","extra":{"field":"status","new_value":"in_progress","old_value":"open"}} +{"id":"int-578e9eea8a2276c5fcd4abccb59975c1","kind":"field_change","created_at":"2026-08-19T15:45:05.053757Z","actor":"Angus Bezzina","issue_id":"attn-mkmz.2","extra":{"field":"status","new_value":"in_progress","old_value":"open"}} +{"id":"int-1064b3f04fd974dc4eaa4e7d2409bede","kind":"field_change","created_at":"2026-08-19T15:45:05.545731Z","actor":"Angus Bezzina","issue_id":"attn-mkmz.3","extra":{"field":"status","new_value":"in_progress","old_value":"open"}} +{"id":"int-90de867344a94bc152f2831be9bab828","kind":"field_change","created_at":"2026-08-19T15:45:06.007448Z","actor":"Angus Bezzina","issue_id":"attn-mkmz.4","extra":{"field":"status","new_value":"in_progress","old_value":"open"}} +{"id":"int-b80b0c4be91262e6a4b08867dd747a50","kind":"field_change","created_at":"2026-08-19T15:45:06.460631Z","actor":"Angus Bezzina","issue_id":"attn-mkmz.5","extra":{"field":"status","new_value":"in_progress","old_value":"open"}} +{"id":"int-a3341024640bbd0cd883f4bdda20ee60","kind":"field_change","created_at":"2026-08-19T15:45:06.893585Z","actor":"Angus Bezzina","issue_id":"attn-mkmz.6","extra":{"field":"status","new_value":"in_progress","old_value":"open"}} +{"id":"int-39667b6dbde0265f341f29325ffb4e72","kind":"field_change","created_at":"2026-08-19T15:45:07.357585Z","actor":"Angus Bezzina","issue_id":"attn-mkmz.7","extra":{"field":"status","new_value":"in_progress","old_value":"open"}} +{"id":"int-b894dbff8ad64fbe6df47170730e2337","kind":"field_change","created_at":"2026-08-19T16:07:39.640957Z","actor":"Angus Bezzina","issue_id":"attn-mkmz.1","extra":{"field":"status","new_value":"closed","old_value":"in_progress"}} +{"id":"int-39816362d97d73b469a89c7a3b48e73e","kind":"field_change","created_at":"2026-08-19T16:07:58.08917Z","actor":"Angus Bezzina","issue_id":"attn-mkmz.2","extra":{"field":"status","new_value":"closed","old_value":"in_progress"}} +{"id":"int-1c9143f396a679dcf1b71884b2dcbdf8","kind":"field_change","created_at":"2026-08-19T16:07:58.589685Z","actor":"Angus Bezzina","issue_id":"attn-mkmz.3","extra":{"field":"status","new_value":"closed","old_value":"in_progress"}} +{"id":"int-45f936899256dd9e976a45471cb18896","kind":"field_change","created_at":"2026-08-19T16:08:17.948798Z","actor":"Angus Bezzina","issue_id":"attn-mkmz.4","extra":{"field":"status","new_value":"closed","old_value":"in_progress"}} +{"id":"int-a04d2f11f0a4cca348a5c2d653f87360","kind":"field_change","created_at":"2026-08-19T16:08:18.419867Z","actor":"Angus Bezzina","issue_id":"attn-mkmz.8","extra":{"field":"status","new_value":"closed","old_value":"open"}} +{"id":"int-2647646ac85e1f2fe381623cc49d5f53","kind":"field_change","created_at":"2026-08-19T16:08:35.307487Z","actor":"Angus Bezzina","issue_id":"attn-mkmz.5","extra":{"field":"status","new_value":"closed","old_value":"in_progress"}} +{"id":"int-58f58980afb23d89f34cc9b9b7a346b9","kind":"field_change","created_at":"2026-08-19T16:09:03.46072Z","actor":"Angus Bezzina","issue_id":"attn-mkmz.6","extra":{"field":"status","new_value":"closed","old_value":"in_progress"}} +{"id":"int-e818fb3587cd472653bc0495dc487739","kind":"field_change","created_at":"2026-08-19T16:09:03.935879Z","actor":"Angus Bezzina","issue_id":"attn-mkmz.7","extra":{"field":"status","new_value":"closed","old_value":"in_progress"}} +{"id":"int-18594ff69f18c264c28adfb2fc79cd03","kind":"field_change","created_at":"2026-08-19T16:11:53.933042Z","actor":"Angus Bezzina","issue_id":"attn-mkmz","extra":{"field":"status","new_value":"closed","old_value":"in_progress"}} +{"id":"int-44f46ccff46d4d4066757475b1a11cc3","kind":"field_change","created_at":"2026-08-19T16:32:35.804687Z","actor":"Angus Bezzina","issue_id":"attn-uld5","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Fixed: distinct cacheDir per config (node_modules/.vite/native and /browser). Verified both dev servers running concurrently with all four surfaces error-free; svelte-check, npm test, both builds and route-bundles green."}} +{"id":"int-123a9a191c6308b313716d74078dd8fd","kind":"field_change","created_at":"2026-08-19T17:27:43.097168Z","actor":"Angus Bezzina","issue_id":"attn-9npk.1","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}} +{"id":"int-1d1a2eccae7fe283383b1044e7159600","kind":"field_change","created_at":"2026-08-19T17:27:43.440126Z","actor":"Angus Bezzina","issue_id":"attn-9npk.2","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}} +{"id":"int-5c05b5e6c2cd472755b00cb53ff8fef8","kind":"field_change","created_at":"2026-08-19T17:27:43.766254Z","actor":"Angus Bezzina","issue_id":"attn-9npk.3","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}} +{"id":"int-5ffd21771b77a74f607cc9ef3cf2ae7b","kind":"field_change","created_at":"2026-08-19T17:27:44.083908Z","actor":"Angus Bezzina","issue_id":"attn-9npk.4","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}} +{"id":"int-4bf784722df7b708e008c1cfd04b73da","kind":"field_change","created_at":"2026-08-19T17:27:44.407951Z","actor":"Angus Bezzina","issue_id":"attn-9npk.5","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}} +{"id":"int-ce99b9af34b829a987fd197cafee9c47","kind":"field_change","created_at":"2026-08-19T17:30:20.412647Z","actor":"Angus Bezzina","issue_id":"attn-9npk","extra":{"field":"status","new_value":"closed","old_value":"open"}} +{"id":"int-0f38f80739449019ce8f499db977e65d","kind":"field_change","created_at":"2026-08-19T17:48:23.134871Z","actor":"Angus Bezzina","issue_id":"attn-w60r","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"97 e2e passed / 4 failed, all four the pre-existing attn-gccz set — no new failures. Static + runtime audits both clean: zero square-cornered full-border elements across 11 routes x 2 themes."}} +{"id":"int-bf71e45565e78c318d8d09ee4221e14a","kind":"field_change","created_at":"2026-08-20T16:45:27.049335Z","actor":"Angus Bezzina","issue_id":"attn-rjuo","extra":{"field":"status","new_value":"in_progress","old_value":"open"}} +{"id":"int-d6a0ec6f804a9215d59852613b42c32e","kind":"field_change","created_at":"2026-08-20T16:45:27.533133Z","actor":"Angus Bezzina","issue_id":"attn-rjuo.1","extra":{"field":"status","new_value":"in_progress","old_value":"open"}} +{"id":"int-fe46823a27427128e00d16f2da4254ef","kind":"field_change","created_at":"2026-08-20T16:45:46.245337Z","actor":"Angus Bezzina","issue_id":"attn-rjuo.1.4","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}} +{"id":"int-925891c43c1df5b8e3bb78acce810b49","kind":"field_change","created_at":"2026-08-20T17:11:02.230956Z","actor":"Angus Bezzina","issue_id":"attn-rjuo.1.1","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}} +{"id":"int-5e6d3cff988ef462bb586103859f7045","kind":"field_change","created_at":"2026-08-20T17:11:03.119894Z","actor":"Angus Bezzina","issue_id":"attn-rjuo.1.2","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}} +{"id":"int-0fb8e0acdb13ca8311aace08906bd51d","kind":"field_change","created_at":"2026-08-20T17:11:03.787009Z","actor":"Angus Bezzina","issue_id":"attn-rjuo.1.3","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}} +{"id":"int-d9d24842e0981aca8a95521265efad44","kind":"field_change","created_at":"2026-08-20T17:11:04.43553Z","actor":"Angus Bezzina","issue_id":"attn-rjuo.1.5","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}} +{"id":"int-37a057a20efdba34a869d26dda7bc97a","kind":"field_change","created_at":"2026-08-20T17:11:05.147599Z","actor":"Angus Bezzina","issue_id":"attn-rjuo.2.1","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}} +{"id":"int-4b9f3c267e043e8af9c9ba294ccbbbd0","kind":"field_change","created_at":"2026-08-20T17:11:05.84708Z","actor":"Angus Bezzina","issue_id":"attn-rjuo.2.2","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}} +{"id":"int-505cc497ec7eef9a1ecb1c5df6902307","kind":"field_change","created_at":"2026-08-20T17:11:06.587219Z","actor":"Angus Bezzina","issue_id":"attn-rjuo.3.1","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}} +{"id":"int-796692bbd1ca28c47f75f7a93d59cb5b","kind":"field_change","created_at":"2026-08-20T17:13:38.027298Z","actor":"Angus Bezzina","issue_id":"attn-rjuo.3.2","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}} +{"id":"int-562f42b536b0d9ba9f189896127612f1","kind":"field_change","created_at":"2026-08-20T17:22:36.035054Z","actor":"Angus Bezzina","issue_id":"attn-rjuo.4","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}} +{"id":"int-eac01156c3d1b135027e4461bf3bf783","kind":"field_change","created_at":"2026-08-20T17:22:36.675945Z","actor":"Angus Bezzina","issue_id":"attn-rjuo.1","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}} +{"id":"int-96b329732cf0b8d1edd28103e800c9ad","kind":"field_change","created_at":"2026-08-20T17:22:37.345008Z","actor":"Angus Bezzina","issue_id":"attn-rjuo.2","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}} +{"id":"int-717288fae76d82c5702c91eacb65f6d4","kind":"field_change","created_at":"2026-08-20T17:22:37.981987Z","actor":"Angus Bezzina","issue_id":"attn-rjuo.3","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}} +{"id":"int-0d35fcb6aa40a6859c0ecea635dcd4cd","kind":"field_change","created_at":"2026-08-20T17:22:38.714682Z","actor":"Angus Bezzina","issue_id":"attn-rjuo","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}} +{"id":"int-c71e784f1f85617aa494639cd36b4d8c","kind":"field_change","created_at":"2026-08-20T17:43:05.808366Z","actor":"Angus Bezzina","issue_id":"attn-rjuo.5","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}} +{"id":"int-d7b596d6ab719d2bae6de4154b1e279b","kind":"field_change","created_at":"2026-08-20T22:36:57.491301Z","actor":"Angus Bezzina","issue_id":"attn-1l2f","extra":{"field":"status","new_value":"in_progress","old_value":"open"}} +{"id":"int-670743da8361f9ee7468d65fada55577","kind":"field_change","created_at":"2026-08-20T22:36:57.95616Z","actor":"Angus Bezzina","issue_id":"attn-1l2f.1","extra":{"field":"status","new_value":"in_progress","old_value":"open"}} +{"id":"int-d93d38db67dd5954134fbd55f4a43f8b","kind":"field_change","created_at":"2026-08-20T22:43:01.280866Z","actor":"Angus Bezzina","issue_id":"attn-1l2f.1","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"UI gate (ReviewMargin canUnresolve + ReviewMarginCard kind check), projection drop in reconstructThreads + review-counts, and a durable Rust guard (ReviewStore::is_suggestion_thread wired into reopen_comment). Browser owner has no event-log access at the authoring layer, so the projection drop is its durable half. Tests: 5 new selectors cases, 2 review-counts cases, 2 Rust store cases."}} +{"id":"int-6f8a1297ff3c2c15f06d6c5b754a3bff","kind":"field_change","created_at":"2026-08-20T22:43:01.86776Z","actor":"Angus Bezzina","issue_id":"attn-1l2f.2","extra":{"field":"status","new_value":"in_progress","old_value":"open"}} +{"id":"int-c93e02f3663bb130f3a1a3bebb71ce38","kind":"field_change","created_at":"2026-08-20T22:47:47.308479Z","actor":"Angus Bezzina","issue_id":"attn-1l2f.2","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Unified transition counter in web/src/hosted/app/navigation-guard.ts (begin/current/isCurrent) + workspace-identity invariant via canApplyWorkspaceRead. Applied to applyEntry, navigate, openWorkspaceRoute, load, createAndOpen, onWorkspaceChanged, refreshActiveBody. 6 regression cases in navigation-guard.test.ts; confirmed the pre-fix logic fails case 1."}} +{"id":"int-f4a72444e1675c5338033ced594447d5","kind":"field_change","created_at":"2026-08-20T22:47:47.920471Z","actor":"Angus Bezzina","issue_id":"attn-1l2f.3","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"appWorkspaceUrl() in web/src/lib/hosted/routes.ts encodes workspace id + each path segment; all 12 raw /app/w writers in AppShell/DeskHome/EditorShell replaced. parseAppRoute already decoded per segment and rejected malformed/encoded-separator input. 12 round-trip paths + 6 edge cases in routes.test.ts."}} +{"id":"int-4a61bc7545c78bd33856bed64a0f7d7e","kind":"field_change","created_at":"2026-08-20T22:47:48.527633Z","actor":"Angus Bezzina","issue_id":"attn-1l2f.4","extra":{"field":"status","new_value":"in_progress","old_value":"open"}} +{"id":"int-ede33a1c0c39c73a2a09fdf82f07157f","kind":"field_change","created_at":"2026-08-20T22:56:31.837259Z","actor":"Angus Bezzina","issue_id":"attn-1l2f.4","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"deskEnterOpensSelection() in web/src/hosted/app/desk-keys.ts: Enter belongs to the desk only when focus is not on/inside an interactive control (tag, href-bearing , ARIA role, contenteditable); the filter input is the explicit exception. Arrow and '/' gates keep typingInField. 20 cases in desk-keys.test.ts including a pre-fix contrast assertion."}} +{"id":"int-c912b8333c9cb887f33085ee0b487a88","kind":"field_change","created_at":"2026-08-20T22:56:32.749332Z","actor":"Angus Bezzina","issue_id":"attn-1l2f","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"All four findings fixed and verified. web: 130 test files 0 failures, svelte-check 0 errors, both builds green. rust: 563 lib tests pass, clippy clean. Review E2E unchanged vs baseline (43 PASS / 4 PEND / 13 pre-existing FAIL). Changes are uncommitted on angus/comment-card-redesign pending review; codex re-review of the criterion needs a commit first."}} diff --git a/.claude/skills/clean-comments b/.claude/skills/clean-comments new file mode 120000 index 00000000..c7d5cf70 --- /dev/null +++ b/.claude/skills/clean-comments @@ -0,0 +1 @@ +../../.agents/skills/clean-comments \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 749a147d..5e7b8cbb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,11 +82,11 @@ jobs: - name: Check formatting run: cargo fmt --check - # Dropped the old separate `cargo clippy --release`: it was a full second - # release-mode compile (~2.5 min) whose artifacts can't be reused by the - # size-gate build (clippy goes through RUSTC_WORKSPACE_WRAPPER, so its - # fingerprints differ from rustc's). Release-cfg code is still compile- - # checked by the actual release build in the size-gate job. + # No separate `cargo clippy --release`: it costs a full second + # release-mode compile (~2.5 min) whose artifacts the size-gate build + # cannot reuse, because clippy goes through RUSTC_WORKSPACE_WRAPPER and + # its fingerprints differ from rustc's. The size-gate job's release build + # still compile-checks release-cfg code. - name: Clippy run: cargo clippy --all-targets -- -D warnings @@ -134,7 +134,7 @@ jobs: with: shared-key: size-gate-macos - # Binary-size gate — enforces the 30 MiB target from + # Binary-size gate — enforces the 40 MiB target from # planning/collab/amendments.md §Decision #1 (webrtc-rs is the main risk). # Emergency bypass via env: ATTN_SIZE_BUDGET_WAIVER=1 (also accepts # BINARY_SIZE_WAIVER=1). See CLAUDE.md §"Binary-size gate". @@ -215,10 +215,9 @@ jobs: - name: Install dependencies run: npm ci - # The relay is the collab backbone (room auth, caps, PoW, abuse gates). - # Its vitest-pool-workers suite (~293 tests) previously ran only on the - # manual relay-deploy workflow — gate it on every PR/push so the collab - # path can't regress unnoticed. + # The relay is the collab backbone (room auth, caps, PoW, abuse gates), + # so its vitest-pool-workers suite gates every PR/push. Leaving it to the + # manual relay-deploy workflow lets the collab path regress unnoticed. - name: Typecheck run: npm run typecheck diff --git a/.gitignore b/.gitignore index 20b7518c..6b6e3037 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,16 @@ web/src/lib/vscode-icon-packs/ # wrangler dev state web/.wrangler/ + +# Impeccable machine-local state. `design.json` is deliberately NOT here: it is +# the design-system sidecar `/impeccable doctor` diffs against DESIGN.md, so it +# is a checked artifact like a lockfile, not noise. +.impeccable/hook.cache.json +.impeccable/live/ + +# Critique snapshots are a local run log — one file per `/impeccable critique`, +# append-only and unbounded. Untracked 2026-08-19 (13 were already committed). +# Consequence to know: a snapshot cited by tracked work — a beads issue, a PR +# description — resolves only on the machine that produced it. Quote the finding +# in the citing document rather than pointing at a path here. +.impeccable/critique/ diff --git a/.impeccable/critique/2026-07-12T14-16-00Z__staging-attn-sh.md b/.impeccable/critique/2026-07-12T14-16-00Z__staging-attn-sh.md deleted file mode 100644 index ac11fdae..00000000 --- a/.impeccable/critique/2026-07-12T14-16-00Z__staging-attn-sh.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -target: staging.attn.sh (hosted web) -total_score: 19 -p0_count: 2 -p1_count: 3 -timestamp: 2026-07-12T14-16-00Z -slug: staging-attn-sh ---- -Method: dual-agent (A: staging design review · B: hosted detector/browser evidence) - -# Critique — staging.attn.sh (hosted web build) - -## Design Health Score - -| # | Heuristic | Score | Key Issue | -|---|-----------|-------|-----------| -| 1 | Visibility of System Status | 3 | Save/share chips excellent; owner gets zero indication reviewer comments exist (silent 400) | -| 2 | Match System / Real World | 3 | Desk/room metaphors strong; "Encrypted mailbox", "Hybrid delivery" protocol-speak leaks into first-run UI | -| 3 | User Control and Freedom | 1 | New-workspace accumulation has no undo; delete confirm renders below fold; no keyboard route editor→desk | -| 4 | Consistency and Standards | 1 | Same object is workspace/project/desk/room; desktop edits sans, mobile edits serif; #join dead | -| 5 | Error Prevention | 1 | `#new` is a GET-that-mutates; nothing prevents minting identical empty Untitled workspaces | -| 6 | Recognition Rather Than Recall | 1 | Four identical `Untitled · 1 file · Local only` rows; no auto-name from H1; no previews | -| 7 | Flexibility and Efficiency | 1 | Cmd+K dead everywhere; no visible shortcuts; Cmd+B works but undiscoverable | -| 8 | Aesthetic and Minimalist Design | 3 | Landing/desk/storage disciplined; editor an empty void; share dialog ~12 options in one modal | -| 9 | Error Recovery | 3 | "NOT ON THIS DEVICE" deep-link state is model copy; comment-sync 400 completely silent | -| 10 | Help and Documentation | 2 | Good inline markdown hint (that confesses missing rules); reviewer gets no "select text to comment" | -| **Total** | | **19/40** | **Poor — major UX work required on the working surfaces** | - -## Anti-Patterns Verdict - -Split verdict. Landing/desk/storage/mobile-reader would pass a Linear/Figma-fluent sniff test: real editorial identity (Source Serif display, paper `rgb(233,228,218)`, ink, rust CTAs, mono micro-labels), confident copy, zero stock gradients. The **desktop editor** is where a fluent eye says "shadcn default": all-sans body, measured `max-width: 1078px` (~135 CPL), browser-blue selection, grey Comment pill overlapping text, blue-accented thread cards in a rust/ink product. - -Detector (198 findings over hosted source): 101 off-palette colors (heavily `var(--x, #2563eb/#dc2626/#16a34a)` shadcn-blue fallback literals in review components), 65 off-ramp font sizes (9–18px px-regime in review components/badges), 27 off-scale radii, 5 side-tab accent stripes (2 are conventional blockquotes = FP). Live-page injection (CSP forced CDP evaluate; worked): landing 9 findings (eyebrow chips, 11px tiny-text, 3px stage-label stripes, skipped h1→h3), editor 6 (transition:width on sidebar = layout thrash, 10.85px hint text, flat type hierarchy, nested cards). `confirm()/alert()`: 0. Muted text 5.7:1 (passes) but rendered at 11–12px. - -## Priority Issues - -- **[P0] Owner never receives reviewer comments in the hosted editor.** Reviewer posts via share link; owner reopens: no rail, no badge, silent console 400. The core promise fails invisibly. Fix: persistent owner review rail + synced thread count + arrival toast; surface mailbox errors first-class. -- **[P0] "New workspace" always creates.** Landing's primary (and mobile-only) CTA → `/app#new` unconditionally mints workspace + untitled.md; bookmarks/back re-trigger; empties never coalesced; never auto-named. Fix: state-aware CTA ("Your desk (4)" primary when workspaces exist), idempotent #new (reuse most recent empty Untitled), auto-name from first H1. -- **[P1] "Join a review" is a dead click** — landing card and desk card both navigate to `/app#join` which renders the desk unchanged. Fix: paste-a-link modal or remove the card. -- **[P1] Editing surface off-brand and typographically unbounded** — sans body, 135 CPL; mobile edit mode is serif, proving intent. Fix: centered ~68ch serif column matching mobile reader. -- **[P1] Markdown affordance mismatch** — `**bold**` stays literal, lists render without visible markers; toolbar hint admits only 3 block rules. Fix: full inline input rules + paste-as-markdown + visible markers. -- **[P2] Review affordances unstyled at the emotional core** — blue selection, grey overlapping Comment pill, blue thread cards, unbranded reviewer bar. Fix: brand tokens for selection/highlight/composer; wordmark in reviewer bar. -- **[P2] Delete confirm below the fold; no undo anywhere.** Fix: modal or row-anchored popover + 10s undo toast. -- **[P2] No command palette, no shortcut surface.** Fix: ⌘K palette + `?` shortcut sheet. - -## Persona Red Flags - -- **Alex (power user):** ⌘K dead; "All workspaces" buried two clicks deep; no bulk-delete for the Untitled pile; leaves for a folder of .md files. -- **Jordan (first-timer from a link):** unbranded reviewer page, "Encrypted mailbox" jargon, passive "No review threads on this file.", nothing says select-text-to-comment, no link home. -- **Sam (keyboard/SR):** good landmarks/focus rings/Escape; but 4 identical "Untitled" accessible names, icon-only share control, below-fold delete confirm is a focus hazard. -- **James (daily owner):** the review never arrives (P0); agent markdown renders half-literal; 135-char lines tire long-doc review; Untitled pile is the "pieces, not product" failure mode. - -## Minor Observations - -Share dialog shows stale `0 B` size at first open; "Search projects..." is the only "projects" in the app; teal "Backup recommended" is a fourth accent used nowhere else; editor empty state is a void; full-width unicode +↥↗ glyphs read tofu-risk; desk row actions 28px tall (<44px); zero animations at idle (no motion system); typed text can vanish on fast navigate (debounced save without flush); thread card not aligned to its anchor. - -## Questions to Consider - -1. Should the browser build have "workspaces" at all — or just "your documents," flat, auto-named, deduped? -2. Why does the owner edit in a different typeface than everyone reads? -3. What is the first 60 seconds of a comment's life — and who tells the owner it exists? - -## Theme Assessment - -Palette is 90% on-brand (paper/ink/rust light; warm brown-black dark with salmon CTA). Gap: the desktop editor abandons serif, measure, and brand tokens; zero motion so "sharp behavior" has no felt texture; teal status accent off-palette; hosted dark (warm brown) disagrees with DESIGN.md INK (cool blue-black). diff --git a/.impeccable/critique/2026-07-12T14-16-01Z__native-app.md b/.impeccable/critique/2026-07-12T14-16-01Z__native-app.md deleted file mode 100644 index 179bcff6..00000000 --- a/.impeccable/critique/2026-07-12T14-16-01Z__native-app.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -target: native app -total_score: 19 -p0_count: 2 -p1_count: 3 -timestamp: 2026-07-12T14-16-01Z -slug: native-app ---- -Method: dual-agent (A: native app design review via daemon automation · B: detector + in-webview injection) - -# Critique — attn native app (wry/tao + Svelte, ATTN_HOME-isolated instance on DESIGN.md) - -## Design Health Score - -| # | Heuristic | Score | Key Issue | -|---|-----------|-------|-----------| -| 1 | Visibility of System Status | 1 | Silent multi-second debounced writes, no saved/dirty indicator; window title empty after navigation | -| 2 | Match System / Real World | 3 | Error copy excellent; YAML frontmatter renders as run-on serif prose | -| 3 | User Control and Freedom | 1 | Ghost modal: closed share dialog stayed painted and intercepted clicks (soft-lock) | -| 4 | Consistency and Standards | 2 | Themed focus ring exists but share-retry shows raw WebKit blue; ⌘P instead of ⌘K | -| 5 | Error Prevention | 1 | Checkbox toggles in UI but never writes to disk; watcher reload reverts it | -| 6 | Recognition Rather Than Recall | 1 | No breadcrumb, blank title, no visible shortcut hints; theme toggle is invisible single-key `t` | -| 7 | Flexibility and Efficiency | 2 | Good shortcut set exists, but `if (editingTarget) return` (keyboard.ts:133) kills ⌘P/⌘//⌘W when editor focused — i.e. most of the time | -| 8 | Aesthetic and Minimalist Design | 4 | Reading column genuinely the hero; best-in-class restraint | -| 9 | Error Recovery | 3 | Share failure state is a model: plain cause, inline retry, honest mono path | -| 10 | Help and Documentation | 1 | ⌘/ help exists but unreachable while editor focused and never advertised | -| **Total** | | **19/40** | **Poor — identity excellent, behavior layer betrays it** | - -## Anti-Patterns Verdict - -Not slop — a real design with a point of view. Paper `oklch(0.905 0.01 78)`, Source Serif body, rust checkboxes with strikethrough, small-caps table headers: nobody mistakes this for shadcn. Trust collapses at behavior: invisible Share modal, dead ⌘P in the document, checkbox state that evaporates. - -Detector (215 CLI findings over native frontend): 110 off-palette colors (large share are `var(--x, #2563eb …)` shadcn-blue fallbacks in review components; raw hits include `#fff` and peer-color literals in App.svelte:617), 65 off-ramp font sizes (review components run a raw-px regime, 9–18px), 39 off-scale radii, 1 side-tab warning (SuggestionComposer border-l-2). In-webview injection (worked; attn:// origin allowed it): `transition: width` on sidebar (layout thrash), 2 four-digit z-indexes (base.css:44 `9999`, prosemirror.css:447 `10000`), nested-cards on prose-scroll-x, 4 hidden-but-not-sr-only dialog labels. `transition: all` in app.css:412,742. Content lists render `list-style:none` with no marker. - -Caveat: the stuck-animation observations occurred on an occluded background window (rAF never fired), which may be WebKit suspending the animation clock — but modal visibility and input-blocking must not depend on animations completing, occluded or not. - -## Priority Issues - -- **[P0] State–pixel desync: critical visibility rides on CSS animations with no fallback.** Dialogs observed at computed opacity 0 while open, painted + click-intercepting while closed; frozen half-theme-switch. Fix: `[data-state=closed]{display:none}` (or unmount), animation as enhancement only; honor prefers-reduced-motion. -- **[P0] Checkbox toggles don't persist** — UI flips, disk file unchanged, watcher reverts. Wire task-item NodeView into the write path or render read-only. -- **[P1] `editingTarget` guard kills ⌘P/⌘//⌘W/⌘[⌘] whenever the always-editable document has focus** (keyboard.ts:133). Move palette/help/nav above the guard. -- **[P1] YAML frontmatter renders as body prose** — table stakes for "the reviewer for agent-authored docs". Render a folded metadata card. -- **[P1] No review affordance on local docs** — selection toolbar gates on `reviewStore.currentRoomId` (App.svelte:947-964); can't comment on your own doc without a network room. Allow local annotation that upgrades to a room. -- **[P2] Lists render markerless** — all 60 DESIGN.md bullets indistinguishable from indented paragraphs. Restore markers (en-dash / small rust dot). -- **[P2] Measure ~95–100 CPL** (739px paragraphs, 1031px max-width). Cap near 68–72ch; spend surplus on the review rail. -- **[P2] No location signal** — empty window title, no breadcrumb, faint active-file tint. Title = filename; quiet path affordance. -- **[P3] Icon vocabulary off-brand** — candy-yellow folders/teal/blue file icons against the editorial identity, glaring in INK. Monochrome ink-line glyphs with rust accents. - -## Persona Red Flags - -- **Alex:** palette is a file-opener only (no commands); ⌘P unadvertised; empty query shows error-flavored copy instead of recents; `⇕ 1` pill communicates nothing. -- **Sam:** WebKit-blue ring leaks on share-retry; single-key `t` theme toggle undiscoverable; links at ~2.4:1 vs body ink with no underline (WCAG 1.4.1 fail); dark muted labels ~4.4:1 at 0.7rem. -- **James:** frontmatter garbles every agent doc's first screen; checkbox non-persistence corrupts task-list review; background serializer rewrites files with no dirty indicator (git diff-noise risk). - -## Minor Observations - -Share pill migrated position between sessions; name-prompt copy is good; palette footer is the app's only visible shortcut hint; syntax palette reads cool on PAPER; sidebar-filter dimming got stuck (same transition pathology); the promised paper grain is not implemented in the native surface; fixture noise committed to repo. - -## Questions to Consider - -1. If the document is always an editor, what is "review"? Should the owner's default mode be suggesting, with direct edit as the indicated exception? -2. Why does annotation require a network room in a local-first tool? A local review journal that syncs later makes privacy structural. -3. What is the app's contract for "pixels always equal state," and where is it tested? - -## Theme Assessment - -PAPER is faithful to DESIGN.md tokens exactly; INK's steel-blue accent shift is genuinely implemented (checked tasks turn steel — lovely). 10/10 changes: kill candy icons + WebKit ring + transition-dependent state; ship the grain; hairline link underlines; raise dark muted ≥ oklch(0.62); expose PAPER/INK as a visible two-state control; honor system appearance on first launch. diff --git a/.impeccable/critique/2026-07-12T14-55-44Z__planning-design-prototypes-editor-polish-html.md b/.impeccable/critique/2026-07-12T14-55-44Z__planning-design-prototypes-editor-polish-html.md deleted file mode 100644 index 9039dba7..00000000 --- a/.impeccable/critique/2026-07-12T14-55-44Z__planning-design-prototypes-editor-polish-html.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -target: editor-polish prototype (as committed) -total_score: 22 -p0_count: 2 -p1_count: 4 -timestamp: 2026-07-12T14-55-44Z -slug: planning-design-prototypes-editor-polish-html ---- -Method: dual-agent (A: design review via headless Playwright · B: detector + measured evidence) - -# Critique — planning/design/prototypes/editor-polish.html (Theme v2 editor prototype, as first committed) - -## Design Health Score - -| # | Heuristic | Score | Key Issue | -|---|-----------|-------|-----------| -| 1 | Visibility of System Status | 2 | Save chip works; thread counts desync after Accept (rail "1 open" vs badge "2" vs doc-meta "2 open threads") | -| 2 | Match System / Real World | 4 | Review vocabulary and E2E-local copy genuinely excellent | -| 3 | User Control and Freedom | 2 | Accept is instant and irreversible; toast auto-dismisses with no recall | -| 4 | Consistency and Standards | 2 | Shortcut hints everywhere; only ⌘K/T/Esc actually work — hints that lie | -| 5 | Error Prevention | 1 | One-click irreversible "file write"; `t` hotkey fires while buttons have focus | -| 6 | Recognition Rather Than Recall | 3 | Palette lists commands + accelerators; sidebar/outline give scent but are dead | -| 7 | Flexibility and Efficiency | 1 | Palette typing/↑↓/↵ dead; all advertised accelerators dead; outline no-op | -| 8 | Aesthetic and Minimalist Design | 4 | 68ch measure, restrained accent, grain felt-not-seen — strongest axis | -| 9 | Error Recovery | 1 | Zero error states in an E2E-encrypted sync product | -| 10 | Help and Documentation | 2 | Palette footer legend good; "? all shortcuts" dead | -| **Total** | | **22/40** | **Acceptable band — static composition excellent, interaction collapses under contact** | - -## Anti-Patterns Verdict - -Visually no slop ("passes the 5-second test"); interactively partial theater ("fails the 15-second test"). Deepest honesty problem: no @font-face — the type identity silently rendered Georgia/system-ui/Courier (measured by glyph-width fingerprint). Detector: 9 CLI findings (7× 2px radius — 4 documented in DESIGN.md prose; em-dash and flat-type-hierarchy counts are FPs from CSS comments and unparsed `font:` shorthand). In-page: 8 findings/theme (cramped padding on toggle/badge rows, tiny-text at 10.85px), text-overflow on breadcrumb at 900px (intentional ellipsis). - -## Priority Issues (as found) - -- **[P0] No responsive story.** Fixed 232/1fr/300 grid, zero media queries: 768px → 140px prose column; 390px → 364px horizontal overflow, rail painted over prose. -- **[P0] Keyboard broken both directions.** Hidden popover/toast buttons ARE tab stops (opacity 0, still focusable); visible sidebar/outline rows and comment anchors are NOT focusable at all. -- **[P1] Command palette is a stage prop.** Typing filters nothing, arrows dead, Enter dead, items dead; role=dialog without aria-modal or focus trap. -- **[P1] Accept irreversible with no undo** — the product's one destructive action. -- **[P1] Fonts not shipped** — the entire stated type system was a fallback illusion. -- **[P1] INK fails AA in 6 places** (faint tier 3.46–3.90, monograms 3.29–3.37, code comments 3.67); PAPER amber badge 2.35:1. -- **[P2] Selection popover detaches on scroll; Comment/Suggest discard the selection silently.** -- **[P2] Rail cards not anchored to text** (259px vertical miss on t1) — the category-defining behavior missing. - -## Persona Red Flags - -Alex: dead palette discovered in seconds; every accelerator except ⌘K/T/Esc decorative; file switch no-op. Sam: ghost tab stops + unreachable nav; dialog without trap; INK metadata illegible. James: Courier code blocks; count desync; no relay-down state; Accept-no-undo hazardous for bulk agent-suggestion review. - -## Minor Observations - -Breadcrumb duplicates H1; "(4)" unlabeled; ⌘J chip unwired; veil too light; toast right hardcoded to rail width; peer avatars the only Notion-pastel note; grain z-order (above content, below floats) reads intentional and works. - -## Questions - -1. If the rail never aligns to anchors, why is it a rail? -2. What are the five verbs that must be instant, and why does the palette advertise "New file" instead of them? -3. Is the accent allowed to change hue across themes (terracotta→steel) as a deliberate identity statement? - -## Theme fidelity - -PAPER is the identity (all sampled pairs but the badge pass AA). INK measured effectively neutral-black (chroma 0.005 — "cool blue-black" was homeopathic) with 6 AA failures; steel accent correct. - ---- - -*Post-critique fix pass (same session): fonts shipped via @font-face import; responsive collapse at 1100/900/700 (390px overflow 0); ghost stops removed (visibility) + rows/anchors focusable; palette made real (filter/↑↓/↵/actions/trap); undo on accept/reject/resolve; counts single-sourced; rail cards anchor-aligned (measured exact); INK re-tuned (faint 5.89, monograms 7.63, badge 7.06; bg chroma raised to 0.014); popover hides on scroll; composer posts real comments/suggestions; share flow with designed error→retry→success; delight: pencil-stroke strikethrough, check pop, anchor pulse, wordmark caret, console line. Re-run critique to score the fixed state.* diff --git a/.impeccable/critique/2026-07-12T15-14-36Z__planning-design-prototypes-editor-polish-html.md b/.impeccable/critique/2026-07-12T15-14-36Z__planning-design-prototypes-editor-polish-html.md deleted file mode 100644 index a25842d8..00000000 --- a/.impeccable/critique/2026-07-12T15-14-36Z__planning-design-prototypes-editor-polish-html.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -target: editor-polish prototype v3 -total_score: 31 -p0_count: 0 -p1_count: 3 -timestamp: 2026-07-12T15-14-36Z -slug: planning-design-prototypes-editor-polish-html ---- -Method: dual-agent (A: design review via headless Playwright, 5 passes, 25+ screenshots · B: detector + canvas-resolved measurements) - -# Critique — editor-polish.html v3 (wide sheet + scrollbars + delight pass) - -## Design Health Score - -| # | Heuristic | Score | Key Issue | -|---|-----------|-------|-----------| -| 1 | Visibility of System Status | 3 | Save chip/counts/toast excellent; comment posted on metrics.md shows zero visible result (misfiles into launch's hidden rail) | -| 2 | Match System / Real World | 4 | Margin notes, ledger diffs, "Nothing left this machine" — metaphor and copy genuinely good | -| 3 | User Control and Freedom | 2 | Undo everywhere (excellent) but one Esc closes ALL layers, composer drafts unrecoverable, resolve-all edits without asking | -| 4 | Consistency and Standards | 2 | ⌘⇧. dead on real keyboards (Shift+. emits ">"); T dies after any click; seeded ins not focusable while comment anchors are; Esc skips the drawer | -| 5 | Error Prevention | 2 | Resolve-all silently ACCEPTS suggestions; cross-file post misfiles silently; no right-edge clamp on popover/composer | -| 6 | Recognition Rather Than Recall | 4 | Shortcuts printed beside every action; teaching empty state | -| 7 | Flexibility and Efficiency | 3 | Real palette filter/↑↓/↵, ⌘J/⌘⇧S work; no ⌘↵ post; two fragile shortcuts | -| 8 | Aesthetic and Minimalist Design | 4 | Restrained, dense, warm; nothing decorative that isn't informative | -| 9 | Error Recovery | 4 | Share error state exemplary; undo notes name actor and consequence; verified under mid-animation stress | -| 10 | Help and Documentation | 3 | Inline hints carry it | -| **Total** | | **31/40** | **Good band — up from 22; keyboard-truth and semantic-trust gaps remain** | - -## Anti-Patterns Verdict - -5-second AND 15-second tests pass. Detector: all 11 CLI findings classified FP/documented; in-page residuals are polish-grade (figcaption line-height 1.2 + 76 uppercase chars, 10.85px metadata trio, rail overflow-clip by design). Zero horizontal overflow at 6 widths; no transition:all; coherent z ladder; 21-stop keyboard cycle with no ghost stops; every contrast pair passes AA in both themes (thinnest: PAPER monogram 4.54); INK's coolness is now real (hue 250–257, chroma 0.014); prose measures exactly 72.0ch with wide blocks at full pane (812px); webfonts load (network-dependent, Georgia fallback offline). - -## What's Working - -1. **The undo grammar** — every destructive action leaves "✓ Accepted by James · file updated · Undo" in place of the buttons; correct under accept→undo→accept and undo at 60ms mid-settle. Linear-grade, actually implemented. -2. **The share error state as brand** — "the share didn't complete. Nothing left this machine" earns the E2E positioning in a failure state; error→retry→success→copy verified. -3. **One token source that survives inversion** — SVG diagram, syntax tokens, diff washes, scrollbar tint all re-derive per theme; PAPER/INK are siblings, not negatives. - -## Priority Issues - -- **[P1] Cross-file misfile:** comment posted on metrics.md lands silently in launch-plan.md's hidden rail (badge jumps 2→3, no visible card). Fix: per-file card tracking or block posting when the target track is hidden. -- **[P1] Resolve-all silently accepts every suggestion** — a document edit under a resolution verb; observed mutilating a sentence via a half-typed user suggestion. Fix: resolve comments only, skip/ask for suggestions; single bulk undo. -- **[P1] ⌘⇧. dead on real keyboards** — handler checks e.key === '.', Shift+Period emits '>'. Fix: e.code === 'Period'. -- **[P2] Esc is a demolition charge** — closes all layers at once, loses composer drafts; doesn't close the mobile drawer. Fix: topmost-only Esc chain including drawer. -- **[P2] No keyboard authoring path** — comment creation gates on mouse selection. Fix: palette command "Comment on current paragraph" or caret-based selection. -- **[P2] Palette lacks AT semantics** — no listbox/aria-activedescendant; focus not returned to trigger on close. -- **[P2] Share dialog receives no focus on open** (activeElement stays BODY). -- **[P3] T toggle inert after any click** (guard requires body focus); seeded ins anchor not focusable; popover lacks right-edge clamp; unanchored accept still claims "file updated"; toast overlaps drawer at 390 (z 65 > 55). - -## Persona Red Flags - -Alex: the two showpiece shortcuts most likely to be tried first (⌘⇧., T) both fail under real conditions; resolve-all edits his document. Sam: reading experience genuinely accessible; writing experience mouse-only; palette selection invisible to AT. James: cross-file misfile bites daily multi-file review; no pointer affordance to reopen a hidden rail on desktop (⌘J span is inert). - -## Minor Observations - -Toast overlaps drawer at 390; popover can cover the selection near the top; save-chip hidden ≤700 leaves phones without save status; sidebar still says "never leaves this machine" after a successful share (the popover's ciphertext-vs-key framing is the honest one); rail cards park cleanly when anchors scroll off; reduced-motion verified end-to-end. - -## Questions to Consider - -1. What does "resolve" mean for a suggestion? Cleanup and merge need different names and confirmations. -2. Should the rail hug the 72ch prose edge when no wide block is in view, and retreat when one is? (At 1600 there's a ~450px gulf between anchor and card.) -3. When a share link exists, whose truth is "never leaves this machine"? diff --git a/.impeccable/critique/2026-07-12T17-38-37Z__native-app.md b/.impeccable/critique/2026-07-12T17-38-37Z__native-app.md deleted file mode 100644 index 2c139548..00000000 --- a/.impeccable/critique/2026-07-12T17-38-37Z__native-app.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -target: native app (Theme v2 gate) -total_score: 29 -p0_count: 0 -p1_count: 3 -timestamp: 2026-07-12T17-38-37Z -slug: native-app ---- -Method: dual-agent (A: daemon-automation design review · B: detector + in-webview injection). Post-fix state. - -# Critique — attn native app (Theme v2 final gate) - -## Design Health Score (as reviewed, before this session's four native fixes) - -| # | Heuristic | Score | Key Issue | -|---|-----------|-------|-----------| -| 1 | Visibility of System Status | 3 | Save-chip state machine excellent; toggled checkbox rendered unchecked (fixed) | -| 2 | Match System / Real World | 3 | Copy confident; "Room: room…tion offline" jargon | -| 3 | User Control and Freedom | 2 | Palette/rooms Escape+focus-restore excellent; Background-settings popover undismissable (pre-existing) | -| 4 | Consistency and Standards | 2 | Save chip occluded Share + Share teleports between states (fixed); ⌘P vs ⌘K mismatch (fixed) | -| 5 | Error Prevention | 3 | Explicit save + unsaved chip; checkbox desync invited double-toggles (fixed) | -| 6 | Recognition Rather Than Recall | 3 | Palette lists commands + shortcuts; some icon-only glyphs | -| 7 | Flexibility and Efficiency | 4 | ⌘K over focused editor, every action keyboard-reachable | -| 8 | Aesthetic and Minimalist Design | 4 | Reading column is the hero; one accent spent correctly | -| 9 | Error Recovery | 2 | "offline" names the problem, offers no action | -| 10 | Help and Documentation | 3 | ⌘/ overlay filterable + grouped | -| **Total** | | **29/40** | **Good — the point-costing items were state-truth bugs, now fixed** | - -## Anti-Patterns Verdict - -Not slop — designed product with exact token fidelity (paper oklch(0.905 0.010 78) to the digit, ink, muted at the documented floor; real grain; serif/sans role split). Detector: 74 CLI findings (from 215) — var() fallback literals GONE, transition:all only the vendored shadcn button, z-index max 70 (mermaid modal fixed), review components px-free; residue is the un-tokenized ins/del diff palette (app.css:873-892) and 10px micro-badge + 0.95rem UI-text clusters. In-webview injection: sidebar transition:width (layout), bits-ui offscreen-title FPs. - -## Fixed this session (all verified on the live daemon) - -- **Checkbox pixels = state:** click handler stopped fighting the native toggle (a checkbox reverts a preventDefault'd flip after the handler); now lets native flip stand and dispatches a matching transaction. Verified: click → input.checked true / node true / '- [x]' on disk. -- **Save chip relocated** from the floating ReviewBar (occluded the breadcrumb Share) into the breadcrumb header flow; measured no overlap (chip 597, Share 605). -- **CSS leak scoped:** rendered-markdown checkbox styling limited to `li:has(> input[type=checkbox]) > input` — the orphaned settings "Launch at login" box is now position:static. -- **⌘K label** in the shortcuts overlay (was ⌘P). - -## Remaining (tracked, not this session) - -- [P1] Background-settings popover undismissable (ResidentSettings, pre-existing). -- [P2] Theme flip resets scroll position. -- [P2] PAPER shiki keyword 3.23:1 (Shiki github-light palette — needs a warm-ground-tuned light theme). -- Popover chrome set in serif (Read/Do), outline "L#" labels read as heading levels. - -## Persona Red Flags - -Alex: home turf (palette + shortcuts); the Share-teleport and ⌘P/⌘K mismatch (both fixed) were the churns. Sam: strong focus/contrast/aria; the orphaned + unsynced checkbox (fixed) and the undismissable popover (open) were the failures. James: the save-chip grammar and palette-over-editor are Linear-grade; the state-truth bugs sat exactly on Share and the task record he trusts most (now fixed). - -## Questions - -1. Should share + save-state + rooms be one permanent, never-moving dock? -2. Outline highlight: caret- or viewport-anchored? -3. When tasks become review objects, do checkboxes become real ≥24px controls? diff --git a/.impeccable/critique/2026-07-12T17-39-13Z__landing-page-hosted.md b/.impeccable/critique/2026-07-12T17-39-13Z__landing-page-hosted.md deleted file mode 100644 index 3fc18bfc..00000000 --- a/.impeccable/critique/2026-07-12T17-39-13Z__landing-page-hosted.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -target: landing page (hosted) -total_score: 36 -p0_count: 0 -p1_count: 0 -timestamp: 2026-07-12T17-39-13Z -slug: landing-page-hosted ---- -Method: dual-agent (A: headless-Playwright design review · B: detector + measurements). Post-fix state. - -# Critique — attn landing page (Theme v2 final gate) - -## Design Health Score - -| # | Heuristic | Score | Key Issue | -|---|-----------|-------|-----------| -| 1 | Visibility of System Status | 4 | State-aware CTA (both placements), Copy→Copied, stage labels narrate state | -| 2 | Match System / Real World | 4 | desk/room/sheet metaphors; states, doesn't sell | -| 3 | User Control and Freedom | 3 | Join Cancel, idempotent #new, back always works | -| 4 | Consistency and Standards | 3→4 | One token system; mono stragglers + nav "Your desk" dupe both fixed | -| 5 | Error Prevention | 3 | idempotent #new, storage fallbacks, invite helper text | -| 6 | Recognition Rather Than Recall | 4 | every entry self-describing, no icon-only nav | -| 7 | Flexibility and Efficiency | 3 | 3 honest paths + state-aware shortcut + deep links | -| 8 | Aesthetic and Minimalist Design | 4 | disciplined accent, generous rhythm, one idea per fold | -| 9 | Error Recovery | 3 | app-side deep-link error is model copy | -| 10 | Help and Documentation | 3 | #how is the doc; contextual join hint | -| **Total** | | **34 → ~36/40** | **Good — gate met after the three point-costing fixes landed** | - -## Anti-Patterns Verdict - -Designed brand surface, named identity, category-reflex passes twice. Absolute bans clean: side-stripes GONE (7px leading dots), no gradient text, no glass, no hero-metric, no card grids. Detector: 5 CLI (all marketing-register display type), 3 borderline eyebrow warnings; zero overflow at 390, every focus stop rings, all contrast AA both themes. Watch item: four tracked-caps eyebrows + two numbered chapters are at the edge of AI-grammar (a coherent named system, but nearly every section opens with a small caps label). - -## Fixed this session - -- The one AA failure — green "BROWSER · NO INSTALL" label 3.35:1 — fixed (text-safe ledger green, re-measured 6.54:1). -- Tiny-mono stragglers (START HERE / surface-label / code-copy at 0.66-0.72rem) lifted to the 0.75rem floor. -- Nav "Your desk" plain link hidden when the state-aware "Your desk (N)" CTA is present (was duplicated). -- Landing/desk now ship the thin ink scrollbar (chrome.css) instead of native gutters; dark theme-color meta updated to INK. - -## What's Working - -1. The state-aware entry system rearranges the marketing page around whether you're already a user — rare and exactly "warm surface, sharp behavior." -2. Dark mode is a real second theme — body flips to cool blue-black, accent terracotta→steel, all three product screenshots swap to INK captures. The briefed "light interior in dark hero — bug?" did NOT reproduce; the window swaps assets and reads as one room at night. -3. Split-scale serif display + strict serif-read / sans-operate / mono-fact discipline carries the voice. - -## Remaining (P2, optional polish) - -- Two-panel "Two surfaces" section flattens in INK (both panels near-identical blue-blacks) — the argument is theme-dependent; consider inverting the browser panel to paper-on-dark-desk. -- Mobile Copy targets 40×23 (<44pt); ~1MB eager hero PNGs (AVIF/srcset). -- "E2EE · direct connection" jargon in first-contact labels; nav links hidden ≤680px with no menu. - -## Persona Red Flags - -Jordan: passes the 5-second test; unexplained crypto/network labels at first contact. Casey: no 390 overflow, thumb-reach CTAs; small Copy targets + heavy hero PNG. Riley: #new idempotence verified; in-app hash nav (#join→#new) is a no-op mid-session; desk-count read once at mount. - -## Questions - -1. Should INK invert the browser panel to paper-on-dark-desk? -2. Do the two numbered chapters pull their weight, or would named kickers be quieter? -3. Fresh-profile secondary CTA: "Open your desk" (empty) or "How it works"? diff --git a/.impeccable/critique/2026-07-12T17-42-31Z__staging-attn-sh.md b/.impeccable/critique/2026-07-12T17-42-31Z__staging-attn-sh.md deleted file mode 100644 index 9d777974..00000000 --- a/.impeccable/critique/2026-07-12T17-42-31Z__staging-attn-sh.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -target: staging.attn.sh (hosted app, Theme v2 gate) -total_score: 25 -p0_count: 0 -p1_count: 2 -timestamp: 2026-07-12T17-42-31Z -slug: staging-attn-sh ---- -Method: dual-agent (A: headless-Playwright design review · B: detector + measurements) + parent relay-build re-verification. - -# Critique — hosted attn app (desk + editor), Theme v2 final gate - -## Design Health Score - -| # | Heuristic | Score | Key Issue | -|---|-----------|-------|-----------| -| 1 | Visibility of System Status | 3 | Save chip present + commits persist (relay build); transient dirty/saving not surfaced (attn-z0t); publish checklist excellent | -| 2 | Match System / Real World | 3 | desk/sheet/room warm; "Hybrid" jargon; literal "# " betrays the markdown promise (attn-vea) | -| 3 | User Control and Freedom | 3 | Escape/rename-cancel/delete-confirm good; publishing Discard/Resume exemplary | -| 4 | Consistency and Standards | 2 | Desktop vs mobile editor are different products (sans raw-text vs serif WYSIWYG) — attn-vea | -| 5 | Error Prevention | 3 | Join validates before navigating; backup gate principled | -| 6 | Recognition Rather Than Recall | 2 | Blank desktop editor gives zero affordances; no markdown-won't-parse hint | -| 7 | Flexibility and Efficiency | 2 | Deep links + tab order, but no command palette on desktop | -| 8 | Aesthetic and Minimalist Design | 3 | Desk/storage/share superb; editor canvas barren | -| 9 | Error Recovery | 2 | Join copy model; publishing-paused best-in-class; /s/ dead-end unstyled (was a no-relay bundle artifact) | -| 10 | Help and Documentation | 2 | Strong inline microcopy; no help surface | -| **Total** | | **25/40** | **Acceptable — gate NOT met; blocker is the tracked desktop-markdown-parity gap (attn-vea), not the Theme v2 visual layer** | - -## Critical caveat on this score - -Assessment A ran against a bundle built WITHOUT `VITE_ATTN_RELAY_URL` → `BrowserRelayUrlError` on bootstrap, which killed the owner session that autosave, the save chip, commits, and auto-rename all depend on. Parent re-verification with a relay-configured build CLEARED those artifacts: editor mounts, zero bootstrap errors, save chip present ("Saved on this device"), a commit persists (data-commits 0→1). So the following A findings were bundle artifacts, not design defects: "save-state decorative" (partly — commits work), the share ack-checkbox "detached at (0,0)" (sound markup: label wraps input; unhydrated fallback), and the /s/ unstyled dead-end (relay-absent). - -## Anti-Patterns Verdict - -Not slop — committed editorial identity on desk/storage/join/share (serif display, mono metadata, ghost sheet, one terracotta pencil, real grain). Detector B: 25 CLI findings (from 198; side-tab FP = blockquote), and empirically: prose 72.0ch cap holds, wide blocks full-pane, zero overflow at 1440/1024/768/390, thin ink scrollbars now ship, INK cool blue-black with steel — all AA both themes (after the green/danger text-safe fix landed this session). - -## The real gate blocker (tracked, out of Theme v2 scope) - -**attn-vea (P1/P0-for-positioning):** the desktop hosted editor doesn't parse typed markdown (# stays literal, no input rules/paste-as-markdown) and reads in sans, not serif — breaking the Read/Do rule and the agent-doc-reviewer positioning on the widest platform. The mobile editor proves the schema works. This cascades into attn-cjn auto-rename (no H1 to read from). It has its own web-editor-parity branch and is the honest reason the hosted editor's score sits below the gate — the Theme v2 visual layer itself passed. - -## Fixed this session - -Text-safe green/danger inks (light-mode AA), landing/desk thin ink scrollbars, INK theme-color meta; share-truth desk copy; #join panel; idempotent #new + state-aware CTA; owner review-rail transport-error surfacing + live counts. - -## Persona Red Flags - -Alex: no desktop palette; markdown-fluent typing stays literal (attn-vea). Jordan: best first-run desk, then a blank editor void; typed # silently literal reads as broken. Sam: strong focus/contrast/aria; share ack-checkbox connection (bundle artifact). James: everything AROUND the document is demo-grade; the document itself needs attn-vea. - -## Questions - -1. Which surface owns markdown fidelity — shared comrak WASM, or ProseMirror authoring + rendered reader mode as mobile implies? -2. Should the app degrade share/join affordances honestly when the relay is unreachable, instead of failing late? -3. Does the durability acknowledgment belong at share time, or at first workspace creation? diff --git a/.impeccable/critique/2026-07-12T20-21-16Z__landing-page-hosted.md b/.impeccable/critique/2026-07-12T20-21-16Z__landing-page-hosted.md deleted file mode 100644 index 4fa6feb8..00000000 --- a/.impeccable/critique/2026-07-12T20-21-16Z__landing-page-hosted.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -target: landing page (gate-35 confirmed) -total_score: 37 -p0_count: 0 -p1_count: 0 -timestamp: 2026-07-12T20-21-16Z -slug: landing-page-hosted ---- -Landing 37/40 (from 34). Verified: leading dots (no stripes), h1->h2 outline, green label AA 3.35->7.27, nav desk dedup, state-aware CTA both placements, thin scrollbars, zero 390 overflow, focus rings. Remaining: P3 only (code-copy 0.72rem nit, dark accent vibrancy, no scroll-spy nav, hero-stage slight clutter). diff --git a/.impeccable/critique/2026-07-12T20-21-16Z__native-app.md b/.impeccable/critique/2026-07-12T20-21-16Z__native-app.md deleted file mode 100644 index ac0a116c..00000000 --- a/.impeccable/critique/2026-07-12T20-21-16Z__native-app.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -target: native app (gate-35 confirmed) -total_score: 37 -p0_count: 0 -p1_count: 0 -timestamp: 2026-07-12T20-21-16Z -slug: native-app ---- -Native 37/40 (from 29). All 4 fixes verified: checkbox truth, save-chip in breadcrumb no-overlap, cmd-K palette + settings both Escape-close, vitesse-light code AA. Grain, dual-theme, serif/sans discipline hold. Remaining: one P2 (shortcuts-overlay single-Escape, fix committed post-review) + P3 polish (dirty is edit-count not content-diff; save-chip minor reflow; dark syntax not vitesse-twin). diff --git a/.impeccable/critique/2026-07-12T20-21-16Z__staging-attn-sh.md b/.impeccable/critique/2026-07-12T20-21-16Z__staging-attn-sh.md deleted file mode 100644 index c70bb092..00000000 --- a/.impeccable/critique/2026-07-12T20-21-16Z__staging-attn-sh.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -target: staging.attn.sh (hosted, gate-35 confirmed) -total_score: 38 -p0_count: 0 -p1_count: 0 -timestamp: 2026-07-12T20-21-16Z -slug: staging-attn-sh ---- -Hosted 38/40 (from 25). Former P0 markdown parity FULLY RESOLVED: typed # -> real Source Serif h1, **bold** -> strong, - -> list, no literal syntax; auto-rename fires; branded /s/ error card with key stripped; empty-editor placeholder; idempotent #new; #join validates. 72ch prose / full-pane code verified; zero overflow 1440/768/390; thin scrollbars; INK cool blue-black. Remaining: P3 only (error-card composition, dark accent vibrancy, desk-row gap). diff --git a/.impeccable/critique/2026-08-04T23-39-08Z__landing-page-hosted.md b/.impeccable/critique/2026-08-04T23-39-08Z__landing-page-hosted.md deleted file mode 100644 index 0d327514..00000000 --- a/.impeccable/critique/2026-08-04T23-39-08Z__landing-page-hosted.md +++ /dev/null @@ -1,68 +0,0 @@ ---- -target: landing page (hosted) -total_score: 20 -max_score: 36 -na_heuristics: 9 -p0_count: 2 -p1_count: 5 -timestamp: 2026-08-04T23-39-08Z -slug: landing-page-hosted ---- -Method: dual-agent (Assessment A design review and Assessment B detector/browser evidence run as isolated parallel sub-agents), plus a two-agent technical audit (a11y+responsive, perf+theming+integrity). Browser evidence via Playwright + system Chrome against the production build; the claude-in-chrome extension was not connected. No user-visible overlay was produced — the in-page detector ran headless. - -## Design Health Score — 20/36 applicable - -| # | Heuristic | Score | Key issue | -|---|-----------|-------|-----------| -| 1 | Visibility of System Status | 2 | CopyCode aria-label frozen at "Copy "; zero aria-live regions; nav has no active-section state | -| 2 | Match System / Real World | 2 | Four nouns (desk/workspace/room/document), "workspace" overloaded, "desk" used 4x defined 0x | -| 3 | User Control and Freedom | 3 | Anchors and theme persistence work; global smooth scroll; all nav links display:none <=680px | -| 4 | Consistency and Standards | 2 | Landing has forked four DESIGN.md named rules; five terracotta elements on the fold | -| 5 | Error Prevention | 3 | Little to get wrong; clipboard failure swallowed by an empty catch | -| 6 | Recognition Rather Than Recall | 2 | The one artifact showing what a review IS is cropped through its own text; unglossed protocol vocabulary | -| 7 | Flexibility and Efficiency | 1 | Page branches on readDeskCount() for returning users, then serves them the identical 6,100px scroll | -| 8 | Aesthetic and Minimalist Design | 3 | Genuinely restrained; 18 non-code mono strings; three identical /app#new CTAs in 1.5 screens | -| 9 | Error Recovery | n/a | Zero forms, zero inputs, zero user-visible async operations — no error surface exists | -| 10 | Help and Documentation | 2 | A no-account E2EE tool with no docs, no FAQ, no threat model, no security page | - -Total 20/36 (56%) — Acceptable band. Visual craft sits well above that number; information architecture and the demonstration of behaviour drag it down. - -## Design Specificity Verdict - -Well-made and under-authored. Roughly 70% could ship for any local-first dev tool with the nouns swapped, and the 30% that is attn's is spent on the wrong argument. - -The damning fact, verified: "agent" and "AI" appear ZERO times on the page. PRODUCT.md positions attn as "the reviewer for agent-authored docs… human comments and AI suggestions in a single end-to-end-encrypted thread". The page argues "private local markdown editor with sharing" — a category with a dozen occupants. Even the hero screenshot shows only human reviewer cards. - -Interchangeable structures: the eyebrow->oversized-headline->lede->button-pair->micro-proof hero with a rotated window screenshot; the three-up entry triptych; numbered 01/02/03 how-it-works; the two-up comparison with one panel inverted to near-black; the brew-install section; accent-mono chapter indices. - -Genuinely authored: the paper ground and grain, the two-tier h1 (line two at 0.78em), the 6rem serif masthead, the Surfaces ground-colour inversion. All surface. The brand is "warm surface, sharp behavior" and the page ships only the surface — two interactions in 6,100px of scroll, zero @keyframes. - -Deterministic scan: CLI detector returns 0 findings on web/src/hosted/landing, but that zero is a scope artifact — the landing's styling lives in chrome.css/tokens.css outside the scanned paths, and the rules that matter are render-time. Injected into the live page the detector found 4: hero-eyebrow-chip (genuine), all-caps-body x2 (false positives — 31/32-char kickers, which is what the rule says uppercase is for), cream-palette (false positive — DESIGN.md specifies oklch(0.905 0.010 78) with chroma deliberately held at 0.010 to read as paper, not cream). All three eyebrow findings originate from one CSS rule at chrome.css:98-103. - -## Audit Health Score — 14/20 - -| # | Dimension | Score | Key finding | -|---|-----------|-------|-------------| -| 1 | Accessibility | 3 | 37 text styles measured, one contrast failure (decorative window dots); five AA-tier defects | -| 2 | Performance | 3 | LCP 1932ms / CLS 0.0027 / TBT 5ms on Fast 3G+4x CPU; hero sizes over-fetches 1.5x; 7.5MB dead PNGs | -| 3 | Responsive Design | 3 | Zero overflow at eight widths; 184px h-scroll at 200% text; 500px breakpoint gap with a real collision | -| 4 | Theming | 2 | Zero hard-coded colours, but three named rules broken systemically plus the INK bootstrap flash | -| 5 | Implementation Integrity | 3 | Detector clean and verified real; hero crop; duplicated window chrome; a public alternate homepage | - -## Priority Issues - -- [P0] The positioning is absent from the positioning surface (zero mentions of agent/AI). -- [P0] The hero screenshot is cropped through its own text on both edges — object-fit:cover on a 1.333 source in a 1.025 box. -- [P1] E2EE asserted four times, demonstrated zero times; no threat model, no security page. -- [P1] Mobile navigation disappears entirely below 680px with no replacement. -- [P1] 200% text produces 184px of horizontal scroll and an unreachable CTA at 390px (four grids use 1fr instead of minmax(0,1fr)). -- [P1] Dark-mode visitors see a full paper-white paint before INK applies; no prefers-color-scheme fallback exists. -- [P1] Eight controls miss 44x44; four miss the WCAG 2.2 24px floor. Only .button carries a size rule. - -## Persona Red Flags - -Jordan: clicks "Open your desk" — a word used four times and defined zero times — and the nav surfaces the empty desk most prominently to the person least equipped to read it. Riley: tests the E2EE claim first and finds nothing testable; presses Copy with permission denied and gets silence. Casey: no navigation at all, 6,100px of scroll, the only reachable controls top-right and undersized. James (PRODUCT.md's primary user): the returning path is one swapped nav label; no recent files, no keyboard entry, no Cmd-K, on the homepage of a keyboard-first product. - -## What's Working - -Token discipline is real — zero hard-coded colours across landing.css and all ten landing components; dark mode is a second design rather than an inversion; AA holds at all 24-37 sampled roles in both themes with the AA-retune documented in-comment. Focus indication is complete: all 17 tabbable controls draw a 2px ring at 5.30-8.18:1. The route-bundle boundary is real and gated pre-deploy at 25.9KB brotli. The side-stripe antipattern was anticipated and avoided on purpose, with the reasoning written into the CSS. diff --git a/.impeccable/critique/2026-08-04T23-40-27Z__desk-page-hosted-app.md b/.impeccable/critique/2026-08-04T23-40-27Z__desk-page-hosted-app.md deleted file mode 100644 index 2a29d344..00000000 --- a/.impeccable/critique/2026-08-04T23-40-27Z__desk-page-hosted-app.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -target: desk page (hosted /app) -total_score: 20 -max_score: 40 -na_heuristics: -p0_count: 3 -p1_count: 4 -timestamp: 2026-08-04T23-40-27Z -slug: desk-page-hosted-app ---- -Method: dual-agent (Assessment A design review and Assessment B detector/browser evidence as isolated sub-agents) plus a technical audit agent. Browser evidence via Playwright + system Chrome against the production build across empty / populated / invite-open / delete-confirm states in both themes; claude-in-chrome was not connected. - -Register: OPERATE. Judged as a tool opened fifty times a week. - -## Design Health Score — 20/40 (all ten applicable) - -| # | Heuristic | Score | Key issue | -|---|-----------|-------|-----------| -| 1 | Visibility of System Status | 3 | best-effort returns warn:false, so "Backup recommended" paints the same green as "On this device" | -| 2 | Match System / Real World | 3 | U+21A5 glyph reads as mojibake; Local only / Backed up / Shared have no legend | -| 3 | User Control and Freedom | 2 | Escape does not close the join panel; closeJoin() never restores focus | -| 4 | Consistency and Standards | 1 | Join panel is a foreign system (10px radius, raw px) inside a rem/0-radius desk; serif buttons | -| 5 | Error Prevention | 2 | role="alertdialog" with no focus move and no Escape; rename commits on blur | -| 6 | Recognition Rather Than Recall | 2 | Mobile deletes file count and last-edited from a list titled "Recently on this device" | -| 7 | Flexibility and Efficiency | 0 | Zero keyboard affordances on the whole desk; no palette, filter, sort or search | -| 8 | Aesthetic and Minimalist Design | 2 | 558px of chrome before the first workspace name; 813px on iPhone — payload below the fold | -| 9 | Error Recovery | 3 | Join error copy is genuinely good and role="alert"-ed; import error is an inline-styled p | -| 10 | Help and Documentation | 2 | Nothing explains what "Backup recommended" wants you to do, or that Storage fixes it | - -Total 20/40 — Acceptable band, and the 0 on heuristic 7 is the headline: this is a keyboard-first product's most-opened surface with no keyboard model. - -## Design Specificity Verdict - -Authored from the fold up; a generic "recent projects" list below it. The masthead is unmistakably attn — "Your desk" at 72px Source Serif over a hairline rule, mono storage line on the baseline, rust eyebrow. Then the payload arrives and the authorship stops: .workspace-row is a four-column div-table with no header, no hover (the hover CSS targets a.workspace-row and the element is a div — dead rule), no keyboard model, no search, and Rename/Delete stamped on every line. Strip the serif and it is any project list. - -Three tells: the accent is spent on a static eyebrow and withheld from the primary action (One Pencil inverted); buttons are set in 400-weight serif (Read/Do violated); four chrome roles are set in mono while the system's label token is used zero times. - -The deeper gap: the desk shows no review state at all, and WorkspaceSummary (types.ts:36-47) carries no review facts to show. A desk that lists files instead of reviews in flight is a file manager wearing attn's typeface. - -## Audit evidence - -Detector: 1 finding on web/src/hosted/app, verified false positive (blockquote rule using the neutral --rule token, not on the desk). Zero true positives. -Contrast: 31 text styles measured across four state/theme combinations — zero failures, lowest 5.23:1. The perceived washed-out metadata is small uppercase mono with wide tracking, not a WCAG problem. -Overflow: clean at 320/375/390/768/1024/1280, zero offending elements. -Console: zero errors, warnings or failed requests across all eight state/theme combinations. -Touch targets at 390: Rename 57.2x27.6, Delete 47.6x27.6 (0.6rem apart, destructive, irreversible), row-open 26px tall, join-go 60x37. - -## Priority Issues - -- [P0] Join panel has 64px above it and 0px below — .folio-label owns no margin and borrows it from .quick-actions via sibling collapse. -- [P0] No keyboard model at all on the surface a keyboard-first power user opens most. -- [P0] Rename/Delete announce no workspace name — a screen-reader user cannot tell which workspace is about to be irreversibly deleted. -- [P1] "Backup recommended" is painted in the safe-state green, and there is no backup affordance on the desk. -- [P1] The workspace row: dead hover CSS, a 200x28px open target in an 80,000px² row, resident admin controls heavier than the content. -- [P1] Mobile puts the first workspace at y=813 in an 844px viewport. -- [P1] The desk exposes no review state; the data model has nowhere to put one. - -## What's Working - -Colour token discipline is excellent and could not be broken — every text pair clears AA in both themes, most clear AAA, and the "never lighter than oklch(0.32 0.012 65)" floor is held exactly. Focus-visible is complete: 16 Tab stops, every one drawing a 2px ring, correctly recoloured to steel in INK. The privacy copy earns its place without a badge wall — "Shared · relay sees only ciphertext" and "The part after # is the room key — it never reaches the relay" are the best-written text in the product. The empty-desk composition (tilted sheet, "What deserves your attention?") is a genuinely authored moment. diff --git a/.impeccable/critique/2026-08-12T18-21-40Z__web-hosted.md b/.impeccable/critique/2026-08-12T18-21-40Z__web-hosted.md deleted file mode 100644 index 018691d9..00000000 --- a/.impeccable/critique/2026-08-12T18-21-40Z__web-hosted.md +++ /dev/null @@ -1,134 +0,0 @@ ---- -target: hosted web product in web/ -total_score: 27 -max_score: 40 -na_heuristics: "" -p0_count: 0 -p1_count: 6 -timestamp: 2026-08-12T18-21-40Z -slug: web-hosted ---- -Method: dual-agent (A: /root/impeccable_design_review · B: /root/impeccable_detector_evidence), with an independent technical audit by /root/impeccable_technical_audit - -# Critique — hosted web product - -Scope: `web/` only — homepage `/`, Desk `/app`, owner workspace `/app/w/:workspaceId/:filePath`, invited review `/review/:roomId` and `/s/:shareId`, and unknown/malformed routes. The separate `site/` package is deferred; it must later inherit the same brand and state grammar. - -## Design Health Score - -| # | Heuristic | Score | Key issue | -|---|---|---:|---| -| 1 | Visibility of System Status | 3 | Save, storage, and share states are strong; bad routes and review failures conceal what happened. | -| 2 | Match System / Real World | 3 | Desk/workspace/review fit; invited-review failures expose fragment/relay internals. | -| 3 | User Control and Freedom | 3 | Core navigation and mobile sheets work; review failures offer no recovery action. | -| 4 | Consistency and Standards | 3 | Core themes and surfaces cohere; lifecycle and not-found states lose the product grammar. | -| 5 | Error Prevention | 3 | Local persistence and destructive flows are thoughtful; routing creates false-success states. | -| 6 | Recognition Rather Than Recall | 3 | Core actions are labeled; missing-key recovery depends on finding the original link elsewhere. | -| 7 | Flexibility and Efficiency | 3 | Strong daily-user workspace; review history differs between desktop and mobile. | -| 8 | Aesthetic and Minimalist Design | 3 | Authored and restrained; mobile Desk rows collapse and the homepage trust proof runs long. | -| 9 | Error Recovery | 1 | Invalid review is a dead end and unknown paths masquerade as successful pages. | -| 10 | Help and Documentation | 2 | Security evidence is unusually concrete; product failure states have no contextual help. | -| **Total** | | **27/40** | **Acceptable, close to Good** | - -## Design Specificity Verdict - -The steady-state product is highly authored for attn: warm PAPER/cool INK, serif reading versus sans chrome, one pencil accent, a lit document plane, and local/encrypted state language form a coherent world that cannot be transferred unchanged to generic SaaS. Specificity collapses at the seams: review loading/errors become blank utilities, malformed routes silently become valid surfaces, and mobile Desk rows no longer preserve the desktop information hierarchy. - -The deterministic CLI scan reported one `broken-image` at `web/src/hosted/landing/Hero.svelte:79`; it is a confirmed false positive caused by `` in an HTML comment. The actual responsive screenshot has real fallback/AVIF sources, intrinsic dimensions, alt text, and no broken image in either inspected viewport. - -Browser overlay injection succeeded. Stable findings were: homepage 4 (`oversized-h1`, two `all-caps-body`, pinned-paper `cream-palette`); populated Desk 2 (`kicker-above-heading`, pinned-paper palette); owner workspace 2 (`flat-type-hierarchy`, pinned-paper palette); invalid review 2 (`flat-type-hierarchy`, pinned-paper palette). The palette signal describes the intentional design system and is not a remediation item. Unknown routes repeat the homepage findings because they incorrectly render that page. - -## Overall Impression - -The core surfaces already feel like one precise editorial tool. The opportunity is to extend that authority through small screens, lifecycle failures, durable review history, and routing—not to redesign the product. - -## What’s Working - -1. The “Lit Reading Room” identity holds across homepage, Desk, and workspace in PAPER and INK. -2. Local-first trust is operational: on-device storage, autosave, deliberate sharing, backup state, and ciphertext-only relay language appear where decisions are made. -3. The workspace puts reading first. Desktop chrome recedes; mobile uses a thumb dock and contextual Review sheet without displacing the document. - -## Priority Issues - -### [P1] Unknown and malformed paths are false successes - -Unknown root paths render the homepage with HTTP 200; malformed `/app/*` paths render Desk; malformed review paths render invite-error UI with HTTP 200. Add strict route recognition and an intentional branded 404 with real HTTP 404 behavior. Preserve the distinction between a malformed route, a valid workspace absent on this device, and a valid review capability that is expired/revoked/denied. - -Evidence: `web/src/lib/hosted/routes.ts:16-24,66-89`, `web/worker.ts`, `web/vite.browser.config.ts`, and `web/src/hosted/app/AppShell.svelte`. - -Suggested command: `/impeccable harden`. - -### [P1] Populated Desk rows break at phone width - -At 390×844, sharing state overlaps the title and metadata collapses into narrow word-per-line columns. Replace the inherited desktop grid with an explicit mobile card hierarchy: title/admin, wrapping metadata, review counts, then share/backup state. Keep the entire card as the open target and place administration behind a labeled overflow action. - -Evidence: `web/src/hosted/app/DeskHome.svelte:374-474`, `web/src/hosted/app/app-shell.css:297-383,2592-2620`. - -Suggested command: `/impeccable adapt`. - -### [P1] Reviewer lifecycle states are unbranded dead ends - -Invalid `/review` and fragmentless `/s` states expose internal error messages, have no semantic heading, and offer zero actions. Build one branded lifecycle shell for loading, invalid, denied, expired, deleted, revoked, offline, and bootstrap failure. Each state needs plain-language diagnosis, an honest privacy reassurance where relevant, and state-appropriate retry/paste/new-link/Home/Desk actions. Raw diagnostics stay in logs. - -Evidence: `web/src/BrowserReviewApp.svelte:1340-1400`, `web/src/browser-review.ts:133-171`. - -Suggested commands: `/impeccable harden`, `/impeccable clarify`. - -### [P1] Review history disappears when “live” is false - -Mobile demo behavior exposes persisted cards while desktop gates its rail on `reviewRoomActive`. Product decision: history remains available; “live” is connection state only. Establish one durable review projection and one count/label model across desktop and mobile. Live connectivity adds presence and authoring; it does not decide whether history exists. - -Evidence: `web/src/hosted/app/EditorShell.svelte` desktop rail versus mobile sheet; real `WorkspaceDetail.reviewCards` currently initializes empty while mock data carries cards. - -Suggested commands: `/impeccable clarify`, `/impeccable harden`. - -### [P1] Desktop workspace eagerly bundles the full icon catalog - -Opening the desktop workspace loads a 3,332,459-byte raw / 783,523-byte gzip chunk containing thousands of SVG modules. Split the hosted icon resolver so it loads only the selected pack and icons actually rendered. Existing exact tracker item: `attn-7xl.7.8`; do not duplicate it. - -Evidence: `web/src/lib/FileTree.svelte`, `web/src/lib/vscode-icon-map.generated.ts`, `web/src/lib/icons/vscode-generated`. - -Suggested command: `/impeccable optimize`. - -### [P1] Homepage install command overflow is not keyboard operable - -Axe reports `scrollable-region-focusable` at 390×844. Make long commands focusable and arrow-scrollable, or wrap without corrupting the copyable command. Verify 320px and 200% text. - -Evidence: `web/src/hosted/landing/CopyCode.svelte`, `web/src/hosted/landing/landing.css:617-622`. - -Suggested command: `/impeccable adapt`. - -### [P2] Homepage trust proof overwhelms the persuasion path - -Keep a concise security guarantee visible. Place the full relay ledger behind an explicit “Read the threat model” disclosure while retaining direct source links. Reduce the first viewport to one primary and one returning-user action; move Import/Join to the quieter start block. - -Evidence: homepage is 5,412px at 1440×1000 and 7,109px at 390×844; first viewport exposes roughly nine choices. - -Suggested command: `/impeccable distill`. - -### [P2] Compact product controls and one destructive dialog miss the accessibility contract - -Measured controls include 15–31px-high targets across Desk, owner mobile Share, reviewer status/toggle, and review file tabs. Preserve compact visuals while providing at least 44×44 coarse-pointer hit areas. The owner file-delete `role=alertdialog` lacks the contract Svelte requires; use the shared dialog primitive or honest inline-confirmation semantics. - -Evidence: `web/src/hosted/app/EditorShell.svelte`, `web/src/BrowserReviewApp.svelte`, `web/src/lib/ReviewerStatusChip.svelte`, `web/src/lib/ReviewFileNav.svelte`; `svelte-check` reports one accessibility warning at the file-delete confirmation. - -Suggested commands: `/impeccable adapt`, `/impeccable harden`. - -## Persona Red Flags - -- James, daily owner: the desktop workspace can lose the review obligation the Desk and mobile sheet advertise; malformed bookmarked paths silently become Desk. -- Jordan, invited reviewer: internal invite/fragment language, no brand, no action, and no safe way to paste/reopen a complete link make a failure the remembered end of the journey. -- Casey, distracted mobile user: the owner workspace is strong, but the highest-priority Desk row looks corrupted and the homepage takes more than seven mobile viewports of vertical content. - -## Minor Observations - -- INK is a genuine second material system; preserve it. -- The pinned paper palette is intentional and detector findings against it are not issues. -- No horizontal page overflow or rendered broken images were found in the inspected desktop/mobile states. -- Connected invited-review states were not visually verified because no authenticated capability/relay fixture was available; implementation acceptance must include a real multi-role loop. - -## Questions Resolved - -- The full relay evidence belongs behind an explicit disclosure; a concise security guarantee stays visible. -- Review history stays available after live connectivity ends; “live” is connection state only. -- `site/` remains out of scope. Its later epic must inherit typography roles, PAPER/INK parity, one-pencil accent use, quiet voice, local-first terminology, CTA vocabulary, and shared loading/error/not-found grammar—preferably through shared tokens or parity tests. diff --git a/Cargo.toml b/Cargo.toml index 38d49442..854ca969 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -151,9 +151,8 @@ objc2-web-kit = { version = "0.3", features = ["WKWebView", "WKSnapshotConfigura objc2-user-notifications = { version = "=0.3.1", default-features = false, features = ["block2", "UNNotification", "UNNotificationContent", "UNNotificationRequest", "UNNotificationResponse", "UNNotificationTrigger", "UNUserNotificationCenter"] } block2 = "0.6" -# Release profile tuned for the 25 MiB binary-size budget (scripts/ +# Release profile tuned for the 40 MiB binary-size budget (scripts/ # check-binary-size.sh; webrtc-rs + tokio + rustls + comrak are the weight). -# A bare `cargo build --release` previously shipped UNSTRIPPED with no LTO. # panic = "unwind" is kept (the default): a panic in a background tokio task # must not abort the whole desktop app. [profile.release] diff --git a/DESIGN.md b/DESIGN.md index 99c02df9..d3b584bb 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -19,6 +19,7 @@ colors: link: "oklch(0.48 0.14 28)" destructive: "oklch(0.55 0.20 27)" suggestion-green: "oklch(0.58 0.15 150)" + suggestion-ink: "oklch(0.40 0.12 150)" comment-amber: "oklch(0.62 0.13 82)" peer-owner: "oklch(0.58 0.14 32)" peer-reviewer: "oklch(0.56 0.11 235)" @@ -188,6 +189,7 @@ A warm parchment field carrying near-black ink and a single terracotta accent; c ### Secondary - **Ledger Green** (`oklch(0.58 0.15 150)`): suggestion / insertion. Inline `ins` marks, the suggestion accent on review cards, "added" ghost text. Reads as *accepted into the record*. +- **Suggestion Ink** (`oklch(0.40 0.12 150)`, INK `oklch(0.74 0.13 150)`): the same hue at text weight, for suggestion counts and any ledger-green *text*. It exists because the accent above backs 3px strips and washes and measures 3.0–3.3:1 as an 11px label — below the floor. Added to `tokens.css` 2026-08-19 (attn-08fa.5); it had lived in `hosted/chrome.css` as the one hosted colour with a literal of its own, so nothing checked it and the Quarantine Rule had no canonical token to point at. Both greens are collaboration-layer: see the Quarantine Rule below, which they are the most-breached instance of. - **Margin Amber** (`oklch(0.62 0.13 82)`): comment. The comment-highlight tint behind anchored text and the comment accent on review cards. Reads as *a note in the margin*, distinct from a proposed edit. ### Tertiary — Peer identity @@ -207,7 +209,7 @@ Role is no longer a color channel for humans — shape carries it (round = human - **Muted Ink** (`oklch(0.32 0.012 65)`): secondary text, labels, table headers. Sits at ~4.5:1 on paper — the floor for body, never lighter. - **Card / Sidebar** (`oklch(0.89 0.012 76)` / `oklch(0.855 0.012 75)`): the second neutral layer for chrome, a hair darker than the content surface so panels recede. - **Panel Surface** (`oklch(0.855 0.012 75)`, INK `oklch(0.172 0.014 257)`): the chrome plane — the comments rail sits on it, deliberately the *same* value as the sidebar. Both edges of the workspace recede equally so the document reads as a lit sheet between two rails. In INK the move inverts (the rails lift off a darker ground rather than sinking into it). -- **Header Surface** (`{colors.primary}` — the accent itself): the app header is an ACCENT PLANE (owner-directed 2026-08-10). Two requests converged on it from opposite directions — the web header should match the `Choose file` button below it (`bg-primary`), and the desktop header should be "orange/blue", which is precisely what the accent is in the two themes. This is the header's fourth surface, and the trajectory is the argument: paper (invisible), panel-surface (vanished into the sidebar), a warm tinted plane (still too quiet), the accent. Shared by all three headers — one grammar, one plane. **Polarity flips between themes**, which is why nothing on it is hard-coded white: Paper's accent is dark (0.48) so its foreground is near-white; Ink's is light (0.72) so its foreground is near-black. Measured on the plane: doc name **6.65:1** Paper / **8.41:1** Ink, muted icons **4.84:1** / **6.29:1** (alpha-composited). +- **Header Surface** (`{colors.primary}` — the accent itself): the app header is an ACCENT PLANE (owner-directed 2026-08-10). Two requests converged on it from opposite directions — the web header should match the `Choose file` button below it (`bg-primary`), and the desktop header should be "orange/blue", which is precisely what the accent is in the two themes. This is the header's fourth surface, and the trajectory is the argument: paper (invisible), panel-surface (vanished into the sidebar), a warm tinted plane (still too quiet), the accent. Shared by all three headers — one grammar, one plane. (Made true of the fourth, 2026-08-18, attn-a9f7.2.1: the hosted **desk** header — `/app`, `/open`, `/app/storage` — was still on paper, so crossing from the desk into a workspace swapped the most identity-carrying surface in the product at the boundary the owner crosses most. It now paints `--header-surface` and re-points the *hosted aliases* — `--ink`, `--hosted-muted`, `--rule`, `--rust`, `--sheet` — exactly as `app.css` re-points the canonical tokens for the other three; the hosted routes speak in aliases, and an alias declared at `:root` freezes its root value, so re-pointing the canonical tokens alone would have left this header unstyled. Its storage badge cannot carry its three tiers in hue on this ground, so they moved to three channels that survive the plane: weight for `ok`, the pre-existing hollow dot for `caution`, and an enclosing chip for `warn` — which is also the only tier that links to its own remedy.) **Polarity flips between themes**, which is why nothing on it is hard-coded white: Paper's accent is dark (0.48) so its foreground is near-white; Ink's is light (0.72) so its foreground is near-black. Measured on the plane: doc name **6.65:1** Paper / **8.41:1** Ink, muted icons **4.84:1** / **6.29:1** (alpha-composited). - **Rail Chip Surface** (`oklch(0.88 0.012 75)`, INK `oklch(0.205 0.013 257)`): fills for chips sitting *on* the panel plane. It exists because `--muted` lands 0.003 from `--panel-surface` in INK, so a `muted` chip on the rail is invisible there — a trap that has now been hit twice. - **Code Block** (`oklch(0.885 0.010 78)`, INK `oklch(0.176 0.014 256)`): the **embedded** surface shared by `pre`, inline code, **tables** and the frontmatter card — these are the same class of object and must not read as different materials. It is *recessed*: on Paper it sits **below** the page, not above it. (Corrected 2026-08-08, attn-evme.3. It read `oklch(0.972)` — lighter than the page — and this line called it "the raised surface", while the Flat-Until-Lifted Rule below listed the same object among things "genuinely pressed into" the page and gave it an inset shadow. It wore a recessed shadow over a raised tone, and the doc licensed it, which is why nobody caught that a reading surface had a panel on it brighter than the paper.) - **Code Block Nested** (`oklch(0.865 0.010 78)`, INK `oklch(0.204 0.014 256)`): the second step of the embedded tier, for a block inside a block — inline code in a table cell. It used to be `--background`, which was a step toward the ink only while the block was lighter than the page; it silently inverted when the block moved. @@ -220,10 +222,11 @@ Role is no longer a color channel for humans — shape carries it (round = human *The plane exception* (added 2026-08-10). The rule above governs MARKS made **on** a surface. The app header is a surface made **of** the accent, which is a different act: it is not one more terracotta thing competing with the others, it is a ground. On that ground the pencil inverts — `--foreground`, `--primary`, `--accent` and `--amber-deep` are all re-pointed at the on-accent foreground for the header's subtree (`app.css`, the `chrome-on-accent` block), so "active" still reads as a filled outlined pill and "muted" still reads as a step back, with the accent-on-accent invisibility that would otherwise follow designed out. The count still holds inside the reading column, which is what the rule is for: the header is chrome, and no second terracotta appears on the page beneath it. Floating cards that render *inside* the header subtree (ShareChip, SnapshotBadge, OutboxIndicator, PeerStrip) restore the ordinary palette — they are their own surface, not the plane. -*The two labelling exceptions* (added 2026-08-07, attn-bw2h.7 / attn-bw2h.8). The pencil also annotates. Two surfaces carry the accent while being none of action, selection, or focus, and they are the **complete** list — this is a closed enumeration, not a new category anyone may extend by analogy: +*The three labelling exceptions* (added 2026-08-07, attn-bw2h.7 / attn-bw2h.8; extended 2026-08-19, attn-08fa.1). The pencil also annotates. Three surfaces carry the accent while being none of action, selection, or focus, and they are the **complete** list — this is a closed enumeration, not a new category anyone may extend by analogy: - **Frontmatter keys** (`.frontmatter-pairs dt`). In a two-column key/value grid the key *names* the content rather than being it. The tint does the job small caps do in print: it separates the columns by role so the pairs scan without a rule between them. Values stay `--foreground`. Measured on the card's own `--code-block` ground: **4.97:1** in Paper, **7.85:1** in Ink. (Re-measured 2026-08-08 after attn-evme.3 recessed that ground; it previously read 6.47:1 / 7.67:1. Paper now clears the 4.5:1 floor by less than half a step, so this pairing is the one to re-measure first if the embedded tier ever moves again — `reading-palette.spec.ts` prints it on every run.) - **The saved save-state glyph** (`[data-slot="native-save-chip"]`, saved state only). This chip is the one piece of chrome that reports *where the user's work lives* — the product's entire claim in one glyph. On the ACCENT PLANE (2026-08-10) this exception is dormant rather than deleted: the header re-points `--primary` and `--amber-deep` at the on-accent foreground, so both save states render in the same on-plane ink and the glyph carries the whole signal — which DESIGN.md already said was the real signal, so nothing that was carrying meaning was lost. Measured on the plane: **6.65:1** Paper / **8.41:1** Ink, far past the 3:1 a 14px 2px-stroke glyph owes. The exception stays written down because the chip returns to a neutral plane the moment the header does. +- **List markers, ordered and unordered** (`.attn-doc :is(ul, ol) > li::marker`, at 80% primary). A bullet is not content — it is the typographic mark that says *this line is an item*, the same job the frontmatter key does one exception above. Set in ink it disappears into the text it is meant to punctuate; in the accent at 80% it reads as the red pencil ticking off a list, which is the product's own metaphor. Ratified 2026-08-19 (attn-08fa.1) after the audit found `base.css` and this document had disagreed since the markers shipped: the code called the rust point deliberate, the enumeration called it a violation, and a rule nobody can apply from the doc alone is not a rule. **Extended to ordered numerals the same day (user ruling)** — they had been left at `--muted-foreground`, so a document mixing the two list kinds coloured the same mark two ways and implied a distinction that does not exist. Sizes still differ by glyph: bullets 0.85em, numerals 0.9em, because a numeral carries shape a dot does not and stops being legible past single digits if shrunk further. **The budget consequence is real and is the price of the exception:** a listed document now spends the accent dozens of times in the reading column, so the column's *other* accent slots are gone — no accented emphasis, no accented callout titles, no second tinted mark of any kind on a page that has a list. Links keep `--link`, which is why they were never in this enumeration. What the exceptions do **not** license, so the rule keeps its teeth: @@ -342,6 +345,7 @@ This rule is descriptive, not aspirational — it predicts the values already in - **Review margin card** (signature): the primary container. Near-opaque raised paper (`oklch(0.94 0.010 76 / 96%)`), `6px` radius, `10px 12px 10px 13px` padding (the asymmetric left leaves room for the accent), the review-card lift shadow, and a top hairline border. - **The accent strip** (corrected 2026-08-06): a `3px` full-height strip on the card's left edge, **square at both ends** even though the card's corners are round. It carries `--rmc-accent` — the comment author's personal color, with kind (comment amber / suggestion green) and state (stale / low-confidence) overrides layered after. Implemented as an absolutely-positioned `::before` at `border-radius: 0`, with `isolation: isolate` on the card so its negative `z-index` cannot escape. It was previously an `inset` box-shadow, which the card's radius necessarily clipped into a tapered curve at both ends; the strip is information (who, and what kind), so it must not read as a decorative flourish. The card must never gain `overflow: hidden` — that would re-clip the strip and bring the curve back. - **General panels:** flat, one tonal step off the content surface, hairline `18%`-ink borders. No nested cards. +- **Nothing that draws a box is square** (user ruling, 2026-08-19). Any element with a **full** border — panel, card, dialog, drop well, choice tile, list box, bordered image — takes a step from the shadcn ladder in the `rounded` scale above: `lg` (10px, shadcn's own `--radius` default) for containers, `md` (8px) for the smaller controls inside them, because 10px on a 28px-high box reads as a pill rather than a rounded rectangle. Keep it subtle; this is a softened corner, not a pill. **Single-side rules stay square** — a radius on one border edge does nothing, and the hairlines under the desk title, the sidebar project row and the review card's top edge are rules, not boxes. Two consequences worth knowing: a container whose dividers are grid `gap` showing the parent's own background (`.quick-actions`, `.entry-strip`, `.surfaces`) also needs `overflow: hidden`, or the square-cornered cell backgrounds paint over the corners you just rounded; and the review margin card's accent strip is the documented exception two bullets above — it stays square-ended and the card must never gain `overflow: hidden`. - **Tables** are code blocks: same `--code-block` fill, 1px border, `6px` radius and inset lip. Achieved with `border-collapse: separate` + `border-spacing: 0` (a collapsed table merges cell borders into the table box and squares off the radius) and **no cell backgrounds** — a filled header row would re-square the top corners and cover the inset lip, so header distinction is carried by ink weight instead. ### Inputs / Fields @@ -350,6 +354,7 @@ This rule is descriptive, not aspirational — it predicts the values already in ### Navigation — Project Sidebar - **Style:** the second neutral layer (`sidebar`), a subtle dotted radial texture, a `10%`-ink right border. Sans-serif throughout. +- **One gutter:** every column in the rail — the project row, the filter field, the file tree, the section labels, the empty card — is inset by the same `12px` (`spacing.md`), declared once as `--sidebar-gutter`. The rows are *contained items*, not full-bleed bands; a row that touches the rail's walls cannot show the radius below on the sides it touches, which is exactly how the tree shipped until 2026-08-19 (attn-mkmz.4). Note the reset in `app.css` that zeroes `padding-inline-start` on sidebar lists is **unlayered**, so the gutter rule has to be unlayered too — a `@layer components` rule loses to it on the start side no matter how specific. - **Tree rows:** `34px` tall, `9px` radius, `20px`-per-depth indent with a hairline guide line; hover fills `14%` ink, active fills `19%` ink with a `2px` accent bar at the left inset. File-type icons come from a VS Code icon pack, contrast-boosted. - **States:** default / hover / active / focus-visible (a `2px` inset ring) are all specified — ship none of them half-done. @@ -360,10 +365,14 @@ The editorial heart of the product. Reviewer edits render as attributed inline m - **Comment anchor:** amber highlight tint behind the running text, `box-decoration-break: clone` so it wraps cleanly across lines. - **Confidence ramp & stale:** anchored suggestions carry a descending-presence background (high → low) in the accent hue; a stale anchor desaturates and switches to a dotted underline. +### Named Rules + +**The One-Dialect Rule** (added 2026-08-18, attn-a9f7.2.2). The hosted app shell is written twice — `app-shell.css` by hand on the hosted aliases, and the workspace editor from the native shadcn-derived components on Tailwind utilities — and for months nothing but review attention kept them in step, which is how the desk header stayed off the accent plane long after this document said every header was on it. The rule is not "one file may not use utilities". It is that **tokens are canonical and the ramp is closed**: chrome states colour as `var(--token)` and never as a raw `#`/`oklch()`/`rgb()` literal in a colour declaration, and every `font-size` in the shell is one of the sizes the frontmatter declares. Both halves are enforced by `web/src/hosted/app/chrome-dialect.test.ts`, which also pins the desk header to `--header-surface` and the keycap to a single `.kbd-chip`. It found three off-ramp sizes (`0.65`, `0.82`, `1.05rem`) and two forked colour literals on its first run, which is the answer to "what test fails when they drift". + ## Do's and Don'ts ### Do: -- **Do** keep the terracotta/steel accent to action, selection, and focus — the One Pencil Rule — plus its two enumerated labelling exceptions (frontmatter keys, the saved save-state glyph) and nothing else. Everything else is ink, paper, and the second neutral layer. +- **Do** keep the terracotta/steel accent to action, selection, and focus — the One Pencil Rule — plus its three enumerated labelling exceptions (frontmatter keys, the saved save-state glyph, list markers) and nothing else. Everything else is ink, paper, and the second neutral layer. - **Do** use serif for everything read and sans for everything operated — no exceptions (the Read/Do Rule). - **Do** hold the paper ground at chroma ≤ 0.012; carry warmth through the accent and the serif. - **Do** keep the review hues (green / amber / peer colors) quarantined to the collaboration layer, distinguished by meaning and attribution — never decoration. diff --git a/PRODUCT.md b/PRODUCT.md index 53fef0d6..abac9577 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -20,7 +20,9 @@ attn is a native, end-to-end-encrypted collaborative markdown reviewer. It opens ## Positioning -The reviewer for agent-authored docs: the one place where you and your agents review the same document together, human comments and AI suggestions in a single end-to-end-encrypted thread, over files that never leave your machine. Every screen should reinforce that a review here is private by construction and that human and agent are peers in the same margin. +The reviewer for agent-authored docs: the one place where you and your agents review the same document together, human comments and AI suggestions in a single end-to-end-encrypted thread, over files that never leave your machine in the clear. Every screen should reinforce that a review here is private by construction and that human and agent are peers in the same margin. + +(*"in the clear"* added 2026-08-19, attn-08fa.3. Sharing publishes an encrypted copy, as the paragraph above already says precisely — so the unqualified form of this line was a claim the product's own share flow contradicts, and it was being quoted verbatim onto the landing hero. Surfaces may compress this positioning, but none of them may drop the qualifier.) ## Brand Personality diff --git a/planning/collab/test-vectors/event-signature.json b/planning/collab/test-vectors/event-signature.json index f4e7b3c8..f911745f 100644 --- a/planning/collab/test-vectors/event-signature.json +++ b/planning/collab/test-vectors/event-signature.json @@ -43,8 +43,14 @@ "snapshotId": "snap-vec-1", "baseHash": "hash-vec-1", "position": { - "byteRange": [0, 5], - "lineRange": [1, 1] + "byteRange": [ + 0, + 5 + ], + "lineRange": [ + 1, + 1 + ] } }, "body": "hello" @@ -70,7 +76,10 @@ "authorId": "p-vec-2", "deviceId": "d-vec-2", "createdAt": 1700000001500, - "parentEventIds": ["evt-zzz", "evt-aaa"], + "parentEventIds": [ + "evt-zzz", + "evt-aaa" + ], "snapshotId": "snap-vec-2" }, "body": { @@ -82,8 +91,14 @@ "snapshotId": "snap-vec-2", "baseHash": "hash-vec-2", "position": { - "byteRange": [10, 14], - "lineRange": [3, 3] + "byteRange": [ + 10, + 14 + ], + "lineRange": [ + 3, + 3 + ] } }, "operation": { @@ -114,7 +129,9 @@ "authorId": "p-vec-3", "deviceId": "d-vec-3", "createdAt": 1700000002000, - "parentEventIds": ["evt-parent-3"] + "parentEventIds": [ + "evt-parent-3" + ] }, "body": { "type": "comment_resolved", @@ -171,7 +188,11 @@ "authorId": "p-vec-5", "deviceId": "d-vec-5", "createdAt": 1700000004500, - "parentEventIds": ["evt-mid-5", "evt-aaa-5", "evt-zzz-5"], + "parentEventIds": [ + "evt-mid-5", + "evt-aaa-5", + "evt-zzz-5" + ], "snapshotId": "snap-vec-5" }, "body": { @@ -186,6 +207,36 @@ "signature": "doERvA05RpnAWdc5u1l2MZZQP2ZlpXXTds4ZHLh70w9m11mgOJSjahCXJrioirwj1jNr8vkXgFqmulTeA5zgBw", "signingKeyId": "tMHs6Jjs4k4k5gEjL5XGoYlxaJoN1mnm14IYU3whw4k" } + }, + { + "name": "CommentReopened with one parent — the resolve inverse (attn-bb6t.4); pins the reopen body shape", + "signingKey": { + "private": "ZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmY", + "public": "NLTZBDFWy23PC-sKKUm3VZyUDSvLbb6MU6mzAnjjp0Y" + }, + "event": { + "meta": { + "v": 2, + "eventId": "placeholder-event-id-6", + "roomId": "room-vec-6", + "authorId": "p-vec-6", + "deviceId": "d-vec-6", + "createdAt": 1700000005000, + "parentEventIds": [ + "evt-parent-6" + ] + }, + "body": { + "type": "comment_reopened", + "threadId": "thr-vec-6", + "reopenedBy": "p-reopener-6" + } + }, + "expected": { + "canonicalSignedBytes": "{\"body\":{\"reopenedBy\":\"p-reopener-6\",\"threadId\":\"thr-vec-6\",\"type\":\"comment_reopened\"},\"meta\":{\"authorId\":\"p-vec-6\",\"createdAt\":1700000005000,\"deviceId\":\"d-vec-6\",\"parentEventIds\":[\"evt-parent-6\"],\"roomId\":\"room-vec-6\",\"v\":2}}", + "signature": "79cQXHqjmkWD8oidDPTFbw2c80b-FT4oCzPYs0PbHOEdowA-g-OAREoPwBj07e_Wcx3xcgX4CqNxorUjJ3zmBw", + "signingKeyId": "97dnbJTffo_ZmY44-f_wTYWIsA0YAXZOPxh3o_6uRHc" + } } ] } diff --git a/relay/src/index.ts b/relay/src/index.ts index cbccd176..e3e450ec 100644 --- a/relay/src/index.ts +++ b/relay/src/index.ts @@ -262,46 +262,10 @@ export default { // rejoin remains available while a first create fails closed. request = await withPrivateQuotaSource(request, env, url.pathname); - // GET /health is the only unauthenticated route. Every other route below - // (when filled in by 5.5–5.11) must verify admission via: - // - // import { verifyAdmission, AdmissionError } from "./admission"; - // try { - // await verifyAdmission(request, url.pathname, { - // roomId, - // admissionKey, // loaded from DO storage at meta:admission_key (5.5) - // }); - // } catch (err) { - // if (err instanceof AdmissionError) { - // return Response.json({ error: { code: err.code, message: err.message } }, { status: 401 }); - // } - // throw err; - // } - // - // Owner-privileged routes additionally call verifyOwnerSignature (5.3); - // writes also call verifyPow (5.4). The owner check composes after - // admission so we never reveal whether owner-sig was even attempted on - // a request the URL-bearer wouldn't otherwise be allowed to make: - // - // import { verifyOwnerSignature, OwnerSigError } from "./owner-sig"; - // - // // ... inside DELETE /v2/rooms/:roomId or POST /acks (with delete=true): - // await verifyAdmission(request, url.pathname, { roomId, admissionKey }); - // try { - // await verifyOwnerSignature(request, url.pathname, ownerSigningKey); - // } catch (err) { - // if (err instanceof OwnerSigError) { - // return Response.json( - // { error: { code: err.code, message: err.message } }, - // { status: 403 }, - // ); - // } - // throw err; - // } - // - // Endpoint dispatch is owned by attn-nnj.5.9 (DELETE /v2/rooms/:roomId) - // and attn-nnj.5.8 (POST /acks). This file currently only stubs the - // composition pattern so reviewers can see how the verifiers chain. + // GET /health is the only unauthenticated route; every other route + // verifies admission inside its Durable Object. Owner-privileged routes + // check owner-sig AFTER admission, so a request the URL-bearer could not + // make in the first place never reveals whether owner-sig was attempted. if (url.pathname === "/health" && request.method === "GET") { return Response.json({ diff --git a/relay/src/room-do.ts b/relay/src/room-do.ts index ba2f4e7b..cb674d6c 100644 --- a/relay/src/room-do.ts +++ b/relay/src/room-do.ts @@ -1724,10 +1724,10 @@ export class RoomDO extends DurableObject { const isOwnerDevice = deviceRecord.kind === "owner"; // An owner snapshot supersedes every collab signal before it, so it drives - // signal compaction (event-log-compaction.md). The authority to compact is - // owner-only, so it must never rest on the previous heuristic (any device - // that merely lacked a reviewer grant tier), which classified every v2 - // reviewer as an owner. + // signal compaction (event-log-compaction.md). Compaction authority is + // owner-only and must bind to a positive owner check — inferring it from + // the ABSENCE of a reviewer grant tier classifies every v2 reviewer as an + // owner. // // v2 has no per-envelope device proof, so the best available check is the // registration-bound device kind: a device registers kind="owner" only if @@ -3904,7 +3904,6 @@ export class RoomDO extends DurableObject { return errorResponse(429, "ATTN_SOCKET_LIMIT", "device socket limit reached"); } - // Build the upgrade response. const pair = new WebSocketPair(); const client = pair[0]; const server = pair[1]; diff --git a/relay/src/schema.ts b/relay/src/schema.ts index 72529872..061f379e 100644 --- a/relay/src/schema.ts +++ b/relay/src/schema.ts @@ -225,7 +225,8 @@ export const envelopeSchema = z.object({ createdAt: unixMs, expiresAt: unixMs, nonce: b64url.min(1, "nonce required").max(XCHACHA20_NONCE_MAX_CHARS), - ciphertext: b64url, // empty ciphertext is allowed at the schema layer; per-kind cap is enforced in handler + // Empty ciphertext passes the schema; the per-kind cap is enforced in the handler. + ciphertext: b64url, ciphertextBytes: z.number().int().positive(), /** V3 signal-only monotonic negotiation/collaboration generation. */ signalGeneration: z.number().int().nonnegative().optional(), diff --git a/scripts/build.sh b/scripts/build.sh index cfd165ce..70f2db9a 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -25,17 +25,14 @@ case "$MODE" in ;; esac -# Install npm deps if missing if [ ! -d "web/node_modules" ]; then echo "==> Installing npm dependencies..." (cd web && npm ci) fi -# Build Svelte frontend echo "==> Building Svelte frontend..." (cd web && npm run build) -# Build Rust binary case "$MODE" in debug) echo "==> Building Rust (debug, staging services)..." diff --git a/scripts/capture-collab-screenshots.sh b/scripts/capture-collab-screenshots.sh index 715616af..fd7242b4 100755 --- a/scripts/capture-collab-screenshots.sh +++ b/scripts/capture-collab-screenshots.sh @@ -29,7 +29,7 @@ cd "$PROJECT_DIR" : "${RELAY_PORT:=8793}" : "${ATTN_BIN:=$PROJECT_DIR/target/debug/attn}" RELAY_URL="http://localhost:${RELAY_PORT}" -OWNER_HOME="/tmp/attn-cap-owner"; RV_HOME="/tmp/attn-cap-rv" +OWNER_HOME="/tmp/attn-cap-owner"; RV_HOME="/tmp/attn-cap-rv"; AGENT_HOME="/tmp/attn-cap-agent" WORK="/tmp/attn-cap-work"; SHARED_DOC="$WORK/launch-plan.md"; RELAY_LOG="$WORK/relay.log" SCRATCH="/tmp/attn-cap-scratch" OUT="$PROJECT_DIR/site/static/screenshots" @@ -52,15 +52,17 @@ esac HERO_THEME="$ATTN_CAPTURE_VARIANT" HERO_SUFFIX="$ATTN_CAPTURE_VARIANT" -RELAY_PID=""; OWNER_PID=""; RV_PID="" +RELAY_PID=""; OWNER_PID=""; RV_PID=""; AGENT_PID="" +AGENT_CMDS=""; AGENT_LOG="" log(){ printf '==> %s\n' "$*"; } attn_owner(){ ATTN_HOME="$OWNER_HOME" ATTN_RELAY_URL="$RELAY_URL" "$ATTN_BIN" "$@"; } attn_rv(){ ATTN_HOME="$RV_HOME" ATTN_RELAY_URL="$RELAY_URL" "$ATTN_BIN" "$@"; } +attn_agent(){ ATTN_HOME="$AGENT_HOME" ATTN_RELAY_URL="$RELAY_URL" "$ATTN_BIN" "$@"; } poll(){ local t="$1"; shift; local d=$(( $(date +%s)*1000 + t )); while [ "$(($(date +%s)*1000))" -lt "$d" ]; do "$@" >/dev/null 2>&1 && return 0; sleep 0.25; done; return 1; } wait_ready(){ poll "${3:-25000}" "$1" --wait-for "$2" --timeout 1000; } kill_pid(){ local p="$1"; [ -z "$p" ] && return 0; kill "$p" 2>/dev/null||true; local i=0; while kill -0 "$p" 2>/dev/null && [ $i -lt 30 ];do sleep 0.1;i=$((i+1));done; kill -0 "$p" 2>/dev/null && kill -9 "$p" 2>/dev/null||true; } -cleanup(){ log "cleanup"; kill_pid "$OWNER_PID"; kill_pid "$RV_PID"; [ -n "$RELAY_PID" ] && { pkill -P "$RELAY_PID" 2>/dev/null||true; kill_pid "$RELAY_PID"; }; pkill -f "wrangler dev --local --port $RELAY_PORT" 2>/dev/null||true; rm -rf "$SCRATCH"; } +cleanup(){ log "cleanup"; kill_pid "$AGENT_PID"; kill_pid "$OWNER_PID"; kill_pid "$RV_PID"; [ -n "$RELAY_PID" ] && { pkill -P "$RELAY_PID" 2>/dev/null||true; kill_pid "$RELAY_PID"; }; pkill -f "wrangler dev --local --port $RELAY_PORT" 2>/dev/null||true; rm -rf "$SCRATCH"; } trap cleanup EXIT INT TERM focus_owner(){ local pid @@ -108,14 +110,19 @@ shot(){ attn_owner --screenshot 2>/dev/null | grep -oE '/tmp/attn-screenshot-[0- owner_window_id(){ attn_owner --info 2>/dev/null | awk '/^window_id:/ {print $2; exit}'; } reviewer_window_id(){ attn_rv --info 2>/dev/null | awk '/^window_id:/ {print $2; exit}'; } owner_shot(){ - local wid out + local wid out native focus_owner + # Native WKWebView snapshot first: exact webview pixels (1920×1440 at 2x), + # independent of Spaces/occlusion — `screencapture -l` of an off-space + # window silently returns a ~214px proxy thumbnail. + native="$(shot)" + if [ -n "$native" ] && [ -f "$native" ]; then echo "$native"; return 0; fi wid="$(owner_window_id)" out="$SCRATCH/owner-shot-$(date +%s%N).png" if [ -n "$wid" ] && command -v screencapture >/dev/null 2>&1; then screencapture -x -o -l "$wid" "$out" >/dev/null 2>&1 && { echo "$out"; return 0; } fi - shot + return 1 } # Match the app's setTheme (theme.ts): set BOTH data-theme AND the .dark class, # otherwise prose text color and shadcn surfaces disagree. @@ -132,6 +139,39 @@ sel(){ attn_rv --eval "(function(){var v=window.__attnPmView;if(!v)return 'no';v selText(){ local mode="${2:-}"; attn_rv --eval "(function(){var v=window.__attnPmView;if(!v)return 'no';var doc=v.state.doc,n='$1',f=null;doc.descendants(function(node,pos){if(f||!node.isText)return !f;var i=node.text.indexOf(n);if(i>=0)f={a:pos+i,b:pos+i+n.length};return !f;});if(!f)return 'notfound';var S=v.state.selection.constructor;v.focus();var to='$mode'==='collapse'?f.a:f.b;v.dispatch(v.state.tr.setSelection(S.create(doc,f.a,to)));return 'ok';})()" 2>/dev/null | tr -d '"'; } pm_insert_reviewer(){ local text="$1"; text="${text//\\/\\\\}"; text="${text//\'/\\\'}"; attn_rv --eval "(function(){var v=window.__attnPmView;if(!v)return 'no-view';v.focus();v.dispatch(v.state.tr.insertText('$text'));return 'ok';})()" >/dev/null 2>&1; } type_reviewer_text(){ local text="$1"; local i ch; for ((i=0; i<${#text}; i++)); do ch="${text:i:1}"; pm_insert_reviewer "$ch"; sleep 0.16; done; } +# Stage a persona display name directly in a home's identity.json. Announces +# (ParticipantJoined) load the identity fresh, so editing between boot and +# share/join is enough — and it keeps the first-run name prompt out of the +# captures. +set_identity_name(){ + python3 - "$1" "$2" <<'PY' +import json, sys +path, name = sys.argv[1], sys.argv[2] +with open(path) as f: + data = json.load(f) +data["displayName"] = name +with open(path, "w") as f: + json.dump(data, f, indent=2) +PY +} +# Fallback if the first-run name prompt still appears in front of a flow. +confirm_name_prompt(){ # $1 = attn fn, $2 = persona name + if "$1" --wait-for '[data-slot=name-prompt-input]' --timeout 2500 >/dev/null 2>&1; then + "$1" --fill '[data-slot=name-prompt-input]' "$2" >/dev/null 2>&1 + sleep 0.3 + "$1" --eval "document.querySelector('[data-slot=name-prompt-confirm]')?.click();'x'" >/dev/null 2>&1 + sleep 0.6 + fi +} +# Count owner margin cards of one kind. +sugg_cards_owner(){ attn_owner --eval "document.querySelectorAll('[data-testid=review-margin-card][data-kind=suggestion]').length" 2>/dev/null | tr -d '"'; } +# Append one JSON command line to the headless agent's command file. +agent_suggest_diff(){ + python3 - "$1" >> "$AGENT_CMDS" <<'PY' +import json, sys +print(json.dumps({"cmd": "suggest-diff", "diff": open(sys.argv[1]).read()})) +PY +} drive_hero_workflow(){ sleep 0.8 @@ -139,6 +179,7 @@ drive_hero_workflow(){ selText 'a few teams' >/dev/null sleep 0.35 attn_rv --eval "window.dispatchEvent(new KeyboardEvent('keydown',{key:'.',code:'Period',metaKey:true,bubbles:true}));'x'" >/dev/null 2>&1 + confirm_name_prompt attn_rv "Sam Porter" if wait_ready attn_rv '.comment-composer textarea' 8000; then attn_rv --fill '.comment-composer textarea' 'Can we open this to the whole waitlist, not just a few teams?' >/dev/null 2>&1 sleep 0.35 @@ -148,17 +189,17 @@ drive_hero_workflow(){ fi sleep 1.3 - log "hero workflow: reviewer suggests editorial rewrite" - selText 'internal dogfooding' >/dev/null - sleep 0.35 - attn_rv --eval "window.dispatchEvent(new KeyboardEvent('keydown',{key:'.',code:'Period',metaKey:true,shiftKey:true,bubbles:true}));'x'" >/dev/null 2>&1 - if wait_ready attn_rv '[data-slot=suggestion-composer-text]' 8000; then - attn_rv --fill '[data-slot=suggestion-composer-text]' 'internal dogfooding + a team bug bash' >/dev/null 2>&1 - sleep 0.35 - attn_rv --eval "document.querySelector('[data-slot=suggestion-composer-submit]')?.click(); 'x'" >/dev/null 2>&1 - else - log "suggestion composer did not open" - fi + # The suggestion beat now belongs to the AGENT (landing cast: one human + # comment + one agent-attributed suggestion in the same margin). The + # headless participant anchors a diff hunk against the shared snapshot. + log "hero workflow: agent suggests editorial rewrite" + agent_suggest_diff "$AGENT_DIFF" + d=$(( $(date +%s)+20 )) + while [ "$(date +%s)" -lt "$d" ]; do + [ "$(sugg_cards_owner)" -ge 1 ] 2>/dev/null && break + sleep 0.5 + done + log "owner shows agent suggestion card: $(sugg_cards_owner)" sleep 1.3 log "hero workflow: reviewer parks a live cursor" @@ -182,6 +223,16 @@ record_hero_video(){ local out="$OUT/collab-hero-${HERO_SUFFIX}.mp4" local raw="$SCRATCH/collab-hero-${HERO_SUFFIX}.mov" local wid + # Stills-only mode: the workflow must still run (it stages the margin cards + # the editorial shots need), but the screen recording is skipped — it is the + # slowest leg and long runs let the relay WS idle into an Offline chip. + if [ "${ATTN_CAPTURE_SKIP_VIDEO:-0}" = "1" ]; then + log "skipping hero video (ATTN_CAPTURE_SKIP_VIDEO=1); staging workflow only" + set_theme "$HERO_THEME" + focus_owner + drive_hero_workflow + return 0 + fi wid="$(owner_window_id)" if [ -z "$wid" ]; then log "SKIP collab-hero-${HERO_SUFFIX}.mp4 (no owner window id)"; return 0; fi if ! command -v screencapture >/dev/null 2>&1; then log "SKIP collab-hero-${HERO_SUFFIX}.mp4 (screencapture missing)"; return 0; fi @@ -230,25 +281,38 @@ record_share_flow(){ focus_owner sleep 0.5 + # ⌘⇧S opens the file-picker step; `share-start` ("Create review link…") + # mints the durable share and swaps the dialog into its link/command state. + drive_share_dialog(){ + attn_owner --eval "window.dispatchEvent(new KeyboardEvent('keydown',{key:'s',code:'KeyS',metaKey:true,shiftKey:true,bubbles:true}));'x'" >/dev/null 2>&1 + confirm_name_prompt attn_owner "Maya Alvarez" + wait_ready attn_owner '[data-slot=share-start]' 10000 || { log "share dialog did not open"; return 1; } + sleep 0.8 + attn_owner --eval "document.querySelector('[data-slot=share-start]')?.click();'x'" >/dev/null 2>&1 + wait_ready attn_owner '[data-slot=share-invite-url]' 20000 + } + if [ -n "$wid" ] && command -v screencapture >/dev/null 2>&1; then rm -f "$raw" "$OUT/share-flow-${HERO_SUFFIX}.gif" log "recording share-flow-${HERO_SUFFIX}.gif from owner window $wid" - screencapture -x -o -v -V 4 -l "$wid" "$raw" >/dev/null 2>&1 & + screencapture -x -o -v -V 8 -l "$wid" "$raw" >/dev/null 2>&1 & local rec_pid=$! sleep 0.65 - attn_owner --eval "window.dispatchEvent(new KeyboardEvent('keydown',{key:'s',code:'KeyS',metaKey:true,shiftKey:true,bubbles:true}));'x'" >/dev/null 2>&1 - wait_ready attn_owner '[data-slot=share-invite-url]' 20000 || { log "no invite"; kill_pid "$rec_pid"; exit 1; } + drive_share_dialog || { log "no invite"; kill_pid "$rec_pid"; exit 1; } sleep 1.2 wait "$rec_pid" || log "FAILED recording share-flow-${HERO_SUFFIX}.gif" encode_share_flow_gif "$raw" else log "open Share dialog" - attn_owner --eval "window.dispatchEvent(new KeyboardEvent('keydown',{key:'s',code:'KeyS',metaKey:true,shiftKey:true,bubbles:true}));'x'" >/dev/null 2>&1 - wait_ready attn_owner '[data-slot=share-invite-url]' 20000 || { log "no invite"; exit 1; } + drive_share_dialog || { log "no invite"; exit 1; } fi } -rm -rf "$OWNER_HOME" "$RV_HOME" "$WORK" "$SCRATCH"; mkdir -p "$OWNER_HOME" "$RV_HOME" "$WORK" "$WORK/empty-rv" "$SCRATCH" "$OUT" +rm -rf "$OWNER_HOME" "$RV_HOME" "$AGENT_HOME" "$WORK" "$SCRATCH"; mkdir -p "$OWNER_HOME" "$RV_HOME" "$AGENT_HOME" "$WORK" "$WORK/empty-rv" "$SCRATCH" "$OUT" +AGENT_CMDS="$WORK/agent-cmds.jsonl"; AGENT_LOG="$WORK/agent.log"; AGENT_DIFF="$WORK/agent-suggestion.diff" +# The "Review loop" section exists so the hero window's bottom third carries +# document instead of empty paper (landing critique 2026-08-18) — and the copy +# it carries is the product's own thesis. cat > "$SHARED_DOC" <<'MD' # Q3 Launch Plan @@ -260,11 +324,26 @@ Reviewers join from a link — no install required, end-to-end encrypted. - Week 1 — internal dogfooding - Week 2 — closed beta with design partners - Week 3 — public launch on attn.sh + +## Review loop + +Comments and suggestions land in one margin, attributed to their author. +The file on disk changes only when the owner accepts a change. MD +cat > "$AGENT_DIFF" <<'DIFF' +--- a/launch-plan.md ++++ b/launch-plan.md +@@ -8 +8 @@ +-- Week 1 — internal dogfooding ++- Week 1 — internal dogfooding + a team bug bash +DIFF [ -d relay/node_modules ] || (cd relay && npm ci >/dev/null) log "relay :$RELAY_PORT" -( cd relay && exec npx wrangler dev --local --port "$RELAY_PORT" ) >"$RELAY_LOG" 2>&1 & RELAY_PID=$! +# QUOTA_ALLOW_UNATTRIBUTED_CREATES: local wrangler has no CF-Connecting-IP, so +# durable-share creation 503s (ATTN_QUOTA_UNAVAILABLE) without it — same var +# relay/package.json's dev script passes. +( cd relay && exec npx wrangler dev --local --port "$RELAY_PORT" --var QUOTA_ALLOW_UNATTRIBUTED_CREATES:true ) >"$RELAY_LOG" 2>&1 & RELAY_PID=$! d=$(( $(date +%s)+60 )); while [ "$(date +%s)" -lt "$d" ]; do curl -fsS "$RELAY_URL/health" >/dev/null 2>&1 && break; sleep 0.3; done log "boot owner + reviewer" @@ -273,15 +352,32 @@ ATTN_HOME="$RV_HOME" ATTN_RELAY_URL="$RELAY_URL" "$ATTN_BIN" --no-fork "$WORK/em wait_ready attn_owner 'h1' || { log "owner not ready"; exit 1; } wait_ready attn_rv 'body' || { log "rv not ready"; exit 1; } +# Personas, not the machine's git identity (the boot created identity.json; +# the share/join announces re-read it). +set_identity_name "$OWNER_HOME/identity.json" "Maya Alvarez" && log "owner persona set" +set_identity_name "$RV_HOME/identity.json" "Sam Porter" && log "reviewer persona set" + record_share_flow -INVITE=""; d=$(( $(date +%s)+15 )); while [ "$(date +%s)" -lt "$d" ]; do INVITE="$(attn_owner --eval "document.querySelector('[data-slot=share-invite-url]')?.value||''" 2>/dev/null | tr -d '"\\' | tr -d '\r\n')"; case "$INVITE" in attn://review/*) break;; esac; sleep 0.3; done +# The url slot now carries the HTTPS browser link; the attn:// deep link the +# native CLI join needs lives in the "Send this command" card. +INVITE=""; d=$(( $(date +%s)+15 )); while [ "$(date +%s)" -lt "$d" ]; do INVITE="$(attn_owner --eval "((document.querySelector('[data-slot=share-cli-command]')?.textContent||'').match(/attn:\\/\\/review\\/[^' ]+/)||[''])[0]" 2>/dev/null | tr -d '"' | sed 's|\\/|/|g' | tr -d '\r\n')"; case "$INVITE" in attn://review/*) break;; esac; sleep 0.3; done +ROOM_ID="$(printf '%s' "$INVITE" | sed -E 's|^attn://review/([^#?]+).*|\1|')" # --- SHARE dialog shots (no reviewer yet → clean, no warnings) --- -dlg(){ attn_owner --eval "(document.querySelector('[data-slot=share-dialog]')||document.querySelector('[data-slot=dialog-overlay]'))?'open':'CLOSED'" 2>/dev/null | tr -d '"'; } +# Visibility, not DOM presence: closed overlays stay mounted (`display: none` +# per the Truth Rule), so a presence probe lies about what pixels show. +dlg(){ attn_owner --eval "var d=document.querySelector('[data-slot=share-dialog]'); d&&(d.offsetWidth||d.offsetHeight)?'open':'CLOSED'" 2>/dev/null | tr -d '"'; } +ensure_share_dialog(){ + [ "$(dlg)" = "open" ] && return 0 + attn_owner --eval "window.dispatchEvent(new KeyboardEvent('keydown',{key:'s',code:'KeyS',metaKey:true,shiftKey:true,bubbles:true}));'x'" >/dev/null 2>&1 + sleep 1.2 + [ "$(dlg)" = "open" ] +} set_theme light +ensure_share_dialog || log "share dialog could not be reopened" log "share dialog before light shot: $(dlg)" sleep 1; save "$(owner_shot)" share-light.png -set_theme dark; sleep 1; log "share dialog before dark shot: $(dlg)"; save "$(owner_shot)" share-dark.png +set_theme dark; sleep 1; ensure_share_dialog; log "share dialog before dark shot: $(dlg)"; save "$(owner_shot)" share-dark.png set_theme light; sleep 1 # Close the dialog so the editorial shots show the doc — invoke the Done button @@ -290,12 +386,18 @@ set_theme light; sleep 1 for _ in $(seq 1 16); do [ "$(dlg)" = "CLOSED" ] && break attn_owner --eval "var b=document.querySelector('[data-slot=share-start]'); if(b&&!b.disabled){b.click();} var e=new KeyboardEvent('keydown',{key:'Escape',code:'Escape',bubbles:true}); document.dispatchEvent(e); window.dispatchEvent(e); 'x'" >/dev/null 2>&1 + # The dialog's own × carries an accessible "Close" — the one path that + # cannot be argued with by focus-trap or synthetic-event quirks. + attn_owner --click 'text=Close' >/dev/null 2>&1 sleep 0.4 done log "share dialog after close: $(dlg)" +[ "$(dlg)" = "CLOSED" ] || { log "FATAL: share dialog still open before editorial shots"; exit 1; } log "reviewer joins" -attn_rv --eval "window.ipc&&window.ipc.postMessage(JSON.stringify({type:'review_join',invite:'$INVITE'}));'x'" >/dev/null 2>&1 +# Daemon-routed CLI join: the webview's review_join IPC is privileged (token +# only the app bundle holds), so a raw postMessage is rejected. +attn_rv review join "$INVITE" >/dev/null 2>&1 || log "reviewer join command failed" rv_has_doc(){ [ -n "$(attn_rv --eval "window.__attnPmView && window.__attnPmView.state.doc.textContent.includes('Launch Plan') ? 'y':''" 2>/dev/null | tr -d '"')" ]; } d=$(( $(date +%s)+30 )); while [ "$(date +%s)" -lt "$d" ]; do rv_has_doc && break; sleep 0.5; done log "reviewer shows shared doc: $(rv_has_doc && echo yes || echo NO)" @@ -304,17 +406,43 @@ log "reviewer shows shared doc: $(rv_has_doc && echo yes || echo NO)" # visible collaborator, then let the recording itself tell the feedback story. selText 'public launch' collapse >/dev/null +# --- Agent participant joins headlessly (kind=agent → violet/hex in the UI). +# It signs with its own home's base identity and gets a suggest-tier +# invite, so its diff-anchored suggestion is allowed and attributed. --- +log "agent joins headlessly" +# Suggest-tier invite minted by room id (path matching predates the durable +# share flow and no longer resolves). +AGENT_INVITE="$(attn_owner review invite "$ROOM_ID" --tier suggest 2>/dev/null | tail -1 | tr -d '\r\n')" +case "$AGENT_INVITE" in + attn://review/*) ;; + *) log "no agent invite (got: ${AGENT_INVITE:-empty})"; exit 1 ;; +esac +: > "$AGENT_CMDS" +ATTN_HOME="$AGENT_HOME" ATTN_RELAY_URL="$RELAY_URL" ATTN_AGENT_CMD_FILE="$AGENT_CMDS" \ + "$ATTN_BIN" review agent >"$AGENT_LOG" 2>&1 & AGENT_PID=$! +d=$(( $(date +%s)+20 )); while [ "$(date +%s)" -lt "$d" ]; do grep -q '@agent ready' "$AGENT_LOG" 2>/dev/null && break; sleep 0.3; done +# Name the agent BEFORE the join announce; the announce reads identity.json. +set_identity_name "$AGENT_HOME/identity.json" "Claude" && log "agent persona set" +python3 - "$AGENT_INVITE" >> "$AGENT_CMDS" <<'PY' +import json, sys +print(json.dumps({"cmd": "join", "invite": sys.argv[1], "kind": "agent"})) +PY +# Joined once updates start flowing into the agent's store. +d=$(( $(date +%s)+30 )); while [ "$(date +%s)" -lt "$d" ]; do grep -q '@update' "$AGENT_LOG" 2>/dev/null && break; sleep 0.5; done +log "agent runtime: $(grep -c '@update' "$AGENT_LOG" 2>/dev/null || echo 0) update line(s)" + # --- Hero MP4/GIF --- record_hero_video -# Did the suggestion register on the reviewer's OWN screen? (submit vs propagation) -log "reviewer self-sees suggestion: $(attn_rv --eval "document.body.textContent.includes('bug bash')?'yes':'no'" 2>/dev/null | tr -d '"')" +# Did the agent's suggestion propagate to the OTHER peer too? (mesh, not just owner) +log "reviewer sees agent suggestion: $(attn_rv --eval "document.querySelectorAll('[data-testid=review-margin-card][data-kind=suggestion]').length>=1?'yes':'no'" 2>/dev/null | tr -d '"')" # Wait for the owner's review-margin CARDS to actually render (rail auto-opens # on first feedback, then cards y-position via coordsAtPos). Wait on the DOM, # don't sleep blindly. cards_n(){ attn_owner --query '.review-margin-slot' 2>/dev/null | python3 -c 'import sys,json;print(json.load(sys.stdin).get("count",0))' 2>/dev/null || echo 0; } -d=$(( $(date +%s)+25 )); while [ "$(date +%s)" -lt "$d" ]; do [ "$(cards_n)" -ge 1 ] && break; sleep 0.5; done +# Two cards now: the reviewer's comment and the agent's suggestion. +d=$(( $(date +%s)+25 )); while [ "$(date +%s)" -lt "$d" ]; do [ "$(cards_n)" -ge 2 ] && break; sleep 0.5; done log "owner review-margin cards: $(cards_n)" log "owner transport: $(attn_owner --eval "(/Live|Connected|Offline/.exec(document.body.textContent)||['?'])[0]" 2>/dev/null | tr -d '"')" log "owner persisted suggestion_created: $(grep -rqa 'suggestion_created' "$OWNER_HOME/reviews" 2>/dev/null && echo YES || echo NO)" @@ -323,6 +451,12 @@ log "capture window ids: owner=$(owner_window_id) reviewer=$(reviewer_window_id) attn_owner --eval "JSON.stringify({sharedBanner: !!document.querySelector('[data-slot=shared-doc-banner]'), cards: document.querySelectorAll('[data-testid=review-margin-card]').length, trayChildren: (document.querySelector('[data-testid=review-margin-tray]')?.children.length||0), hasSuggestionText: document.body.textContent.includes('bug bash'), backdrop: !!document.querySelector('.comment-composer-backdrop, [data-slot=suggestion-composer], [data-slot=share-dialog]')})" 2>/dev/null # --- Editorial shots --- +# The header transport chip is in frame: wait for Live so the capture doesn't +# ship an Offline badge next to a "Review room · live" claim. +d=$(( $(date +%s)+25 )); while [ "$(date +%s)" -lt "$d" ]; do + [ "$(attn_owner --eval "(/Live|Connected|Offline/.exec(document.body.textContent)||['?'])[0]" 2>/dev/null | tr -d '"')" = "Live" ] && break + sleep 1 +done set_theme light; sleep 1; save "$(owner_shot)" collab-light.png set_theme dark; sleep 1; save "$(owner_shot)" collab-dark.png log "done" diff --git a/scripts/test-e2e.sh b/scripts/test-e2e.sh index 5fcb5947..4a078225 100755 --- a/scripts/test-e2e.sh +++ b/scripts/test-e2e.sh @@ -31,7 +31,6 @@ FAIL=0 # --- Helpers --- cleanup() { - # Kill any attn daemon we started if [ -n "${ATTN_PID:-}" ] && kill -0 "$ATTN_PID" 2>/dev/null; then kill "$ATTN_PID" 2>/dev/null || true wait "$ATTN_PID" 2>/dev/null || true @@ -95,7 +94,6 @@ wait_for_ready() { local max_attempts=100 local attempt=0 - # Wait for socket to appear while [ ! -S "$SOCKET" ] && [ $attempt -lt $max_attempts ]; do sleep 0.1 attempt=$((attempt + 1)) @@ -131,7 +129,6 @@ kill_daemon() { ATTN_PID="" fi rm -f "$SOCKET" - # Wait for socket to be cleaned up local attempt=0 while [ -S "$SOCKET" ] && [ $attempt -lt 20 ]; do sleep 0.1 diff --git a/scripts/test-review-e2e.sh b/scripts/test-review-e2e.sh index 9f577541..cd9f0311 100755 --- a/scripts/test-review-e2e.sh +++ b/scripts/test-review-e2e.sh @@ -168,7 +168,6 @@ assert_eq "Scenario JSON version=1" "$scenario_version" "1" scenario_events_kind=$(jq -r '.events | type' "$SCENARIO_JSON" 2>/dev/null || echo "") assert_eq "Scenario JSON .events is array" "$scenario_events_kind" "array" -# Ensure a clean runtime dir. rm -rf "$ATTN_HOME" mkdir -p "$ATTN_HOME" rm -rf "$SCREENSHOT_DIR" diff --git a/site/src/lib/Collaborate.svelte b/site/src/lib/Collaborate.svelte index c968c422..760c5eaa 100644 --- a/site/src/lib/Collaborate.svelte +++ b/site/src/lib/Collaborate.svelte @@ -11,7 +11,7 @@ let collabStill = $derived(media(isDark ? '/screenshots/collab-dark.png' : '/screenshots/collab-light.png')); let shareFlow = $derived(media(isDark ? '/screenshots/share-flow-dark.gif' : '/screenshots/share-flow-light.gif')); - // Live collaboration — the headline capability added in the collab epic. + // Live collaboration — the headline capability. // The hero shows a real capture of a live review session (comment cards + // cursors); this section breaks down what makes it work and shows the real // Share dialog. Copy is deliberately accurate: ALWAYS end-to-end encrypted diff --git a/site/static/screenshots/collab-dark.png b/site/static/screenshots/collab-dark.png index 4a82a277..fddba59c 100644 --- a/site/static/screenshots/collab-dark.png +++ b/site/static/screenshots/collab-dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cb52d4b075498b339b5203469f19e2533957a8e52e1e8fc508f16a906944feac -size 979583 +oid sha256:ccce5b871677939f96dd14e350b20b664c55d3c2de4421d4b5bb22865c0b787b +size 1644373 diff --git a/site/static/screenshots/collab-light.png b/site/static/screenshots/collab-light.png index 61e1c91d..e887cf2a 100644 --- a/site/static/screenshots/collab-light.png +++ b/site/static/screenshots/collab-light.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:12d0388c035449b44db2c30c350c0a4a99becff925ff6332b7bb3aeb285b7529 -size 1050683 +oid sha256:065f4a01eb66584cb873de843d9bcf76fcb79ecc10bd6b89a21ea54ba9db3f53 +size 2333743 diff --git a/site/static/screenshots/share-dark.png b/site/static/screenshots/share-dark.png index c7d42232..a02f8b52 100644 --- a/site/static/screenshots/share-dark.png +++ b/site/static/screenshots/share-dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dfe8193ec542753f2824ea44580accdcb97e77c8b17372ece56b5de5d28bdbec -size 934152 +oid sha256:a0a4b10d1ad73876f8cba0395c3482c5f3c74ca72f1823d7a4cac81a4fe8ffbd +size 697521 diff --git a/site/static/screenshots/share-light.png b/site/static/screenshots/share-light.png index 9c2210fb..55cb53b7 100644 --- a/site/static/screenshots/share-light.png +++ b/site/static/screenshots/share-light.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a92cdc605004f3bc003d589509ece10cbae2f1e178145f7cac5884a6ce69e9ee -size 1182009 +oid sha256:db8ccde6c5e612c36d1e7d9ee03cb15d5a348c8a12710c62d219343922a596fd +size 934216 diff --git a/skills-lock.json b/skills-lock.json index 5efedba9..e229de71 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -1,6 +1,12 @@ { "version": 1, "skills": { + "clean-comments": { + "source": "gpu-cli/skills", + "sourceType": "github", + "skillPath": "skills/clean-comments/SKILL.md", + "computedHash": "9becde391d0dcb6a60d17ca09787ea2eb31f51adacbd10f5a19746601822247b" + }, "frontend-design": { "source": "anthropics/skills", "sourceType": "github", diff --git a/src/daemon.rs b/src/daemon.rs index 4513fe8f..dfe6a14f 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -252,12 +252,10 @@ fn short_exe_namespace(path: &std::path::Path) -> String { format!("{hash:016x}") } -/// Return the socket path. fn socket_path() -> Result { Ok(runtime_dir()?.join("attn.sock")) } -/// Ensure the runtime directory exists. fn ensure_runtime_dir() -> Result<()> { let dir = runtime_dir()?; if !dir.exists() { @@ -351,12 +349,10 @@ pub fn replace_stale_daemon() -> Result { eprintln!("attn: binary changed, replacing daemon (pid {})", info.pid); let pid = nix::unistd::Pid::from_raw(info.pid as i32); let _ = nix::sys::signal::kill(pid, nix::sys::signal::Signal::SIGTERM); - // Wait for socket to disappear let deadline = Instant::now() + Duration::from_secs(3); while sock.exists() && Instant::now() < deadline { std::thread::sleep(Duration::from_millis(50)); } - // Force cleanup if socket is still there if sock.exists() { let _ = nix::sys::signal::kill(pid, nix::sys::signal::Signal::SIGKILL); std::thread::sleep(Duration::from_millis(100)); @@ -622,7 +618,6 @@ fn send_command(msg: &SocketMessage) -> Result> { .shutdown(std::net::Shutdown::Write) .context("failed to shutdown write")?; - // Read response let mut reader = BufReader::new(&stream); let mut line = String::new(); reader @@ -719,7 +714,6 @@ pub fn maybe_fork(no_fork: bool) -> Result<()> { // Safety: we're single-threaded at this point (before event loop starts) match unsafe { fork() }.context("fork failed")? { ForkResult::Child => { - // Become session leader setsid().context("setsid failed")?; // Redirect stderr to the (rotated) log file for debugging. @@ -727,7 +721,6 @@ pub fn maybe_fork(no_fork: bool) -> Result<()> { let fd = log_file.into_raw_fd(); let _ = dup2(fd, std::io::stderr().as_raw_fd()); let _ = close(fd); - // Close stdin let _ = close(std::io::stdin().as_raw_fd()); } @@ -955,7 +948,6 @@ pub fn start_listener( ensure_runtime_dir()?; let sock = socket_path()?; - // Remove stale socket if sock.exists() { std::fs::remove_file(&sock).context("could not remove stale socket")?; } diff --git a/src/ipc.rs b/src/ipc.rs index c985025f..7c128cfd 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -157,6 +157,9 @@ pub enum IpcMessage { #[serde(rename = "review_resolve_comment", rename_all = "camelCase")] ReviewResolveComment { room_id: RoomId, thread_id: String }, + #[serde(rename = "review_reopen_comment", rename_all = "camelCase")] + ReviewReopenComment { room_id: RoomId, thread_id: String }, + #[serde(rename = "review_stop", rename_all = "camelCase")] ReviewStop { #[serde(default)] @@ -571,6 +574,9 @@ pub fn handle_message(body: &str, state: &Arc>, proxy: &EventLoo IpcMessage::ReviewResolveComment { room_id, thread_id } => { submit_review_command(state, ReviewCommand::ResolveComment { room_id, thread_id }); } + IpcMessage::ReviewReopenComment { room_id, thread_id } => { + submit_review_command(state, ReviewCommand::ReopenComment { room_id, thread_id }); + } IpcMessage::ReviewStop { room_id } => { submit_review_command(state, ReviewCommand::Stop { room_id }); } @@ -659,20 +665,16 @@ fn toggle_checkbox(state: &Arc>, line: usize, checked: bool) { } let current_line = lines[idx]; - let new_line; - let replaced: String; - - if checked { + let replaced = if checked { // Want to check: replace `- [ ]` with `- [x]` - replaced = current_line.replacen("- [ ]", "- [x]", 1); - new_line = replaced.as_str(); + current_line.replacen("- [ ]", "- [x]", 1) } else { // Want to uncheck: replace `- [x]` or `- [X]` with `- [ ]` - replaced = current_line + current_line .replacen("- [x]", "- [ ]", 1) - .replacen("- [X]", "- [ ]", 1); - new_line = replaced.as_str(); - } + .replacen("- [X]", "- [ ]", 1) + }; + let new_line = replaced.as_str(); if new_line == current_line { tracing::warn!("line {} does not contain a checkbox", line); diff --git a/src/main.rs b/src/main.rs index 6c4f5c0d..b983f59f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -540,18 +540,12 @@ fn run_daemon(cli: Cli, path: PathBuf, resident_mode: bool) -> Result<()> { let base = ReviewManager::new(Arc::clone(store), working_copy, update_tx) .with_notification_sink(Arc::clone(¬ification_sink)); - // Attach the bootstrap pipeline so Share/Join IPCs go through real - // create-room + register-device against the relay rather than the - // stub. - // - // Resolution: a runtime ATTN_RELAY_URL always wins (dev, tests, - // self-hosting). Otherwise fall back to ATTN_DEFAULT_RELAY_URL baked in - // at build time — release builds set it to the production relay so a - // downloaded app collaborates out of the box without any env var. - // A bare debug build is a staging client; a bare release build is a - // production client. This keeps the native app functional even when - // it was built outside the wrapper scripts, while the runtime env var - // remains the explicit self-hosting/test escape hatch. + // Relay resolution: a runtime ATTN_RELAY_URL always wins, and is the + // escape hatch for dev, tests, and self-hosting. Otherwise fall back + // to ATTN_DEFAULT_RELAY_URL baked in at build time, so a downloaded + // app collaborates without an env var. A bare debug build is a + // staging client; a bare release build is a production client, which + // keeps a build made outside the wrapper scripts functional. let runtime_relay_url = std::env::var("ATTN_RELAY_URL").ok(); let relay_url = resolve_native_relay_url( runtime_relay_url.as_deref(), @@ -906,7 +900,6 @@ fn run_daemon(cli: Cli, path: PathBuf, resident_mode: bool) -> Result<()> { let mut modifiers = ModifiersState::default(); tracing::info!("event loop running"); - // Run event loop event_loop.run(move |event, _, control_flow| { *control_flow = ControlFlow::Wait; @@ -1465,16 +1458,11 @@ fn build_review_dispatch_js( ) -> Result { use crate::review::manager::ReviewUpdate; let callback = update.callback_name(); - // Some variants need to be "unwrapped" before they reach the JS bridge - // so the payload's shape lines up with the typed callback signature in - // `web/src/lib/mock-ipc.ts`. The default wire form (`{kind:..., ...rest}`) - // is fine for status / share / anchor / outbox / error — those callbacks - // accept the union-typed payload directly. The exception is - // `EventImported`, which the frontend's `reviewEvent(payload: ReviewEvent)` - // expects to receive *as* a `ReviewEvent` (i.e. `{meta, body, auth}`), - // not wrapped in a discriminator. Extracting `event` here keeps the - // Rust shape rich (room_id available to manager-side observers) while - // still feeding the bridge what its typed signature wants. + // The default wire form (`{kind:..., ...rest}`) matches the typed callback + // signatures in `web/src/lib/mock-ipc.ts` for every variant except + // `EventImported`: `reviewEvent(payload: ReviewEvent)` wants a bare + // `{meta, body, auth}`, not a discriminated wrapper. Extracting `event` + // here keeps room_id on the Rust side for manager-side observers. let json = match update { ReviewUpdate::EventImported { event, .. } => serde_json::to_string(event)?, _ => serde_json::to_string(update)?, @@ -2142,14 +2130,12 @@ fn build_page_html(init_payload_json: &str, theme: &str, typeset: &str) -> Strin .replace("", &init_script) .replace("data-theme=\"system\"", &format!("data-theme=\"{theme}\"")) .replace("data-theme=\"light\"", &format!("data-theme=\"{theme}\"")) - // `replacen(.., 1)`, not `replace`: since typeset.css gained an - // explicit `[data-typeset='editorial']` rule, this needle is no longer - // unique to the tag in principle. It is in practice only - // because the CSS minifier emits selectors unquoted - // (`[data-typeset=editorial]`) — one minifier-config change away from - // this rewriting the default preset's own selector and stripping its - // tokens. The tag is the first occurrence in the document, so - // bounding the replacement removes the dependency on that accident. + // `replacen(.., 1)`, not `replace`: typeset.css also carries a + // `[data-typeset='editorial']` rule, and only the minifier's unquoted + // output keeps this needle unique to the tag. One minifier-config + // change would let `replace` rewrite that selector and strip its tokens. + // The tag is the document's first occurrence, so bounding the + // replacement to one removes the dependency on that accident. .replacen( "data-typeset=\"editorial\"", &format!("data-typeset=\"{typeset}\""), diff --git a/src/markdown.rs b/src/markdown.rs index 18b9628a..7e7eacb4 100644 --- a/src/markdown.rs +++ b/src/markdown.rs @@ -43,7 +43,6 @@ fn extract_structure(markdown: &str) -> PlanStructure { for (line_num, line) in markdown.lines().enumerate() { let trimmed = line.trim(); - // Detect top-level headers as phases if trimmed.starts_with("## ") { phases.push(Phase { title: trimmed.trim_start_matches('#').trim().to_string(), @@ -51,7 +50,6 @@ fn extract_structure(markdown: &str) -> PlanStructure { }); } - // Detect task list items if let Some(text) = trimmed .strip_prefix("- [x] ") .or_else(|| trimmed.strip_prefix("- [X] ")) diff --git a/src/review/agent.rs b/src/review/agent.rs index 42b97e7c..8f7878cb 100644 --- a/src/review/agent.rs +++ b/src/review/agent.rs @@ -23,8 +23,9 @@ //! Stdin commands: //! ```text //! {"cmd":"share","path":"/work/doc.md","mode":"live"} -//! {"cmd":"join","invite":"attn://review/#key=..."} +//! {"cmd":"join","invite":"attn://review/#key=...","kind":"agent"?} //! {"cmd":"comment","body":"text"} +//! {"cmd":"suggest-diff","diff":"--- a/doc.md\n+++ b/doc.md\n@@ ...","room":""} //! {"cmd":"collab","payload":"{...opaque...}"} //! {"cmd":"pull"} //! {"cmd":"quit"} @@ -72,6 +73,9 @@ pub fn run(share: Option<&str>, mode: &str, relay_url: Option<&str>) -> Result<( let relay_url = resolve_relay_url(relay_url)?; let store = Arc::new(ReviewStore::open().context("open review store for agent")?); + // Kept alongside the manager's handle: suggest-diff anchors hunks against + // the snapshots this store persisted during join. + let store_for_diffs = Arc::clone(&store); let working_copy = Arc::new(WorkingCopyService::new()); // Latest room id learned from updates, so comment/collab can target the @@ -149,8 +153,16 @@ pub fn run(share: Option<&str>, mode: &str, relay_url: Option<&str>) -> Result<( .get("invite") .and_then(|v| v.as_str()) .unwrap_or_default(); + // `"kind":"agent"` announces this participant as an agent + // (violet/hex in every peer's UI) while signing with the same + // base identity every later event uses. + let as_agent = cmd.get("kind").and_then(|v| v.as_str()) == Some("agent"); if invite.is_empty() { emit(&stdout_lock, "error join: missing invite"); + } else if as_agent { + manager.submit(ReviewCommand::JoinAsAgent { + invite: invite.to_string(), + }); } else { manager.submit(ReviewCommand::Join { invite: invite.to_string(), @@ -175,6 +187,44 @@ pub fn run(share: Option<&str>, mode: &str, relay_url: Option<&str>) -> Result<( None => emit(&stdout_lock, "error comment: no active room"), } } + "suggest-diff" => { + let diff = cmd.get("diff").and_then(|v| v.as_str()).unwrap_or_default(); + let room = cmd.get("room").and_then(|v| v.as_str()); + if diff.is_empty() { + emit(&stdout_lock, "error suggest-diff: missing diff"); + } else { + match crate::review::diff_suggestions::suggestions_from_diff( + &store_for_diffs, + diff, + room, + ) { + Ok(report) => { + for item in report.suggestions { + match manager.submit_suggestion_sync(item.room_id, item.draft) { + Ok(id) => emit( + &stdout_lock, + &format!("suggested hunk={} id={id}", item.hunk), + ), + Err(e) => emit( + &stdout_lock, + &format!("error suggest-diff hunk={}: {e:#}", item.hunk), + ), + } + } + for failure in report.failures { + emit( + &stdout_lock, + &format!( + "error suggest-diff hunk={}: {}", + failure.hunk, failure.message + ), + ); + } + } + Err(e) => emit(&stdout_lock, &format!("error suggest-diff: {e:#}")), + } + } + } "collab" => { let payload = cmd .get("payload") diff --git a/src/review/anchors/resolve.rs b/src/review/anchors/resolve.rs index 5d4c544d..3abcde23 100644 --- a/src/review/anchors/resolve.rs +++ b/src/review/anchors/resolve.rs @@ -1106,26 +1106,11 @@ mod tests { #[test] fn structure_quote_match_when_quote_step_does_not_fire() { - // To isolate step 5 we want: quote occurs INSIDE a block with the - // right heading path AND the quote also occurs once in the document - // overall (so step 3 also fires). With dedup-by-range taking the MAX, - // the structure-quote candidate at 0.80 is shadowed by the quote - // candidate at 0.90 in the same range. So to actually OBSERVE step - // 5 as the winner we'd need the quote step to be unique elsewhere — - // which never happens because they're computed from the same bytes. - // Easier: prove step 5 fires by inspecting the result's reason set - // through an indirect path — set up a scenario where the quote - // appears in MULTIPLE locations (so step 3 produces 0.90 candidates - // at different ranges), but only ONE of those locations has the - // matching headingPath. The matching location gets a 0.90 (quote) + - // 0.80 (structure_quote) collapsed to 0.90; the OTHER location stays - // at 0.90. That's still ambiguous, not a clean structure-quote. - // - // For unit purposes: assert that the resolver does produce a - // structure_quote_match-flavored candidate path internally by - // setting up a doc where the quote ONLY appears inside the right - // heading path. This makes step 5 redundant with step 3 but proves - // step 5 doesn't crash and produces a sensible result. + // Step 5 cannot be observed as the winner: it shares its bytes with + // step 3, so dedup-by-range always collapses its 0.80 into step 3's + // 0.90 at the same range. The doc below puts the quote only inside + // the right heading path, which makes step 5 redundant with step 3 + // but still proves it produces a sensible result. let base = b"# H1\n\n## Sub\n\nDistinct phrase here.\n"; let current = b"# H1\n\n## Sub\n\nDistinct phrase here.\n"; let idx = build_anchor_index(current, &snap_id("s5")).expect("idx"); diff --git a/src/review/apply.rs b/src/review/apply.rs index 079c7912..1075e968 100644 --- a/src/review/apply.rs +++ b/src/review/apply.rs @@ -2338,31 +2338,10 @@ mod tests { // ====== END-TO-END APPLY INTEGRATION (attn-nnj.8.6) ==================== // - // These tests exercise the full owner-side accept/reject pipeline as a - // single composed flow: - // - // (1) seed a snapshot + the owner's evolved working copy - // (2) author a SuggestionCreated event against the snapshot - // (3) resolve_suggestion against the *current* (drifted) markdown — the - // anchor must REMAP, not exact-match - // (4) apply_ready_verdict writes the file via WorkingCopyService and - // journals a LocalRevision with source=AcceptedSuggestion - // (5) construct a SuggestionAccepted (or SuggestionRejected) review - // event and assemble it into an outbox MailboxEnvelope - // (6) store.append_outbox + store.iter_outbox round-trips the envelope - // (7) assert: file content matches expected; revision journal has the - // UserEdit + AcceptedSuggestion entries; outbox has the accept - // envelope; resulting_hash carried by the event matches the disk - // hash byte-for-byte. - // - // 8.5 (the ReviewManager wiring that owns the AcceptSuggestion command) - // is still a stub at the time this test lands. The pipeline pieces all - // exist as standalone helpers (apply orchestrator, store, envelope - // assembler, working-copy service) — these tests glue them together the - // same way 8.5 will, so 8.5 will inherit the contract without needing to - // rediscover it. When 8.5 lands, the wiring inside `accept_suggestion_e2e` - // can be replaced by a single `ReviewManager::submit(AcceptSuggestion)` - // call and the assertions stay byte-identical. + // These tests compose the standalone helpers — apply orchestrator, store, + // envelope assembler, working-copy service — into the owner-side + // accept/reject flow, so they pin the contract that + // `ReviewManager::submit(AcceptSuggestion)` must keep. use crate::review::crypto::kdf::derive_room_keys; use crate::review::crypto::signing::DeviceSigningKey; diff --git a/src/review/bootstrap.rs b/src/review/bootstrap.rs index c35bb1ff..a99d2b6d 100644 --- a/src/review/bootstrap.rs +++ b/src/review/bootstrap.rs @@ -2472,6 +2472,30 @@ impl Bootstrapper { .await } + /// Join with the daemon's own base identity while announcing + /// `kind: "agent"`. For a dedicated headless home (`attn review agent`), + /// the base identity IS the agent: every later event signs with the same + /// key ([`Self::send_event_sync`] always loads the base identity), so the + /// announce and the authorship stay one participant. The named-agent + /// registry path ([`Self::join_as_agent`]) keeps its separate keypair for + /// homes that host several agents beside a daemon. + pub async fn join_self_as_agent( + &self, + invite: &str, + verifying_keys: Option>>>, + ) -> Result { + let identity_dir = self.config.identity_dir()?; + let identity = load_or_create_identity_in(&identity_dir)?; + self.join_with_identity( + invite, + &identity, + ParticipantKind::Agent, + DeviceClient::AgentCli, + verifying_keys, + ) + .await + } + /// Join an existing room from an invite as an `kind: "agent"` /// participant. /// diff --git a/src/review/crypto/signing.rs b/src/review/crypto/signing.rs index fb6b828d..85fc60a5 100644 --- a/src/review/crypto/signing.rs +++ b/src/review/crypto/signing.rs @@ -734,12 +734,32 @@ mod tests { resulting_hash: id::("hash-after-apply-5"), }; + // Vector 6: CommentReopened — the resolve inverse (attn-bb6t.4). + // Pinned alongside vector 3 so both halves of the resolve/reopen pair + // have a locked canonical shape for the TS implementation. + let seed6: [u8; 32] = [0x66u8; 32]; + let meta6 = EventMeta { + v: 2, + event_id: id::("placeholder-event-id-6"), + room_id: id::("room-vec-6"), + author_id: id::("p-vec-6"), + device_id: id::("d-vec-6"), + created_at: 1_700_000_005_000, + parent_event_ids: vec![id::("evt-parent-6")], + snapshot_id: None, + }; + let body6 = ReviewEventBody::CommentReopened { + thread_id: "thr-vec-6".to_string(), + reopened_by: id::("p-reopener-6"), + }; + for (label, seed, meta, body) in [ ("vec1", seed1, &meta1, &body1), ("vec2", seed2, &meta2, &body2), ("vec3", seed3, &meta3, &body3), ("vec4", seed4, &meta4, &body4), ("vec5", seed5, &meta5, &body5), + ("vec6", seed6, &meta6, &body6), ] { let sk = DeviceSigningKey::from_bytes(&seed).unwrap(); let vk = sk.verifying_key(); diff --git a/src/review/manager.rs b/src/review/manager.rs index 04d17a17..bbf2c0d0 100644 --- a/src/review/manager.rs +++ b/src/review/manager.rs @@ -98,6 +98,10 @@ pub enum ReviewCommand { }, /// Join a remote review room from an `attn://review/...` invite. Join { invite: String }, + /// Join a remote review room announcing `kind: "agent"`, signing with + /// this home's own base identity (see `Bootstrapper::join_self_as_agent`). + /// Used by the headless `attn review agent` runtime. + JoinAsAgent { invite: String }, /// Pull pending envelopes for a room, or for every active room when `None`. Pull { room_id: Option }, /// Stop hosting/participating in a room (all rooms when `None`). @@ -161,6 +165,10 @@ pub enum ReviewCommand { /// event so the resolution persists and propagates to every peer (a /// resolution is a shared fact, not a local view tweak). ResolveComment { room_id: RoomId, thread_id: String }, + /// Reopen a resolved comment thread. Mints a durable `CommentReopened` + /// event; same reasoning as `ResolveComment` — reopening is a shared + /// fact, so it travels rather than living in one client's view state. + ReopenComment { room_id: RoomId, thread_id: String }, /// Owner edited a shared file — republish a fresh snapshot so connected /// reviewers see the update. No-op when `path` isn't part of any share. PublishSnapshot { path: PathBuf }, @@ -982,6 +990,12 @@ impl ReviewManager { self.emit_join_outcome(result); return; } + (ReviewCommand::JoinAsAgent { invite }, Some(bootstrapper), Some(runtime)) => { + let cache = self.verifying_keys.clone(); + let result = runtime.block_on(bootstrapper.join_self_as_agent(invite, cache)); + self.emit_join_outcome(result); + return; + } ( ReviewCommand::CreateComment { room_id, @@ -1117,6 +1131,14 @@ impl ReviewManager { self.resolve_comment(bootstrapper, room_id, thread_id); return; } + ( + ReviewCommand::ReopenComment { room_id, thread_id }, + Some(bootstrapper), + Some(_runtime), + ) => { + self.reopen_comment(bootstrapper, room_id, thread_id); + return; + } ( ReviewCommand::SendCollab { room_id, payload }, Some(bootstrapper), @@ -1843,8 +1865,8 @@ impl ReviewManager { /// normal outbox path, so the resolution persists locally and propagates /// to peers. The frontend's `reconstructThreads` flips the thread's /// `resolved` flag off the same event, so the card collapses to its - /// resolved strip when the `EventImported` round-trips. Reopening is a - /// future `CommentReopened` event (not yet modeled). + /// resolved strip when the `EventImported` round-trips. The inverse is + /// [`Self::reopen_comment`], which mints `CommentReopened`. fn resolve_comment(&self, bootstrapper: &Arc, room_id: &RoomId, thread_id: &str) { let emit_err = |msg: String| { (self.update_tx)(ReviewUpdate::Error { @@ -1877,6 +1899,57 @@ impl ReviewManager { ); } + /// Reopen a resolved comment thread — the inverse of + /// [`Self::resolve_comment`] (attn-bb6t.4). Mints a durable + /// `CommentReopened` event carrying the reopener's participant id, so the + /// thread comes back for every peer rather than only in the clicking + /// client's view. Projections fold resolve/reopen in log order, so a + /// reopen after a resolve wins and a later resolve closes it again. + fn reopen_comment(&self, bootstrapper: &Arc, room_id: &RoomId, thread_id: &str) { + let emit_err = |msg: String| { + (self.update_tx)(ReviewUpdate::Error { + room_id: Some(room_id.clone()), + code: "ATTN_REOPEN_COMMENT".to_string(), + message: msg, + }); + }; + + // Only a comment thread reopens (attn-1l2f.1). The UI hides Unresolve + // on suggestion cards; this is the durable half of that rule, so a + // stale client or a scripted command can't mint the event either. + match self.store.is_suggestion_thread(room_id, thread_id) { + Ok(true) => { + return emit_err(format!( + "thread {thread_id} is a suggestion: accept and reject are terminal" + )); + } + Ok(false) => {} + Err(e) => return emit_err(format!("read room events: {e}")), + } + + let reopened_by = match bootstrapper + .config() + .identity_dir() + .and_then(|dir| crate::review::bootstrap::load_or_create_identity_in(&dir)) + { + Ok(identity) => identity.typed_participant_id(), + Err(e) => return emit_err(format!("load identity: {e}")), + }; + + let body = crate::review::model::ReviewEventBody::CommentReopened { + thread_id: thread_id.to_string(), + reopened_by, + }; + let send = bootstrapper.send_event_sync(room_id, body, unix_now_ms_for_manager()); + self.emit_event_outcome(room_id.clone(), send); + + tracing::info!( + "reopened comment thread {} (room={})", + thread_id, + room_id.as_str() + ); + } + /// Owner/reviewer manually re-anchors a stale comment or suggestion to a /// range they selected in the editor. We: /// 1. Look up the original event to recover its real `file_id` (the @@ -2172,21 +2245,15 @@ impl ReviewManager { // fallback would spend a request + fan-out on a sample that the next // cursor update immediately supersedes. // - // Document collaboration retains the hybrid routing below. The old - // logic was all-or-nothing — a - // COMPLETE mesh sent over channels only (skip relay), an INCOMPLETE mesh - // sent over the relay only (NO channels). That dropped data under a - // partial mesh: with no TURN, a peer-pair that can't form a direct - // DataChannel leaves the mesh incomplete, and a peer reachable ONLY via - // its DataChannel (relay used as signaling) then got nothing on the - // relay-only path. The robust rule: ALWAYS send over every *connected* - // channel, AND additionally relay whenever the mesh is incomplete so the - // un-meshable peer(s) still receive it. Connected peers may then see it - // twice (channel + relay broadcast); collab is idempotent on the - // receiver (steps dedup by version, cursor/presence is last-writer, and - // the `from` field drops self-echoes), so double-delivery is safe. A - // complete mesh still skips the relay to keep the high-frequency - // step/cursor traffic off it (the cost driver at scale). + // Document collaboration uses the hybrid routing below: ALWAYS send + // over every *connected* channel, AND relay as well whenever the mesh + // is incomplete. Choosing one or the other drops data under a partial + // mesh — without TURN, a peer reachable only via its DataChannel gets + // nothing on a relay-only path. Connected peers may then see the + // sample twice; the receiver is idempotent (steps dedup by version, + // cursor/presence is last-writer, `from` drops self-echoes). A + // complete mesh skips the relay to keep high-frequency step/cursor + // traffic off it, which is the cost driver at scale. let (channels, peer_count): ( Vec>, usize, @@ -2452,14 +2519,13 @@ impl ReviewManager { let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false); // One cancel signal drives BOTH the outbox loop and the WS subscriber: // the outbox owns `cancel_rx`, the WS subscriber gets a `subscribe()` - // clone below. Retain the sender in the per-room `cancels` registry so - // it lives for the room's life — the same lifetime guarantee the old - // `Box::leak` provided, so the no-race behavior is preserved. (Without - // a live sender, `cancel.changed()` resolves Err, which the WS - // `select!` misreads as a cancel — aborting connect_async before it - // completes.) Holding it in the map ADDITIONALLY lets `Stop` flip it - // to wind the outbox + WS tasks down cooperatively. The matching - // outbox handle is retained too so `Pull` can force a one-shot drain. + // clone below. The sender must live for the room's life, so it goes in + // the per-room `cancels` registry — drop it and `cancel.changed()` + // resolves Err, which the WS `select!` misreads as a cancel and aborts + // connect_async before it completes. Keeping it in the map also lets + // `Stop` flip it to wind the outbox + WS tasks down cooperatively. The + // matching outbox handle is retained so `Pull` can force a one-shot + // drain. let ws_cancel_rx = cancel_tx.subscribe(); if let Ok(mut cancels) = self.cancels.lock() { cancels.insert(room_id.clone(), cancel_tx); @@ -3061,16 +3127,14 @@ impl ReviewManager { // only the local file tree even though the WS subscription // is already streaming inbound envelopes. // - // The lifecycle string is role-accurate: a room WE shared (a + // The lifecycle string must be role-accurate: a room WE shared (a // local share binding exists) resumes as the owner's "Live"; a - // room we joined resumes as "Joined". The frontend activates the - // room on both and derives the role from the string — the old - // neutral "Resumed" was passive (switcher-only) and left the - // role 'unknown', so a restarted reviewer could never flip back - // into the shared-doc view even with the snapshot replayed - // (attn-6dd). Owners don't flip either way (isReviewerView - // requires role 'reviewer'), so a resumed share never hijacks - // the owner's local file view (attn-0wa). + // room we joined resumes as "Joined". The frontend derives the + // role from that string, so a neutral value leaves the role + // 'unknown' and a restarted reviewer never flips back into the + // shared-doc view (attn-6dd). Owners never flip — isReviewerView + // requires role 'reviewer' — so a resumed share cannot hijack the + // owner's local file view (attn-0wa). let is_owner = crate::review::bootstrap::find_path_for_room(self.store.root(), &room_id) .ok() @@ -3712,6 +3776,7 @@ fn review_command_name(cmd: &ReviewCommand) -> &'static str { ReviewCommand::RevokeDurableShare { .. } => "RevokeDurableShare", ReviewCommand::OpenDurableShare { .. } => "OpenDurableShare", ReviewCommand::Join { .. } => "Join", + ReviewCommand::JoinAsAgent { .. } => "JoinAsAgent", ReviewCommand::Pull { .. } => "Pull", ReviewCommand::Stop { .. } => "Stop", ReviewCommand::Inbox => "Inbox", @@ -3722,6 +3787,7 @@ fn review_command_name(cmd: &ReviewCommand) -> &'static str { ReviewCommand::ResolveAnchor { .. } => "ResolveAnchor", ReviewCommand::ReportHtmlAnchorResolution { .. } => "ReportHtmlAnchorResolution", ReviewCommand::ResolveComment { .. } => "ResolveComment", + ReviewCommand::ReopenComment { .. } => "ReopenComment", ReviewCommand::SendCollab { .. } => "SendCollab", ReviewCommand::PublishSnapshot { .. } => "PublishSnapshot", ReviewCommand::ReannounceIdentity => "ReannounceIdentity", @@ -3777,6 +3843,10 @@ fn stub_update_for(cmd: &ReviewCommand) -> ReviewUpdate { room_id: stub_room_id(), status: "Pending join — invite accepted for processing".to_string(), }, + ReviewCommand::JoinAsAgent { invite: _ } => ReviewUpdate::RoomStatusChanged { + room_id: stub_room_id(), + status: "Pending join — invite accepted for processing".to_string(), + }, // Pull / Stop / Inbox are handled for real in `submit` (they drive the // per-room runtime registries) and always return before reaching here. // These arms keep the match exhaustive; they are not reached in @@ -3902,6 +3972,10 @@ fn stub_update_for(cmd: &ReviewCommand) -> ReviewUpdate { room_id: room_id.clone(), status: "Pending resolve-comment — no bootstrap attached".to_string(), }, + ReviewCommand::ReopenComment { room_id, .. } => ReviewUpdate::RoomStatusChanged { + room_id: room_id.clone(), + status: "Pending reopen-comment — no bootstrap attached".to_string(), + }, // PublishSnapshot goes through the real bootstrap path in `submit` // when one is attached. Without a bootstrapper (smoke tests) it's a // no-op — surface a benign status so the dispatch contract stays @@ -4569,6 +4643,7 @@ fn review_event_body_name(body: &crate::review::model::ReviewEventBody) -> &'sta ReviewEventBody::SnapshotSuperseded { .. } => "snapshot_superseded", ReviewEventBody::CommentCreated { .. } => "comment_created", ReviewEventBody::CommentResolved { .. } => "comment_resolved", + ReviewEventBody::CommentReopened { .. } => "comment_reopened", ReviewEventBody::SuggestionCreated { .. } => "suggestion_created", ReviewEventBody::SuggestionAccepted { .. } => "suggestion_accepted", ReviewEventBody::SuggestionRejected { .. } => "suggestion_rejected", diff --git a/src/review/model.rs b/src/review/model.rs index 9bc115d3..8b09886e 100644 --- a/src/review/model.rs +++ b/src/review/model.rs @@ -1386,6 +1386,22 @@ pub enum ReviewEventBody { thread_id: String, resolved_by: ParticipantId, }, + /// Reopen a resolved thread (attn-bb6t.4). Deliberately its own variant + /// rather than a `resolved: bool` on `CommentResolved`: the log is + /// append-only and every existing receiver already reads + /// `CommentResolved` as "this thread is closed", so flipping a field + /// would have changed the meaning of events already on disk. Projections + /// must therefore fold resolve/reopen in log order — last writer wins, + /// not "any resolve anywhere". + /// + /// Receivers older than this variant reject the event (the enum is + /// externally tagged and unknown tags fail to deserialize), so a reopen + /// in a mixed-version room is invisible to them and the thread stays + /// resolved on their side. Same compatibility family as attn-mz25. + CommentReopened { + thread_id: String, + reopened_by: ParticipantId, + }, SuggestionCreated { suggestion_id: String, anchor: Anchor, diff --git a/src/review/store.rs b/src/review/store.rs index f020a902..31a29ac3 100644 --- a/src/review/store.rs +++ b/src/review/store.rs @@ -606,6 +606,27 @@ impl ReviewStore { /// Fold persisted suggestion events for one room. Creation authorship is /// recorded separately from owner-authored verdict events, then joined so /// identity scoping always uses `SuggestionCreated.meta.author_id`. + /// Whether `thread_id` names a suggestion rather than a comment thread. + /// + /// Reopen is the inverse of resolve, and only comments resolve — an + /// accepted or rejected suggestion is decided for good. Projections fold + /// lifecycle events last-writer-wins, so a `CommentReopened` minted for a + /// suggestion id would hand a already-applied edit its Accept/Reject + /// actions back on every peer. Undecodable lines are skipped: a corrupt + /// event elsewhere in the log must not block a legitimate reopen. + pub fn is_suggestion_thread(&self, room_id: &RoomId, thread_id: &str) -> Result { + for event in self.iter_events(room_id)?.flatten() { + if let crate::review::model::ReviewEventBody::SuggestionCreated { + suggestion_id, .. + } = &event.body + && suggestion_id == thread_id + { + return Ok(true); + } + } + Ok(false) + } + pub fn verdicts_for_room( &self, room_id: &RoomId, @@ -1459,6 +1480,46 @@ mod tests { ); } + // attn-1l2f.1 — reopen is comment-only, and the manager asks the store + // which kind a thread id names before minting a `CommentReopened`. + #[test] + fn is_suggestion_thread_distinguishes_suggestions_from_comment_threads() { + let (_tmp, store) = fresh_store(); + let room_id: RoomId = id("room-thread-kind"); + let created = suggestion_event( + "evt-kind-1", + "room-thread-kind", + "p-1", + suggestion_created("suggestion-1"), + ); + store.append_event(&room_id, &created).expect("append"); + + assert!( + store + .is_suggestion_thread(&room_id, "suggestion-1") + .expect("scan"), + "a suggestion id must be recognized" + ); + assert!( + !store + .is_suggestion_thread(&room_id, "thread-1") + .expect("scan"), + "a comment thread id must not be" + ); + } + + #[test] + fn is_suggestion_thread_on_a_room_with_no_events_is_false() { + let (_tmp, store) = fresh_store(); + let room_id: RoomId = id("room-thread-kind-empty"); + assert!( + !store + .is_suggestion_thread(&room_id, "anything") + .expect("missing events.jsonl reads as an empty log"), + "a room with no log must not block a legitimate reopen" + ); + } + #[test] fn unread_append_watermark_survives_b_then_a_accounting_order() { let (tmp, store) = fresh_store(); diff --git a/src/review/transport/inbound.rs b/src/review/transport/inbound.rs index 7b8c0bad..e6fcba8c 100644 --- a/src/review/transport/inbound.rs +++ b/src/review/transport/inbound.rs @@ -727,6 +727,16 @@ fn authorize_event( { Ok(()) } + // Reopening carries exactly the resolve authority (attn-bb6t.4): a + // non-agent participant, acting as themselves. Anything narrower — + // "only the resolver may reopen" — would strand a thread whose + // resolver has left the room. + ReviewEventBody::CommentReopened { reopened_by, .. } + if registered.kind != ParticipantKind::Agent + && reopened_by == &event.meta.author_id => + { + Ok(()) + } ReviewEventBody::PresenceUpdated { participant_id, device_id, @@ -1188,6 +1198,47 @@ mod tests { assert_eq!(store.iter_events(&room_id).expect("events").count(), 0); } + #[tokio::test] + async fn reviewer_can_import_self_attributed_comment_reopened() { + let (pipeline, store, signer, room_id, _tmp) = fresh_pipeline_with_signer(); + let envelope = mint_event_envelope_with_body( + pipeline.event_key, + signer, + &room_id, + ReviewEventBody::CommentReopened { + thread_id: "thread-1".to_string(), + reopened_by: id::("p-author-01"), + }, + ); + pipeline + .import_event_envelope(&room_id, &envelope) + .await + .expect("self-attributed reopen must be accepted"); + assert_eq!(store.iter_events(&room_id).expect("events").count(), 1); + } + + #[tokio::test] + async fn comment_reopened_on_someone_elses_behalf_is_refused() { + let (pipeline, store, signer, room_id, _tmp) = fresh_pipeline_with_signer(); + let envelope = mint_event_envelope_with_body( + pipeline.event_key, + signer, + &room_id, + ReviewEventBody::CommentReopened { + thread_id: "thread-1".to_string(), + // Not the envelope's author: reopening in another + // participant's name is exactly what the guard exists for. + reopened_by: id::("p-someone-else"), + }, + ); + let error = pipeline + .import_event_envelope(&room_id, &envelope) + .await + .expect_err("reopen attributed to another participant must be refused"); + assert!(matches!(error, InboundError::UnauthorizedEvent)); + assert_eq!(store.iter_events(&room_id).expect("events").count(), 0); + } + #[tokio::test] async fn reviewer_cannot_import_owner_only_snapshot_event() { let (pipeline, store, signer, room_id, _tmp) = fresh_pipeline_with_signer(); @@ -1729,11 +1780,6 @@ mod tests { // device id (or accepts the broadcast / target=None form) BEFORE // AEAD-open, and surfaces a relay-redirect attempt as // `InboundError::TargetDeviceMismatch`. - // - // Tests cover the three branches of the target check: - // 12. target=Some(self) → accept (targeted-to-us). - // 13. target=Some(other) → reject with TargetDeviceMismatch (relay redirect). - // 14. target=None → accept (true broadcast). // ----------------------------------------------------------------- #[tokio::test] diff --git a/src/screenshot.rs b/src/screenshot.rs index 30e38e5a..18f23d35 100644 --- a/src/screenshot.rs +++ b/src/screenshot.rs @@ -50,17 +50,14 @@ pub fn take_snapshot(wk_webview: &wry::WryWebView, output_path: &str, tx: Sender /// Convert an NSImage to PNG and write to disk. #[cfg(target_os = "macos")] fn save_nsimage_as_png(image: &objc2_app_kit::NSImage, path: &str) -> Result<(), String> { - // Get TIFF representation from NSImage let tiff_data: objc2::rc::Retained = image .TIFFRepresentation() .ok_or("failed to get TIFF representation")?; - // Create NSBitmapImageRep from TIFF data let bitmap_rep: objc2::rc::Retained = NSBitmapImageRep::imageRepWithData(&tiff_data) .ok_or("failed to create bitmap image rep")?; - // Convert to PNG let empty_dict: objc2::rc::Retained< NSDictionary, > = NSDictionary::new(); @@ -69,7 +66,6 @@ fn save_nsimage_as_png(image: &objc2_app_kit::NSImage, path: &str) -> Result<(), } .ok_or("failed to convert to PNG")?; - // Write to file let bytes = png_data.to_vec(); std::fs::write(path, &bytes).map_err(|e| format!("failed to write {path}: {e}"))?; diff --git a/tests/webrtc_e2e.rs b/tests/webrtc_e2e.rs index ccd8e83d..c653f35a 100644 --- a/tests/webrtc_e2e.rs +++ b/tests/webrtc_e2e.rs @@ -648,9 +648,9 @@ async fn webrtc_happy_path_delivers_comment_envelope_to_owner_store() { // The owner's `on_message` handler runs `InboundPipeline::import_event_envelope` // (persists to `events.jsonl`) and then surfaces the decoded event upstream // as `TransportEvent::EventImported` — the SAME variant the relay WS path - // emits and the daemon's UI bridge consumes. Emitting `Envelope` here used - // to be a silent UI no-op (forward_transport_event drops it), so review - // events delivered over the P2P DataChannel never reached the frontend. + // emits and the daemon's UI bridge consumes. Emitting `Envelope` here is a + // silent UI no-op (forward_transport_event drops it), which strands every + // review event delivered over the P2P DataChannel. let received = timeout(Duration::from_secs(5), harness.owner_events_rx.recv()) .await .expect("owner events_rx must surface event within 5s") diff --git a/web/e2e/hosted-a11y.spec.ts b/web/e2e/hosted-a11y.spec.ts index 1029bb32..4fabeb0a 100644 --- a/web/e2e/hosted-a11y.spec.ts +++ b/web/e2e/hosted-a11y.spec.ts @@ -52,12 +52,12 @@ test('axe: share sheet open', async ({ page }) => { await expectNoAxeViolations(page, 'share sheet'); }); -test('axe: saved review docked beside the owner document', async ({ page }) => { +test('axe: comments docked beside the owner document', async ({ page }) => { await page.setViewportSize({ width: 1440, height: 900 }); await page.goto('/app/w/ws-product/direction.md?shell=demo'); - await page.getByRole('button', { name: 'Saved review' }).click(); + await page.locator('[data-slot="comments-toggle"]').click(); await expect(page.locator('.review-history-placeholder')).toBeVisible(); - await expectNoAxeViolations(page, 'saved review dock'); + await expectNoAxeViolations(page, 'comments dock'); }); test('axe: mobile editor with files sheet', async ({ page }) => { @@ -74,13 +74,13 @@ test('keyboard-only: landing reaches both CTAs', async ({ browserName, page }) = // enables full keyboard navigation. Exercise the platform's real shortcut. const tabKey = browserName === 'webkit' ? 'Alt+Tab' : 'Tab'; // Tab from the top of the document into the nav and hero. - const newWorkspace = page.locator('.hero a[data-action="new-workspace"]'); + const openDocument = page.locator('.hero a[data-action="open-document"]'); const openDesk = page.locator('.hero a[data-action="open-desk"]'); for (let presses = 0; presses < 25; presses += 1) { await page.keyboard.press(tabKey); - if (await newWorkspace.evaluate((el) => el === document.activeElement)) break; + if (await openDocument.evaluate((el) => el === document.activeElement)) break; } - await expect(newWorkspace).toBeFocused(); + await expect(openDocument).toBeFocused(); await page.keyboard.press(tabKey); await expect(openDesk).toBeFocused(); }); @@ -165,29 +165,72 @@ test('keyboard-only: desk rows and storage clear confirm are operable', async ({ test('authoring controls move focus into transient inputs and restore it on cancel', async ({ page }) => { await page.goto('/app#new'); + // The per-file flows below drive the tree row's context menu, and a bare + // workspace mounts no rail to hold it (attn-mkmz.5) — give it a second file, + // then reopen untitled.md so the preconditions are otherwise unchanged. + // untitled.md needs content first, or the import supersedes it + // (attn-rjuo.3.1) and there is no row left to right-click. + await page.locator('[data-body-text] .ProseMirror').click(); + await page.keyboard.type('Seed.'); + const chooser = page.waitForEvent('filechooser'); + await page.keyboard.press('ControlOrMeta+KeyK'); + await page.getByRole('option', { name: /Add files to this workspace/u }).click(); + await (await chooser).setFiles({ + name: 'second.md', + mimeType: 'text/markdown', + buffer: Buffer.from('# Second\n'), + }); + await expect(page.getByRole('textbox', { name: 'Filter files' })).toBeVisible(); + await page.getByRole('button', { name: 'untitled.md', exact: true }).click(); + await expect(page).toHaveURL(/\/untitled\.md$/u); + const documentEditor = page.getByRole('textbox', { name: 'Document editor' }); await expect(documentEditor).toHaveAttribute('aria-multiline', 'true'); await expect(documentEditor).toHaveAttribute('aria-readonly', 'false'); const projectPicker = page.getByRole('combobox', { name: 'Project picker' }); + const triggerBox = await projectPicker.boundingBox(); await projectPicker.click(); await page.getByRole('menuitem', { name: 'Rename workspace' }).click(); const workspaceInput = page.getByRole('textbox', { name: 'Workspace title' }); await expect(workspaceInput).toBeFocused(); + /* IN PLACE (attn-rjuo.2.1). The rename used to render in the header's ACTIONS + cluster, at the far right, editing a name that sat at the far left — an + unstyled field floating beside Share, which read as a rendering fault. It + now takes the name's own slot, so assert the geometry and not merely that + an input exists somewhere. */ + const inputBox = await workspaceInput.boundingBox(); + expect(triggerBox).not.toBeNull(); + expect(inputBox).not.toBeNull(); + expect(Math.abs(inputBox!.x - triggerBox!.x)).toBeLessThanOrEqual(8); + expect(Math.abs(inputBox!.y - triggerBox!.y)).toBeLessThanOrEqual(8); await page.keyboard.press('Escape'); await expect(projectPicker).toBeFocused(); const fileRow = page.getByRole('button', { name: 'untitled.md', exact: true }); + const fileRowBox = await fileRow.boundingBox(); await fileRow.click({ button: 'right' }); const fileRename = page.getByRole('menuitem', { name: 'Rename…', exact: true }); await fileRename.click(); const pathInput = page.getByRole('textbox', { name: 'New path' }); await expect(pathInput).toBeFocused(); + /* ON THE ROW (user ruling, 2026-08-20), for the reason the workspace rename + above is pinned the same way. This field used to render in the rail's + FOOTER — bottom of a column whose top held the row it renamed, hundreds of + pixels apart with nothing joining them. Assert the geometry, not merely + that an input exists somewhere in the rail. */ + const pathInputBox = await pathInput.boundingBox(); + expect(fileRowBox).not.toBeNull(); + expect(pathInputBox).not.toBeNull(); + expect(pathInputBox!.y).toBeGreaterThanOrEqual(fileRowBox!.y - 2); + expect(pathInputBox!.y + pathInputBox!.height).toBeLessThanOrEqual( + fileRowBox!.y + fileRowBox!.height + 2, + ); await page.keyboard.press('Escape'); await expect(fileRow).toBeFocused(); // New Markdown moved to the command palette; Escape from the transient - // path input returns focus to the sidebar's project picker anchor. + // path input returns focus to the project picker, which lives in the header. await page.keyboard.press('ControlOrMeta+KeyK'); await page.getByRole('option', { name: /New Markdown file/u }).click(); await expect(page.getByRole('textbox', { name: 'New Markdown file path' })).toBeFocused(); diff --git a/web/e2e/hosted-authoring.spec.ts b/web/e2e/hosted-authoring.spec.ts index f22a334d..f26842bb 100644 --- a/web/e2e/hosted-authoring.spec.ts +++ b/web/e2e/hosted-authoring.spec.ts @@ -19,12 +19,58 @@ function activeSidebarEntry(page: Page) { return page.locator('[data-path][data-active="true"]'); } +/** + * Give a bare workspace a second file, then reopen `untitled.md`. + * + * A workspace holding one blank untitled.md mounts NO file rail (attn-mkmz.5): + * the canvas invitation is the whole page and the workspace switcher lives in + * the header. Every flow below that drives the rail — the tree row, its context + * menu, the filter field — therefore has to give the rail something to list + * first. The reopen restores the original precondition (blank untitled.md + * active) with the rail present. + */ +async function giveWorkspaceASecondFile(page: Page): Promise { + // untitled.md has to be given CONTENT before the import, or the import + // supersedes it (attn-rjuo.3.1) and the workspace ends up holding only the + // imported file — which is the whole point of that fix. A typed placeholder + // is the caller's document and survives. + const editor = documentEditor(page); + await editor.click(); + await page.keyboard.type('Seed.'); + const chooser = page.waitForEvent('filechooser'); + await runPaletteCommand(page, /Add files to this workspace/u); + await (await chooser).setFiles({ + name: 'second.md', + mimeType: 'text/markdown', + buffer: Buffer.from('# Second\n'), + }); + await expect(page.getByRole('textbox', { name: 'Filter files' })).toBeVisible(); + await page.getByRole('button', { name: 'untitled.md', exact: true }).click(); + await expect(page).toHaveURL(/\/untitled\.md$/u); +} + +/** + * Assert which workspace is open. + * + * Addressed through the picker's own list rather than the header label. The + * header does name the workspace (mark | workspace | file), but the checked row + * is the assertion that survives the trigger changing register again, and it is + * where a list of projects should name the current project anyway. + */ +async function expectOpenWorkspace(page: Page, name: string): Promise { + await page.getByRole('combobox', { name: 'Project picker' }).click(); + await expect(page.locator('.sidebar-project-menu-item[data-current="true"]')).toContainText(name); + await page.keyboard.press('Escape'); + await expect(page.locator('.sidebar-project-menu')).toHaveCount(0); +} + function documentEditor(page: Page) { return page.locator('[data-body-text] .ProseMirror'); } -// Workspace-level actions live in the ⌘K palette (the sidebar footer is a -// pure drop zone; per-file actions are in the tree context menu). +// Workspace-level actions live in the ⌘K palette; per-file actions are in the +// tree context menu. The sidebar footer carries one standing control, "Add +// files" (attn-mkmz.3) — everything else there is transient. async function runPaletteCommand(page: Page, label: RegExp): Promise { await page.keyboard.press('ControlOrMeta+KeyK'); await page.getByRole('option', { name: label }).click(); @@ -37,14 +83,21 @@ test('one-click create is real: persists across reload with zero relay traffic', // The editor opens in place and the URL is rewritten to the workspace. await expect(page.locator('[data-app-view="workspace"]')).toBeVisible(); await expect(page).toHaveURL(/\/app\/w\/[A-Za-z0-9_-]+\/untitled\.md$/u); - await expect(page.getByRole('combobox', { name: 'Project picker' })).toContainText('Untitled'); + await expectOpenWorkspace(page, 'Untitled'); expect(offOrigin).toEqual([]); // A full reload restores the workspace from IndexedDB. await page.reload(); await expect(page.locator('[data-app-view="workspace"]')).toBeVisible(); - await expect(page.getByRole('combobox', { name: 'Project picker' })).toContainText('Untitled'); - await expect(activeSidebarEntry(page)).toContainText('untitled.md'); + await expectOpenWorkspace(page, 'Untitled'); + // No rail for a bare workspace; the header carries the file name. + // + // It DOES carry it here, unlike the import route (hosted-shells.spec.ts): + // "New workspace" is the blank origin, so no invitation is raised and the + // untitled.md is a document being worked on rather than a placeholder + // standing in for a choice nobody has made. The header goes quiet on exactly + // the condition that raises the invitation, which is not this one. + await expect(page.locator('[data-slot="owner-file-name"]')).toContainText('untitled.md'); await expect(page.locator('[data-degraded="lease-denied"]')).toHaveCount(0); await expect(documentEditor(page)).toHaveAttribute('contenteditable', 'true'); @@ -108,6 +161,8 @@ test('desktop editor fills the canvas and has no edit mode toggle', async ({ pag // Removing the canvas rectangle must not weaken visible focus on controls: // the sidebar filter draws its box via :focus-within when it holds focus. + // The filter only exists once the rail does. + await giveWorkspaceASecondFile(page); const filterField = page.locator('.sidebar-filter'); const blurredBorder = await filterField.evaluate((el) => getComputedStyle(el).borderColor); await page.getByRole('textbox', { name: 'Filter files' }).focus(); @@ -123,7 +178,7 @@ test('workspace picker is bounded and provides switch, create, rename, and desk await page.getByRole('menuitem', { name: 'Rename workspace' }).click(); await page.getByRole('textbox', { name: 'Workspace title' }).fill('First workspace'); await page.getByRole('textbox', { name: 'Workspace title' }).press('Enter'); - await expect(page.getByRole('combobox', { name: 'Project picker' })).toContainText('First workspace'); + await expectOpenWorkspace(page, 'First workspace'); const picker = page.getByRole('combobox', { name: 'Project picker' }); await picker.click(); @@ -149,7 +204,7 @@ test('workspace picker is bounded and provides switch, create, rename, and desk await expect(title).toBeFocused(); await title.fill('Second workspace'); await title.press('Enter'); - await expect(page.getByRole('combobox', { name: 'Project picker' })).toContainText('Second workspace'); + await expectOpenWorkspace(page, 'Second workspace'); await page.getByRole('combobox', { name: 'Project picker' }).click(); await page.getByPlaceholder('Search projects...').fill('First workspace'); @@ -167,6 +222,8 @@ test('desktop Markdown formatting is keyboard-correct and supports input rules', // formatting toolbar — keyboard shortcuts and Markdown input rules are // the formatting surface (mobile keeps its thumb-reachable edit bar). await page.goto('/app#new'); + // The sidebar-state assertions below need a rail to assert about. + await giveWorkspaceASecondFile(page); const editor = documentEditor(page); await editor.click(); @@ -201,8 +258,12 @@ test('the workspace drop target opens dropped Markdown files', async ({ page }) await page.goto('/app#new'); await expect(documentEditor(page)).toHaveAttribute('contenteditable', 'true'); - const workspaceDrop = page.locator('.hosted-sidebar-dropzone'); - await expect(workspaceDrop).toContainText('Drop files anywhere'); + // For a BARE workspace the resting drop affordance is the canvas invitation, + // not the rail's "Add files" button — the rail is not mounted at all + // (attn-mkmz.5). The drop still bubbles to the workspace-level `use:fileDrop` + // container, which is what this gate is really about. + const workspaceDrop = page.locator('[data-slot="canvas-invite"]'); + await expect(workspaceDrop).toContainText('Drop a Markdown file or a folder here'); await workspaceDrop.evaluate((target) => { const transfer = new DataTransfer(); transfer.items.add(new File(['## Added note'], 'added.md', { type: 'text/markdown' })); @@ -211,6 +272,9 @@ test('the workspace drop target opens dropped Markdown files', async ({ page }) await expect(page.getByRole('button', { name: 'added.md', exact: true })).toBeVisible(); await expect(page).toHaveURL(/\/added\.md$/u); await expect(documentEditor(page)).toContainText('Added note'); + // The drop route carries the placeholder rule too (attn-rjuo.3.2): every + // import reaches one function, so none of them can leave an untitled.md. + await expect(page.getByRole('button', { name: 'untitled.md', exact: true })).toHaveCount(0); }); test('the mobile Files add flow opens its imported document', async ({ page }) => { @@ -262,11 +326,56 @@ test('import creates a real multi-file workspace preserving paths', async ({ pag ]); // Import navigates into the imported workspace's editor. await expect(page).toHaveURL(/\/app\/w\/[A-Za-z0-9_-]+\//u); - await expect(page.getByRole('combobox', { name: 'Project picker' })).toContainText('direction'); + // The header path is workspace › file; the leaf names the open document. + await expect(page.locator('[data-slot="owner-file-name"]')).toContainText('direction'); await expect(page.locator('[data-body-text]')).toContainText('Imported direction'); await expect(page.getByRole('button', { name: 'desk.png' })).toBeVisible(); }); +test('importing into a bare workspace leaves no untitled.md behind', async ({ page }) => { + // THE FAILURE THIS PINS (attn-rjuo.3.1): both desk routes mint a workspace by + // creating untitled.md, and the import path only ever ADDED — so choosing + // import first left the imported document sitting beside an empty placeholder + // nobody asked for. + await page.goto('/app'); + await page.locator('[data-action="import-files"]').click(); + await expect(page.locator('[data-slot="canvas-invite"]')).toBeVisible(); + const chooser = page.waitForEvent('filechooser'); + await page.locator('[data-slot="canvas-invite"] .button.primary').click(); + await (await chooser).setFiles({ + name: 'brief.md', + mimeType: 'text/markdown', + buffer: Buffer.from('# Real brief\n\nFrom disk.\n'), + }); + await expect(page).toHaveURL(/\/brief\.md$/u); + await expect(page.getByRole('button', { name: 'brief.md', exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: 'untitled.md', exact: true })).toHaveCount(0); + await expect(page.locator('[data-path]')).toHaveCount(1); + // A workspace still on its auto-name takes the import's name, the same way + // the desk's own import route does. + await expectOpenWorkspace(page, 'brief'); +}); + +test('a placeholder that has been typed into survives a later import', async ({ page }) => { + // The guard is emptiness, not intent: a blank page someone typed into is + // theirs whatever they clicked to reach it. + await page.goto('/app#new'); + const editor = documentEditor(page); + await editor.click(); + await page.keyboard.type('Words I typed before importing.'); + const chooser = page.waitForEvent('filechooser'); + await runPaletteCommand(page, /Add files to this workspace/u); + await (await chooser).setFiles({ + name: 'extra.md', + mimeType: 'text/markdown', + buffer: Buffer.from('# Extra\n'), + }); + await expect(page.getByRole('button', { name: 'extra.md', exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: 'untitled.md', exact: true })).toBeVisible(); + await page.getByRole('button', { name: 'untitled.md', exact: true }).click(); + await expect(documentEditor(page)).toContainText('Words I typed before importing.'); +}); + test('imported HTML opens as a document rather than a download-only asset', async ({ page }) => { await page.goto('/app'); const chooser = page.waitForEvent('filechooser'); @@ -323,7 +432,16 @@ test('desk rename and delete are real and confirmed in-app', async ({ page }) => await expect(page.locator('.workspace-row')).toHaveCount(0); await page.reload(); await expect(page.locator('.workspace-row')).toHaveCount(0); - await expect(page.locator('.empty-desk')).toBeVisible(); + // An emptied desk leads with import, not with a blank untitled.md + // (attn-mkmz.5). The well this replaced said in five elements what the + // workspace canvas says a click later; the desk states that it is empty and + // names the two ways out. + const offer = page.locator('.desk-empty-offer'); + await expect(offer).toBeVisible(); + await expect(offer.locator('[data-action="import-files"]')).toHaveText('Import files'); + await expect(offer.locator('[data-action="start-blank"]')).toHaveText( + 'Start a blank untitled.md', + ); }); test('editing autosaves durable revisions and recovers after reload', async ({ page }) => { @@ -355,6 +473,7 @@ test('editing autosaves durable revisions and recovers after reload', async ({ p test('active Markdown rename stays mounted and autosave follows the new path', async ({ page }) => { await page.goto('/app#new'); + await giveWorkspaceASecondFile(page); const editor = documentEditor(page); const navigationCount = await page.evaluate(() => performance.getEntriesByType('navigation').length); @@ -470,9 +589,7 @@ test('workspace rename stays mounted and keeps the same tab writable', async ({ await input.fill('Lease handoff'); await input.press('Enter'); - await expect(page.getByRole('combobox', { name: 'Project picker' })).toContainText( - 'Lease handoff', - ); + await expectOpenWorkspace(page, 'Lease handoff'); await expect(page.locator('[data-degraded="lease-denied"]')).toHaveCount(0); await expect(documentEditor(page)).toHaveAttribute('contenteditable', 'true'); expect(await page.evaluate(() => performance.getEntriesByType('navigation').length)).toBe( @@ -654,9 +771,12 @@ test('phase gate: create → type → reload → edit → export → reimport wi allRequests.push(request.url()); }); - // From the landing, one click into a real editor. - await page.goto('/'); - await page.locator('.hero a[data-action="new-workspace"]').click(); + // Into a real editor. This used to click the landing's primary CTA, which no + // longer creates anything (the front door asks for an existing document — + // user ruling, 2026-08-19). The subject of this test is the offline journey + // through a live workspace, not which control starts one, so it takes the + // create intent directly. + await page.goto('/app#new'); await expect(page.locator('[data-app-view="workspace"]')).toBeVisible(); // Type through the real editor; wait for the durable commit. @@ -737,7 +857,7 @@ test('storage page: export marks backup, reimport dedupes names, clear-all erase { name: 'backup.zip', mimeType: 'application/zip', buffer: fs.readFileSync(zipPath!) }, ]); await expect(page).toHaveURL(/\/app\/w\//u); - await expect(page.getByRole('combobox', { name: 'Project picker' })).toContainText('Untitled 2'); + await expectOpenWorkspace(page, 'Untitled 2'); // Clear all local data: in-app confirm, durable erasure. await page.goto('/app/storage'); diff --git a/web/e2e/hosted-offline.spec.ts b/web/e2e/hosted-offline.spec.ts index 72e0a675..44fb2252 100644 --- a/web/e2e/hosted-offline.spec.ts +++ b/web/e2e/hosted-offline.spec.ts @@ -24,7 +24,10 @@ test('offline launch serves the cached shell and local content survives', async await context.setOffline(true); await page.reload(); await expect(page.locator('[data-app-view="workspace"]')).toBeVisible({ timeout: 30_000 }); - await expect(page.getByRole('combobox', { name: 'Project picker' })).toContainText('Untitled'); + // The workspace name is the witness, not a file name: this workspace has no + // chosen document yet, so the header names only the workspace. The point here + // is unchanged — it came back from IndexedDB with the network down. + await expect(page.locator('.owner-project-name').first()).toContainText('Untitled'); await context.setOffline(false); }); diff --git a/web/e2e/hosted-routes.spec.ts b/web/e2e/hosted-routes.spec.ts index a27b3a83..b5049999 100644 --- a/web/e2e/hosted-routes.spec.ts +++ b/web/e2e/hosted-routes.spec.ts @@ -45,6 +45,25 @@ function captureAssetRequests(page: Page): string[] { const LANDING_CAPTURE_IMAGES = '.product-stage img, .share-proof .capture img, .native-shot img'; +/** + * Drive the landing's appearance control to a given theme. + * + * The control became a three-state CYCLE in attn-08fa (Paper → Ink → System), + * so it is no longer "Switch to dark theme" and no longer flips in one press: + * from the default `system` the first press lands on `light`. Asserting the + * cycle's exact order here would just restate theme.svelte.ts, so press until + * the painted theme is the one asked for, bounded so a broken control fails + * rather than loops. + */ +async function setLandingTheme(page: Page, theme: 'light' | 'dark'): Promise { + const control = page.getByRole('button', { name: /^Appearance: /u }); + for (let press = 0; press < 3; press += 1) { + if ((await page.locator('html').getAttribute('data-theme')) === theme) return; + await control.click(); + } + await expect(page.locator('html')).toHaveAttribute('data-theme', theme); +} + async function waitForLandingCaptureImages(page: Page, theme: 'light' | 'dark'): Promise { const images = page.locator(LANDING_CAPTURE_IMAGES); await expect(images).toHaveCount(3); @@ -124,8 +143,13 @@ test('landing serves at / without editor, crypto, or other-entry chunks', async test('landing leads with browser CTAs and keeps native install below', async ({ page }) => { await page.goto('/'); const hero = page.locator('.hero'); - await expect(hero.locator('a[data-action="new-workspace"]')).toHaveAttribute('href', '/app#new'); + // No default path mints an untitled file (user ruling, 2026-08-19): the + // front door asks for the document you already have. `#new` still exists + // behind controls that say "New workspace" — the desk tile and the sidebar + // project menu — it is simply not what the landing's primary CTA does. + await expect(hero.locator('a[data-action="open-document"]')).toHaveAttribute('href', '/open'); await expect(hero.locator('a[data-action="open-desk"]')).toHaveAttribute('href', '/app'); + await expect(hero.locator('a[href="/app#new"]')).toHaveCount(0); await expect(page.locator('.native-section .code').first()).toContainText( 'brew install lightsofapollo/attn/attn', ); @@ -147,7 +171,7 @@ test('landing theme toggle flips palette, swaps captures, and persists', async ( ), ).toBe(true); expect(await heroShot.evaluate((image) => (image as HTMLImageElement).currentSrc)).toMatch(/\.avif$/u); - await page.getByRole('button', { name: /^Switch to (dark|light) theme$/u }).click(); + await setLandingTheme(page, 'dark'); await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); await expect(heroShot).toHaveAttribute('src', /collab-dark/u); await waitForLandingCaptureImages(page, 'dark'); @@ -172,8 +196,7 @@ test('capture landing screenshots for design review', async ({ page }) => { await expect(page.locator('body')).toHaveAttribute('data-hydrated', 'true'); await waitForLandingCaptureImages(page, 'light'); await page.screenshot({ path: 'test-results/landing-desktop-light.png', fullPage: true }); - await page.getByRole('button', { name: /^Switch to (dark|light) theme$/u }).click(); - await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); + await setLandingTheme(page, 'dark'); await waitForLandingCaptureImages(page, 'dark'); await page.screenshot({ path: 'test-results/landing-desktop-dark.png', fullPage: true }); await page.setViewportSize({ width: 390, height: 844 }); @@ -181,8 +204,7 @@ test('capture landing screenshots for design review', async ({ page }) => { // lives inside the disclosure. This test asserted it was clickable at 390 // without opening the menu and had been failing on main for that reason. await page.getByRole('button', { name: 'Open menu' }).click(); - await page.getByRole('button', { name: /^Switch to (dark|light) theme$/u }).click(); - await expect(page.locator('html')).toHaveAttribute('data-theme', 'light'); + await setLandingTheme(page, 'light'); await waitForLandingCaptureImages(page, 'light'); await page.screenshot({ path: 'test-results/landing-iphone-light.png', fullPage: true }); }); diff --git a/web/e2e/hosted-shells.spec.ts b/web/e2e/hosted-shells.spec.ts index b49ea5b6..5c15ec32 100644 --- a/web/e2e/hosted-shells.spec.ts +++ b/web/e2e/hosted-shells.spec.ts @@ -15,8 +15,10 @@ async function expectNoHorizontalScroll(page: Page): Promise { test('desk home lists recent workspaces with storage health', async ({ page }) => { await page.goto('/app?shell=demo'); await expect(page.locator('h1')).toHaveText('Your desk'); + // The storage badge was removed from the header (user ruling, 2026-08-20); + // the shell still reports which state it is in on the header itself. await expect(page.locator('[data-storage-mode]')).toHaveAttribute('data-storage-mode', 'persistent'); - await expect(page.locator('.local-badge').first()).toContainText('On this device'); + await expect(page.locator('.app-header .local-badge')).toHaveCount(0); await expect(page.locator('.quick')).toHaveCount(3); await expect(page.locator('.workspace-row')).toHaveCount(3); await expect(page.locator('.workspace-row').first()).toContainText('Product direction'); @@ -57,17 +59,267 @@ test('mobile Desk makes workspace facts and administration scannable', async ({ await expectNoHorizontalScroll(page); }); +/* The Join panel's open state is a question for the URL, asked at every mount + (attn-ze60.3). It used to be answered once at boot and handed down, and the + desk mounts more than once per page load — it unmounts when a workspace opens + and mounts again on the way back. So the snapshot was wrong in both + directions: it reopened the panel over an address bar that had said plain + /app for two screens, and it left the panel shut on a Back to a history entry + that genuinely asked for #join. Both directions are pinned here, because a + fix for either one alone looks correct from the other side. */ +test('the Join panel follows the URL across desk↔workspace navigation', async ({ page }) => { + const panel = page.locator('[data-slot="join-panel"]'); + + // A workspace to travel into. (Creating one REPLACES the desk history entry, + // so the Back that matters is the one out of an already-existing workspace.) + await page.goto('/app'); + await page.locator('[data-action="start-blank"]').click(); + await expect(page.locator('[data-app-view="workspace"]')).toBeVisible(); + + // Arrive with the intent, then dismiss it: closing takes the hash with it. + await page.goto('/app#join'); + await expect(panel).toBeVisible(); + await page.getByRole('button', { name: 'Cancel' }).click(); + await expect(panel).toHaveCount(0); + expect(new URL(page.url()).hash).toBe(''); + + // Into the workspace and back out. The desk remounts against a URL with no + // fragment, and must come back the way it was left. + await page.locator('.row-open').first().click(); + await expect(page.locator('[data-app-view="workspace"]')).toBeVisible(); + await page.goBack(); + await expect(page.locator('[data-app-view="home"]')).toBeVisible(); + expect(new URL(page.url()).hash).toBe(''); + await expect(panel).toHaveCount(0); + + // The other direction: opening the panel from the desk pushes #join, so that + // history entry really is asking for an open panel and Back must honour it. + await page.getByRole('link', { name: /Join a review/iu }).first().click(); + await expect(panel).toBeVisible(); + expect(new URL(page.url()).hash).toBe('#join'); + await page.locator('.row-open').first().click(); + await expect(page.locator('[data-app-view="workspace"]')).toBeVisible(); + await page.goBack(); + await expect(page.locator('[data-app-view="home"]')).toBeVisible(); + expect(new URL(page.url()).hash).toBe('#join'); + await expect(panel).toBeVisible(); +}); + test('landing one-click intent opens an untitled draft editor', async ({ page }) => { await page.goto('/app#new'); await expect(page.locator('[data-app-view="workspace"]')).toBeVisible(); await expect(page.locator('.hosted-native-document .ProseMirror')).toBeVisible(); - await expect(page.locator('[data-path][data-active="true"]')).toContainText('untitled.md'); + // A bare workspace mounts no file rail (attn-mkmz.5): a filter over a single + // row, and that row is the document already open beside it. The header does + // not name a file either (user ruling, 2026-08-20) — while the canvas is + // asking which document to open, the placeholder `untitled.md` is not a file + // anyone has chosen, and printing it contradicted the invitation beneath. + await expect(page.locator('[data-slot="owner-file-name"]')).toHaveCount(0); + await expect(page.locator('[data-path][data-active="true"]')).toHaveCount(0); + await expect(page.locator('[data-slot="canvas-invite"]')).toBeVisible(); + // The workspace switcher survives the rail's absence — it lives in the header. + await expect(page.getByRole('combobox', { name: 'Project picker' })).toBeVisible(); await expect(page.locator('[data-save-state]')).toHaveAttribute( 'data-save-state', 'Changes autosaved', ); }); +test('the bare canvas invitation withdraws when the canvas is answered', async ({ page }) => { + await page.goto('/app#new'); + const invite = page.locator('[data-slot="canvas-invite"]'); + await expect(invite).toBeVisible(); + // Pointer-transparent except its buttons: a click anywhere else has to reach + // the ProseMirror and place a caret, exactly as on any other empty document — + // and that click alone withdraws the offer (attn-rjuo.1.3). Waiting for a + // keystroke left the caret and the centred invitation on screen together. + await page.locator('.hosted-native-document .ProseMirror').click({ position: { x: 300, y: 20 } }); + await expect(invite).toHaveCount(0); + await page.keyboard.type('Typed straight through the invitation.'); + await expect(page.locator('.hosted-native-document .ProseMirror')).toContainText( + 'Typed straight through the invitation.', + ); + // Answering the canvas also returns the rail — the rail is hidden exactly + // while the invitation is up. Because the trigger is POINTER-DOWN, that + // happens before the first character, never mid-word. + await expect(page.locator('[data-path][data-active="true"]')).toContainText('untitled.md'); + // And it must not come back a second later, when the autosave commit hands + // down a fresh workspace: that refresh used to reset the answered latch and + // resurrect the invitation over live typing (attn-rjuo). + await page.waitForTimeout(3000); + await expect(invite).toHaveCount(0); +}); + +test('a blank untitled.md opens the ordinary editor, rail and all', async ({ page }) => { + // Case 2 of the desk's two routes (user ruling, 2026-08-20). "Start a blank + // untitled.md" is a document you are already working on, not an empty surface + // waiting to be told what it is: no invitation, and the file rail, the file + // row and the Add files footer are all there from the first frame. + await page.goto('/app'); + await page.locator('[data-action="start-blank"]').click(); + await expect(page.locator('[data-app-view="workspace"]')).toBeVisible(); + await expect(page.locator('[data-slot="canvas-invite"]')).toHaveCount(0); + await expect(page.getByRole('textbox', { name: 'Filter files' })).toBeVisible(); + await expect(page.locator('[data-path][data-active="true"]')).toContainText('untitled.md'); + await expect(page.locator('.hosted-sidebar-add')).toContainText('Add files'); + + // The invitation must not arrive late — the autosave commit that follows the + // first keystroke refreshes the workspace, and that refresh used to clear the + // create-intent this route depends on. + await page.locator('.hosted-native-document .ProseMirror').click(); + await page.keyboard.type('rotwjboritj'); + await page.waitForTimeout(3000); + await expect(page.locator('[data-slot="canvas-invite"]')).toHaveCount(0); + await expect(page.getByRole('textbox', { name: 'Filter files' })).toBeVisible(); +}); + +test('the invitation does not wait for the editor chunk', async ({ page }) => { + // THE FAILURE THIS PINS (attn-rjuo.1.1): the invitation was nested inside the + // branch that renders once the lazily-imported editor resolves, so a bare + // workspace's first paint was a lone caret on an empty canvas for the length + // of a dynamic import — reported as "the untitled experience is broken". + // + // Asserted STRUCTURALLY, not by racing the network. "The editor has not + // mounted yet" is not a claim a test can hold on both builds: the dev server + // fetches the chunk on demand while the worker modulepreloads it, so a + // timing proxy passes on one and lies on the other. Whether the invitation is + // a DESCENDANT of the editor surface is the regression itself, and it is the + // same answer in every build. + await page.goto('/app#new'); + const invite = page.locator('[data-slot="canvas-invite"]'); + await expect(invite).toBeVisible(); + await expect(page.locator('.hosted-editor-surface [data-slot="canvas-invite"]')).toHaveCount(0); + // Nor may the wait's own message double up with it on one empty canvas. + await expect(page.locator('.hosted-editor-loading')).toHaveCount(0); +}); + +test('the invitation still paints while the editor chunk is stalled', async ({ page }) => { + // The other half of attn-rjuo.1.1, as close to the reported symptom as a test + // can get: hold the editor chunk and the canvas must still say something. + // Matches the chunk in BOTH shapes — `…/Editor.svelte` on the dev server, + // `assets/Editor-.js` from the build. + await page.route('**/*', async (route) => { + if (/\bEditor(\.svelte|-[A-Za-z0-9_-]+\.js)/u.test(route.request().url())) { + await new Promise((resolve) => setTimeout(resolve, 3000)); + } + await route.continue(); + }); + await page.goto('/app#new', { waitUntil: 'domcontentloaded' }); + await expect(page.locator('[data-slot="canvas-invite"]')).toBeVisible({ timeout: 10_000 }); +}); + +test.describe('while the editor chunk is not here', () => { + /* The service worker caches chunks — which is what makes the app work + offline, and what would hide these tests' control of the network from + them: a cached copy is served without the request ever reaching it. + Blocking it puts the fetch back where both cases below actually live. */ + test.use({ serviceWorkers: 'block' }); + + test('the wait fills its screen and centres its sentence', async ({ page }) => { + /* attn-ze60.5: the wait ran a spinner in front of the sentence, and the + surfaces centre their single child — so what was centred was the GROUP, and + the words sat ~15px right of the middle by a distance that changed with the + sentence. Crossing from one stage of a wait to the next therefore slid the + line sideways. Asserted as geometry because that is what the defect was: + the markup looked centred at every step. */ + await page.setViewportSize({ width: 1280, height: 800 }); + await page.goto('/app'); + await page.locator('[data-action="start-blank"]').click(); + await expect(page.locator('[data-app-view="workspace"]')).toBeVisible(); + const workspaceUrl = page.url(); + + // Hold the desktop frame's chunk so its wait stays on screen to be measured. + let release: () => void = () => undefined; + const held = new Promise((resolve) => (release = resolve)); + await page.route('**/*', async (route) => { + if (/HostedDesktopWorkspaceFrame/u.test(route.request().url())) await held; + return route.continue(); + }); + await page.goto(workspaceUrl, { waitUntil: 'domcontentloaded' }); + + const wait = page.locator('.hosted-shell-loading'); + await expect(wait).toBeVisible(); + + const geometry = await page.evaluate(() => { + const surface = document.querySelector('.hosted-shell-loading')!.getBoundingClientRect(); + const line = document.querySelector('.loading-line')!.getBoundingClientRect(); + return { + surface: { width: surface.width, height: surface.height }, + lineCentre: line.x + line.width / 2, + viewport: { width: window.innerWidth, height: window.innerHeight }, + }; + }); + // Full available space, and the sentence in the middle of it. + expect(geometry.surface.width).toBe(geometry.viewport.width); + expect(geometry.surface.height).toBe(geometry.viewport.height); + expect(Math.abs(geometry.lineCentre - geometry.viewport.width / 2)).toBeLessThanOrEqual(1); + // The mechanism, after the outcome: nothing shares the centred line. + await expect(page.locator('.loading-spinner')).toHaveCount(0); + + release(); + }); + + test('an editor chunk that never arrives reloads once, then says so', async ({ page }) => { + /* attn-ze60.1: the loader had no rejection handler, so a chunk that failed to + arrive — an offline tab, or hashed chunks 404ing because the deployment the + document came from has been replaced — left the workspace under "Opening + ..." indefinitely, with an unhandled rejection as the only record. + + Retrying the import cannot fix it (the module map answers with the same + rejection without re-fetching), so the recovery is one automatic reload, + and the ceiling of ONE is the load-bearing part: a page that reloads on + every failure and fails on every load is a loop nobody can click their way + out of. Matches the chunk in BOTH shapes, like the stall case above. */ + const isEditorChunk = (url: string) => + /\bEditorShell(\.svelte|-[A-Za-z0-9_-]+\.js)/u.test(url); + + // A workspace to open, created while the chunk still loads normally. + await page.goto('/app'); + await page.locator('[data-action="start-blank"]').click(); + await expect(page.locator('[data-app-view="workspace"]')).toBeVisible(); + const workspaceUrl = page.url(); + + let attempts = 0; + await page.route('**/*', async (route) => { + if (!isEditorChunk(route.request().url())) return route.continue(); + attempts += 1; + return route.abort(); + }); + + await page.goto(workspaceUrl); + + await expect(page.getByText('The editor didn’t finish loading')).toBeVisible(); + await expect(page.locator('[data-app-view="editor-loading"]')).toHaveCount(0); + // The two ways out are real: a reload, and a route back to the desk. + await expect(page.getByRole('button', { name: 'Reload attn' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Go to your desk' })).toBeVisible(); + // The first load and exactly one retry — never a reload loop. + expect(attempts).toBe(2); + }); +}); + +test('an explicitly blank workspace stays blank across a reload', async ({ page }) => { + // attn-rjuo.1.2: the create-intent used to live only in component state, so a + // refresh re-covered a page someone had asked to be blank with an offer to + // import something. + await page.goto('/app'); + await page.locator('[data-action="start-blank"]').click(); + await expect(page.locator('[data-app-view="workspace"]')).toBeVisible(); + await expect(page.locator('[data-slot="canvas-invite"]')).toHaveCount(0); + await page.reload(); + await expect(page.locator('[data-app-view="workspace"]')).toBeVisible(); + await expect(page.locator('[data-slot="canvas-invite"]')).toHaveCount(0); + await expect(page.locator('.ProseMirror p.is-editor-empty')).toBeVisible(); +}); + +test('the import route keeps its invitation across a reload', async ({ page }) => { + await page.goto('/app'); + await page.locator('[data-action="import-files"]').click(); + await expect(page.locator('[data-slot="canvas-invite"]')).toBeVisible(); + await page.reload(); + await expect(page.locator('[data-slot="canvas-invite"]')).toBeVisible(); +}); + test('desktop editor reuses the native sidebar, editor, and review rail frame', async ({ page }) => { await page.setViewportSize({ width: 1440, height: 900 }); await page.goto('/app/w/ws-product/direction.md?shell=demo'); @@ -78,22 +330,26 @@ test('desktop editor reuses the native sidebar, editor, and review rail frame', await expect(page.locator('.hosted-native-document .ProseMirror')).toBeVisible(); await expect(page.locator('[data-action="edit"]')).toHaveCount(0); await expect(page.getByRole('button', { name: 'Done', exact: true })).toHaveCount(0); - const savedReview = page.getByRole('button', { name: 'Saved review' }); - await expect(savedReview).toBeVisible(); - await expect(savedReview).toHaveAttribute('aria-expanded', 'false'); + // Addressed by slot, not by name: this control RENAMES itself between states + // ("Comments" -> "Hide comments"), so a name-based locator silently stops + // matching the moment it is opened — which is exactly what it did. + const commentsToggle = page.locator('[data-slot="comments-toggle"]'); + await expect(commentsToggle).toBeVisible(); + await expect(commentsToggle).toHaveAccessibleName('Comments'); + await expect(commentsToggle).toHaveAttribute('aria-expanded', 'false'); await expect(page.locator('[data-slot="right-rail"]')).toHaveCount(0); await expect(page.locator('.review-history-placeholder')).toHaveCount(0); - await savedReview.click(); - await expect(savedReview).toHaveAttribute('aria-expanded', 'true'); + await commentsToggle.click(); + await expect(commentsToggle).toHaveAttribute('aria-expanded', 'true'); + await expect(commentsToggle).toHaveAccessibleName('Hide comments'); await expect(page.locator('[data-slot="right-rail"]')).toHaveAttribute('data-mode', 'expanded'); - await expect(page.locator('.review-history-placeholder')).toContainText('Saved review'); + await expect(page.locator('.review-history-placeholder')).toContainText('Comments'); await expect(page.locator('.review-history-placeholder')).toContainText('JULES'); await expect(page.locator('.review-history-placeholder')).toContainText( - 'Live review adds presence and replies; saved feedback stays here.', + 'Live review adds presence and replies; these comments stay here.', ); - // Saved review has its own docked column. It reflows the document only while + // Comments get their own docked column. It reflows the document only while // explicitly open; closing removes the rail rather than retaining a gutter. - await expect(page.getByRole('button', { name: 'Hide saved review' })).toBeVisible(); const readingLayout = await page.evaluate(() => { const documentSurface = document.querySelector('.hosted-native-document'); const rail = document.querySelector('[data-slot="right-rail"]'); @@ -111,9 +367,9 @@ test('desktop editor reuses the native sidebar, editor, and review rail frame', expect(readingLayout.documentWidth).toBeGreaterThanOrEqual(600); expect(readingLayout.railWidth).toBeGreaterThanOrEqual(300); await page.screenshot({ path: 'test-results/hosted-saved-review-docked.png' }); - await page.getByRole('button', { name: 'Hide saved review' }).click(); + await commentsToggle.click(); await expect(page.locator('[data-slot="right-rail"]')).toHaveCount(0); - await page.getByRole('button', { name: 'Saved review' }).click(); + await commentsToggle.click(); await expect(page.locator('[data-slot="right-rail"]')).toHaveAttribute('data-mode', 'expanded'); // 1024px is still the desktop workspace (the phone layout begins below the // app's 900px breakpoint). The dock must shrink the reading measure rather @@ -189,7 +445,8 @@ test('open page presents the import handoff', async ({ page }) => { test('private browsing scenario degrades honestly', async ({ page }) => { await page.goto('/app?shell=private'); await expect(page.locator('[data-storage-mode]')).toHaveAttribute('data-storage-mode', 'session-only'); - await expect(page.locator('.local-badge').first()).toContainText('This session only'); + // The banner is the whole surface now — it says the state, the consequence, + // and offers the remedy, which the removed badge never did. await expect(page.locator('[data-degraded="session-only"]')).toContainText( 'This private session may erase your desk when it closes.', ); @@ -197,7 +454,7 @@ test('private browsing scenario degrades honestly', async ({ page }) => { test('blocked-storage scenario keeps the desk viewable', async ({ page }) => { await page.goto('/app?shell=blocked'); - await expect(page.locator('.local-badge').first()).toContainText('View-only'); + await expect(page.locator('[data-storage-mode]')).toHaveAttribute('data-storage-mode', 'unavailable'); await expect(page.locator('[data-degraded="unavailable"]')).toContainText( 'This browser currently blocks local document storage.', ); diff --git a/web/e2e/hosted-workspace-switch.spec.ts b/web/e2e/hosted-workspace-switch.spec.ts new file mode 100644 index 00000000..1ef33179 --- /dev/null +++ b/web/e2e/hosted-workspace-switch.spec.ts @@ -0,0 +1,126 @@ +// In-place workspace switching (attn-e9r2). +// +// Desktop switches workspaces without unmounting the editor: same EditorShell, +// new `workspace` prop. Three separate defects lived in that seam — an import +// still reading bytes finished against whichever workspace was on screen by +// then, a pending lease acquisition installed workspace A's session as +// workspace B's, and the departed workspace's runtime (lease, heartbeat, +// local-collab hub, review transport) was never handed back. The races +// themselves are pinned deterministically in the unit suites +// (import-into-workspace.test.ts, owner-session-gate.test.ts, +// workspace-service.test.ts); this spec is the end-to-end floor: after real +// switching in the real app, each workspace still holds its own text, and a +// workspace this tab has left is writable from another tab. + +import { expect, test, type Page } from '@playwright/test'; + +function editor(page: Page) { + return page.locator('[data-body-text] .ProseMirror'); +} + +async function switchTo(page: Page, name: string) { + await page.getByRole('combobox', { name: 'Project picker' }).click(); + await page.getByPlaceholder('Search projects...').fill(name); + await page.locator('.sidebar-project-menu-item').filter({ hasText: name }).click(); +} + +/** Wait for the durable commit, exactly as the authoring suite does — a page + * load taken inside the autosave debounce loses the text either way. */ +async function settled(page: Page) { + await expect(page.locator('.save-state[data-save-state]')).toHaveAttribute( + 'data-save-state', + 'Changes autosaved', + { timeout: 15_000 }, + ); +} + +async function rename(page: Page, name: string) { + await page.getByRole('combobox', { name: 'Project picker' }).click(); + await page.getByRole('menuitem', { name: 'Rename workspace' }).click(); + const title = page.getByRole('textbox', { name: 'Workspace title' }); + await title.fill(name); + await title.press('Enter'); +} + +test('in-place switching keeps each workspace’s text in its own workspace', async ({ page }) => { + const errors: string[] = []; + page.on('console', (message) => { + if (message.type() === 'error') errors.push(message.text()); + }); + page.on('pageerror', (error) => errors.push(String(error))); + + await page.goto('/app#new'); + await expect(editor(page)).toHaveAttribute('contenteditable', 'true'); + await rename(page, 'Alpha'); + const alphaUrl = page.url(); + await editor(page).click(); + await page.keyboard.type('alpha body'); + await settled(page); + + // New workspace, in place: same EditorShell, new workspace prop. + await page.getByRole('combobox', { name: 'Project picker' }).click(); + await page.getByRole('menuitem', { name: 'New workspace' }).click(); + await expect(editor(page)).toHaveAttribute('contenteditable', 'true'); + await rename(page, 'Beta'); + const betaUrl = page.url(); + expect(betaUrl).not.toBe(alphaUrl); + await editor(page).click(); + await page.keyboard.type('beta body'); + await settled(page); + + // Back and forth twice: every switch tears one runtime down and builds another. + await switchTo(page, 'Alpha'); + await expect(page).toHaveURL(alphaUrl); + await expect(editor(page)).toContainText('alpha body'); + await expect(editor(page)).not.toContainText('beta body'); + await expect(editor(page)).toHaveAttribute('contenteditable', 'true'); + + await switchTo(page, 'Beta'); + await expect(page).toHaveURL(betaUrl); + await expect(editor(page)).toContainText('beta body'); + await expect(editor(page)).not.toContainText('alpha body'); + await editor(page).click(); + await page.keyboard.type(' more'); + await settled(page); + + await switchTo(page, 'Alpha'); + await expect(editor(page)).toContainText('alpha body'); + await expect(editor(page)).toHaveAttribute('contenteditable', 'true'); + + // Durability: reload each and confirm autosave committed to the right one. + await page.goto(betaUrl); + await expect(editor(page)).toContainText('beta body more'); + await expect(editor(page)).not.toContainText('alpha body'); + await page.goto(alphaUrl); + await expect(editor(page)).toContainText('alpha body'); + await expect(editor(page)).not.toContainText('beta body'); + + expect(errors, `console errors: ${errors.join(' | ')}`).toEqual([]); +}); + +test('a second tab takes the pen of a workspace the first tab has left', async ({ context, page }) => { + await page.goto('/app#new'); + await expect(editor(page)).toHaveAttribute('contenteditable', 'true'); + await rename(page, 'Gamma'); + const gammaUrl = page.url(); + await editor(page).click(); + await page.keyboard.type('gamma body'); + await settled(page); + + await page.getByRole('combobox', { name: 'Project picker' }).click(); + await page.getByRole('menuitem', { name: 'New workspace' }).click(); + await expect(editor(page)).toHaveAttribute('contenteditable', 'true'); + await rename(page, 'Delta'); + + // The first tab has LEFT Gamma. A second tab must be able to write it. + const second = await context.newPage(); + await second.goto(gammaUrl); + await expect(editor(second)).toHaveAttribute('contenteditable', 'true'); + await editor(second).click(); + await second.keyboard.press('ControlOrMeta+End'); + await second.keyboard.type(' from the second tab'); + await settled(second); + await second.reload(); + await expect(editor(second)).toContainText('gamma body from the second tab'); + await second.close(); +}); diff --git a/web/e2e/html-annotation-runtime.spec.ts b/web/e2e/html-annotation-runtime.spec.ts index 5db29600..54665c68 100644 --- a/web/e2e/html-annotation-runtime.spec.ts +++ b/web/e2e/html-annotation-runtime.spec.ts @@ -145,6 +145,13 @@ async function selectText(page: import('@playwright/test').Page, needle: string) }, needle); } +interface DocRectShape { + x: number; + y: number; + width: number; + height: number; +} + test.describe('HTML annotation runtime', () => { test('completes the handshake across an opaque-origin frame', async ({ page }) => { await boot(page); @@ -703,6 +710,112 @@ test.describe('HTML annotation runtime', () => { await expect(frame.locator('.attn-chip')).toBeHidden(); }); + /** + * Card ↔ segment hover linking (attn-bb6t.3). A text-range highlight is a + * CSS Custom Highlight, not a DOM node, so it receives no events of its own + * — the runtime has to hit-test the range's rects against the pointer. This + * is the only place that geometry is exercised for real. + */ + test('reports hover over a committed text-range anchor, and its exit', async ({ page }) => { + await boot(page); + const frame = page.frameLocator('#doc'); + await page.evaluate(() => { + (window as unknown as { __attn_send: (m: unknown) => void }).__attn_send({ + type: 'renderAnchors', + v: 1, + anchors: [ + { + anchorId: 'ranged', + html: { + v: 1, + target: 'text_range', + cssSelector: 'p.intro', + context: { tagName: 'p', scopePreview: 'The quick brown fox' }, + }, + state: 'default', + quote: 'quick brown fox', + }, + ], + }); + }); + await page.waitForFunction(() => + (window as unknown as { __attn_last: (t: string) => unknown }).__attn_last('anchorsResolved'), + ); + + // Aim at the RANGE, not the paragraph: the highlight covers only the + // quoted phrase, and the frame reports its rects in frame coordinates, + // so they need the iframe's own offset to become page coordinates. + const rect = await page.evaluate( + () => + ( + window as unknown as { + __attn_last: (t: string) => { results: { rects: DocRectShape[] }[] }; + } + ).__attn_last('anchorsResolved').results[0]!.rects[0]!, + ); + const frameBox = (await page.locator('#doc').boundingBox())!; + await page.mouse.move( + frameBox.x + rect.x + rect.width / 2, + frameBox.y + rect.y + rect.height / 2, + { steps: 8 }, + ); + const entered = await page.waitForFunction( + () => + (window as unknown as { __attn_last: (t: string) => { anchorId: string | null } | null }) + .__attn_last('anchorHover')?.anchorId === 'ranged', + ); + expect(await entered.jsonValue()).toBeTruthy(); + + // Leaving it must report null, or the shell keeps a card lit forever. + await page.mouse.move( + frameBox.x + rect.x + rect.width + 200, + frameBox.y + rect.y + rect.height / 2, + { steps: 8 }, + ); + const left = await page.waitForFunction( + () => + (window as unknown as { __attn_last: (t: string) => { anchorId: string | null } | null }) + .__attn_last('anchorHover')?.anchorId === null, + ); + expect(await left.jsonValue()).toBeTruthy(); + }); + + test('paints a hovered anchor distinctly from an active one', async ({ page }) => { + await boot(page); + const frame = page.frameLocator('#doc'); + await page.evaluate(() => { + (window as unknown as { __attn_send: (m: unknown) => void }).__attn_send({ + type: 'renderAnchors', + v: 1, + anchors: [ + { + anchorId: 'pinned', + html: { + v: 1, + target: 'element', + cssSelector: '#title', + context: { tagName: 'h1', scopePreview: 'Quarterly report' }, + }, + state: 'default', + label: '1', + }, + ], + }); + }); + await expect(frame.locator('.attn-overlay')).toHaveAttribute('data-state', 'default'); + + await page.evaluate(() => { + (window as unknown as { __attn_send: (m: unknown) => void }).__attn_send({ + type: 'setAnchorState', + v: 1, + anchorId: 'pinned', + state: 'hovered', + }); + }); + await expect(frame.locator('.attn-overlay')).toHaveAttribute('data-state', 'hovered'); + await expect(frame.locator('.attn-pin')).toHaveAttribute('data-state', 'hovered'); + }); + test('marks a dragged selection passive and a pressed pill explicit', async ({ page }) => { await boot(page); await selectText(page, 'quick brown fox'); diff --git a/web/hosted/app/main.ts b/web/hosted/app/main.ts index b8e640ac..08f6c0d9 100644 --- a/web/hosted/app/main.ts +++ b/web/hosted/app/main.ts @@ -12,7 +12,7 @@ import '../../src/hosted/chrome.css'; import '../../src/styles/bottom-sheet.css'; import '../../src/hosted/app/app-shell.css'; import { mount } from 'svelte'; -import { parseAppRoute } from '../../src/lib/hosted/routes'; +import { appHashIntent, parseAppRoute } from '../../src/lib/hosted/routes'; import AppShell from '../../src/hosted/app/AppShell.svelte'; import { MockWorkspaceService, shellScenarioFromSearch } from '../../src/hosted/app/mock-service'; import type { WorkspaceAppService } from '../../src/hosted/app/types'; @@ -62,8 +62,12 @@ async function bootstrap(): Promise { props: { service, route, - newIntent: route?.view === 'home' && window.location.hash === '#new', - joinIntent: route?.view === 'home' && window.location.hash === '#join', + /* `#new` is a boot intent and stays one: the shell consumes it once, in + its initial load, and the shell is mounted exactly once per document. + `#join` used to be passed the same way and was not the same kind of + thing — the desk that answers it mounts and unmounts as workspaces + open and close, so it reads the fragment itself (attn-ze60.3). */ + newIntent: route?.view === 'home' && appHashIntent(window.location.hash) === 'new', }, }); document.body.dataset.hydrated = 'true'; diff --git a/web/playwright.routes.config.ts b/web/playwright.routes.config.ts index 41c53ee1..31ce6172 100644 --- a/web/playwright.routes.config.ts +++ b/web/playwright.routes.config.ts @@ -18,6 +18,7 @@ export default defineConfig({ 'hosted-offline.spec.ts', 'hosted-share-sheet.spec.ts', 'hosted-recovery.spec.ts', + 'hosted-workspace-switch.spec.ts', 'landing-review-demo.spec.ts', ], timeout: 60_000, diff --git a/web/scripts/build-landing-screenshots.mjs b/web/scripts/build-landing-screenshots.mjs new file mode 100644 index 00000000..ae1fc3d6 --- /dev/null +++ b/web/scripts/build-landing-screenshots.mjs @@ -0,0 +1,76 @@ +// Convert the raw marketing captures (scripts/capture-collab-screenshots.sh → +// site/static/screenshots/-{light,dark}.png, 1920×1440) into the landing +// page's responsive asset set: +// +// web/src/hosted/landing/assets/--{768,1280,1920}.avif +// web/src/hosted/landing/assets/--fallback.webp (1280w) +// +// Every output stays 4:3 — ResponsiveScreenshot hard-codes width=1920 +// height=1440 as the CLS box and hosted-routes.spec.ts asserts it. +// +// Usage: node web/scripts/build-landing-screenshots.mjs [name ...] +// (default: collab share) + +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import sharp from 'sharp'; + +const webRoot = fileURLToPath(new URL('..', import.meta.url)); +const repoRoot = path.join(webRoot, '..'); +const SRC = path.join(repoRoot, 'site', 'static', 'screenshots'); +const OUT = path.join(webRoot, 'src', 'hosted', 'landing', 'assets'); + +/** + * Per-name 4:3 crops, as fractions of the source width/height. + * share: tight to the Share dialog — the raw capture is the whole app window, + * and its dimmed backdrop read as a dead gray slab at exactly the + * trust-transfer moment (critique 2026-08-18). Height follows from width to + * hold 4:3. + */ +const CROPS = { + // Calibrated against the 2026-08-18 capture: dialog spans x 0.242–0.758, + // its "Send this command" block + E2EE blurb end at y≈0.52, and the next + // section heading starts at y≈0.60 — so the crop bottom lands in clean + // dialog whitespace and the left/right edges sit on the dialog's own + // borders (no backdrop slab). The thin top strip keeps the overlay depth + // cue. + share: { left: 0.24, top: 0.05, width: 0.52 }, +}; + +const WIDTHS = [768, 1280, 1920]; +const names = process.argv.length > 2 ? process.argv.slice(2) : ['collab', 'share']; + +for (const name of names) { + for (const theme of ['light', 'dark']) { + const src = path.join(SRC, `${name}-${theme}.png`); + const meta = await sharp(src).metadata(); + if (Math.abs(meta.width / meta.height - 4 / 3) > 0.01) { + throw new Error(`${src}: expected a 4:3 source, got ${meta.width}x${meta.height}`); + } + let base = sharp(src); + const crop = CROPS[name]; + if (crop) { + const width = Math.round(meta.width * crop.width); + const height = Math.round((width * 3) / 4); + base = base.extract({ + left: Math.round(meta.width * crop.left), + top: Math.round(meta.height * crop.top), + width, + height, + }); + } + const upscale = crop ? true : false; + for (const width of WIDTHS) { + const dest = path.join(OUT, `${name}-${theme}-${width}.avif`); + await base + .clone() + .resize({ width, withoutEnlargement: !upscale }) + .avif({ quality: 45, effort: 6 }) + .toFile(dest); + console.log('wrote', path.relative(repoRoot, dest)); + } + const fallback = path.join(OUT, `${name}-${theme}-fallback.webp`); + await base.clone().resize({ width: 1280 }).webp({ quality: 82 }).toFile(fallback); + console.log('wrote', path.relative(repoRoot, fallback)); + } +} diff --git a/web/scripts/check-route-bundles.mjs b/web/scripts/check-route-bundles.mjs index 59b97f22..aa18db27 100644 --- a/web/scripts/check-route-bundles.mjs +++ b/web/scripts/check-route-bundles.mjs @@ -84,12 +84,12 @@ if (failures > 0) { console.error(`route bundle boundaries violated (${failures} finding${failures === 1 ? '' : 's'})`); process.exit(1); } -/* Say only what was actually checked (attn-n01r.41). This previously claimed - 'route bundle boundaries hold', which read as a guarantee about what ships. - It is not: this walks chunk.imports, and Vite records a dynamic import's graph +/* Say only what was actually checked (attn-n01r.41). Claiming 'route bundle + boundaries hold' would read as a guarantee about what ships, and this is not + that: it walks chunk.imports, while Vite records a dynamic import's graph under chunk.dynamicImports. An awaited import() in an entry pulls that graph - over the wire during bootstrap while this gate stays green — which is exactly - how ~600 KB of ProseMirror and crypto reached the desk under a passing build. + over the wire during bootstrap with this gate still green — which is how + ~600 KB of ProseMirror and crypto reached the desk under a passing build. The wire is verified by the per-route script budgets in e2e/hosted-routes.spec.ts; this checks the static graph only. */ console.log('static route graphs clean: no editor or crypto chunks statically reachable'); diff --git a/web/src/App.svelte b/web/src/App.svelte index e6c50cfa..94f03ebe 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -158,6 +158,7 @@ shareTargetMatches, } from './lib/review/room-ui'; import { + applyReviewHoverHighlight, clearPendingAnchorRange, pendingAnchorHighlightPlugin, requestReviewDecorationsRebuild, @@ -567,6 +568,18 @@ bridge.renderAnchors(anchors); }); + // Card → document hover for HTML docs (attn-bb6t.3). The rail stores the + // hovered thread by ROOT EVENT id; the frame knows anchors by thread id. + $effect(() => { + const bridge = htmlBridge; + const hovered = reviewStore.hoveredEventId; + if (!bridge) return; + const thread = hovered === null + ? undefined + : reviewStore.threadsForCurrentFile.find((t) => t.rootEvent.meta.eventId === hovered); + bridge.setHoveredAnchor(thread?.id ?? null); + }); + // Debug/E2E mirror of the shell's own annotation wiring, in the same spirit // as `__attn_collab_debug__`. It exists because the daemon automation bridge // evaluates in the SHELL's context and cannot reach into the opaque-origin @@ -675,6 +688,14 @@ const thread = reviewStore.threadsForCurrentFile.find((t) => t.id === anchorId); if (thread) reviewStore.setFocusEventId(thread.rootEvent.meta.eventId); }, + onAnchorHover: (anchorId) => { + // Document → card (attn-bb6t.3). An unknown id means "nothing", which + // is also what the frame sends on exit. + const thread = anchorId === null + ? undefined + : reviewStore.threadsForCurrentFile.find((t) => t.id === anchorId); + reviewStore.setHoveredEventId(thread?.rootEvent.meta.eventId ?? null); + }, }; // Markdown snapshots seed the prosemirror editor (anchors/collab). HTML @@ -859,11 +880,9 @@ let commandPaletteSearchQuery = $state(''); let commandPaletteSearchResults: SearchResultItem[] = $state([]); - // Right-rail (Phase 2 ReviewPanel mount point). Default collapsed; no review - // session is active yet, so the slot renders a neutral placeholder. Toggling - // shortcut (Cmd+J) is wired here as a placeholder until 12.9 owns it. - // State lives on `reviewStore.panelOpen` so the future keyboard hook / - // ReviewPanel can drive it via `reviewStore.togglePanel()`. + // Right-rail open/closed state lives on `reviewStore.panelOpen`, not here, so + // the keyboard hook and the ReviewPanel drive it through + // `reviewStore.togglePanel()`. // Review-decoration plugin host (attn-nnj.4.6). One plugin instance per // editor mount; the `onReady` callback hands us the EditorView so the @@ -1366,15 +1385,12 @@ // --------------------------------------------------------------------------- // Autosave (attn-yzsa.1) // - // ONE timer writes this window's file. It used to be a 1.5s debounce living - // inside `handleCollabDocChange` and guarded by `collabActive && collabRole - // === 'owner'`, which meant a person editing a local file with no review room - // open had no autosave at all — the "Changes autosaved" the chip now claims - // was true on hosted /app and false here. Bringing autosave to plain edit - // mode is the whole point of the epic; keeping the old collab timer beside - // the new one would have been the easy version and the wrong one, because two - // debounces racing to write the same path interleave and a late one can land - // a staler buffer on top of a newer save. + // ONE timer writes this window's file, and plain edit mode gets it too — a + // debounce guarded by `collabActive && collabRole === 'owner'` leaves anyone + // editing a local file with no review room open with no autosave at all, + // while the chip claims "Changes autosaved". A second timer must never sit + // beside this one: two debounces racing the same path interleave, and a late + // one lands a staler buffer on top of a newer save. // // The policy — when it is safe to write at all, and how long it waits — lives // in lib/native-autosave.ts so it can be unit-tested against a virtual clock. @@ -1611,9 +1627,9 @@ const roomId = reviewStore.currentRoomId; if (!roomId) return; if (suggestAvailability.status !== 'ready') { - // Includes the grant-tier refusal that used to be the very first bare - // `return` in this function — a reviewer holding a comment-only invite - // pressed ⌘⇧. and got nothing, with no way to learn why. + // Covers the grant-tier refusal too. A bare `return` here leaves a + // reviewer on a comment-only invite pressing ⌘⇧. with nothing on screen + // and no way to learn why. refreshSelectionToolbar(); return; } @@ -1856,6 +1872,19 @@ requestReviewDecorationsRebuild(pmViewForReview); }); + // Card → segment hover linking (attn-bb6t.2). Separate from the rebuild + // effect on purpose: this one runs on every mouseenter, and all it does is + // toggle a class on the marks for one thread. It also depends on the same + // inputs as the rebuild above so the class is re-applied after ProseMirror + // redraws the marks out from under it. + $effect(() => { + const hovered = reviewStore.hoveredEventId; + void reviewStore.anchorResolutions; + void reviewStore.events; + if (!pmViewForReview) return; + applyReviewHoverHighlight(pmViewForReview, hovered); + }); + function emptyPlanStructure(): PlanStructure { return { phases: [], tasks: [], file_refs: [] }; } @@ -3152,9 +3181,8 @@ /** * Write the buffer to disk now. * - * WHAT ⌘S MEANS NOW THAT AUTOSAVE EXISTS (attn-yzsa.1). It is no longer the - * only thing standing between the user and lost work — autosave covers that. - * It keeps two jobs, and both are real: + * Autosave, not ⌘S, is what stands between the user and lost work + * (attn-yzsa.1). ⌘S keeps two jobs of its own, and both are real: * * 1. An immediate flush. "Write it, I'm about to do something else" is a * reasonable thing to want, and a 1.2s wait you cannot skip is not. diff --git a/web/src/BrowserReviewApp.svelte b/web/src/BrowserReviewApp.svelte index 8814f505..21f8ec0e 100644 --- a/web/src/BrowserReviewApp.svelte +++ b/web/src/BrowserReviewApp.svelte @@ -62,6 +62,7 @@ import { reviewerStatusPresentation } from './lib/review/reviewer-status-model'; import { reviewStore } from './lib/review/store.svelte'; import { + applyReviewHoverHighlight, clearPendingAnchorRange, pendingAnchorHighlightPlugin, reviewDecorationsPlugin, @@ -526,6 +527,18 @@ requestReviewDecorationsRebuild(pmViewForReview); }); + // Card → segment hover linking (attn-bb6t.2). Deliberately not folded into + // the rebuild effect above: this fires on every mouseenter and only toggles + // a class on one thread's marks. The resolution/event reads keep it correct + // across ProseMirror redraws, which discard the class. + $effect(() => { + const hovered = reviewStore.hoveredEventId; + void reviewStore.anchorResolutions; + void reviewStore.events; + if (!pmViewForReview) return; + applyReviewHoverHighlight(pmViewForReview, hovered); + }); + // --------------------------------------------------------------------------- // Derived view state. // --------------------------------------------------------------------------- @@ -1066,6 +1079,18 @@ bridge.renderAnchors(anchors); }); + // Card → document hover for HTML docs (attn-bb6t.3). The rail stores the + // hovered thread by ROOT EVENT id; the frame knows anchors by thread id. + $effect(() => { + const bridge = htmlBridge; + const hovered = reviewStore.hoveredEventId; + if (!bridge) return; + const thread = hovered === null + ? undefined + : reviewStore.threadsForCurrentFile.find((t) => t.rootEvent.meta.eventId === hovered); + bridge.setHoveredAnchor(thread?.id ?? null); + }); + // Hover chrome is always live in an annotating frame; taking the CLICK — so // the page's own links stop firing — waits until the document is genuinely // reviewable. @@ -1125,6 +1150,14 @@ const thread = reviewStore.threadsForCurrentFile.find((t) => t.id === anchorId); if (thread) reviewStore.setFocusEventId(thread.rootEvent.meta.eventId); }, + onAnchorHover: (anchorId) => { + // Document → card (attn-bb6t.3). An unknown id means "nothing", which + // is also what the frame sends on exit. + const thread = anchorId === null + ? undefined + : reviewStore.threadsForCurrentFile.find((t) => t.id === anchorId); + reviewStore.setHoveredEventId(thread?.rootEvent.meta.eventId ?? null); + }, }; @@ -1275,6 +1308,10 @@ await session.resolveComment(threadId); } + async function reopenBrowserComment(threadId: string): Promise { + await session.reopenComment(threadId); + } + async function rememberBrowserRoom(): Promise { await session.rememberRoom(); } @@ -1503,7 +1540,7 @@ {/if}

@@ -1698,6 +1735,7 @@ readOnly={true} reviewerAuthoring={reviewerAvailability.reviewAuthoring} onResolveComment={resolveBrowserComment} + onReopenComment={reopenBrowserComment} onReplyComment={replyBrowserComment} /> @@ -1737,6 +1775,7 @@ readOnly={true} reviewerAuthoring={reviewerAvailability.reviewAuthoring} onResolveComment={resolveBrowserComment} + onReopenComment={reopenBrowserComment} onReplyComment={replyBrowserComment} /> diff --git a/web/src/app.css b/web/src/app.css index 63ef28ef..20a80818 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -119,59 +119,12 @@ display: none !important; } -/* --------------------------------------------------------------------------- - THE HEADER IS AN ACCENT PLANE (2026-08-10, owner-directed). - - The bar is `--header-surface`, which is `--primary` itself. That makes every - control on it wrong by default: the whole header vocabulary is written in - `--foreground`, `--muted-foreground`, `--accent` and — worst — `--primary`, - which is now the ground those controls sit ON. The active-state convention - (`bg-primary/10` + `border-primary/35` + `text-primary`, attn-11g4.6) would - be an invisible accent-on-accent pill. - - Rather than rewrite ~8 components' class strings, the header RE-POINTS the - tokens for its own subtree. Every Tailwind utility inside — `text-muted- - foreground`, `bg-accent`, `border-primary/35`, `text-primary` — resolves - through `--color-*: var(--*)` in the @theme block above, so they all adapt - with no component edits. The translation is exact: on this plane the pencil - IS the on-accent foreground, so "active" still reads as a filled, outlined - pill and "muted" still reads as a step back. - - POLARITY FLIPS BETWEEN THEMES and that is why nothing here is hard-coded to - white: Paper's accent is dark (0.48) so its foreground is near-white, while - Ink's is light (0.72) so its foreground is near-black. `--primary-foreground` - is correct in both, `white` would be correct in one. - - --amber-deep goes with them: the dirty save glyph's amber has no contrast on - terracotta. DESIGN.md already states the save chip's real signal is the - GLYPH (disk+pen vs disk+check), not the tint, so flattening both states to - the on-accent foreground costs nothing that was carrying meaning. - - THE POPOVERS OPT OUT below. ShareChip, SnapshotBadge, OutboxIndicator and - PeerStrip render their floating cards INSIDE the header subtree (deliberately - — they are absolutely positioned rather than portalled, so they cannot be - torn away by the overflow-prone dock row). Those cards are their own - surface, not the accent plane, so they restore the ordinary palette from the - `--chrome-*` values captured at :root. Portalled menus (bits-ui dropdowns) - escape the subtree and need nothing. */ -:is( - [data-slot="native-header"], - [data-slot="owner-header"], - [data-slot="browser-review-header"] -) { - --foreground: var(--primary-foreground); - --muted-foreground: color-mix(in oklch, var(--primary-foreground) 80%, transparent); - --faint-foreground: color-mix(in oklch, var(--primary-foreground) 62%, transparent); - --primary: var(--primary-foreground); - --accent: color-mix(in oklch, var(--primary-foreground) 16%, transparent); - --accent-foreground: var(--primary-foreground); - --border: color-mix(in oklch, var(--primary-foreground) 34%, transparent); - --input: color-mix(in oklch, var(--primary-foreground) 34%, transparent); - --ring: var(--primary-foreground); - --amber-deep: var(--primary-foreground); - --muted: color-mix(in oklch, var(--primary-foreground) 14%, transparent); - color: var(--primary-foreground); -} +/* THE HEADER IS AN ACCENT PLANE — the token re-pointing that makes it work now + lives in tokens.css, because the hosted app entry imports tokens.css and + chrome.css but NOT this file (attn-08fa.13). It had been declared here, so + the hosted shell's header painted the accent ground and then rendered + page-coloured controls on it. The opt-out below stays here: the popover slots + it names are native/review components that never mount in the hosted shell. */ /* The floating cards are NOT the accent plane. Restoring from the captured root values keeps this honest — no literal is repeated, and a palette change @@ -205,6 +158,17 @@ padding-inline-start: 0; } +/* The rail's inline gutter for the file tree (attn-mkmz.4). Unlayered on + purpose, and this is the whole reason: the reset directly above is unlayered + too, so ANY rule inside @layer components loses to it on the start side no + matter how specific — the first attempt at this landed 12px on the right and + 0 on the left, which is worse than the full-bleed it was fixing. Same + specificity race, one step further along. `--sidebar-gutter` is declared on + `.project-sidebar`; the fallback covers a tree mounted outside one. */ +ul.sidebar-tree-menu[data-sidebar] { + padding-inline: var(--sidebar-gutter, 12px); +} + [data-sidebar="group"] { padding-inline: 6px; padding-block-end: 4px; @@ -222,11 +186,23 @@ /* Flush editorial furniture — no floating card. The controls sit directly on the sidebar so the paper grain reads continuously; a hairline rule under the project label (not a bordered panel) separates identity from the tree. */ + /* ONE gutter down the whole rail (attn-mkmz.4). The controls block, the + outline wrap and the empty card each carried their own inset (12px, 10px, + 10px) and the FILE TREE carried none, so the tree rows ran edge to edge: + the active row's fill touched both walls of the sidebar and its radius was + invisible on the sides it touched — a full-bleed band where the design + specifies a contained item. Named once here and used by everything below, + so the four cannot drift apart again. Shared with the native app, which had + the same defect for the same reason. */ + .project-sidebar { + --sidebar-gutter: 12px; + } + .project-sidebar .sidebar-controls[data-sidebar-controls="true"] { display: grid; gap: 8px; margin: 0; - padding: 10px 12px 8px; + padding: 10px var(--sidebar-gutter) 8px; background: transparent; border: 0; box-shadow: none; @@ -248,6 +224,136 @@ min-width: 0; } + /* Renaming a file happens ON its row, in the row's own type — the rail's + half of the same ruling the header's workspace rename carries. No border, + no fill, no field: the name simply starts accepting keystrokes, with a rust + underline saying it is live and the row's own active fill still behind it. + + Full width rather than content width, unlike the header's. A tree row is + already a fixed lane whose names all start at the same x; a field that hugged + its text would put the name in the middle of the row while every name above + and below it stayed left. */ + .sidebar-name-edit { + position: relative; + display: block; + flex: 1; + min-width: 0; + } + + .sidebar-rename-input { + width: 100%; + min-width: 0; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + /* The tree row's own ink, not the rail's generic foreground: the label this + replaces is set by the active-row rule, and any other value would change + colour the instant the name became editable. */ + color: color-mix(in oklch, var(--foreground) 96%, transparent); + font-family: var(--sans); + font-size: 0.875rem; + font-weight: 500; + line-height: 1; + caret-color: var(--primary); + } + + .sidebar-rename-input:focus, + .sidebar-rename-input:focus-visible { + outline: none; + } + + .sidebar-name-edit::after { + content: ''; + position: absolute; + inset-inline: 0; + bottom: -3px; + height: 1.5px; + background: var(--primary); + } + + /* The row wears the ACTIVE row's treatment while the field is up — same fill, + same inset, same accent tick at the left — because it is the active row: + renaming a file switches to it first. Reproduced rather than inherited + because the button and its `[data-active]` attribute are what the tree + drops for the duration, and losing the "you are here" mark mid-rename would + make the row look like it had been deselected by its own rename. */ + .sidebar-tree-menu .sidebar-tree-row--renaming { + position: relative; + display: flex; + align-items: center; + gap: 6px; + min-height: 34px; + max-height: 34px; + margin-bottom: 2px; + padding-right: 10px; + padding-left: calc(var(--tree-depth, 0) * 20px + 10px); + border: 1px solid transparent; + border-radius: 8px; + background: color-mix(in oklch, var(--foreground) 19%, transparent); + color: color-mix(in oklch, var(--foreground) 96%, transparent); + } + + .sidebar-tree-menu .sidebar-tree-row--renaming::before { + content: ""; + position: absolute; + inset: 5px auto 5px 1px; + width: 2px; + border-radius: 2px; + background: var(--primary); + } + + /* Back to the desk — hosted only, first row in the rail. It borrows the tree + row's inset, radius and hover accent so it belongs to this column rather + than sitting on top of it, but stays shorter and muted and carries a BACK + chevron: it points out of the tree that every row beneath it points into, + and should not read as one more file. No rule beneath it — the filter box + below already draws its own edge, and on native the project row draws the + one hairline this stack is allowed. */ + .sidebar-back { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + min-width: 0; + margin: 0; + padding: 6px 10px; + border: 0; + border-radius: 8px; + background: transparent; + font-family: var(--sans); + font-size: 0.78rem; + font-weight: 500; + line-height: 1.2; + color: color-mix(in oklch, var(--sidebar-foreground) 86%, transparent); + text-align: start; + cursor: pointer; + -webkit-user-select: none; + user-select: none; + transition: background-color var(--t) var(--ease), color var(--t) var(--ease); + } + + .sidebar-back:hover { + background: var(--sidebar-accent); + color: var(--sidebar-foreground); + } + + .sidebar-back:focus-visible { + outline: 2px solid var(--sidebar-ring); + outline-offset: 1px; + } + + .sidebar-back-glyph { + flex: none; + opacity: 0.75; + } + + @media (prefers-reduced-motion: reduce) { + .sidebar-back { + transition: none; + } + } + /* Brand row (attn-64iy.5) — browser tabs only, where the top-left corner is not the traffic lights'. Deliberately quiet: it names the product once, at the top of the chrome, and then gets out of the way. The wordmark matches @@ -340,6 +446,194 @@ opacity: 0.9; } + /* The mark, as a link home. Deliberately the same weight it had as inert + furniture — a logo that starts glowing is a logo competing with the + document. Hover dims it a step, which is the whole of the affordance; + focus takes the app's ring like every other control in the bar. */ + .owner-brand { + display: flex; + flex: none; + align-items: center; + gap: 6px; + border-radius: 6px; + color: inherit; + text-decoration: none; + transition: opacity var(--t) var(--ease); + } + + .owner-brand:hover { + opacity: 0.72; + text-decoration: none; + } + + .owner-brand:focus-visible { + outline: 2px solid var(--ring); + outline-offset: 2px; + } + + @media (prefers-reduced-motion: reduce) { + .owner-brand { + transition: none; + } + } + + /* ONE TYPE FOR BOTH NAMES (user ruling, 2026-08-20). The file name had been + set a step lighter and a step dimmer than the workspace, on the theory that + the interactive half should out-rank the inert one. Printed side by side + they simply looked like a mistake — the same face at two weights, four + words apart — and the header stopped reading as one line. + They are now the same declaration; `.owner-project-name` carries it too, so + changing one changes both. Interactivity is said by BEHAVIOUR instead: the + workspace lifts on hover and underlines in rust on focus, the file does + neither. */ + .owner-file-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--sans); + font-size: var(--text-meta); + font-weight: 500; + line-height: 1.2; + color: var(--foreground); + } + + /* The header variant of the same picker (user ruling, 2026-08-20). The MENU + is shared verbatim with the rail's — same card, same rows — because it is + the same control; only the trigger changes register. + + The trigger is a NAME IN A PATH: same face, size, weight and colour as the + file beside it, because they are two names on one line and any difference + between them reads as a mistake rather than as a hint. What marks it as the + control is what it DOES — see the hover and focus rules below. */ + .owner-project-trigger { + display: inline-flex; + align-items: center; + max-width: 100%; + min-width: 0; + padding: 0; + border: 0; + background: transparent; + color: inherit; + cursor: pointer; + } + + .owner-project-name { + display: block; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--sans); + font-size: var(--text-meta); + font-weight: 500; + line-height: 1.2; + color: var(--foreground); + } + + .owner-project-trigger:focus { + outline: none; + } + + /* With the chevron gone, this pair IS the affordance. A rust underline on + hover and on keyboard focus — the same mark, so pointer and keyboard learn + one thing — and it holds while the menu is open, which keeps the trigger + visibly attached to the card hanging off it. Nothing beside it in the bar + underlines, so the treatment stays unambiguous. */ + .owner-project-trigger:hover .owner-project-name, + .owner-project-trigger:focus-visible .owner-project-name, + .owner-project-trigger[aria-expanded='true'] .owner-project-name { + color: var(--foreground); + text-decoration: underline; + text-decoration-color: var(--primary); + text-decoration-thickness: 1.5px; + text-underline-offset: 3px; + } + + /* Renaming edits the name where the name is (attn-rjuo.2.1). + + The input this replaces (`.hosted-title-input`) rendered in the header's + ACTIONS cluster, at the far right, while the name it renamed sat at the far + left — and it painted `--background` on `--foreground`, which on the accent + plane is a near-black slab on terracotta or steel. Detached and off-plane: it + read as a rendering fault rather than a control. + + This one takes the picker trigger's slot and its type step, so renaming looks + like editing the word rather than opening a dialog somewhere else. Colours + come from the header's own re-pointed tokens — `--primary-foreground` for ink + on the plane, a hairline of the same at low alpha for the field. */ + /* NO BOX (user ruling, 2026-08-20). The field above still announced itself as + a field — a bordered, filled, fixed-16rem slab that appeared where a word + had been and pushed the rest of the path sideways. Renaming is not a + separate mode you enter; it is the same word, still in its own slot, now + accepting keystrokes. + The wrapper is a one-cell grid holding two things: the field, and a hidden + copy of what is typed set in exactly the same type. The cell takes the + copy's width, the field fills the cell — so the control is always precisely + as wide as the name and nothing around it moves as you type. */ + .owner-name-edit { + position: relative; + display: inline-grid; + align-items: center; + min-width: 0; + max-width: 100%; + } + + .owner-name-edit::after { + content: attr(data-value); + grid-area: 1 / 1; + visibility: hidden; + overflow: hidden; + /* `pre` keeps trailing spaces in the measurement, and the extra pixel is + the caret's room at the end of the string. */ + white-space: pre; + padding-inline-end: 1px; + font-family: var(--sans); + font-size: var(--text-meta); + font-weight: 500; + line-height: 1.2; + } + + /* The one mark that the word is live, and it is a mark the trigger already + wears on hover — so entering rename reads as the hover state gaining a + caret rather than a control appearing. Drawn in the grid cell and pulled + below the text box, so arriving and leaving costs no layout. */ + .owner-name-edit::before { + content: ''; + grid-area: 1 / 1; + align-self: end; + height: 1.5px; + margin-block-end: -3px; + background: var(--primary); + } + + .owner-title-input { + grid-area: 1 / 1; + width: 100%; + min-width: 1.5rem; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + color: var(--foreground); + font-family: var(--sans); + font-size: var(--text-meta); + font-weight: 500; + line-height: 1.2; + caret-color: var(--primary); + } + + /* The underline IS the focus indicator here; a ring around a borderless + field would put back the box this rule exists to remove. */ + .owner-title-input:focus, + .owner-title-input:focus-visible { + outline: none; + } + + .owner-title-input::placeholder { + color: color-mix(in oklch, var(--foreground) 55%, transparent); + } + .sidebar-project-menu { min-width: 220px; max-width: 320px; @@ -422,10 +716,12 @@ background: var(--primary); } - /* Workspace actions appended below the project list (hosted picker): - same type as the project rows, indented past the check gutter. */ + /* Workspace actions appended below the project list (hosted picker): same + type as the project rows. Their GEOMETRY is set unlayered further down — + `px-2 py-1.5` rides in on the dropdown item's own class and beats anything + declared here, which is why the inset those rows are supposed to share had + no effect from this block. */ .sidebar-project-menu-action { - padding-inline-start: calc(0.5rem + 8px + 0.875rem); font-family: var(--sans); font-size: 0.95rem; line-height: 1.25; @@ -614,6 +910,12 @@ background: color-mix(in oklch, var(--sidebar-foreground) 8%, transparent); } + /* The section label joins the same gutter. The file tree's own copy of this + has to live unlayered — see the note beside the `[data-sidebar] ul` reset. */ + [data-slot="sidebar-shared-label"] { + padding-inline: var(--sidebar-gutter, 12px); + } + .sidebar-tree-menu [data-sidebar="menu-button"], .sidebar-tree-menu [data-sidebar="menu-sub-button"] { position: relative; @@ -667,6 +969,12 @@ color: color-mix(in oklch, var(--foreground) 96%, transparent); } + /* The active bar is the ACCENT (attn-08fa.9). DESIGN.md's navigation spec has + always said "a 2px accent bar at the left inset", and One Pencil lists + current selection among the accent's three jobs — this is the signature + one. It shipped as 62% ink, which made the bar a second, weaker statement + of the fill it sits inside instead of the one mark that says "you are + here". */ .sidebar-tree-menu [data-sidebar="menu-button"][data-active="true"]::before, .sidebar-tree-menu [data-sidebar="menu-sub-button"][data-active="true"]::before { content: ""; @@ -674,7 +982,7 @@ inset: 5px auto 5px 1px; width: 2px; border-radius: 2px; - background: color-mix(in oklch, var(--foreground) 62%, transparent); + background: var(--primary); } .sidebar-tree-row { @@ -841,7 +1149,7 @@ .sidebar-outline-wrap { margin: 0; - padding: 0 10px 12px; + padding: 0 var(--sidebar-gutter) 12px; border: 0; border-radius: 0; background: transparent; @@ -894,10 +1202,9 @@ border: 1px dashed color-mix(in oklch, var(--foreground) 16%, transparent); border-radius: 10px; padding: 12px; - /* Inset to the same 10px gutter `.sidebar-outline-wrap` gives its rows, so - the card lines up with the filter and the file list instead of running - edge to edge. */ - margin: 0 10px 12px; + /* The rail's one gutter, so the card lines up with the filter and the file + list instead of running edge to edge. */ + margin: 0 var(--sidebar-gutter) 12px; background: color-mix(in oklch, var(--background) 75%, white 25%); } @@ -938,6 +1245,28 @@ text-decoration-thickness: 1px; } + /* ----- Card ↔ segment linking (attn-bb6t.2) ----- + Both halves of the hover tie are painted here rather than baked into the + decoration set: the mark keeps whatever kind/confidence fill it already + has and gains a ring, so one rule covers comments, suggestions, deletions + and every confidence tier without inventing a second color per kind. + `box-decoration-break: clone` on the base classes makes the ring wrap + correctly around each line fragment of a multi-line range. + + `is-hovered` is toggled directly on the mark DOM by + `applyReviewHoverHighlight` (hover must never rebuild decorations); + `is-focused` comes from the decoration set itself. Focus is declared last + so the stronger ring wins when a card is both hovered and focused. */ + [data-event-id].is-hovered { + border-radius: 2px; + box-shadow: 0 0 0 2px color-mix(in oklch, var(--primary) 42%, transparent); + } + + [data-event-id].is-focused { + border-radius: 2px; + box-shadow: 0 0 0 2px color-mix(in oklch, var(--primary) 72%, transparent); + } + /* Inline ghost text for a proposed insertion/replacement — the editorial "suggesting mode" surface. Green to read as "added", slightly inset so it's visually distinct from the author's own prose. */ @@ -1036,6 +1365,25 @@ } /* Sidebar control hard overrides (outside @layer to beat utility classes). */ + +/* THE ACTIVE TREE ROW (attn-08fa.9). DESIGN.md specifies "active fills 19% ink + with a 2px accent bar at the left inset", and `@layer components` above + declares exactly that — but shadcn's menu-button ships + `data-[active=true]:bg-sidebar-accent` as a Tailwind UTILITY, and the Layer + Order puts utilities last on purpose. So the shipped fill was + `--sidebar-accent` (10% ink): the doc said 19, the page drew 10, and neither + was wrong about itself. Measured, not guessed — the row read + `oklch(0.14 0.008 55 / 0.1)` in the hosted editor. + + This belongs in the hard-override block for the same reason the input sizing + does: it is a design-system decision that a component-library utility would + otherwise win. The ::before bar is layered and needs no override, since + nothing in the utility layer paints it. */ +.sidebar-tree-menu [data-sidebar="menu-button"][data-active="true"], +.sidebar-tree-menu [data-sidebar="menu-sub-button"][data-active="true"] { + background: color-mix(in oklch, var(--foreground) 19%, transparent); +} + .project-sidebar [data-slot="sidebar-input"] { height: 36px; min-height: 36px; @@ -1082,6 +1430,38 @@ padding: 6px 0; } +/* THE DEFECT THIS FIXES: the action rows ran the full width of a popover that + clips its own overflow (`overflow-x-hidden` + a rounded border on the + dropdown content), so the focus ring drawn on the highlighted row was sliced + off at both walls — a focused control with no visible left or right edge. + The rows above it never showed this because command items already carry a + 6px inline margin, which is also why the two lists never lined up: the + actions sat 10px to the left of the project labels they were supposed to + continue. + One inset for both, and it is spent twice: it is the margin the ring needs + and the alignment the list needs. The last row also gives its ring room at + the bottom, since the content scrolls on the block axis rather than + clipping. Unlayered because `px-2 py-1.5` arrives on the dropdown item's own + class and would beat any layered rule. */ +.sidebar-project-menu[data-slot="dropdown-menu-content"] .sidebar-project-menu-action { + margin: 2px 10px; + padding: 8px 12px; + /* Past the check gutter, so actions align with the project labels above: + the item's own 12px, the 0.875rem check, and the 8px gap between them. */ + padding-inline-start: calc(12px + 0.875rem + 8px); + border-radius: 8px; +} + +.sidebar-project-menu[data-slot="dropdown-menu-content"] .sidebar-project-menu-action:last-child { + margin-block-end: 6px; +} + +/* The separator's `-mx-1` was written for a menu with `p-1`; this one is `p-0`, + so it hung 4px past both walls and got clipped like everything else. */ +.sidebar-project-menu[data-slot="dropdown-menu-content"] [data-slot="dropdown-menu-separator"] { + margin-inline: 0; +} + /* ============================================ * Dialog EXIT-animation override (WKWebView unmount fix) * @@ -1327,16 +1707,13 @@ /* Inline suggesting mode (track changes, attn-07i.2). Reviewer suggestions render as colored, attributed inline marks: green insertions, red strikethrough deletions. The owner accepts/rejects; the file stays clean. */ -/* Colours come from the review tokens, not Tailwind's palette (design-system - consolidation, 2026-08-08). These marks previously used Tailwind hexes — - green-700/#15803d, red-700/#b91c1c and their dark variants — which are COOL - greens and reds from a different system. attn's own vocabulary is the - 150-hue suggestion green and the 27-hue destructive clay, and the product - principle is one vocabulary for review marks everywhere: a `del` here must - be the same red as `.attn-review-suggestion--deletion`, because they are the - same statement made by two renderers. The tokens are theme-aware, so the - :root.dark colour overrides that existed only to re-state the hexes are - gone rather than migrated. */ +/* Colours come from the review tokens, never Tailwind's palette: its greens + and reds are COOL, from a different system. attn's vocabulary is the 150-hue + suggestion green and the 27-hue destructive clay, and review marks use one + vocabulary everywhere — a `del` here must be the same red as + `.attn-review-suggestion--deletion`, because they are the same statement + made by two renderers. The tokens are theme-aware, so no :root.dark + override belongs here. */ .ProseMirror ins[data-id] { text-decoration: none; color: var(--review-card-suggestion-accent); diff --git a/web/src/doc-runtime/index.ts b/web/src/doc-runtime/index.ts index 6463f220..f56c673e 100644 --- a/web/src/doc-runtime/index.ts +++ b/web/src/doc-runtime/index.ts @@ -42,6 +42,7 @@ import { RUNTIME_STYLES } from './styles'; const HIGHLIGHT_BUCKET = 'attn-text'; const HIGHLIGHT_ACTIVE_BUCKET = 'attn-text-active'; +const HIGHLIGHT_HOVER_BUCKET = 'attn-text-hover'; /** Context captured either side of a selection, for later disambiguation. */ const CONTEXT_CHARS = 64; @@ -391,7 +392,81 @@ function scheduleHide(): void { }, HOVER_GRACE_MS) as unknown as number; } +// --------------------------------------------------------------------------- +// Anchor hover → shell (attn-bb6t.3) +// --------------------------------------------------------------------------- + +/** Last anchor reported to the shell, so we only send on transitions. */ +let lastHoverAnchorId: string | null = null; + +/** + * Which committed anchor, if any, is under the pointer. + * + * Text ranges are checked before elements because a range is always the more + * specific target: commenting on a phrase inside an already-commented block is + * exactly the nesting the annotation model supports, and reporting the block + * there would light up the wrong card. + * + * A CSS Custom Highlight is not a DOM node and receives no events, so a text + * range can only be hit-tested geometrically — hence `getClientRects()` rather + * than a listener. Both coordinate spaces are the frame's viewport. + */ +function anchorAtPoint(event: MouseEvent, target: Element | null): string | null { + // The pin hangs outside the element it belongs to, so hit-test chrome first. + const chrome = target?.closest('[data-anchor-id]'); + if (chrome?.dataset.anchorId) return chrome.dataset.anchorId; + + const x = event.clientX; + const y = event.clientY; + for (const anchor of anchors.values()) { + if (anchor.spec.html.target !== 'text_range' || !anchor.range) continue; + for (const rect of anchor.range.getClientRects()) { + if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) { + return anchor.spec.anchorId; + } + } + } + + if (target) { + // Innermost wins when element anchors nest. + let best: { id: string; depth: number } | null = null; + for (const anchor of anchors.values()) { + if (anchor.spec.html.target !== 'element' || !anchor.element) continue; + if (!anchor.element.contains(target)) continue; + let depth = 0; + for (let node: Element | null = anchor.element; node; node = node.parentElement) depth += 1; + if (!best || depth > best.depth) best = { id: anchor.spec.anchorId, depth }; + } + if (best) return best.id; + } + return null; +} + +/** + * Report anchor hover to the shell. Runs before the inspect gate in + * `onPointerMove` on purpose: lighting up the card for the segment you are + * pointing at is reading affordance, not authoring, so it must work on a + * document whose click-to-comment mode is off. + */ +function reportAnchorHover(event: MouseEvent): void { + if (anchors.size === 0 && lastHoverAnchorId === null) return; + const target = event.target instanceof Element ? event.target : null; + const anchorId = anchorAtPoint(event, target); + if (anchorId === lastHoverAnchorId) return; + lastHoverAnchorId = anchorId; + send({ type: 'anchorHover', v: DOC_PROTOCOL_VERSION, anchorId }); +} + +/** Pointer left the document — nothing is hovered any more. */ +function clearAnchorHover(): void { + if (lastHoverAnchorId === null) return; + lastHoverAnchorId = null; + send({ type: 'anchorHover', v: DOC_PROTOCOL_VERSION, anchorId: null }); +} + function onPointerMove(event: MouseEvent): void { + reportAnchorHover(event); + // A document that cannot take a comment gets no hover chrome at all. The // chip is opaque and clickable and is painted OVER the page, so showing it on // a document that is merely being read would occlude — and swallow clicks on @@ -653,12 +728,16 @@ function repaintHighlights(): void { if (!highlights || typeof Highlight === 'undefined') return; const base: Range[] = []; const active: Range[] = []; + const hovered: Range[] = []; for (const anchor of anchors.values()) { if (anchor.spec.html.target !== 'text_range' || !anchor.range) continue; - (anchor.spec.state === 'active' ? active : base).push(anchor.range); + if (anchor.spec.state === 'active') active.push(anchor.range); + else if (anchor.spec.state === 'hovered') hovered.push(anchor.range); + else base.push(anchor.range); } highlights.set(HIGHLIGHT_BUCKET, new Highlight(...base)); highlights.set(HIGHLIGHT_ACTIVE_BUCKET, new Highlight(...active)); + highlights.set(HIGHLIGHT_HOVER_BUCKET, new Highlight(...hovered)); } /** @@ -680,6 +759,7 @@ function paintElementAnchor(anchor: LiveAnchor): void { const overlay = document.createElement('div'); overlay.className = 'attn-overlay'; overlay.dataset.state = anchor.spec.state; + overlay.dataset.anchorId = anchor.spec.anchorId; overlay.style.cssText = `top:${top}px;left:${left}px;width:${rect.width}px;height:${rect.height}px`; layer.appendChild(overlay); anchor.overlay = overlay; @@ -690,6 +770,7 @@ function paintElementAnchor(anchor: LiveAnchor): void { pin.type = 'button'; pin.className = 'attn-pin'; pin.dataset.state = anchor.spec.state; + pin.dataset.anchorId = anchor.spec.anchorId; pin.textContent = anchor.spec.label ?? '1'; pin.style.cssText = `top:${top - 10}px;left:${left - 14}px`; pin.addEventListener('click', (event) => { @@ -960,6 +1041,9 @@ function boot(): void { document.addEventListener('click', onDocumentClick, true); // Leaving the document entirely is unambiguous; no grace period needed. document.documentElement.addEventListener('mouseleave', hideHover); + // The same exit must clear anchor hover, or the shell's card stays lit after + // the cursor has left the frame (attn-bb6t.3). + document.documentElement.addEventListener('mouseleave', clearAnchorHover); window.addEventListener('scroll', scheduleReflow, { passive: true }); window.addEventListener('resize', scheduleReflow, { passive: true }); new ResizeObserver(scheduleReflow).observe(document.body); diff --git a/web/src/doc-runtime/runtime.generated.js b/web/src/doc-runtime/runtime.generated.js index 6bb04f9e..65370564 100644 --- a/web/src/doc-runtime/runtime.generated.js +++ b/web/src/doc-runtime/runtime.generated.js @@ -1,4 +1,4 @@ -"use strict";(()=>{var Z="attn:doc:hello",J="attn:shell:init";var ft=new TextEncoder;var Re=new TextEncoder;function g(e){return Re.encode(e).length}function S(e,t,n){let o=0,s=0,r=0;for(let i of e){let l=g(i);if(s+1>t||o+l>n)break;o+=l,s+=1,r+=i.length}return r===e.length?e:e.slice(0,r)}function re(e){return e.ownerDocument.createTreeWalker(e,NodeFilter.SHOW_TEXT)}function x(e,t,n){if(t.nodeType!==Node.TEXT_NODE){let i=e.ownerDocument.createRange();return i.selectNodeContents(e),i.setEnd(t,n),g(i.toString())}let o=0,s=re(e),r=s.nextNode();for(;r;){if(r===t)return o+g((r.nodeValue??"").slice(0,n));o+=g(r.nodeValue??""),r=s.nextNode()}return o}function ee(e,t){let n=0,o=re(e),s=o.nextNode(),r=null;for(;s;){let i=s.nodeValue??"",l=g(i);if(n+l>=t){let c=0,d=0;for(let u of i){if(n+c>=t)return{node:s,offset:d};c+=g(u),d+=u.length}return{node:s,offset:i.length}}n+=l,r={node:s,offset:i.length},s=o.nextNode()}return r}function Y(e,t,n){let o=ee(e,t),s=ee(e,n);if(!o||!s)return null;let r=e.ownerDocument.createRange();try{r.setStart(o.node,o.offset),r.setEnd(s.node,s.offset)}catch{return null}return r}function G(e){return e.textContent??""}var Oe=new Set(["TD","TH"]),_e=e=>Oe.has(e.tagName);function Le(e){let t=e.parentElement;return t?Array.from(t.children).filter(n=>n.tagName==="TR").indexOf(e)+1:1}function H(e){let t=e.parentElement;if(!t)return"";let n=Array.from(t.children).filter(o=>o.tagName===e.tagName);return n.length<=1?"":`:nth-of-type(${n.indexOf(e)+1})`}function se(e){return e.length===0||e.length>64||!/^[A-Za-z][\w-]*$/.test(e)||/^(react|radix|mui|headless|aria)[-_]/i.test(e)?!1:!/[0-9a-f]{8,}/i.test(e)}function ie(e){if(typeof e.className!="string")return"";let t=e.className.trim().split(/\s+/).filter(Boolean);for(let n of t)if(/^[\w-]+$/.test(n)&&n.length<=40&&!/[0-9a-f]{6,}/i.test(n))return`.${CSS.escape(n)}`;return""}function ce(e){let t=e.parentElement,n=e.closest("table"),o=[n?T(n):"table"];return t&&t!==n&&o.push(t.tagName.toLowerCase()),o.push(`tr:nth-of-type(${Le(e)})`),o.join(" > ")}function Ne(e){let t=e.closest("tr");if(!t)return e.tagName.toLowerCase();let n=Array.from(t.children).filter(o=>o.tagName===e.tagName);return`${ce(t)} > ${e.tagName.toLowerCase()}:nth-of-type(${n.indexOf(e)+1})`}function T(e){return e.id&&se(e.id)?`#${CSS.escape(e.id)}`:e.tagName==="TR"?ce(e):_e(e)?Ne(e):`${e.tagName.toLowerCase()}${ie(e)}${H(e)}`}function le(e){let t=[],n=c=>{c&&!t.includes(c)&&t.length<8&&t.push(c)},o=[],s=e;for(;s&&s.tagName!=="BODY"&&o.length<12;)o.unshift(`${s.tagName.toLowerCase()}${H(s)}`),s=s.parentElement;o.length>0&&n(o.join(" > "));let r=e.parentElement,i=[`${e.tagName.toLowerCase()}${H(e)}`];for(;r&&r.tagName!=="BODY"&&i.length<6;){if(r.id&&se(r.id)){n(`#${CSS.escape(r.id)} ${i.join(" > ")}`);break}i.unshift(`${r.tagName.toLowerCase()}${H(r)}`),r=r.parentElement}let l=ie(e);return l&&n(`${e.tagName.toLowerCase()}${l}`),t.filter(c=>c!==T(e))}var ke={TR:"row",TD:"cell",TH:"columnheader",TABLE:"table",LI:"listitem",UL:"list",OL:"list",P:"paragraph",BLOCKQUOTE:"blockquote",FIGURE:"figure",IMG:"img",H1:"heading",H2:"heading",H3:"heading",H4:"heading"};function ae(e,t){let n=[],o=e;for(;o&&o.tagName!=="BODY"&&n.length<8;)n.unshift(o.tagName.toLowerCase()),o=o.parentElement;let s=e.getAttribute("role")??ke[e.tagName],r={tagName:e.tagName.toLowerCase(),scopePreview:S(t,200,256),domPath:n};return s&&(r.role=s),r}function Me(e,t){let n=t.startContainer.nodeType===Node.ELEMENT_NODE?t.startContainer:t.startContainer.parentElement,o=t.endContainer.nodeType===Node.ELEMENT_NODE?t.endContainer:t.endContainer.parentElement;return!n||!o||n===o?null:{startSelector:T(n),startOffset:x(n,t.startContainer,t.startOffset),endSelector:T(o),endOffset:x(o,t.endContainer,t.endOffset)}}function ue(e,t){let n=t.commonAncestorContainer.nodeType===Node.ELEMENT_NODE?t.commonAncestorContainer:t.commonAncestorContainer.parentElement??e,o=x(e,t.startContainer,t.startOffset),s=x(e,t.endContainer,t.endOffset),r={v:1,target:"text_range",cssSelector:T(n),fallbackSelectors:le(n),textPosition:{start:o,end:s},context:ae(n,S(t.toString(),120,256))},i=Me(e,t);return i&&(r.range=i),r}function V(e,t,n){let o=e.ownerDocument.createRange();return o.selectNodeContents(t),{v:1,target:"element",cssSelector:T(t),fallbackSelectors:le(t),textPosition:{start:x(e,o.startContainer,o.startOffset),end:x(e,o.endContainer,o.endOffset)},context:ae(t,n)}}var te={range:null,element:null,status:"stale",confidence:0};function Ie(e){return e.replace(/[‘’]/g,"'").replace(/[“”]/g,'"').replace(/[–—]/g,"-").replace(/\s+/g," ").trim()}function De(e){let t=[],n=[],o=[],s=-1;for(let r=0;r0&&(t.push(" "),n.push(s),o.push(r)),s=-1,t.push(i),n.push(r),o.push(r+1)}return{normalized:t.join(""),starts:n,ends:o}}function L(e,t){try{return e.querySelector(t)}catch{return null}}function ne(e,t){let n=[];if(t.length===0)return n;let o=0;for(;;){let s=e.indexOf(t,o);if(s===-1||(n.push(s),o=s+1,n.length>64))return n}}function oe(e,t){let n=Math.min(e.length,t.length),o=0;for(;o{let c=e.slice(Math.max(0,l-o.length),l),d=e.slice(l+n,l+n+s.length),u=oe([...c].reverse().join(""),[...o].reverse().join(""))+oe(d,s);return{at:l,score:u}});r.sort((l,c)=>c.score-l.score);let i=r.length>1&&r[0].score===r[1].score;return{index:r[0].at,ambiguous:i}}function de(e,t){let{anchor:n,quote:o,prefix:s="",suffix:r=""}=t;if(n.target==="element"){let c=L(e,n.cssSelector)??(n.fallbackSelectors??[]).reduce((A,C)=>A??L(e,C),null);if(!c)return te;let d=e.ownerDocument.createRange();d.selectNodeContents(c);let u=L(e,n.cssSelector)===c;return{range:d,element:c,status:u?"exact":"remapped",confidence:u?1:.7}}let i=G(e);if(o&&n.textPosition){let{start:c,end:d}=n.textPosition,u=Y(e,c,d);if(u&&u.toString()===o)return{range:u,element:null,status:"exact",confidence:1}}if(o&&o.length>0){let c=ne(i,o);if(c.length>0){let{index:d,ambiguous:u}=He(i,c,o.length,s,r),A=g(i.slice(0,d)),C=Y(e,A,A+g(o));if(C)return{range:C,element:null,status:u?"ambiguous":"remapped",confidence:u?.4:c.length===1?.9:.75}}}if(o){let c=Ie(o),{normalized:d,starts:u,ends:A}=De(i);if(c.length>0){let C=ne(d,c);if(C.length===1){let z=C[0],W=u[z],K=A[z+c.length-1];if(W!==void 0&&K!==void 0){let Ae=g(i.slice(0,W)),Te=g(i.slice(0,K)),Q=Y(e,Ae,Te);if(Q)return{range:Q,element:null,status:"remapped",confidence:.6}}}}}let l=L(e,n.cssSelector)??(n.fallbackSelectors??[]).reduce((c,d)=>c??L(e,d),null);if(l){let c=e.ownerDocument.createRange();return c.selectNodeContents(l),{range:c,element:l,status:"remapped",confidence:.35}}return te}var fe=` +"use strict";(()=>{var J="attn:doc:hello",ee="attn:shell:init";var Et=new TextEncoder;var Oe=new TextEncoder;function x(e){return Oe.encode(e).length}function S(e,t,n){let o=0,s=0,r=0;for(let i of e){let a=x(i);if(s+1>t||o+a>n)break;o+=a,s+=1,r+=i.length}return r===e.length?e:e.slice(0,r)}function se(e){return e.ownerDocument.createTreeWalker(e,NodeFilter.SHOW_TEXT)}function v(e,t,n){if(t.nodeType!==Node.TEXT_NODE){let i=e.ownerDocument.createRange();return i.selectNodeContents(e),i.setEnd(t,n),x(i.toString())}let o=0,s=se(e),r=s.nextNode();for(;r;){if(r===t)return o+x((r.nodeValue??"").slice(0,n));o+=x(r.nodeValue??""),r=s.nextNode()}return o}function te(e,t){let n=0,o=se(e),s=o.nextNode(),r=null;for(;s;){let i=s.nodeValue??"",a=x(i);if(n+a>=t){let c=0,d=0;for(let u of i){if(n+c>=t)return{node:s,offset:d};c+=x(u),d+=u.length}return{node:s,offset:i.length}}n+=a,r={node:s,offset:i.length},s=o.nextNode()}return r}function V(e,t,n){let o=te(e,t),s=te(e,n);if(!o||!s)return null;let r=e.ownerDocument.createRange();try{r.setStart(o.node,o.offset),r.setEnd(s.node,s.offset)}catch{return null}return r}function G(e){return e.textContent??""}var _e=new Set(["TD","TH"]),Le=e=>_e.has(e.tagName);function ke(e){let t=e.parentElement;return t?Array.from(t.children).filter(n=>n.tagName==="TR").indexOf(e)+1:1}function P(e){let t=e.parentElement;if(!t)return"";let n=Array.from(t.children).filter(o=>o.tagName===e.tagName);return n.length<=1?"":`:nth-of-type(${n.indexOf(e)+1})`}function ie(e){return e.length===0||e.length>64||!/^[A-Za-z][\w-]*$/.test(e)||/^(react|radix|mui|headless|aria)[-_]/i.test(e)?!1:!/[0-9a-f]{8,}/i.test(e)}function ce(e){if(typeof e.className!="string")return"";let t=e.className.trim().split(/\s+/).filter(Boolean);for(let n of t)if(/^[\w-]+$/.test(n)&&n.length<=40&&!/[0-9a-f]{6,}/i.test(n))return`.${CSS.escape(n)}`;return""}function ae(e){let t=e.parentElement,n=e.closest("table"),o=[n?A(n):"table"];return t&&t!==n&&o.push(t.tagName.toLowerCase()),o.push(`tr:nth-of-type(${ke(e)})`),o.join(" > ")}function Ne(e){let t=e.closest("tr");if(!t)return e.tagName.toLowerCase();let n=Array.from(t.children).filter(o=>o.tagName===e.tagName);return`${ae(t)} > ${e.tagName.toLowerCase()}:nth-of-type(${n.indexOf(e)+1})`}function A(e){return e.id&&ie(e.id)?`#${CSS.escape(e.id)}`:e.tagName==="TR"?ae(e):Le(e)?Ne(e):`${e.tagName.toLowerCase()}${ce(e)}${P(e)}`}function le(e){let t=[],n=c=>{c&&!t.includes(c)&&t.length<8&&t.push(c)},o=[],s=e;for(;s&&s.tagName!=="BODY"&&o.length<12;)o.unshift(`${s.tagName.toLowerCase()}${P(s)}`),s=s.parentElement;o.length>0&&n(o.join(" > "));let r=e.parentElement,i=[`${e.tagName.toLowerCase()}${P(e)}`];for(;r&&r.tagName!=="BODY"&&i.length<6;){if(r.id&&ie(r.id)){n(`#${CSS.escape(r.id)} ${i.join(" > ")}`);break}i.unshift(`${r.tagName.toLowerCase()}${P(r)}`),r=r.parentElement}let a=ce(e);return a&&n(`${e.tagName.toLowerCase()}${a}`),t.filter(c=>c!==A(e))}var Ie={TR:"row",TD:"cell",TH:"columnheader",TABLE:"table",LI:"listitem",UL:"list",OL:"list",P:"paragraph",BLOCKQUOTE:"blockquote",FIGURE:"figure",IMG:"img",H1:"heading",H2:"heading",H3:"heading",H4:"heading"};function ue(e,t){let n=[],o=e;for(;o&&o.tagName!=="BODY"&&n.length<8;)n.unshift(o.tagName.toLowerCase()),o=o.parentElement;let s=e.getAttribute("role")??Ie[e.tagName],r={tagName:e.tagName.toLowerCase(),scopePreview:S(t,200,256),domPath:n};return s&&(r.role=s),r}function Me(e,t){let n=t.startContainer.nodeType===Node.ELEMENT_NODE?t.startContainer:t.startContainer.parentElement,o=t.endContainer.nodeType===Node.ELEMENT_NODE?t.endContainer:t.endContainer.parentElement;return!n||!o||n===o?null:{startSelector:A(n),startOffset:v(n,t.startContainer,t.startOffset),endSelector:A(o),endOffset:v(o,t.endContainer,t.endOffset)}}function de(e,t){let n=t.commonAncestorContainer.nodeType===Node.ELEMENT_NODE?t.commonAncestorContainer:t.commonAncestorContainer.parentElement??e,o=v(e,t.startContainer,t.startOffset),s=v(e,t.endContainer,t.endOffset),r={v:1,target:"text_range",cssSelector:A(n),fallbackSelectors:le(n),textPosition:{start:o,end:s},context:ue(n,S(t.toString(),120,256))},i=Me(e,t);return i&&(r.range=i),r}function U(e,t,n){let o=e.ownerDocument.createRange();return o.selectNodeContents(t),{v:1,target:"element",cssSelector:A(t),fallbackSelectors:le(t),textPosition:{start:v(e,o.startContainer,o.startOffset),end:v(e,o.endContainer,o.endOffset)},context:ue(t,n)}}var ne={range:null,element:null,status:"stale",confidence:0};function He(e){return e.replace(/[‘’]/g,"'").replace(/[“”]/g,'"').replace(/[–—]/g,"-").replace(/\s+/g," ").trim()}function De(e){let t=[],n=[],o=[],s=-1;for(let r=0;r0&&(t.push(" "),n.push(s),o.push(r)),s=-1,t.push(i),n.push(r),o.push(r+1)}return{normalized:t.join(""),starts:n,ends:o}}function L(e,t){try{return e.querySelector(t)}catch{return null}}function oe(e,t){let n=[];if(t.length===0)return n;let o=0;for(;;){let s=e.indexOf(t,o);if(s===-1||(n.push(s),o=s+1,n.length>64))return n}}function re(e,t){let n=Math.min(e.length,t.length),o=0;for(;o{let c=e.slice(Math.max(0,a-o.length),a),d=e.slice(a+n,a+n+s.length),u=re([...c].reverse().join(""),[...o].reverse().join(""))+re(d,s);return{at:a,score:u}});r.sort((a,c)=>c.score-a.score);let i=r.length>1&&r[0].score===r[1].score;return{index:r[0].at,ambiguous:i}}function fe(e,t){let{anchor:n,quote:o,prefix:s="",suffix:r=""}=t;if(n.target==="element"){let c=L(e,n.cssSelector)??(n.fallbackSelectors??[]).reduce((T,C)=>T??L(e,C),null);if(!c)return ne;let d=e.ownerDocument.createRange();d.selectNodeContents(c);let u=L(e,n.cssSelector)===c;return{range:d,element:c,status:u?"exact":"remapped",confidence:u?1:.7}}let i=G(e);if(o&&n.textPosition){let{start:c,end:d}=n.textPosition,u=V(e,c,d);if(u&&u.toString()===o)return{range:u,element:null,status:"exact",confidence:1}}if(o&&o.length>0){let c=oe(i,o);if(c.length>0){let{index:d,ambiguous:u}=Pe(i,c,o.length,s,r),T=x(i.slice(0,d)),C=V(e,T,T+x(o));if(C)return{range:C,element:null,status:u?"ambiguous":"remapped",confidence:u?.4:c.length===1?.9:.75}}}if(o){let c=He(o),{normalized:d,starts:u,ends:T}=De(i);if(c.length>0){let C=oe(d,c);if(C.length===1){let W=C[0],K=u[W],Q=T[W+c.length-1];if(K!==void 0&&Q!==void 0){let Ae=x(i.slice(0,K)),Re=x(i.slice(0,Q)),Z=V(e,Ae,Re);if(Z)return{range:Z,element:null,status:"remapped",confidence:.6}}}}}let a=L(e,n.cssSelector)??(n.fallbackSelectors??[]).reduce((c,d)=>c??L(e,d),null);if(a){let c=e.ownerDocument.createRange();return c.selectNodeContents(a),{range:c,element:a,status:"remapped",confidence:.35}}return ne}var pe=` .attn-layer { position: absolute; inset: 0; @@ -30,6 +30,12 @@ ::highlight(attn-text-active) { background-color: oklch(0.80 0.16 82 / 52%); } +/* Hover sits between base and active (attn-bb6t.3): strong enough to answer + "which segment is this card about?", quiet enough that it never reads as + the focused thread. */ +::highlight(attn-text-hover) { + background-color: oklch(0.81 0.15 84 / 42%); +} /* Element overlay. The fill is inert so text underneath a commented element stays selectable \u2014 you can always comment on something inside something @@ -50,6 +56,10 @@ border-style: dashed; opacity: 0.55; } +.attn-overlay[data-state="hovered"] { + border-color: color-mix(in oklch, var(--attn-element-accent) 85%, transparent); + background: color-mix(in oklch, var(--attn-element-accent) 13%, transparent); +} /* Persistent marker for a committed comment: visible without hovering, so the document reads as annotated at a glance. */ @@ -73,7 +83,8 @@ transition: transform 120ms ease; } .attn-pin:hover, -.attn-pin[data-state="active"] { +.attn-pin[data-state="active"], +.attn-pin[data-state="hovered"] { transform: scale(1.12); } .attn-pin[data-state="resolved"] { @@ -195,4 +206,4 @@ .attn-overlay, .attn-pin { transition: none; } } -`;var Pe="attn-text",Be="attn-text-active",N=64,P=null,f,b,a,y,m,E=new Map,B=new Map,p=null,I=null,j=null,R=null,Xe=0,X=!1;function v(e){P?.postMessage(e)}function $e(e){return{x:e.x,y:e.y,width:e.width,height:e.height}}function _(e){return e?Array.from(e.getClientRects()).filter(n=>n.width>0&&n.height>0).slice(0,128).map($e):[]}var Ye=new Set(["TD","TH","TR","LI","FIGURE","PRE","CODE","TABLE","BLOCKQUOTE","H1","H2","H3","H4","H5","H6","P","IMG","FIGCAPTION","UL","OL","DL","DT","DD","SECTION","ARTICLE","ASIDE","HEADER","FOOTER","MAIN","NAV","DETAILS","SUMMARY","FORM","VIDEO","AUDIO","CANVAS","SVG","A","BUTTON","LABEL","INPUT","TEXTAREA","SELECT","HR"]),he=e=>e.tagName==="TD"||e.tagName==="TH";function Ge(e){let t=e;for(let n=0;t&&t!==f&&n<12;n+=1){let o=t.getBoundingClientRect();if(o.width>0&&o.height>0)return t;t=t.parentElement}return null}function Ve(e){let t=[],n=e;for(;n&&n!==f&&t.length<12;)Ye.has(n.tagName.toUpperCase())&&t.push(n),n=n.parentElement;if(t.length===0){let o=Ge(e);o&&t.push(o)}return t}function Ue(e){return e[0]}function ge(e){let t=e.parentElement;return t?Array.from(t.children).filter(n=>n.tagName==="TR").indexOf(e)+1:1}function Ee(e){return he(e)?"cell":e.tagName==="TR"?e.closest("thead")?"header row":`row ${ge(e)}`:e.tagName==="LI"?"list item":e.tagName==="PRE"?"code block":e.tagName==="UL"||e.tagName==="OL"?"list":e.tagName==="A"?"link":e.tagName==="IMG"?"image":e.tagName==="BLOCKQUOTE"?"quote":/^H[1-6]$/.test(e.tagName)?"heading":e.tagName.toLowerCase()}function xe(e){if(e.tagName==="TR"){let n=Array.from(e.querySelectorAll("th,td")).map(r=>r.textContent?.trim()??""),o=e.closest("thead")?"header row":`row ${ge(e)}`,s=[n[0],n[1]].filter(Boolean).join(" \xB7 ");return s?`${o} \xB7 ${s}`:o}if(he(e)){let n=e.closest("tr"),o=n?Array.from(n.children).indexOf(e):-1,r=e.closest("table")?.querySelector("thead tr")?.children[o]?.textContent?.trim(),i=e.textContent?.trim()??"";return r?`${r}: ${i}`:i}let t=e.textContent?.trim()??"";return t?t.slice(0,80):null}function Fe(e){let t=0;for(let n of E.values())n.element===e&&(t+=1);return t}function je(e){let t=f.ownerDocument.createRange();t.selectNodeContents(f),t.setEnd(e.startContainer,e.startOffset);let n=f.ownerDocument.createRange();n.selectNodeContents(f),n.setStart(e.endContainer,e.endOffset);let o=t.toString(),s=[...o.slice(-N*2)].slice(-N),r=[...n.toString().slice(0,N*2)].slice(0,N);if(s.length>0&&o.length>N*2){let i=s[0].charCodeAt(0);i>=56320&&i<=57343&&s.shift()}return{prefix:s.join(""),suffix:r.join("")}}function be(e){let t=x(f,e.startContainer,e.startOffset),n=x(f,e.endContainer,e.endOffset),o=e.toString(),{prefix:s,suffix:r}=je(e);return{html:ue(f,e),quote:S(o,4e3,4096),prefix:s,suffix:r,textStart:t,textEnd:n}}function qe(e){let t=xe(e)??Ee(e),n=V(f,e,t),o=S((e.textContent??"").trim(),4e3,4096);return{html:n,quote:o,prefix:"",suffix:"",textStart:n.textPosition?.start??0,textEnd:n.textPosition?.end??0}}function ze(){let e=window.getSelection();return!!e&&!e.isCollapsed&&e.rangeCount>0}function We(){let e=window.getSelection();if(!e||e.isCollapsed||e.rangeCount===0){R=null,ve(),v({type:"selectionCleared",v:1});return}let t=e.getRangeAt(0);if(t.toString().trim().length===0)return;R=t.cloneRange(),w();let n=_(t),o=n[n.length-1];Ke(o),v({type:"selection",v:1,proposal:be(t),rects:n,caret:o??{x:0,y:0,width:0,height:0},explicit:!1})}function Ke(e){e&&(m.style.left=`${e.x+e.width}px`,m.style.top=`${e.y+e.height+8}px`,m.classList.add("is-visible"))}function ve(){m.classList.remove("is-visible")}var Qe=160,pe=4,Ze=2,O=0;function Se(e){return e instanceof Node&&b.contains(e)}function M(){O&&(clearTimeout(O),O=0)}function Ce(){O||(O=setTimeout(()=>{O=0,w()},Qe))}function Je(e){if(!X)return;if(a.contains(e.target)){M();return}if(Se(e.target))return;if(ze()){w();return}let t=e.target;if(!(t instanceof Element))return;if(t===j){p&&M();return}j=t;let n=Ve(t),o=Ue(n);if(!o){Ce();return}M(),o!==p&&(p=o,D(o),nt(tt(n)))}function w(){M(),p=null,j=null,a.classList.remove("is-visible"),D(void 0)}function et(e){if(Se(e.target)||!X)return;let t=e.target;if(!(t instanceof Element))return;let n=I,o=p;!n||!o||o!==t&&!o.contains(t)||(e.preventDefault(),e.stopPropagation(),$(n))}function tt(e){B.clear();let t=e.slice(0,8).map(n=>{let o=`scope-${Xe+=1}`;B.set(o,n);let s=xe(n);return{scopeId:o,title:Ee(n),preview:s===null?null:S(s,200,256),selector:V(f,n,"").cssSelector,commentCount:Fe(n),rects:_(n)}});return v({type:"scopeHover",v:1,chain:t}),t}function nt(e){if(y.textContent="",I=e[0]?.scopeId??null,e.length===0){a.classList.remove("is-visible");return}let t=e.slice(0,pe).reverse(),n=e.length>pe;if(n){let o=document.createElement("span");o.className="attn-chip-sep",o.textContent="\u2026",y.appendChild(o)}t.forEach((o,s)=>{if(s>0||n){let c=document.createElement("span");c.className="attn-chip-sep",c.textContent="\u203A",c.setAttribute("aria-hidden","true"),y.appendChild(c)}let r=s===t.length-1,i=document.createElement("button");i.type="button",i.className="attn-chip-seg",r&&i.classList.add("is-current"),i.dataset.scope=o.scopeId,i.setAttribute("aria-label",`Comment on ${o.preview??o.title}`);let l=document.createElement("span");if(l.className="attn-chip-title",l.textContent=o.title,i.appendChild(l),r&&o.preview&&o.preview!==o.title){let c=document.createElement("span");c.className="attn-chip-preview",c.textContent=S(o.preview,48,192),i.appendChild(c)}if(o.commentCount>0){let c=document.createElement("span");c.className="attn-chip-count",c.textContent=String(o.commentCount),i.appendChild(c)}i.addEventListener("mouseenter",()=>D(B.get(o.scopeId))),i.addEventListener("mouseleave",()=>D(p??void 0)),i.addEventListener("mousedown",c=>c.preventDefault()),i.addEventListener("click",c=>{c.preventDefault(),c.stopPropagation(),$(o.scopeId)}),y.appendChild(i)}),a.classList.add("is-visible"),p&&ye(p)}function ye(e){let t=e.getBoundingClientRect(),n=t.top+window.scrollY,o=t.left+window.scrollX,s=a.offsetHeight,r=n-s+Ze;a.style.top=`${r"u")return;let t=[],n=[];for(let o of E.values())o.spec.html.target!=="text_range"||!o.range||(o.spec.state==="active"?n:t).push(o.range);e.set(Pe,new Highlight(...t)),e.set(Be,new Highlight(...n))}function we(e){if(e.overlay?.remove(),e.pin?.remove(),e.overlay=null,e.pin=null,e.spec.html.target!=="element"||!e.element)return;let t=e.element.getBoundingClientRect(),n=t.top+window.scrollY,o=t.left+window.scrollX,s=document.createElement("div");s.className="attn-overlay",s.dataset.state=e.spec.state,s.style.cssText=`top:${n}px;left:${o}px;width:${t.width}px;height:${t.height}px`,b.appendChild(s),e.overlay=s;let r=document.createElement("button");r.type="button",r.className="attn-pin",r.dataset.state=e.spec.state,r.textContent=e.spec.label??"1",r.style.cssText=`top:${n-10}px;left:${o-14}px`,r.addEventListener("click",i=>{i.stopPropagation(),v({type:"anchorActivated",v:1,anchorId:e.spec.anchorId})}),b.appendChild(r),e.pin=r}function ot(e){let t=de(f,{anchor:e.html,quote:e.quote,prefix:e.prefix,suffix:e.suffix}),n={spec:e,range:t.range,element:t.element,status:t.status,confidence:t.confidence,overlay:null,pin:null};return we(n),n}function rt(e){for(let t of E.values())t.overlay?.remove(),t.pin?.remove();E.clear();for(let t of e)E.set(t.anchorId,ot(t));q(),st()}function st(){let e=[];for(let t of E.values())e.push({anchorId:t.spec.anchorId,status:t.status,confidence:t.confidence,rects:_(t.range??t.element)});v({type:"anchorsResolved",v:1,results:e})}function it(){let e=[];for(let t of E.values())e.push({anchorId:t.spec.anchorId,rects:_(t.range??t.element)});v({type:"geometry",v:1,results:e,scrollTop:window.scrollY})}function ct(e,t){let n=E.get(e);n&&(n.spec={...n.spec,state:t},n.overlay&&(n.overlay.dataset.state=t),n.pin&&(n.pin.dataset.state=t),q())}function lt(e,t){let n=E.get(e);if(!n||!t)return;(n.element??n.range?.startContainer.parentElement)?.scrollIntoView({behavior:"smooth",block:"center"})}var U=0;function F(){U||(U=requestAnimationFrame(()=>{U=0;for(let e of E.values())we(e);p&&!p.isConnected?w():p&&(D(p),ye(p)),q(),it()}))}function at(e){switch(e.type){case"renderAnchors":rt(e.anchors);break;case"setAnchorState":ct(e.anchorId,e.state);break;case"focusAnchor":lt(e.anchorId,e.scrollIntoView);break;case"pickScope":$(e.scopeId);break;case"dismissSelection":window.getSelection()?.removeAllRanges(),R=null,ve();break;case"inspect":{let t=e.enabled===!0;X&&!t&&(w(),I=null),X=t;break}case"theme":f.dataset.attnTheme=e.mode;break;default:break}}function ut(){let e=document.createElement("style");e.textContent=fe,document.head.appendChild(e),b=document.createElement("div"),b.className="attn-layer",document.body.appendChild(b),a=document.createElement("div"),a.className="attn-chip",a.setAttribute("role","toolbar"),a.setAttribute("aria-label","Comment on this element"),a.addEventListener("mouseenter",M),a.addEventListener("mouseleave",Ce),a.addEventListener("mousedown",t=>t.preventDefault()),a.addEventListener("click",t=>{let n=t.target;n instanceof Element&&n.closest(".attn-chip-seg")||(t.preventDefault(),t.stopPropagation(),I&&$(I))}),y=document.createElement("div"),y.className="attn-chip-body",a.appendChild(y),b.appendChild(a),m=document.createElement("button"),m.type="button",m.className="attn-pill",m.textContent="Comment",m.addEventListener("mousedown",t=>t.preventDefault()),m.addEventListener("click",t=>{if(t.preventDefault(),t.stopPropagation(),!R)return;let n=_(R);v({type:"selection",v:1,proposal:be(R),rects:n,caret:n[n.length-1]??{x:0,y:0,width:0,height:0},explicit:!0})}),b.appendChild(m)}function dt(e){P=e,P.onmessage=t=>{let n=t.data;!n||typeof n!="object"||typeof n.type!="string"||n.v===1&&at(n)},P.start(),v({type:"ready",v:1,textLength:G(f).length,title:S(document.title,200,512)})}function me(){let e=window;e.__attnDocRuntime||(e.__attnDocRuntime=!0,f=document.body,ut(),document.addEventListener("selectionchange",We),document.addEventListener("mousemove",Je,{passive:!0}),document.addEventListener("click",et,!0),document.documentElement.addEventListener("mouseleave",w),window.addEventListener("scroll",F,{passive:!0}),window.addEventListener("resize",F,{passive:!0}),new ResizeObserver(F).observe(document.body),window.addEventListener("message",t=>{if(t.source!==window.parent)return;let n=t.data;if(!n||n.type!==J)return;let[o]=t.ports;o&&dt(o)}),window.parent.postMessage({type:Z,v:1},"*"))}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",me,{once:!0}):me();})(); +`;var Be="attn-text",Xe="attn-text-active",$e="attn-text-hover",k=64,B=null,f,b,l,y,g,h=new Map,X=new Map,p=null,H=null,q=null,R=null,Ye=0,$=!1;function E(e){B?.postMessage(e)}function Ve(e){return{x:e.x,y:e.y,width:e.width,height:e.height}}function _(e){return e?Array.from(e.getClientRects()).filter(n=>n.width>0&&n.height>0).slice(0,128).map(Ve):[]}var Ge=new Set(["TD","TH","TR","LI","FIGURE","PRE","CODE","TABLE","BLOCKQUOTE","H1","H2","H3","H4","H5","H6","P","IMG","FIGCAPTION","UL","OL","DL","DT","DD","SECTION","ARTICLE","ASIDE","HEADER","FOOTER","MAIN","NAV","DETAILS","SUMMARY","FORM","VIDEO","AUDIO","CANVAS","SVG","A","BUTTON","LABEL","INPUT","TEXTAREA","SELECT","HR"]),ge=e=>e.tagName==="TD"||e.tagName==="TH";function Ue(e){let t=e;for(let n=0;t&&t!==f&&n<12;n+=1){let o=t.getBoundingClientRect();if(o.width>0&&o.height>0)return t;t=t.parentElement}return null}function Fe(e){let t=[],n=e;for(;n&&n!==f&&t.length<12;)Ge.has(n.tagName.toUpperCase())&&t.push(n),n=n.parentElement;if(t.length===0){let o=Ue(e);o&&t.push(o)}return t}function je(e){return e[0]}function Ee(e){let t=e.parentElement;return t?Array.from(t.children).filter(n=>n.tagName==="TR").indexOf(e)+1:1}function xe(e){return ge(e)?"cell":e.tagName==="TR"?e.closest("thead")?"header row":`row ${Ee(e)}`:e.tagName==="LI"?"list item":e.tagName==="PRE"?"code block":e.tagName==="UL"||e.tagName==="OL"?"list":e.tagName==="A"?"link":e.tagName==="IMG"?"image":e.tagName==="BLOCKQUOTE"?"quote":/^H[1-6]$/.test(e.tagName)?"heading":e.tagName.toLowerCase()}function ve(e){if(e.tagName==="TR"){let n=Array.from(e.querySelectorAll("th,td")).map(r=>r.textContent?.trim()??""),o=e.closest("thead")?"header row":`row ${Ee(e)}`,s=[n[0],n[1]].filter(Boolean).join(" \xB7 ");return s?`${o} \xB7 ${s}`:o}if(ge(e)){let n=e.closest("tr"),o=n?Array.from(n.children).indexOf(e):-1,r=e.closest("table")?.querySelector("thead tr")?.children[o]?.textContent?.trim(),i=e.textContent?.trim()??"";return r?`${r}: ${i}`:i}let t=e.textContent?.trim()??"";return t?t.slice(0,80):null}function qe(e){let t=0;for(let n of h.values())n.element===e&&(t+=1);return t}function ze(e){let t=f.ownerDocument.createRange();t.selectNodeContents(f),t.setEnd(e.startContainer,e.startOffset);let n=f.ownerDocument.createRange();n.selectNodeContents(f),n.setStart(e.endContainer,e.endOffset);let o=t.toString(),s=[...o.slice(-k*2)].slice(-k),r=[...n.toString().slice(0,k*2)].slice(0,k);if(s.length>0&&o.length>k*2){let i=s[0].charCodeAt(0);i>=56320&&i<=57343&&s.shift()}return{prefix:s.join(""),suffix:r.join("")}}function be(e){let t=v(f,e.startContainer,e.startOffset),n=v(f,e.endContainer,e.endOffset),o=e.toString(),{prefix:s,suffix:r}=ze(e);return{html:de(f,e),quote:S(o,4e3,4096),prefix:s,suffix:r,textStart:t,textEnd:n}}function We(e){let t=ve(e)??xe(e),n=U(f,e,t),o=S((e.textContent??"").trim(),4e3,4096);return{html:n,quote:o,prefix:"",suffix:"",textStart:n.textPosition?.start??0,textEnd:n.textPosition?.end??0}}function Ke(){let e=window.getSelection();return!!e&&!e.isCollapsed&&e.rangeCount>0}function Qe(){let e=window.getSelection();if(!e||e.isCollapsed||e.rangeCount===0){R=null,Se(),E({type:"selectionCleared",v:1});return}let t=e.getRangeAt(0);if(t.toString().trim().length===0)return;R=t.cloneRange(),w();let n=_(t),o=n[n.length-1];Ze(o),E({type:"selection",v:1,proposal:be(t),rects:n,caret:o??{x:0,y:0,width:0,height:0},explicit:!1})}function Ze(e){e&&(g.style.left=`${e.x+e.width}px`,g.style.top=`${e.y+e.height+8}px`,g.classList.add("is-visible"))}function Se(){g.classList.remove("is-visible")}var Je=160,he=4,et=2,O=0;function Ce(e){return e instanceof Node&&b.contains(e)}function I(){O&&(clearTimeout(O),O=0)}function ye(){O||(O=setTimeout(()=>{O=0,w()},Je))}var M=null;function tt(e,t){let n=t?.closest("[data-anchor-id]");if(n?.dataset.anchorId)return n.dataset.anchorId;let o=e.clientX,s=e.clientY;for(let r of h.values())if(!(r.spec.html.target!=="text_range"||!r.range)){for(let i of r.range.getClientRects())if(o>=i.left&&o<=i.right&&s>=i.top&&s<=i.bottom)return r.spec.anchorId}if(t){let r=null;for(let i of h.values()){if(i.spec.html.target!=="element"||!i.element||!i.element.contains(t))continue;let a=0;for(let c=i.element;c;c=c.parentElement)a+=1;(!r||a>r.depth)&&(r={id:i.spec.anchorId,depth:a})}if(r)return r.id}return null}function nt(e){if(h.size===0&&M===null)return;let t=e.target instanceof Element?e.target:null,n=tt(e,t);n!==M&&(M=n,E({type:"anchorHover",v:1,anchorId:n}))}function ot(){M!==null&&(M=null,E({type:"anchorHover",v:1,anchorId:null}))}function rt(e){if(nt(e),!$)return;if(l.contains(e.target)){I();return}if(Ce(e.target))return;if(Ke()){w();return}let t=e.target;if(!(t instanceof Element))return;if(t===q){p&&I();return}q=t;let n=Fe(t),o=je(n);if(!o){ye();return}I(),o!==p&&(p=o,D(o),ct(it(n)))}function w(){I(),p=null,q=null,l.classList.remove("is-visible"),D(void 0)}function st(e){if(Ce(e.target)||!$)return;let t=e.target;if(!(t instanceof Element))return;let n=H,o=p;!n||!o||o!==t&&!o.contains(t)||(e.preventDefault(),e.stopPropagation(),Y(n))}function it(e){X.clear();let t=e.slice(0,8).map(n=>{let o=`scope-${Ye+=1}`;X.set(o,n);let s=ve(n);return{scopeId:o,title:xe(n),preview:s===null?null:S(s,200,256),selector:U(f,n,"").cssSelector,commentCount:qe(n),rects:_(n)}});return E({type:"scopeHover",v:1,chain:t}),t}function ct(e){if(y.textContent="",H=e[0]?.scopeId??null,e.length===0){l.classList.remove("is-visible");return}let t=e.slice(0,he).reverse(),n=e.length>he;if(n){let o=document.createElement("span");o.className="attn-chip-sep",o.textContent="\u2026",y.appendChild(o)}t.forEach((o,s)=>{if(s>0||n){let c=document.createElement("span");c.className="attn-chip-sep",c.textContent="\u203A",c.setAttribute("aria-hidden","true"),y.appendChild(c)}let r=s===t.length-1,i=document.createElement("button");i.type="button",i.className="attn-chip-seg",r&&i.classList.add("is-current"),i.dataset.scope=o.scopeId,i.setAttribute("aria-label",`Comment on ${o.preview??o.title}`);let a=document.createElement("span");if(a.className="attn-chip-title",a.textContent=o.title,i.appendChild(a),r&&o.preview&&o.preview!==o.title){let c=document.createElement("span");c.className="attn-chip-preview",c.textContent=S(o.preview,48,192),i.appendChild(c)}if(o.commentCount>0){let c=document.createElement("span");c.className="attn-chip-count",c.textContent=String(o.commentCount),i.appendChild(c)}i.addEventListener("mouseenter",()=>D(X.get(o.scopeId))),i.addEventListener("mouseleave",()=>D(p??void 0)),i.addEventListener("mousedown",c=>c.preventDefault()),i.addEventListener("click",c=>{c.preventDefault(),c.stopPropagation(),Y(o.scopeId)}),y.appendChild(i)}),l.classList.add("is-visible"),p&&we(p)}function we(e){let t=e.getBoundingClientRect(),n=t.top+window.scrollY,o=t.left+window.scrollX,s=l.offsetHeight,r=n-s+et;l.style.top=`${r"u")return;let t=[],n=[],o=[];for(let s of h.values())s.spec.html.target!=="text_range"||!s.range||(s.spec.state==="active"?n.push(s.range):s.spec.state==="hovered"?o.push(s.range):t.push(s.range));e.set(Be,new Highlight(...t)),e.set(Xe,new Highlight(...n)),e.set($e,new Highlight(...o))}function Te(e){if(e.overlay?.remove(),e.pin?.remove(),e.overlay=null,e.pin=null,e.spec.html.target!=="element"||!e.element)return;let t=e.element.getBoundingClientRect(),n=t.top+window.scrollY,o=t.left+window.scrollX,s=document.createElement("div");s.className="attn-overlay",s.dataset.state=e.spec.state,s.dataset.anchorId=e.spec.anchorId,s.style.cssText=`top:${n}px;left:${o}px;width:${t.width}px;height:${t.height}px`,b.appendChild(s),e.overlay=s;let r=document.createElement("button");r.type="button",r.className="attn-pin",r.dataset.state=e.spec.state,r.dataset.anchorId=e.spec.anchorId,r.textContent=e.spec.label??"1",r.style.cssText=`top:${n-10}px;left:${o-14}px`,r.addEventListener("click",i=>{i.stopPropagation(),E({type:"anchorActivated",v:1,anchorId:e.spec.anchorId})}),b.appendChild(r),e.pin=r}function at(e){let t=fe(f,{anchor:e.html,quote:e.quote,prefix:e.prefix,suffix:e.suffix}),n={spec:e,range:t.range,element:t.element,status:t.status,confidence:t.confidence,overlay:null,pin:null};return Te(n),n}function lt(e){for(let t of h.values())t.overlay?.remove(),t.pin?.remove();h.clear();for(let t of e)h.set(t.anchorId,at(t));z(),ut()}function ut(){let e=[];for(let t of h.values())e.push({anchorId:t.spec.anchorId,status:t.status,confidence:t.confidence,rects:_(t.range??t.element)});E({type:"anchorsResolved",v:1,results:e})}function dt(){let e=[];for(let t of h.values())e.push({anchorId:t.spec.anchorId,rects:_(t.range??t.element)});E({type:"geometry",v:1,results:e,scrollTop:window.scrollY})}function ft(e,t){let n=h.get(e);n&&(n.spec={...n.spec,state:t},n.overlay&&(n.overlay.dataset.state=t),n.pin&&(n.pin.dataset.state=t),z())}function pt(e,t){let n=h.get(e);if(!n||!t)return;(n.element??n.range?.startContainer.parentElement)?.scrollIntoView({behavior:"smooth",block:"center"})}var F=0;function j(){F||(F=requestAnimationFrame(()=>{F=0;for(let e of h.values())Te(e);p&&!p.isConnected?w():p&&(D(p),we(p)),z(),dt()}))}function ht(e){switch(e.type){case"renderAnchors":lt(e.anchors);break;case"setAnchorState":ft(e.anchorId,e.state);break;case"focusAnchor":pt(e.anchorId,e.scrollIntoView);break;case"pickScope":Y(e.scopeId);break;case"dismissSelection":window.getSelection()?.removeAllRanges(),R=null,Se();break;case"inspect":{let t=e.enabled===!0;$&&!t&&(w(),H=null),$=t;break}case"theme":f.dataset.attnTheme=e.mode;break;default:break}}function mt(){let e=document.createElement("style");e.textContent=pe,document.head.appendChild(e),b=document.createElement("div"),b.className="attn-layer",document.body.appendChild(b),l=document.createElement("div"),l.className="attn-chip",l.setAttribute("role","toolbar"),l.setAttribute("aria-label","Comment on this element"),l.addEventListener("mouseenter",I),l.addEventListener("mouseleave",ye),l.addEventListener("mousedown",t=>t.preventDefault()),l.addEventListener("click",t=>{let n=t.target;n instanceof Element&&n.closest(".attn-chip-seg")||(t.preventDefault(),t.stopPropagation(),H&&Y(H))}),y=document.createElement("div"),y.className="attn-chip-body",l.appendChild(y),b.appendChild(l),g=document.createElement("button"),g.type="button",g.className="attn-pill",g.textContent="Comment",g.addEventListener("mousedown",t=>t.preventDefault()),g.addEventListener("click",t=>{if(t.preventDefault(),t.stopPropagation(),!R)return;let n=_(R);E({type:"selection",v:1,proposal:be(R),rects:n,caret:n[n.length-1]??{x:0,y:0,width:0,height:0},explicit:!0})}),b.appendChild(g)}function gt(e){B=e,B.onmessage=t=>{let n=t.data;!n||typeof n!="object"||typeof n.type!="string"||n.v===1&&ht(n)},B.start(),E({type:"ready",v:1,textLength:G(f).length,title:S(document.title,200,512)})}function me(){let e=window;e.__attnDocRuntime||(e.__attnDocRuntime=!0,f=document.body,mt(),document.addEventListener("selectionchange",Qe),document.addEventListener("mousemove",rt,{passive:!0}),document.addEventListener("click",st,!0),document.documentElement.addEventListener("mouseleave",w),document.documentElement.addEventListener("mouseleave",ot),window.addEventListener("scroll",j,{passive:!0}),window.addEventListener("resize",j,{passive:!0}),new ResizeObserver(j).observe(document.body),window.addEventListener("message",t=>{if(t.source!==window.parent)return;let n=t.data;if(!n||n.type!==ee)return;let[o]=t.ports;o&>(o)}),window.parent.postMessage({type:J,v:1},"*"))}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",me,{once:!0}):me();})(); diff --git a/web/src/doc-runtime/styles.ts b/web/src/doc-runtime/styles.ts index 25f9aa20..1a1902cb 100644 --- a/web/src/doc-runtime/styles.ts +++ b/web/src/doc-runtime/styles.ts @@ -43,6 +43,12 @@ export const RUNTIME_STYLES = ` ::highlight(attn-text-active) { background-color: oklch(0.80 0.16 82 / 52%); } +/* Hover sits between base and active (attn-bb6t.3): strong enough to answer + "which segment is this card about?", quiet enough that it never reads as + the focused thread. */ +::highlight(attn-text-hover) { + background-color: oklch(0.81 0.15 84 / 42%); +} /* Element overlay. The fill is inert so text underneath a commented element stays selectable — you can always comment on something inside something @@ -63,6 +69,10 @@ export const RUNTIME_STYLES = ` border-style: dashed; opacity: 0.55; } +.attn-overlay[data-state="hovered"] { + border-color: color-mix(in oklch, var(--attn-element-accent) 85%, transparent); + background: color-mix(in oklch, var(--attn-element-accent) 13%, transparent); +} /* Persistent marker for a committed comment: visible without hovering, so the document reads as annotated at a glance. */ @@ -86,7 +96,8 @@ export const RUNTIME_STYLES = ` transition: transform 120ms ease; } .attn-pin:hover, -.attn-pin[data-state="active"] { +.attn-pin[data-state="active"], +.attn-pin[data-state="hovered"] { transform: scale(1.12); } .attn-pin[data-state="resolved"] { diff --git a/web/src/hosted/app/AppHeader.svelte b/web/src/hosted/app/AppHeader.svelte index c34e3a4d..b3a71136 100644 --- a/web/src/hosted/app/AppHeader.svelte +++ b/web/src/hosted/app/AppHeader.svelte @@ -2,7 +2,6 @@ import type { Snippet } from 'svelte'; import BrandMark from '../../lib/BrandMark.svelte'; import type { PersistenceMode } from './types'; - import { SAVE_STATE_STORAGE_ATTENTION } from '../../lib/save-state-copy'; interface Props { mode: PersistenceMode; @@ -10,52 +9,31 @@ } const { mode, actions }: Props = $props(); + - /* Literal desk-header states from planning/web-authoring/ios-ux.md §8. - Three tones, not two (attn-n01r.31). `best-effort` used to return - warn: false, so "Backup recommended" rendered in the same green as - "On this device" — green-on-green is the universal "you're fine" signal, - attached to a message meaning the user's work is one storage eviction from - gone. It is a caution, not a success, and it is also not the destructive - red the genuine failures use. - - Green is additionally quarantined to the collaboration layer by DESIGN.md, - so a storage state was never entitled to it in the first place. */ - const badge = $derived.by((): { label: string; tone: 'ok' | 'caution' | 'warn' } => { - switch (mode) { - case 'persistent': - return { label: 'On this device', tone: 'ok' }; - case 'best-effort': - return { label: 'Backup recommended', tone: 'caution' }; - case 'session-only': - return { label: 'This session only', tone: 'warn' }; - case 'unavailable': - return { label: 'View-only', tone: 'warn' }; - case 'quota-pressure': - return { label: SAVE_STATE_STORAGE_ATTENTION, tone: 'warn' }; - } - }); + + +
attn
- {#if needsAttention} - - - {badge.label} - - {:else} - - - {badge.label} - - {/if} {@render actions?.()}
diff --git a/web/src/hosted/app/AppShell.svelte b/web/src/hosted/app/AppShell.svelte index 86e54afd..7eafd32c 100644 --- a/web/src/hosted/app/AppShell.svelte +++ b/web/src/hosted/app/AppShell.svelte @@ -1,8 +1,24 @@ {#if phase === 'loading'} +
-
-

Opening your desk…

-
+
{:else if phase === 'error'} +
+
@@ -328,9 +630,20 @@

Your desk couldn’t open

-

- {errorMessage} +

+
+ + Check storage +
+ {#if errorMessage} +
+ Technical detail +

{errorMessage}

+
+ {/if}
{:else if editorMode && detail && EditorShell} @@ -341,55 +654,116 @@ {activePath} {bodyText} {isNewDraft} + {createIntent} {onSelectEntry} {onWorkspaceChanged} {workspaces} onSwitchWorkspace={(workspaceId) => { const target = workspaces.find((workspace) => workspace.id === workspaceId); - window.location.assign( - target ? `/app/w/${workspaceId}/${target.openPath}` : `/app/w/${workspaceId}`, - ); + void navigate({ + view: 'workspace', + workspaceId, + filePath: target?.openPath, + }); }} /> -{:else if editorMode && detail} - -
-
-

Opening {detail.name}…

-
-
-{:else if route?.view === 'workspace'} -
+{:else if editorMode && detail && editorShellFailed} + +
+
-
Not on this device
-

That workspace isn’t here

+
Something went wrong
+

The editor didn’t finish loading

-

- Local workspaces live in the browser profile that created them. Import a backup, or go - back to your desk. +

+
+ + Go to your desk +
+ {#if editorShellError} +
+ Technical detail +

{editorShellError}

+
+ {/if}
-{:else if route?.view === 'storage'} - +
+
+
+{:else if editorMode} + + -{:else if route?.view === 'open'} - {:else} - + +
+ + {#snippet actions()} + {#if chromeView === 'home'} + Storage + {:else} + Back to your desk + {/if} + {/snippet} + + {#if chromeView === 'storage'} + + {:else if chromeView === 'open'} + + {:else} + + {/if} +
{/if} + diff --git a/web/src/hosted/app/CommandPalette.svelte b/web/src/hosted/app/CommandPalette.svelte index f5370c24..d57d18eb 100644 --- a/web/src/hosted/app/CommandPalette.svelte +++ b/web/src/hosted/app/CommandPalette.svelte @@ -114,7 +114,7 @@ {/each}
- ↑↓ navigate runesc close + ↑↓ navigate runesc close
{/if} @@ -183,12 +183,4 @@ font: 500 0.75rem var(--sans); color: var(--hosted-muted); } - kbd { - font: 600 0.75rem var(--sans); - padding: 1px 5px; - border: 1px solid var(--rule); - border-bottom-width: 2px; - border-radius: 6px; - background: var(--paper); - } diff --git a/web/src/hosted/app/ConfirmPanel.svelte b/web/src/hosted/app/ConfirmPanel.svelte new file mode 100644 index 00000000..59fbc392 --- /dev/null +++ b/web/src/hosted/app/ConfirmPanel.svelte @@ -0,0 +1,89 @@ + + + + +
+ {title} + {#if body}{@render body()}{/if} +
+ + {#if extra}{@render extra()}{/if} + +
+
diff --git a/web/src/hosted/app/DegradedBanner.svelte b/web/src/hosted/app/DegradedBanner.svelte index cb1cf4e8..0cf711be 100644 --- a/web/src/hosted/app/DegradedBanner.svelte +++ b/web/src/hosted/app/DegradedBanner.svelte @@ -48,9 +48,9 @@
{#each state.actions as action (action)} + export / backup / persistence controls. A handler-less
diff --git a/web/src/hosted/app/DeskHome.svelte b/web/src/hosted/app/DeskHome.svelte index 3d6aee0e..ee948785 100644 --- a/web/src/hosted/app/DeskHome.svelte +++ b/web/src/hosted/app/DeskHome.svelte @@ -1,30 +1,54 @@ + + +{text}… diff --git a/web/src/hosted/app/OpenPage.svelte b/web/src/hosted/app/OpenPage.svelte index 893ea77e..f83632a4 100644 --- a/web/src/hosted/app/OpenPage.svelte +++ b/web/src/hosted/app/OpenPage.svelte @@ -1,8 +1,7 @@ -
- - {#snippet actions()} - Back to your desk - {/snippet} - -
- -
-
-
Import handoff
-

Import into your desk

-
-

Everything imports to this device only

+
+ +
+
+ +
Bring files in
+

Import into your desk

+

Everything imports to this device only

+
-
void importFiles(files) }}> -

Drop files to import

-

- Markdown files, referenced images and assets, whole folders where the browser supports - them, or a zip. Relative paths are preserved exactly as native attn sees them. -

-
.md · images & assets · folder · .zip · .attn-workspace (soon)
-
- -
- - {#if importError} - - {/if} +
void importFiles(files), + onError: (message) => (importError = message), + }} + > +

Drop files to import

+ +

+ Markdown, images and assets, whole folders where the browser supports them, or a zip. + Relative paths are preserved exactly as native attn sees them. +

+ +
    +
  • .md
  • +
  • .markdown
  • +
  • .png
  • +
  • .jpg
  • +
  • .svg
  • +
  • folder/
  • +
  • .zip
  • +
+
+
-
-
+ + {#if importError} + + + {/if} +
+ diff --git a/web/src/hosted/app/ReviewTroubleDialog.svelte b/web/src/hosted/app/ReviewTroubleDialog.svelte new file mode 100644 index 00000000..a73b2c85 --- /dev/null +++ b/web/src/hosted/app/ReviewTroubleDialog.svelte @@ -0,0 +1,114 @@ + + + + +
+ diff --git a/web/src/hosted/app/ShareSheet.svelte b/web/src/hosted/app/ShareSheet.svelte index 1c974944..2e628ff9 100644 --- a/web/src/hosted/app/ShareSheet.svelte +++ b/web/src/hosted/app/ShareSheet.svelte @@ -434,7 +434,7 @@