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;