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>
181 lines
6.8 KiB
Vue
181 lines
6.8 KiB
Vue
<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>
|