diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json
index f8ac872..794ecf9 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.44",
+ "version": "0.1.45",
"author": { "name": "Bryan Van Deusen" },
"mcpServers": {
"scribe": {
diff --git a/tests/test_integration_shape_classify.py b/tests/test_integration_shape_classify.py
index 12f06db..034cfdc 100644
--- a/tests/test_integration_shape_classify.py
+++ b/tests/test_integration_shape_classify.py
@@ -683,6 +683,58 @@ async def test_derive_new_names_the_copy_that_joined_a_family_since_the_stamp(se
assert derive_new_summary(rows, since=None)["count"] == 0
+@pytest.mark.integration
+async def test_transition_and_prefix_references_clear_the_unused_css_flag(seeded):
+ """#2970 end to end: the two class forms a template never spells out —
+ a transition `name=` and a concatenated name — travel from the real
+ extractor through resolution into the flag, so rules they reach stop
+ being reported as unused. Only a rule nothing can reach stays listed."""
+ from scribe.services.coverage import class_references
+ from scribe.services.shape_ledger import sync_repo_consumers, used_by_map, live_rows
+
+ pid, owner = seeded["pid"], seeded["owner"]
+ defs = _defs(
+ ("assets/anim.css", "css", "toast-enter-active",
+ ".toast-enter-active {", ".toast-enter-active { opacity: 0 }"),
+ ("assets/anim.css", "css", "toast-leave-to",
+ ".toast-leave-to {", ".toast-leave-to { opacity: 0 }"),
+ ("assets/state.css", "css", "status-done",
+ ".status-done {", ".status-done { color: green }"),
+ ("assets/state.css", "css", "status-todo",
+ ".status-todo {", ".status-todo { color: grey }"),
+ ("assets/state.css", "css", "really-dead",
+ ".really-dead {", ".really-dead { color: red }"),
+ )
+ await sync_repo_shapes(pid, REPO, defs, seen_marker="t1")
+
+ refs = {
+ # Vue applies the toast-* classes itself; the markup names none of them.
+ "v/Toast.vue": class_references(
+ "v/Toast.vue", ''
+ ),
+ # The board builds its status class; which one is unknowable.
+ "v/Board.vue": class_references("v/Board.vue", ''),
+ }
+ assert "toast-enter-active" in refs["v/Toast.vue"]
+ assert refs["v/Board.vue"] == {"status-*": 1}
+
+ await sync_repo_consumers(pid, REPO, refs)
+ live = [r for r in await live_rows(pid) if r.kind == "css"]
+ used = await used_by_map(live)
+ by = {(r.path, r.symbol): used[r.id]["count"] for r in live}
+ assert by[("assets/anim.css", "toast-enter-active")] == 1
+ assert by[("assets/anim.css", "toast-leave-to")] == 1
+ # the prefix credits BOTH candidates — the template does not say which
+ assert by[("assets/state.css", "status-done")] == 1
+ assert by[("assets/state.css", "status-todo")] == 1
+ assert by[("assets/state.css", "really-dead")] == 0
+
+ unused, n = await list_project_shapes(owner, pid, flag="unused-css")
+ assert n == 1 and [(r.path, r.symbol) for r in unused] == [
+ ("assets/state.css", "really-dead")
+ ]
+
+
@pytest.mark.integration
async def test_consumer_map_syncs_edges_from_template_references(seeded):
"""Milestone 302: the consumer edges follow the archive — own-file