feat: scaffold expo mobile app

This commit is contained in:
lucaronin
2026-05-04 00:35:24 +02:00
parent 30d4d55b4a
commit ef544006d8
28 changed files with 5113 additions and 26 deletions

41
apps/mobile/.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
# generated native folders
/ios
/android

11
apps/mobile/App.tsx Normal file
View File

@@ -0,0 +1,11 @@
import { StatusBar } from 'expo-status-bar'
import { MobileApp } from './src/MobileApp'
export default function App() {
return (
<>
<MobileApp />
<StatusBar style="auto" />
</>
)
}

32
apps/mobile/app.json Normal file
View File

@@ -0,0 +1,32 @@
{
"expo": {
"name": "Tolaria",
"slug": "tolaria-mobile",
"version": "0.1.0",
"orientation": "default",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"newArchEnabled": true,
"splash": {
"image": "./assets/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"ios": {
"bundleIdentifier": "com.tolaria.mobile.dev",
"supportsTablet": true
},
"android": {
"package": "com.tolaria.mobile.dev",
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
},
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false
},
"web": {
"favicon": "./assets/favicon.png"
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
apps/mobile/assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

8
apps/mobile/index.ts Normal file
View File

@@ -0,0 +1,8 @@
import { registerRootComponent } from 'expo';
import App from './App';
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
// It also ensures that whether you load the app in Expo Go or in a native build,
// the environment is set up appropriately
registerRootComponent(App);

View File

@@ -0,0 +1,14 @@
const path = require('node:path')
const { getDefaultConfig } = require('expo/metro-config')
const projectRoot = __dirname
const workspaceRoot = path.resolve(projectRoot, '../..')
const config = getDefaultConfig(projectRoot)
config.watchFolders = [workspaceRoot]
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, 'node_modules'),
path.resolve(workspaceRoot, 'node_modules'),
]
module.exports = config

29
apps/mobile/package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "@tolaria/mobile",
"version": "0.1.0",
"main": "index.ts",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"test": "vitest run --config vitest.config.ts",
"typecheck": "tsc --noEmit -p tsconfig.json",
"web": "expo start --web"
},
"dependencies": {
"@tolaria/markdown": "workspace:*",
"expo": "~55.0.19",
"expo-status-bar": "~55.0.5",
"phosphor-react-native": "^3.0.6",
"react": "19.2.0",
"react-native": "0.83.6",
"react-native-safe-area-context": "^5.6.2",
"react-native-svg": "^15.15.3"
},
"devDependencies": {
"@types/react": "~19.2.14",
"typescript": "~5.9.3",
"vitest": "^4.0.18"
},
"private": true
}

View File

