Skip to content
Merged

K #40

Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 8 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Screen / Route
- The App Store is the single owner of client state and client workflows; screens and routes read it through selectors and call its actions.
- The Feature Query Layer (TanStack Query) is the single owner of server state: cache, mutations, and SSE updates.
- Store actions own client workflows; query hooks, options, and mutations own server operations.
- Screens never call the API service or MMKV directly; presentational UI receives values and callbacks through props and never imports the Store, query dependencies, services, or MMKV.
- Screens never call the API service or MMKV directly; presentational UI receives values and callbacks through props and never imports the Store, query layer, services, or MMKV.
- Combine Store and Query state in the owning feature hook, not in every Route.
- Never mirror state across the boundary: no Query -> Store and no Store -> Query copies.
- Validate server responses, SSE payloads, and persisted storage once at their ingress boundary with the owning Valibot schema. Never keep synchronized copies of the same data in UI, networking, and persistence.
Expand All @@ -39,6 +39,13 @@ Use Tamagui as the default UI and styling layer. Before writing Tamagui code, ru
- Keep `tamagui` and `@tamagui/*` versions aligned. After UI or config changes run `npm run tamagui:check`, `npm run tamagui:generate`, and `npx tsc --noEmit`.
- Put user-visible prose in i18n resources, not hardcoded strings.

Closure has two width layouts. Small is the default; large starts at 768px.

- Use small styles as component defaults and `$large` for structural overrides. Do not introduce additional width breakpoints.
- Use flex, wrap, or grid for continuous resizing instead of adding breakpoints.
- Call `useMedia().large` only when JavaScript values or different render trees are required. Read it at the lowest component that consumes it; never pass layout size through props.
- Do not create layout schemas, layout providers, or wrapper hooks around Tamagui media APIs.

# Valibot Schema-First Domain Models

Valibot schemas in `src/schemas/<domain>/` are the source of truth for domain data and cross-boundary input. The full contract lives in `src/schemas/README.md`.
Expand Down
422 changes: 211 additions & 211 deletions package-lock.json

Large diffs are not rendered by default.

