CI & Build / Python lint (push) Failing after 3s
CI & Build / Plugin hooks (push) Failing after 12s
CI & Build / integration (push) Failing after 27s
CI & Build / TypeScript typecheck (push) Failing after 35s
CI & Build / Python tests (push) Failing after 37s
CI & Build / Build & push image (push) Skipped
Milestone 394, steps 5-8. Operator: "remove the always on rule functionality as the goal was to not have it at all since it didn't seem to work as expected." Unconditional preload had three failures the retrieval arms do not. It could not be MEASURED — a resident rule is in the context whether or not it mattered, so nothing distinguished "this governed the act" from "this was scenery", and it was the one surface structurally exempt from the scoreboard judging every other. It was SUMMARISED AWAY by compaction while the session went on believing it held the rules. And it CROWDED OUT the few rules that applied with the thirty that did not. WHAT GOES Schema (0100): rules.tier + ck_rules_tier, rule_versions.tier, rulebooks.always_on, and project_rulebook_exclusions — a table recording a project's opt-out of something that no longer binds it unasked. Tools: list_always_on_rules, exclude_always_on_rulebook, include_always_on_rulebook. Service: the same three plus rules_etag_for, _valid_tier and the whole etag family. The SessionStart preload and the write-path staleness arm go with them: nothing is resident, so nothing can have drifted since a session loaded it. THREE CALLS WORTH REVIEWING enter_project got NARROWER, not wider. Its filter was `always_on OR area-tagged`; dropping the tier arm leaves the deterministic half, so a project with no canonical-tagged Systems gets no bulk rules and reaches them by retrieval instead. Dropping the whole clause would have made that payload bigger than the preload this milestone deletes. Backups import tolerantly. A pre-394 archive carries tier, always_on and the retired inception choice; none is read, and the exclusion key is DROPPED rather than remapped, because restoring it would write data that validate_inception now rejects as unknown. The migration is irreversible in the way that matters and says so: downgrade recreates the columns at their defaults and cannot restore which rules were always-on. A value invented to fill a hole is not a measurement. THE INSTRUCTION SURFACES SAY THE HARDER THING Deleting "call list_always_on_rules()" is easy; replacing it is not, because the new model asks a session to trust something it cannot see. All three surfaces now say a session holds nothing, that rules arrive when work matches them, and — the half that got dangerous — that "no rule arrived" means "nothing matched", never "there is no rule". Under residency an empty session was rare and suspicious; it is now the ordinary state of most turns, so reading it as permission is wrong on nearly every turn rather than occasionally. That is #3720's defect at session scale. test_instruction_surfaces_agree is repointed rather than retired: its two halves collapsed into one instruction, and it gains a guard that every surface states what absence means. _INSTRUCTIONS is back at 1999/2000 — the inception clause paid for the longer HOW line. UI (rule 27, and the opportunity step 8 named) The tier selector is gone, and what replaces it is the point: `when_to_apply` is now the field that decides whether a rule is ever seen, so the editor marks it required, warns while it is empty, and both rule lists badge a trigger-less rule "never surfaces". A rule without one is not quiet, it is unreachable. TESTS Two files deleted outright — test_rules_etag.py and test_inception_rules.py tested subsystems that no longer exist. Elsewhere obsolete cases were removed and the rest repointed. One deserves naming: the wiring test asserted the act arms pass no `tier`, which had become an assertion that could not fail. It is repointed onto `kind`, which does still exist and where the same claim is live — a preference must reach a write exactly as a rule does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011cPyzNnegXHr5iRMzzy5KJ
508 lines
18 KiB
Vue
508 lines
18 KiB
Vue
<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;
|
||
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: "",
|
||
});
|
||
|
||
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 || "",
|
||
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,
|
||
});
|
||
newProjectRule.value = {
|
||
title: "", statement: "", why: "", how_to_apply: "",
|
||
when_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.when_to_apply"
|
||
placeholder="When to apply — the moment, in the words a session actually produces"
|
||
rows="2"
|
||
></textarea>
|
||
<p v-if="!newProjectRule.when_to_apply.trim()" class="trigger-hint">
|
||
Without a trigger the rule will never reach a session — nothing is
|
||
preloaded, so a rule arrives only when work matches what it names.
|
||
</p>
|
||
<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>
|
||
.trigger-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; }
|
||
.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>
|