Files
FabledScribe/frontend/src/components/rules/RulebookListPane.vue
T
bvandeusenandClaude Opus 5 3345be84d1 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 13:43:57 -04:00

96 lines
3.1 KiB
Vue

<script setup lang="ts">
import { ref } from "vue";
import { useRulebooksStore } from "@/stores/rulebooks";
import type { Rulebook } from "@/api/rulebooks";
defineProps<{ rulebooks: Rulebook[]; selectedId: number | null; sweepActive: boolean }>();
const emit = defineEmits<{ select: [id: number]; "select-sweep": [] }>();
const store = useRulebooksStore();
const isCreating = ref(false);
const newTitle = ref("");
async function submitNew() {
const title = newTitle.value.trim();
if (!title) return;
const rb = await store.createRulebook({ title });
newTitle.value = "";
isCreating.value = false;
emit("select", rb.id);
}
</script>
<template>
<aside class="pane">
<header><h2>Rulebooks</h2></header>
<ul>
<li
v-for="rb in rulebooks"
:key="rb.id"
:class="{ active: rb.id === selectedId }"
@click="emit('select', rb.id)"
>
<span class="title">{{ rb.title }}</span>
<span v-if="rb.always_on" class="always-on-badge" title="Loaded at session start">always on</span>
</li>
</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">
<button v-if="!isCreating" @click="isCreating = true">+ New rulebook</button>
<form v-else @submit.prevent="submitNew">
<input v-model="newTitle" autofocus placeholder="Rulebook title" />
<div class="form-buttons">
<button type="submit">Create</button>
<button type="button" @click="isCreating = false">Cancel</button>
</div>
</form>
</div>
</aside>
</template>
<style src="@/assets/rules-shared.css" />
<style scoped>
ul { list-style: none; padding: 0; margin: 1rem 0; }
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; display: flex; align-items: center; gap: 0.5rem; }
li.active { background: var(--fs-accent-soft); }
li:hover { background: var(--fs-surface-hover); }
.always-on-badge {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.1rem 0.4rem;
border-radius: 3px;
background: var(--fs-accent);
color: var(--fs-text-on-action);
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 input {
width: 100%; margin-bottom: 0.5rem;
background: var(--fs-surface-page); color: inherit;
border: 1px solid var(--fs-border-color); border-radius: 6px;
padding: 0.5rem;
}
button { cursor: pointer; }
</style>