diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 222ae37..46b74da 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -5,12 +5,14 @@ import { BottomTabBar } from '../../src/components/ui/BottomTabBar'; import { AlertToast } from '../../src/components/alerts/AlertToast'; /** - * The read-only mirror shell (#218): five bottom tabs — - * Glance · Planner · Skills · UI · Automations (which folds MCP servers in as - * a segmented page). The app lands on Glance. Pairing, providers, theme, and - * the (coming) Security audit page live behind the header's More corner. - * The AlertToast (#222, generalising #219) overlays every tab: a paused - * session or any fleet alert surfaces a banner deep-linking to its target. + * The read-only mirror shell (#218, reprioritised in #233): five bottom tabs — + * Glance · Planner · Studio · Skills · Automations (which folds MCP servers in + * as a segmented page). Studio folds Teams · Components · Algorithms · Sounds + * into one segmented page (the old two-segment "UI" tab). The app lands on + * Glance. Pairing, providers, theme, and the (coming) Security audit page live + * behind the header's More corner. The AlertToast (#222, generalising #219) + * overlays every tab: a paused session or any fleet alert surfaces a banner + * deep-linking to its target. */ export default function TabsLayout() { return ( @@ -21,8 +23,8 @@ export default function TabsLayout() { > + - diff --git a/app/(tabs)/ui.tsx b/app/(tabs)/ui.tsx index e3e0879..28a110d 100644 --- a/app/(tabs)/ui.tsx +++ b/app/(tabs)/ui.tsx @@ -2,25 +2,32 @@ import React, { useState } from 'react'; import { Pressable, StyleSheet, Text, View } from 'react-native'; import { useTheme } from '../../src/theme'; import { MirrorScaffold } from '../../src/components/shell/MirrorScaffold'; +import { ScreenHeader } from '../../src/components/shell/ScreenHeader'; import { ComponentsMirror } from '../../src/components/design/ComponentsMirror'; -import { ThemesMirror } from '../../src/components/design/ThemesMirror'; +import { TeamsMirror } from '../../src/components/design/TeamsMirror'; -type Segment = 'components' | 'themes'; +type Segment = 'teams' | 'components' | 'algorithms' | 'sounds'; const SEGMENTS: ReadonlyArray<{ key: Segment; label: string }> = [ + { key: 'teams', label: 'TEAMS' }, { key: 'components', label: 'COMPONENTS' }, - { key: 'themes', label: 'THEMES' }, + { key: 'algorithms', label: 'ALGORITHMS' }, + { key: 'sounds', label: 'SOUNDS' }, ]; /** - * UI (Design) tab (#221) — mirrors the desktop's Design Studio read-only over two store domains: the - * component kits + summaries (`components`, rendered as a composition graph or a by-kit list) and the - * theme registry (`themes`, active highlighted). Each segment reads its own mirror domain, so each - * keeps its own MirrorScaffold awaiting/empty state. + * Studio tab (#233) — reprioritised from the old two-segment "UI" tab (#221): reusable-artifact + * library, four segments sharing one page because Teams/Components/Algorithms/Sounds all render + * through the same `src/lib/graph` composition-graph pipeline (see the composition-graph decision in + * `src/lib/pages/designPage.ts`), not merely to save a tab slot. Themes moved to More (a registry you + * consult, not browse) and is no longer a segment here. Algorithms and Sounds ship dark — their mirror + * domains don't exist on the desktop yet (blocked on base-studio-code, tracked in #243) — until then + * each renders a static "coming soon" card rather than a MirrorScaffold awaiting-sync state, since no + * amount of waiting will sync them. */ -export default function UiTab() { +export default function StudioTab() { const t = useTheme(); - const [segment, setSegment] = useState('components'); + const [segment, setSegment] = useState('teams'); const toolbar = ( @@ -43,30 +50,70 @@ export default function UiTab() { ); - return segment === 'components' ? ( - - {(data) => } - - ) : ( - - {(data) => } - + switch (segment) { + case 'teams': + return ( + + {(data) => } + + ); + case 'components': + return ( + + {(data) => } + + ); + case 'algorithms': + return ( + + ); + case 'sounds': + return ( + + ); + default: + return null; + } +} + +/** A segment whose mirror domain doesn't exist on the desktop yet — a static dark card, not a + * MirrorScaffold awaiting-sync state (that implies syncing will eventually happen on its own). */ +function StudioComingSoon({ toolbar, detail }: { toolbar: React.ReactNode; detail: string }) { + const t = useTheme(); + return ( + + + {toolbar} + + + Coming soon + {detail} + + + ); } const styles = StyleSheet.create({ + root: { flex: 1 }, segments: { flexDirection: 'row', borderBottomWidth: StyleSheet.hairlineWidth, @@ -79,4 +126,17 @@ const styles = StyleSheet.create({ borderBottomColor: 'transparent', }, segmentText: { fontSize: 10.5, letterSpacing: 0.6, fontWeight: '600' }, + comingSoonBody: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24 }, + comingSoonCard: { + alignSelf: 'stretch', + alignItems: 'center', + gap: 8, + paddingVertical: 28, + paddingHorizontal: 22, + borderWidth: StyleSheet.hairlineWidth, + borderStyle: 'dashed', + borderRadius: 10, + }, + comingSoonTitle: { fontSize: 15, fontWeight: '600' }, + comingSoonDetail: { fontSize: 12.5, lineHeight: 18, textAlign: 'center' }, }); diff --git a/src/components/design/TeamsMirror.tsx b/src/components/design/TeamsMirror.tsx new file mode 100644 index 0000000..dfefc6a --- /dev/null +++ b/src/components/design/TeamsMirror.tsx @@ -0,0 +1,180 @@ +import React, { useMemo, useRef, useState } from 'react'; +import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { useTheme } from '../../theme'; +import { useMirrorDomain } from '../../lib/mirror/MirrorContext'; +import { Surface } from '../ui/Surface'; +import { Tag } from '../ui/Tag'; +import { GraphCanvas, type GraphCanvasHandle, type GraphSelection } from '../graph/GraphCanvas'; +import { buildOrgScene, type GraphScene, type OrgGraphInput } from '../../lib/graph'; +import { + selectBlueprints, selectOrgPersonas, blueprintTeamToOrgInput, type BlueprintCardVM, +} from '../../lib/pages/blueprintsPage'; + +/** + * Teams mirror (#233) — Studio's Teams segment. Replaces the old Planner-tab `BlueprintsSection`'s + * `library | team` sub-toggle with a single flattened view: a blueprint picker strip above the team + * graph, so phones never stack three levels of segmentation (Studio segment → sub-toggle → list). + * Only the ACTIVE blueprint's team currently crosses the wire (base-studio-code#236), so picking a + * different blueprint shows a "no team mirrored yet" fallback rather than a graph — this falls away on + * its own once that lands, no mobile change needed. Reads `org` directly (like the old Planner tab did) + * to resolve persona refs for pool collapsing; enrichment only, so a missing `org` domain still renders. + */ +export function TeamsMirror({ data }: { data: unknown }) { + const t = useTheme(); + const { data: orgData } = useMirrorDomain('org'); + const model = useMemo(() => selectBlueprints(data), [data]); + const personas = useMemo(() => selectOrgPersonas(orgData), [orgData]); + const [selectedId, setSelectedId] = useState(null); + + if (!model) { + return ( + + Couldn’t read the desktop’s Blueprints projection. + + ); + } + + if (model.library.length === 0) { + return ( + + No blueprints mirrored from the desktop yet. + + ); + } + + const effectiveId = selectedId ?? model.active; + const selected = model.library.find((b) => b.id === effectiveId) ?? model.library[0]; + const hasTeamData = effectiveId === model.active && !!model.activeTeam; + const orgInput: OrgGraphInput | null = hasTeamData && model.activeTeam + ? blueprintTeamToOrgInput(model.activeTeam, personas) + : null; + + return ( + + + {orgInput ? ( + + ) : ( + + + {selected.hasTeam + ? 'This blueprint’s team isn’t mirrored yet — only the active blueprint’s team crosses the wire today.' + : `${selected.name} has no team defined.`} + + + )} + + ); +} + +function BlueprintPicker({ + library, activeId, selectedId, onSelect, +}: { + library: BlueprintCardVM[]; + activeId: string; + selectedId: string; + onSelect: (id: string) => void; +}) { + const t = useTheme(); + return ( + + {library.map((b) => { + const selected = b.id === selectedId; + return ( + onSelect(b.id)} + accessibilityRole="button" + accessibilityLabel={b.name} + style={[ + styles.chip, + { borderColor: selected ? t.accent : t.borderColor }, + selected && { backgroundColor: t.surface }, + ]} + > + {b.icon ? {b.icon} : null} + + {b.name} + + {b.id === activeId ? ( + Active + ) : null} + + ); + })} + + ); +} + +function TeamGraph({ input }: { input: OrgGraphInput }) { + const t = useTheme(); + const canvasRef = useRef(null); + const [selected, setSelected] = useState(null); + const scene: GraphScene = useMemo(() => buildOrgScene(input), [input]); + const node = selected?.kind === 'node' ? scene.nodes.find((n) => n.id === selected.id) : undefined; + + const colors = { + card: t.surfaceSolid, cardStack: t.bg, border: t.borderColor, + text: t.fg, muted: t.fgMuted, selection: t.accent, + }; + + return ( + + + team + canvasRef.current?.fitToView()} hitSlop={6}> + Fit + + + + {node ? ( + + {node.title} + {node.subtitle ? {node.subtitle} : null} + {node.stackCount ? ( + + pool · {node.stackCount} members{node.homogeneous === false ? ' · mixed wiring' : ''} + + ) : null} + + ) : ( + drag to pan · pinch to zoom · tap a position + )} + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1 }, + pickerRow: { flexGrow: 0, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: 'transparent' }, + pickerInner: { flexDirection: 'row', gap: 8, paddingHorizontal: 14, paddingVertical: 10 }, + chip: { + flexDirection: 'row', alignItems: 'center', gap: 6, + paddingHorizontal: 12, paddingVertical: 7, borderRadius: 15, borderWidth: StyleSheet.hairlineWidth, + }, + chipIcon: { fontSize: 13 }, + chipText: { fontSize: 12.5, fontWeight: '600', maxWidth: 140 }, + emptyTeam: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24 }, + emptyTeamText: { fontSize: 12.5, lineHeight: 18, textAlign: 'center' }, + toolbar: { flexDirection: 'row', alignItems: 'center', gap: 8, paddingHorizontal: 14, paddingVertical: 8 }, + crumb: { flex: 1, fontSize: 11.5 }, + fitChip: { paddingHorizontal: 11, paddingVertical: 5, borderRadius: 8, borderWidth: StyleSheet.hairlineWidth }, + fitChipText: { fontSize: 12.5, fontWeight: '600' }, + canvas: { flex: 1 }, + inspector: { margin: 12, padding: 13, gap: 4 }, + inspTitle: { fontSize: 15, fontWeight: '700' }, + inspSub: { fontSize: 12 }, + hint: { fontSize: 11, textAlign: 'center', paddingVertical: 12 }, + fallback: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24 }, + fallbackText: { fontSize: 13, textAlign: 'center' }, +}); diff --git a/src/components/design/ThemesMirror.tsx b/src/components/design/ThemesMirror.tsx deleted file mode 100644 index 7961235..0000000 --- a/src/components/design/ThemesMirror.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import React, { useMemo } from 'react'; -import { ScrollView, StyleSheet, Text, View } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { useTheme } from '../../theme'; -import { Surface } from '../ui/Surface'; -import { Tag } from '../ui/Tag'; -import { selectThemes } from '../../lib/pages/designPage'; - -/** - * Themes mirror (#221) — read-only list of the desktop's `themes` domain, the active theme - * highlighted. Each theme shows its label/description + semantic-token count. No editing. - */ -export function ThemesMirror({ data }: { data: unknown }) { - const t = useTheme(); - const insets = useSafeAreaInsets(); - const model = useMemo(() => selectThemes(data), [data]); - - if (!model) { - return ( - - Couldn’t read the desktop’s Themes projection. - - ); - } - - return ( - - {model.themes.map((th) => ( - - - {th.label} - {th.active ? Active : null} - {th.builtin ? built-in : null} - - {th.description ? {th.description} : null} - - {th.varCount ? `${th.varCount} token override${th.varCount === 1 ? '' : 's'}` : 'base look'} - - - ))} - - ); -} - -const styles = StyleSheet.create({ - root: { flex: 1 }, - inner: { paddingHorizontal: 14, paddingTop: 12, gap: 10 }, - card: { padding: 12, gap: 6 }, - head: { flexDirection: 'row', alignItems: 'center', gap: 8 }, - name: { flex: 1, fontSize: 14, fontWeight: '700' }, - desc: { fontSize: 12, lineHeight: 17 }, - meta: { fontSize: 11 }, - fallback: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24 }, - fallbackText: { fontSize: 13, textAlign: 'center' }, -});