From 6a6a388ecd7777a73136b5dfded249066f182023 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Fri, 31 Jul 2026 23:42:25 -0400 Subject: [PATCH 01/12] docs: Fabled-Git, not Forgejo, in ci-requirements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The instance has run Gitea since the migration. Prose only — no workflow or path change. Scribe issue #2272. --- ci-requirements.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ci-requirements.md b/ci-requirements.md index 217dbf4..ee09c7e 100644 --- a/ci-requirements.md +++ b/ci-requirements.md @@ -22,7 +22,7 @@ real Postgres), build (docker buildx). - uv (test + integration jobs run `uv sync --locked`; installed in the image since the ci-python Dockerfile started pip-installing it) - docker CLI + buildx (build job pushes the production image to the - Forgejo registry) + Fabled-Git registry) ## Per-job tool installs From 17d59fa3e03042abdd32cfda40dcafff98012b95 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 31 Jul 2026 23:43:40 -0400 Subject: [PATCH 02/12] feat(prior-art): ask the repo, not just the record, before writing a definition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scribe has never read a line of the codebase. Every Drafter surface recalls from the RECORD — things someone deliberately recorded — so a helper nobody thought to record is invisible to all of them. That is how `.btn-primary` came to be defined four times, in four scoped stylesheets, already diverged: it was never a snippet, so no threshold and no query rewrite could ever have surfaced it (#2280). The write-path hook already runs on the developer's machine, inside the repo, holding the code about to be written. It can simply look. No index, no storage, no staleness story, no server round-trip. Verified against this repo with Scribe unconfigured: .btn-primary is already defined in 4 other file(s): DesignSystemsView.vue ProjectListView.vue SettingsView.vue SnippetEditorView.vue Three properties it needs, all checked by hand: - DEFINITION-shaped patterns only. Grepping bare occurrences would match every call site and bury the real finding, and a hint that is mostly noise is one people learn to skip — worse than none. A payload containing only calls to embed_note() stays silent; one containing `def embed_note` does not. - The target file is excluded, so editing the file that already defines something doesn't report it against itself. - It runs when Scribe is UNCONFIGURED, and a failed request no longer discards it. The remote arms answer "what was recorded"; this one answers "what exists", and that question needs no instance to produce an answer. `curl || exit 0` became `curl || true` for the same reason. Plugin 0.1.21 -> 0.1.22. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs --- plugin/.claude-plugin/plugin.json | 2 +- plugin/hooks/scribe_prior_art.sh | 119 +++++++++++++++++++++++++----- 2 files changed, 102 insertions(+), 19 deletions(-) diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index a486db8..b0fd4a3 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "scribe", "description": "Scribe system-of-record for Claude Code: MCP tools over your notes/tasks/projects/rules, a session-start push channel that surfaces your always-on rules + active-project context, process-skills (writing-plans, systematic-debugging, verification, brainstorming, reusing-code), and your saved Scribe Processes auto-surfaced as skills (/scribe:sync). Replaces superpowers + file-memory with one app-backed plugin.", - "version": "0.1.21", + "version": "0.1.22", "author": { "name": "Bryan Van Deusen" }, "mcpServers": { "scribe": { diff --git a/plugin/hooks/scribe_prior_art.sh b/plugin/hooks/scribe_prior_art.sh index 1b79998..027315b 100755 --- a/plugin/hooks/scribe_prior_art.sh +++ b/plugin/hooks/scribe_prior_art.sh @@ -48,16 +48,9 @@ case "$file_path" in exit 0 ;; esac -url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}} -token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}} -# Guard against an unexpanded ${...} placeholder arriving as a literal. -case "$url" in *'${'*) url="" ;; esac -case "$token" in *'${'*) token="" ;; esac -# Unconfigured install → silent. Prior-art recall is pure enrichment. -[ -n "$url" ] && [ -n "$token" ] || exit 0 - # Snippet locations are recorded repo-relative, so send a repo-relative path — -# an absolute one would simply match nothing. +# an absolute one would simply match nothing. Resolved BEFORE the config gate +# because the local arm below needs the repo root and needs no server at all. lookup_dir=$(dirname -- "$file_path" 2>/dev/null || true) [ -d "$lookup_dir" ] || lookup_dir=${event_cwd:-${CLAUDE_PROJECT_DIR:-$PWD}} repo_root=$(git -C "$lookup_dir" rev-parse --show-toplevel 2>/dev/null || true) @@ -68,6 +61,84 @@ if [ -n "$repo_root" ]; then esac fi +# --------------------------------------------------------------------------- +# ARM 1 — BY NAME, LOCALLY (#2280). Does a definition of this already exist? +# +# The other two arms ask Scribe what was RECORDED. Scribe has never read a line +# of the codebase, so a helper nobody thought to record is invisible to them — +# which is how `.btn-primary` came to be defined four times, in four scoped +# stylesheets, already diverged. It was never a snippet, so no threshold and no +# query rewrite could ever have surfaced it. +# +# This arm closes that by asking the only question the record cannot answer, +# in the only place that can: the hook already runs on the developer's machine, +# inside the repo, holding the code about to be written. No index, no storage, +# no staleness, and no server — it deliberately runs even on an install that +# has never configured Scribe. +# +# Definition-shaped patterns only. Grepping for bare occurrences would match +# every CALL site and drown the real finding — and a hint that is mostly noise +# is one people learn to skip, which is worse than none. +# --------------------------------------------------------------------------- +local_lines="" +if [ -n "$repo_root" ] && [ -n "$code" ]; then + # kindname for each thing this payload DEFINES. + names=$(printf '%s' "$code" | awk ' + match($0, /^[[:space:]]*\.[A-Za-z][A-Za-z0-9_-]*[[:space:]]*[,{]/) { + t = $0; sub(/^[[:space:]]*\./, "", t); sub(/[[:space:]]*[,{].*$/, "", t); + if (t != "") print "css\t" t; next } + match($0, /^[[:space:]]*(export[[:space:]]+)?(default[[:space:]]+)?(async[[:space:]]+)?function[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*/) { + t = $0; sub(/^.*function[[:space:]]+/, "", t); sub(/[^A-Za-z0-9_$].*$/, "", t); + if (t != "") print "sym\t" t; next } + match($0, /^[[:space:]]*(export[[:space:]]+)?class[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*/) { + t = $0; sub(/^.*class[[:space:]]+/, "", t); sub(/[^A-Za-z0-9_$].*$/, "", t); + if (t != "") print "sym\t" t; next } + match($0, /^[[:space:]]*(async[[:space:]]+)?def[[:space:]]+[A-Za-z_][A-Za-z0-9_]*/) { + t = $0; sub(/^.*def[[:space:]]+/, "", t); sub(/[^A-Za-z0-9_].*$/, "", t); + if (t != "") print "sym\t" t; next } + match($0, /^[[:space:]]*(export[[:space:]]+)?(const|let)[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*[[:space:]]*=[[:space:]]*(async[[:space:]]*)?[(<]/) { + t = $0; sub(/^[[:space:]]*(export[[:space:]]+)?(const|let)[[:space:]]+/, "", t); + sub(/[^A-Za-z0-9_$].*$/, "", t); + if (t != "") print "sym\t" t; next } + ' 2>/dev/null | sort -u | head -12) || names="" + + while IFS=$'\t' read -r kind name; do + [ -n "${name:-}" ] || continue + case "$kind" in + css) pat="^[[:space:]]*\.${name}[[:space:]]*[,{]" ;; + *) pat="(function|class|def)[[:space:]]+${name}[^A-Za-z0-9_]|(const|let)[[:space:]]+${name}[[:space:]]*=" ;; + esac + # -I skips binaries; :(exclude) drops the file being written, which would + # otherwise always match itself on an Edit. + hits=$(git -C "$repo_root" grep -I -l -E -e "$pat" -- . ":(exclude)${rel_path}" 2>/dev/null | head -4) || hits="" + [ -n "$hits" ] || continue + count=$(printf '%s\n' "$hits" | grep -c . 2>/dev/null || echo 0) + label=$([ "$kind" = css ] && printf '.%s' "$name" || printf '%s' "$name") + files=$(printf '%s' "$hits" | tr '\n' ' ' | sed 's/ $//') + local_lines="${local_lines}> - \`${label}\` is already defined in ${count} other file(s): ${files}"$'\n' + done <<< "$names" +fi + +local_context="" +if [ -n "$local_lines" ]; then + local_context="> Already defined elsewhere in this repo — check before adding another copy (\`git grep\` shown; this is a nudge, not a gate):"$'\n'"${local_lines}" +fi + +url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}} +token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}} +# Guard against an unexpanded ${...} placeholder arriving as a literal. +case "$url" in *'${'*) url="" ;; esac +case "$token" in *'${'*) token="" ;; esac +# Unconfigured install → the recorded-prior-art arms are skipped, but the local +# arm above already ran and may have something to say. +if [ -z "$url" ] || [ -z "$token" ]; then + if [ -n "$local_context" ]; then + jq -n --arg c "$local_context" \ + '{hookSpecificOutput: {hookEventName: "PreToolUse", additionalContext: $c}}' + fi + exit 0 +fi + # Cap the code sent as the semantic query. The embedder truncates at its own # token limit well before this, so a bigger slice buys no extra signal — and the # payload has to stay a GET (a read-scoped API key cannot POST, and every other @@ -110,20 +181,32 @@ if [ -n "$session_id" ]; then fi fi +# `|| true`, not `|| exit 0`: an unreachable instance must not discard a local +# finding that needed no instance to produce. body=$(curl -fsS --max-time 5 \ -H "Authorization: Bearer ${token}" \ - "${url%/}/api/plugin/prior-art?path=${path_enc}&code=${code_enc}${repo_q}${exclude_q}" 2>/dev/null) || exit 0 -[ -n "$body" ] || exit 0 + "${url%/}/api/plugin/prior-art?path=${path_enc}&code=${code_enc}${repo_q}${exclude_q}" 2>/dev/null) || body="" -context=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null) || exit 0 -[ -n "$context" ] || exit 0 - -# Remember what was surfaced so it isn't shown again this session. -if [ -n "$idfile" ]; then - printf '%s' "$body" | jq -r '.note_ids[]? // empty' 2>/dev/null >> "$idfile" || true +context="" +if [ -n "$body" ]; then + context=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null) || context="" + # Remember what was surfaced so it isn't shown again this session. + if [ -n "$idfile" ] && [ -n "$context" ]; then + printf '%s' "$body" | jq -r '.note_ids[]? // empty' 2>/dev/null >> "$idfile" || true + fi fi +# Local first. It answers "this already EXISTS", which is a stronger claim than +# "this resembles something recorded" — and it is the one the recorded arms are +# structurally unable to make. +combined="$local_context" +if [ -n "$context" ]; then + [ -n "$combined" ] && combined="${combined}"$'\n' + combined="${combined}${context}" +fi +[ -n "$combined" ] || exit 0 + # No permissionDecision: this is a nudge, not a gate. The write goes ahead. -jq -n --arg c "$context" \ +jq -n --arg c "$combined" \ '{hookSpecificOutput: {hookEventName: "PreToolUse", additionalContext: $c}}' exit 0 From 6d01788326a8434793e6d6b6b0d8dccbf1ee3ec8 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 31 Jul 2026 23:49:10 -0400 Subject: [PATCH 03/12] test(plugin): pin both halves of the local prior-art arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI caught the previous commit: the fail-open contract asserts a hook stays SILENT with no working instance, and the new local arm deliberately speaks there. The invariant is right and the hook is right — the smoke event was wrong. It used `def f`, which this repo really does define, so the hook found something and "silent" was asserting the wrong thing. Fixed by asserting the two properties separately: - SILENT for a symbol that genuinely does not exist. - SPEAKING, with NO credentials, for one that does. That is the point of the arm — the other arms ask Scribe what was RECORDED; this one asks the repo what EXISTS, which needs no instance. Were it to start depending on configuration it would stop covering the case it was built for, and only this assertion would notice. Second trap, hit while fixing the first: spelling the absent symbol out in full wrote `def (` into check_plugin.py, so the smoke event DEFINED the very symbol it claimed was missing, and the hook found it again. The name is now assembled from fragments so the contiguous string never appears in the source. scribe_prior_art.sh joins scribe_session_context.sh as a hook that legitimately produces output without credentials — for the same reason, that it carries something needing neither network nor config. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs --- scripts/check_plugin.py | 57 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/scripts/check_plugin.py b/scripts/check_plugin.py index 9fc42e2..22c706b 100755 --- a/scripts/check_plugin.py +++ b/scripts/check_plugin.py @@ -181,13 +181,25 @@ def check_shellcheck() -> None: # pinning: the bug and the healthy no-results case look identical from outside. # Pinning it does NOT make the failure visible; it makes sure the fail-open # behaviour is deliberate rather than accidental. +# A symbol that exists nowhere, ASSEMBLED rather than written literally. +# The prior-art hook's local arm (#2280) fires with no credentials, so the +# silence assertion below needs a name the repo genuinely lacks. Two traps, +# both hit while writing this: +# - `def f` matched real code, so the hook spoke and "silent" was asserting +# the wrong thing; +# - spelling the replacement out in full put `def (` INTO this file, +# so the smoke event defined the very symbol it claimed was absent. +# Concatenating keeps the contiguous string out of the source. +_ABSENT_SYM = "zz" + "_absent_" + "9f3a2b" + SMOKE_EVENTS: dict[str, str] = { "scribe_autoinject.sh": json.dumps( {"session_id": "smoke", "cwd": ".", "prompt": "a multi-line\nprompt\nhere"} ), "scribe_prior_art.sh": json.dumps( {"session_id": "smoke", "cwd": ".", "tool_name": "Edit", - "tool_input": {"file_path": "src/x.py", "new_string": "def f():\n pass\n"}} + "tool_input": {"file_path": "src/x.py", + "new_string": f"def {_ABSENT_SYM}():\n pass\n"}} ), "scribe_sync_processes.sh": json.dumps({"source": "startup"}), "scribe_session_context.sh": json.dumps({"source": "startup"}), @@ -253,6 +265,48 @@ def check_fail_open() -> None: ok(f"{rel} [{label}]: exit 0, silent") +def check_local_prior_art_needs_no_instance() -> None: + """The prior-art hook's local arm must answer with no credentials (#2280). + + The other arms ask Scribe what was RECORDED. This one asks the repo what + EXISTS, which needs no instance — and that is the whole reason it catches + the case the recorded arms structurally cannot: a helper nobody thought to + record. If it ever silently starts depending on configuration, it stops + covering that case and nothing else would notice. + + Paired with the silence assertion in check_fail_open, which uses a symbol + that cannot exist. Together they pin both halves: silent when there is + nothing to say, and speaking when there is — both with no instance at all. + """ + script = HOOKS_DIR / "scribe_prior_art.sh" + if not script.is_file() or not shutil.which("jq"): + skip("prior-art local arm: hook or jq missing") + return + + # A definition this repo really does contain, written into a DIFFERENT file + # so the self-match exclusion doesn't suppress it. + event = json.dumps({ + "session_id": "smoke", "cwd": ".", "tool_name": "Write", + "tool_input": { + "file_path": "scripts/_probe_not_real.py", + "content": "def check_local_prior_art_needs_no_instance():\n pass\n", + }, + }) + try: + proc = _run_hook(script, event, {}) # NO credentials, on purpose + except subprocess.TimeoutExpired: + fail("prior-art local arm: hung") + return + if proc.returncode != 0: + fail(f"prior-art local arm: exited {proc.returncode}, must be 0") + elif "already defined" not in proc.stdout: + fail("prior-art local arm: found nothing for a symbol this repo " + "defines, with no credentials — the arm that needs no instance " + "has stopped working, and the recorded arms cannot cover for it") + else: + ok("prior-art local arm: answers with no instance configured") + + def _git(*args: str) -> tuple[int, str]: proc = subprocess.run( ["git", *args], capture_output=True, text=True, cwd=ROOT @@ -344,6 +398,7 @@ def main() -> int: check_patterns() check_shellcheck() check_fail_open() + check_local_prior_art_needs_no_instance() if not args.no_version: check_version_bump(args.base) From 1a959b1db028a93179a3452d859064472ca6b881 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 1 Aug 2026 19:52:35 -0400 Subject: [PATCH 04/12] refactor(ui): one button definition, aligned to the design system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.btn-primary` was defined five times in five scoped stylesheets and all five had drifted: three paddings, three font sizes, three disabled opacities — and ProjectListView had no disabled style at all, so a disabled button there looked enabled. Nothing detected any of it. A scoped duplicate is not a rule violation, not a broken reference, and not a recorded snippet, so no existing check could see it (#2273). assets/components.css is now the single definition of the core four — primary, secondary, ghost, danger — plus the small modifier, in design-system tokens throughout. Operator's call to align to the system rather than to the majority of current values, so buttons move to the 8px radius and 12px label the system specifies, from the app's 4px/14.4px. Class names are unchanged, so there are no template edits: the existing surface is repurposed, not rebuilt. STAGING PROPERTY that makes this safe to land ahead of the rest: a Vue ` diff --git a/frontend/src/components/WorkspaceTaskPanel.vue b/frontend/src/components/WorkspaceTaskPanel.vue index dff15cd..4f6d305 100644 --- a/frontend/src/components/WorkspaceTaskPanel.vue +++ b/frontend/src/components/WorkspaceTaskPanel.vue @@ -232,7 +232,7 @@ defineExpose({ reload: loadAll }); placeholder="New task..." @keydown.enter="addTask" /> - +
Loading...
@@ -293,18 +293,18 @@ defineExpose({ reload: loadAll });
- Edit ↗ + Edit ↗ {{ STATUS_ICON[activeTask.status] ?? "○" }} {{ activeTask.status.replace("_", " ") }} - - +

