Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 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
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)",
],
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(() => {});
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.

P2: Empty catch block swallows SplashScreen.hideAsync() errors, masking startup-blocking failures without any diagnostic context

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/mobile/app/_layout.tsx, line 45:

<comment>Empty catch block swallows `SplashScreen.hideAsync()` errors, masking startup-blocking failures without any diagnostic context</comment>

<file context>
@@ -1,6 +1,53 @@
+
+	useEffect(() => {
+		if (fontsLoaded) {
+			SplashScreen.hideAsync().catch(() => {});
+		}
+	}, [fontsLoaded]);
</file context>
Suggested change
SplashScreen.hideAsync().catch(() => {});
SplashScreen.hideAsync().catch((err) => {
console.warn("Failed to hide splash screen:", err);
});

}
}, [fontsLoaded, fontError]);

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

const Root = StorybookRoot ?? RootLayout;
Comment on lines +30 to +49
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.

P1 Font error ignored — app permanently frozen at splash screen

useFonts returns [loaded, error]. When fonts fail to load for any reason (even unlikely for bundled assets), fontsLoaded stays false forever. Because SplashScreen.hideAsync() is only called when fontsLoaded is true, and the component returns null while !fontsLoaded, the app will be permanently stuck at the splash screen with no recovery path. The fontError result should also trigger hideAsync so the app renders (with system fallback fonts) rather than hanging.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/mobile/app/_layout.tsx
Line: 35-51

Comment:
**Font error ignored — app permanently frozen at splash screen**

`useFonts` returns `[loaded, error]`. When fonts fail to load for any reason (even unlikely for bundled assets), `fontsLoaded` stays `false` forever. Because `SplashScreen.hideAsync()` is only called when `fontsLoaded` is `true`, and the component returns `null` while `!fontsLoaded`, the app will be permanently stuck at the splash screen with no recovery path. The `fontError` result should also trigger `hideAsync` so the app renders (with system fallback fonts) rather than hanging.

How can I resolve this? If you propose a fix, please make it concise.

return <Root />;
}
154 changes: 154 additions & 0 deletions apps/mobile/components/FabBase/FabBase.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import type { Meta, StoryObj } from "@storybook/react-native";
import {
ArrowDown,
type LucideIcon,
MessageSquarePlus,
Plus,
} from "lucide-react-native";
import { View } from "react-native";
import { FabBase } from "./FabBase";

const ICON_MAP: Record<string, LucideIcon> = {
Plus,
ArrowDown,
MessageSquarePlus,
};

const meta: Meta<typeof FabBase> = {
title: "Components/FabBase",
component: FabBase,
parameters: {
docs: {
description: {
component:
"Floating action button base — sessions-list +, scroll-back-button, extended pill FAB. Three variants (accent · neutral · overlay) × two sizes (md=56pt · lg=64pt). Optional `label` enables extended pill; optional `liveRing` adds a pulsing mint halo. Always carries elevation shadow; aria-label is required.",
},
},
layout: "centered",
},
args: {
icon: Plus,
accessibilityLabel: "New chat session",
variant: "accent",
size: "md",
loading: false,
liveRing: false,
disabled: false,
},
argTypes: {
icon: {
control: { type: "select" },
options: Object.keys(ICON_MAP),
mapping: ICON_MAP,
description: "Lucide icon component (centered when no label)",
},
accessibilityLabel: {
control: "text",
description: "Required — action description, e.g. 'New chat session'",
},
label: {
control: "text",
description: "Optional visible label — enables extended pill variant",
},
variant: {
control: { type: "select" },
options: ["accent", "neutral", "overlay"],
},
size: {
control: { type: "select" },
options: ["md", "lg"],
description: "md=56pt diameter (icon 24) · lg=64pt diameter (icon 28)",
},
loading: {
control: "boolean",
description: "Hides icon, renders ActivityIndicator, sets aria-busy",
},
liveRing: {
control: "boolean",
description: "Decorative pulsing mint ring; honors reduced-motion",
},
disabled: { control: "boolean" },
},
};

export default meta;

type Story = StoryObj<typeof FabBase>;

export const NewChat: Story = {};

export const ExtendedPill: Story = {
args: { label: "New chat", icon: MessageSquarePlus },
};

export const ScrollBack: Story = {
args: {
icon: ArrowDown,
accessibilityLabel: "Scroll to latest",
variant: "overlay",
},
};

export const Neutral: Story = {
args: {
icon: Plus,
accessibilityLabel: "New chat session (neutral)",
variant: "neutral",
},
};

export const Large: Story = {
args: { size: "lg" },
};

export const Loading: Story = {
args: { loading: true },
};

export const Disabled: Story = {
args: { disabled: true },
};

export const WithLiveRing: Story = {
args: { liveRing: true },
};

export const AllVariantsAllSizes: Story = {
render: () => (
<View className="gap-6 p-4 items-center">
<View className="flex-row items-center gap-6">
<FabBase icon={Plus} accessibilityLabel="accent md" variant="accent" />
<FabBase
icon={Plus}
accessibilityLabel="neutral md"
variant="neutral"
/>
<FabBase
icon={Plus}
accessibilityLabel="overlay md"
variant="overlay"
/>
</View>
<View className="flex-row items-center gap-6">
<FabBase
icon={Plus}
accessibilityLabel="accent lg"
variant="accent"
size="lg"
/>
<FabBase
icon={Plus}
accessibilityLabel="neutral lg"
variant="neutral"
size="lg"
/>
<FabBase
icon={Plus}
accessibilityLabel="overlay lg"
variant="overlay"
size="lg"
/>
</View>
</View>
),
};
Loading