61 KiB
App-wide desktop design pass Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Bring the app's theme, fonts, container/spacing system, and typography conventions up to the HTML prototype's desktop/web design across every screen, and fully redesign the listing detail page to match its prototype counterpart.
Architecture: A small set of app-wide foundations (theme tokens in global.css, DM Sans/DM Mono fonts loaded in the root layout, a PageContainer max-width wrapper, a MonoText component, a shared micro-label class constant) get built first, then applied mechanically to every existing screen (Home, Browse, Orders, Order Detail, CheckoutSheet) without changing their structure, and finally the listing detail screen gets a full page-specific redesign built on top of those foundations.
Tech Stack: Expo Router, React 19, HeroUI Native, Uniwind (Tailwind CSS v4 for React Native), react-native-svg (already a dependency, used for the new gradient overlays), tailwind-merge (already a dependency), @expo-google-fonts/dm-sans + @expo-google-fonts/dm-mono (new dependencies).
Testing approach: This repo has no test runner configured (package.json has no Jest/Vitest, confirmed in CLAUDE.md) — do not add one. Every task's verification is: npm run typecheck (must pass with zero errors), npm run lint (must pass with zero errors), and a manual check in a running expo start session (web at a specified viewport width, and native where noted) confirming the specific visual change described in that task. Run all commands from the app/ directory.
Reference spec: docs/superpowers/specs/2026-07-03-app-wide-desktop-design.md — read it before starting if any task below feels underspecified; it has the full rationale (why 1320px, why the hero gradient is white/black not teal, etc).
Task 1: Theme tokens — --border and --subtle
Files:
-
Modify:
app/src/global.css -
Step 1: Add the missing
--borderand new--subtletokens
Replace the entire @layer theme { ... } block in app/src/global.css with:
@layer theme {
@variant light {
--background: #f7f6fb;
--surface: #ffffff;
--surface-secondary: #f0eef8;
--accent: #7a34d4;
--accent-foreground: #ffffff;
--success: #059669;
--warning: #d97706;
--danger: #dc2626;
--border: #e2dff0;
--subtle: #9991b8;
}
@variant dark {
--background: #0e0e1c;
--surface: #171730;
--surface-secondary: #1e1e3c;
--accent: #b060ff;
--accent-foreground: #ffffff;
--success: #14f195;
--warning: #f5a623;
--danger: #ff4d4f;
--border: #252548;
--subtle: #605c88;
}
}
@theme inline {
--color-subtle: var(--subtle);
}
Everything except --border and --subtle is unchanged from the current file — do not alter those existing values.
- Step 2: Typecheck and lint
Run: cd app && npm run typecheck && npm run lint
Expected: both pass with no errors (this is a CSS-only change, but confirms nothing else broke).
- Step 3: Manual check
Run npx expo start --web from app/, open the app in a browser. Any existing screen (e.g. Browse) should show a visibly different (correct, subtler) border color on cards than before — confirms --border is now applied instead of HeroUI's generic default gray. text-subtle isn't used anywhere yet, so there's nothing to see for that token yet — that's expected, it lands in later tasks.
- Step 4: Commit
git add app/src/global.css
git commit -m "Add border and subtle theme tokens"
Task 2: DM Sans / DM Mono fonts
Files:
-
Modify:
app/package.json(via install) -
Modify:
app/src/global.css -
Modify:
app/src/app/_layout.tsx -
Step 1: Install the font packages
Run: cd app && npx expo install @expo-google-fonts/dm-sans @expo-google-fonts/dm-mono
Expected: both packages added to app/package.json dependencies, install completes with no errors.
- Step 2: Wire the font family variables into the theme
Append to the end of app/src/global.css (after the @theme inline { --color-subtle: ...; } block added in Task 1):
@theme {
--font-normal: 'DMSans_400Regular';
--font-medium: 'DMSans_500Medium';
--font-semibold: 'DMSans_600SemiBold';
}
- Step 3: Load the fonts in the root layout
Replace the full contents of app/src/app/_layout.tsx with:
import type { JSX } from "react";
import { Stack } from "expo-router";
import { StatusBar } from "expo-status-bar";
import { HeroUINativeProvider } from "heroui-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { View } from "react-native";
import { useFonts } from "expo-font";
import {
DMSans_400Regular,
DMSans_500Medium,
DMSans_600SemiBold,
DMSans_700Bold,
} from "@expo-google-fonts/dm-sans";
import { DMMono_400Regular, DMMono_500Medium } from "@expo-google-fonts/dm-mono";
import "../global.css";
import { WalletProvider } from "@/state/wallet";
import { ModalProvider } from "@/state/modals";
import { TopNav } from "@/components/TopNav";
import { WalletConnectSheet } from "@/components/WalletConnectSheet";
import { CheckoutSheet } from "@/components/CheckoutSheet";
export default function RootLayout(): JSX.Element | null {
const [fontsLoaded] = useFonts({
DMSans_400Regular,
DMSans_500Medium,
DMSans_600SemiBold,
DMSans_700Bold,
DMMono_400Regular,
DMMono_500Medium,
});
if (!fontsLoaded) {
return null;
}
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<HeroUINativeProvider config={{ devInfo: { stylingPrinciples: false } }}>
<WalletProvider>
<ModalProvider>
<View className="flex-1 bg-background">
<TopNav />
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(tabs)" />
</Stack>
</View>
<WalletConnectSheet />
<CheckoutSheet />
</ModalProvider>
</WalletProvider>
<StatusBar style="auto" />
</HeroUINativeProvider>
</GestureHandlerRootView>
);
}
- Step 4: Typecheck and lint
Run: cd app && npm run typecheck && npm run lint
Expected: both pass.
- Step 5: Manual check
Run npx expo start --web, open the app. Body text throughout (headings, buttons, paragraphs) should visibly render in DM Sans (a geometric sans with distinct rounded terminals) instead of the previous system font. If the screen stays blank, check the terminal for a font-loading error — the fontsLoaded gate means the app renders nothing until fonts resolve.
- Step 6: Commit
git add app/package.json app/package-lock.json app/yarn.lock app/src/global.css app/src/app/_layout.tsx
git commit -m "Load DM Sans and DM Mono fonts"
(Only add whichever lockfile actually exists/changed — this repo uses Yarn per CLAUDE.md, so it'll be app/yarn.lock.)
Task 3: Shared typography helpers — MICRO_LABEL_CLASS and MonoText
Files:
-
Create:
app/src/lib/typography.ts -
Create:
app/src/components/MonoText.tsx -
Step 1: Create the micro-label class constant
Create app/src/lib/typography.ts:
export const MICRO_LABEL_CLASS =
"text-[11px] font-semibold uppercase tracking-wider text-subtle";
- Step 2: Create the
MonoTextcomponent
Create app/src/components/MonoText.tsx:
import type { ComponentProps, JSX } from "react";
import { Typography } from "heroui-native";
type MonoTextProps = ComponentProps<typeof Typography>;
export function MonoText({ className, style, ...props }: MonoTextProps): JSX.Element {
return (
<Typography
type="code"
className={`self-auto bg-transparent px-0 py-0 ${className ?? ""}`}
style={[{ fontFamily: "DMMono_500Medium" }, style]}
{...props}
/>
);
}
This wraps HeroUI's Typography type="code" (which selects a monospace font) but strips its default chip-style background/padding, and overrides the font family to the now-loaded DMMono_500Medium via style (HeroUI's code-font selection is hardcoded in its own source and isn't theme-variable-driven, so style — which takes precedence over className — is the only way to swap it).
- Step 3: Typecheck and lint
Run: cd app && npm run typecheck && npm run lint
Expected: both pass. Neither file is used anywhere yet, so there's no visual check for this task — later tasks that import MonoText/MICRO_LABEL_CLASS will verify them visually.
- Step 4: Commit
git add app/src/lib/typography.ts app/src/components/MonoText.tsx
git commit -m "Add MICRO_LABEL_CLASS and MonoText typography helpers"
Task 4: PageContainer component
Files:
-
Create:
app/src/components/PageContainer.tsx -
Step 1: Create the component
Create app/src/components/PageContainer.tsx:
import type { JSX, ReactNode } from "react";
import { View } from "react-native";
import { twMerge } from "tailwind-merge";
interface PageContainerProps {
children: ReactNode;
className?: string;
}
export function PageContainer({ children, className }: PageContainerProps): JSX.Element {
return (
<View className={twMerge("w-full self-center px-4 md:px-8 lg:max-w-[1320px] lg:px-12", className)}>
{children}
</View>
);
}
- Step 2: Typecheck and lint
Run: cd app && npm run typecheck && npm run lint
Expected: both pass. Not used anywhere yet — no visual check for this task.
- Step 3: Commit
git add app/src/components/PageContainer.tsx
git commit -m "Add PageContainer component"
Task 5: StatusChip outline variant
Files:
-
Modify:
app/src/components/StatusChip.tsx -
Step 1: Add the
variantprop
Replace the full contents of app/src/components/StatusChip.tsx with:
import type { JSX } from "react";
import { Chip } from "heroui-native";
import type { StatusColor } from "@/lib/status";
interface StatusChipProps {
label: string;
color: StatusColor;
variant?: "soft" | "outline";
}
const OUTLINE_BORDER_CLASS: Record<StatusColor, string> = {
default: "border border-default",
accent: "border border-accent",
success: "border border-success",
warning: "border border-warning",
danger: "border border-danger",
};
export function StatusChip({ label, color, variant = "soft" }: StatusChipProps): JSX.Element {
if (variant === "outline") {
return (
<Chip variant="tertiary" color={color} size="sm" className={OUTLINE_BORDER_CLASS[color]}>
<Chip.Label>{label}</Chip.Label>
</Chip>
);
}
return (
<Chip variant="soft" color={color} size="sm">
<Chip.Label>{label}</Chip.Label>
</Chip>
);
}
The lookup map uses static, fully-written-out class strings ("border border-success", etc.) rather than a template literal like `border-${color}` — Uniwind/Tailwind need statically analyzable class names present in the source to include them in the build; a dynamically interpolated class name would silently fail to apply the border color at runtime.
- Step 2: Typecheck and lint
Run: cd app && npm run typecheck && npm run lint
Expected: both pass. variant defaults to "soft", so every existing call site (ListingCard, OrderRow, orders/[id].tsx) is unaffected — no visual change yet.
- Step 3: Manual check
Run npx expo start --web, open Browse or Orders. Stock/status chips should look exactly as before (filled soft background) — confirms the default path is unchanged. The new "outline" variant gets exercised visually in Task 9 (listing detail sidebar).
- Step 4: Commit
git add app/src/components/StatusChip.tsx
git commit -m "Add outline variant to StatusChip"
Task 6: ResolverCard — hover, elevated background, monospace fee
Files:
-
Modify:
app/src/components/ResolverCard.tsx -
Step 1: Rewrite the component
Replace the full contents of app/src/components/ResolverCard.tsx with:
import type { JSX } from "react";
import { useState } from "react";
import { Pressable, View } from "react-native";
import { Typography } from "heroui-native";
import type { Resolver } from "@/data/mock";
import { MonoText } from "@/components/MonoText";
interface ResolverCardProps {
resolver: Resolver;
isSelected: boolean;
onSelect: () => void;
}
export function ResolverCard({ resolver, isSelected, onSelect }: ResolverCardProps): JSX.Element {
const [isHovered, setIsHovered] = useState(false);
const borderClass = isSelected ? "border-accent" : isHovered ? "border-border-secondary" : "border-border";
const bgClass = isSelected ? "bg-accent/10" : "bg-surface-secondary";
return (
<Pressable
onPress={onSelect}
onHoverIn={() => setIsHovered(true)}
onHoverOut={() => setIsHovered(false)}
className={`mb-1.5 rounded-xl border p-3 ${borderClass} ${bgClass}`}
>
<View className="flex-row items-center justify-between">
<Typography type="body-sm" weight="semibold" className={isSelected ? "text-accent" : undefined}>
{resolver.name}
</Typography>
<MonoText weight="semibold" className={isSelected ? "text-accent" : undefined}>
{resolver.fee}
</MonoText>
</View>
<Typography type="body-xs" color="muted" className="mt-0.5">
{resolver.type} · {resolver.wins}% buyer-favorable
</Typography>
</Pressable>
);
}
Changes from the current version: unselected background is bg-surface-secondary instead of bg-transparent; onHoverIn/onHoverOut (fire only on web/mouse input, no-op on native touch — no Platform.OS check needed) lighten the border on hover using HeroUI's calculated border-secondary token; the fee percentage renders via MonoText.
- Step 2: Typecheck and lint
Run: cd app && npm run typecheck && npm run lint
Expected: both pass.
- Step 3: Manual check
Run npx expo start --web, open the listing detail page (/listing/0) — the "Dispute Protection" resolver options should now have a visibly elevated (lighter) background when unselected, the fee percentages (0.5%, 1.0%, 1.5%) should render in a monospace font, and hovering an unselected option with the mouse should lighten its border. Also open CheckoutSheet (tap "Buy Now" on any in-stock listing while a wallet is connected, or connect one first) to confirm the same component renders correctly there.
- Step 4: Commit
git add app/src/components/ResolverCard.tsx
git commit -m "Add hover state, elevated background, and monospace fee to ResolverCard"
Task 7: TopNav — height, PageContainer, wider search
Files:
-
Modify:
app/src/components/TopNav.tsx -
Step 1: Rewrite the component
Replace the full contents of app/src/components/TopNav.tsx with:
import type { JSX } from "react";
import { useState } from "react";
import { Pressable, View } from "react-native";
import { useRouter } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { Button, SearchField, Typography, useThemeColor } from "heroui-native";
import { useWallet } from "@/state/wallet";
import { useModals } from "@/state/modals";
import { PageContainer } from "@/components/PageContainer";
export function TopNav(): JSX.Element {
const router = useRouter();
const accentForeground = useThemeColor("accent-foreground");
const { connected, address, disconnect } = useWallet();
const { openWalletConnect } = useModals();
const [search, setSearch] = useState("");
return (
<View className="hidden border-b border-border bg-background md:flex">
<PageContainer className="h-16 flex-row items-center gap-4 lg:h-[76px]">
<Pressable onPress={() => router.push("/")} className="flex-row items-center gap-2">
<View className="size-6 items-center justify-center rounded-md bg-accent">
<Ionicons name="list" size={14} color={accentForeground} />
</View>
<Typography type="h6">Volana</Typography>
</Pressable>
<View className="max-w-[320px] flex-1 lg:max-w-[480px]">
<SearchField value={search} onChange={setSearch}>
<SearchField.Group>
<SearchField.SearchIcon />
<SearchField.Input
placeholder="Search listings…"
onSubmitEditing={() => router.push("/browse")}
/>
</SearchField.Group>
</SearchField>
</View>
<View className="flex-1" />
<Button variant="ghost" size="sm" onPress={() => router.push("/browse")}>
<Button.Label>Browse</Button.Label>
</Button>
<Button variant="ghost" size="sm" onPress={() => router.push("/orders")}>
<Button.Label>My Orders</Button.Label>
</Button>
{connected ? (
<Button variant="secondary" size="sm" onPress={disconnect}>
<Button.Label>{address}</Button.Label>
</Button>
) : (
<Button size="sm" onPress={openWalletConnect}>
<Button.Label>Connect Wallet</Button.Label>
</Button>
)}
</PageContainer>
</View>
);
}
The border-b/bg-background moved to the outer full-width View (the nav bar itself stays edge-to-edge); PageContainer now wraps only the row of content inside it.
- Step 2: Typecheck and lint
Run: cd app && npm run typecheck && npm run lint
Expected: both pass.
- Step 3: Manual check
Run npx expo start --web, resize the browser window: at ≥1320px width the nav content should stop growing and stay centered with visible side margins (previously it stretched edge-to-edge); at ≥1024px the nav bar should be visibly taller (76px vs 64px) and the search field wider (480px cap vs 320px).
- Step 4: Commit
git add app/src/components/TopNav.tsx
git commit -m "Wrap TopNav in PageContainer, scale height and search width"
Task 8: ListingCard — grid variant redesign, hover on both variants
Files:
-
Modify:
app/src/components/ListingCard.tsx -
Step 1: Rewrite the component
Replace the full contents of app/src/components/ListingCard.tsx with:
import type { GestureResponderEvent } from "react-native";
import type { JSX } from "react";
import { useState } from "react";
import { Pressable, StyleSheet, View } from "react-native";
import { useRouter } from "expo-router";
import { Button, Separator, Surface, Typography } from "heroui-native";
import Svg, { Defs, RadialGradient, Rect, Stop } from "react-native-svg";
import type { Product } from "@/data/mock";
import { availabilityInfo } from "@/lib/status";
import { StatusChip } from "@/components/StatusChip";
import { MonoText } from "@/components/MonoText";
import { useModals } from "@/state/modals";
interface ListingCardProps {
product: Product;
variant?: "list" | "grid";
}
function CardHighlightOverlay({ gradientId }: { gradientId: string }): JSX.Element {
return (
<Svg style={StyleSheet.absoluteFill} width="100%" height="100%" pointerEvents="none">
<Defs>
<RadialGradient id={gradientId} cx="30%" cy="25%" r="60%">
<Stop offset="0%" stopColor="#ffffff" stopOpacity={0.07} />
<Stop offset="100%" stopColor="#ffffff" stopOpacity={0} />
</RadialGradient>
</Defs>
<Rect width="100%" height="100%" fill={`url(#${gradientId})`} />
</Svg>
);
}
export function ListingCard({ product, variant = "grid" }: ListingCardProps): JSX.Element {
const router = useRouter();
const { requestBuy } = useModals();
const [isHovered, setIsHovered] = useState(false);
const availability = availabilityInfo(product.qty);
const isOut = product.qty === 0;
const openListing = () => router.push(`/listing/${product.id}`);
const handleBuyPress = (event: GestureResponderEvent) => {
event.stopPropagation();
requestBuy(product.id);
};
if (variant === "list") {
return (
<Pressable
onPress={openListing}
onHoverIn={() => setIsHovered(true)}
onHoverOut={() => setIsHovered(false)}
>
<Surface
variant="default"
className={`flex-row items-stretch gap-0 overflow-hidden rounded-xl border p-0 ${
isHovered ? "border-border-secondary" : "border-border"
}`}
>
<View
className="w-24 items-center justify-center md:w-36"
style={{ backgroundColor: product.bg }}
>
<Typography className="text-4xl">{product.emoji}</Typography>
</View>
<Separator orientation="vertical" />
<View className="w-24 justify-center gap-0.5 p-3 md:w-32">
<Typography type="h6" className="text-accent">
{product.priceNum}
</Typography>
<Typography type="body-xs" color="muted">
{product.cur}
</Typography>
</View>
<Separator orientation="vertical" />
<View className="flex-1 justify-center gap-1 p-3">
<Typography type="body-sm" weight="semibold" numberOfLines={1}>
{product.name}
</Typography>
<Typography type="body-xs" color="muted" numberOfLines={2}>
{product.desc}
</Typography>
</View>
<Separator orientation="vertical" />
<View className="w-28 items-stretch justify-center gap-2 p-3">
<StatusChip label={availability.label} color={availability.color} />
<Button
size="sm"
variant={isOut ? "secondary" : "primary"}
isDisabled={isOut}
onPress={handleBuyPress}
>
<Button.Label>{isOut ? "Out of Stock" : "Buy Now"}</Button.Label>
</Button>
</View>
</Surface>
</Pressable>
);
}
return (
<Pressable
onPress={openListing}
onHoverIn={() => setIsHovered(true)}
onHoverOut={() => setIsHovered(false)}
className="w-full p-1.5 md:w-1/2 lg:w-1/4"
>
<Surface
variant="default"
className={`gap-0 overflow-hidden rounded-xl border p-0 ${isHovered ? "border-border-secondary" : "border-border"}`}
>
<View className="aspect-[4/3] items-center justify-center" style={{ backgroundColor: product.bg }}>
<CardHighlightOverlay gradientId={`card-highlight-${product.id}`} />
<Typography className="text-6xl lg:text-7xl">{product.emoji}</Typography>
</View>
<View className="gap-1 p-2.5">
<View className="flex-row items-start justify-between gap-2">
<Typography type="body-sm" weight="semibold" numberOfLines={2} className="flex-1">
{product.name}
</Typography>
<StatusChip label={availability.label} color={availability.color} />
</View>
<MonoText type="body-xs" color="muted" numberOfLines={1}>
{product.seller}
</MonoText>
<Typography type="body" weight="bold" className="mt-1 text-accent">
{product.price}
</Typography>
<Button
size="sm"
variant={isOut ? "secondary" : "primary"}
isDisabled={isOut}
className="mt-2 w-full"
onPress={handleBuyPress}
>
<Button.Label>{isOut ? "Out of Stock" : "Buy Now"}</Button.Label>
</Button>
</View>
</Surface>
</Pressable>
);
}
Key changes to the grid variant: image area is now aspect-[4/3] (was a fixed h-32) with a per-card radial highlight overlay (unique gradient id per product, since SVG gradient ids are DOM-scoped on web and multiple cards render simultaneously); emoji is much larger (text-6xl lg:text-7xl vs the old flat text-4xl); grid width steps w-full → md:w-1/2 → lg:w-1/4 (1/2/4 columns, was w-1/2/md:w-1/3/lg:w-1/4, i.e. always at least 2 columns even on phones); the stock badge moved next to the title instead of next to the price; seller address uses MonoText. Both variants gained a permanent 1px border (border-border, brightening to border-border-secondary on hover) — Surface has no border by default, so this is new, and it's always present (not just added on hover) to avoid layout shift. Both variants also override Surface's default rounded-3xl (24px) down to rounded-xl (12px) — the prototype's product cards use much tighter ~9-10px corners than Surface's default, and 12px is the closest step on the existing radius scale, consistent with the flatter-cornered look used elsewhere (e.g. the listing detail page's CTA button).
- Step 2: Typecheck and lint
Run: cd app && npm run typecheck && npm run lint
Expected: both pass.
- Step 3: Manual check
Run npx expo start --web, open Home — the featured-listings grid should show 1 card per row on a narrow (<768px) window, 2 per row at md, and (once you're on a screen ≥1024px wide) 4 per row, each with a large centered emoji over a subtly gradient-lit background and a visible thin border that brightens on mouse hover. Open Browse in grid view for the same check, and list view to confirm the hover border there too. Also run on a native simulator (iOS or Android) to confirm the grid still renders correctly there (no SVG crashes, no onHoverIn issues — these are no-ops on native, which is expected).
- Step 4: Commit
git add app/src/components/ListingCard.tsx
git commit -m "Redesign ListingCard grid variant, add hover to both variants"
Task 9: Listing detail page — full redesign
Files:
-
Modify:
app/src/app/listing/[id].tsx -
Step 1: Rewrite the screen
Replace the full contents of app/src/app/listing/[id].tsx with:
import type { JSX } from "react";
import type { ViewStyle } from "react-native";
import { useMemo, useState } from "react";
import { Platform, ScrollView, StyleSheet, View, useWindowDimensions } from "react-native";
import { useLocalSearchParams } from "expo-router";
import { Button, Chip, Separator, Typography, useThemeColor, useToast } from "heroui-native";
import { Ionicons } from "@expo/vector-icons";
import Svg, { Defs, LinearGradient, RadialGradient, Rect, Stop } from "react-native-svg";
import type { Currency } from "@/data/mock";
import { PRODUCTS, RESOLVERS } from "@/data/mock";
import { availabilityInfo } from "@/lib/status";
import { MICRO_LABEL_CLASS } from "@/lib/typography";
import { AppHeader } from "@/components/AppHeader";
import { StatusChip } from "@/components/StatusChip";
import { ResolverCard } from "@/components/ResolverCard";
import { ListingCard } from "@/components/ListingCard";
import { MonoText } from "@/components/MonoText";
import { PageContainer } from "@/components/PageContainer";
import { useModals } from "@/state/modals";
const STICKY_BREAKPOINT = 1024;
function HeroGradientOverlay({ gradientId }: { gradientId: string }): JSX.Element {
const highlightId = `${gradientId}-highlight`;
const fadeId = `${gradientId}-fade`;
return (
<Svg style={StyleSheet.absoluteFill} width="100%" height="100%" pointerEvents="none">
<Defs>
<RadialGradient id={highlightId} cx="35%" cy="30%" r="60%">
<Stop offset="0%" stopColor="#ffffff" stopOpacity={0.08} />
<Stop offset="100%" stopColor="#ffffff" stopOpacity={0} />
</RadialGradient>
<LinearGradient id={fadeId} x1="0" y1="0" x2="0" y2="1">
<Stop offset="78%" stopColor="#000000" stopOpacity={0} />
<Stop offset="100%" stopColor="#000000" stopOpacity={0.3} />
</LinearGradient>
</Defs>
<Rect width="100%" height="100%" fill={`url(#${highlightId})`} />
<Rect width="100%" height="100%" fill={`url(#${fadeId})`} />
</Svg>
);
}
export default function ListingDetailScreen(): JSX.Element {
const { id } = useLocalSearchParams<{ id: string }>();
const { toast } = useToast();
const { requestBuy } = useModals();
const [resolverId, setResolverId] = useState(0);
const { width } = useWindowDimensions();
const accentColor = useThemeColor("accent");
// react-native-web supports CSS `position: sticky`, but React Native's own
// ViewStyle type doesn't include it — cast is required to use it web-only.
const stickyBoxStyle: ViewStyle | undefined =
Platform.OS === "web" && width >= STICKY_BREAKPOINT
? ({ position: "sticky", top: 92 } as ViewStyle)
: undefined;
const listing = useMemo(
() => PRODUCTS.find((product) => product.id === Number(id)) ?? PRODUCTS[0],
[id],
);
const [currency, setCurrency] = useState<Currency>(listing.cur);
const availability = availabilityInfo(listing.qty);
const isOut = listing.qty === 0;
const relatedProducts = PRODUCTS.filter((p) => p.id !== listing.id && p.qty > 0).slice(0, 6);
return (
<ScrollView className="flex-1 bg-background">
<AppHeader title={listing.name} />
<PageContainer className="gap-6 py-4 lg:flex-row lg:items-start lg:gap-8 lg:py-10">
<View className="flex-1 gap-6 lg:gap-10">
<View
className="aspect-[8/5] w-full items-center justify-center overflow-hidden rounded-2xl lg:aspect-[12/5]"
style={{ backgroundColor: listing.bg }}
>
<HeroGradientOverlay gradientId={`hero-${listing.id}`} />
<Typography className="text-[96px] lg:text-[160px]">{listing.emoji}</Typography>
</View>
<View className="gap-2">
<Typography type="h4" weight="bold" className="text-2xl leading-tight lg:text-[38px]">
{listing.name}
</Typography>
<Typography type="body-sm" color="muted" className="text-[15px] lg:text-[17px]">
{listing.desc}
</Typography>
</View>
<View className="flex-row items-center gap-3 rounded-xl border border-border bg-surface p-3.5">
<View className="size-9 items-center justify-center rounded-full bg-accent-soft">
<Ionicons name="checkmark-circle" size={18} color={accentColor} />
</View>
<View className="flex-1">
<Typography type="body-sm" weight="semibold">
Verified on Solana
</Typography>
<MonoText type="body-xs" color="muted">
{listing.seller}
</MonoText>
</View>
<Button size="sm" variant="ghost" onPress={() => toast.show("Address copied!")}>
<Button.Label>Copy</Button.Label>
</Button>
</View>
<View className="gap-3 rounded-xl border border-border bg-surface p-3.5">
<Typography className={MICRO_LABEL_CLASS}>About this listing</Typography>
<View className="flex-row flex-wrap gap-y-4">
<View className="w-1/2">
<Typography className={MICRO_LABEL_CLASS}>Category</Typography>
<Typography type="body-sm" weight="medium" className="mt-1">
{listing.cat}
</Typography>
</View>
<View className="w-1/2">
<Typography className={MICRO_LABEL_CLASS}>Available</Typography>
<Typography type="body-sm" weight="medium" className="mt-1">
{listing.qty} units
</Typography>
</View>
<View className="w-1/2">
<Typography className={MICRO_LABEL_CLASS}>Currency</Typography>
<Typography type="body-sm" weight="medium" className="mt-1">
{listing.cur}
</Typography>
</View>
<View className="w-1/2">
<Typography className={MICRO_LABEL_CLASS}>Listing ID</Typography>
<MonoText type="body-sm" color="muted" className="mt-1">
{`#${listing.id}${listing.id}${listing.id}${listing.id}`}
</MonoText>
</View>
</View>
</View>
{relatedProducts.length > 0 && (
<View className="gap-3">
<Typography type="h6">Customers also bought</Typography>
<View className="flex-row flex-wrap gap-5 lg:gap-6">
{relatedProducts.map((product) => (
<ListingCard key={product.id} product={product} variant="grid" />
))}
</View>
</View>
)}
</View>
<View
className="gap-5 rounded-2xl border border-border bg-surface p-6 lg:w-[340px] lg:gap-6 lg:p-8"
style={stickyBoxStyle}
>
<View>
<Typography weight="bold" className="text-[30px] font-extrabold leading-none lg:text-[42px]">
{listing.price}
</Typography>
<Typography className="mt-1 text-[15px] text-subtle lg:text-[18px]">
≈ {listing.usd} USD
</Typography>
<View className="mt-2.5 self-start">
<StatusChip label={availability.label} color={availability.color} variant="outline" />
</View>
</View>
<Separator />
{listing.alt.length > 0 && (
<>
<View className="gap-2">
<Typography className={MICRO_LABEL_CLASS}>Pay with</Typography>
<View className="flex-row gap-2">
<Chip
variant={currency === listing.cur ? "primary" : "soft"}
color={currency === listing.cur ? "accent" : "default"}
onPress={() => setCurrency(listing.cur)}
>
<Chip.Label>{listing.cur}</Chip.Label>
</Chip>
<Chip
variant={currency === listing.alt[0] ? "primary" : "soft"}
color={currency === listing.alt[0] ? "accent" : "default"}
onPress={() => setCurrency(listing.alt[0])}
>
<Chip.Label>{listing.alt[0]}</Chip.Label>
</Chip>
</View>
</View>
<Separator />
</>
)}
<View className="gap-2">
<Typography className={MICRO_LABEL_CLASS}>Dispute Protection</Typography>
{RESOLVERS.map((resolver) => (
<ResolverCard
key={resolver.id}
resolver={resolver}
isSelected={resolver.id === resolverId}
onSelect={() => setResolverId(resolver.id)}
/>
))}
<Typography type="body-xs" className="text-subtle">
Fee only charged if dispute is adjudicated.
</Typography>
</View>
<Separator />
<Button
isDisabled={isOut}
size="lg"
variant={isOut ? "secondary" : "primary"}
className="w-full rounded-xl"
onPress={() => requestBuy(listing.id)}
>
<Button.Label className="font-semibold">{isOut ? "Out of Stock" : "Buy Now →"}</Button.Label>
</Button>
<View className="flex-row flex-wrap gap-1.5">
<Chip size="sm" variant="secondary">
<Chip.Label>✓ Final Payment</Chip.Label>
</Chip>
<Chip size="sm" variant="secondary">
<Chip.Label>🛡 Dispute Cover</Chip.Label>
</Chip>
<Chip size="sm" variant="secondary">
<Chip.Label>⬡ On-chain</Chip.Label>
</Chip>
</View>
<Typography type="body-xs" className="text-subtle" align="center">
Funds held in secure escrow until you confirm receipt.
</Typography>
</View>
</PageContainer>
</ScrollView>
);
}
Notes on choices that differ slightly from the spec's illustrative pseudocode:
-
The hero's aspect ratio is set via Tailwind's
aspect-[8/5] lg:aspect-[12/5]classes (1.6 and 2.4 respectively) rather than a JS-computed inlineaspectRatiostyle. This is simpler and — important — avoids a cross-platform bug: the two-column layout'slg:flex-rowbreakpoint is evaluated by Uniwind on every platform (a wide native tablet can trigger it), so the hero's sizing must respond to the same CSS breakpoint, not aPlatform.OS === "web"-gated JS value. -
position: stickygenuinely has no native equivalent, sostickyBoxStylestays gated on bothPlatform.OS === "web"and the width check — that's the one piece that's correctly platform-specific. -
The sidebar price uses
font-extraboldviaclassName(Typography'sweightprop only accepts'normal' | 'medium' | 'semibold' | 'bold', noextrabold, so the Tailwind class is required to reach that weight). -
Step 2: Typecheck and lint
Run: cd app && npm run typecheck && npm run lint
Expected: both pass.
- Step 3: Manual check — desktop
Run npx expo start --web, open /listing/0 (MacBook Pro listing, which has an alt currency and 5 in stock — exercises every conditional section) at a browser width ≥1320px. Confirm: page content is centered with margins, not edge-to-edge; hero is a wide (2.4:1) rounded rectangle with a large centered emoji and a visible soft highlight/gradient (not a flat color box); title is large (~38px) and bold; sidebar is a distinct bordered card fixed at 340px on the right, sticky when you scroll; "5 in stock" renders as an outlined pill (colored border + text, transparent background) not a filled badge; "Buy Now →" button is tall (~56px) with a less-rounded corner than other buttons in the app; fee percentages and wallet/listing-ID text render in monospace; "Pay with"/"Dispute Protection"/"About this listing" labels are small, uppercase, letter-spaced.
- Step 4: Manual check — mobile and native
Resize the browser to <768px width: layout should stay single-column (hero, then content, then sidebar stacked below, same as before this change — just check nothing looks cramped or oversized). Then run on a native simulator (iOS or Android) and open the same listing: confirm it renders without crashing (this is the first screen using react-native-svg gradients directly in a screen file, not just inside ListingCard) and that the sidebar is not sticky (expected — native has no sticky positioning) but otherwise looks correct stacked below the hero.
- Step 5: Commit
git add app/src/app/listing/[id].tsx
git commit -m "Redesign listing detail page for desktop"
Task 10: Home — adopt PageContainer
Files:
-
Modify:
app/src/app/(tabs)/index.tsx -
Step 1: Rewrite the screen
Replace the full contents of app/src/app/(tabs)/index.tsx with:
import type { JSX } from "react";
import { ScrollView, View } from "react-native";
import { useRouter } from "expo-router";
import { Button, Typography } from "heroui-native";
import { PRODUCTS } from "@/data/mock";
import { ListingCard } from "@/components/ListingCard";
import { PageContainer } from "@/components/PageContainer";
export default function HomeTab(): JSX.Element {
const router = useRouter();
const featured = PRODUCTS.filter((product) => product.qty > 0).slice(0, 4);
return (
<ScrollView className="flex-1 bg-background">
<PageContainer className="gap-10 py-8 lg:py-14">
<View className="gap-4 md:items-center">
<Typography type="h2" className="md:text-center">
{"The marketplace\n"}
<Typography type="h2" className="text-accent">
nobody controls.
</Typography>
</Typography>
<Typography type="body-sm" color="muted" className="md:max-w-md md:text-center">
Buy and sell freely. Payments are final — no chargebacks, no frozen accounts.
</Typography>
<Button onPress={() => router.push("/browse")} className="self-start md:self-center">
<Button.Label>Browse Listings →</Button.Label>
</Button>
</View>
<View className="gap-3">
<Typography type="h5">Featured listings</Typography>
<View className="flex-row flex-wrap gap-5 lg:gap-6">
{featured.map((product) => (
<ListingCard key={product.id} product={product} variant="grid" />
))}
</View>
</View>
</PageContainer>
</ScrollView>
);
}
- Step 2: Typecheck and lint
Run: cd app && npm run typecheck && npm run lint
Expected: both pass.
- Step 3: Manual check
Run npx expo start --web, open Home at ≥1320px width — content should stay centered with margins instead of stretching edge-to-edge, and the featured grid should show 4 columns. At <768px, confirm it still looks like a normal single-column mobile screen (headline, description, button, then a 1-column grid).
- Step 4: Commit
git add "app/src/app/(tabs)/index.tsx"
git commit -m "Wrap Home in PageContainer"
Task 11: Browse — adopt PageContainer and micro-labels
Files:
-
Modify:
app/src/app/(tabs)/browse.tsx -
Step 1: Rewrite the screen
Replace the full contents of app/src/app/(tabs)/browse.tsx with:
import type { JSX } from "react";
import { useMemo, useState } from "react";
import { Pressable, ScrollView, View } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { Chip, SearchField, Switch, Typography, useThemeColor } from "heroui-native";
import type { Currency, Product } from "@/data/mock";
import { PRODUCTS } from "@/data/mock";
import { ListingCard } from "@/components/ListingCard";
import { PageContainer } from "@/components/PageContainer";
import { MICRO_LABEL_CLASS } from "@/lib/typography";
type CurrencyFilter = "all" | Currency;
type SortMode = "newest" | "priceLow" | "priceHigh";
type ViewMode = "list" | "grid";
export default function BrowseTab(): JSX.Element {
const [search, setSearch] = useState("");
const [currencyFilter, setCurrencyFilter] = useState<CurrencyFilter>("all");
const [sortMode, setSortMode] = useState<SortMode>("newest");
const [inStockOnly, setInStockOnly] = useState(true);
const [viewMode, setViewMode] = useState<ViewMode>("list");
const mutedColor = useThemeColor("muted");
const accentForeground = useThemeColor("accent-foreground");
const products = useMemo<Product[]>(() => {
let result = [...PRODUCTS];
if (inStockOnly) result = result.filter((product) => product.qty > 0);
if (currencyFilter !== "all") result = result.filter((product) => product.cur === currencyFilter);
if (search.trim()) {
const query = search.trim().toLowerCase();
result = result.filter(
(product) =>
product.name.toLowerCase().includes(query) || product.desc.toLowerCase().includes(query),
);
}
if (sortMode === "priceLow") result.sort((a, b) => a.priceNum - b.priceNum);
else if (sortMode === "priceHigh") result.sort((a, b) => b.priceNum - a.priceNum);
return result;
}, [search, currencyFilter, sortMode, inStockOnly]);
return (
<View className="flex-1 bg-background">
<View className="border-b border-border">
<PageContainer className="gap-3 py-3">
<SearchField value={search} onChange={setSearch}>
<SearchField.Group>
<SearchField.SearchIcon />
<SearchField.Input placeholder="Search listings…" />
<SearchField.ClearButton />
</SearchField.Group>
</SearchField>
<View className="flex-row flex-wrap items-center gap-2">
<Typography className={MICRO_LABEL_CLASS}>Currency</Typography>
<Chip
size="sm"
variant={currencyFilter === "all" ? "primary" : "soft"}
color={currencyFilter === "all" ? "accent" : "default"}
onPress={() => setCurrencyFilter("all")}
>
<Chip.Label>All</Chip.Label>
</Chip>
<Chip
size="sm"
variant={currencyFilter === "SOL" ? "primary" : "soft"}
color={currencyFilter === "SOL" ? "accent" : "default"}
onPress={() => setCurrencyFilter("SOL")}
>
<Chip.Label>SOL</Chip.Label>
</Chip>
<Chip
size="sm"
variant={currencyFilter === "USDC" ? "primary" : "soft"}
color={currencyFilter === "USDC" ? "accent" : "default"}
onPress={() => setCurrencyFilter("USDC")}
>
<Chip.Label>USDC</Chip.Label>
</Chip>
<Typography className={`${MICRO_LABEL_CLASS} ml-2`}>Sort</Typography>
<Chip
size="sm"
variant={sortMode === "newest" ? "primary" : "soft"}
color={sortMode === "newest" ? "accent" : "default"}
onPress={() => setSortMode("newest")}
>
<Chip.Label>Newest</Chip.Label>
</Chip>
<Chip
size="sm"
variant={sortMode === "priceLow" ? "primary" : "soft"}
color={sortMode === "priceLow" ? "accent" : "default"}
onPress={() => setSortMode("priceLow")}
>
<Chip.Label>Price ↑</Chip.Label>
</Chip>
<Chip
size="sm"
variant={sortMode === "priceHigh" ? "primary" : "soft"}
color={sortMode === "priceHigh" ? "accent" : "default"}
onPress={() => setSortMode("priceHigh")}
>
<Chip.Label>Price ↓</Chip.Label>
</Chip>
</View>
<View className="flex-row items-center justify-between">
<View className="flex-row items-center gap-2">
<Switch isSelected={inStockOnly} onSelectedChange={setInStockOnly} />
<Typography type="body-xs" color="muted">
In stock only
</Typography>
</View>
<View className="flex-row gap-1 rounded-lg border border-border bg-surface p-1">
<Pressable
onPress={() => setViewMode("list")}
className={`h-7 w-8 items-center justify-center rounded-md ${
viewMode === "list" ? "bg-accent" : ""
}`}
>
<Ionicons name="list" size={14} color={viewMode === "list" ? accentForeground : mutedColor} />
</Pressable>
<Pressable
onPress={() => setViewMode("grid")}
className={`h-7 w-8 items-center justify-center rounded-md ${
viewMode === "grid" ? "bg-accent" : ""
}`}
>
<Ionicons name="grid" size={14} color={viewMode === "grid" ? accentForeground : mutedColor} />
</Pressable>
</View>
</View>
</PageContainer>
</View>
<ScrollView className="flex-1">
<PageContainer className="py-3.5">
{viewMode === "list" ? (
<View className="gap-2.5">
{products.map((product) => (
<ListingCard key={product.id} product={product} variant="list" />
))}
</View>
) : (
<View className="flex-row flex-wrap">
{products.map((product) => (
<ListingCard key={product.id} product={product} variant="grid" />
))}
</View>
)}
</PageContainer>
</ScrollView>
</View>
);
}
- Step 2: Typecheck and lint
Run: cd app && npm run typecheck && npm run lint
Expected: both pass.
- Step 3: Manual check
Run npx expo start --web, open Browse. "Currency" and "Sort" captions should render small, uppercase, and letter-spaced (not the previous plain sentence-case muted text). At ≥1320px width, the filter bar and listing grid/list should stay centered with margins instead of stretching edge-to-edge, matching Home's and the listing detail page's container width. Toggle list/grid view and confirm both still work.
- Step 4: Commit
git add "app/src/app/(tabs)/browse.tsx"
git commit -m "Wrap Browse in PageContainer, apply micro-label style to filter captions"
Task 12: Orders list + OrderRow — PageContainer, monospace, hover
Files:
-
Modify:
app/src/app/(tabs)/orders.tsx -
Modify:
app/src/components/OrderRow.tsx -
Step 1: Rewrite the Orders list screen
Replace the full contents of app/src/app/(tabs)/orders.tsx with:
import type { JSX } from "react";
import { ScrollView } from "react-native";
import { Typography } from "heroui-native";
import { ORDERS } from "@/data/mock";
import { OrderRow } from "@/components/OrderRow";
import { PageContainer } from "@/components/PageContainer";
export default function OrdersTab(): JSX.Element {
return (
<ScrollView className="flex-1 bg-background">
<PageContainer className="gap-2.5 py-4">
<Typography type="h4" className="mb-1">
My Orders
</Typography>
{ORDERS.map((order) => (
<OrderRow key={order.id} order={order} />
))}
</PageContainer>
</ScrollView>
);
}
Note the removed View import (no longer needed — PageContainer replaces the wrapping View) and the removed md:mx-auto md:w-full md:max-w-2xl — the order list now uses the same 1320px-capped container as every other screen instead of its own narrower 672px cap.
- Step 2: Rewrite
OrderRow
Replace the full contents of app/src/components/OrderRow.tsx with:
import type { JSX } from "react";
import { useState } from "react";
import { Pressable, View } from "react-native";
import { useRouter } from "expo-router";
import { Typography } from "heroui-native";
import type { Order } from "@/data/mock";
import { orderStatusInfo } from "@/lib/status";
import { StatusChip } from "@/components/StatusChip";
import { MonoText } from "@/components/MonoText";
interface OrderRowProps {
order: Order;
}
export function OrderRow({ order }: OrderRowProps): JSX.Element {
const router = useRouter();
const [isHovered, setIsHovered] = useState(false);
const status = orderStatusInfo(order.status);
return (
<Pressable
onPress={() => router.push(`/orders/${order.id}`)}
onHoverIn={() => setIsHovered(true)}
onHoverOut={() => setIsHovered(false)}
className={`flex-row items-center gap-3.5 rounded-xl border bg-surface p-3.5 ${
isHovered ? "border-border-secondary" : "border-border"
}`}
>
<Typography className="text-2xl">{order.emoji}</Typography>
<View className="flex-1 gap-0.5">
<Typography type="body-sm" weight="semibold" numberOfLines={1}>
{order.name}
</Typography>
<MonoText type="body-xs" color="muted">
{order.seller}
</MonoText>
</View>
<View className="items-end gap-0.5">
<Typography type="body-sm" weight="bold">
{order.amt}
</Typography>
<Typography type="body-xs" color="muted">
{order.date}
</Typography>
</View>
<StatusChip label={status.label} color={status.color} />
</Pressable>
);
}
- Step 3: Typecheck and lint
Run: cd app && npm run typecheck && npm run lint
Expected: both pass.
- Step 4: Manual check
Run npx expo start --web, open Orders. Seller addresses should render in monospace; hovering a row with the mouse should brighten its border; the page content should be capped/centered at the same 1320px width as other screens (wider than before, since the old 672px cap is gone).
- Step 5: Commit
git add "app/src/app/(tabs)/orders.tsx" app/src/components/OrderRow.tsx
git commit -m "Wrap Orders list in PageContainer, add monospace and hover to OrderRow"
Task 13: Order detail — PageContainer, monospace, micro-label
Files:
-
Modify:
app/src/app/orders/[id].tsx -
Step 1: Rewrite the screen
Replace the full contents of app/src/app/orders/[id].tsx with:
import type { JSX } from "react";
import { useMemo, useState } from "react";
import { ScrollView, View } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";
import { Button, Typography, useToast } from "heroui-native";
import { ORDERS } from "@/data/mock";
import { orderStatusInfo } from "@/lib/status";
import { AppHeader } from "@/components/AppHeader";
import { StatusChip } from "@/components/StatusChip";
import { MonoText } from "@/components/MonoText";
import { PageContainer } from "@/components/PageContainer";
import { MICRO_LABEL_CLASS } from "@/lib/typography";
const STEP_LABELS = ["Payment\nHeld", "Seller\nConfirms", "You Confirm\nReceipt"] as const;
export default function OrderDetailScreen(): JSX.Element {
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
const { toast } = useToast();
const order = useMemo(() => ORDERS.find((item) => item.id === Number(id)) ?? ORDERS[0], [id]);
const [status, setStatus] = useState(order.status);
const statusInfo = orderStatusInfo(status);
const step2Done = status !== "AwaitingConfirm";
const step3Done = status === "Complete";
const activeStepIndex = step3Done ? 2 : step2Done ? 1 : 0;
const canCancel = status === "AwaitingConfirm";
const canConfirm = status === "Active";
const handleCancel = () => {
setStatus("Cancelled");
toast.show({ variant: "success", label: "Order cancelled. Full refund initiated." });
};
const handleConfirmReceipt = () => {
setStatus("Complete");
toast.show({ variant: "success", label: "Receipt confirmed! Seller has been paid." });
};
return (
<ScrollView className="flex-1 bg-background">
<AppHeader title="Order Detail" />
<PageContainer className="gap-5 py-4 lg:py-8">
<View className="flex-row items-start justify-between gap-3 rounded-xl border border-border bg-surface p-4">
<View className="flex-1 gap-1">
<Typography type="body-xs" color="muted">
Order placed {order.date}
</Typography>
<Typography type="h6">{order.name}</Typography>
<MonoText type="body-xs" color="muted">
{order.seller}
</MonoText>
</View>
<View className="items-end gap-1.5">
<Typography type="h6">{order.amt}</Typography>
<StatusChip label={statusInfo.label} color={statusInfo.color} />
</View>
</View>
<View className="gap-4 rounded-xl border border-border bg-surface p-4">
<Typography className={MICRO_LABEL_CLASS}>Order Progress</Typography>
<View className="flex-row items-start">
{STEP_LABELS.map((label, index) => {
const done = index < activeStepIndex || (index === activeStepIndex && step3Done);
const active = index === activeStepIndex && !step3Done;
return (
<View key={label} className="flex-1 items-center gap-2">
<View
className={`size-7 items-center justify-center rounded-full ${
done ? "bg-success" : active ? "bg-accent" : "bg-surface-secondary"
}`}
>
<Typography type="body-xs" weight="bold" className={done ? "text-black" : "text-white"}>
{done ? "✓" : String(index + 1)}
</Typography>
</View>
<Typography
type="body-xs"
weight="semibold"
align="center"
color={done || active ? undefined : "muted"}
className={done ? "text-success" : active ? "text-accent" : undefined}
>
{label}
</Typography>
</View>
);
})}
</View>
</View>
<View className="gap-1 rounded-xl bg-surface-secondary p-4">
<Typography type="body-sm" color="muted">
<Typography type="body-sm" weight="semibold">
{order.amt}
</Typography>
{" is locked in a secure on-chain escrow. Funds are released to the seller only after you "}
confirm receipt — no third party can touch them.
</Typography>
</View>
<View className="flex-row flex-wrap gap-2.5">
{canCancel && (
<Button variant="danger-soft" onPress={handleCancel}>
<Button.Label>Cancel Order (Full Refund)</Button.Label>
</Button>
)}
{canConfirm && (
<Button onPress={handleConfirmReceipt}>
<Button.Label>Confirm Receipt — Release Payment</Button.Label>
</Button>
)}
<Button variant="secondary" onPress={() => router.push(`/listing/${order.lid}`)}>
<Button.Label>View Listing →</Button.Label>
</Button>
</View>
</PageContainer>
</ScrollView>
);
}
- Step 2: Typecheck and lint
Run: cd app && npm run typecheck && npm run lint
Expected: both pass.
- Step 3: Manual check
Run npx expo start --web, open an order detail page from Orders. Seller address should render in monospace; "Order Progress" caption should be small/uppercase/letter-spaced; content should be capped/centered at 1320px on wide viewports instead of the old narrower 672px cap. Click through the Cancel/Confirm buttons (where applicable to the order's status) to confirm the status-stepper interaction still works.
- Step 4: Commit
git add "app/src/app/orders/[id].tsx"
git commit -m "Wrap Order Detail in PageContainer, add monospace and micro-label"
Task 14: CheckoutSheet — monospace seller address
Files:
-
Modify:
app/src/components/CheckoutSheet.tsx -
Step 1: Import
MonoTextand swap the seller address
In app/src/components/CheckoutSheet.tsx, add the import alongside the other @/components imports:
import { ResolverCard } from "@/components/ResolverCard";
import { MonoText } from "@/components/MonoText";
Then replace this block (inside the checkoutStep === "review" branch):
<Typography type="body-xs" color="muted">
{listing.seller}
</Typography>
with:
<MonoText type="body-xs" color="muted">
{listing.seller}
</MonoText>
- Step 2: Typecheck and lint
Run: cd app && npm run typecheck && npm run lint
Expected: both pass.
- Step 3: Manual check
Run npx expo start --web. Connect a wallet, tap "Buy Now" on an in-stock listing to open the checkout sheet, and confirm the seller address in the order summary now renders in monospace.
- Step 4: Commit
git add app/src/components/CheckoutSheet.tsx
git commit -m "Render seller address in monospace in CheckoutSheet"
Task 15: Final full-app verification pass
Files: none (verification only)
- Step 1: Full typecheck and lint
Run: cd app && npm run typecheck && npm run lint && npm run format:check
Expected: all three pass with zero errors. If format:check fails, run npm run format and re-check the diff is only whitespace/formatting before committing it separately.
- Step 2: Web pass — every screen, three widths
Run npx expo start --web. At each of three browser widths — 375px (phone), 900px (tablet), 1400px (desktop, wider than the 1320px cap so you can see the cap take effect) — visit and check for layout breakage (overlapping text, content escaping its container, oversized/tiny text) on: Home, Browse (both list and grid view), Orders list, an Order Detail page, and a Listing Detail page (use one with an alt currency and in-stock, e.g. /listing/0, and one out-of-stock, e.g. /listing/5, to check the disabled "Out of Stock" button state).
- Step 3: Native pass
Run npm run ios or npm run android (whichever simulator/emulator is available). Navigate through Home → Browse → a listing detail page → back → Orders → an order detail page → Checkout flow (connect wallet, buy, confirm). Confirm: no crashes (particularly around the new react-native-svg gradients and onHoverIn/onHoverOut handlers, both of which are new on native even though they're inert there), fonts render as DM Sans/DM Mono (not a fallback system font — if fonts are missing, text will look like the platform default), and nothing web-only (the sticky sidebar, hover borders) leaked into a broken native-only state.
- Step 4: Fix anything found, or confirm clean
If Steps 2 or 3 surface an issue, fix it in the relevant file from the task above that owns it, re-run that task's typecheck/lint, and commit the fix with a message describing what was broken (not "fix bug" — name the actual problem, e.g. "Fix hero gradient overflowing rounded corners on Android"). If nothing is found, no commit is needed for this task — it's verification-only.
Self-review notes
- Spec coverage: every numbered section of the spec (theme, fonts,
PageContainer, app-wide rollout, listing detail layout/hero/typography/sidebar/verified/about,StatusChip,ResolverCard,TopNav,ListingCard, cross-cutting hover/spacing) maps to a task above. The spec's "Testing" section maps to Task 15 plus the per-task manual-check steps. - No test runner: every task's verification step uses
npm run typecheck/npm run lint/manual checks instead of a test framework, perCLAUDE.md— no task assumes Jest/Vitest/pytest exist. - Type/name consistency check:
MICRO_LABEL_CLASS(from@/lib/typography),MonoText(from@/components/MonoText),PageContainer(from@/components/PageContainer),StatusChip'svariantprop, andStatusColor(from@/lib/status) are named identically everywhere they're imported/used across Tasks 3–14 — verified by re-reading each usage site against its defining task.