Files
FabledScribe/frontend/src/components/rules/PreferenceDriftPane.vue
T
bvandeusenandClaude Opus 5.5 c787957ddf
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 52s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Successful in 1m36s
CI & Build / Build & push image (push) Canceled after 35s
fix(frontend): load sweep-shared.css once from main.ts — a shared <style src> breaks vite build (#3207)
plugin-vue throws "Cannot read properties of undefined (reading 'scoped')"
when several SFCs share one src stylesheet. vue-tsc passed, so only the
image job caught it; :dev has not rebuilt since 22b7a92.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 06:50:59 -04:00

197 lines
8.0 KiB
Vue

<script setup lang="ts">
/**
* What Scribe has changed about how it works with you.
*
* THE RISK THIS EXISTS FOR (milestone 399). A preference is the one record
* kind the agent rewrites on its own, mid-work, without asking — which is
* what keeps it current and what makes it dangerous. An agent misreads one
* session, rewrites a preference, and follows the rewritten version forever
* while the operator never sees the moment it changed. That is worse than
* having no preference at all: a confident wrong answer wearing the
* operator's own authority.
*
* `rule_versions` already recorded every rewrite. What it could not do is
* ARRIVE. A history you open one rule at a time, having first suspected that
* rule, is not oversight — so this pane is the PUSH half, and it sits beside
* the staleness sweep for the same reason that does: drift belongs to no one
* rulebook.
*
* Cross-cutting, and deliberately not a filter on the per-topic rule list —
* that list shows one topic of one rulebook, so filtering it would silently
* under-report, which is the exact failure this surface exists to catch.
*/
import { onMounted, ref } from "vue";
import DiffView from "@/components/DiffView.vue";
import { computeDiff } from "@/utils/diff";
import { useRulebooksStore } from "@/stores/rulebooks";
import { useToastStore } from "@/stores/toast";
import type { PreferenceDrift } from "@/api/rulebooks";
const emit = defineEmits<{ "open-rule": [id: number] }>();
const store = useRulebooksStore();
const toast = useToastStore();
const openId = ref<number | null>(null);
const busyId = ref<number | null>(null);
/** Old on the left, new on the right — the direction a reader expects of
* "what changed", and the opposite of the rule history panel, which is
* answering "what did it used to say" from the current text backwards. */
function diffFor(row: PreferenceDrift) {
return computeDiff(row.previous.statement, row.current.statement);
}
function triggerChanged(row: PreferenceDrift): boolean {
return row.previous.when_to_apply !== row.current.when_to_apply;
}
function stamp(iso: string | null): string {
return iso ? iso.slice(0, 10) : "";
}
function toggle(row: PreferenceDrift) {
openId.value = openId.value === row.rule.id ? null : row.rule.id;
}
/** The veto. One action, because a veto that costs more than shrugging is
* not really supervision — and nothing is lost either way: the restore is
* itself an edit, so the rewrite stays in the preference's history with the
* revert recorded after it. */
async function restore(row: PreferenceDrift) {
busyId.value = row.rule.id;
try {
await store.restoreVersion(row.rule.id, row.previous.id);
toast.show(`Put “${row.previous.title}” back`, "success");
openId.value = null;
} catch {
toast.show("Could not put that wording back", "error");
} finally {
busyId.value = null;
}
}
onMounted(() => store.fetchDrift());
</script>
<template>
<section class="pane drift">
<header>
<h2>Recent changes</h2>
<p class="sweep-lede">
Preferences Scribe rewrote while working, most recently changed first. A
preference is how you want work done, so sessions keep it current
without asking — this is where you see what they decided. Rules are not
here: those change when you change them.
</p>
</header>
<p v-if="store.loading" class="sweep-state">Loading…</p>
<!-- Nothing changed is the ordinary state and must not read as a fault. -->
<p v-else-if="!store.drift.length" class="sweep-state empty">
Nothing has been rewritten. A preference appears here the first time a
session changes one — until then there is nothing to review.
</p>
<ol v-else class="sweep-rows">
<li v-for="row in store.drift" :key="row.rule.id" class="sweep-row">
<div class="sweep-row-head">
<button class="sweep-row-title" @click="emit('open-rule', row.rule.id)">
{{ row.rule.title }}
</button>
<span class="when">{{ stamp(row.previous.created_at) }}</span>
</div>
<!-- The provenance, named rather than numbered: a bare id reads as
complete to the writer and as homework to the reader. -->
<p v-if="row.taught_by" class="taught">
Learned from <em>{{ row.taught_by.title }}</em>
<span class="taught-id">#{{ row.taught_by.id }}</span>
</p>
<p v-else class="taught untaught">
Nothing recorded what taught this change.
</p>
<button class="expand" :aria-expanded="openId === row.rule.id" @click="toggle(row)">
{{ openId === row.rule.id ? "Hide what changed" : "See what changed" }}
</button>
<div v-if="openId === row.rule.id" class="detail">
<p v-if="triggerChanged(row)" class="trigger-moved">
Its trigger changed too, so it now arrives at a different moment.
<span class="was">Was:</span> {{ row.previous.when_to_apply || "nothing" }}
</p>
<DiffView v-if="diffFor(row).length" :diff="diffFor(row)" />
<p v-else class="sweep-state">
The statement is unchanged — this edit moved another field.
</p>
<div class="sweep-actions">
<button
:disabled="busyId === row.rule.id"
@click="restore(row)"
>Put the old wording back</button>
<span class="actions-note">
Kept, not erased: this is recorded as another edit, so both
wordings stay in the preference's history.
</span>
</div>
</div>
</li>
</ol>
<p v-if="store.drift.length" class="footnote">
One row per preference, carrying its latest rewrite. A preference changed
several times shows the most recent one — its full history is in its
editor, under <strong>Edit history</strong>.
</p>
</section>
</template>
<style src="@/assets/rules-shared.css" />
<style scoped>
.drift { display: flex; flex-direction: column; gap: var(--fs-space-3); }
.when {
margin-left: auto; font-size: var(--fs-size-tiny);
color: var(--fs-text-secondary); font-variant-numeric: tabular-nums;
}
.taught {
margin: var(--fs-space-2) 0 0; font-size: var(--fs-size-tiny);
color: var(--fs-text-secondary); line-height: var(--fs-leading-body);
}
.taught em { font-style: italic; color: var(--fs-text-primary); }
.taught-id { margin-left: 0.35rem; color: var(--fs-text-tertiary); font-variant-numeric: tabular-nums; }
/* Not a warning: every preference written before provenance was required has
none, and marking those as faults would cry wolf on the whole backlog. */
.taught.untaught { color: var(--fs-text-tertiary); font-style: italic; }
.expand {
align-self: flex-start; margin-top: var(--fs-space-2);
background: none; border: none; padding: 0; cursor: pointer;
font: inherit; font-size: var(--fs-size-tiny); color: var(--fs-text-secondary);
}
.expand:hover { color: var(--fs-text-primary); text-decoration: underline; }
.detail { margin-top: var(--fs-space-2); display: flex; flex-direction: column; gap: var(--fs-space-2); }
/* A TINT, not the solid token — `--fs-warning-fg` is defined as warning text
ON a warning tint, and painting it over solid `--fs-warning` is the
same-hue contrast failure #3141 records. */
.trigger-moved {
margin: 0; font-size: var(--fs-size-tiny); line-height: var(--fs-leading-body);
color: var(--fs-warning-fg);
background: color-mix(in srgb, var(--fs-warning) 12%, transparent);
border-radius: var(--fs-radius-sm); padding: var(--fs-space-2);
}
.was { color: var(--fs-text-tertiary); }
.actions-note {
flex: 1; min-width: 18ch; font-size: var(--fs-size-tiny);
color: var(--fs-text-tertiary); line-height: var(--fs-leading-body);
}
.footnote { margin: 0; max-width: 62ch; font-size: var(--fs-size-tiny); color: var(--fs-text-tertiary); line-height: var(--fs-leading-body); }
/* Drift's buttons sit beside a note inside the expanded detail, not under a
row of facts. */
.sweep-actions { align-items: baseline; gap: var(--fs-space-3); flex-wrap: wrap; margin-top: 0; }
</style>