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>
96 lines
3.1 KiB
Vue
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>
|