@@ -0,0 +1,275 @@
import { useMemo, useState } from 'react'
import {
FlatList,
Pressable,
ScrollView,
Text,
useWindowDimensions,
View,
} from 'react-native'
import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context'
import {
CaretLeft,
DotsThreeVertical,
Info,
List,
MagnifyingGlass,
PencilSimple,
SlidersHorizontal,
} from 'phosphor-react-native'
import { MobileNote, notes, sidebarSections } from './demoData'
import { NamedIcon, type IconName } from './NamedIcon'
import { styles } from './styles'
import { colors } from './theme'
type CompactPanel = 'sidebar' | 'list' | 'note' | 'properties'
export function MobileApp() {
const { width } = useWindowDimensions()
const isTablet = width >= 820
const showsProperties = width >= 1120
const [selectedNoteId, setSelectedNoteId] = useState(notes[0].id)
const [compactPanel, setCompactPanel] = useState<CompactPanel>('list')
const selectedNote = useMemo(
() => notes.find((note) => note.id === selectedNoteId) ?? notes[0],
[selectedNoteId],
)
const selectNote = (note: MobileNote) => {
setSelectedNoteId(note.id)
setCompactPanel('note')
}
return (
<SafeAreaProvider>
<SafeAreaView style={styles.safeArea}>
{isTablet ? (
<View style={styles.tabletShell}>
<SidebarPanel />
<NoteListPanel selectedNoteId={selectedNoteId} onSelectNote={selectNote} />
<EditorPanel note={selectedNote} />
{showsProperties ? <PropertiesPanel note={selectedNote} /> : null}
</View>
) : (
<CompactShell
activePanel={compactPanel}
note={selectedNote}
selectedNoteId={selectedNoteId}
onPanelChange={setCompactPanel}
onSelectNote={selectNote}
/>
)}
</SafeAreaView>
</SafeAreaProvider>
)
}
function CompactShell({
activePanel,
note,
onPanelChange,
onSelectNote,
selectedNoteId,
}: {
activePanel: CompactPanel
note: MobileNote
onPanelChange: (panel: CompactPanel) => void
onSelectNote: (note: MobileNote) => void
selectedNoteId: string
}) {
if (activePanel === 'sidebar') {
return <SidebarPanel onClose={() => onPanelChange('list')} />
}
if (activePanel === 'note') {
return (
<EditorPanel
note={note}
onBack={() => onPanelChange('list')}
onOpenProperties={() => onPanelChange('properties')}
/>
)
}
if (activePanel === 'properties') {
return <PropertiesPanel note={note} onClose={() => onPanelChange('note')} />
}
return (
<NoteListPanel
selectedNoteId={selectedNoteId}
onOpenSidebar={() => onPanelChange('sidebar')}
onSelectNote={onSelectNote}
/>
)
}
function SidebarPanel({ onClose }: { onClose?: () => void }) {
return (
<View style={styles.sidebar}>
<Toolbar>
{onClose ? <IconButton icon={<CaretLeft size={24} color={colors.textSoft} />} onPress={onClose} /> : null}
<View style={styles.toolbarSpacer} />
<IconButton icon={<SlidersHorizontal size={22} color={colors.textSoft} />} />
</Toolbar>
<ScrollView contentContainerStyle={styles.sidebarContent}>
{sidebarSections.map((section) => (
<View key={section.title} style={styles.sidebarSection}>
<Text style={styles.sidebarSectionTitle}>{section.title}</Text>
{section.items.map((item) => (
<Pressable
key={item.id}
style={({ pressed }) => [
styles.sidebarItem,
item.id === 'inbox' ? styles.sidebarItemSelected : null,
pressed ? styles.pressed : null,
]}
>
<NamedIcon name={item.icon as IconName} size={20} color={item.id === 'inbox' ? colors.primary : colors.iconMuted} />
<Text style={styles.sidebarItemText}>{item.label}</Text>
{item.count > 0 ? <Text style={styles.sidebarCount}>{item.count}</Text> : null}
</Pressable>
))}
</View>
))}
</ScrollView>
</View>
)
}
function NoteListPanel({
onOpenSidebar,
onSelectNote,
selectedNoteId,
}: {
onOpenSidebar?: () => void
onSelectNote: (note: MobileNote) => void
selectedNoteId: string
}) {
return (
<View style={styles.noteList}>
<Toolbar>
{onOpenSidebar ? <IconButton icon={<List size={25} color={colors.textSoft} />} onPress={onOpenSidebar} /> : null}
<Text style={styles.listTitle}>Inbox</Text>
<View style={styles.toolbarSpacer} />
<IconButton icon={<MagnifyingGlass size={23} color={colors.textSoft} />} />
</Toolbar>
<FlatList
data={notes}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.noteListContent}
renderItem={({ item }) => (
<Pressable
onPress={() => onSelectNote(item)}
style={({ pressed }) => [
styles.noteRow,
item.id === selectedNoteId ? styles.noteRowSelected : null,
pressed ? styles.pressed : null,
]}
>
<View style={styles.noteRowHeader}>
<Text style={styles.noteTitle}>{item.title}</Text>
<NamedIcon name={item.icon as IconName} size={18} color={colors.primary} />
</View>
<Text numberOfLines={2} style={styles.noteSnippet}>{item.snippet}</Text>
<View style={styles.noteMetaRow}>
<Text style={styles.noteMeta}>{item.modified}</Text>
<Text style={styles.noteMeta}>Created {item.date}</Text>
</View>
<View style={styles.tagRow}>
{item.tags.slice(0, 2).map((tag) => <Tag key={tag} label={tag} />)}
</View>
</Pressable>
)}
/>
<Pressable style={styles.composeButton}>
<PencilSimple size={28} color="#ffffff" />
</Pressable>
</View>
)
}
function EditorPanel({
note,
onBack,
onOpenProperties,
}: {
note: MobileNote
onBack?: () => void
onOpenProperties?: () => void
}) {
const bodyLines = note.content.split('\n').filter((line) => line.trim() && !line.startsWith('---') && !line.includes(': '))
return (
<View style={styles.editor}>
<Toolbar>
{onBack ? <IconButton icon={<CaretLeft size={25} color={colors.textSoft} />} onPress={onBack} /> : null}
<View style={styles.toolbarSpacer} />
{onOpenProperties ? <IconButton icon={<Info size={23} color={colors.textSoft} />} onPress={onOpenProperties} /> : null}
<IconButton icon={<DotsThreeVertical size={23} color={colors.textSoft} />} />
</Toolbar>
<ScrollView contentContainerStyle={styles.editorContent}>
<View style={styles.breadcrumbRow}>
<Text style={styles.breadcrumbText}>{note.type}</Text>
<Text style={styles.breadcrumbDivider}>/</Text>
<Text style={styles.breadcrumbText}>{note.id}</Text>
</View>
<Text style={styles.editorTitle}>{note.title}</Text>
{bodyLines.slice(1).map((line) => (
<Text key={line} style={line.startsWith('-') ? styles.editorBullet : styles.editorParagraph}>
{line}
</Text>
))}
</ScrollView>
</View>
)
}
function PropertiesPanel({ note, onClose }: { note: MobileNote; onClose?: () => void }) {
return (
<View style={styles.properties}>
<Toolbar>
<Text style={styles.propertiesTitle}>Properties</Text>
<View style={styles.toolbarSpacer} />
{onClose ? <IconButton icon={<CaretLeft size={23} color={colors.textSoft} />} onPress={onClose} /> : null}
</Toolbar>
<ScrollView contentContainerStyle={styles.propertiesContent}>
<PropertyRow label="Type" value={note.type} />
<PropertyRow label="Date" value={note.date} />
<PropertyRow label="Words" value={String(note.words)} />
<PropertyRow label="Modified" value={note.modified} />
<Text style={styles.propertyGroupTitle}>Tags</Text>
<View style={styles.tagRow}>
{note.tags.map((tag) => <Tag key={tag} label={tag} />)}
</View>
<Text style={styles.propertyGroupTitle}>History</Text>
<Text style={styles.historyItem}>eb373865c - Updated 1 note</Text>
<Text style={styles.historyItem}>5e853fdfe - Updated 1 note</Text>
</ScrollView>
</View>
)
}
function Toolbar({ children }: { children: React.ReactNode }) {
return <View style={styles.toolbar}>{children}</View>
}
function IconButton({ icon, onPress }: { icon: React.ReactNode; onPress?: () => void }) {
return (
<Pressable onPress={onPress} style={({ pressed }) => [styles.iconButton, pressed ? styles.pressed : null]}>
{icon}
</Pressable>
)
}
function PropertyRow({ label, value }: { label: string; value: string }) {
return (
<View style={styles.propertyRow}>
<Text style={styles.propertyLabel}>{label}</Text>
<Text style={styles.propertyValue}>{value}</Text>
</View>
)
}
function Tag({ label }: { label: string }) {
return <Text style={styles.tag}>{label}</Text>
}

