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
- 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>
213 lines
7.2 KiB
Vue
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>
|