-
Notifications
You must be signed in to change notification settings - Fork 987
feat(mobile): wave-1 chat-view atoms — 10 atoms (Sprint 1 / PR 3 of 7) #4870
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
deb2c2f
a6b586e
e0b818a
bbf7ab1
59eeb8a
d91b0cd
5dcf47e
2c39d89
cd9786e
a107400
93545ba
272ae39
f92c0c2
2762ff6
52add93
9aee0bd
4a82191
cb71817
5931639
309c706
b915485
d7a54e7
7cc47f0
9929588
36bbbb6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| storybook.requires.ts |
| 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> | ||
| ); | ||
| } |
| 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> | ||
| ); | ||
| } |
| 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; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| module.exports = {}; |
| 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; |
| 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"; |
| 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"; |
| 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; | ||
|
Comment on lines
+30
to
+49
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Prompt To Fix With AIThis 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 />; | ||
| } | ||
| 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> | ||
| ), | ||
| }; |
There was a problem hiding this comment.
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 contextPrompt for AI agents