View File

@@ -0,0 +1,32 @@
import {
Archive,
Books,
Drop,
FileText,
Flag,
GitBranch,
PenNib,
Sun,
Tray,
Wrench,
} from 'phosphor-react-native'
export type IconName = 'archive' | 'books' | 'drop' | 'file-text' | 'flag' | 'git-branch' | 'pen-nib' | 'sun' | 'tray' | 'wrench'
const iconByName = {
archive: Archive,
books: Books,
drop: Drop,
'file-text': FileText,
flag: Flag,
'git-branch': GitBranch,
'pen-nib': PenNib,
sun: Sun,
tray: Tray,
wrench: Wrench,
} as const
export function NamedIcon({ color, name, size }: { color: string; name: IconName; size: number }) {
const Icon = iconByName[name]
return <Icon color={color} size={size} weight="regular" />
}

View File

@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest'
import { notes, sidebarSections } from './demoData'
describe('mobile demo data', () => {
it('derives note titles and snippets through shared markdown utilities', () => {
expect(notes[0].title).toBe('Workflow Orchestration Essay')
expect(notes[0].snippet).toContain('The current narrative / temptation')
expect(notes[0].words).toBeGreaterThan(20)
})
it('keeps the initial sidebar focused on inbox', () => {
expect(sidebarSections[0].items[0]).toMatchObject({
id: 'inbox',
label: 'Inbox',
})
})
})

