Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
deb2c2f
chore(mobile): scaffold pixel-perfect Storybook sandbox
justincrich May 22, 2026
a6b586e
chore: regenerate bun.lock after scaffold install
justincrich May 22, 2026
e0b818a
feat(mobile): scaffold ember theme — Path A token rewrite (Sprint 01 …
justincrich May 22, 2026
bbf7ab1
fix(mobile): pass AsyncStorage adapter to Storybook getStorybookUI
justincrich May 22, 2026
59eeb8a
fix(mobile): stub Node `tty` module in metro for Storybook 9.x
justincrich May 22, 2026
d91b0cd
fix(mobile): exclude screens/**/*.stories from Storybook RN main.js
justincrich May 22, 2026
5dcf47e
fix(mobile): wrap Storybook stories in expo-router NavigationContainer
justincrich May 22, 2026
2c39d89
fix(mobile): re-exclude screens stories — nav-wrapper insufficient at…
justincrich May 22, 2026
cd9786e
REMED-001: align mobile dark theme tokens
justincrich May 24, 2026
a107400
REMED-002: align mobile light theme tokens
justincrich May 24, 2026
93545ba
REMED-004: add mobile sidebar tertiary highlight tokens
justincrich May 24, 2026
272ae39
REMED-004: remove token verification spacer
justincrich May 24, 2026
f92c0c2
REMED-005: document primary semantic split (tooling half — global.css…
justincrich May 24, 2026
2762ff6
REMED-009: storybook router context (follow-up)
justincrich May 24, 2026
52add93
fix(mobile): address PR #4874 review feedback
justincrich May 26, 2026
9aee0bd
fix(mobile): revert theme to base shadcn/ui zinc palette from main
justincrich May 26, 2026
4a82191
fix(mobile): storybook nested NavigationContainer + empty index
justincrich May 26, 2026
cb71817
chore(mobile): ingest all 28 vendor primitives into Storybook (theme …
justincrich May 22, 2026
5931639
chore(mobile): migrate 8 first-party app components to ember theme (a…
justincrich May 22, 2026
309c706
chore(mobile): remove Welcome smoke story (real stories arrive in thi…
justincrich May 26, 2026
b915485
feat(mobile): add 5 chat-domain atom foundations (pre-spec)
justincrich May 22, 2026
d7a54e7
feat(mobile): wave-1 chat-view atoms — refine 5, create 5
justincrich May 22, 2026
7cc47f0
fix(mobile): ScrollFade — inline surface colors, drop lib/theme import
justincrich May 22, 2026
9929588
fix(mobile): unblock Storybook RN — drop nav import + move React elem…
justincrich May 22, 2026
36bbbb6
refactor(mobile): remove HitTargetWrapper — IconButton covers the sam…
justincrich May 26, 2026
01e524f
feat(mobile): wave-2 chat-view molecules — 19 built, flips molecules …
justincrich May 22, 2026
a04ca1a
refactor(mobile): ComposerRow → Claude iOS layout (single container)
justincrich May 22, 2026
a98b606
refactor(mobile): ComposerRow polish — single-tone bg, slash icon, ma…
justincrich May 22, 2026
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
1 change: 1 addition & 0 deletions apps/mobile/.rnstorybook/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
storybook.requires.ts
57 changes: 57 additions & 0 deletions apps/mobile/.rnstorybook/StorybookRouterProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import {
PreviewRouteContext,
type PreviewRouteContextType,
} from "expo-router/build/link/preview/PreviewRouteContext";
import {
LinkingContext,
NavigationContainer,
NavigationContainerRefContext,
ThemeProvider,
UNSTABLE_UnhandledLinkingContext as UnhandledLinkingContext,
} from "expo-router/react-navigation";
import { type PropsWithChildren, useContext } from "react";
import { NAV_THEME } from "@/lib/theme";

import {
storybookLinkingContext,
storybookLinkingOptions,
} from "./router/LinkingContext";
import { storybookUnhandledLinkingContext } from "./router/UnhandledLinkingContext";

const storybookRoute = {
pathname: "/storybook",
params: {},
segments: ["storybook"],
} satisfies PreviewRouteContextType;

export function StorybookRouterProvider({ children }: PropsWithChildren) {
const navigationRef = useContext(NavigationContainerRefContext);

const content = (
<ThemeProvider value={NAV_THEME.dark}>
<UnhandledLinkingContext.Provider
value={storybookUnhandledLinkingContext}
>
<LinkingContext.Provider value={storybookLinkingContext}>
<PreviewRouteContext.Provider value={storybookRoute}>
{children}
</PreviewRouteContext.Provider>
</LinkingContext.Provider>
</UnhandledLinkingContext.Provider>
</ThemeProvider>
);

if (navigationRef) {
return content;
}

return (
<NavigationContainer
fallback={null}
linking={storybookLinkingOptions}
theme={NAV_THEME.dark}
>
{content}
</NavigationContainer>
);
}
26 changes: 26 additions & 0 deletions apps/mobile/.rnstorybook/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Entry point used by app/_layout.tsx when EXPO_PUBLIC_STORYBOOK=true.
// `storybook.requires` is generated by `sb-rn-get-stories` at dev-time —
// see the "storybook" script in package.json. It is gitignored.
import AsyncStorage from "@react-native-async-storage/async-storage";
import { StorybookRouterProvider } from "./StorybookRouterProvider";
import { view } from "./storybook.requires";

// Storybook v9 react-native requires the `storage` adapter to be passed
// explicitly when `shouldPersistSelection: true` — it does not auto-detect
// AsyncStorage. Without this, attempts to read the persisted story selection
// throw `TypeError: Cannot read property 'getItem' of undefined`.
const StorybookUIRoot = view.getStorybookUI({
shouldPersistSelection: true,
storage: {
getItem: AsyncStorage.getItem,
setItem: AsyncStorage.setItem,
},
});

export default function StorybookRoot() {
return (
<StorybookRouterProvider>
<StorybookUIRoot />
</StorybookRouterProvider>
);
}
13 changes: 13 additions & 0 deletions apps/mobile/.rnstorybook/main.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/** @type {import('@storybook/react-native').StorybookConfig} */
const main = {
stories: [
"./stories/**/*.stories.?(ts|tsx|js|jsx)",
"../components/**/*.stories.?(ts|tsx|js|jsx)",
],
Comment on lines +3 to +6
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Include screens stories in Storybook discovery.

The current stories globs skip apps/mobile/screens/**/*.stories.*, so those stories in this cohort won’t load.

Suggested diff
 const main = {
 	stories: [
 		"./stories/**/*.stories.?(ts|tsx|js|jsx)",
 		"../components/**/*.stories.?(ts|tsx|js|jsx)",
+		"../screens/**/*.stories.?(ts|tsx|js|jsx)",
 	],
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
stories: [
"./stories/**/*.stories.?(ts|tsx|js|jsx)",
"../components/**/*.stories.?(ts|tsx|js|jsx)",
],
stories: [
"./stories/**/*.stories.?(ts|tsx|js|jsx)",
"../components/**/*.stories.?(ts|tsx|js|jsx)",
"../screens/**/*.stories.?(ts|tsx|js|jsx)",
],
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/mobile/.rnstorybook/main.js` around lines 3 - 6, The Storybook `stories`
array in apps/mobile/.rnstorybook/main.js omits the app's screen stories; add a
glob for the screens (e.g. "../screens/**/*.stories.?(ts|tsx|js|jsx)" or
"./screens/**/*.stories.?(ts|tsx|js|jsx)" depending on relative layout) to the
existing stories array so apps/mobile/screens/**/*.stories.* are discovered;
update the `stories` array entry near the existing
"./stories/**/*.stories.?(ts|tsx|js|jsx)" and
"../components/**/*.stories.?(ts|tsx|js|jsx)" entries.

addons: [
"@storybook/addon-ondevice-controls",
"@storybook/addon-ondevice-actions",
],
};

module.exports = main;
1 change: 1 addition & 0 deletions apps/mobile/.rnstorybook/mocks/tty.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = {};
35 changes: 35 additions & 0 deletions apps/mobile/.rnstorybook/preview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { PortalHost } from "@rn-primitives/portal";
import type { Preview } from "@storybook/react-native";
import { View } from "react-native";
import { cn } from "@/lib/utils";

// NavigationContainer is provided by StorybookRouterProvider —
// do NOT add one here or SDK 56's expo-router compat check will fail.
const preview: Preview = {
decorators: [
(Story, context) => {
const isFullscreen = context.parameters?.layout === "fullscreen";
return (
<View className={cn("flex-1 bg-background", !isFullscreen && "p-4")}>
<Story />
<PortalHost />
</View>
);
},
],
parameters: {
controls: {
matchers: {
color: /(background|color|foreground)$/i,
date: /Date$/i,
},
},
moduleMock: {
mockingPairedModules: {
tty: () => require("./mocks/tty"),
},
},
},
};

export default preview;
23 changes: 23 additions & 0 deletions apps/mobile/.rnstorybook/router/LinkingContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type {
LinkingOptions,
ParamListBase,
} from "expo-router/react-navigation";
import * as React from "react";

export const storybookLinkingOptions: LinkingOptions<ParamListBase> = {
enabled: false,
prefixes: [],
config: {
screens: {
Storybook: "*",
},
},
};

export const storybookLinkingContext = {
options: storybookLinkingOptions,
};

export const LinkingContext = React.createContext(storybookLinkingContext);

LinkingContext.displayName = "LinkingContext";
19 changes: 19 additions & 0 deletions apps/mobile/.rnstorybook/router/UnhandledLinkingContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import * as React from "react";

export type StorybookUnhandledLinkingContextValue = {
lastUnhandledLink: string | undefined;
setLastUnhandledLink: (lastUnhandledUrl: string | undefined) => void;
};

export const storybookUnhandledLinkingContext: StorybookUnhandledLinkingContextValue =
{
lastUnhandledLink: undefined,
setLastUnhandledLink: () => {},
};

export const UnhandledLinkingContext =
React.createContext<StorybookUnhandledLinkingContextValue>(
storybookUnhandledLinkingContext,
);

UnhandledLinkingContext.displayName = "UnhandledLinkingContext";
47 changes: 46 additions & 1 deletion apps/mobile/app/_layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,51 @@
import "react-native-get-random-values"; // MUST BE FIRST IMPORT
import "../global.css";

import {
Geist_400Regular,
Geist_500Medium,
Geist_600SemiBold,
Geist_700Bold,
useFonts,
} from "@expo-google-fonts/geist";
import {
GeistMono_400Regular,
GeistMono_500Medium,
} from "@expo-google-fonts/geist-mono";
import * as SplashScreen from "expo-splash-screen";
import { useEffect } from "react";
import { RootLayout } from "@/screens/RootLayout";

export default RootLayout;
SplashScreen.preventAutoHideAsync().catch(() => {
/* splash already hidden — fine to swallow */
});

const StorybookRoot =
process.env.EXPO_PUBLIC_STORYBOOK === "true"
? require("../.rnstorybook").default
: null;

export default function App() {
const [fontsLoaded, fontError] = useFonts({
Geist_400Regular,
Geist_500Medium,
Geist_600SemiBold,
Geist_700Bold,
GeistMono_400Regular,
GeistMono_500Medium,
});

useEffect(() => {
if (fontsLoaded || fontError) {
if (fontError) {
console.error("Font loading failed:", fontError);
}
SplashScreen.hideAsync().catch(() => {});
}
}, [fontsLoaded, fontError]);

if (!fontsLoaded && !fontError) return null;

const Root = StorybookRoot ?? RootLayout;
return <Root />;
}
57 changes: 57 additions & 0 deletions apps/mobile/components/AppHeader/AppHeader.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type { Meta, StoryObj } from "@storybook/react-native";
import { Settings } from "lucide-react-native";
import { AppHeader } from "./AppHeader";

const meta: Meta<typeof AppHeader> = {
title: "Molecules/AppHeader",
component: AppHeader,
parameters: {
docs: {
description: {
component:
"Top navigation header on every chat view. Three-region flex: leading back IconButton (optional) + centered title/subtitle + trailing actions IconButton (optional). `isScrolled` adds a layered shadow for separation from scrolling content. Composes first-party IconButton + Text.",
},
},
layout: "fullscreen",
},
args: {
title: "Fix auth bug",
subtitle: "superset · main",
showBack: true,
showActions: true,
isScrolled: false,
},
argTypes: {
title: { control: "text" },
subtitle: { control: "text" },
showBack: { control: "boolean" },
showActions: { control: "boolean" },
isScrolled: { control: "boolean", description: "Adds 1px bottom shadow" },
},
};

export default meta;

type Story = StoryObj<typeof AppHeader>;

export const Default: Story = {};

export const NoSubtitle: Story = {
args: { subtitle: undefined },
};

export const NoBack: Story = {
args: { showBack: false, title: "Sessions" },
};

export const SimpleNoActions: Story = {
args: { subtitle: undefined, showActions: false },
};

export const Scrolled: Story = {
args: { isScrolled: true },
};

export const CustomActionsIcon: Story = {
args: { actionsIcon: Settings, actionsAccessibilityLabel: "Settings" },
};
99 changes: 99 additions & 0 deletions apps/mobile/components/AppHeader/AppHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { ArrowLeft, type LucideIcon, MoreVertical } from "lucide-react-native";
import { View, type ViewProps } from "react-native";
import { IconButton } from "@/components/IconButton";
import { Text } from "@/components/ui/text";
import { cn } from "@/lib/utils";

export type AppHeaderProps = ViewProps & {
title: string;
subtitle?: string;
/** Show the leading back button. Default true. */
showBack?: boolean;
onBack?: () => void;
backAccessibilityLabel?: string;
/** Show the trailing actions button. Default true. */
showActions?: boolean;
onActions?: () => void;
actionsAccessibilityLabel?: string;
/** Override the trailing actions icon (default MoreVertical). */
actionsIcon?: LucideIcon;
/** Adds a layered shadow for visual separation from scrolling content. */
isScrolled?: boolean;
};

/**
* Top navigation header on every chat view (UC-RENDER-01 §A, UC-SESS-04 §A).
*
* Per mol-app-header spec:
* - Three-region flex: leading back (optional) + centered title/subtitle + trailing actions (optional)
* - Subtitle (project · branch) appears below the title in --md type-meta
* - `isScrolled` adds a 1px bottom shadow for layered separation
*
* Composes first-party IconButton + Text.
*/
export function AppHeader({
title,
subtitle,
showBack = true,
onBack,
backAccessibilityLabel = "Back to sessions",
showActions = true,
onActions,
actionsAccessibilityLabel = "Session actions",
actionsIcon = MoreVertical,
Comment on lines +37 to +43
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Prevent no-op header actions when callbacks are missing.

Both action buttons can render as tappable controls even when onBack/onActions are undefined, which creates dead interactions.

Suggested diff
-	showBack = true,
+	showBack = true,
 	onBack,
@@
-	showActions = true,
+	showActions = true,
 	onActions,
@@
-			{showBack ? (
+			{showBack && onBack ? (
 				<IconButton
@@
-			{showActions ? (
+			{showActions && onActions ? (
 				<IconButton

Also applies to: 58-65, 86-93

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/mobile/components/AppHeader/AppHeader.tsx` around lines 37 - 43, The
header currently renders tappable back/action controls even when callbacks are
undefined; update AppHeader so the back and actions buttons are only rendered as
interactive when their respective handlers (onBack, onActions) are provided —
otherwise render a non-interactive element or a disabled button (no onPress) and
ensure accessibilityLabel remains present but not actionable; locate the back
button and actions button render logic in AppHeader (props: showBack, onBack,
backAccessibilityLabel and showActions, onActions, actionsAccessibilityLabel,
actionsIcon) and conditionally attach onPress/interactive wrappers or set
disabled/pointerEvents accordingly to prevent no-op taps.

isScrolled = false,
className,
...props
}: AppHeaderProps) {
return (
<View
accessibilityRole="header"
className={cn(
"flex-row items-center min-h-touch-min px-3 py-2 bg-background border-b border-border",
isScrolled && "shadow-sm",
className,
)}
{...props}
>
{showBack ? (
<IconButton
icon={ArrowLeft}
accessibilityLabel={backAccessibilityLabel}
variant="ghost"
size="md"
onPress={onBack}
/>
) : (
<View className="w-1" />
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use fixed-width spacers matching icon-button width.

w-1 placeholders can shift title alignment when one side control is hidden. Use a stable spacer width equal to the button slot.

Suggested diff
-				<View className="w-1" />
+				<View className="w-touch-min" />
@@
-				<View className="w-1" />
+				<View className="w-touch-min" />

Also applies to: 95-95

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/mobile/components/AppHeader/AppHeader.tsx` at line 67, In AppHeader
replace the fragile "w-1" spacer Views with a fixed-width spacer that matches
the header icon button slot (use the same width used by the IconButton component
or the shared ICON_BUTTON_SIZE constant), so the title stays centered when a
side control is hidden; update both spacer occurrences around the title (the two
View elements currently using className="w-1") to use that exact fixed width via
className or inline style referencing the shared button size.

)}

<View
className={cn(
"flex-1 gap-0.5",
showBack ? "items-center" : "items-start pl-2",
)}
>
<Text className="font-semibold text-foreground" numberOfLines={1}>
{title}
</Text>
{subtitle ? (
<Text variant="muted" className="text-xs" numberOfLines={1}>
{subtitle}
</Text>
) : null}
</View>

{showActions ? (
<IconButton
icon={actionsIcon}
accessibilityLabel={actionsAccessibilityLabel}
variant="ghost"
size="md"
onPress={onActions}
/>
) : (
<View className="w-1" />
)}
</View>
);
}
1 change: 1 addition & 0 deletions apps/mobile/components/AppHeader/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { AppHeader, type AppHeaderProps } from "./AppHeader";
Loading