Files
FabledGrimoire/frontend/src/components/FamilyAdoptionMatrix.vue
T
bvandeusenandClaude Opus 5.5 5f41dbd283
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 58s
CI & Build / integration (push) Successful in 1m26s
CI & Build / Python tests (push) Successful in 2m9s
CI & Build / Build & push image (push) Successful in 1m27s
feat(family): the shape ledger judges against family ideas, across languages (milestone 463 step 5, #4991)
- classify_shapes takes idea_id: the shape is judged against the idea's
  reference in its language, or its first.
- A shape classified against a canon idea's reference moves the project's
  adoption row (instance -> adopted, variant -> variant). Withdrawing the
  shapes that gave an answer returns it to unassessed. This runs on
  classify_shapes, the sweep, confirm_proposals and the coverage refresh.
- assess and undo refuse an answer the shapes contradict. A conflict
  resolution re-judges the variant shapes on both sides.
- The proposer's family arm offers a canon idea's reference on a shared
  platform, in any language. It proposes only when the reference is the top
  hit over every readable snippet and scores at least 0.70. The pairs this
  was measured on are recorded beside _FAMILY_FLOOR. The proposer is now v6.
- The matrix cell shows the code that answers it.

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

341 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/**
* The adoption matrix (milestone 463 step 4): every project × every canon
* family idea that reaches it, and the project's answer.
*
* Nobody approves an answer — the agent assesses each project against the
* four outcomes and records why. This is where a person sees that happening:
* which projects adopted, which depart and for what fact, which owe the work
* (with the task filed in that project), and which answers were given
* against an older version of the idea and need a recheck. Disagreeing is an
* undo in the decision log below.
*
* `projectId` narrows it to one project's answers — the same component, so a
* project's Family tab and this page cannot describe a cell differently.
*/
import { computed, onMounted, ref, watch } from "vue";
import {
fetchAdoptionMatrix,
type AdoptionCell, type AdoptionMatrix, type AdoptionStatus,
} from "@/api/family";
import { usePlatformsStore } from "@/stores/platforms";
import { fmtStamp } from "@/utils/dateFormat";
import { recordHref } from "@/utils/recordHref";
const props = defineProps<{ projectId?: number }>();
const platformsStore = usePlatformsStore();
const matrix = ref<AdoptionMatrix | null>(null);
const platform = ref("");
const loading = ref(true);
const failed = ref(false);
const selectedKey = ref<string | null>(null);
const LABELS: Record<AdoptionStatus, string> = {
unassessed: "Not assessed",
adopted: "Adopted",
variant: "Variant",
exempt: "Doesn't apply",
owed: "Owed",
};
async function load() {
loading.value = true;
failed.value = false;
try {
const [, data] = await Promise.all([
platformsStore.fetchCatalog(),
fetchAdoptionMatrix({ platform: platform.value, projectId: props.projectId }),
]);
matrix.value = data;
if (selectedKey.value && !data.cells.some((c) => key(c) === selectedKey.value)) {
selectedKey.value = null;
}
} catch {
// A matrix that failed to load must not read as "nobody owes anything".
failed.value = true;
} finally {
loading.value = false;
}
}
function key(c: { project_id: number; idea_id: number }): string {
return `${c.project_id}:${c.idea_id}`;
}
const cellByKey = computed(() => {
const out: Record<string, AdoptionCell> = {};
for (const c of matrix.value?.cells ?? []) out[key(c)] = c;
return out;
});
const selected = computed(() =>
selectedKey.value ? cellByKey.value[selectedKey.value] ?? null : null,
);
const outcomeTest = computed(() => {
const out: Partial<Record<AdoptionStatus, string>> = {};
for (const o of matrix.value?.outcomes ?? []) out[o.key] = o.test;
return out;
});
/** The answers that want attention: owed, or given against an older canon. */
const summary = computed(() => {
const cells = matrix.value?.cells ?? [];
return {
owed: cells.filter((c) => c.status === "owed").length,
recheck: cells.filter((c) => c.needs_recheck).length,
unassessed: cells.filter((c) => c.status === "unassessed").length,
};
});
function toggle(c: AdoptionCell) {
selectedKey.value = selectedKey.value === key(c) ? null : key(c);
}
function cellTitle(c: AdoptionCell): string {
const parts = [LABELS[c.status]];
if (c.needs_recheck) parts.push(`answered against v${c.canon_version}, now v${c.idea_version}`);
if (c.reason) parts.push(c.reason);
return parts.join(" — ");
}
onMounted(load);
watch(platform, load);
defineExpose({ load });
</script>
<template>
<div class="fam-matrix-block">
<div class="fam-matrix-head">
<p class="fam-muted">
<template v-if="projectId">This project's answer to each family idea that reaches it.</template>
<template v-else>Each project's answer to each canon idea that reaches it.</template>
The agent answers by the four outcomes below and records why; “recheck” means
the idea changed after it was answered.
</p>
<label v-if="platformsStore.catalog.length" class="fam-select-label">
Platform
<select v-model="platform" class="fam-select">
<option value="">All</option>
<option v-for="p in platformsStore.catalog" :key="p.slug" :value="p.slug">{{ p.name }}</option>
</select>
</label>
</div>
<p v-if="loading && !matrix" class="fam-muted">Loading…</p>
<div v-else-if="failed" class="fam-note">
<strong>The adoption matrix couldn't load.</strong>
<p>Nothing is known either way — this is a failure, not an empty ledger.</p>
<button class="btn-secondary btn-sm" @click="load">Try again</button>
</div>
<template v-else-if="matrix">
<p v-if="!matrix.ideas.length" class="fam-muted">
<template v-if="platform">No canon idea is for this platform yet.</template>
<template v-else>No idea is canon yet, so nothing has reached a project.</template>
</p>
<p v-else-if="!matrix.projects.length" class="fam-muted">
<template v-if="projectId">
No canon idea reaches this project — it is on none of their platforms.
</template>
<template v-else>
No project is on a platform any canon idea is for. A project joins a platform
on its Family tab, or by detection from a bound repo.
</template>
</p>
<template v-else>
<p class="fam-summary">
{{ summary.owed }} owed · {{ summary.recheck }} to recheck ·
{{ summary.unassessed }} not assessed
</p>
<div class="fam-matrix-wrap" role="region" aria-label="Adoption matrix" tabindex="0">
<table class="fam-matrix">
<thead>
<tr>
<th scope="col" class="fam-corner">Project</th>
<th v-for="idea in matrix.ideas" :key="idea.note_id" scope="col">
<router-link
:to="recordHref({ id: idea.note_id, is_task: idea.is_task, note_type: idea.note_type })"
:title="idea.applies_when ? `Applies when: ${idea.applies_when}` : undefined"
>{{ idea.title }}</router-link>
<span class="fam-version">v{{ idea.canon_version }}</span>
</th>
</tr>
</thead>
<tbody>
<tr v-for="p in matrix.projects" :key="p.id">
<th scope="row">
<router-link :to="`/projects/${p.id}`">{{ p.title }}</router-link>
</th>
<td v-for="idea in matrix.ideas" :key="idea.note_id">
<button
v-if="cellByKey[`${p.id}:${idea.note_id}`]"
type="button"
:class="['fam-cell', `fam-cell-${cellByKey[`${p.id}:${idea.note_id}`].status}`,
{ 'fam-cell-on': selectedKey === `${p.id}:${idea.note_id}` }]"
:aria-pressed="selectedKey === `${p.id}:${idea.note_id}`"
:title="cellTitle(cellByKey[`${p.id}:${idea.note_id}`])"
@click="toggle(cellByKey[`${p.id}:${idea.note_id}`])"
>
{{ LABELS[cellByKey[`${p.id}:${idea.note_id}`].status] }}
<span v-if="cellByKey[`${p.id}:${idea.note_id}`].needs_recheck" class="fam-recheck">recheck</span>
</button>
<span v-else class="fam-cell-none" title="Not on this idea's platforms">—</span>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="selected" class="fam-detail" aria-live="polite">
<div class="fam-detail-head">
<strong>{{ selected.project_title }} · {{ selected.idea_title }}</strong>
<span :class="['fam-cell', `fam-cell-${selected.status}`]">{{ LABELS[selected.status] }}</span>
<button type="button" class="btn-ghost btn-sm" @click="selectedKey = null">Close</button>
</div>
<p v-if="selected.reason" class="fam-detail-reason">{{ selected.reason }}</p>
<p v-else-if="!selected.reached" class="fam-muted">
The idea reaches this project, but nobody has been asked yet — whoever promoted
it could not write here. The project's own agent answers it on its next pass.
</p>
<p v-else-if="selected.status === 'unassessed'" class="fam-muted">
Waiting for the agent to assess it against the four outcomes.
</p>
<p v-if="selected.needs_recheck" class="fam-detail-recheck">
Answered against v{{ selected.canon_version }}; the idea is now
v{{ selected.idea_version }}, so this answer needs a recheck.
</p>
<p v-if="!selected.in_scope" class="fam-muted">
This project is no longer on the idea's platforms; the answer is kept as history.
</p>
<p v-if="selected.owed_task" class="fam-detail-task">
Owed task:
<router-link :to="recordHref({ id: selected.owed_task.id, is_task: true })">
{{ selected.owed_task.title }}
</router-link>
<span class="fam-muted">({{ selected.owed_task.status.replace("_", " ") }})</span>
</p>
<div v-if="selected.shapes.length" class="fam-detail-shapes">
<p>In this project's code — the shape ledger classifies:</p>
<ul>
<li v-for="s in selected.shapes" :key="`${s.path}::${s.symbol}`">
<code>{{ s.path }} · {{ s.symbol }}</code>
<span class="fam-muted">{{ s.status === "variant" ? "variant" : "instance" }}</span>
</li>
</ul>
</div>
<p v-if="selected.assessed_at" class="fam-muted">
Answered {{ fmtStamp(selected.assessed_at) }}
<template v-if="selected.decided_via">by the {{ selected.decided_via }}</template>
<template v-if="selected.canon_version"> against v{{ selected.canon_version }}</template>.
</p>
</div>
</template>
<details class="fam-outcomes">
<summary>The four outcomes</summary>
<dl>
<template v-for="o in matrix.outcomes" :key="o.key">
<dt>{{ LABELS[o.key] }}</dt>
<dd>{{ outcomeTest[o.key] }}</dd>
</template>
</dl>
</details>
</template>
</div>
</template>
<style scoped>
.fam-matrix-head { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 0.5rem 1rem; margin-bottom: 0.75rem; }
.fam-matrix-head .fam-muted { flex: 1; min-width: 16rem; max-width: 52rem; }
.fam-muted { color: var(--fs-text-tertiary); font-size: 0.85rem; margin: 0; }
.fam-note {
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-md);
padding: 0.85rem 1rem;
}
.fam-note p { margin: 0.35rem 0 0.6rem; color: var(--fs-text-secondary); font-size: 0.875rem; }
.fam-select-label { display: flex; align-items: center; gap: 0.4rem; font-size: 0.85rem; color: var(--fs-text-secondary); }
.fam-select {
padding: 0.3rem 0.5rem;
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;
}
.fam-summary { margin: 0 0 0.5rem; font-size: 0.85rem; color: var(--fs-text-secondary); }
.fam-matrix-wrap {
overflow-x: auto;
max-width: 100%;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-md);
}
.fam-matrix-wrap:focus-visible { outline: none; box-shadow: var(--fs-focus-ring); }
button.fam-cell:focus-visible { outline: none; box-shadow: var(--fs-focus-ring); }
.fam-matrix { border-collapse: collapse; font-size: 0.85rem; min-width: 100%; }
.fam-matrix th, .fam-matrix td {
padding: 0.45rem 0.6rem;
border-bottom: 1px solid var(--fs-border-color);
text-align: left;
vertical-align: middle;
}
.fam-matrix thead th { font-weight: 500; min-width: 9rem; max-width: 14rem; }
.fam-matrix tbody th { font-weight: 500; white-space: nowrap; }
.fam-matrix tbody tr:last-child th, .fam-matrix tbody tr:last-child td { border-bottom: none; }
.fam-corner { color: var(--fs-text-tertiary); }
.fam-version { display: block; color: var(--fs-text-tertiary); font-size: 0.75rem; font-weight: 400; }
.fam-cell {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font: inherit;
font-size: 0.78rem;
padding: 0.1rem 0.5rem;
border-radius: var(--fs-radius-pill);
border: 1px solid var(--fs-border-color);
background: transparent;
color: var(--fs-text-secondary);
white-space: nowrap;
}
button.fam-cell { cursor: pointer; }
button.fam-cell:hover { background: var(--fs-surface-hover); }
.fam-cell-on { outline: 2px solid var(--fs-text-secondary); outline-offset: 1px; }
.fam-cell-adopted { background: var(--fs-status-done-bg); color: var(--fs-status-done-fg); }
.fam-cell-owed { background: var(--fs-status-todo-bg); color: var(--fs-status-todo-fg); }
.fam-cell-variant { background: var(--fs-status-in-progress-bg); color: var(--fs-status-in-progress-fg); }
.fam-cell-exempt { color: var(--fs-status-cancelled-fg); }
.fam-cell-unassessed { color: var(--fs-text-tertiary); border-style: dashed; }
.fam-recheck {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--fs-text-primary);
}
.fam-cell-none { color: var(--fs-text-tertiary); }
.fam-detail {
margin-top: 0.75rem;
padding: 0.75rem 1rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-md);
background: var(--fs-surface-raised);
}
.fam-detail-head { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem 0.6rem; }
.fam-detail-head .btn-ghost { margin-left: auto; }
.fam-detail p { margin: 0.4rem 0 0; font-size: 0.875rem; }
.fam-detail-reason { color: var(--fs-text-primary); }
.fam-detail-recheck { color: var(--fs-text-secondary); }
.fam-detail-shapes ul { margin: 0.25rem 0 0; padding-left: 1.1rem; font-size: 0.85rem; }
.fam-detail-shapes li { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0 0.5rem; overflow-wrap: anywhere; }
.fam-detail-shapes code { font-family: var(--fs-font-mono); }
.fam-outcomes { margin-top: 0.75rem; font-size: 0.85rem; color: var(--fs-text-secondary); }
.fam-outcomes summary { cursor: pointer; color: var(--fs-text-secondary); }
.fam-outcomes dl { margin: 0.5rem 0 0; }
.fam-outcomes dt { font-weight: 500; color: var(--fs-text-primary); }
.fam-outcomes dd { margin: 0 0 0.4rem 1rem; }
</style>