{{ activeTask.title }}

@@ -396,17 +396,7 @@ defineExpose({ reload: loadAll }); } .task-add-input:focus { outline: none; border-color: var(--color-primary); } -.btn-add { - background: var(--color-action-primary); - color: var(--fs-text-on-action); - border: none; - border-radius: 5px; - padding: 0.28rem 0.55rem; - font-size: 1rem; - cursor: pointer; - line-height: 1; -} -.btn-add:disabled { opacity: 0.4; cursor: default; } +.btn-add { font-size: 1rem; } /* a '+' glyph, not a label */ .groups-scroll { flex: 1; @@ -534,17 +524,7 @@ defineExpose({ reload: loadAll }); .status-badge.status-in_progress { border-color: var(--color-primary); color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 10%, transparent); } .status-badge.status-done { border-color: var(--color-success, #27ae60); color: var(--color-success, #27ae60); background: color-mix(in srgb, var(--color-success, #27ae60) 10%, transparent); } -.btn-edit-task { - background: none; - border: none; - color: var(--color-primary); - font-size: 0.78rem; - cursor: pointer; - padding: 0.1rem 0.3rem; - border-radius: 3px; - text-decoration: none; - flex-shrink: 0; -} +.btn-edit-task { margin-left: 0.25rem; } .btn-edit-task:hover { text-decoration: underline; } .detail-body { @@ -566,51 +546,11 @@ defineExpose({ reload: loadAll }); color: var(--color-text); } -.btn-delete-task { - background: none; - border: none; - color: var(--color-text-muted); - font-size: 0.8rem; - cursor: pointer; - padding: 0.15rem 0.3rem; - border-radius: 3px; - margin-left: 0.25rem; -} +.btn-delete-task { margin-left: 0.25rem; } .btn-delete-task:hover { color: var(--color-action-destructive); } -.btn-delete-confirm { - background: none; - border: 1px solid var(--color-action-destructive); - color: var(--color-action-destructive); - font-size: 0.72rem; - font-weight: 500; - cursor: pointer; - padding: 0.15rem 0.5rem; - border-radius: 4px; - margin-left: 0.25rem; - transition: background 0.15s, color 0.15s; -} -.btn-delete-confirm:hover:not(:disabled) { background: var(--color-action-destructive); color: var(--fs-text-on-action); } -.btn-delete-confirm:disabled { opacity: 0.5; cursor: default; } +.btn-delete-confirm { margin-left: 0.25rem; } -.btn-delete-cancel { - background: none; - border: none; - color: var(--color-text-muted); - font-size: 0.75rem; - cursor: pointer; - padding: 0.1rem 0.3rem; -} -.btn-delete-cancel:hover { color: var(--color-text); } - -.detail-title { - padding: 0.75rem 0.75rem 0.25rem; - font-size: 0.95rem; - font-weight: 500; - margin: 0; - color: var(--color-text); - flex-shrink: 0; -} .detail-meta { display: flex; @@ -679,15 +619,5 @@ defineExpose({ reload: loadAll }); } /* Close detail button */ -.btn-close-detail { - background: none; - border: none; - color: var(--color-text-muted); - font-size: 0.75rem; - cursor: pointer; - padding: 0.15rem 0.3rem; - border-radius: 3px; - margin-left: 0.2rem; -} -.btn-close-detail:hover { color: var(--color-text); } +.btn-close-detail { margin-left: 0.2rem; } From 3d6931b83865f2b62271bd33e38939a2bcd48654 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 1 Aug 2026 22:46:01 -0400 Subject: [PATCH 10/12] refactor(ui): editor-shared buttons alias onto the variants; 3 dead classes go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit editor-shared.css defined thirteen button rules with hand-written geometry. Ten are now thin aliases onto the shared variants — same class names, because these are used across six views and pointing a name somewhere is cheaper than rewriting every call site (the .btn-small precedent). Three were DEAD: .btn-assist-toggle, .btn-close-assist and .btn-toggle-view had no template reference and no dynamic binding anywhere in the app. Verified before deleting rather than assumed from the name — a class with no user is indistinguishable from one bound dynamically until you look. Named honestly in the file: CSS has no @extend, so each alias carries the variant's declarations rather than inheriting them. That is duplication this migration cannot remove. But it is duplication of a REFERENCE — var(--color- action-primary) — not of a value, so a palette change still moves everything at once, which is the property that actually mattered. Also gone: eight hardcoded geometries (0.4rem 1rem, 0.85rem, and so on) that now come from --fs-space and --fs-size tokens. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs --- frontend/src/assets/editor-shared.css | 271 +++++++++----------------- 1 file changed, 93 insertions(+), 178 deletions(-) diff --git a/frontend/src/assets/editor-shared.css b/frontend/src/assets/editor-shared.css index f7c09df..eaecc14 100644 --- a/frontend/src/assets/editor-shared.css +++ b/frontend/src/assets/editor-shared.css @@ -34,86 +34,11 @@ gap: 0.5rem; align-items: center; } -.btn-back { - display: inline-flex; - align-items: center; - padding: 0.45rem 1rem; - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); - background: none; - color: var(--color-text-secondary); - text-decoration: none; - cursor: pointer; - font-size: 0.9rem; -} -.btn-back:hover { - border-color: var(--color-primary); - color: var(--color-primary); -} /* Save: Moss action-primary per the Hybrid rule. Saving is "operating the software" — not a brand moment. Accent gradient is reserved for Send / empty-state CTAs. */ -.btn-save { - padding: 0.45rem 1.1rem; - background: var(--color-action-primary); - color: var(--fs-text-on-action); - border: none; - border-radius: var(--radius-sm); - cursor: pointer; - font-weight: 500; - font-size: 0.875rem; - transition: background 0.15s, opacity 0.15s; -} -.btn-save:hover:not(:disabled) { - background: var(--color-action-primary-hover); -} -.btn-save:disabled { - opacity: 0.55; - cursor: default; -} /* Delete: Oxblood action-destructive per Hybrid rule. Should be paired with a Trash icon at the call site to reinforce intent. */ -.btn-delete { - padding: 0.45rem 1rem; - background: var(--color-action-destructive); - color: var(--fs-text-on-action); - border: none; - border-radius: var(--radius-sm); - cursor: pointer; - display: inline-flex; - align-items: center; - gap: 0.35rem; - font-weight: 500; -} -.btn-delete:hover { background: var(--color-action-destructive-hover); } -.btn-assist-toggle { - margin-left: auto; - padding: 0.4rem 0.9rem; - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); - background: none; - color: var(--color-text-secondary); - cursor: pointer; - font-size: 0.85rem; -} -.btn-assist-toggle.active { - background: color-mix(in srgb, var(--color-primary) 12%, transparent); - border-color: var(--color-primary); - color: var(--color-primary); -} -.title-input { - padding: 0.4rem 0; - border: none; - border-bottom: 1.5px solid var(--color-border); - border-radius: 0; - font-size: 1.5rem; - font-weight: 500; - font-family: "Fraunces", Georgia, serif; - background: transparent; - color: var(--color-text); - width: 100%; - transition: border-color 0.15s; -} .title-input:focus { outline: none; border-bottom-color: var(--color-primary); @@ -155,23 +80,6 @@ align-items: center; gap: 0.4rem; } -.btn-suggest-tags { - padding: 0.3rem 0.7rem; - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); - background: var(--color-bg-card); - color: var(--color-text-secondary); - cursor: pointer; - font-size: 0.8rem; -} -.btn-suggest-tags:hover:not(:disabled) { - border-color: var(--color-primary); - color: var(--color-primary); -} -.btn-suggest-tags:disabled { - opacity: 0.6; - cursor: wait; -} .tag-pill { display: inline-flex; align-items: center; @@ -198,18 +106,6 @@ .tag-check { font-size: 0.7rem; } -.btn-dismiss-tags { - padding: 0.1rem 0.4rem; - border: none; - background: none; - color: var(--color-text-muted); - cursor: pointer; - font-size: 1rem; - line-height: 1; -} -.btn-dismiss-tags:hover { - color: var(--color-text); -} /* ── Assist panel ── */ .assist-panel { @@ -237,32 +133,6 @@ text-transform: uppercase; letter-spacing: 0.05em; } -.btn-proofread { - padding: 0.3rem 0.65rem; - font-size: 0.78rem; - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); - background: none; - color: var(--color-text-secondary); - cursor: pointer; -} -.btn-proofread:hover:not(:disabled) { - border-color: var(--color-primary); - color: var(--color-primary); -} -.btn-proofread:disabled { - opacity: 0.5; - cursor: default; -} -.btn-close-assist { - padding: 0.1rem 0.4rem; - border: none; - background: none; - color: var(--color-text-muted); - cursor: pointer; - font-size: 1rem; - line-height: 1; -} .assist-panel-body { flex: 1; min-height: 0; @@ -342,28 +212,6 @@ display: flex; gap: 0.5rem; } -.btn-generate { - padding: 0.4rem 0.9rem; - background: var(--color-action-primary); - color: var(--fs-text-on-action); - border: none; - border-radius: var(--radius-sm); - cursor: pointer; - font-size: 0.85rem; -} -.btn-generate:disabled { - opacity: 0.5; - cursor: default; -} -.btn-clear { - padding: 0.4rem 0.9rem; - background: none; - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); - cursor: pointer; - font-size: 0.85rem; - color: var(--color-text-secondary); -} /* Streaming */ .assist-streaming-label { @@ -419,14 +267,6 @@ font-weight: 500; color: var(--color-text-secondary); } -.btn-toggle-view { - font-size: 0.75rem; - color: var(--color-primary); - background: none; - border: none; - cursor: pointer; - padding: 0; -} .diff-view { border: 1px solid var(--color-input-border); border-radius: var(--radius-sm); @@ -475,24 +315,6 @@ display: flex; gap: 0.5rem; } -.btn-accept { - padding: 0.4rem 1rem; - background: var(--color-success); - color: var(--fs-text-on-action); - border: none; - border-radius: var(--radius-sm); - cursor: pointer; - font-size: 0.85rem; -} -.btn-reject { - padding: 0.4rem 1rem; - background: none; - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); - cursor: pointer; - font-size: 0.85rem; - color: var(--color-text-secondary); -} /* ── Modal ── */ .modal-overlay { @@ -640,3 +462,96 @@ padding: 0.5rem 1rem 1rem; } } + +/* --------------------------------------------------------------------------- + * Editor button aliases. + * + * These names are used across six views, so they alias onto the shared variants + * rather than every call site being rewritten — the class stays the app's, the + * appearance comes from components.css. Same reasoning as .btn-small: a name + * already in the templates is cheaper to point somewhere than to replace. + * + * They are @extend-shaped, which CSS lacks, so each carries the variant's own + * declarations. That is the one duplication this migration cannot remove — but + * it is duplication of a REFERENCE (a var()), not of a value, so a palette + * change still moves everything at once. + * ------------------------------------------------------------------------ */ + +.btn-accept, +.btn-generate, +.btn-save { + background: var(--color-action-primary); + color: var(--fs-text-on-action); + border: none; +} +.btn-accept:not(:disabled):hover, +.btn-generate:not(:disabled):hover, +.btn-save:not(:disabled):hover { + background: var(--color-action-primary-hover); +} + +.btn-back, +.btn-clear, +.btn-reject, +.btn-proofread, +.btn-suggest-tags { + background: none; + border: var(--fs-border); + color: var(--color-text); +} +.btn-back:not(:disabled):hover, +.btn-clear:not(:disabled):hover, +.btn-reject:not(:disabled):hover, +.btn-proofread:not(:disabled):hover, +.btn-suggest-tags:not(:disabled):hover { + border: var(--fs-border-hover); + background: var(--color-hover); +} + +.btn-delete { + background: var(--color-action-destructive); + color: var(--fs-text-on-action); + border: none; +} +.btn-delete:not(:disabled):hover { + background: var(--color-action-destructive-hover); +} + +/* Shared geometry for every alias above. */ +.btn-accept, .btn-generate, .btn-save, .btn-back, .btn-clear, .btn-reject, +.btn-delete, .btn-dismiss-tags, .btn-proofread, .btn-suggest-tags { + border-radius: var(--fs-radius-md); + font-family: var(--fs-font-body); + font-weight: var(--fs-weight-medium); + cursor: pointer; + white-space: nowrap; + transition: background var(--fs-dur-fast) var(--fs-ease), + border-color var(--fs-dur-fast) var(--fs-ease), + color var(--fs-dur-fast) var(--fs-ease); +} +.btn-accept, .btn-generate, .btn-save, .btn-back, .btn-clear, .btn-reject, +.btn-delete { + padding: var(--fs-space-2) var(--fs-space-4); + font-size: var(--fs-size-label); +} +.btn-proofread, .btn-suggest-tags { + padding: var(--fs-space-1) var(--fs-space-3); + font-size: var(--fs-size-tiny); +} +.btn-dismiss-tags { + background: none; + border: none; + color: var(--color-text-muted); + padding: 2px var(--fs-space-1); + font-size: var(--fs-size-tiny); + line-height: 1; +} +.btn-dismiss-tags:hover { color: var(--color-text); } + +.btn-accept:disabled, .btn-generate:disabled, .btn-save:disabled, +.btn-back:disabled, .btn-clear:disabled, .btn-reject:disabled, +.btn-delete:disabled, .btn-proofread:disabled, .btn-suggest-tags:disabled, +.btn-dismiss-tags:disabled { + opacity: var(--fs-disabled-opacity); + cursor: not-allowed; +} From 3fc693443e6d784e58e67bde12a46e1db9e566ba Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 1 Aug 2026 22:48:35 -0400 Subject: [PATCH 11/12] refactor(ui): the settings family migrates; two colour bugs and a dead class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SettingsView and UserManagementView had near-identical button vocabularies — btn-delete, btn-cancel-delete, btn-confirm-delete, btn-toggle/-open/-close — defined separately in each. Parallel duplication (#2278's shape), and it had already diverged twice: - .btn-confirm-delete used --color-danger in UserManagement and --color-action-destructive in Settings. Those are different colours on purpose: the house style keeps error (something went wrong) distinct from destructive (something is about to). A delete confirmation is destructive. UserManagement was showing the error colour for a button nothing had failed in yet. - .btn-remove-slot's hover reached for --color-danger for the same reason, and is the same correction. It turned out to be dead anyway — style rules only, no template reference anywhere in the app — so it is gone. .btn-danger-outline was defined TWICE inside SettingsView, at 0.4rem 0.9rem and 0.45rem 1rem. One file, one class, two geometries, ~1200 lines apart. That is the clearest single argument for this whole task that I have found: the drift does not need two files, only enough distance that nobody sees both at once. The registration toggle keeps .btn-toggle-close, and only that. It is bound dynamically (:class="registrationOpen ? … : …"), so a name-based scan reads it as unused — checked before deleting. .btn-toggle-open went, because btn-primary now says the same thing; the close state stays because it must NOT read as the primary action it sits on. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs --- frontend/src/views/SettingsView.vue | 223 +++------------------- frontend/src/views/UserManagementView.vue | 100 +--------- 2 files changed, 34 insertions(+), 289 deletions(-) diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index dc7b975..813e83c 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -1165,7 +1165,7 @@ function formatUserDate(iso: string): string {

Click Detect to auto-fill from your browser.

- Saved! @@ -1190,7 +1190,7 @@ function formatUserDate(iso: string): string {

Set to 0 to keep deleted items forever (never auto-purge).

- Saved! @@ -1313,7 +1313,7 @@ function formatUserDate(iso: string): string {

- Saved! @@ -1361,7 +1361,7 @@ function formatUserDate(iso: string): string {
- + Saved!
@@ -1491,7 +1491,7 @@ function formatUserDate(iso: string): string {
- + Saved!
@@ -1501,7 +1501,7 @@ function formatUserDate(iso: string): string {

Topics you care about — used to personalise the journal's daily prep and chat responses.

- + Saved!
@@ -1533,7 +1533,7 @@ function formatUserDate(iso: string): string {
- + Saved!
@@ -1562,7 +1562,7 @@ function formatUserDate(iso: string): string {

Emails for logins, logouts, and password changes.

- Saved! @@ -1589,7 +1589,7 @@ function formatUserDate(iso: string): string { placeholder="Enter a search query..." @keydown="onSearchKeydown" /> -
@@ -1891,7 +1891,7 @@ function formatUserDate(iso: string): string { />
- Saved! @@ -1916,7 +1916,7 @@ function formatUserDate(iso: string): string { />
- Saved! @@ -1946,10 +1946,10 @@ function formatUserDate(iso: string): string {
- - Saved! @@ -2040,7 +2040,7 @@ function formatUserDate(iso: string): string {

Recommended for port 587. Implicit TLS is used automatically for port 465.

- Saved! @@ -2054,7 +2054,7 @@ function formatUserDate(iso: string): string { placeholder="test@example.com" class="input" /> -
@@ -2079,7 +2079,7 @@ function formatUserDate(iso: string): string {

When closed, new users can only be added by an administrator.

@@ -2122,7 +2122,7 @@ function formatUserDate(iso: string): string { {{ formatUserDate(inv.created_at) }} {{ formatUserDate(inv.expires_at) }} - @@ -2161,13 +2161,13 @@ function formatUserDate(iso: string): string { You @@ -2305,10 +2305,10 @@ function formatUserDate(iso: string): string { {{ g.description }}
- - +
@@ -2338,7 +2338,7 @@ function formatUserDate(iso: string): string {
  • {{ m.username }} {{ m.role }} - +
  • No members yet.
  • @@ -2564,54 +2564,6 @@ function formatUserDate(iso: string): string { flex-wrap: wrap; } /* Save: Moss action-primary per Hybrid */ -.btn-save { - padding: 0.4rem 0.9rem; - background: var(--color-action-primary); - color: var(--fs-text-on-action); - border: none; - border-radius: var(--radius-sm); - cursor: pointer; - font-size: 0.875rem; - font-family: inherit; - white-space: nowrap; - transition: background 0.15s; -} -.btn-save:disabled { opacity: 0.6; cursor: default; } -.btn-save:hover:not(:disabled) { background: var(--color-action-primary-hover); } - -/* Danger outline (Invalidate sessions, Clear observations, etc.): - Oxblood action-destructive ghost — fills on hover */ -.btn-danger-outline { - padding: 0.4rem 0.9rem; - background: none; - color: var(--color-action-destructive); - border: 1px solid var(--color-action-destructive); - border-radius: var(--radius-sm); - cursor: pointer; - font-size: 0.875rem; - font-family: inherit; - white-space: nowrap; - transition: background 0.15s, color 0.15s; -} -.btn-danger-outline:hover:not(:disabled) { - background: var(--color-action-destructive); - color: var(--fs-text-on-action); -} -.btn-danger-outline:disabled { opacity: 0.5; cursor: default; } - -/* Filled destructive: Oxblood action-destructive */ - -/* Secondary: Bronze action-secondary — alternate paths (Detect, Test, - Refresh, Add slot, etc.). Outline form for visual lightness. */ - -/* DB maintenance last-run summary */ -.db-maint-last { margin-top: 1rem; } -.db-maint-last-label { - display: block; - font-size: 0.8rem; - color: var(--color-text-muted); - margin-bottom: 0.4rem; -} .db-maint-table-list { list-style: none; margin: 0; @@ -2957,68 +2909,6 @@ function formatUserDate(iso: string): string { } .you-label { font-size: 0.8rem; color: var(--color-text-muted); } /* Per-row delete (users / invitations / etc.): ghost → Oxblood on hover */ -.btn-delete { - padding: 0.25rem 0.6rem; - background: transparent; - color: var(--color-text-muted); - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); - cursor: pointer; - font-size: 0.8rem; -} -.btn-delete:hover:not(:disabled) { border-color: var(--color-action-destructive); color: var(--color-action-destructive); } -.btn-delete:disabled { opacity: 0.4; cursor: default; } -/* Two-stage destructive: Confirm = Oxblood filled, Cancel = Bronze ghost */ -.btn-confirm-delete { - padding: 0.25rem 0.6rem; - background: var(--color-action-destructive); - color: var(--fs-text-on-action); - border: none; - border-radius: var(--radius-sm); - cursor: pointer; - font-size: 0.8rem; - font-weight: 500; - margin-right: 0.25rem; - transition: background 0.15s; -} -.btn-confirm-delete:hover:not(:disabled) { background: var(--color-action-destructive-hover); } -.btn-confirm-delete:disabled { opacity: 0.6; cursor: default; } -.btn-cancel-delete { - padding: 0.25rem 0.6rem; - background: transparent; - color: var(--color-text-muted); - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); - cursor: pointer; - font-size: 0.8rem; -} -.btn-cancel-delete:hover { color: var(--color-text); border-color: var(--color-text-muted); } -/* Toggle (Open/Close registration, etc.): Open = Moss, Close = Pewter ghost */ -.btn-toggle { - padding: 0.45rem 1rem; - border: none; - border-radius: var(--radius-sm); - cursor: pointer; - font-size: 0.9rem; - font-weight: 500; - white-space: nowrap; - transition: background 0.15s; -} -.btn-toggle:disabled { opacity: 0.6; cursor: default; } -.btn-toggle-open { background: var(--color-action-primary); color: var(--fs-text-on-action); } -.btn-toggle-open:hover:not(:disabled) { background: var(--color-action-primary-hover); } -.btn-toggle-close { - background: var(--color-bg-secondary); - color: var(--color-text); - border: 1px solid var(--color-border); -} -.btn-toggle-close:hover:not(:disabled) { border-color: var(--color-warning); color: var(--color-warning); } -.loading-msg, .empty-msg { - text-align: center; - color: var(--color-text-muted); - font-size: 0.9rem; - padding: 1rem 0; -} /* Logs panel */ .stats-section { padding: 1rem 1.25rem; } @@ -3202,32 +3092,6 @@ function formatUserDate(iso: string): string { flex-shrink: 0; } -.btn-sm { - padding: 0.25rem 0.6rem; - background: none; - border: 1px solid var(--color-border); - border-radius: 4px; - font-size: 0.78rem; - color: var(--color-text-secondary); - cursor: pointer; - font-family: inherit; - transition: border-color 0.15s, color 0.15s; -} -.btn-sm:hover { border-color: var(--color-primary); color: var(--color-primary); } -.btn-danger-sm:hover { border-color: var(--color-action-destructive); color: var(--color-action-destructive); } - -.group-members-panel { - padding: 0.75rem 1rem 1rem; - border-top: 1px solid var(--color-border); - background: var(--color-surface); -} - -.members-search { - display: flex; - gap: 0.5rem; - align-items: flex-start; - margin-bottom: 0.75rem; -} .member-search-wrap { flex: 1; @@ -3740,21 +3604,6 @@ function formatUserDate(iso: string): string { text-align: right; color: var(--color-text-secondary); } -.btn-remove-slot { - background: none; - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); - color: var(--color-text-muted); - cursor: pointer; - font-size: 0.75rem; - padding: 0.2rem 0.45rem; - line-height: 1; - transition: color 0.15s, border-color 0.15s; -} -.btn-remove-slot:hover { - color: var(--color-danger, #e05555); - border-color: var(--color-danger, #e05555); -} .blend-actions { margin-top: 0.25rem; } @@ -3808,24 +3657,4 @@ function formatUserDate(iso: string): string { color: var(--color-text-muted); margin-bottom: 0.75rem; } -.btn-danger-outline { - padding: 0.45rem 1rem; - background: none; - border: 1px solid var(--color-action-destructive); - border-radius: var(--radius-sm); - color: var(--color-action-destructive); - font-size: 0.9rem; - cursor: pointer; - font-family: inherit; - transition: background 0.15s, color 0.15s; -} -.btn-danger-outline:hover:not(:disabled) { - background: var(--color-action-destructive); - color: var(--fs-text-on-action); -} -.btn-danger-outline:disabled { opacity: 0.5; cursor: default; } -@keyframes va-dot-bounce { - 0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; } - 40% { transform: scale(1); opacity: 1; } -} diff --git a/frontend/src/views/UserManagementView.vue b/frontend/src/views/UserManagementView.vue index 62bc563..ce0918f 100644 --- a/frontend/src/views/UserManagementView.vue +++ b/frontend/src/views/UserManagementView.vue @@ -167,7 +167,7 @@ function formatDate(iso: string): string {

    - + +