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
+15 -2
View File
@@ -1,9 +1,14 @@
/** Project inception (milestone 297): what a project was decided to inherit. */
import { apiGet, apiPost } from "@/api/client";
import type { ProjectPlatform } from "@/api/platforms";
export interface InceptionChoices {
design_system_id: number | null;
seed_systems: boolean;
/** Platform slugs (milestone 463). null = not answered; a list is the
* whole answer — members left out of it are recorded as "not this".
* Absent on records decided before the question existed. */
platforms?: string[] | null;
}
export interface InceptionRecord {
@@ -17,16 +22,24 @@ export interface InceptionDefaults {
design_system_id: number | null;
design_systems: { id: number; title: string }[];
systems: number;
platforms: { slug: string; name: string }[];
/** What detection (or an earlier answer) already says about the project. */
project_platforms: ProjectPlatform[];
}
export interface InceptionDecision {
project_id: number;
inception: InceptionRecord;
effects: { design_system_id: number | null; systems_seeded: string[] };
effects: {
design_system_id: number | null;
systems_seeded: string[];
/** The project's answers after the decision; null when left unstated. */
platforms: ProjectPlatform[] | null;
};
}
export const emptyChoices = (): InceptionChoices => ({
design_system_id: null, seed_systems: false,
design_system_id: null, seed_systems: false, platforms: null,
});
export const fetchInceptionDefaults = (projectId: number) =>
+77
View File
@@ -0,0 +1,77 @@
/**
* Platforms — what a project is built on or ships as (milestone 463).
*
* The catalog is GLOBAL, like the canonical areas, and admin-written. A
* project's answer per platform is `declared` (a person said so), `detected`
* (a marker file in a bound repo said so) or `rejected` (a person said no —
* kept, so the next refresh cannot detect it back). Only declared and
* detected make a project a member, and membership is what decides which
* family ideas reach it.
*/
import { apiGet, apiPatch, apiPost, apiPut } from "@/api/client";
export interface Platform {
id: number;
name: string;
/** The match key, and the name every door takes (stable across restores). */
slug: string;
description: string | null;
/** Repo-relative globs. A bare name matches a basename anywhere; one with
* a slash matches the whole path. Empty = declare-only. */
markers: string[];
order_index: number;
created_at: string | null;
updated_at: string | null;
}
export type PlatformState = "declared" | "detected" | "rejected";
/** What a person may set. `null` withdraws the answer. */
export type SettablePlatformState = "declared" | "rejected" | null;
export interface ProjectPlatform {
id: number;
slug: string;
name: string;
state: PlatformState;
}
export const MEMBER_STATES: PlatformState[] = ["declared", "detected"];
export async function listPlatforms(): Promise<Platform[]> {
const data = await apiGet<{ platforms: Platform[] }>("/api/platforms");
return data.platforms;
}
/** Admin only. A name that reduces to an existing slug answers 409. */
export async function createPlatform(data: {
name: string;
description?: string;
markers?: string[];
}): Promise<Platform> {
return apiPost("/api/platforms", data);
}
export async function updatePlatform(
id: number,
data: Partial<{ name: string; description: string; order_index: number; markers: string[] }>,
): Promise<Platform> {
return apiPatch(`/api/platforms/${id}`, data);
}
export async function fetchProjectPlatforms(projectId: number): Promise<ProjectPlatform[]> {
const data = await apiGet<{ project_platforms: ProjectPlatform[] }>(
`/api/projects/${projectId}/platforms`,
);
return data.project_platforms;
}
/** Only the slugs named change; the update applies whole or not at all. */
export async function setProjectPlatforms(
projectId: number,
platforms: Record<string, SettablePlatformState>,
): Promise<ProjectPlatform[]> {
const data = await apiPut<{ project_platforms: ProjectPlatform[] }>(
`/api/projects/${projectId}/platforms`, { platforms },
);
return data.project_platforms;
}
+71 -2
View File
@@ -6,10 +6,16 @@
* 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.
*
* Platforms (milestone 463) start UNANSWERED (null). Ticking any box makes
* the list the whole answer; in decide mode the boxes start from what
* detection already found, so recording confirms it.
*/
import { computed, onMounted, ref, watch } from "vue";
import { apiErrorMessage } from "@/api/client";
import { fetchDesignSystems } from "@/api/designSystems";
import { MEMBER_STATES } from "@/api/platforms";
import { usePlatformsStore } from "@/stores/platforms";
import {
decideInception, emptyChoices, fetchInceptionDefaults,
type InceptionChoices, type InceptionDecision, type InceptionDefaults,
@@ -29,6 +35,9 @@ const emit = defineEmits<{
const local = ref<InceptionChoices>(props.choices ? { ...props.choices } : emptyChoices());
const designSystems = ref<{ id: number; title: string }[]>([]);
const systemsCount = ref(0);
const platforms = ref<{ slug: string; name: string }[]>([]);
const detected = ref<Set<string>>(new Set());
const platformsStore = usePlatformsStore();
const loading = ref(true);
const saving = ref(false);
const error = ref("");
@@ -46,11 +55,25 @@ async function load() {
const d: InceptionDefaults = await fetchInceptionDefaults(props.projectId);
designSystems.value = d.design_systems;
systemsCount.value = d.systems;
platforms.value = d.platforms;
const members = d.project_platforms
.filter((p) => MEMBER_STATES.includes(p.state))
.map((p) => p.slug);
detected.value = new Set(
d.project_platforms.filter((p) => p.state === "detected").map((p) => p.slug),
);
// Start from what stands today, so "record" without changes keeps it.
local.value = { design_system_id: d.design_system_id, seed_systems: false };
local.value = {
design_system_id: d.design_system_id,
seed_systems: false,
platforms: members.length ? members : null,
};
} else {
const ds = await fetchDesignSystems();
const [ds, catalog] = await Promise.all([
fetchDesignSystems(), platformsStore.fetchCatalog(),
]);
designSystems.value = ds.design_systems.map((d) => ({ id: d.id, title: d.title }));
platforms.value = catalog.map((p) => ({ slug: p.slug, name: p.name }));
}
} catch (e: unknown) {
error.value = apiErrorMessage(e, "Could not load what this project could inherit");
@@ -61,6 +84,22 @@ async function load() {
const nothingToDecide = computed(() => !designSystems.value.length);
function isChecked(slug: string): boolean {
return (local.value.platforms ?? []).includes(slug);
}
function togglePlatform(slug: string, on: boolean) {
const current = new Set(local.value.platforms ?? []);
if (on) current.add(slug);
else current.delete(slug);
local.value.platforms = [...current].sort();
}
/** Back to "not answered" — distinct from an empty list, which says "none". */
function clearPlatforms() {
local.value.platforms = null;
}
async function record() {
if (!props.projectId) return;
saving.value = true;
@@ -105,6 +144,31 @@ onMounted(load);
</span>
</label>
</div>
<div v-if="platforms.length" class="inception-group">
<h4>Platforms</h4>
<p class="inception-muted">
What it is built on or ships as. Ideas the family has proven for a
platform reach every project that is one.
<template v-if="local.platforms === null"> Not answered yet.</template>
<template v-else>
Anything left unticked is recorded as "not this project".
<button type="button" class="btn-ghost btn-sm" @click="clearPlatforms">Leave unanswered</button>
</template>
</p>
<div class="inception-platforms">
<label v-for="p in platforms" :key="p.slug" class="inception-choice">
<input
type="checkbox"
:checked="isChecked(p.slug)"
@change="togglePlatform(p.slug, ($event.target as HTMLInputElement).checked)"
/>
<span>
{{ p.name }}
<em v-if="detected.has(p.slug)" class="inception-muted"> — detected</em>
</span>
</label>
</div>
</div>
<p v-if="nothingToDecide" class="inception-muted">
No design systems on this install yet — recording still settles the question.
</p>
@@ -132,6 +196,11 @@ onMounted(load);
.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-platforms {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr));
gap: 0 1rem;
}
.inception-select {
padding: 0.45rem 0.7rem;
border: 1px solid var(--fs-border-color);
@@ -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>
+48
View File
@@ -0,0 +1,48 @@
import { ref } from "vue";
import { defineStore } from "pinia";
import * as api from "@/api/platforms";
import type { Platform } from "@/api/platforms";
/**
* The global platform catalog (milestone 463). Shared by every project, so
* it is fetched once per session — as the canonical-area catalog is.
*/
export const usePlatformsStore = defineStore("platforms", () => {
const catalog = ref<Platform[]>([]);
const loaded = ref(false);
const loading = ref(false);
async function fetchCatalog(force = false) {
if (loaded.value && !force) return catalog.value;
loading.value = true;
try {
catalog.value = await api.listPlatforms();
loaded.value = true;
} catch {
// The catalog is a vocabulary for a form; an empty one degrades the
// form rather than failing the screen it sits on.
catalog.value = [];
} finally {
loading.value = false;
}
return catalog.value;
}
async function createEntry(data: { name: string; description?: string; markers?: string[] }) {
const entry = await api.createPlatform(data);
catalog.value.push(entry);
return entry;
}
async function updateEntry(
id: number,
data: Partial<{ name: string; description: string; order_index: number; markers: string[] }>,
) {
const entry = await api.updatePlatform(id, data);
const idx = catalog.value.findIndex((p) => p.id === id);
if (idx >= 0) catalog.value[idx] = entry;
return entry;
}
return { catalog, loaded, loading, fetchCatalog, createEntry, updateEntry };
});
+17 -1
View File
@@ -12,6 +12,8 @@ import KindBadge from "@/components/KindBadge.vue";
import ProjectStatusBadge from "@/components/ProjectStatusBadge.vue";
import type { TaskKind } from "@/types/note";
import ProjectDesignTab from "@/components/ProjectDesignTab.vue";
import ProjectFamilyTab from "@/components/ProjectFamilyTab.vue";
import { canWriteRecord } from "@/utils/permission";
import ProjectRulesTab from "@/components/rules/ProjectRulesTab.vue";
import SystemsSection from "@/components/SystemsSection.vue";
import InceptionCard from "@/components/InceptionCard.vue";
@@ -125,7 +127,7 @@ async function confirmStartPlanning() {
const saving = ref(false);
const error = ref<string | null>(null);
const activeTab = ref<"tasks" | "notes" | "systems" | "rules" | "design">("tasks");
const activeTab = ref<"tasks" | "notes" | "systems" | "rules" | "design" | "family">("tasks");
const tasks = ref<NoteItem[]>([]);
const notes = ref<NoteItem[]>([]);
@@ -747,6 +749,10 @@ async function confirmDelete() {
Inheritance decided {{ fmtDate(project.inception.decided_at) }} via {{ project.inception.via }}
· 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>
<!-- What was said AT inception; the live answers are on the Family tab. -->
<template v-if="project.inception.choices.platforms?.length">
· platforms {{ project.inception.choices.platforms.join(", ") }}
</template>
</p>
<!-- Summary stat chips -->
@@ -945,6 +951,9 @@ async function confirmDelete() {
<button :class="['tab-btn', { active: activeTab === 'design' }]" @click="activeTab = 'design'">
Design
</button>
<button :class="['tab-btn', { active: activeTab === 'family' }]" @click="activeTab = 'family'">
Family
</button>
</div>
<!-- Tasks tab — milestone-grouped kanban -->
@@ -1190,6 +1199,13 @@ async function confirmDelete() {
:project-id="projectId"
:design-system-id="project.design_system_id ?? null"
/>
<!-- Family tab (milestone 463): which platforms this project is. -->
<ProjectFamilyTab
v-if="activeTab === 'family'"
:project-id="projectId"
:can-write="canWriteRecord(project.permission)"
/>
</div>
</div>
</template>
+168 -2
View File
@@ -4,6 +4,7 @@ import { useSettingsStore } from "@/stores/settings";
import { useAuthStore } from "@/stores/auth";
import { useToastStore } from "@/stores/toast";
import { useCanonicalSystemsStore } from "@/stores/canonicalSystems";
import { usePlatformsStore } from "@/stores/platforms";
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getProfile, updateProfile, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type UserProfile, apiErrorMessage } from "@/api/client";
import type { User } from "@/types/auth";
import PaginationBar from "@/components/PaginationBar.vue";
@@ -71,6 +72,75 @@ async function saveArea() {
savingArea.value = false;
}
}
// ── Platforms (milestone 463) ───────────────────────────────────────────
// The global list of what a project can be built on or ship as. Same split
// as the areas: admin-only to write, readable by everyone. Markers are the
// repo-relative globs that let a coverage refresh DETECT a platform; they
// are edited one per line, since a glob may itself contain a comma.
const platformsStore = usePlatformsStore();
const newPlatformName = ref("");
const newPlatformDescription = ref("");
const newPlatformMarkers = ref("");
const creatingPlatform = ref(false);
const editingPlatformId = ref<number | null>(null);
const editPlatformName = ref("");
const editPlatformDescription = ref("");
const editPlatformMarkers = ref("");
const savingPlatform = ref(false);
function markerLines(text: string): string[] {
return text.split("\n").map((m) => m.trim()).filter(Boolean);
}
async function createPlatform() {
const name = newPlatformName.value.trim();
if (!name || creatingPlatform.value) return;
creatingPlatform.value = true;
try {
await platformsStore.createEntry({
name,
description: newPlatformDescription.value.trim() || undefined,
markers: markerLines(newPlatformMarkers.value),
});
newPlatformName.value = "";
newPlatformDescription.value = "";
newPlatformMarkers.value = "";
toastStore.show("Platform added");
} catch (e) {
// A 409 names the existing platform the new name reduces to.
toastStore.show(apiErrorMessage(e, "Failed to add platform"), "error");
} finally {
creatingPlatform.value = false;
}
}
function startEditPlatform(id: number, name: string, description: string | null, markers: string[]) {
editingPlatformId.value = id;
editPlatformName.value = name;
editPlatformDescription.value = description ?? "";
editPlatformMarkers.value = markers.join("\n");
}
async function savePlatform() {
const id = editingPlatformId.value;
const name = editPlatformName.value.trim();
if (id == null || !name || savingPlatform.value) return;
savingPlatform.value = true;
try {
await platformsStore.updateEntry(id, {
name,
description: editPlatformDescription.value.trim(),
markers: markerLines(editPlatformMarkers.value),
});
editingPlatformId.value = null;
toastStore.show("Platform updated");
} catch (e) {
toastStore.show(apiErrorMessage(e, "Failed to update platform"), "error");
} finally {
savingPlatform.value = false;
}
}
const userTimezone = ref("");
const savingTimezone = ref(false);
const timezoneSaved = ref(false);
@@ -479,7 +549,7 @@ async function copyCommit() {
const restoreFileInput = ref<HTMLInputElement | null>(null);
// Migrate stored "admin" → "config"; unknown tabs fall back to "general"
const VALID_TABS = new Set(["general", "account", "profile", "notifications", "integrations", "data", "apikeys", "config", "users", "logs", "groups", "areas"]);
const VALID_TABS = new Set(["general", "account", "profile", "notifications", "integrations", "data", "apikeys", "config", "users", "logs", "groups", "areas", "platforms"]);
const _stored = localStorage.getItem("settings_tab") ?? "general";
const activeTab = ref(VALID_TABS.has(_stored) ? (_stored === "admin" ? "config" : _stored) : "general");
@@ -489,6 +559,7 @@ function _loadTabContent(tab: string) {
else if (tab === "logs") loadLogsPanel();
else if (tab === "groups") loadGroupsPanel();
else if (tab === "areas") canonStore.fetchCatalog(true);
else if (tab === "platforms") platformsStore.fetchCatalog(true);
else if (tab === "config" && !versionInfo.value) loadVersionPanel();
}
if (tab === "apikeys") { fetchApiKeys(); }
@@ -1611,7 +1682,7 @@ async function deleteUser(userId: number) {
<div v-if="authStore.isAdmin" class="sidebar-group">
<div class="sidebar-group-label">Admin</div>
<button
v-for="tab in ['config', 'areas', 'users', 'groups', 'logs']"
v-for="tab in ['config', 'areas', 'platforms', 'users', 'groups', 'logs']"
:key="tab"
:class="['sidebar-item', { active: activeTab === tab }]"
@click="activeTab = tab"
@@ -3179,6 +3250,99 @@ async function deleteUser(userId: number) {
</section>
</div>
<!-- ── Platforms ── -->
<div v-if="authStore.isAdmin" v-show="activeTab === 'platforms'" class="settings-grid">
<section class="settings-section full-width">
<h2>Platforms</h2>
<p class="field-hint">
What a project can be built on or ship as. A project's platforms decide which family
ideas reach it. Markers are repo-relative file patterns, one per line: a bare name
(<code>go.mod</code>) matches that file anywhere in a bound repo, and a pattern with a
slash (<code>.github/workflows/*</code>) matches the whole path. A platform with no
markers is never detected — projects declare it themselves.
</p>
<ul class="area-admin-list">
<li v-for="entry in platformsStore.catalog" :key="entry.id" class="area-admin-row">
<template v-if="editingPlatformId === entry.id">
<form class="area-admin-form" @submit.prevent="savePlatform">
<input v-model="editPlatformName" class="fs-input" aria-label="Platform name" />
<textarea
v-model="editPlatformDescription"
class="fs-input"
rows="2"
aria-label="Platform description"
></textarea>
<textarea
v-model="editPlatformMarkers"
class="fs-input area-admin-markers"
rows="3"
aria-label="Detection markers, one per line"
></textarea>
<div class="area-admin-actions">
<button type="submit" class="btn-primary btn-compact" :disabled="!editPlatformName.trim() || savingPlatform">
{{ savingPlatform ? "Saving…" : "Save" }}
</button>
<button type="button" class="btn-ghost btn-compact" @click="editingPlatformId = null">Cancel</button>
</div>
</form>
</template>
<template v-else>
<div class="area-admin-body">
<div class="area-admin-name-row">
<span class="area-admin-name">{{ entry.name }}</span>
<code class="area-admin-slug" title="The match key, and the name tools and backups use.">{{ entry.slug }}</code>
</div>
<p v-if="entry.description" class="area-admin-desc">{{ entry.description }}</p>
<p class="area-admin-desc">
<template v-if="entry.markers.length">
Detected by
<code v-for="m in entry.markers" :key="m" class="area-admin-slug">{{ m }}</code>
</template>
<template v-else>Declare-only — never detected.</template>
</p>
</div>
<button
class="btn-ghost btn-compact"
@click="startEditPlatform(entry.id, entry.name, entry.description, entry.markers)"
>Edit</button>
</template>
</li>
</ul>
<p v-if="!platformsStore.catalog.length && !platformsStore.loading" class="settings-empty">
No platforms yet.
</p>
<form class="area-admin-form area-admin-create" @submit.prevent="createPlatform">
<input
v-model="newPlatformName"
class="fs-input"
placeholder="New platform name (e.g. Flutter app)"
aria-label="New platform name"
/>
<textarea
v-model="newPlatformDescription"
class="fs-input"
rows="2"
placeholder="What makes a project this platform? One sentence."
aria-label="New platform description"
></textarea>
<textarea
v-model="newPlatformMarkers"
class="fs-input area-admin-markers"
rows="2"
placeholder="Markers, one per line (e.g. pubspec.yaml) — leave empty for declare-only"
aria-label="New platform markers, one per line"
></textarea>
<div class="area-admin-actions">
<button type="submit" class="btn-primary btn-compact" :disabled="!newPlatformName.trim() || creatingPlatform">
{{ creatingPlatform ? "Adding…" : "Add platform" }}
</button>
</div>
</form>
</section>
</div>
<div v-if="authStore.isAdmin" v-show="activeTab === 'users'" class="settings-grid">
<section class="settings-section full-width">
@@ -4386,6 +4550,8 @@ async function deleteUser(userId: number) {
.area-admin-form { display: flex; flex-direction: column; gap: 0.5rem; flex: 1; }
.area-admin-create { margin-top: var(--fs-space-4); }
.area-admin-actions { display: flex; gap: 0.4rem; }
.area-admin-markers { font-family: var(--fs-font-mono); font-size: 0.82rem; }
.area-admin-desc .area-admin-slug { margin-right: 0.25rem; }
/* The retrieval tuning trail (#4102). Reads as a record, not a control panel:
the operator is reviewing what was done, and the reason is the part worth