From c83bedf3bea68d19cba6ed729ebc6301b8853778 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Thu, 27 Aug 2026 11:53:35 -0400
Subject: [PATCH] feat(rules): the check is editable, visible, and sweepable in
the UI (#3098, milestone 312 step 4)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
---
frontend/src/assets/rules-shared.css | 22 ++-
.../src/components/rules/ProjectRulesTab.vue | 35 +++-
.../components/rules/RuleEditorSlideOver.vue | 100 ++++++++++
.../src/components/rules/RuleListPane.vue | 31 +--
.../src/components/rules/RuleSweepPane.vue | 180 ++++++++++++++++++
.../src/components/rules/RulebookListPane.vue | 25 ++-
frontend/src/stores/rulebooks.ts | 50 ++++-
frontend/src/views/RulesView.vue | 25 ++-
8 files changed, 447 insertions(+), 21 deletions(-)
create mode 100644 frontend/src/components/rules/RuleSweepPane.vue
diff --git a/frontend/src/assets/rules-shared.css b/frontend/src/assets/rules-shared.css
index 9efc62b..72c1448 100644
--- a/frontend/src/assets/rules-shared.css
+++ b/frontend/src/assets/rules-shared.css
@@ -1,5 +1,7 @@
-/* Shared by the three rules panes (RulebookListPane, RuleListPane,
- RulebookDetailPane): the pane surface and its heading. Load with
+/* Shared by the rules panes (RulebookListPane, RuleListPane,
+ 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
beside the component's own
scoped block; never restate these there (#2903, milestone 299). */
.pane {
@@ -13,3 +15,19 @@
margin: 0 0 0.5rem 0;
}
.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;
+}
diff --git a/frontend/src/components/rules/ProjectRulesTab.vue b/frontend/src/components/rules/ProjectRulesTab.vue
index 8fdd3fb..db69979 100644
--- a/frontend/src/components/rules/ProjectRulesTab.vue
+++ b/frontend/src/components/rules/ProjectRulesTab.vue
@@ -24,7 +24,10 @@ const allRulebooks = ref([]);
const showPicker = ref(false);
const expandedRuleIds = ref>(new Set());
-const ruleDetails = ref>({});
+const ruleDetails = ref>({});
const showProjectRuleForm = ref(false);
const newProjectRule = ref({
@@ -67,6 +70,9 @@ async function toggleRuleExpand(ruleId: number) {
ruleDetails.value[ruleId] = {
why: rule.why || "",
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);
}
+/** "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) {
const query: Record = { rb: String(rulebookId) };
if (ruleId) query.rule = String(ruleId);
@@ -279,6 +290,16 @@ watch(() => props.projectId, load);
How to apply: {{ ruleDetails[r.id].how_to_apply }}
@@ -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-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:hover, .close:hover { opacity: 1; }
diff --git a/frontend/src/components/rules/RuleListPane.vue b/frontend/src/components/rules/RuleListPane.vue
index 78ba14a..6c2a662 100644
--- a/frontend/src/components/rules/RuleListPane.vue
+++ b/frontend/src/components/rules/RuleListPane.vue
@@ -17,7 +17,17 @@ const emit = defineEmits<{
{{ r.title }}
- conditional
+ conditional
+
+ {{ r.last_verified === "never" ? "unverified" : `checked ${r.last_verified}` }}
{{ r.statement }}
@@ -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; }
.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; }
-.tier-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;
-}
+/* Only the departures from .rule-chip (rules-shared.css) live here. */
+.check-chip { font-variant-numeric: tabular-nums; }
+/* No age-graded colour on purpose. 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. Only "never" is marked, because it is categorically
+ different from a date rather than a worse one. */
+.check-chip.unchecked { font-style: italic; color: var(--fs-text-tertiary); }
.new-rule { cursor: pointer; }
diff --git a/frontend/src/components/rules/RuleSweepPane.vue b/frontend/src/components/rules/RuleSweepPane.vue
new file mode 100644
index 0000000..f072500
--- /dev/null
+++ b/frontend/src/components/rules/RuleSweepPane.vue
@@ -0,0 +1,180 @@
+
+
+
+
+
+
Due for verification
+
+ 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.
+
+
+
+
+
+
+
+
+
Loading…
+
+
+
+ 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." }}
+
+ 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.
+
+
+
+
+
+
diff --git a/frontend/src/components/rules/RulebookListPane.vue b/frontend/src/components/rules/RulebookListPane.vue
index b808c3d..f76effe 100644
--- a/frontend/src/components/rules/RulebookListPane.vue
+++ b/frontend/src/components/rules/RulebookListPane.vue
@@ -3,8 +3,8 @@ import { ref } from "vue";
import { useRulebooksStore } from "@/stores/rulebooks";
import type { Rulebook } from "@/api/rulebooks";
-defineProps<{ rulebooks: Rulebook[]; selectedId: number | null }>();
-const emit = defineEmits<{ select: [id: number] }>();
+defineProps<{ rulebooks: Rulebook[]; selectedId: number | null; sweepActive: boolean }>();
+const emit = defineEmits<{ select: [id: number]; "select-sweep": [] }>();
const store = useRulebooksStore();
const isCreating = ref(false);
@@ -34,6 +34,18 @@ async function submitNew() {
always on
+
+
+
-
+
Select a topic to view its rules.
route.query, syncFromRoute);
gap: 1px;
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 {
background: var(--fs-surface-hover);
padding: 1rem;