113
apps/mobile/src/demoData.ts Normal file
View File

@@ -0,0 +1,113 @@
import { countWords, deriveDisplayTitleState, extractSnippet } from '@tolaria/markdown'
export interface MobileNote {
id: string
type: string
icon: string
date: string
modified: string
content: string
title: string
snippet: string
words: number
tags: string[]
}
const noteContent = [
{
id: 'workflow',
type: 'Essay',
icon: 'pen-nib',
date: 'May 13, 2026',
modified: '6h ago',
filename: 'workflow-orchestration-kestra.md',
tags: ['Design Inspiration', 'Tolaria MVP'],
content: [
'---',
'title: Workflow Orchestration Essay',
'type: Essay',
'---',
'',
'# Workflow Orchestration Essay',
'',
'- The current narrative / temptation: everything routed through an LLM.',
'- A real example (Tolaria + OpenClaw): OpenClaw does a lot for me in product development.',
'- The cost of AI everywhere: expensive, slow, and unpredictable.',
'- Where orchestration wins: observability, human-in-the-loop approvals, reliability.',
].join('\n'),
},
{
id: 'release',
type: 'Release Note',
icon: 'flag',
date: 'May 2, 2026',
modified: '12h ago',
filename: 'v2026-05-02.md',
tags: ['Release', 'Stable'],
content: [
'---',
'title: v2026-05-02',
'type: Release Note',
'---',
'',
'# v2026-05-02',
'',
'Another Tolaria release in the bag. This one is focused on performance, bug fixes, and lower-friction note workflows.',
].join('\n'),
},
{
id: 'migration',
type: 'Project',
icon: 'git-branch',
date: 'Apr 28, 2026',
modified: '1d ago',
filename: 'tolaria-obsidian-migration.md',
tags: ['Project', 'Resources'],
content: [
'---',
'title: Tolaria <> Obsidian migration proposal',
'type: Project',
'---',
'',
'# Tolaria <> Obsidian migration proposal',
'',
'Obsidian vaults are already close to Tolaria ideal substrate: local Markdown, portable attachments, and git-backed history.',
].join('\n'),
},
]
export const notes: MobileNote[] = noteContent.map((note) => {
const titleState = deriveDisplayTitleState({ content: note.content, filename: note.filename })
return {
...note,
title: titleState.title,
snippet: extractSnippet(note.content),
words: countWords(note.content),
}
})
export const sidebarSections = [
{
title: 'Library',
items: [
{ id: 'inbox', label: 'Inbox', count: 7, icon: 'tray' },
{ id: 'all', label: 'All Notes', count: 8846, icon: 'file-text' },
{ id: 'archive', label: 'Archive', count: 276, icon: 'archive' },
],
},
{
title: 'Favorites',
items: [
{ id: 'journal', label: 'Personal Journal', count: 0, icon: 'sun' },
{ id: 'mvp', label: 'Tolaria MVP', count: 0, icon: 'drop' },
],
},
{
title: 'Types',
items: [
{ id: 'projects', label: 'Projects', count: 6, icon: 'wrench' },
{ id: 'essays', label: 'Essays', count: 448, icon: 'pen-nib' },
{ id: 'resources', label: 'Resources', count: 840, icon: 'books' },
],
},
]

13
apps/mobile/src/styles.ts Normal file
View File

@@ -0,0 +1,13 @@
import { commonStyles } from './styles/commonStyles'
import { editorStyles } from './styles/editorStyles'
import { noteListStyles } from './styles/noteListStyles'
import { propertiesStyles } from './styles/propertiesStyles'
import { sidebarStyles } from './styles/sidebarStyles'
export const styles = {
...commonStyles,
...editorStyles,
...noteListStyles,
...propertiesStyles,
...sidebarStyles,
}

View File

