feat: scaffold expo mobile app
This commit is contained in:
41
apps/mobile/.gitignore
vendored
Normal file
41
apps/mobile/.gitignore
vendored
Normal 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
11
apps/mobile/App.tsx
Normal 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
32
apps/mobile/app.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
apps/mobile/assets/adaptive-icon.png
Normal file
BIN
apps/mobile/assets/adaptive-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
apps/mobile/assets/favicon.png
Normal file
BIN
apps/mobile/assets/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
apps/mobile/assets/icon.png
Normal file
BIN
apps/mobile/assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
apps/mobile/assets/splash-icon.png
Normal file
BIN
apps/mobile/assets/splash-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
8
apps/mobile/index.ts
Normal file
8
apps/mobile/index.ts
Normal 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);
|
||||
14
apps/mobile/metro.config.js
Normal file
14
apps/mobile/metro.config.js
Normal 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
29
apps/mobile/package.json
Normal 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
|
||||
}
|
||||
275
apps/mobile/src/MobileApp.tsx
Normal file
275
apps/mobile/src/MobileApp.tsx
Normal 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>
|
||||
}
|
||||
32
apps/mobile/src/NamedIcon.tsx
Normal file
32
apps/mobile/src/NamedIcon.tsx
Normal 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" />
|
||||
}
|
||||
17
apps/mobile/src/demoData.test.ts
Normal file
17
apps/mobile/src/demoData.test.ts
Normal 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
113
apps/mobile/src/demoData.ts
Normal 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
13
apps/mobile/src/styles.ts
Normal 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,
|
||||
}
|
||||
53
apps/mobile/src/styles/commonStyles.ts
Normal file
53
apps/mobile/src/styles/commonStyles.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
50
apps/mobile/src/styles/editorStyles.ts
Normal file
50
apps/mobile/src/styles/editorStyles.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
69
apps/mobile/src/styles/noteListStyles.ts
Normal file
69
apps/mobile/src/styles/noteListStyles.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
50
apps/mobile/src/styles/propertiesStyles.ts
Normal file
50
apps/mobile/src/styles/propertiesStyles.ts
Normal 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',
|
||||
},
|
||||
})
|
||||
46
apps/mobile/src/styles/sidebarStyles.ts
Normal file
46
apps/mobile/src/styles/sidebarStyles.ts
Normal 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
31
apps/mobile/src/theme.ts
Normal 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
10
apps/mobile/tsconfig.json
Normal 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"]
|
||||
}
|
||||
8
apps/mobile/vitest.config.ts
Normal file
8
apps/mobile/vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['src/**/*.{test,spec}.{ts,tsx}'],
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
4195
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user