Files
FabledScribe/frontend/src/components/rules/ProjectRulesTab.vue
T
bvandeusenandClaude Opus 5 a6d6550483
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 57s
CI & Build / Build & push image (push) Successful in 45s
fix(ui): walk the eleven dangling-style reports — two were real
#2444. Each needed reading rather than a batch fix, and the split was 2 real
losses, 4 false reports, 5 wrappers that are bare on purpose.

REAL:

  .system-card   was a flex row, and every child still says so —
                 .system-swatch and .system-actions are flex-shrink: 0,
                 .system-body and .system-form--inline are flex: 1.
                 align-items: flex-start is why the swatch carries
                 margin-top: 0.3rem: nudged onto the first line of text.
  .systems-list  no rule AT ALL, so the systems list rendered with browser
                 bullets and indent. Invisible to the check — see below.
  .graph-embed   the panel is a flex column whose header is flex-shrink: 0,
                 so this is the item that takes the remaining height. Without
                 it the `height: 100%` on the line below resolves against auto
                 and does nothing, which left the comment above it specifying
                 a rule that could not work.

FALSE REPORTS, and the checker was wrong rather than the code:

`.pane.empty` and `td.num` are base rules for the element that carries those
classes — the check read any compound with more than a lone class as a
modifier. It now records a compound's whole class SET and clears an element
carrying all of them, which is exact: recording the classes individually would
have cleared `.pane` everywhere on the strength of a rule that only applies
alongside `.empty`. Four reports gone, and a check with false reports is one
that gets skimmed.

BARE ON PURPOSE — .rb, .topic-group, .new-topic, .sub-list, .dash-head, and
both .detail-row rows. Each namespaces descendant rules and assumes nothing
about layout, which is the tell that separates them from a deleted base. All
seven now carry a comment saying so, so the next reader doesn't re-litigate
them and a NEW entry in the report means something actually changed.

Also recorded in the script: it cannot see a class with no rule anywhere, since
that is indistinguishable from a semantic-only hook. `.systems-list` was found
by reading the file beside a class that WAS half-styled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-05 09:26:35 -04:00

443 lines
16 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,
} 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 }>>({});
const showProjectRuleForm = ref(false);
const newProjectRule = ref({ title: "", statement: "", why: "", how_to_apply: "" });
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 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 || "",
};
}
}
// trigger reactivity on Set mutation
expandedRuleIds.value = new Set(expandedRuleIds.value);
}
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) {
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 === r.topic_id);
if (!topic) {
topic = { topic_id: r.topic_id, 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,
});
newProjectRule.value = { title: "", statement: "", why: "", how_to_apply: "" };
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 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.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>
<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>
<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>
.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(--color-primary-bg);
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(--color-border);
padding: 0.25rem 0.75rem; border-radius: 999px; cursor: pointer;
color: inherit;
}
select {
background: var(--color-bg); color: inherit;
border: 1px solid var(--color-border); 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(--color-primary);
padding-left: 0.75rem; margin: 0.5rem 0;
}
.rule-head { cursor: pointer; }
.rule-title { font-weight: 500; }
.rule-statement { display: block; opacity: 0.85; margin-top: 0.25rem; }
.rule-detail {
margin-top: 0.5rem; padding: 0.5rem;
background: var(--color-bg); border-radius: 6px;
}
.rule-detail > div { margin-bottom: 0.5rem; }
.edit-link {
background: none; border: none; cursor: pointer;
color: var(--color-primary); 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(--color-bg);
border: 1px solid var(--color-border); border-radius: 6px;
}
.new-rule-form input, .new-rule-form textarea {
background: var(--color-surface); color: inherit;
border: 1px solid var(--color-border); 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(--color-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(--color-muted); 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(--color-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(--color-bg);
border: 1px solid var(--color-border);
}
.suppressed-path { flex: 1; }
.reenable-btn {
background: none; border: none; cursor: pointer;
color: var(--color-primary); font-size: 0.85em;
}
.reenable-btn:hover { text-decoration: underline; }
</style>