Files
FabledScribe/frontend/src/views/RulesView.vue
T
bvandeusenandClaude Opus 5 c83bedf3be
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
feat(rules): the check is editable, visible, and sweepable in the UI (#3098, milestone 312 step 4)
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>
2026-08-27 11:53:35 -04:00

138 lines
4.2 KiB
Vue

<script setup lang="ts">
import { onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useRulebooksStore } from "@/stores/rulebooks";
import RulebookListPane from "@/components/rules/RulebookListPane.vue";
import RulebookDetailPane from "@/components/rules/RulebookDetailPane.vue";
import RuleListPane from "@/components/rules/RuleListPane.vue";
import RuleEditorSlideOver from "@/components/rules/RuleEditorSlideOver.vue";
import RuleSweepPane from "@/components/rules/RuleSweepPane.vue";
const store = useRulebooksStore();
const route = useRoute();
const router = useRouter();
const selectedRulebookId = ref<number | null>(null);
const selectedTopicId = ref<number | null>(null);
const editingRuleId = ref<number | null>(null);
const creatingRuleForTopic = ref<number | null>(null);
const sweepActive = ref(false);
function syncFromRoute() {
const rb = route.query.rb ? Number(route.query.rb) : null;
const topic = route.query.topic ? Number(route.query.topic) : null;
const rule = route.query.rule ? Number(route.query.rule) : null;
selectedRulebookId.value = rb;
selectedTopicId.value = topic;
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) {
sweepActive.value = false;
selectedRulebookId.value = id;
selectedTopicId.value = null;
router.replace({ query: { rb: String(id) } });
store.fetchTopics(id);
}
function selectTopic(id: number) {
selectedTopicId.value = id;
router.replace({ query: { ...route.query, topic: String(id) } });
store.fetchRules(id);
}
function openRule(id: number) {
editingRuleId.value = id;
router.replace({ query: { ...route.query, rule: String(id) } });
}
function closeRuleEditor() {
editingRuleId.value = null;
creatingRuleForTopic.value = null;
const { rule, ...rest } = route.query;
void rule;
router.replace({ query: rest });
}
function startCreatingRule(topicId: number) {
creatingRuleForTopic.value = topicId;
}
onMounted(async () => {
await store.fetchRulebooks();
syncFromRoute();
if (selectedRulebookId.value) await store.fetchTopics(selectedRulebookId.value);
if (selectedTopicId.value) await store.fetchRules(selectedTopicId.value);
});
watch(() => route.query, syncFromRoute);
</script>
<template>
<div class="rules-view">
<RulebookListPane
:rulebooks="store.rulebooks"
:selected-id="selectedRulebookId"
:sweep-active="sweepActive"
@select="selectRulebook"
@select-sweep="selectSweep"
/>
<RuleSweepPane v-if="sweepActive" class="sweep-span" @open-rule="openRule" />
<RulebookDetailPane
v-else-if="selectedRulebookId !== null"
:rulebook-id="selectedRulebookId"
:topics="store.topicsByRulebook[selectedRulebookId] || []"
:selected-topic-id="selectedTopicId"
@select-topic="selectTopic"
/>
<div v-else class="pane empty">
<p>Select a rulebook to view its topics.</p>
</div>
<RuleListPane
v-if="!sweepActive && selectedTopicId !== null"
:topic-id="selectedTopicId"
:rules="store.rulesByTopic[selectedTopicId] || []"
@open-rule="openRule"
@create-rule="startCreatingRule"
/>
<div v-else-if="!sweepActive" class="pane empty">
<p>Select a topic to view its rules.</p>
</div>
<RuleEditorSlideOver
v-if="editingRuleId !== null || creatingRuleForTopic !== null"
:rule-id="editingRuleId"
:topic-id="creatingRuleForTopic"
@close="closeRuleEditor"
/>
</div>
</template>
<style scoped>
.rules-view {
display: grid;
grid-template-columns: 280px 300px 1fr;
height: 100vh;
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;
opacity: 0.6;
font-style: italic;
}
</style>