Files
FabledScribe/frontend/src/components/rules/RulebookDetailPane.vue
T
bvandeusen 658348f208
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 46s
CI & Build / Build & push image (push) Successful in 1m1s
feat(rules): always_on rulebook flag + Scribe-first prompt
Adds rulebooks.always_on (migration 0058) and a new list_always_on_rules
MCP tool so a session-start eager pull can fetch standing rules without
needing an active-project notion. Updates _INSTRUCTIONS so Claude calls
the new tool at session start and codifies engineering rules in Scribe
rather than CLAUDE.md / auto-memory.

Seeds FabledSword family rulebook to always_on=true on migrate, matching
its design role as the cross-project standards rulebook.

Frontend: badge in RulebookListPane for always-on rulebooks; toggle in
RulebookDetailPane header bound to a new toggleAlwaysOn store action.

This is S1+S2 of the rules-consolidation plan (Scribe task #508). S3
(project-scoped rules) and S4 (enter_project handshake) follow.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 00:56:08 -04:00

155 lines
5.2 KiB
Vue

<script setup lang="ts">
import { ref, computed, onMounted, watch } from "vue";
import { useRulebooksStore } from "@/stores/rulebooks";
import { apiGet } from "@/api/client";
import {
subscribeProject, unsubscribeProject, getProjectApplicableRules,
} from "@/api/rulebooks";
import type { RulebookTopic } from "@/api/rulebooks";
const props = defineProps<{
rulebookId: number;
topics: RulebookTopic[];
selectedTopicId: number | null;
}>();
const emit = defineEmits<{ "select-topic": [id: number] }>();
const store = useRulebooksStore();
const isCreating = ref(false);
const newTitle = ref("");
const currentRulebook = computed(() =>
store.rulebooks.find((rb) => rb.id === props.rulebookId),
);
interface ProjectLite { id: number; title: string }
const projects = ref<ProjectLite[]>([]);
// Map<project_id, Set<rulebook_id>>
const subscribedRulebookIds = ref<Map<number, Set<number>>>(new Map());
async function loadProjects() {
const data = await apiGet<{ projects: ProjectLite[] }>("/api/projects");
projects.value = data.projects;
for (const p of projects.value) {
const result = await getProjectApplicableRules(p.id);
subscribedRulebookIds.value.set(
p.id,
new Set(result.subscribed_rulebooks.map((rb) => rb.id)),
);
}
}
function isSubscribed(projectId: number): boolean {
return subscribedRulebookIds.value.get(projectId)?.has(props.rulebookId) ?? false;
}
async function toggleSubscription(projectId: number, checked: boolean) {
if (checked) {
await subscribeProject(projectId, props.rulebookId);
const set = subscribedRulebookIds.value.get(projectId) || new Set<number>();
set.add(props.rulebookId);
subscribedRulebookIds.value.set(projectId, set);
} else {
await unsubscribeProject(projectId, props.rulebookId);
subscribedRulebookIds.value.get(projectId)?.delete(props.rulebookId);
}
// trigger reactivity on Map mutation
subscribedRulebookIds.value = new Map(subscribedRulebookIds.value);
}
async function submitNew() {
const title = newTitle.value.trim();
if (!title) return;
const topic = await store.createTopic(props.rulebookId, { title });
newTitle.value = "";
isCreating.value = false;
emit("select-topic", topic.id);
}
onMounted(loadProjects);
watch(() => props.rulebookId, () => {/* re-render of isSubscribed from existing map */});
</script>
<template>
<section class="pane">
<header>
<h2>Topics</h2>
<label v-if="currentRulebook" class="always-on-toggle" title="When on, rules from this rulebook load at session start regardless of project context">
<input
type="checkbox"
:checked="currentRulebook.always_on"
@change="store.toggleAlwaysOn(currentRulebook.id)"
/>
<span>Always on</span>
</label>
</header>
<ul>
<li
v-for="t in topics"
:key="t.id"
:class="{ active: t.id === selectedTopicId }"
@click="emit('select-topic', t.id)"
>
{{ t.title }}
</li>
</ul>
<div class="new-topic">
<button v-if="!isCreating" @click="isCreating = true">+ New topic</button>
<form v-else @submit.prevent="submitNew">
<input v-model="newTitle" autofocus placeholder="Topic title (e.g. git-workflow)" />
<div class="form-buttons">
<button type="submit">Create</button>
<button type="button" @click="isCreating = false">Cancel</button>
</div>
</form>
</div>
<div class="subscriptions">
<h3>Subscribers</h3>
<ul class="sub-list">
<li v-for="p in projects" :key="p.id">
<label>
<input
type="checkbox"
:checked="isSubscribed(p.id)"
@change="toggleSubscription(p.id, ($event.target as HTMLInputElement).checked)"
/>
{{ p.title }}
</label>
</li>
</ul>
</div>
</section>
</template>
<style scoped>
.pane { background: var(--color-surface, #18181b); padding: 1rem; overflow-y: auto; }
header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; }
.always-on-toggle {
display: flex; align-items: center; gap: 0.4rem;
font-size: 0.85rem; opacity: 0.85; cursor: pointer;
user-select: none;
}
.always-on-toggle input { cursor: pointer; }
ul { list-style: none; padding: 0; margin: 1rem 0; }
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; }
li.active { background: var(--color-primary-bg, rgba(99,102,241,0.15)); }
li:hover { background: var(--color-hover, rgba(255,255,255,0.05)); }
.new-topic input {
width: 100%; margin-bottom: 0.5rem;
background: var(--color-bg, #111113); color: inherit;
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
padding: 0.5rem;
}
.form-buttons { display: flex; gap: 0.5rem; }
.subscriptions {
margin-top: 2rem;
border-top: 1px solid var(--color-border, #2a2a2e);
padding-top: 1rem;
}
.subscriptions h3 { font-size: 0.9em; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em; }
.sub-list li { cursor: default; }
.sub-list label { display: flex; gap: 0.5rem; align-items: center; cursor: pointer; }
button { cursor: pointer; }
</style>