@@ -0,0 +1,53 @@
import { StyleSheet } from 'react-native'
import { colors, radii, spacing } from '../theme'
export const commonStyles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: colors.appBackground,
},
tabletShell: {
flex: 1,
flexDirection: 'row',
backgroundColor: colors.canvas,
},
toolbar: {
minHeight: 58,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
borderBottomColor: colors.border,
borderBottomWidth: StyleSheet.hairlineWidth,
paddingHorizontal: spacing.md,
},
toolbarSpacer: {
flex: 1,
},
iconButton: {
width: 42,
height: 42,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 21,
backgroundColor: '#ffffff',
},
pressed: {
opacity: 0.65,
},
tagRow: {
marginTop: spacing.sm,
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.sm,
},
tag: {
overflow: 'hidden',
borderRadius: radii.sm,
backgroundColor: colors.chipGreen,
color: '#3f8a5c',
fontSize: 12,
fontWeight: '600',
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
},
})

View File

@@ -0,0 +1,50 @@
import { StyleSheet } from 'react-native'
import { colors, spacing } from '../theme'
export const editorStyles = StyleSheet.create({
editor: {
flex: 2,
minWidth: 0,
backgroundColor: colors.canvas,
},
editorContent: {
width: '100%',
maxWidth: 760,
alignSelf: 'center',
paddingHorizontal: spacing.xl,
paddingBottom: 96,
},
breadcrumbRow: {
marginBottom: spacing.xl,
flexDirection: 'row',
gap: spacing.sm,
},
breadcrumbText: {
color: colors.mutedText,
fontSize: 14,
fontWeight: '600',
},
breadcrumbDivider: {
color: colors.border,
fontSize: 14,
},
editorTitle: {
marginBottom: spacing.xl,
color: colors.text,
fontSize: 34,
fontWeight: '800',
lineHeight: 42,
},
editorParagraph: {
marginBottom: spacing.lg,
color: colors.text,
fontSize: 18,
lineHeight: 28,
},
editorBullet: {
marginBottom: spacing.md,
color: colors.text,
fontSize: 18,
lineHeight: 28,
},
})

View File

@@ -0,0 +1,69 @@
import { StyleSheet } from 'react-native'
import { colors, spacing } from '../theme'
export const noteListStyles = StyleSheet.create({
noteList: {
width: 360,
flex: 1,
maxWidth: 390,
borderRightColor: colors.border,
borderRightWidth: StyleSheet.hairlineWidth,
backgroundColor: colors.canvas,
},
noteListContent: {
paddingBottom: 104,
},
listTitle: {
color: colors.text,
fontSize: 18,
fontWeight: '700',
},
noteRow: {
borderBottomColor: colors.border,
borderBottomWidth: StyleSheet.hairlineWidth,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
},
noteRowSelected: {
backgroundColor: colors.selected,
borderLeftColor: '#59b57c',
borderLeftWidth: 3,
},
noteRowHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
},
noteTitle: {
flex: 1,
color: colors.text,
fontSize: 16,
fontWeight: '700',
},
noteSnippet: {
marginTop: spacing.sm,
color: colors.textSoft,
fontSize: 15,
lineHeight: 21,
},
noteMetaRow: {
marginTop: spacing.md,
flexDirection: 'row',
justifyContent: 'space-between',
},
noteMeta: {
color: colors.mutedText,
fontSize: 12,
},
composeButton: {
position: 'absolute',
right: spacing.xl,
bottom: spacing.xl,
width: 64,
height: 64,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 32,
backgroundColor: colors.primary,
},
})

View File

@@ -0,0 +1,50 @@
import { StyleSheet } from 'react-native'
import { colors, spacing } from '../theme'
export const propertiesStyles = StyleSheet.create({
properties: {
width: 300,
borderLeftColor: colors.border,
borderLeftWidth: StyleSheet.hairlineWidth,
backgroundColor: colors.sidebar,
},
propertiesContent: {
padding: spacing.lg,
},
propertiesTitle: {
color: colors.text,
fontSize: 17,
fontWeight: '700',
},
propertyRow: {
minHeight: 44,
flexDirection: 'row',
alignItems: 'center',
borderBottomColor: colors.border,
borderBottomWidth: StyleSheet.hairlineWidth,
},
propertyLabel: {
width: 92,
color: colors.mutedText,
fontSize: 14,
},
propertyValue: {
flex: 1,
color: colors.text,
fontSize: 15,
fontWeight: '600',
textAlign: 'right',
},
propertyGroupTitle: {
marginTop: spacing.xl,
color: colors.mutedText,
fontSize: 13,
fontWeight: '700',
},
historyItem: {
marginTop: spacing.md,
color: colors.primary,
fontSize: 13,
fontWeight: '600',
},
})

