feat(family): platforms declared at inception and detected from bound repos (milestone 463 step 2, #4988)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 21s
CI & Build / TypeScript typecheck (push) Successful in 1m10s
CI & Build / integration (push) Successful in 1m48s
CI & Build / Python tests (push) Successful in 2m40s
CI & Build / Build & push image (push) Failing after 44s

A project's platforms decide which family ideas reach it. This step makes membership answerable from every door:

- services/platforms.py: the global catalog (writes are admin-only and duplicate-gated by slug); pure marker detection; and membership reads and writes. Detection only ADDS, and only where nobody has answered. It never overrides a declared or rejected row and never removes one.
- coverage: the archive scan now carries every path, and the refresh runs detection fail-open.
- inception: a platforms choice (slugs, or null for unanswered). The list is the whole answer: members left out of it become rejected.
- MCP: list_platforms and set_project_platforms; enter_project and get_project carry the project's platforms.
- REST: /api/platforms (admin writes) and /api/projects/<id>/platforms.
- UI: a platforms checklist on the inception card, a Family tab on ProjectView, and a Platforms admin tab in Settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 09:51:28 -04:00
co-authored by Claude Opus 5.5
parent e68ccc5884
commit 07d2542479
27 changed files with 1638 additions and 51 deletions
@@ -0,0 +1,200 @@
<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.
*/
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";
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.
</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>
</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;
}
@media (max-width: 640px) {
.pft-row { flex-direction: column; align-items: stretch; }
}
</style>