Files
FabledScribe/frontend/src/components/rules/ProjectRulesTab.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

540 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, onMounted, watch } from "vue";
import { useRouter } from "vue-router";
import {
getProjectApplicableRules,
subscribeProject,
unsubscribeProject,
listRulebooks,
getRule,
createProjectRule,
deleteRule,
suppressRuleForProject,
unsuppressRuleForProject,
suppressTopicForProject,
unsuppressTopicForProject,
includeAlwaysOnRulebook,
} from "@/api/rulebooks";
import type { ApplicableRules, Rulebook } from "@/api/rulebooks";
const props = defineProps<{ projectId: number }>();
const router = useRouter();
const applicable = ref<ApplicableRules | null>(null);
const allRulebooks = ref<Rulebook[]>([]);
const showPicker = ref(false);
const expandedRuleIds = ref<Set<number>>(new Set());
const ruleDetails = ref<Record<number, {
why: string; how_to_apply: string;
verify_with: string; expires_when: string; verified_at: string | null;
}>>({});
const showProjectRuleForm = ref(false);
const newProjectRule = ref({
title: "", statement: "", why: "", how_to_apply: "",
when_to_apply: "", tier: "always_on" as "always_on" | "conditional",
});
async function load() {
applicable.value = await getProjectApplicableRules(props.projectId);
}
async function loadAllRulebooks() {
allRulebooks.value = await listRulebooks();
}
async function subscribe(rulebookId: number) {
await subscribeProject(props.projectId, rulebookId);
showPicker.value = false;
await load();
}
async function includeBack(rulebookId: number) {
await includeAlwaysOnRulebook(props.projectId, rulebookId);
await load();
}
async function unsubscribe(rulebookId: number) {
if (!confirm("Unsubscribe from this rulebook for this project?")) return;
await unsubscribeProject(props.projectId, rulebookId);
await load();
}
async function toggleRuleExpand(ruleId: number) {
if (expandedRuleIds.value.has(ruleId)) {
expandedRuleIds.value.delete(ruleId);
} else {
expandedRuleIds.value.add(ruleId);
if (!ruleDetails.value[ruleId]) {
const rule = await getRule(ruleId);
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,
};
}
}
// trigger reactivity on Set mutation
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<string, string> = { rb: String(rulebookId) };
if (ruleId) query.rule = String(ruleId);
router.push({ path: "/rules", query });
}
interface TopicGroup {
topic_id: number;
topic_title: string;
rules: ApplicableRules["rules"];
}
interface RulebookGroup {
rulebook_id: number;
rulebook_title: string;
topics: TopicGroup[];
}
function groupByRulebookAndTopic(rules: ApplicableRules["rules"]): RulebookGroup[] {
const byRulebook = new Map<number, RulebookGroup>();
for (const r of rules) {
// A rule carries topic_id XOR project_id. Only rulebook-scoped rules reach
// this list, so a null topic would be a server-side contradiction — skip
// it rather than widen the group's type to accommodate a case that means
// something is wrong upstream.
if (r.topic_id === null) continue;
const topicId = r.topic_id;
let rb = byRulebook.get(r.rulebook_id);
if (!rb) {
rb = { rulebook_id: r.rulebook_id, rulebook_title: r.rulebook_title, topics: [] };
byRulebook.set(r.rulebook_id, rb);
}
let topic = rb.topics.find((t) => t.topic_id === topicId);
if (!topic) {
topic = { topic_id: topicId, topic_title: r.topic_title, rules: [] };
rb.topics.push(topic);
}
topic.rules.push(r);
}
return Array.from(byRulebook.values());
}
async function submitProjectRule() {
const statement = newProjectRule.value.statement.trim();
if (!statement) return;
await createProjectRule(props.projectId, {
statement,
title: newProjectRule.value.title.trim() || undefined,
why: newProjectRule.value.why.trim() || undefined,
how_to_apply: newProjectRule.value.how_to_apply.trim() || undefined,
when_to_apply: newProjectRule.value.when_to_apply.trim() || undefined,
tier: newProjectRule.value.tier,
});
newProjectRule.value = {
title: "", statement: "", why: "", how_to_apply: "",
when_to_apply: "", tier: "always_on",
};
showProjectRuleForm.value = false;
await load();
}
async function removeProjectRule(ruleId: number) {
if (!confirm("Delete this project rule? It will move to the trash.")) return;
await deleteRule(ruleId);
await load();
}
const showSuppressed = ref(false);
async function suppressRule(ruleId: number) {
await suppressRuleForProject(props.projectId, ruleId);
await load();
}
async function unsuppressRule(ruleId: number) {
await unsuppressRuleForProject(props.projectId, ruleId);
await load();
}
async function suppressTopic(topicId: number) {
await suppressTopicForProject(props.projectId, topicId);
await load();
}
async function unsuppressTopic(topicId: number) {
await unsuppressTopicForProject(props.projectId, topicId);
await load();
}
onMounted(async () => {
await load();
await loadAllRulebooks();
});
watch(() => props.projectId, load);
</script>
<template>
<div class="rules-tab" v-if="applicable">
<section class="subscribed">
<h3>Subscribed rulebooks</h3>
<div class="chips">
<span
v-for="rb in applicable.subscribed_rulebooks"
:key="rb.id"
class="chip"
>
<a @click="openInRulesView(rb.id)">{{ rb.title }}</a>
<button class="chip-remove" @click="unsubscribe(rb.id)" aria-label="Unsubscribe">×</button>
</span>
<button v-if="!showPicker" class="add" @click="showPicker = true">+ Subscribe</button>
<select
v-else
@change="subscribe(Number(($event.target as HTMLSelectElement).value))"
>
<option value="">Choose a rulebook</option>
<option
v-for="rb in allRulebooks.filter((rb) => !applicable!.subscribed_rulebooks.some((s) => s.id === rb.id))"
:key="rb.id"
:value="rb.id"
>
{{ rb.title }}
</option>
</select>
</div>
</section>
<section v-if="applicable.excluded_always_on?.length" class="excluded">
<h3>Excluded always-on rulebooks</h3>
<p class="excluded-note">Opted out at inception these do not bind this project.</p>
<div class="chips">
<span v-for="rb in applicable.excluded_always_on" :key="rb.id" class="chip chip-excluded">
<a @click="openInRulesView(rb.id)">{{ rb.title }}</a>
<button class="chip-remove" @click="includeBack(rb.id)" aria-label="Include again" title="Include again"></button>
</span>
</div>
</section>
<section class="project-rules">
<div class="section-head">
<h3>Project rules</h3>
<button
v-if="!showProjectRuleForm"
class="add"
@click="showProjectRuleForm = true"
>
+ New project rule
</button>
</div>
<form v-if="showProjectRuleForm" class="new-rule-form" @submit.prevent="submitProjectRule">
<input
v-model="newProjectRule.title"
placeholder="Title (optional — derived from statement if blank)"
/>
<textarea
v-model="newProjectRule.statement"
required
autofocus
placeholder="Statement (required) — the actionable instruction, 1-2 sentences"
rows="2"
></textarea>
<textarea
v-model="newProjectRule.when_to_apply"
placeholder="When to apply — the trigger, not the instruction"
rows="2"
></textarea>
<div class="tier-row">
<label>
<input v-model="newProjectRule.tier" type="radio" value="always_on" />
Always on
</label>
<label>
<input v-model="newProjectRule.tier" type="radio" value="conditional" />
Conditional
</label>
<span class="tier-hint">
Conditional if you had to name a system, an artifact or a moment to state the trigger.
</span>
</div>
<textarea
v-model="newProjectRule.why"
placeholder="Why (optional) — the rationale"
rows="2"
></textarea>
<textarea
v-model="newProjectRule.how_to_apply"
placeholder="How to apply (optional) — when / where it kicks in"
rows="2"
></textarea>
<div class="form-buttons">
<button type="submit">Create</button>
<button type="button" @click="showProjectRuleForm = false">Cancel</button>
</div>
</form>
<ul v-if="applicable.project_rules && applicable.project_rules.length > 0" class="rule-list">
<li v-for="r in applicable.project_rules" :key="r.id" class="rule">
<div class="rule-head" @click="toggleRuleExpand(r.id)">
<span class="rule-title">{{ r.title }}</span>
<span class="rule-statement">{{ r.statement }}</span>
</div>
<div v-if="expandedRuleIds.has(r.id) && ruleDetails[r.id]" class="rule-detail">
<div v-if="ruleDetails[r.id].why">
<strong>Why:</strong> {{ ruleDetails[r.id].why }}
</div>
<div v-if="ruleDetails[r.id].how_to_apply">
<strong>How to apply:</strong> {{ ruleDetails[r.id].how_to_apply }}
</div>
<!-- Shown only when the rule carries a check. Read-only here: this
tab is the project's view of what binds it, and editing a rule
belongs on the rulebook surface that owns it. -->
<div v-if="ruleDetails[r.id].verify_with">
<strong>Check:</strong> {{ ruleDetails[r.id].verify_with }}
<span class="rule-check-age">{{ checkAge(ruleDetails[r.id].verified_at) }}</span>
</div>
<div v-if="ruleDetails[r.id].expires_when">
<strong>Ends when:</strong> {{ ruleDetails[r.id].expires_when }}
</div>
<button class="delete-link" @click="removeProjectRule(r.id)">Delete</button>
</div>
</li>
</ul>
<p
v-else-if="!showProjectRuleForm"
class="empty"
>
No project-only rules yet.
</p>
</section>
<section class="applicable">
<h3>Applicable rules</h3>
<p v-if="applicable.rules.length === 0" class="empty">
No rules yet — subscribe to a rulebook above, or create one at
<a @click="router.push('/rules')">Rulebooks</a>.
</p>
<div
v-for="rb in groupByRulebookAndTopic(applicable.rules)"
:key="rb.rulebook_id"
class="rb-group"
>
<h4>{{ rb.rulebook_title }}</h4>
<div v-for="topic in rb.topics" :key="topic.topic_id" class="topic-group">
<h5>
<span>{{ topic.topic_title }}</span>
<button
class="skip-btn"
:title="`Skip the entire ${topic.topic_title} topic for this project`"
@click="suppressTopic(topic.topic_id)"
>× skip topic</button>
</h5>
<ul>
<li v-for="r in topic.rules" :key="r.id" class="rule">
<div class="rule-head">
<div class="rule-head-text" @click="toggleRuleExpand(r.id)">
<span class="rule-title">{{ r.title }}</span>
<span class="rule-statement">{{ r.statement }}</span>
</div>
<button
class="skip-btn"
title="Skip this rule for this project"
@click.stop="suppressRule(r.id)"
>× skip</button>
</div>
<div v-if="expandedRuleIds.has(r.id) && ruleDetails[r.id]" class="rule-detail">
<div v-if="ruleDetails[r.id].why">
<strong>Why:</strong> {{ ruleDetails[r.id].why }}
</div>
<div v-if="ruleDetails[r.id].how_to_apply">
<strong>How to apply:</strong> {{ ruleDetails[r.id].how_to_apply }}
</div>
<div v-if="ruleDetails[r.id].verify_with">
<strong>Check:</strong> {{ ruleDetails[r.id].verify_with }}
<span class="rule-check-age">{{ checkAge(ruleDetails[r.id].verified_at) }}</span>
</div>
<div v-if="ruleDetails[r.id].expires_when">
<strong>Ends when:</strong> {{ ruleDetails[r.id].expires_when }}
</div>
<button
class="edit-link"
@click="openInRulesView(r.rulebook_id, r.id)"
>
Edit in Rulebook →
</button>
</div>
</li>
</ul>
</div>
</div>
<p v-if="applicable.truncated" class="truncated">
Truncated at 50 rules — there are more applicable rules.
</p>
</section>
<section
v-if="applicable.suppressed_rules.length + applicable.suppressed_topics.length > 0"
class="suppressed"
>
<button class="suppressed-toggle" @click="showSuppressed = !showSuppressed">
<span>Suppressed ({{ applicable.suppressed_rules.length + applicable.suppressed_topics.length }})</span>
<span class="caret">{{ showSuppressed ? "▾" : "▸" }}</span>
</button>
<div v-if="showSuppressed" class="suppressed-body">
<ul v-if="applicable.suppressed_topics.length > 0" class="suppressed-list">
<li v-for="t in applicable.suppressed_topics" :key="`topic-${t.id}`">
<span class="suppressed-kind">topic</span>
<span class="suppressed-path">{{ t.rulebook_title }} → {{ t.title }}</span>
<button class="reenable-btn" @click="unsuppressTopic(t.id)">↻ re-enable</button>
</li>
</ul>
<ul v-if="applicable.suppressed_rules.length > 0" class="suppressed-list">
<li v-for="r in applicable.suppressed_rules" :key="`rule-${r.id}`">
<span class="suppressed-kind">rule</span>
<span class="suppressed-path">{{ r.rulebook_title }} → {{ r.topic_title }} → {{ r.title }}</span>
<button class="reenable-btn" @click="unsuppressRule(r.id)">↻ re-enable</button>
</li>
</ul>
</div>
</section>
</div>
</template>
<style scoped>
.tier-row { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; font-size: 0.85rem; }
.tier-row label { display: inline-flex; align-items: center; gap: 0.3rem; }
.tier-row input { accent-color: var(--fs-accent); }
.tier-hint { flex: 1; min-width: 12rem; font-size: 0.75rem; color: var(--fs-text-tertiary); }
.excluded-note { margin: 0 0 0.5rem; color: var(--fs-text-tertiary); font-size: 0.85rem; }
.chip-excluded { opacity: 0.8; text-decoration: line-through; }
.chip-excluded .chip-remove { text-decoration: none; }
.rules-tab { padding: 1rem; }
h3 {
font-size: 0.9em; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em;
margin-top: 0;
}
.chips { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; }
.chip {
display: inline-flex; align-items: center; gap: 0.25rem;
background: var(--fs-accent-soft);
padding: 0.25rem 0.5rem; border-radius: 999px;
}
.chip a { cursor: pointer; }
.chip-remove { background: none; border: none; cursor: pointer; opacity: 0.5; font-size: 1.1em; }
.chip-remove:hover { opacity: 1; }
.add {
background: none;
border: 1px dashed var(--fs-border-color);
padding: 0.25rem 0.75rem; border-radius: 999px; cursor: pointer;
color: inherit;
}
select {
background: var(--fs-surface-page); color: inherit;
border: 1px solid var(--fs-border-color); border-radius: 6px;
padding: 0.25rem 0.5rem;
}
.applicable { margin-top: 2rem; }
.rb-group { margin-bottom: 1.5rem; }
.rb-group h4 { font-family: Fraunces, serif; font-style: italic; margin-bottom: 0.5rem; }
/* `.topic-group` is deliberately bare — a namespace for the two h5 rules (this
one and the flex row further down), with the h5's own margin-top doing the
separating. Its children assume nothing about it, which is what tells it
apart from a base rule someone deleted (#2444). */
.topic-group h5 {
font-size: 0.85em; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em;
margin-top: 0.75rem;
}
ul { list-style: none; padding: 0; margin: 0; }
.rule {
border-left: 2px solid var(--fs-accent);
padding-left: 0.75rem; margin: 0.5rem 0;
}
.rule-head { cursor: pointer; }
.rule-title { font-weight: 500; }
.rule-check-age {
margin-left: var(--fs-space-2);
color: var(--fs-text-tertiary);
font-variant-numeric: tabular-nums;
}
.rule-statement { display: block; opacity: 0.85; margin-top: 0.25rem; }
.rule-detail {
margin-top: 0.5rem; padding: 0.5rem;
background: var(--fs-surface-page); border-radius: 6px;
}
.rule-detail > div { margin-bottom: 0.5rem; }
.edit-link {
background: none; border: none; cursor: pointer;
color: var(--fs-accent); padding: 0.5rem 0 0 0;
}
.empty, .truncated { opacity: 0.7; font-style: italic; }
.empty a { cursor: pointer; text-decoration: underline; }
.project-rules { margin-top: 1.5rem; }
.section-head { display: flex; justify-content: space-between; align-items: center; }
.new-rule-form {
display: flex; flex-direction: column; gap: 0.5rem;
padding: 0.75rem; margin: 0.5rem 0;
background: var(--fs-surface-page);
border: 1px solid var(--fs-border-color); border-radius: 6px;
}
.new-rule-form input, .new-rule-form textarea {
background: var(--fs-surface-hover); color: inherit;
border: 1px solid var(--fs-border-color); border-radius: 6px;
padding: 0.5rem; font: inherit; resize: vertical;
}
.rule-list { margin-top: 0.5rem; }
.delete-link {
background: none; border: none; cursor: pointer;
color: var(--fs-destructive); padding: 0.5rem 0 0 0;
}
/* Per-rule / per-topic suppress affordance — quiet by default, reveal on hover */
.topic-group h5 {
display: flex; justify-content: space-between; align-items: center; gap: 0.5rem;
}
.rule-head {
display: flex; justify-content: space-between; align-items: flex-start; gap: 0.5rem;
}
.rule-head-text { flex: 1; cursor: pointer; }
.skip-btn {
background: none; border: none; cursor: pointer;
color: var(--fs-text-tertiary); font-size: 0.75rem;
padding: 0.1rem 0.4rem; opacity: 0; transition: opacity 0.15s;
white-space: nowrap;
}
.topic-group h5:hover .skip-btn,
.rule:hover .skip-btn,
.skip-btn:focus { opacity: 1; }
.skip-btn:hover { color: var(--fs-destructive); }
/* Suppressed section */
.suppressed { margin-top: 1.5rem; }
.suppressed-toggle {
display: flex; align-items: center; gap: 0.4rem;
background: none; border: none; cursor: pointer;
font-size: 0.85rem; opacity: 0.7; padding: 0.25rem 0; color: inherit;
}
.suppressed-toggle:hover { opacity: 1; }
.suppressed-toggle .caret { font-size: 0.7em; }
.suppressed-body { margin-top: 0.5rem; }
.suppressed-list { padding-left: 0; }
.suppressed-list li {
display: flex; align-items: center; gap: 0.5rem;
padding: 0.25rem 0; opacity: 0.75;
}
.suppressed-kind {
font-size: 0.7em; text-transform: uppercase; letter-spacing: 0.05em;
padding: 0.1rem 0.4rem; border-radius: 3px;
background: var(--fs-surface-page);
border: 1px solid var(--fs-border-color);
}
.suppressed-path { flex: 1; }
.reenable-btn {
background: none; border: none; cursor: pointer;
color: var(--fs-accent); font-size: 0.85em;
}
.reenable-btn:hover { text-decoration: underline; }
</style>