feat(inception): UI — New-project modal step 2, InceptionCard on the project page, Rules tab shows excluded always-on rulebooks; REST exclusion routes (#2883, milestone 297 step 5)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 21s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Failing after 42s
CI & Build / Build & push image (push) Skipped

- components/InceptionCard.vue: the one form, two homes — mode="create" in
  the New-project modal's second step (emits the choices; the create carries
  `inception`), mode="decide" on ProjectView for the owner of an undecided
  project (loads that project's defaults, records the decision). Always-on
  rulebooks listed checked (uncheck = exclude), others unchecked (check =
  subscribe), design system select, seed-Systems toggle (disabled once the
  project has Systems). Tokens only; modal canon (#2855); .btn-* canon.
- ProjectView: the card while undecided, one "Inheritance decided <date> via
  … · …" line after; onDecided refreshes the project.
- ProjectRulesTab: "Excluded always-on rulebooks" section with include-back.
- api/inception.ts (types, fetchInceptionDefaults, decideInception);
  api/rulebooks.ts: ApplicableRules.excluded_always_on, exclude/include
  wrappers; REST POST/DELETE /api/projects/<id>/exclusions/rulebooks/<rb>.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 22:09:18 -04:00
co-authored by Claude Fable 5
parent c7a58bb610
commit 00c7badc3f
8 changed files with 367 additions and 12 deletions
+42
View File
@@ -0,0 +1,42 @@
/** Project inception (milestone 297): what a project was decided to inherit. */
import { apiGet, apiPost } from "@/api/client";
export interface InceptionChoices {
exclude_always_on_rulebooks: number[];
subscribe_rulebooks: number[];
design_system_id: number | null;
seed_systems: boolean;
}
export interface InceptionRecord {
decided_at: string;
decided_by: number | null;
via: "mcp" | "ui" | "legacy";
choices: InceptionChoices;
}
export interface InceptionDefaults {
always_on_rulebooks: { id: number; title: string }[];
other_rulebooks: { id: number; title: string }[];
excluded_always_on: { id: number; title: string }[];
subscribed_rulebooks: { id: number; title: string }[];
design_system_id: number | null;
design_systems: { id: number; title: string }[];
systems: number;
}
export interface InceptionDecision {
project_id: number;
inception: InceptionRecord;
effects: { excluded: number[]; subscribed: number[]; design_system_id: number | null; systems_seeded: string[] };
}
export const emptyChoices = (): InceptionChoices => ({
exclude_always_on_rulebooks: [], subscribe_rulebooks: [], design_system_id: null, seed_systems: false,
});
export const fetchInceptionDefaults = (projectId: number) =>
apiGet<InceptionDefaults>(`/api/projects/${projectId}/inception/defaults`);
export const decideInception = (projectId: number, choices: InceptionChoices) =>
apiPost<InceptionDecision>(`/api/projects/${projectId}/inception`, { choices });
+13
View File
@@ -71,6 +71,8 @@ export interface ApplicableRules {
}[];
truncated: boolean;
subscribed_rulebooks: { id: number; title: string }[];
/** Always-on rulebooks this project opted out of at inception (milestone 297). */
excluded_always_on: { id: number; title: string }[];
}
// ── Rulebooks ───────────────────────────────────────────────────────
@@ -181,3 +183,14 @@ export async function suppressTopicForProject(projectId: number, topicId: number
export async function unsuppressTopicForProject(projectId: number, topicId: number): Promise<void> {
return apiDelete(`/api/projects/${projectId}/suppressions/topics/${topicId}`);
}
// ── Always-on exclusions (milestone 297) ────────────────────────────────────
export async function excludeAlwaysOnRulebook(projectId: number, rulebookId: number): Promise<void> {
await apiPost(`/api/projects/${projectId}/exclusions/rulebooks/${rulebookId}`, {});
}
export async function includeAlwaysOnRulebook(projectId: number, rulebookId: number): Promise<void> {
await apiDelete(`/api/projects/${projectId}/exclusions/rulebooks/${rulebookId}`);
}
+189
View File
@@ -0,0 +1,189 @@
<script setup lang="ts">
/**
* The inception form (milestone 297): "what does this project inherit?"
*
* Two homes, one component. mode="create" rides the New-project modal's
* second step and only emits the choices (the project does not exist yet);
* mode="decide" sits on ProjectView for an undecided project, loads that
* project's current defaults, and records the decision itself.
*/
import { computed, onMounted, ref, watch } from "vue";
import { apiErrorMessage } from "@/api/client";
import { fetchDesignSystems } from "@/api/designSystems";
import {
decideInception, emptyChoices, fetchInceptionDefaults,
type InceptionChoices, type InceptionDecision, type InceptionDefaults,
} from "@/api/inception";
import { listRulebooks } from "@/api/rulebooks";
const props = withDefaults(defineProps<{
mode: "create" | "decide";
projectId?: number;
choices?: InceptionChoices;
}>(), { projectId: 0, choices: undefined });
const emit = defineEmits<{
"update:choices": [value: InceptionChoices];
decided: [decision: InceptionDecision];
}>();
const local = ref<InceptionChoices>(props.choices ? { ...props.choices } : emptyChoices());
const alwaysOn = ref<{ id: number; title: string }[]>([]);
const others = ref<{ id: number; title: string }[]>([]);
const designSystems = ref<{ id: number; title: string }[]>([]);
const systemsCount = ref(0);
const loading = ref(true);
const saving = ref(false);
const error = ref("");
function emitChoices() {
emit("update:choices", { ...local.value });
}
watch(local, emitChoices, { deep: true });
async function load() {
loading.value = true;
error.value = "";
try {
if (props.mode === "decide" && props.projectId) {
const d: InceptionDefaults = await fetchInceptionDefaults(props.projectId);
alwaysOn.value = d.always_on_rulebooks;
others.value = d.other_rulebooks;
designSystems.value = d.design_systems;
systemsCount.value = d.systems;
// Start from what stands today so "record" without changes is a true inherit-all.
local.value = {
exclude_always_on_rulebooks: d.excluded_always_on.map((r) => r.id),
subscribe_rulebooks: d.subscribed_rulebooks.map((r) => r.id),
design_system_id: d.design_system_id,
seed_systems: false,
};
} else {
const [rulebooks, ds] = await Promise.all([listRulebooks(), fetchDesignSystems()]);
alwaysOn.value = rulebooks.filter((r) => r.always_on).map((r) => ({ id: r.id, title: r.title }));
others.value = rulebooks.filter((r) => !r.always_on).map((r) => ({ id: r.id, title: r.title }));
designSystems.value = ds.design_systems.map((d) => ({ id: d.id, title: d.title }));
}
} catch (e: unknown) {
error.value = apiErrorMessage(e, "Could not load what this project could inherit");
} finally {
loading.value = false;
}
}
function inherits(id: number): boolean {
return !local.value.exclude_always_on_rulebooks.includes(id);
}
function toggleInherit(id: number) {
const list = local.value.exclude_always_on_rulebooks;
local.value.exclude_always_on_rulebooks = list.includes(id) ? list.filter((x) => x !== id) : [...list, id];
}
function subscribed(id: number): boolean {
return local.value.subscribe_rulebooks.includes(id);
}
function toggleSubscribe(id: number) {
const list = local.value.subscribe_rulebooks;
local.value.subscribe_rulebooks = list.includes(id) ? list.filter((x) => x !== id) : [...list, id];
}
const nothingToDecide = computed(
() => !alwaysOn.value.length && !others.value.length && !designSystems.value.length,
);
async function record() {
if (!props.projectId) return;
saving.value = true;
error.value = "";
try {
const decision = await decideInception(props.projectId, local.value);
emit("decided", decision);
} catch (e: unknown) {
error.value = apiErrorMessage(e, "Could not record the decision");
} finally {
saving.value = false;
}
}
onMounted(load);
</script>
<template>
<section class="inception" aria-labelledby="inception-title">
<h3 id="inception-title" class="inception-title">What does this project inherit?</h3>
<p class="inception-lede">
A project's inheritance is a decision, not a default. Until it is recorded,
every always-on rulebook binds, nothing is subscribed, and there is no design
system or Systems.
</p>
<p v-if="loading" class="inception-muted">Loading…</p>
<p v-else-if="error" class="error-msg">{{ error }}</p>
<template v-else>
<div v-if="alwaysOn.length" class="inception-group">
<h4>Always-on rulebooks</h4>
<p class="inception-muted">Checked = inherits. Uncheck to exclude a rulebook for this project only.</p>
<label v-for="rb in alwaysOn" :key="rb.id" class="inception-choice">
<input type="checkbox" :checked="inherits(rb.id)" @change="toggleInherit(rb.id)" />
<span>{{ rb.title }}</span>
</label>
</div>
<div v-if="others.length" class="inception-group">
<h4>Subscribe to rulebooks</h4>
<label v-for="rb in others" :key="rb.id" class="inception-choice">
<input type="checkbox" :checked="subscribed(rb.id)" @change="toggleSubscribe(rb.id)" />
<span>{{ rb.title }}</span>
</label>
</div>
<div class="inception-group">
<h4>Design system</h4>
<select v-model="local.design_system_id" class="inception-select" aria-label="Design system">
<option :value="null">None</option>
<option v-for="ds in designSystems" :key="ds.id" :value="ds.id">{{ ds.title }}</option>
</select>
</div>
<div class="inception-group">
<label class="inception-choice">
<input type="checkbox" v-model="local.seed_systems" :disabled="systemsCount > 0" />
<span>
Seed the standard starter Systems (CI &amp; Release, Auth &amp; Access, Data Model &amp; Storage, …)
<em v-if="systemsCount > 0" class="inception-muted"> — this project already has {{ systemsCount }}</em>
</span>
</label>
</div>
<p v-if="nothingToDecide" class="inception-muted">
Nothing to inherit yet on this install — recording still settles the question.
</p>
<div v-if="mode === 'decide'" class="inception-actions">
<button class="btn-primary" :disabled="saving" @click="record">
{{ saving ? "Recording" : "Record decision" }}
</button>
</div>
</template>
</section>
</template>
<style scoped>
.inception {
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
padding: 1.25rem 1.5rem;
margin-bottom: 1.5rem;
}
.inception-title { margin: 0 0 0.35rem; font-size: 1.05rem; }
.inception-lede { margin: 0 0 1rem; color: var(--fs-text-secondary); font-size: 0.9rem; }
.inception-muted { color: var(--fs-text-tertiary); font-size: 0.85rem; margin: 0 0 0.35rem; }
.inception-group { margin-bottom: 1rem; }
.inception-group h4 { margin: 0 0 0.35rem; font-size: 0.9rem; font-weight: 500; }
.inception-choice { display: flex; align-items: flex-start; gap: 0.5rem; font-size: 0.9rem; margin: 0.25rem 0; }
.inception-choice input { margin-top: 0.2rem; accent-color: var(--fs-accent); }
.inception-select {
padding: 0.45rem 0.7rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-page);
color: var(--fs-text-primary);
font-size: 0.9rem;
}
.inception-actions { display: flex; justify-content: flex-end; margin-top: 0.5rem; }
.error-msg { color: var(--fs-error); font-size: 0.9rem; }
</style>
@@ -2,10 +2,18 @@
import { ref, onMounted, watch } from "vue";
import { useRouter } from "vue-router";
import {
getProjectApplicableRules, subscribeProject, unsubscribeProject,
listRulebooks, getRule, createProjectRule, deleteRule,
suppressRuleForProject, unsuppressRuleForProject,
suppressTopicForProject, unsuppressTopicForProject,
getProjectApplicableRules,
subscribeProject,
unsubscribeProject,
listRulebooks,
getRule,
createProjectRule,
deleteRule,
suppressRuleForProject,
unsuppressRuleForProject,
suppressTopicForProject,
unsuppressTopicForProject,
includeAlwaysOnRulebook,
} from "@/api/rulebooks";
import type { ApplicableRules, Rulebook } from "@/api/rulebooks";
@@ -35,6 +43,11 @@ async function subscribe(rulebookId: number) {
await load();
}
async function includeBack(rulebookId: number) {
await includeAlwaysOnRulebook(props.projectId, rulebookId);
await load();
}
async function unsubscribe(rulebookId: number) {
if (!confirm("Unsubscribe from this rulebook for this project?")) return;
await unsubscribeProject(props.projectId, rulebookId);
@@ -172,6 +185,17 @@ watch(() => props.projectId, load);
</div>
</section>
<section v-if="applicable.excluded_always_on?.length" class="excluded">
<h3>Excluded always-on rulebooks</h3>
<p class="excluded-note">Opted out at inception these do not bind this project.</p>
<div class="chips">
<span v-for="rb in applicable.excluded_always_on" :key="rb.id" class="chip chip-excluded">
<a @click="openInRulesView(rb.id)">{{ rb.title }}</a>
<button class="chip-remove" @click="includeBack(rb.id)" aria-label="Include again" title="Include again"></button>
</span>
</div>
</section>
<section class="project-rules">
<div class="section-head">
<h3>Project rules</h3>
@@ -321,6 +345,9 @@ watch(() => props.projectId, load);
</template>
<style scoped>
.excluded-note { margin: 0 0 0.5rem; color: var(--fs-text-tertiary); font-size: 0.85rem; }
.chip-excluded { opacity: 0.8; text-decoration: line-through; }
.chip-excluded .chip-remove { text-decoration: none; }
.rules-tab { padding: 1rem; }
h3 {
font-size: 0.9em; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em;
+28 -8
View File
@@ -1,7 +1,9 @@
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { useRouter } from "vue-router";
import { apiGet, apiPost } from "@/api/client";
import { apiGet, apiPost, apiErrorMessage } from "@/api/client";
import { emptyChoices, type InceptionChoices } from "@/api/inception";
import InceptionCard from "@/components/InceptionCard.vue";
import { useToastStore } from "@/stores/toast";
import { milestoneColor } from "@/utils/palette";
@@ -47,6 +49,9 @@ const newTitle = ref("");
const newDescription = ref("");
const newGoal = ref("");
const creating = ref(false);
// Step 2 of the modal (milestone 297): what the new project inherits.
const modalStep = ref<1 | 2>(1);
const newInception = ref<InceptionChoices>(emptyChoices());
const filteredProjects = computed(() => {
if (activeTab.value === "all") return projects.value;
@@ -73,6 +78,8 @@ function openNewProjectModal() {
newTitle.value = "";
newDescription.value = "";
newGoal.value = "";
modalStep.value = 1;
newInception.value = emptyChoices();
showNewProjectModal.value = true;
}
@@ -88,13 +95,15 @@ async function createProject() {
title: newTitle.value.trim(),
description: newDescription.value.trim() || undefined,
goal: newGoal.value.trim() || undefined,
// The decision rides the create: a project made here is never undecided.
inception: newInception.value,
});
projects.value.unshift(project);
showNewProjectModal.value = false;
toast.show("Project created");
router.push(`/projects/${project.id}`);
} catch {
toast.show("Failed to create project", "error");
} catch (e: unknown) {
toast.show(apiErrorMessage(e, "Failed to create project"), "error");
} finally {
creating.value = false;
}
@@ -266,8 +275,9 @@ function overallPct(project: Project): { total: number; pct: number } {
<teleport to="body">
<div v-if="showNewProjectModal" class="modal-overlay" @click.self="closeModal">
<div class="modal-card">
<h3 class="modal-title">New Project</h3>
<div class="modal-field">
<h3 class="modal-title">{{ modalStep === 1 ? "New Project" : "New Project — what it inherits" }}</h3>
<InceptionCard v-if="modalStep === 2" mode="create" v-model:choices="newInception" />
<div v-if="modalStep === 1" class="modal-field">
<label>Title <span class="required">*</span></label>
<input
v-model="newTitle"
@@ -275,11 +285,11 @@ function overallPct(project: Project): { total: number; pct: number } {
class="modal-input"
placeholder="Project title"
autofocus
@keydown.enter="createProject"
@keydown.enter="modalStep = 2"
@keydown.escape="closeModal"
/>
</div>
<div class="modal-field">
<div v-if="modalStep === 1" class="modal-field">
<label>Goal</label>
<input
v-model="newGoal"
@@ -289,7 +299,7 @@ function overallPct(project: Project): { total: number; pct: number } {
@keydown.escape="closeModal"
/>
</div>
<div class="modal-field">
<div v-if="modalStep === 1" class="modal-field">
<label>Description</label>
<textarea
v-model="newDescription"
@@ -301,7 +311,17 @@ function overallPct(project: Project): { total: number; pct: number } {
</div>
<div class="modal-actions">
<button class="modal-btn" @click="closeModal">Cancel</button>
<button v-if="modalStep === 2" class="modal-btn" @click="modalStep = 1">Back</button>
<button
v-if="modalStep === 1"
class="modal-btn modal-btn-primary"
@click="modalStep = 2"
:disabled="!newTitle.trim()"
>
Next
</button>
<button
v-else
class="modal-btn modal-btn-primary"
@click="createProject"
:disabled="!newTitle.trim() || creating"
+31
View File
@@ -11,6 +11,9 @@ import ShareDialog from "@/components/ShareDialog.vue";
import ProjectDesignTab from "@/components/ProjectDesignTab.vue";
import ProjectRulesTab from "@/components/rules/ProjectRulesTab.vue";
import SystemsSection from "@/components/SystemsSection.vue";
import InceptionCard from "@/components/InceptionCard.vue";
import { fmtDate } from "@/utils/dateFormat";
import type { InceptionDecision, InceptionRecord } from "@/api/inception";
import {
fetchDesignSystems,
setProjectDesignSystem,
@@ -50,6 +53,7 @@ interface Project {
color: string | null;
design_system_id: number | null;
forge_connection_id: number | null;
inception?: InceptionRecord | null;
permission?: string;
created_at: string;
updated_at: string;
@@ -75,6 +79,12 @@ interface NoteItem {
const route = useRoute();
const router = useRouter();
const toast = useToastStore();
function onInceptionDecided(decision: InceptionDecision) {
if (project.value) project.value.inception = decision.inception;
toast.show("Inheritance recorded");
void loadProject();
}
const tasksStore = useTasksStore();
const project = ref<Project | null>(null);
@@ -695,6 +705,26 @@ async function confirmDelete() {
</p>
</div>
<!-- Inception (milestone 297): the owner of an undecided project is asked
what it inherits; once recorded, one line says what was decided. -->
<InceptionCard
v-if="project.inception == null && isProjectOwner"
mode="decide"
:project-id="projectId"
@decided="onInceptionDecided"
/>
<p v-else-if="project.inception" class="inception-line">
Inheritance decided {{ fmtDate(project.inception.decided_at) }} via {{ project.inception.via }}
<template v-if="project.inception.choices.exclude_always_on_rulebooks.length">
· excludes {{ project.inception.choices.exclude_always_on_rulebooks.length }} always-on rulebook(s)
</template>
<template v-if="project.inception.choices.subscribe_rulebooks.length">
· subscribes {{ project.inception.choices.subscribe_rulebooks.length }}
</template>
· design system {{ project.inception.choices.design_system_id ? "#" + project.inception.choices.design_system_id : "none" }}
<template v-if="project.inception.choices.seed_systems"> · Systems seeded</template>
</p>
<!-- Summary stat chips -->
<div v-if="project.summary" class="summary-stats">
<div class="stat-chip stat-todo">
@@ -1197,6 +1227,7 @@ async function confirmDelete() {
min-width: 200px;
}
.inception-line { margin: 0 0 1rem; color: var(--fs-text-secondary); font-size: 0.85rem; }
.project-title-input {
flex: 1;
font-size: 1.75rem;