Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions app/(tabs)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -21,8 +23,8 @@ export default function TabsLayout() {
>
<Tabs.Screen name="index" options={{ title: 'Glance' }} />
<Tabs.Screen name="plan" options={{ title: 'Planner' }} />
<Tabs.Screen name="ui" options={{ title: 'Studio' }} />
<Tabs.Screen name="skills" options={{ title: 'Skills' }} />
<Tabs.Screen name="ui" options={{ title: 'UI' }} />
<Tabs.Screen name="automations" options={{ title: 'Automations' }} />
</Tabs>
<AlertToast />
Expand Down
118 changes: 89 additions & 29 deletions app/(tabs)/ui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Segment>('components');
const [segment, setSegment] = useState<Segment>('teams');

const toolbar = (
<View style={[styles.segments, { borderBottomColor: t.borderColor }]}>
Expand All @@ -43,30 +50,70 @@ export default function UiTab() {
</View>
);

return segment === 'components' ? (
<MirrorScaffold
domain="components"
title="UI"
subtitle="Design Studio · component kits"
blurb="UI mirrors the desktop's Design Studio — the component kits and how they compose."
toolbar={toolbar}
>
{(data) => <ComponentsMirror data={data} />}
</MirrorScaffold>
) : (
<MirrorScaffold
domain="themes"
title="UI"
subtitle="Design Studio · themes"
blurb="UI mirrors the desktop's theme registry — the semantic-token themes each kit restyles under."
toolbar={toolbar}
>
{(data) => <ThemesMirror data={data} />}
</MirrorScaffold>
switch (segment) {
case 'teams':
return (
<MirrorScaffold
domain="blueprints"
title="Studio"
subtitle="Teams · desktop blueprints"
blurb="Teams mirrors the desktop's blueprint library and the active blueprint's team graph."
toolbar={toolbar}
>
{(data) => <TeamsMirror data={data} />}
</MirrorScaffold>
);
case 'components':
return (
<MirrorScaffold
domain="components"
title="Studio"
subtitle="Component kits"
blurb="Components mirrors the desktop's Design Studio — the component kits and how they compose."
toolbar={toolbar}
>
{(data) => <ComponentsMirror data={data} />}
</MirrorScaffold>
);
case 'algorithms':
return (
<StudioComingSoon
toolbar={toolbar}
detail="Algorithms will mirror the desktop's algorithm library once it projects that domain over the tunnel."
/>
);
case 'sounds':
return (
<StudioComingSoon
toolbar={toolbar}
detail="Sounds will mirror the desktop's sound library once it projects that domain over the tunnel."
/>
);
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 (
<View style={styles.root}>
<ScreenHeader title="Studio" subtitle="Coming soon" />
{toolbar}
<View style={styles.comingSoonBody}>
<View style={[styles.comingSoonCard, { borderColor: t.borderColor }]}>
<Text style={[styles.comingSoonTitle, { color: t.fgMuted }]}>Coming soon</Text>
<Text style={[styles.comingSoonDetail, { color: t.fgDim }]}>{detail}</Text>
</View>
</View>
</View>
);
}

const styles = StyleSheet.create({
root: { flex: 1 },
segments: {
flexDirection: 'row',
borderBottomWidth: StyleSheet.hairlineWidth,
Expand All @@ -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' },
});
180 changes: 180 additions & 0 deletions src/components/design/TeamsMirror.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(null);

if (!model) {
return (
<View style={styles.fallback}>
<Text style={[styles.fallbackText, { color: t.fgMuted }]}>Couldn’t read the desktop’s Blueprints projection.</Text>
</View>
);
}

if (model.library.length === 0) {
return (
<View style={styles.fallback}>
<Text style={[styles.fallbackText, { color: t.fgMuted }]}>No blueprints mirrored from the desktop yet.</Text>
</View>
);
}

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 (
<View style={styles.root}>
<BlueprintPicker
library={model.library}
activeId={model.active}
selectedId={effectiveId}
onSelect={setSelectedId}
/>
{orgInput ? (
<TeamGraph input={orgInput} />
) : (
<View style={styles.emptyTeam}>
<Text style={[styles.emptyTeamText, { color: t.fgDim }]}>
{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.`}
</Text>
</View>
)}
</View>
);
}

function BlueprintPicker({
library, activeId, selectedId, onSelect,
}: {
library: BlueprintCardVM[];
activeId: string;
selectedId: string;
onSelect: (id: string) => void;
}) {
const t = useTheme();
return (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.pickerRow}
contentContainerStyle={styles.pickerInner}
>
{library.map((b) => {
const selected = b.id === selectedId;
return (
<Pressable
key={b.id}
onPress={() => onSelect(b.id)}
accessibilityRole="button"
accessibilityLabel={b.name}
style={[
styles.chip,
{ borderColor: selected ? t.accent : t.borderColor },
selected && { backgroundColor: t.surface },
]}
>
{b.icon ? <Text style={styles.chipIcon}>{b.icon}</Text> : null}
<Text style={[styles.chipText, { color: selected ? t.fg : t.fgMuted }]} numberOfLines={1}>
{b.name}
</Text>
{b.id === activeId ? (
<Tag color={t.accent} bg={`${t.accent}22`} border={false}>Active</Tag>
) : null}
</Pressable>
);
})}
</ScrollView>
);
}

function TeamGraph({ input }: { input: OrgGraphInput }) {
const t = useTheme();
const canvasRef = useRef<GraphCanvasHandle>(null);
const [selected, setSelected] = useState<GraphSelection | null>(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 (
<View style={styles.root}>
<View style={styles.toolbar}>
<Text style={[styles.crumb, { color: t.fgMuted }]}>team</Text>
<Pressable style={[styles.fitChip, { borderColor: t.borderColor }]} onPress={() => canvasRef.current?.fitToView()} hitSlop={6}>
<Text style={[styles.fitChipText, { color: t.fg }]}>Fit</Text>
</Pressable>
</View>
<GraphCanvas ref={canvasRef} scene={scene} selected={selected} onSelect={setSelected} colors={colors} style={styles.canvas} />
{node ? (
<Surface style={styles.inspector} radius={12}>
<Text style={[styles.inspTitle, { color: t.fg }]}>{node.title}</Text>
{node.subtitle ? <Text style={[styles.inspSub, { color: t.fgMuted }]}>{node.subtitle}</Text> : null}
{node.stackCount ? (
<Text style={[styles.inspSub, { color: t.fgDim }]}>
pool · {node.stackCount} members{node.homogeneous === false ? ' · mixed wiring' : ''}
</Text>
) : null}
</Surface>
) : (
<Text style={[styles.hint, { color: t.fgDim }]}>drag to pan · pinch to zoom · tap a position</Text>
)}
</View>
);
}

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' },
});
Loading