CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 17s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 37s
#2533. theme.css claimed "removing this block is a rename sweep across the
components, tracked separately" — written in 67a529a, never filed, which made
the comment itself an instance of the survey's presence-without-reference
pattern. This is that sweep.
73 alias declarations deleted; 69 files rewritten; every --color-*-style name
now references its --fs-* token directly. Mechanical by construction: the map
IS the alias block, applied longest-name-first with a boundary guard so
--color-text never matched inside --color-text-muted. Zero survivors outside
theme.css, verified by grep rather than assumed.
One deliberate survivor: --color-shadow stays DECLARED, because it was never
an alias — it is a literal value the design system has no token for. Marked
in place as a recorded gap: promote it to an --fs-* token when a second app
needs it, don't copy the line.
Nothing is lost mode-wise: the aliases' resolve-at-use-time trick (which
absorbed 48 dark-mode overrides) lives one layer down in the --fs-* tokens'
own derivations, which is why the sweep is a pure rename. Both CSS checkers
green.
Why now rather than never: check_snippets_against_design_system reports every
--color-* reference as "unknown — renders as NOTHING", and nine recipe
snippets recorded from components.css carried the deprecated names, making
them prior art pointing the wrong way. With the sweep in, the checker's
report over re-recorded snippets should be EMPTY — the acceptance test that
proves the checker was right all along (#2517's correction).
Refs #2533
443 lines
16 KiB
Vue
443 lines
16 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 }>>({});
|
||
|
||
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(--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-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>
|