View File

@@ -0,0 +1,46 @@
import { StyleSheet } from 'react-native'
import { colors, radii, spacing } from '../theme'
export const sidebarStyles = StyleSheet.create({
sidebar: {
width: 272,
borderRightColor: colors.border,
borderRightWidth: StyleSheet.hairlineWidth,
backgroundColor: colors.sidebar,
},
sidebarContent: {
padding: spacing.md,
paddingTop: spacing.sm,
},
sidebarSection: {
marginBottom: spacing.lg,
},
sidebarSectionTitle: {
marginBottom: spacing.sm,
color: colors.mutedText,
fontSize: 12,
fontWeight: '700',
textTransform: 'uppercase',
},
sidebarItem: {
minHeight: 40,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
borderRadius: radii.sm,
paddingHorizontal: spacing.md,
},
sidebarItemSelected: {
backgroundColor: colors.primarySoft,
},
sidebarItemText: {
flex: 1,
color: colors.text,
fontSize: 16,
fontWeight: '600',
},
sidebarCount: {
color: colors.mutedText,
fontSize: 13,
},
})

31
apps/mobile/src/theme.ts Normal file
View File

@@ -0,0 +1,31 @@
export const colors = {
appBackground: '#f6f5f2',
border: '#dedbd4',
canvas: '#ffffff',
chipBlue: '#e7edff',
chipGreen: '#dff4e8',
chipRed: '#ffe5e2',
iconMuted: '#8f8d87',
mutedText: '#78746d',
primary: '#3367f6',
primarySoft: '#e8eeff',
selected: '#edf5ef',
sidebar: '#fbfaf7',
text: '#292825',
textSoft: '#4e4b46',
} as const
export const spacing = {
xs: 4,
sm: 8,
md: 12,
lg: 16,
xl: 24,
xxl: 32,
} as const
export const radii = {
sm: 6,
md: 8,
pill: 999,
} as const

10
apps/mobile/tsconfig.json Normal file
View File

@@ -0,0 +1,10 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["App.tsx", "index.ts", "src/**/*.ts", "src/**/*.tsx"]
}

View File

@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'node',
include: ['src/**/*.{test,spec}.{ts,tsx}'],
},
})

View File