20 changes: 10 additions & 10 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,33 +5,33 @@
"dependencies": {
"@expo-google-fonts/geist-mono": "^0.4.3",
"@expo-google-fonts/oxanium": "^0.4.2",
"@expo/ui": "~57.0.13",
"@expo/ui": "~57.0.14",
"@shopify/flash-list": "2.0.2",
"@shopify/react-native-skia": "2.6.2",
"@tamagui/config": "^2.5.1",
"@tamagui/portal": "^2.5.1",
"@tanstack/react-query": "^5.101.4",
"expo": "~57.0.16",
"expo": "~57.0.17",
"expo-blur": "~57.0.2",
"expo-constants": "~57.0.8",
"expo-dev-client": "~57.0.15",
"expo-dev-client": "~57.0.16",
"expo-device": "~57.0.1",
"expo-font": "~57.0.1",
"expo-image": "~57.0.3",
"expo-linking": "~57.0.7",
"expo-linking": "~57.0.8",
"expo-localization": "~57.0.1",
"expo-router": "~57.0.16",
"expo-router": "~57.0.17",
"expo-splash-screen": "~57.0.8",
"expo-status-bar": "~57.0.1",
"expo-symbols": "~57.0.2",
"expo-system-ui": "~57.0.2",
"expo-system-ui": "~57.0.3",
"expo-web-browser": "~57.0.2",
"i18next": "^26.3.6",
"lucide-react-native": "^1.26.0",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-i18next": "^17.0.11",
"react-native": "0.86.2",
"react-native": "0.86.3",
"react-native-gesture-handler": "~2.32.0",
"react-native-mmkv": "4.3.2",
"react-native-nitro-modules": "0.36.5",
Expand All @@ -48,18 +48,18 @@
"zustand": "^5.0.14"
},
"devDependencies": {
"@react-native/jest-preset": "0.86.2",
"@react-native/jest-preset": "^0.86.3",
"@tamagui/cli": "^2.5.1",
"@tamagui/metro-plugin": "^2.5.1",
"@testing-library/react-native": "^14.0.1",
"@types/jest": "29.5.14",
"@types/node": "~26.1.1",
"@types/react": "~19.2.2",
"eslint": "^9.0.0",
"eslint-config-expo": "~57.0.1",
"eslint-config-expo": "~57.0.2",
"expo-doctor": "^1.20.1",
"jest": "~29.7.0",
"jest-expo": "~57.0.4",
"jest-expo": "~57.0.5",
"sharp": "^0.35.3",
"typescript": "~6.0.3",
"typescript-eslint": "^8.65.0"
Expand Down
12 changes: 6 additions & 6 deletions scripts/generate-dashboard-assets.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import {
AVATAR_FILTER_SCANLINE_OPACITY,
AVATAR_FILTER_WASH_STOPS,
} from '../src/components/ui/avatar-filter-config.ts';
import { ITEM_ARTWORK_SIZE } from '../src/components/ui/item-artwork-config.ts';
import {
OPERATOR_PORTRAIT_FILTER,
OPERATOR_PORTRAIT_GEOMETRY,
Expand Down Expand Up @@ -78,9 +77,10 @@ const ICONS = [
...Array.from({ length: 6 }, (_, index) => ({ category: 'potential', name: `potential_${index}` })),
];

const INVENTORY_FILTERS = Object.entries(ITEM_ARTWORK_SIZE).map(
([layoutSize, size]) => ({ layoutSize, size }),
);
const INVENTORY_FILTERS = [
{ name: 'small', size: 48 },
{ name: 'large', size: 104 },
];

function parseColor(value) {
const hexMatch = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i.exec(value);
Expand Down Expand Up @@ -505,13 +505,13 @@ async function generateCellBottomTransition(outputRoot) {
return outputPath;
}

async function generateInventoryFilter(outputRoot, { layoutSize, size }) {
async function generateInventoryFilter(outputRoot, { name, size }) {
const outputPath = path.join(
outputRoot,
'assets',
'images',
'inventory',
`grid-filter-${layoutSize}.webp`,
`grid-filter-${name}.webp`,
);
const data = Buffer.alloc(size * size * 4);

Expand Down
7 changes: 3 additions & 4 deletions src/app/(app)/dashboard/_layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Tabs as DashboardTabs } from 'expo-router/tabs';
import { useEffect } from 'react';
import { Spinner, YStack } from 'tamagui';
import { Spinner, YStack, useMedia } from 'tamagui';

import { MonoText } from '@/components';
import {
Expand All @@ -12,7 +12,6 @@ import {
DashboardSmallScreenTabBar,
dashboardPagesList,
} from '@/features/navigation';
import { useLayoutSize } from '@/providers/layout-size-provider';

function DashboardState({ label }: { label: string }) {
return (
Expand All @@ -24,7 +23,7 @@ function DashboardState({ label }: { label: string }) {
}

function DashboardContent() {
const layoutSize = useLayoutSize();
const { large } = useMedia();
const {
gameAccountsQuery,
selectedGameAccount,
Expand Down Expand Up @@ -55,7 +54,7 @@ function DashboardContent() {
lazy: true,
sceneStyle: { backgroundColor: 'transparent' },
}}
tabBar={layoutSize === 'small'
tabBar={!large
? (props) => (
<DashboardSmallScreenTabBar
{...props}
Expand Down
6 changes: 3 additions & 3 deletions src/app/(app)/settings/(tabs)/_layout.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { TopTabs as SettingsTopTabs } from 'expo-router/js-top-tabs';
import { useMedia } from 'tamagui';

import {
SettingsFrame,
SettingsTabBar,
settingsPagesList,
} from '@/features/navigation';
import { useLayoutSize } from '@/providers/layout-size-provider';

export default function SettingsTabsLayout() {
const layoutSize = useLayoutSize();
const { large } = useMedia();

return (
<SettingsFrame>
Expand All @@ -20,7 +20,7 @@ export default function SettingsTabsLayout() {
sceneStyle: { backgroundColor: 'transparent' },
swipeEnabled: true,
}}
tabBar={layoutSize === 'small' ? SettingsTabBar : () => null}
tabBar={!large ? SettingsTabBar : () => null}
>
{settingsPagesList.map((page) => (
<SettingsTopTabs.Screen key={page.id} name={page.id} />
Expand Down
6 changes: 3 additions & 3 deletions src/components/layout/section-page-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,16 @@ export function SectionPageHeader({
title: string;
}) {
return (
<YStack gap="$4" py="$2" $md={{ py: '$4' }}>
<YStack gap="$4" py="$2" $large={{ py: '$4' }}>
<XStack items="center" justify="space-between" gap="$3">
<XStack items="center" gap="$2">
<YStack width={7} height={7} rounded="$10" bg="$appAccent" />
<MonoText size="$1" color="$appAccent">{eyebrow}</MonoText>
</XStack>
<MonoText size="$1">{code}</MonoText>
</XStack>
<YStack gap="$3" $lg={{ flexDirection: 'row', items: 'flex-end', justify: 'space-between' }}>
<TerminalText size="$9" lineHeight="$9" fontWeight="800" letterSpacing={-1.5} textTransform="uppercase" maxW={760} $md={{ size: '$10', lineHeight: '$10' }}>
<YStack gap="$3" $large={{ flexDirection: 'row', items: 'flex-end', justify: 'space-between' }}>
<TerminalText size="$9" lineHeight="$9" fontWeight="800" letterSpacing={-1.5} textTransform="uppercase" maxW={760} $large={{ size: '$10', lineHeight: '$10' }}>
{title}
</TerminalText>
<YStack gap="$2" maxW={360} pb="$1">
Expand Down
24 changes: 23 additions & 1 deletion src/components/ui/frame/frame.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { render } from '@testing-library/react-native';
import { Text } from 'react-native';
import { StyleSheet, Text } from 'react-native';
import { TamaguiProvider } from 'tamagui';

import { tamaguiConfig } from '../../../../tamagui.config';
Expand All @@ -16,6 +16,28 @@ function FrameFixture({ selected }: { selected?: boolean }) {
}

describe('Frame', () => {
it('uses the shared translucent surface by default', async () => {
const screen = await render(<FrameFixture />);

expect(StyleSheet.flatten(screen.getByTestId('frame').props.style)).toEqual(
expect.objectContaining({ backgroundColor: 'rgba(29, 32, 34, 0.40)' }),
);
});

it('lets an explicit tone override the shared translucent surface', async () => {
const screen = await render(
<TamaguiProvider config={tamaguiConfig} defaultTheme="dark">
<Frame testID="warning-frame" tone="warning">
<Text>Content</Text>
</Frame>
</TamaguiProvider>,
);

expect(StyleSheet.flatten(screen.getByTestId('warning-frame').props.style)).toEqual(
expect.objectContaining({ backgroundColor: 'rgba(255, 157, 54, 0.05)' }),
);
});

it('renders four corner brackets only in the selected state', async () => {
const screen = await render(<FrameFixture selected />);

Expand Down
2 changes: 1 addition & 1 deletion src/components/ui/frame/frame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ const FrameRoot = styled(YStack, {
name: 'Frame',
position: 'relative',
overflow: 'hidden',
bg: '$appSurface',
bg: '$appSurfaceRaisedTranslucent',
borderWidth: 1,
borderColor: '$appBorder',
rounded: '$0',
Expand Down
6 changes: 0 additions & 6 deletions src/components/ui/item-artwork-config.ts

This file was deleted.

18 changes: 11 additions & 7 deletions src/components/ui/item-artwork.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { render } from '@testing-library/react-native';
import { setMediaState } from '@tamagui/web';
import { TamaguiProvider } from 'tamagui';

import itemArtworkFilterLarge from '@/assets/images/inventory/grid-filter-large.webp';
Expand All @@ -18,12 +19,12 @@ jest.mock('expo-image', () => {
};
});

async function renderArtwork(layoutSize: 'small' | 'large', source: string | number) {
async function renderArtwork(large: boolean, source: string | number) {
setMediaState({ large });
return await render(
<TamaguiProvider config={tamaguiConfig} defaultTheme="dark">
<ItemArtwork
accessibilityLabel="Test item"
layoutSize={layoutSize}
recyclingKey="test-item"
source={source}
testID="test-item-artwork"
Expand All @@ -33,11 +34,14 @@ async function renderArtwork(layoutSize: 'small' | 'large', source: string | num
}

describe('ItemArtwork', () => {
afterEach(() => {
setMediaState({ large: false });
});
it.each([
{ layoutSize: 'small' as const, size: 48, filter: itemArtworkFilterSmall },
{ layoutSize: 'large' as const, size: 104, filter: itemArtworkFilterLarge },
])('renders the $layoutSize artwork geometry and filter', async ({ filter, layoutSize, size }) => {
const view = await renderArtwork(layoutSize, 'https://example.test/item.webp');
{ large: false, size: 48, filter: itemArtworkFilterSmall },
{ large: true, size: 104, filter: itemArtworkFilterLarge },
])('renders the responsive artwork geometry and filter', async ({ filter, large, size }) => {
const view = await renderArtwork(large, 'https://example.test/item.webp');

expect(view.getByTestId('test-item-artwork')).toHaveStyle({
borderBottomLeftRadius: 999,
Expand Down Expand Up @@ -66,7 +70,7 @@ describe('ItemArtwork', () => {
});

it('passes local asset module sources through the same image path', async () => {
const view = await renderArtwork('small', 42);
const view = await renderArtwork(false, 42);

expect(view.getByTestId('test-item-artwork-image').props.src).toBe(42);
});
Expand Down
36 changes: 12 additions & 24 deletions src/components/ui/item-artwork.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,12 @@
import { Image, type ImageProps } from 'expo-image';
import { memo } from 'react';
import { StyleSheet } from 'react-native';
import { XStack, styled } from 'tamagui';
import { XStack, styled, useMedia } from 'tamagui';

import itemArtworkFilterLarge from '@/assets/images/inventory/grid-filter-large.webp';
import itemArtworkFilterSmall from '@/assets/images/inventory/grid-filter-small.webp';
import type { LayoutSize } from '@/schemas/layout-size';
import { ITEM_ARTWORK_SIZE } from './item-artwork-config';

const ITEM_ARTWORK_FILTER_IMAGES = {
small: itemArtworkFilterSmall,
large: itemArtworkFilterLarge,
} as const satisfies Record<LayoutSize, number>;
export const ITEM_ARTWORK_SIZE = 48;
export const ITEM_ARTWORK_LARGE_SIZE = 104;

const ItemArtworkFrame = styled(XStack, {
name: 'ItemArtwork',
Expand All @@ -21,40 +16,33 @@ const ItemArtworkFrame = styled(XStack, {
justify: 'center',
overflow: 'hidden',
rounded: 999,
variants: {
layoutSize: {
small: {
width: ITEM_ARTWORK_SIZE.small,
height: ITEM_ARTWORK_SIZE.small,
},
large: {
width: ITEM_ARTWORK_SIZE.large,
height: ITEM_ARTWORK_SIZE.large,
},
},
} as const,
width: ITEM_ARTWORK_SIZE,
height: ITEM_ARTWORK_SIZE,
$large: {
width: ITEM_ARTWORK_LARGE_SIZE,
height: ITEM_ARTWORK_LARGE_SIZE,
},
});

export type ItemArtworkProps = {
accessibilityLabel: string;
layoutSize: LayoutSize;
recyclingKey: string;
source: Exclude<ImageProps['source'], undefined>;
testID?: string;
};

export const ItemArtwork = memo(function ItemArtwork({
accessibilityLabel,
layoutSize,
recyclingKey,
source,
testID,
}: ItemArtworkProps) {
const { large } = useMedia();
const imageTestID = testID ? `${testID}-image` : undefined;
const filterTestID = testID ? `${testID}-filter` : undefined;

return (
<ItemArtworkFrame testID={testID} layoutSize={layoutSize}>
<ItemArtworkFrame testID={testID}>
<Image
testID={imageTestID}
source={source}
Expand All @@ -66,7 +54,7 @@ export const ItemArtwork = memo(function ItemArtwork({
/>
<Image
testID={filterTestID}
source={ITEM_ARTWORK_FILTER_IMAGES[layoutSize]}
source={large ? itemArtworkFilterLarge : itemArtworkFilterSmall}
cachePolicy="memory"
contentFit="fill"
style={StyleSheet.absoluteFill}
Expand Down
2 changes: 1 addition & 1 deletion src/components/ui/terminal/terminal-marquee.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ export const TerminalMarquee = memo(function TerminalMarquee({ items }: Terminal
borderColor="$appBorder"
bg="$appSurfaceRaised"
overflow="hidden"
$md={{ mt: '$0' }}
$large={{ mt: '$0' }}
>
<Animated.View style={[{ flexDirection: 'row', alignSelf: 'flex-start' }, marqueeStyle]}>
<XStack onLayout={handleContentLayout} shrink={0}>
Expand Down
Loading