Files
FabledScribe/frontend/src/components/ProjectFamilyTab.vue
T
bvandeusenandClaude Opus 5.5 201e09901b
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 1m0s
CI & Build / integration (push) Successful in 1m8s
CI & Build / Python tests (push) Failing after 1m28s
CI & Build / Build & push image (push) Skipped
feat(family): the adoption ledger - assessment, the conflict order, owed->task, recheck, the adoption matrix (milestone 463 step 4, #4990)
- services/family_adoption.py: assess one project against one canon idea by
  the four outcomes in order (exempt, variant, adopted, owed). Every outcome
  needs a reason and adopted needs evidence. The engine records the precedents
  itself: this idea's answers elsewhere, and this project's answers to the
  nearest ideas. The same answer given twice records nothing.
- owed files a task in the OWING project, tagged to the System matching the
  idea's canonical area, naming the gap and the reference for that project's
  language. The task follows the answer: adopted closes it, exempt or variant
  cancels it, owed again reopens it. Each move is logged on the task.
- the conflict order is enforced: every ground above the deciding one must
  say why it did not decide. The losing side is folded into the idea's note as
  a trap, an alternative or a condition branch, the version moves, and both
  rows are answered against the revision.
- recheck is derived (row version != idea version). family.revise moves the
  version when substance changes. undo covers a project's latest answer too.
- set_family_references names the reference implementations.
- MCP: get/list/assess adoption, resolve_family_conflict, revise_family_idea,
  set_family_references. Web: GET /api/family/matrix.
- UI: an adoption matrix on /family (platform filter, cell detail with reason,
  recheck and owed-task link) and the same matrix narrowed to one project on
  its Family tab. The decision log now reads project-level decisions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:05:00 -04:00

213 lines
7.2 KiB
Vue

<script setup lang="ts">
/**
* A project's place in the family (milestone 463): which platforms it is.
*
* Membership is what makes "does this family idea reach this project?" a
* lookup rather than a judgment — an idea is for some platforms, and it
* reaches every project that is one of them. So the answers here matter, and
* each kind of answer is shown as what it is:
*
* - Yes — a person said so.
* - Detected — a marker file in a bound repo said so. Detection only adds,
* and only where nobody has answered; it never overrides a Yes or a No.
* - No — a person said this project is NOT that platform. Kept as a row, so
* the next coverage refresh cannot detect it straight back.
* - No answer — detection may decide on the next refresh.
*
* Each change is saved as it is made: one platform, one request, applied
* whole or refused whole.
*
* Below the platforms: this project's answer to each canon idea that reaches
* it — the adoption matrix narrowed to one row, so it reads exactly as the
* family page does.
*/
import { computed, onMounted, ref, watch } from "vue";
import { apiErrorMessage } from "@/api/client";
import {
fetchProjectPlatforms, setProjectPlatforms,
type PlatformState, type ProjectPlatform, type SettablePlatformState,
} from "@/api/platforms";
import { usePlatformsStore } from "@/stores/platforms";
import FamilyAdoptionMatrix from "@/components/FamilyAdoptionMatrix.vue";
const props = defineProps<{ projectId: number; canWrite: boolean }>();
const platformsStore = usePlatformsStore();
const answers = ref<ProjectPlatform[]>([]);
const loading = ref(false);
const failed = ref(false);
const saving = ref<string | null>(null);
const error = ref("");
const LABELS: Record<PlatformState, string> = {
declared: "Yes",
detected: "Detected",
rejected: "No",
};
async function load() {
loading.value = true;
failed.value = false;
try {
const [, rows] = await Promise.all([
platformsStore.fetchCatalog(),
fetchProjectPlatforms(props.projectId),
]);
answers.value = rows;
} catch {
// Said out loud: "couldn't load" must not read as "no platforms".
failed.value = true;
} finally {
loading.value = false;
}
}
const stateBySlug = computed(() => {
const out: Record<string, PlatformState> = {};
for (const a of answers.value) out[a.slug] = a.state;
return out;
});
const rows = computed(() =>
platformsStore.catalog.map((p) => ({
...p,
state: stateBySlug.value[p.slug] ?? null,
})),
);
const memberCount = computed(
() => answers.value.filter((a) => a.state !== "rejected").length,
);
async function answer(slug: string, value: string) {
const state: SettablePlatformState = value === "" ? null : (value as SettablePlatformState);
saving.value = slug;
error.value = "";
try {
answers.value = await setProjectPlatforms(props.projectId, { [slug]: state });
} catch (e: unknown) {
error.value = apiErrorMessage(e, "Could not save that answer");
} finally {
saving.value = null;
}
}
onMounted(load);
watch(() => props.projectId, load);
</script>
<template>
<div class="pft">
<header class="pft-head">
<h3 class="pft-title">Platforms</h3>
<p class="pft-muted">
What this project is built on or ships as. Ideas the family has proven
for a platform reach every project that is one, and each project
answers them — adopts, varies with a reason, or owes the work.
<router-link to="/family">The family's ideas and decisions</router-link>
</p>
</header>
<p v-if="loading" class="pft-muted">Loading…</p>
<div v-else-if="failed" class="pft-note">
<strong>The platforms couldn't load.</strong>
<p>Nothing is known either way — this is a failure, not an empty answer.</p>
<button class="btn-secondary btn-sm" @click="load">Try again</button>
</div>
<p v-else-if="!rows.length" class="pft-muted">
This install has no platforms in its catalog yet. An administrator adds
them in Settings.
</p>
<template v-else>
<p class="pft-summary">
<template v-if="memberCount">
A member of {{ memberCount }} platform{{ memberCount === 1 ? "" : "s" }}.
</template>
<template v-else>
Not a member of any platform yet — bind a repo and refresh coverage
to detect them, or answer below.
</template>
</p>
<p v-if="error" class="error-msg">{{ error }}</p>
<ul class="pft-list">
<li v-for="p in rows" :key="p.id" class="pft-row">
<div class="pft-name">
<span>{{ p.name }}</span>
<span v-if="p.state" :class="['pft-tag', `pft-${p.state}`]">{{ LABELS[p.state] }}</span>
<span v-if="p.description" class="pft-desc">{{ p.description }}</span>
</div>
<select
v-if="canWrite"
class="pft-select"
:value="p.state === 'detected' ? 'detected' : (p.state ?? '')"
:disabled="saving === p.slug"
:aria-label="`Is this project ${p.name}?`"
@change="answer(p.slug, ($event.target as HTMLSelectElement).value)"
>
<option v-if="p.state === 'detected'" value="detected" disabled>Detected</option>
<option value="declared">Yes</option>
<option value="rejected">No</option>
<option value="">No answer</option>
</select>
</li>
</ul>
</template>
<section class="pft-answers" aria-labelledby="pft-answers-title">
<h3 id="pft-answers-title" class="pft-title">Family ideas</h3>
<FamilyAdoptionMatrix :key="projectId" :project-id="projectId" />
</section>
</div>
</template>
<style scoped>
.pft { padding: 0.25rem 0; }
.pft-head { margin-bottom: 1rem; }
.pft-title { margin: 0 0 0.35rem; font-size: 1rem; }
.pft-muted { color: var(--fs-text-tertiary); font-size: 0.875rem; margin: 0; }
.pft-summary { color: var(--fs-text-secondary); font-size: 0.9rem; margin: 0 0 0.75rem; }
.pft-note {
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-md);
padding: 0.85rem 1rem;
}
.pft-note p { margin: 0.35rem 0 0.6rem; color: var(--fs-text-secondary); font-size: 0.875rem; }
.pft-list { list-style: none; margin: 0; padding: 0; }
.pft-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.6rem 0;
border-bottom: 1px solid var(--fs-border-color);
}
.pft-name { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.25rem 0.5rem; min-width: 0; }
.pft-desc { flex-basis: 100%; color: var(--fs-text-tertiary); font-size: 0.8rem; }
.pft-tag {
font-size: 0.75rem;
padding: 0.05rem 0.45rem;
border-radius: var(--fs-radius-sm);
border: 1px solid var(--fs-border-color);
color: var(--fs-text-secondary);
}
.pft-declared { color: var(--fs-text-primary); }
.pft-rejected { color: var(--fs-text-tertiary); text-decoration: line-through; }
.pft-select {
padding: 0.35rem 0.6rem;
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.85rem;
flex-shrink: 0;
}
.pft-answers { margin-top: 1.75rem; }
@media (max-width: 640px) {
.pft-row { flex-direction: column; align-items: stretch; }
}
</style>