feat(rules): the check is editable, visible, and sweepable in the UI (#3098, milestone 312 step 4)
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 29s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 1m13s
CI & Build / Build & push image (push) Successful in 37s

Rule 27 — the milestone was backend-only until this. Four surfaces:

RULE EDITOR — verify_with and expires_when under a legend that asks the
actual question ("Can this rule go stale?") and says empty is the normal
answer, because most rules are decisions and a form that implies a missing
field would get them filled in out of tidiness. When the SAVED rule carries
a check, the stamp shows with Still true / No longer true beside it. The
stamp reads the stored value, not the draft: an unsaved edit to the textarea
has not been run against anything.

SWEEP PANE — its own surface, not a filter on the rule list. That list can
only ever show one topic of one rulebook, and a rule that has gone false
belongs to no one rulebook; filtering it would under-report, which is the
failure this whole surface exists to catch. Reached from the rulebook list,
below the rulebooks, because that is where you go to look at rules.

RULE ROWS — a chip only on rules carrying a check, so its presence is the
signal. PROJECT RULES TAB — the check shows beside `why` when a rule has
one, read-only: that tab is the project's view of what binds it.

NO AGE-GRADED COLOUR anywhere, deliberately. The sweep is already ordered by
urgency, so a red/amber ramp would restate the ordering AND require an
invented "stale after N days" threshold — a magic number nobody could defend
and the first thing to go out of date. --fs-overdue is error red and reserved
for a broken promise like a missed due date; a verification age is not one,
and colouring it that way makes a rule someone just wrote look broken. Only
"never" is marked, because it is categorically different from a date rather
than a worse one — and it is marked by weight, not hue.

An empty sweep says "Nothing to check", not nothing: good news must not read
as a broken page.

Two chips (tier, then verification) turned out byte-identical, so .rule-chip
moves to rules-shared.css and snippet #2906 is updated to match rather than
left describing a file that has moved on. Its header comment counted the
panes it served; that count went stale the moment a fourth arrived, so it no
longer counts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-27 11:53:35 -04:00
co-authored by Claude Opus 5
parent 9d7485df2d
commit c83bedf3be
8 changed files with 447 additions and 21 deletions
+20 -2
View File
@@ -1,5 +1,7 @@
/* Shared by the three rules panes (RulebookListPane, RuleListPane, /* Shared by the rules panes (RulebookListPane, RuleListPane,
RulebookDetailPane): the pane surface and its heading. Load with RulebookDetailPane, RuleSweepPane): the pane surface, its heading, and the
title chip. Counting them in this comment went stale the first time a
fourth was added, so it no longer does. Load with
<style src="@/assets/rules-shared.css" /> beside the component's own <style src="@/assets/rules-shared.css" /> beside the component's own
scoped block; never restate these there (#2903, milestone 299). */ scoped block; never restate these there (#2903, milestone 299). */
.pane { .pane {
@@ -13,3 +15,19 @@
margin: 0 0 0.5rem 0; margin: 0 0 0.5rem 0;
} }
.form-buttons { display: flex; gap: 0.5rem; } .form-buttons { display: flex; gap: 0.5rem; }
/* A small marker beside a rule's title. Two of these appeared within one
milestone (tier, then verification) and were byte-identical; a third would
have drifted. The pane's italic serif title is inherited by anything inside
it, so the chip resets family and style explicitly. */
.rule-chip {
margin-left: 0.4rem;
font-family: var(--fs-font-body);
font-style: normal;
font-size: 0.62rem;
color: var(--fs-text-secondary);
background: var(--fs-surface-raised);
border-radius: var(--fs-radius-pill);
padding: 0.05rem 0.4rem;
vertical-align: middle;
}
@@ -24,7 +24,10 @@ const allRulebooks = ref<Rulebook[]>([]);
const showPicker = ref(false); const showPicker = ref(false);
const expandedRuleIds = ref<Set<number>>(new Set()); const expandedRuleIds = ref<Set<number>>(new Set());
const ruleDetails = ref<Record<number, { why: string; how_to_apply: string }>>({}); const ruleDetails = ref<Record<number, {
why: string; how_to_apply: string;
verify_with: string; expires_when: string; verified_at: string | null;
}>>({});
const showProjectRuleForm = ref(false); const showProjectRuleForm = ref(false);
const newProjectRule = ref({ const newProjectRule = ref({
@@ -67,6 +70,9 @@ async function toggleRuleExpand(ruleId: number) {
ruleDetails.value[ruleId] = { ruleDetails.value[ruleId] = {
why: rule.why || "", why: rule.why || "",
how_to_apply: rule.how_to_apply || "", how_to_apply: rule.how_to_apply || "",
verify_with: rule.verify_with || "",
expires_when: rule.expires_when || "",
verified_at: rule.verified_at,
}; };
} }
} }
@@ -74,6 +80,11 @@ async function toggleRuleExpand(ruleId: number) {
expandedRuleIds.value = new Set(expandedRuleIds.value); expandedRuleIds.value = new Set(expandedRuleIds.value);
} }
/** "never run" reads as a stronger claim than an absent date — and it is. */
function checkAge(verifiedAt: string | null): string {
return verifiedAt ? `last passed ${verifiedAt.slice(0, 10)}` : "never run";
}
function openInRulesView(rulebookId: number, ruleId?: number) { function openInRulesView(rulebookId: number, ruleId?: number) {
const query: Record<string, string> = { rb: String(rulebookId) }; const query: Record<string, string> = { rb: String(rulebookId) };
if (ruleId) query.rule = String(ruleId); if (ruleId) query.rule = String(ruleId);
@@ -279,6 +290,16 @@ watch(() => props.projectId, load);
<div v-if="ruleDetails[r.id].how_to_apply"> <div v-if="ruleDetails[r.id].how_to_apply">
<strong>How to apply:</strong> {{ ruleDetails[r.id].how_to_apply }} <strong>How to apply:</strong> {{ ruleDetails[r.id].how_to_apply }}
</div> </div>
<!-- Shown only when the rule carries a check. Read-only here: this
tab is the project's view of what binds it, and editing a rule
belongs on the rulebook surface that owns it. -->
<div v-if="ruleDetails[r.id].verify_with">
<strong>Check:</strong> {{ ruleDetails[r.id].verify_with }}
<span class="rule-check-age">{{ checkAge(ruleDetails[r.id].verified_at) }}</span>
</div>
<div v-if="ruleDetails[r.id].expires_when">
<strong>Ends when:</strong> {{ ruleDetails[r.id].expires_when }}
</div>
<button class="delete-link" @click="removeProjectRule(r.id)">Delete</button> <button class="delete-link" @click="removeProjectRule(r.id)">Delete</button>
</div> </div>
</li> </li>
@@ -332,6 +353,13 @@ watch(() => props.projectId, load);
<div v-if="ruleDetails[r.id].how_to_apply"> <div v-if="ruleDetails[r.id].how_to_apply">
<strong>How to apply:</strong> {{ ruleDetails[r.id].how_to_apply }} <strong>How to apply:</strong> {{ ruleDetails[r.id].how_to_apply }}
</div> </div>
<div v-if="ruleDetails[r.id].verify_with">
<strong>Check:</strong> {{ ruleDetails[r.id].verify_with }}
<span class="rule-check-age">{{ checkAge(ruleDetails[r.id].verified_at) }}</span>
</div>
<div v-if="ruleDetails[r.id].expires_when">
<strong>Ends when:</strong> {{ ruleDetails[r.id].expires_when }}
</div>
<button <button
class="edit-link" class="edit-link"
@click="openInRulesView(r.rulebook_id, r.id)" @click="openInRulesView(r.rulebook_id, r.id)"
@@ -428,6 +456,11 @@ ul { list-style: none; padding: 0; margin: 0; }
} }
.rule-head { cursor: pointer; } .rule-head { cursor: pointer; }
.rule-title { font-weight: 500; } .rule-title { font-weight: 500; }
.rule-check-age {
margin-left: var(--fs-space-2);
color: var(--fs-text-tertiary);
font-variant-numeric: tabular-nums;
}
.rule-statement { display: block; opacity: 0.85; margin-top: 0.25rem; } .rule-statement { display: block; opacity: 0.85; margin-top: 0.25rem; }
.rule-detail { .rule-detail {
margin-top: 0.5rem; padding: 0.5rem; margin-top: 0.5rem; padding: 0.5rem;
@@ -16,6 +16,8 @@ const tier = ref<RuleTier>("always_on");
const systemIds = ref<number[]>([]); const systemIds = ref<number[]>([]);
const why = ref(""); const why = ref("");
const howToApply = ref(""); const howToApply = ref("");
const verifyWith = ref("");
const expiresWhen = ref("");
const relations = computed(() => store.currentRule?.relations ?? []); const relations = computed(() => store.currentRule?.relations ?? []);
@@ -38,6 +40,27 @@ function toggleSystem(id: number) {
const isCreating = ref(props.ruleId === null); const isCreating = ref(props.ruleId === null);
// The stored stamp, not the draft: it describes the check that was RUN, and
// an unsaved edit to the textarea has not been run against anything.
const verifiedAt = computed(() => store.currentRule?.verified_at ?? null);
const savedCheck = computed(() => store.currentRule?.verify_with ?? "");
// Built here rather than in the template: same shape as the server's
// last_verified_label, and it keeps the null-narrowing in TypeScript's reach.
const stampLabel = computed(() =>
verifiedAt.value ? `Last checked ${verifiedAt.value.slice(0, 10)}` : "Never checked",
);
const verifying = ref(false);
async function verify(stillTrue: boolean) {
if (props.ruleId === null) return;
verifying.value = true;
try {
await store.verifyRule(props.ruleId, stillTrue);
} finally {
verifying.value = false;
}
}
async function load() { async function load() {
if (props.ruleId !== null) { if (props.ruleId !== null) {
await store.fetchRule(props.ruleId); await store.fetchRule(props.ruleId);
@@ -50,6 +73,8 @@ async function load() {
systemIds.value = (r.systems ?? []).map((sys) => sys.id); systemIds.value = (r.systems ?? []).map((sys) => sys.id);
why.value = r.why || ""; why.value = r.why || "";
howToApply.value = r.how_to_apply || ""; howToApply.value = r.how_to_apply || "";
verifyWith.value = r.verify_with || "";
expiresWhen.value = r.expires_when || "";
} }
} else { } else {
title.value = ""; title.value = "";
@@ -59,6 +84,8 @@ async function load() {
systemIds.value = []; systemIds.value = [];
why.value = ""; why.value = "";
howToApply.value = ""; howToApply.value = "";
verifyWith.value = "";
expiresWhen.value = "";
} }
await canon.fetchCatalog(); await canon.fetchCatalog();
} }
@@ -78,6 +105,11 @@ async function save() {
system_ids: systemIds.value, system_ids: systemIds.value,
why: why.value, why: why.value,
how_to_apply: howToApply.value, how_to_apply: howToApply.value,
// Always sent, including empty. The REST door maps "" to NULL, so
// clearing a field here actually clears it — the MCP door's "" means
// "leave unchanged" and needs an explicit clear_fields list instead.
verify_with: verifyWith.value,
expires_when: expiresWhen.value,
}; };
if (isCreating.value && props.topicId !== null) { if (isCreating.value && props.topicId !== null) {
await store.createRule(props.topicId, fields); await store.createRule(props.topicId, fields);
@@ -162,6 +194,45 @@ watch(() => props.ruleId, load);
</p> </p>
</fieldset> </fieldset>
<fieldset class="check">
<legend>Can this rule go stale?</legend>
<p class="tier-test intro">
Most rules are <em>decisions</em> they have no truth value and change only when you
change them. Leave this empty for those. Fill it in when the rule asserts a
<em>fact</em> about something outside your control, because those go false quietly.
</p>
<label>
How to check it is still true
<textarea
v-model="verifyWith"
rows="2"
placeholder="A command, a path, a query — something runnable beats prose."
/>
</label>
<label>
What would end it
<textarea
v-model="expiresWhen"
rows="2"
placeholder="A state, not a date — “when the runner can be given a bash shell”."
/>
</label>
<div v-if="savedCheck" class="stamp">
<span class="stamp-age" :class="{ unchecked: !verifiedAt }">{{ stampLabel }}</span>
<span class="stamp-actions">
<button type="button" :disabled="verifying" @click="verify(true)">Still true</button>
<button type="button" :disabled="verifying" @click="verify(false)">No longer true</button>
</span>
</div>
<p v-if="savedCheck" class="tier-test">
Record this after actually running the check, never on the strength of the rule
sounding plausible. No longer true deliberately stores nothing the rule is wrong,
not in a state worth recording, so it stays at the top of the sweep until you fix or
retire it.
</p>
</fieldset>
<section v-if="relations.length" class="relations"> <section v-if="relations.length" class="relations">
<h3>Related rules</h3> <h3>Related rules</h3>
<ul> <ul>
@@ -231,6 +302,35 @@ legend { padding: 0 0.35rem; font-size: 0.8rem; color: var(--fs-text-tertiary);
.relation-target { color: var(--fs-text-primary); } .relation-target { color: var(--fs-text-primary); }
.relation-note { width: 100%; font-size: 0.78rem; color: var(--fs-text-tertiary); } .relation-note { width: 100%; font-size: 0.78rem; color: var(--fs-text-tertiary); }
/* A real base rule, not just descendants: the dangling-style check reads a
class that only ever appears as an ancestor as a half-deleted rule, and it
is right to — an element whose appearance comes only from its tag is one
`fieldset {}` edit away from being unstyled. */
.check { margin-bottom: 1rem; }
.check .intro { margin-top: 0; margin-bottom: 0.75rem; }
.check label { margin-bottom: 0.75rem; }
.stamp {
display: flex; align-items: center; gap: var(--fs-space-2);
flex-wrap: wrap;
margin-top: 0.25rem;
}
.stamp-age { font-size: 0.8rem; color: var(--fs-text-secondary); font-variant-numeric: tabular-nums; }
/* Never-checked is INFORMATION, not an error: it is the ordinary starting
state of every constraint anyone has just written. --fs-overdue (error red)
is reserved for a broken promise like a missed due date; a verification age
is not one, and colouring it that way would make a brand-new rule look
broken. Secondary text, weighted normally. */
.stamp-age.unchecked { color: var(--fs-text-tertiary); font-style: italic; }
.stamp-actions { display: flex; gap: var(--fs-space-2); margin-left: auto; }
.stamp-actions button {
cursor: pointer; font: inherit; font-size: 0.78rem;
background: var(--fs-surface-raised); color: var(--fs-text-primary);
border: 1px solid var(--fs-border-color); border-radius: var(--fs-radius-sm);
padding: 0.2rem 0.55rem;
}
.stamp-actions button:hover:not(:disabled) { background: var(--fs-surface-hover); }
.stamp-actions button:disabled { opacity: var(--fs-disabled-opacity); cursor: default; }
.trash, .close { background: none; border: none; cursor: pointer; opacity: 0.6; font-size: 1.25em; } .trash, .close { background: none; border: none; cursor: pointer; opacity: 0.6; font-size: 1.25em; }
.trash:hover, .close:hover { opacity: 1; } .trash:hover, .close:hover { opacity: 1; }
</style> </style>
+19 -12
View File
@@ -17,7 +17,17 @@ const emit = defineEmits<{
{{ r.title }} {{ r.title }}
<!-- Only conditional is marked: always-on is the default and <!-- Only conditional is marked: always-on is the default and
badging every row would say nothing. --> badging every row would say nothing. -->
<span v-if="r.tier === 'conditional'" class="tier-chip" title="Arrives when its trigger fires, rather than in every session">conditional</span> <span v-if="r.tier === 'conditional'" class="rule-chip" title="Arrives when its trigger fires, rather than in every session">conditional</span>
<!-- Present only on a rule carrying a check, so the chip's very
presence says "this one asserts a fact that can go false". -->
<span
v-if="r.last_verified"
class="rule-chip check-chip"
:class="{ unchecked: r.last_verified === 'never' }"
:title="r.last_verified === 'never'
? 'Asserts a fact nobody has confirmed yet'
: `Check last passed ${r.last_verified}`"
>{{ r.last_verified === "never" ? "unverified" : `checked ${r.last_verified}` }}</span>
</div> </div>
<div class="statement">{{ r.statement }}</div> <div class="statement">{{ r.statement }}</div>
<div v-if="r.when_to_apply || r.updated_at" class="meta"> <div v-if="r.when_to_apply || r.updated_at" class="meta">
@@ -47,16 +57,13 @@ li:hover { background: var(--fs-surface-hover); }
.meta { display: flex; align-items: baseline; gap: 0.5rem; margin-top: 0.35rem; font-size: 0.75em; } .meta { display: flex; align-items: baseline; gap: 0.5rem; margin-top: 0.35rem; font-size: 0.75em; }
.trigger { flex: 1; min-width: 0; color: var(--fs-text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .trigger { flex: 1; min-width: 0; color: var(--fs-text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.age { color: var(--fs-text-tertiary); font-variant-numeric: tabular-nums; flex-shrink: 0; } .age { color: var(--fs-text-tertiary); font-variant-numeric: tabular-nums; flex-shrink: 0; }
.tier-chip { /* Only the departures from .rule-chip (rules-shared.css) live here. */
margin-left: 0.4rem; .check-chip { font-variant-numeric: tabular-nums; }
font-family: var(--fs-font-body); /* No age-graded colour on purpose. The sweep is already ordered by urgency, so
font-style: normal; a red/amber ramp would restate the ordering AND require an invented "stale
font-size: 0.62rem; after N days" threshold — a magic number nobody could defend and the first
color: var(--fs-text-secondary); thing to go out of date. Only "never" is marked, because it is categorically
background: var(--fs-surface-raised); different from a date rather than a worse one. */
border-radius: var(--fs-radius-pill); .check-chip.unchecked { font-style: italic; color: var(--fs-text-tertiary); }
padding: 0.05rem 0.4rem;
vertical-align: middle;
}
.new-rule { cursor: pointer; } .new-rule { cursor: pointer; }
</style> </style>
@@ -0,0 +1,180 @@
<script setup lang="ts">
/**
* The staleness sweep: rules that assert a FACT, oldest verification first.
*
* Cross-cutting by nature — a rule that has gone false does not care which
* rulebook it sits in — so this is its own pane rather than a filter on the
* per-topic rule list. That list can only ever show one topic of one
* rulebook, so filtering it would quietly under-report, which is the exact
* failure this surface exists to catch.
*/
import { onMounted, ref } from "vue";
import { useRulebooksStore } from "@/stores/rulebooks";
import type { RuleTier } from "@/api/rulebooks";
const emit = defineEmits<{ "open-rule": [id: number] }>();
const store = useRulebooksStore();
const neverOnly = ref(false);
const tier = ref<RuleTier | "">("");
const busyId = ref<number | null>(null);
function reload() {
return store.fetchRulesDue({
neverOnly: neverOnly.value || undefined,
tier: tier.value || undefined,
});
}
async function verify(id: number, stillTrue: boolean) {
busyId.value = id;
try {
await store.verifyRule(id, stillTrue);
} finally {
busyId.value = null;
}
}
onMounted(reload);
</script>
<template>
<section class="pane sweep">
<header>
<h2>Due for verification</h2>
<p class="lede">
Rules that assert a fact about something outside your control. Most rules are
decisions and never appear here they have no truth value to go stale.
</p>
</header>
<div class="filters">
<label class="filter">
<input v-model="neverOnly" type="checkbox" @change="reload" />
<span>Never checked only</span>
</label>
<label class="filter">
<span>Tier</span>
<select v-model="tier" @change="reload">
<option value="">any</option>
<option value="always_on">always on</option>
<option value="conditional">conditional</option>
</select>
</label>
</div>
<p v-if="store.loading" class="state">Loading</p>
<!-- An empty sweep is GOOD NEWS, and must not read like a broken page. -->
<p v-else-if="!store.rulesDue.length" class="state empty">
Nothing to check.
{{ neverOnly || tier ? "No rule matches these filters." : "No rule carries a check yet add one to a rule that asserts a fact." }}
</p>
<ol v-else class="rows">
<li v-for="r in store.rulesDue" :key="r.id" class="row">
<div class="row-head">
<button class="row-title" @click="emit('open-rule', r.id)">{{ r.title }}</button>
<span v-if="r.tier === 'always_on'" class="rule-chip" title="Loaded into every session — a wrong one is wrong everywhere at once">always on</span>
<span class="age" :class="{ unchecked: r.days_since_verified === null }">
{{ r.days_since_verified === null
? "never checked"
: `${r.days_since_verified}d ago` }}
</span>
</div>
<p class="statement">{{ r.statement }}</p>
<dl class="check">
<dt>Check</dt>
<dd><code>{{ r.verify_with }}</code></dd>
<template v-if="r.expires_when">
<dt>Ends when</dt>
<dd>{{ r.expires_when }}</dd>
</template>
</dl>
<div class="actions">
<button :disabled="busyId === r.id" @click="verify(r.id, true)">Still true</button>
<button :disabled="busyId === r.id" @click="verify(r.id, false)">No longer true</button>
</div>
</li>
</ol>
<p v-if="store.rulesDue.length" class="footnote">
Record a result only after actually running the check. No longer true stores nothing
on purpose the rule is wrong rather than in a state worth recording, so it keeps its
place here until you correct or retire it.
</p>
</section>
</template>
<style src="@/assets/rules-shared.css" />
<style scoped>
.sweep { display: flex; flex-direction: column; gap: var(--fs-space-3); }
.lede {
margin: 0;
max-width: 62ch;
font-size: 0.85rem;
color: var(--fs-text-secondary);
line-height: 1.5;
}
.filters { display: flex; gap: var(--fs-space-5); align-items: center; flex-wrap: wrap; }
.filter { display: flex; align-items: center; gap: var(--fs-space-2); font-size: 0.82rem; color: var(--fs-text-secondary); }
.filter input[type="checkbox"] { accent-color: var(--fs-accent); }
.filter select {
font: inherit; font-size: 0.82rem;
background: var(--fs-surface-page); color: var(--fs-text-primary);
border: 1px solid var(--fs-border-color); border-radius: var(--fs-radius-md);
padding: 0.2rem 0.4rem;
}
.state { margin: 0; font-size: 0.9rem; color: var(--fs-text-secondary); }
.state.empty { color: var(--fs-text-tertiary); }
.rows { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--fs-space-3); }
.row {
background: var(--fs-surface-raised);
border-radius: var(--fs-radius-md);
padding: var(--fs-space-3);
}
.row-head { display: flex; align-items: baseline; gap: var(--fs-space-2); flex-wrap: wrap; }
.row-title {
background: none; border: none; padding: 0; cursor: pointer;
font-family: Fraunces, serif; font-style: italic; font-size: 1.02rem;
color: var(--fs-text-primary); text-align: left;
}
.row-title:hover { text-decoration: underline; }
/* The ORDER carries urgency — the top of this list is the most overdue thing
in the rulebook. No red/amber ramp: it would restate the ordering and force
an invented "stale after N days" threshold. "Never" is marked because it is
categorically different from a date, not a worse one. */
.age { margin-left: auto; font-size: 0.78rem; color: var(--fs-text-secondary); font-variant-numeric: tabular-nums; }
.age.unchecked { font-style: italic; color: var(--fs-text-tertiary); }
.statement { margin: 0.35rem 0 0; font-size: 0.88rem; color: var(--fs-text-secondary); }
.check { display: grid; grid-template-columns: auto 1fr; gap: 0.15rem var(--fs-space-3); margin: var(--fs-space-3) 0 0; }
.check dt { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--fs-text-tertiary); }
.check dd { margin: 0; font-size: 0.82rem; color: var(--fs-text-primary); min-width: 0; }
.check code {
font-family: var(--fs-font-mono);
background: var(--fs-surface-code-inline);
border-radius: var(--fs-radius-sm);
padding: 0.05rem 0.3rem;
overflow-wrap: anywhere;
}
.actions { display: flex; gap: var(--fs-space-2); margin-top: var(--fs-space-3); }
.actions button {
cursor: pointer; font: inherit; font-size: 0.78rem;
background: var(--fs-surface-page); color: var(--fs-text-primary);
border: 1px solid var(--fs-border-color); border-radius: var(--fs-radius-md);
padding: 0.25rem 0.6rem;
}
.actions button:hover:not(:disabled) { background: var(--fs-surface-hover); }
.actions button:disabled { opacity: var(--fs-disabled-opacity); cursor: default; }
.footnote { margin: 0; max-width: 62ch; font-size: 0.78rem; color: var(--fs-text-tertiary); line-height: 1.45; }
</style>
@@ -3,8 +3,8 @@ import { ref } from "vue";
import { useRulebooksStore } from "@/stores/rulebooks"; import { useRulebooksStore } from "@/stores/rulebooks";
import type { Rulebook } from "@/api/rulebooks"; import type { Rulebook } from "@/api/rulebooks";
defineProps<{ rulebooks: Rulebook[]; selectedId: number | null }>(); defineProps<{ rulebooks: Rulebook[]; selectedId: number | null; sweepActive: boolean }>();
const emit = defineEmits<{ select: [id: number] }>(); const emit = defineEmits<{ select: [id: number]; "select-sweep": [] }>();
const store = useRulebooksStore(); const store = useRulebooksStore();
const isCreating = ref(false); const isCreating = ref(false);
@@ -34,6 +34,18 @@ async function submitNew() {
<span v-if="rb.always_on" class="always-on-badge" title="Loaded at session start">always on</span> <span v-if="rb.always_on" class="always-on-badge" title="Loaded at session start">always on</span>
</li> </li>
</ul> </ul>
<!-- Not a rulebook, and deliberately below them: a cross-cutting view over
every rule the operator owns. It lives here because this is where you
come to look at rules, and a rule that has gone false belongs to no
one rulebook. -->
<button
class="sweep-entry"
:class="{ active: sweepActive }"
@click="emit('select-sweep')"
>
Due for verification
</button>
<div class="new-rulebook"> <div class="new-rulebook">
<button v-if="!isCreating" @click="isCreating = true">+ New rulebook</button> <button v-if="!isCreating" @click="isCreating = true">+ New rulebook</button>
<form v-else @submit.prevent="submitNew"> <form v-else @submit.prevent="submitNew">
@@ -63,6 +75,15 @@ li:hover { background: var(--fs-surface-hover); }
color: var(--fs-text-on-action); color: var(--fs-text-on-action);
margin-left: auto; margin-left: auto;
} }
.sweep-entry {
display: block; width: 100%; text-align: left;
margin-top: var(--fs-space-3);
padding: 0.5rem; border-radius: 6px;
background: none; border: 1px dashed var(--fs-border-color);
color: var(--fs-text-secondary); font: inherit; cursor: pointer;
}
.sweep-entry:hover { background: var(--fs-surface-hover); }
.sweep-entry.active { background: var(--fs-accent-soft); color: var(--fs-text-primary); }
.new-rulebook { margin-top: 1rem; } .new-rulebook { margin-top: 1rem; }
.new-rulebook input { .new-rulebook input {
width: 100%; margin-bottom: 0.5rem; width: 100%; margin-bottom: 0.5rem;
+49 -1
View File
@@ -9,6 +9,11 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
const topicsByRulebook = ref<Record<number, RulebookTopic[]>>({}); const topicsByRulebook = ref<Record<number, RulebookTopic[]>>({});
const rulesByTopic = ref<Record<number, RuleHeader[]>>({}); const rulesByTopic = ref<Record<number, RuleHeader[]>>({});
const currentRule = ref<Rule | null>(null); const currentRule = ref<Rule | null>(null);
const rulesDue = ref<api.RuleVerificationRow[]>([]);
// Kept so a verify re-reads the sweep with the SAME filters the operator is
// looking at — re-fetching unfiltered would silently widen the list under
// them at the moment they acted on it.
const lastSweepOpts = ref<{ olderThanDays?: number; tier?: api.RuleTier; neverOnly?: boolean }>({});
const loading = ref(false); const loading = ref(false);
async function fetchRulebooks() { async function fetchRulebooks() {
@@ -111,6 +116,13 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
updated_at: rule.updated_at, updated_at: rule.updated_at,
when_to_apply: rule.when_to_apply || undefined, when_to_apply: rule.when_to_apply || undefined,
arose_from_id: rule.arose_from_id ?? undefined, arose_from_id: rule.arose_from_id ?? undefined,
// Mirrors services.rulebooks.last_verified_label: present ONLY when the
// rule carries a check, and "never" rather than absent when it has one
// nobody has run. Computed here so a row just written looks identical to
// the same row re-fetched, instead of losing its chip until a reload.
last_verified: rule.verify_with
? (rule.verified_at ? rule.verified_at.slice(0, 10) : "never")
: undefined,
}; };
} }
@@ -148,6 +160,41 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
await fetchRule(refreshRuleId); await fetchRule(refreshRuleId);
} }
/** The staleness sweep: rules asserting a fact, oldest verification first. */
async function fetchRulesDue(opts: {
olderThanDays?: number; tier?: api.RuleTier; neverOnly?: boolean;
} = {}) {
loading.value = true;
lastSweepOpts.value = opts;
try {
const data = await api.listRulesDueForVerification(opts);
rulesDue.value = data.rules;
} finally {
loading.value = false;
}
}
/**
* Record that a rule's check was RUN, and what it said.
*
* A pass re-sorts the row to the back of the sweep, so the list is re-read
* rather than patched: the whole point of this surface is an ORDER, and a
* locally-mutated row would sit in its old position claiming a new date.
* A failure writes nothing server-side and the row keeps its place — also
* correct, and also what a re-read shows.
*/
async function verifyRule(id: number, stillTrue: boolean) {
const rule = await api.markRuleVerified(id, stillTrue);
if (currentRule.value?.id === id) currentRule.value = rule;
for (const tid of Object.keys(rulesByTopic.value)) {
const list = rulesByTopic.value[Number(tid)];
const idx = list.findIndex((r) => r.id === id);
if (idx >= 0) list[idx] = toHeader(rule);
}
if (rulesDue.value.length) await fetchRulesDue(lastSweepOpts.value);
return rule;
}
async function deleteRule(id: number) { async function deleteRule(id: number) {
await api.deleteRule(id); await api.deleteRule(id);
if (currentRule.value?.id === id) currentRule.value = null; if (currentRule.value?.id === id) currentRule.value = null;
@@ -157,10 +204,11 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
} }
return { return {
rulebooks, topicsByRulebook, rulesByTopic, currentRule, loading, rulebooks, topicsByRulebook, rulesByTopic, currentRule, rulesDue, lastSweepOpts, loading,
fetchRulebooks, fetchTopics, fetchRules, fetchRule, fetchRulebooks, fetchTopics, fetchRules, fetchRule,
createRulebook, updateRulebook, toggleAlwaysOn, deleteRulebook, createRulebook, updateRulebook, toggleAlwaysOn, deleteRulebook,
createTopic, updateTopic, deleteTopic, createTopic, updateTopic, deleteTopic,
createRule, updateRule, deleteRule, relateRules, unrelateRules, createRule, updateRule, deleteRule, relateRules, unrelateRules,
fetchRulesDue, verifyRule,
}; };
}); });
+22 -3
View File
@@ -6,6 +6,7 @@ import RulebookListPane from "@/components/rules/RulebookListPane.vue";
import RulebookDetailPane from "@/components/rules/RulebookDetailPane.vue"; import RulebookDetailPane from "@/components/rules/RulebookDetailPane.vue";
import RuleListPane from "@/components/rules/RuleListPane.vue"; import RuleListPane from "@/components/rules/RuleListPane.vue";
import RuleEditorSlideOver from "@/components/rules/RuleEditorSlideOver.vue"; import RuleEditorSlideOver from "@/components/rules/RuleEditorSlideOver.vue";
import RuleSweepPane from "@/components/rules/RuleSweepPane.vue";
const store = useRulebooksStore(); const store = useRulebooksStore();
const route = useRoute(); const route = useRoute();
@@ -15,6 +16,7 @@ const selectedRulebookId = ref<number | null>(null);
const selectedTopicId = ref<number | null>(null); const selectedTopicId = ref<number | null>(null);
const editingRuleId = ref<number | null>(null); const editingRuleId = ref<number | null>(null);
const creatingRuleForTopic = ref<number | null>(null); const creatingRuleForTopic = ref<number | null>(null);
const sweepActive = ref(false);
function syncFromRoute() { function syncFromRoute() {
const rb = route.query.rb ? Number(route.query.rb) : null; const rb = route.query.rb ? Number(route.query.rb) : null;
@@ -23,9 +25,20 @@ function syncFromRoute() {
selectedRulebookId.value = rb; selectedRulebookId.value = rb;
selectedTopicId.value = topic; selectedTopicId.value = topic;
editingRuleId.value = rule; editingRuleId.value = rule;
sweepActive.value = route.query.view === "due";
}
function selectSweep() {
sweepActive.value = true;
// Keeps ?rule=… so the editor survives the mode switch, and drops the
// rulebook/topic selection the sweep does not use.
const { rb, topic, ...rest } = route.query;
void rb; void topic;
router.replace({ query: { ...rest, view: "due" } });
} }
function selectRulebook(id: number) { function selectRulebook(id: number) {
sweepActive.value = false;
selectedRulebookId.value = id; selectedRulebookId.value = id;
selectedTopicId.value = null; selectedTopicId.value = null;
router.replace({ query: { rb: String(id) } }); router.replace({ query: { rb: String(id) } });
@@ -70,10 +83,13 @@ watch(() => route.query, syncFromRoute);
<RulebookListPane <RulebookListPane
:rulebooks="store.rulebooks" :rulebooks="store.rulebooks"
:selected-id="selectedRulebookId" :selected-id="selectedRulebookId"
:sweep-active="sweepActive"
@select="selectRulebook" @select="selectRulebook"
@select-sweep="selectSweep"
/> />
<RuleSweepPane v-if="sweepActive" class="sweep-span" @open-rule="openRule" />
<RulebookDetailPane <RulebookDetailPane
v-if="selectedRulebookId !== null" v-else-if="selectedRulebookId !== null"
:rulebook-id="selectedRulebookId" :rulebook-id="selectedRulebookId"
:topics="store.topicsByRulebook[selectedRulebookId] || []" :topics="store.topicsByRulebook[selectedRulebookId] || []"
:selected-topic-id="selectedTopicId" :selected-topic-id="selectedTopicId"
@@ -83,13 +99,13 @@ watch(() => route.query, syncFromRoute);
<p>Select a rulebook to view its topics.</p> <p>Select a rulebook to view its topics.</p>
</div> </div>
<RuleListPane <RuleListPane
v-if="selectedTopicId !== null" v-if="!sweepActive && selectedTopicId !== null"
:topic-id="selectedTopicId" :topic-id="selectedTopicId"
:rules="store.rulesByTopic[selectedTopicId] || []" :rules="store.rulesByTopic[selectedTopicId] || []"
@open-rule="openRule" @open-rule="openRule"
@create-rule="startCreatingRule" @create-rule="startCreatingRule"
/> />
<div v-else class="pane empty"> <div v-else-if="!sweepActive" class="pane empty">
<p>Select a topic to view its rules.</p> <p>Select a topic to view its rules.</p>
</div> </div>
<RuleEditorSlideOver <RuleEditorSlideOver
@@ -109,6 +125,9 @@ watch(() => route.query, syncFromRoute);
gap: 1px; gap: 1px;
background: var(--fs-border-color); background: var(--fs-border-color);
} }
/* The sweep is cross-cutting, so it takes the width the rulebook + topic
panes would have used rather than being squeezed into one column. */
.sweep-span { grid-column: 2 / -1; }
.pane.empty { .pane.empty {
background: var(--fs-surface-hover); background: var(--fs-surface-hover);
padding: 1rem; padding: 1rem;