CI & Build / Python lint (push) Successful in 6s
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Failing after 27s
CI & Build / integration (push) Successful in 38s
CI & Build / Python tests (push) Successful in 1m27s
CI & Build / Build & push image (push) Skipped
Rule 27 — the schema and both doors shipped with no human surface, so step 3 was not shippable until this. RuleEditorSlideOver gains the trigger, the tier, the areas, and a read-only view of the rule's edges. The tier is a radio pair carrying the test itself rather than a bare toggle: can you name the trigger WITHOUT naming a system, an artifact type or a moment? If the honest answer is "whenever you are working", it is always on. It also says why conditional is not a demotion — it costs nothing when irrelevant, which is what lets a rule be as long as it needs to be. The relations block states the rule the whole milestone turns on: rules that FAIL TOGETHER are linked, never merged. RuleListPane shows the trigger and the LAST-CHANGED DATE on every row, and marks conditional only — always_on is the default and badging every row would say nothing. The date is the cheap triage the FabledCurator case wanted: a rule whose age predates the capability it duplicates is visible at a glance instead of needing a get_rule to find out. ProjectRulesTab's inline create form gains the same two fields, because a project rule bloats exactly the way a family one does — FabledCurator has 23 of them. Two type fixes the new shapes forced, both worth keeping: - toHeader() in the store: a list row is the server's rule_brief, so patching a list locally has to mirror every field it carries or the two disagree. There were two hand-built four-field literals doing that job. - ApplicableRules.rules / .project_rules are now described AS RuleHeader rather than as two more hand-written shapes — the same builder produces them, so the same type should describe them. groupByRulebookAndTopic skips a null-topic rule rather than widening TopicGroup to accept one: a rule carries topic_id XOR project_id, so a null topic in that list means something is wrong upstream, and a widened type would hide it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
237 lines
8.2 KiB
Vue
237 lines
8.2 KiB
Vue
<script setup lang="ts">
|
||
import { computed, ref, watch, onMounted } from "vue";
|
||
import { useRulebooksStore } from "@/stores/rulebooks";
|
||
import { useCanonicalSystemsStore } from "@/stores/canonicalSystems";
|
||
import type { RuleTier } from "@/api/rulebooks";
|
||
|
||
const props = defineProps<{ ruleId: number | null; topicId: number | null }>();
|
||
const emit = defineEmits<{ close: [] }>();
|
||
|
||
const store = useRulebooksStore();
|
||
const canon = useCanonicalSystemsStore();
|
||
const title = ref("");
|
||
const statement = ref("");
|
||
const whenToApply = ref("");
|
||
const tier = ref<RuleTier>("always_on");
|
||
const systemIds = ref<number[]>([]);
|
||
const why = ref("");
|
||
const howToApply = ref("");
|
||
|
||
const relations = computed(() => store.currentRule?.relations ?? []);
|
||
|
||
// The label a reader needs to judge an edge, not the stored token.
|
||
const RELATION_LABEL: Record<string, { outgoing: string; incoming: string }> = {
|
||
co_surfaces: { outgoing: "arrives with", incoming: "arrives with" },
|
||
overrides: { outgoing: "overrides", incoming: "is overridden by" },
|
||
elaborates: { outgoing: "elaborates", incoming: "is elaborated by" },
|
||
};
|
||
|
||
function relationLabel(kind: string, direction: "outgoing" | "incoming") {
|
||
return RELATION_LABEL[kind]?.[direction] ?? kind;
|
||
}
|
||
|
||
function toggleSystem(id: number) {
|
||
const at = systemIds.value.indexOf(id);
|
||
if (at >= 0) systemIds.value.splice(at, 1);
|
||
else systemIds.value.push(id);
|
||
}
|
||
|
||
const isCreating = ref(props.ruleId === null);
|
||
|
||
async function load() {
|
||
if (props.ruleId !== null) {
|
||
await store.fetchRule(props.ruleId);
|
||
const r = store.currentRule;
|
||
if (r) {
|
||
title.value = r.title;
|
||
statement.value = r.statement;
|
||
whenToApply.value = r.when_to_apply || "";
|
||
tier.value = r.tier || "always_on";
|
||
systemIds.value = (r.systems ?? []).map((sys) => sys.id);
|
||
why.value = r.why || "";
|
||
howToApply.value = r.how_to_apply || "";
|
||
}
|
||
} else {
|
||
title.value = "";
|
||
statement.value = "";
|
||
whenToApply.value = "";
|
||
tier.value = "always_on";
|
||
systemIds.value = [];
|
||
why.value = "";
|
||
howToApply.value = "";
|
||
}
|
||
await canon.fetchCatalog();
|
||
}
|
||
|
||
async function save() {
|
||
if (!title.value.trim() || !statement.value.trim()) {
|
||
emit("close");
|
||
return;
|
||
}
|
||
const fields = {
|
||
title: title.value,
|
||
statement: statement.value,
|
||
when_to_apply: whenToApply.value,
|
||
tier: tier.value,
|
||
// Always sent, so clearing the last area actually clears it — the server
|
||
// reads a list as "these ARE the areas now".
|
||
system_ids: systemIds.value,
|
||
why: why.value,
|
||
how_to_apply: howToApply.value,
|
||
};
|
||
if (isCreating.value && props.topicId !== null) {
|
||
await store.createRule(props.topicId, fields);
|
||
} else if (props.ruleId !== null) {
|
||
await store.updateRule(props.ruleId, fields);
|
||
}
|
||
emit("close");
|
||
}
|
||
|
||
async function remove() {
|
||
if (props.ruleId === null) return;
|
||
if (!confirm("Delete this rule? This cannot be undone.")) return;
|
||
await store.deleteRule(props.ruleId);
|
||
emit("close");
|
||
}
|
||
|
||
onMounted(load);
|
||
watch(() => props.ruleId, load);
|
||
</script>
|
||
|
||
<template>
|
||
<div class="backdrop" @click="save">
|
||
<aside class="slide-over" @click.stop>
|
||
<header>
|
||
<h2>{{ isCreating ? "New rule" : "Edit rule" }}</h2>
|
||
<button v-if="!isCreating" class="trash" @click="remove" aria-label="Delete">🗑</button>
|
||
<button class="close" @click="save" aria-label="Close">×</button>
|
||
</header>
|
||
<label>
|
||
Title
|
||
<input v-model="title" placeholder="e.g. dev is home" />
|
||
</label>
|
||
<label>
|
||
Statement <span class="required">*</span>
|
||
<textarea v-model="statement" rows="3" placeholder="The actionable instruction (1-2 sentences)." />
|
||
</label>
|
||
<label>
|
||
When to apply
|
||
<textarea
|
||
v-model="whenToApply"
|
||
rows="2"
|
||
placeholder="The trigger, not the instruction — “before any git push”, “when a release is being cut”."
|
||
/>
|
||
</label>
|
||
|
||
<fieldset class="tier">
|
||
<legend>How it reaches a session</legend>
|
||
<label class="tier-opt">
|
||
<input v-model="tier" type="radio" value="always_on" />
|
||
<span>
|
||
<strong>Always on</strong>
|
||
— loaded into every session.
|
||
</span>
|
||
</label>
|
||
<label class="tier-opt">
|
||
<input v-model="tier" type="radio" value="conditional" />
|
||
<span>
|
||
<strong>Conditional</strong>
|
||
— arrives when its trigger fires.
|
||
</span>
|
||
</label>
|
||
<p class="tier-test">
|
||
The test: can you name the trigger <em>without</em> naming a system, an artifact type
|
||
or a moment? If the honest answer is “whenever you are working”, it is always on.
|
||
Conditional costs nothing when it is irrelevant, which is what lets it be as long as
|
||
it needs to be.
|
||
</p>
|
||
</fieldset>
|
||
|
||
<fieldset v-if="canon.catalog.length" class="areas">
|
||
<legend>Areas this rule is about</legend>
|
||
<label v-for="entry in canon.catalog" :key="entry.id" class="area-opt">
|
||
<input
|
||
type="checkbox"
|
||
:checked="systemIds.includes(entry.id)"
|
||
@change="toggleSystem(entry.id)"
|
||
/>
|
||
<span>{{ entry.name }}</span>
|
||
</label>
|
||
<p class="tier-test">
|
||
What lets this rule reach a project working in that area.
|
||
</p>
|
||
</fieldset>
|
||
|
||
<section v-if="relations.length" class="relations">
|
||
<h3>Related rules</h3>
|
||
<ul>
|
||
<li v-for="rel in relations" :key="rel.id" class="relation">
|
||
<span class="relation-kind">{{ relationLabel(rel.kind, rel.direction) }}</span>
|
||
<span class="relation-target">rule #{{ rel.rule_id }}</span>
|
||
<span v-if="rel.note" class="relation-note">{{ rel.note }}</span>
|
||
</li>
|
||
</ul>
|
||
<p class="tier-test">
|
||
Rules that <em>fail together</em> are linked, never merged — a merged rule cannot be
|
||
cited, surfaced or suppressed a clause at a time.
|
||
</p>
|
||
</section>
|
||
|
||
<label>
|
||
Why
|
||
<textarea v-model="why" rows="4" placeholder="Rationale — the reason this rule exists." />
|
||
</label>
|
||
<label>
|
||
How to apply
|
||
<textarea v-model="howToApply" rows="4" placeholder="When / where this kicks in." />
|
||
</label>
|
||
</aside>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.backdrop {
|
||
position: fixed; inset: 0;
|
||
background: rgba(0, 0, 0, 0.4);
|
||
z-index: 100;
|
||
}
|
||
.slide-over {
|
||
position: fixed; top: 0; right: 0; bottom: 0;
|
||
width: min(520px, 90vw);
|
||
background: var(--fs-surface-hover);
|
||
border-left: 2px solid var(--fs-accent);
|
||
padding: 1.5rem;
|
||
overflow-y: auto;
|
||
box-shadow: -8px 0 32px rgba(0, 0, 0, 0.3);
|
||
}
|
||
header { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 1rem; }
|
||
header h2 {
|
||
flex: 1; margin: 0;
|
||
font-family: Fraunces, serif; font-style: italic;
|
||
}
|
||
label { display: block; margin-bottom: 1rem; }
|
||
.required { color: var(--fs-accent); }
|
||
input, textarea {
|
||
width: 100%; margin-top: 0.25rem;
|
||
background: var(--fs-surface-page); color: inherit;
|
||
border: 1px solid var(--fs-border-color); border-radius: 6px;
|
||
padding: 0.5rem; font: inherit;
|
||
font-family: inherit;
|
||
}
|
||
fieldset { border: 1px solid var(--fs-border-color); border-radius: var(--fs-radius-md); padding: 0.75rem; margin-bottom: 1rem; }
|
||
legend { padding: 0 0.35rem; font-size: 0.8rem; color: var(--fs-text-tertiary); }
|
||
.tier-opt, .area-opt { display: flex; align-items: flex-start; gap: 0.5rem; margin-bottom: 0.4rem; font-size: 0.88rem; }
|
||
.tier-opt input, .area-opt input { width: auto; margin-top: 0.2rem; accent-color: var(--fs-accent); }
|
||
.tier-test { margin: 0.5rem 0 0; font-size: 0.78rem; color: var(--fs-text-tertiary); line-height: 1.45; }
|
||
|
||
.relations h3 { margin: 0 0 0.5rem; font-size: 0.85rem; color: var(--fs-text-secondary); }
|
||
.relations ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.35rem; }
|
||
.relation { display: flex; align-items: baseline; gap: 0.4rem; flex-wrap: wrap; font-size: 0.85rem; }
|
||
.relation-kind { color: var(--fs-accent); }
|
||
.relation-target { color: var(--fs-text-primary); }
|
||
.relation-note { width: 100%; font-size: 0.78rem; color: var(--fs-text-tertiary); }
|
||
|
||
.trash, .close { background: none; border: none; cursor: pointer; opacity: 0.6; font-size: 1.25em; }
|
||
.trash:hover, .close:hover { opacity: 1; }
|
||
</style>
|