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