@@ -7,8 +7,8 @@ This file is the resumable working log for Tolaria mobile. The strategy and road
## Current State
- Branch: `codex/mobile`
- Active phase: Phase 1 - Shared Foundation
- Active slice: Extract shared Markdown utilities for desktop/mobile reuse
- Active phase: Phase 2 - Mobile Shell
- Active slice: Scaffold iPad-first Expo shell with fixture data
- Push policy: commit locally; do not push unless explicitly requested
- Validation target: iPad/iOS simulator first
@@ -28,16 +28,21 @@ This file is the resumable working log for Tolaria mobile. The strategy and road
- Updated the pre-commit branch guard to allow local commits on `codex/mobile` for this isolated mobile worktree.
- Split `src/utils/wikilinks.ts` into shared `@tolaria/markdown` modules for frontmatter, wikilink block transforms, outgoing links, backlink context, snippets, and word counts.
- Moved note-title derivation helpers into `@tolaria/markdown`, leaving the existing desktop import path as a compatibility export.
- Added `apps/mobile` as an Expo React Native workspace package.
- Added root mobile scripts: `mobile:start`, `mobile:ios`, `mobile:test`, and `mobile:typecheck`.
- Configured Expo for a universal light-mode app with a development bundle identifier (`com.tolaria.mobile.dev`) and iPad support enabled.
- Added the first iPad-first/phone-ready shell using fixture notes, shared Markdown title/snippet helpers, Phosphor React Native icons, and responsive panels for sidebar, note list, editor, and properties.
- Split mobile styles into small panel-specific StyleSheet modules so new mobile code starts at CodeScene `10.0`.
- Pinned Expo runtime dependencies to the versions expected by Expo 55 after the initial generated versions failed iOS bundle export (`react-native@0.83.6`, `react@19.2.0`, matching safe-area/svg versions).
## Next Action
Continue Phase 1 with the next low-risk shared extraction:
Continue Phase 2 with the next mobile shell slice:
1. Identify pure markdown/frontmatter/wikilink utilities suitable for `packages/markdown`.
2. Capture CodeScene scores before editing any existing scorable files.
3. Add tests or preserve existing tests around extracted behavior.
4. Create the package with CodeScene `10.0` and zero scanner issues as the target.
5. Prefer `noteTitle` next only after splitting or moving the small frontmatter/wikilink dependencies it needs.
1. Resolve the local CoreSimulator hang so `pnpm mobile:ios` can launch the app in an iPad simulator.
2. Add a navigation-state test around the compact panel flow before introducing gestures.
3. Add native gesture support for phone/tablet panel transitions.
4. Start the storage/auth abstraction skeleton only after the shell has a reliable simulator loop.
## Verification Log
@@ -64,6 +69,18 @@ Continue Phase 1 with the next low-risk shared extraction:
- `pnpm --filter @tolaria/markdown test` passed after note-title extraction: 56 tests.
- `pnpm test -- src/utils/noteTitle.test.ts` passed: 14 desktop tests.
- `pnpm --filter @tolaria/markdown typecheck` passed after note-title extraction.
- `pnpm --filter @tolaria/mobile typecheck` passed.
- `pnpm --filter @tolaria/mobile test` passed: 1 file / 2 tests.
- `pnpm --filter @tolaria/mobile exec expo install --check` passed after pinning Expo-compatible dependency versions.
- `pnpm --filter @tolaria/mobile exec expo config --type public` passed and shows iOS bundle identifier `com.tolaria.mobile.dev`, Android package `com.tolaria.mobile.dev`, and `supportsTablet: true`.
- `pnpm --filter @tolaria/mobile exec expo export --platform ios --output-dir /tmp/tolaria-mobile-export` passed and produced the iOS bundle.
- `pnpm lint` passed with one pre-existing warning in `src/components/tolariaBlockNoteSideMenu.tsx`.
- `npx tsc --noEmit` passed.
- `pnpm test` passed after the mobile scaffold: 309 desktop test files / 3639 desktop tests plus 56 shared package tests.
- CodeScene mobile shell scores: `apps/mobile/App.tsx`, `apps/mobile/src/MobileApp.tsx`, `apps/mobile/src/NamedIcon.tsx`, `apps/mobile/src/demoData.ts`, `apps/mobile/src/demoData.test.ts`, and all scorable style modules scored `10`; tiny config/export/theme files returned no scorable code.
- CodeScene pre-commit safeguard passed for the current change set.
- Codacy MCP can read repository analysis and security items for `refactoringhq/tolaria`; local Codacy CLI analysis is currently blocked because the Codacy CLI binary is not installed in this environment.
- iOS simulator validation is blocked locally: Xcode is installed (`Xcode 26.3`), but `xcrun simctl list ...` hangs indefinitely even after opening `/Applications/Xcode.app/Contents/Developer/Applications/Simulator.app`.
## Risks / Watch Items

View File

@@ -260,6 +260,8 @@ tolaria/
Move desktop into `apps/desktop` only when the shared package structure is stable enough that the relocation will not distract from feature work.
Use pnpm workspaces for all packages and apps. React Native / Expo dependency resolution is sensitive to package hoisting, so the initial mobile workspace uses pnpm's hoisted node linker. Treat that as part of the mobile runtime setup, not as a desktop architecture decision: if it causes desktop dependency churn later, isolate and document the smallest workspace-level adjustment instead of rewriting the package strategy.
### Package Boundaries
#### `packages/core`

View File

@@ -12,6 +12,10 @@
"l10n:translate": "lara-cli translate",
"l10n:translate:force": "lara-cli translate --force",
"l10n:validate": "node scripts/validate-locales.mjs",
"mobile:ios": "pnpm --filter @tolaria/mobile ios",
"mobile:start": "pnpm --filter @tolaria/mobile start",
"mobile:test": "pnpm --filter @tolaria/mobile test",
"mobile:typecheck": "pnpm --filter @tolaria/mobile typecheck",
"preview": "vite preview",
"tauri": "tauri",
"test": "node scripts/run-tests.mjs",

4195
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,13 @@
packages:
- apps/*
- packages/*
- mcp-server
ignoredBuiltDependencies:
- esbuild
nodeLinker: hoisted
patchedDependencies:
'@blocknote/core@0.46.2': patches/@blocknote__core@0.46.2.patch
'@blocknote/react@0.46.2': patches/@blocknote__react@0.46.2.patch