geeknative 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/LICENSE +202 -0
  2. package/bin/geeknative.js +12 -0
  3. package/package.json +31 -4
  4. package/src/cli.js +61 -0
  5. package/src/codegen/app.js +97 -0
  6. package/src/codegen/icons.js +87 -0
  7. package/src/codegen/index.js +44 -0
  8. package/src/codegen/strings.js +308 -0
  9. package/src/codegen/tokens.js +449 -0
  10. package/src/codegen/util.js +122 -0
  11. package/src/commands/check.js +143 -0
  12. package/src/commands/create.js +78 -0
  13. package/src/commands/doctor.js +108 -0
  14. package/src/commands/gen.js +44 -0
  15. package/src/commands/parity.js +134 -0
  16. package/src/commands/run.js +91 -0
  17. package/src/lib/exec.js +77 -0
  18. package/src/lib/fsutil.js +92 -0
  19. package/src/lib/log.js +37 -0
  20. package/src/lib/managed.js +80 -0
  21. package/src/lib/project.js +52 -0
  22. package/src/lib/toolchains.js +126 -0
  23. package/template/.claude/agents/android-engineer.md +15 -0
  24. package/template/.claude/agents/ios-engineer.md +15 -0
  25. package/template/.claude/agents/web-engineer.md +15 -0
  26. package/template/.claude/settings.json +65 -0
  27. package/template/.geeknative/framework.mk +41 -0
  28. package/template/.geeknative/hooks/after-edit.mjs +28 -0
  29. package/template/.geeknative/hooks/parity-guard.mjs +49 -0
  30. package/template/.geeknative/hooks/subagent-context.mjs +24 -0
  31. package/template/.geeknative/playbooks/bug.md +7 -0
  32. package/template/.geeknative/playbooks/design.md +10 -0
  33. package/template/.geeknative/playbooks/engineering.md +9 -0
  34. package/template/.geeknative/playbooks/feature.md +11 -0
  35. package/template/.geeknative/playbooks/platform-specific.md +8 -0
  36. package/template/.geeknative/playbooks/product.md +12 -0
  37. package/template/.geeknative/templates/feature.md +49 -0
  38. package/template/.gemini/settings.json +5 -0
  39. package/template/.github/dependabot.yml +18 -0
  40. package/template/AGENTS.md +97 -0
  41. package/template/CLAUDE.md +9 -0
  42. package/template/DESIGN.md +65 -0
  43. package/template/Makefile +6 -0
  44. package/template/PRODUCT.md +40 -0
  45. package/template/README.md +28 -0
  46. package/template/_gitignore +33 -0
  47. package/template/apps/android/AGENTS.md +66 -0
  48. package/template/apps/android/CLAUDE.md +1 -0
  49. package/template/apps/android/app/build.gradle.kts +68 -0
  50. package/template/apps/android/app/identity.properties +4 -0
  51. package/template/apps/android/app/src/main/AndroidManifest.xml +24 -0
  52. package/template/apps/android/app/src/main/kotlin/app/App.kt +15 -0
  53. package/template/apps/android/app/src/main/kotlin/app/MainActivity.kt +49 -0
  54. package/template/apps/android/app/src/main/kotlin/app/core/AppContainer.kt +27 -0
  55. package/template/apps/android/app/src/main/kotlin/app/core/designsystem/AppCard.kt +30 -0
  56. package/template/apps/android/app/src/main/kotlin/app/core/designsystem/AppTheme.kt +35 -0
  57. package/template/apps/android/app/src/main/kotlin/app/core/navigation/AppNavigation.kt +40 -0
  58. package/template/apps/android/app/src/main/kotlin/app/core/navigation/Route.kt +13 -0
  59. package/template/apps/android/app/src/main/kotlin/app/features/home/HomeScreen.kt +67 -0
  60. package/template/apps/android/app/src/main/kotlin/app/features/settings/Appearance.kt +22 -0
  61. package/template/apps/android/app/src/main/kotlin/app/features/settings/SettingsRepository.kt +40 -0
  62. package/template/apps/android/app/src/main/kotlin/app/features/settings/SettingsScreen.kt +113 -0
  63. package/template/apps/android/app/src/main/kotlin/app/features/settings/SettingsViewModel.kt +28 -0
  64. package/template/apps/android/app/src/main/kotlin/app/generated/AppIdentity.kt +10 -0
  65. package/template/apps/android/app/src/main/kotlin/app/generated/Icons.kt +15 -0
  66. package/template/apps/android/app/src/main/kotlin/app/generated/Tokens.kt +144 -0
  67. package/template/apps/android/app/src/main/res/drawable/ic_launcher_foreground.xml +17 -0
  68. package/template/apps/android/app/src/main/res/mipmap/ic_launcher.xml +10 -0
  69. package/template/apps/android/app/src/main/res/mipmap/ic_launcher_round.xml +10 -0
  70. package/template/apps/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +6 -0
  71. package/template/apps/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +6 -0
  72. package/template/apps/android/app/src/main/res/values/generated_colors.xml +15 -0
  73. package/template/apps/android/app/src/main/res/values/generated_strings.xml +16 -0
  74. package/template/apps/android/app/src/main/res/values/ic_launcher_background.xml +4 -0
  75. package/template/apps/android/app/src/main/res/values/themes.xml +7 -0
  76. package/template/apps/android/app/src/main/res/values-night/generated_colors.xml +15 -0
  77. package/template/apps/android/app/src/main/res/values-night/themes.xml +6 -0
  78. package/template/apps/android/app/src/test/kotlin/app/features/settings/SettingsTest.kt +90 -0
  79. package/template/apps/android/build.gradle.kts +5 -0
  80. package/template/apps/android/gradle/gradle-daemon-jvm.properties +12 -0
  81. package/template/apps/android/gradle/libs.versions.toml +37 -0
  82. package/template/apps/android/gradle/wrapper/gradle-wrapper.jar +0 -0
  83. package/template/apps/android/gradle/wrapper/gradle-wrapper.properties +9 -0
  84. package/template/apps/android/gradle.properties +7 -0
  85. package/template/apps/android/gradlew +248 -0
  86. package/template/apps/android/gradlew.bat +82 -0
  87. package/template/apps/android/settings.gradle.kts +29 -0
  88. package/template/apps/ios/AGENTS.md +70 -0
  89. package/template/apps/ios/App/AppMain.swift +13 -0
  90. package/template/apps/ios/App/Core/AppContainer.swift +22 -0
  91. package/template/apps/ios/App/Core/DesignSystem/AppCard.swift +15 -0
  92. package/template/apps/ios/App/Core/Navigation/Route.swift +4 -0
  93. package/template/apps/ios/App/Core/RootView.swift +32 -0
  94. package/template/apps/ios/App/Features/Home/HomeScreen.swift +35 -0
  95. package/template/apps/ios/App/Features/Settings/Appearance.swift +27 -0
  96. package/template/apps/ios/App/Features/Settings/SettingsRepository.swift +40 -0
  97. package/template/apps/ios/App/Features/Settings/SettingsScreen.swift +40 -0
  98. package/template/apps/ios/App/Features/Settings/SettingsViewModel.swift +23 -0
  99. package/template/apps/ios/App/Generated/AppIdentity.swift +9 -0
  100. package/template/apps/ios/App/Generated/Colors.xcassets/AccentColor.colorset/Contents.json +38 -0
  101. package/template/apps/ios/App/Generated/Colors.xcassets/Contents.json +6 -0
  102. package/template/apps/ios/App/Generated/Icons.swift +8 -0
  103. package/template/apps/ios/App/Generated/Localizable.xcstrings +127 -0
  104. package/template/apps/ios/App/Generated/Strings.swift +40 -0
  105. package/template/apps/ios/App/Generated/Tokens.swift +65 -0
  106. package/template/apps/ios/App/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png +0 -0
  107. package/template/apps/ios/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json +14 -0
  108. package/template/apps/ios/App/Resources/Assets.xcassets/Contents.json +6 -0
  109. package/template/apps/ios/App.xcodeproj/project.pbxproj +298 -0
  110. package/template/apps/ios/App.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  111. package/template/apps/ios/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme +89 -0
  112. package/template/apps/ios/AppTests/SettingsTests.swift +54 -0
  113. package/template/apps/ios/CLAUDE.md +1 -0
  114. package/template/apps/ios/Config/App.xcconfig +23 -0
  115. package/template/apps/ios/Config/AppTests.xcconfig +10 -0
  116. package/template/apps/ios/Config/Base.xcconfig +25 -0
  117. package/template/apps/ios/Config/Debug.xcconfig +9 -0
  118. package/template/apps/ios/Config/Identity.xcconfig +5 -0
  119. package/template/apps/ios/Config/Release.xcconfig +6 -0
  120. package/template/apps/web/AGENTS.md +63 -0
  121. package/template/apps/web/CLAUDE.md +1 -0
  122. package/template/apps/web/package.json +38 -0
  123. package/template/apps/web/public/favicon.svg +6 -0
  124. package/template/apps/web/src/core/AppContainer.ts +18 -0
  125. package/template/apps/web/src/core/AppearanceEffect.tsx +31 -0
  126. package/template/apps/web/src/core/designsystem/AppCard.tsx +6 -0
  127. package/template/apps/web/src/features/home/HomeScreen.tsx +15 -0
  128. package/template/apps/web/src/features/home/home.test.tsx +22 -0
  129. package/template/apps/web/src/features/settings/Appearance.ts +29 -0
  130. package/template/apps/web/src/features/settings/LocalStorageSettingsRepository.ts +21 -0
  131. package/template/apps/web/src/features/settings/SettingsRepository.ts +7 -0
  132. package/template/apps/web/src/features/settings/SettingsScreen.tsx +44 -0
  133. package/template/apps/web/src/features/settings/settings.test.ts +45 -0
  134. package/template/apps/web/src/features/settings/useSettingsViewModel.ts +42 -0
  135. package/template/apps/web/src/generated/app.ts +9 -0
  136. package/template/apps/web/src/generated/icons.ts +9 -0
  137. package/template/apps/web/src/generated/strings.ts +88 -0
  138. package/template/apps/web/src/generated/tokens.css +86 -0
  139. package/template/apps/web/src/routeTree.gen.ts +86 -0
  140. package/template/apps/web/src/router.tsx +23 -0
  141. package/template/apps/web/src/routes/__root.tsx +72 -0
  142. package/template/apps/web/src/routes/index.tsx +6 -0
  143. package/template/apps/web/src/routes/settings.tsx +9 -0
  144. package/template/apps/web/src/styles.css +9 -0
  145. package/template/apps/web/tsconfig.json +26 -0
  146. package/template/apps/web/tsr.config.json +3 -0
  147. package/template/apps/web/vite.config.ts +9 -0
  148. package/template/apps/web/vitest.config.ts +9 -0
  149. package/template/contracts/app.json +7 -0
  150. package/template/contracts/icons.json +5 -0
  151. package/template/contracts/strings/en.json +13 -0
  152. package/template/contracts/tokens.json +40 -0
  153. package/template/package.json +17 -0
  154. package/template/spec/decisions.md +10 -0
  155. package/template/spec/features/home.md +39 -0
  156. package/template/spec/features/settings.md +50 -0
  157. package/template/spec/navigation.md +16 -0
  158. package/template/spec/parity.md +29 -0
  159. package/README.md +0 -3
@@ -0,0 +1,89 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <Scheme
3
+ LastUpgradeVersion = "2630"
4
+ version = "1.7">
5
+ <BuildAction
6
+ parallelizeBuildables = "YES"
7
+ buildImplicitDependencies = "YES">
8
+ <BuildActionEntries>
9
+ <BuildActionEntry
10
+ buildForTesting = "YES"
11
+ buildForRunning = "YES"
12
+ buildForProfiling = "YES"
13
+ buildForArchiving = "YES"
14
+ buildForAnalyzing = "YES">
15
+ <BuildableReference
16
+ BuildableIdentifier = "primary"
17
+ BlueprintIdentifier = "A1B2C3D4E5F6000000000020"
18
+ BuildableName = "App.app"
19
+ BlueprintName = "App"
20
+ ReferencedContainer = "container:App.xcodeproj">
21
+ </BuildableReference>
22
+ </BuildActionEntry>
23
+ </BuildActionEntries>
24
+ </BuildAction>
25
+ <TestAction
26
+ buildConfiguration = "Debug"
27
+ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
28
+ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
29
+ shouldUseLaunchSchemeArgsEnv = "YES">
30
+ <Testables>
31
+ <TestableReference
32
+ skipped = "NO"
33
+ parallelizable = "YES">
34
+ <BuildableReference
35
+ BuildableIdentifier = "primary"
36
+ BlueprintIdentifier = "A1B2C3D4E5F6000000000030"
37
+ BuildableName = "AppTests.xctest"
38
+ BlueprintName = "AppTests"
39
+ ReferencedContainer = "container:App.xcodeproj">
40
+ </BuildableReference>
41
+ </TestableReference>
42
+ </Testables>
43
+ </TestAction>
44
+ <LaunchAction
45
+ buildConfiguration = "Debug"
46
+ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
47
+ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
48
+ launchStyle = "0"
49
+ useCustomWorkingDirectory = "NO"
50
+ ignoresPersistentStateOnLaunch = "NO"
51
+ debugDocumentVersioning = "YES"
52
+ debugServiceExtension = "internal"
53
+ allowLocationSimulation = "YES">
54
+ <BuildableProductRunnable
55
+ runnableDebuggingMode = "0">
56
+ <BuildableReference
57
+ BuildableIdentifier = "primary"
58
+ BlueprintIdentifier = "A1B2C3D4E5F6000000000020"
59
+ BuildableName = "App.app"
60
+ BlueprintName = "App"
61
+ ReferencedContainer = "container:App.xcodeproj">
62
+ </BuildableReference>
63
+ </BuildableProductRunnable>
64
+ </LaunchAction>
65
+ <ProfileAction
66
+ buildConfiguration = "Release"
67
+ shouldUseLaunchSchemeArgsEnv = "YES"
68
+ savedToolIdentifier = ""
69
+ useCustomWorkingDirectory = "NO"
70
+ debugDocumentVersioning = "YES">
71
+ <BuildableProductRunnable
72
+ runnableDebuggingMode = "0">
73
+ <BuildableReference
74
+ BuildableIdentifier = "primary"
75
+ BlueprintIdentifier = "A1B2C3D4E5F6000000000020"
76
+ BuildableName = "App.app"
77
+ BlueprintName = "App"
78
+ ReferencedContainer = "container:App.xcodeproj">
79
+ </BuildableReference>
80
+ </BuildableProductRunnable>
81
+ </ProfileAction>
82
+ <AnalyzeAction
83
+ buildConfiguration = "Debug">
84
+ </AnalyzeAction>
85
+ <ArchiveAction
86
+ buildConfiguration = "Release"
87
+ revealArchiveInOrganizer = "YES">
88
+ </ArchiveAction>
89
+ </Scheme>
@@ -0,0 +1,54 @@
1
+ import Foundation
2
+ import Testing
3
+ import UIKit
4
+ @testable import App
5
+
6
+ @Suite("Settings")
7
+ struct SettingsTests {
8
+ /// An empty UserDefaults suite, so tests don't share state.
9
+ private func freshDefaults() -> UserDefaults {
10
+ let name = "SettingsTests.\(UUID().uuidString)"
11
+ let defaults = UserDefaults(suiteName: name)!
12
+ defaults.removePersistentDomain(forName: name)
13
+ return defaults
14
+ }
15
+
16
+ @Test("AC-settings-1: appearance defaults to System")
17
+ func defaultsToSystem() {
18
+ #expect(UserDefaultsSettingsRepository(defaults: freshDefaults()).appearance == .system)
19
+
20
+ let defaults = freshDefaults()
21
+ defaults.set("sepia", forKey: Appearance.storageKey)
22
+ #expect(UserDefaultsSettingsRepository(defaults: defaults).appearance == .system)
23
+ }
24
+
25
+ @Test("AC-settings-2: choosing an appearance applies it to the whole app immediately")
26
+ func appliesImmediately() {
27
+ let repository = InMemorySettingsRepository()
28
+ let viewModel = SettingsViewModel(repository: repository)
29
+
30
+ viewModel.selectAppearance(.dark)
31
+
32
+ // RootView applies the repository's appearance to every window as it changes.
33
+ #expect(repository.appearance == .dark)
34
+ #expect(viewModel.appearance == .dark)
35
+ #expect(Appearance.dark.interfaceStyle == .dark)
36
+ #expect(Appearance.system.interfaceStyle == .unspecified)
37
+ }
38
+
39
+ @Test("AC-settings-3: the chosen appearance survives a restart")
40
+ func survivesRestart() {
41
+ let defaults = freshDefaults()
42
+ UserDefaultsSettingsRepository(defaults: defaults).setAppearance(.light)
43
+
44
+ #expect(UserDefaultsSettingsRepository(defaults: defaults).appearance == .light)
45
+ #expect(defaults.string(forKey: "settings.appearance") == "light")
46
+ }
47
+
48
+ @Test("AC-settings-4: shows the app version and build number")
49
+ func showsVersion() {
50
+ let text = SettingsViewModel(repository: InMemorySettingsRepository()).versionText
51
+ #expect(text.contains(AppIdentity.version))
52
+ #expect(text.contains(String(AppIdentity.build)))
53
+ }
54
+ }
@@ -0,0 +1 @@
1
+ @AGENTS.md
@@ -0,0 +1,23 @@
1
+ // The app target. Its name, bundle ID and version come from contracts/app.json (via Identity.xcconfig).
2
+ #include "Identity.xcconfig"
3
+
4
+ PRODUCT_NAME = App
5
+ PRODUCT_BUNDLE_IDENTIFIER = $(APP_BUNDLE_ID)
6
+ MARKETING_VERSION = $(APP_VERSION)
7
+ CURRENT_PROJECT_VERSION = $(APP_BUILD)
8
+
9
+ GENERATE_INFOPLIST_FILE = YES
10
+ INFOPLIST_KEY_CFBundleDisplayName = $(APP_DISPLAY_NAME)
11
+ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES
12
+ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES
13
+ INFOPLIST_KEY_UILaunchScreen_Generation = YES
14
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight
15
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight
16
+
17
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon
18
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor
19
+ ENABLE_PREVIEWS = YES
20
+ LD_RUNPATH_SEARCH_PATHS = $(inherited) @executable_path/Frameworks
21
+
22
+ // To run on a device, set your Apple Developer team ID.
23
+ DEVELOPMENT_TEAM =
@@ -0,0 +1,10 @@
1
+ // The unit test target, hosted in the app.
2
+ #include "Identity.xcconfig"
3
+
4
+ PRODUCT_NAME = AppTests
5
+ PRODUCT_BUNDLE_IDENTIFIER = $(APP_BUNDLE_ID).tests
6
+ MARKETING_VERSION = $(APP_VERSION)
7
+ CURRENT_PROJECT_VERSION = $(APP_BUILD)
8
+ GENERATE_INFOPLIST_FILE = YES
9
+ TEST_HOST = $(BUILT_PRODUCTS_DIR)/App.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/App
10
+ BUNDLE_LOADER = $(TEST_HOST)
@@ -0,0 +1,25 @@
1
+ // Settings shared by every target and configuration.
2
+ // Edit build settings here, not in the Xcode project file.
3
+
4
+ SDKROOT = iphoneos
5
+ IPHONEOS_DEPLOYMENT_TARGET = 17.0
6
+ TARGETED_DEVICE_FAMILY = 1,2
7
+
8
+ SWIFT_VERSION = 6.0
9
+ SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor
10
+ SWIFT_APPROACHABLE_CONCURRENCY = YES
11
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES
12
+
13
+ // contracts/strings is the source of copy; don't let Xcode extract strings into the generated catalog.
14
+ SWIFT_EMIT_LOC_STRINGS = NO
15
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES
16
+
17
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES
18
+ ENABLE_USER_SCRIPT_SANDBOXING = YES
19
+ ENABLE_STRICT_OBJC_MSGSEND = YES
20
+ CLANG_ENABLE_MODULES = YES
21
+ CLANG_ENABLE_OBJC_ARC = YES
22
+ GCC_C_LANGUAGE_STANDARD = gnu17
23
+ ALWAYS_SEARCH_USER_PATHS = NO
24
+ CODE_SIGN_STYLE = Automatic
25
+ COPY_PHASE_STRIP = NO
@@ -0,0 +1,9 @@
1
+ #include "Base.xcconfig"
2
+
3
+ DEBUG_INFORMATION_FORMAT = dwarf
4
+ ENABLE_TESTABILITY = YES
5
+ GCC_OPTIMIZATION_LEVEL = 0
6
+ GCC_PREPROCESSOR_DEFINITIONS = DEBUG=1 $(inherited)
7
+ ONLY_ACTIVE_ARCH = YES
8
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG $(inherited)
9
+ SWIFT_OPTIMIZATION_LEVEL = -Onone
@@ -0,0 +1,5 @@
1
+ // GENERATED by geeknative from contracts/app.json. Do not edit: change the contract, then run `make gen`.
2
+ APP_DISPLAY_NAME = GeekNative
3
+ APP_BUNDLE_ID = com.example.geeknative
4
+ APP_VERSION = 1.0.0
5
+ APP_BUILD = 1
@@ -0,0 +1,6 @@
1
+ #include "Base.xcconfig"
2
+
3
+ DEBUG_INFORMATION_FORMAT = dwarf-with-dsym
4
+ ENABLE_NS_ASSERTIONS = NO
5
+ SWIFT_COMPILATION_MODE = wholemodule
6
+ VALIDATE_PRODUCT = YES
@@ -0,0 +1,63 @@
1
+ <!-- geeknative:begin (managed by geeknative: replaced on upgrade. Add your own rules under "Project rules" at the end.) -->
2
+ # Web app (React + TanStack Start)
3
+
4
+ Read the root AGENTS.md first. This file covers how the web app is built.
5
+
6
+ ## Stack
7
+
8
+ - React 19 and TanStack Start (server rendering), with TanStack Router file routes and TanStack Query.
9
+ - Tailwind CSS v4 with the generated design tokens, and Lucide icons.
10
+ - TypeScript (strict), Vite, Vitest.
11
+ - Supports Safari 16.4+, Chrome 111+ and Firefox 128+ (Tailwind v4's minimum).
12
+
13
+ ## Layout
14
+
15
+ ```
16
+ apps/web/src/
17
+ router.tsx Creates the router with { queryClient, container } as context
18
+ routes/ One thin file per URL: head, loader, then render a feature's screen
19
+ __root.tsx HTML shell, site header, AppearanceEffect
20
+ core/ AppContainer, AppearanceEffect
21
+ designsystem/ Shared components (AppCard, ...)
22
+ features/<name>/ <Name>Screen.tsx, use<Name>ViewModel.ts, <Name>Repository.ts, implementations, tests
23
+ generated/ From contracts/ by `make gen`. Never edit.
24
+ styles.css Tailwind entry; imports generated/tokens.css
25
+ routeTree.gen.ts Generated by TanStack Router. Never edit.
26
+ ```
27
+
28
+ ## Patterns
29
+
30
+ Same names and responsibilities as the iOS and Android apps.
31
+
32
+ | Concept | Web |
33
+ |---|---|
34
+ | Screen | `NotesScreen` in features/notes/, rendered by routes/notes.tsx |
35
+ | State and actions | `useNotesViewModel()`: reads data with TanStack Query, returns state and actions |
36
+ | Data | `NotesRepository` interface, plus implementations such as `LocalStorageNotesRepository` |
37
+ | Dependencies | `AppContainer`, in router context: `useRouteContext({ from: '__root__' }).container` |
38
+ | Navigation | A file in routes/ per screen; link with `<Link to="/notes">` |
39
+ | Copy | `strings.notesEmptyTitle` (generated) |
40
+ | Colors, spacing, radii, type | Tailwind token classes: `bg-primary`, `p-md`, `rounded-lg`, `text-title` |
41
+ | Icons | `<AppIcons.settings className="size-6" />` (generated) |
42
+ | Test IDs | `data-testid="notes.addButton"` |
43
+
44
+ Follow features/settings as the reference implementation.
45
+
46
+ ## Rules
47
+
48
+ - Never hardcode user-facing text, colors or spacing. Add them to contracts/ and run `make gen`. Use token classes (`bg-surface`, `p-lg`), not arbitrary values (`bg-[#fff]`, `p-[13px]`).
49
+ - Server functions and API routes are only for web-only concerns (rendering, cookies, SEO). Logic the mobile apps need too goes through repositories, so a future backend can serve all three apps.
50
+ - Code that uses browser APIs (localStorage, window) must also run safely during server rendering.
51
+ - Use semantic HTML, give icon-only links and buttons an `aria-label` from strings, and keep focus visible.
52
+ - For complex primitives (dialogs, menus, popovers), use shadcn/ui: the tokens already map onto its CSS variables.
53
+ - Name tests after acceptance criteria: `test('AC-notes-2: ...')`.
54
+
55
+ ## Commands
56
+
57
+ - `make check-web`: type-check, unit tests and a production build. Full log: .geeknative/logs/web.log.
58
+ - `make run-web`: dev server at http://localhost:3000.
59
+ <!-- geeknative:end -->
60
+
61
+ ## Project rules
62
+
63
+ <!-- Your web-specific rules. They override the defaults above, and geeknative upgrades never touch them. -->
@@ -0,0 +1 @@
1
+ @AGENTS.md
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "web",
3
+ "private": true,
4
+ "type": "module",
5
+ "imports": {
6
+ "#/*": "./src/*"
7
+ },
8
+ "scripts": {
9
+ "dev": "vite dev --port 3000",
10
+ "build": "vite build",
11
+ "preview": "vite preview",
12
+ "routes": "tsr generate",
13
+ "typecheck": "tsr generate && tsc --noEmit",
14
+ "test": "vitest run",
15
+ "check": "npm run typecheck && npm run test && npm run build"
16
+ },
17
+ "dependencies": {
18
+ "@tailwindcss/vite": "4.3.3",
19
+ "@tanstack/react-query": "5.104.1",
20
+ "@tanstack/react-router": "1.170.42",
21
+ "@tanstack/react-router-ssr-query": "1.167.3",
22
+ "@tanstack/react-start": "1.168.61",
23
+ "lucide-react": "1.55.0",
24
+ "react": "^19.2.0",
25
+ "react-dom": "^19.2.0",
26
+ "tailwindcss": "4.3.3"
27
+ },
28
+ "devDependencies": {
29
+ "@tanstack/router-cli": "1.167.41",
30
+ "@types/node": "^24.0.0",
31
+ "@types/react": "^19.2.0",
32
+ "@types/react-dom": "^19.2.0",
33
+ "@vitejs/plugin-react": "^6.0.1",
34
+ "typescript": "^6.0.2",
35
+ "vite": "^8.0.0",
36
+ "vitest": "^5.0.3"
37
+ }
38
+ }
@@ -0,0 +1,6 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
2
+ <rect width="64" height="64" rx="14" fill="#4F46E5"/>
3
+ <circle cx="19" cy="32" r="5.5" fill="#fff"/>
4
+ <circle cx="32" cy="32" r="5.5" fill="#fff"/>
5
+ <circle cx="45" cy="32" r="5.5" fill="#fff"/>
6
+ </svg>
@@ -0,0 +1,18 @@
1
+ import { LocalStorageSettingsRepository } from '#/features/settings/LocalStorageSettingsRepository'
2
+ import type { SettingsRepository } from '#/features/settings/SettingsRepository'
3
+
4
+ /**
5
+ * The app's dependencies, created once per router and shared through router context.
6
+ * Swap an implementation here (for example, a backend-backed repository) without touching screens.
7
+ */
8
+ export interface AppContainer {
9
+ settingsRepository: SettingsRepository
10
+ }
11
+
12
+ export function createAppContainer(): AppContainer {
13
+ // During server rendering there's no localStorage, so the repository falls back to defaults.
14
+ const storage = typeof window === 'undefined' ? undefined : window.localStorage
15
+ return {
16
+ settingsRepository: new LocalStorageSettingsRepository(storage),
17
+ }
18
+ }
@@ -0,0 +1,31 @@
1
+ import { useEffect } from 'react'
2
+ import { APPEARANCE_STORAGE_KEY, isDark } from '#/features/settings/Appearance'
3
+ import { useAppearance } from '#/features/settings/useSettingsViewModel'
4
+
5
+ /**
6
+ * Runs in <head> before the first paint: applies the stored appearance (unknown or missing
7
+ * values count as "system"), so the page never flashes the wrong theme.
8
+ */
9
+ export const appearanceScript = `(() => {
10
+ try {
11
+ const stored = localStorage.getItem(${JSON.stringify(APPEARANCE_STORAGE_KEY)})
12
+ const dark = stored === 'dark' || (stored !== 'light' && matchMedia('(prefers-color-scheme: dark)').matches)
13
+ document.documentElement.classList.toggle('dark', dark)
14
+ } catch {}
15
+ })()`
16
+
17
+ /** Keeps the "dark" class on <html> in step with the chosen appearance and the system setting. */
18
+ export function AppearanceEffect() {
19
+ const { data: appearance } = useAppearance()
20
+
21
+ useEffect(() => {
22
+ if (!appearance) return
23
+ const media = window.matchMedia('(prefers-color-scheme: dark)')
24
+ const apply = () => document.documentElement.classList.toggle('dark', isDark(appearance, media.matches))
25
+ apply()
26
+ media.addEventListener('change', apply)
27
+ return () => media.removeEventListener('change', apply)
28
+ }, [appearance])
29
+
30
+ return null
31
+ }
@@ -0,0 +1,6 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ /** A card for grouped content on a screen (DESIGN.md: Card). */
4
+ export function AppCard({ children, className = '' }: { children: ReactNode; className?: string }) {
5
+ return <section className={`rounded-lg bg-surface p-lg text-on-surface ${className}`}>{children}</section>
6
+ }
@@ -0,0 +1,15 @@
1
+ import { AppCard } from '#/core/designsystem/AppCard'
2
+ import { AppIdentity } from '#/generated/app'
3
+ import { strings } from '#/generated/strings'
4
+
5
+ export function HomeScreen() {
6
+ return (
7
+ <div className="flex flex-col gap-xl">
8
+ <h1 className="text-display">{AppIdentity.name}</h1>
9
+ <AppCard>
10
+ <h2 className="text-title">{strings.homeWelcomeTitle}</h2>
11
+ <p className="mt-sm text-body text-on-surface-variant">{strings.homeWelcomeBody}</p>
12
+ </AppCard>
13
+ </div>
14
+ )
15
+ }
@@ -0,0 +1,22 @@
1
+ import { renderToString } from 'react-dom/server'
2
+ import { describe, expect, test } from 'vitest'
3
+ import { AppIdentity } from '#/generated/app'
4
+ import { strings } from '#/generated/strings'
5
+ import { HomeScreen } from './HomeScreen'
6
+
7
+ /** How React escapes text in server-rendered HTML. */
8
+ const escapeHtml = (text: string) =>
9
+ text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#x27;')
10
+
11
+ describe('Home', () => {
12
+ const html = renderToString(<HomeScreen />)
13
+
14
+ test('AC-home-1: shows the app name as its title', () => {
15
+ expect(html).toContain(`>${escapeHtml(AppIdentity.name)}</h1>`)
16
+ })
17
+
18
+ test("AC-home-2: shows the welcome card's title and text", () => {
19
+ expect(html).toContain(escapeHtml(strings.homeWelcomeTitle))
20
+ expect(html).toContain(escapeHtml(strings.homeWelcomeBody))
21
+ })
22
+ })
@@ -0,0 +1,29 @@
1
+ import { strings } from '#/generated/strings'
2
+
3
+ /** The appearance setting. Stored as these exact values on every platform. */
4
+ export const appearances = ['system', 'light', 'dark'] as const
5
+ export type Appearance = (typeof appearances)[number]
6
+
7
+ /** Storage key, shared with the iOS and Android apps. */
8
+ export const APPEARANCE_STORAGE_KEY = 'settings.appearance'
9
+
10
+ /** Missing or unknown stored values count as "system". */
11
+ export function parseAppearance(value: string | null | undefined): Appearance {
12
+ return appearances.find((appearance) => appearance === value) ?? 'system'
13
+ }
14
+
15
+ /** Whether to render dark, given the chosen appearance and the system setting. */
16
+ export function isDark(appearance: Appearance, systemIsDark: boolean) {
17
+ return appearance === 'dark' || (appearance === 'system' && systemIsDark)
18
+ }
19
+
20
+ export function appearanceLabel(appearance: Appearance) {
21
+ switch (appearance) {
22
+ case 'system':
23
+ return strings.settingsAppearanceSystem
24
+ case 'light':
25
+ return strings.settingsAppearanceLight
26
+ case 'dark':
27
+ return strings.settingsAppearanceDark
28
+ }
29
+ }
@@ -0,0 +1,21 @@
1
+ import { APPEARANCE_STORAGE_KEY, parseAppearance, type Appearance } from './Appearance'
2
+ import type { SettingsRepository } from './SettingsRepository'
3
+
4
+ type KeyValueStorage = Pick<Storage, 'getItem' | 'setItem'>
5
+
6
+ /** Keeps settings in the browser's localStorage. Without storage (during server rendering) it returns defaults. */
7
+ export class LocalStorageSettingsRepository implements SettingsRepository {
8
+ private readonly storage: KeyValueStorage | undefined
9
+
10
+ constructor(storage: KeyValueStorage | undefined) {
11
+ this.storage = storage
12
+ }
13
+
14
+ async getAppearance(): Promise<Appearance> {
15
+ return parseAppearance(this.storage?.getItem(APPEARANCE_STORAGE_KEY))
16
+ }
17
+
18
+ async setAppearance(appearance: Appearance): Promise<void> {
19
+ this.storage?.setItem(APPEARANCE_STORAGE_KEY, appearance)
20
+ }
21
+ }
@@ -0,0 +1,7 @@
1
+ import type { Appearance } from './Appearance'
2
+
3
+ /** Stores the user's preferences. Screens use whichever implementation AppContainer provides. */
4
+ export interface SettingsRepository {
5
+ getAppearance(): Promise<Appearance>
6
+ setAppearance(appearance: Appearance): Promise<void>
7
+ }
@@ -0,0 +1,44 @@
1
+ import { AppCard } from '#/core/designsystem/AppCard'
2
+ import { strings } from '#/generated/strings'
3
+ import { useSettingsViewModel } from './useSettingsViewModel'
4
+
5
+ export function SettingsScreen() {
6
+ const viewModel = useSettingsViewModel()
7
+
8
+ return (
9
+ <div className="flex flex-col gap-xl">
10
+ <h1 className="text-display">{strings.settingsTitle}</h1>
11
+
12
+ <AppCard>
13
+ <fieldset data-testid="settings.appearance">
14
+ <legend className="text-label text-on-surface-variant">{strings.settingsAppearanceTitle}</legend>
15
+ <div className="mt-md flex flex-col gap-xs">
16
+ {viewModel.appearanceOptions.map((option) => (
17
+ <label
18
+ key={option.id}
19
+ className="flex cursor-pointer items-center gap-md rounded-md p-sm hover:bg-surface-variant"
20
+ >
21
+ <input
22
+ type="radio"
23
+ name="appearance"
24
+ value={option.id}
25
+ checked={viewModel.appearance === option.id}
26
+ onChange={() => void viewModel.selectAppearance(option.id)}
27
+ className="size-4 accent-primary"
28
+ />
29
+ <span className="text-body">{option.label}</span>
30
+ </label>
31
+ ))}
32
+ </div>
33
+ </fieldset>
34
+ </AppCard>
35
+
36
+ <AppCard>
37
+ <h2 className="text-label text-on-surface-variant">{strings.settingsAboutTitle}</h2>
38
+ <p data-testid="settings.version" className="mt-sm text-body">
39
+ {viewModel.versionText}
40
+ </p>
41
+ </AppCard>
42
+ </div>
43
+ )
44
+ }
@@ -0,0 +1,45 @@
1
+ import { QueryClient } from '@tanstack/react-query'
2
+ import { describe, expect, test } from 'vitest'
3
+ import { AppIdentity } from '#/generated/app'
4
+ import { isDark, parseAppearance } from './Appearance'
5
+ import { LocalStorageSettingsRepository } from './LocalStorageSettingsRepository'
6
+ import { appearanceQuery, selectAppearance, versionText } from './useSettingsViewModel'
7
+
8
+ function memoryStorage() {
9
+ const values = new Map<string, string>()
10
+ return {
11
+ getItem: (key: string) => values.get(key) ?? null,
12
+ setItem: (key: string, value: string) => void values.set(key, value),
13
+ }
14
+ }
15
+
16
+ describe('Settings', () => {
17
+ test('AC-settings-1: appearance defaults to System', async () => {
18
+ const repository = new LocalStorageSettingsRepository(memoryStorage())
19
+ expect(await repository.getAppearance()).toBe('system')
20
+ expect(parseAppearance('sepia')).toBe('system')
21
+ })
22
+
23
+ test('AC-settings-2: choosing an appearance applies it to the whole app immediately', async () => {
24
+ const queryClient = new QueryClient()
25
+ const repository = new LocalStorageSettingsRepository(memoryStorage())
26
+ const saving = selectAppearance(queryClient, repository, 'dark')
27
+ // The shared query changes before saving finishes, so every screen updates at once.
28
+ expect(queryClient.getQueryData(appearanceQuery(repository).queryKey)).toBe('dark')
29
+ await saving
30
+ expect(isDark('dark', false)).toBe(true)
31
+ expect(isDark('system', true)).toBe(true)
32
+ expect(isDark('light', true)).toBe(false)
33
+ })
34
+
35
+ test('AC-settings-3: the chosen appearance survives a restart', async () => {
36
+ const storage = memoryStorage()
37
+ await new LocalStorageSettingsRepository(storage).setAppearance('light')
38
+ expect(await new LocalStorageSettingsRepository(storage).getAppearance()).toBe('light')
39
+ })
40
+
41
+ test('AC-settings-4: shows the app version and build number', () => {
42
+ expect(versionText()).toContain(AppIdentity.version)
43
+ expect(versionText()).toContain(String(AppIdentity.build))
44
+ })
45
+ })
@@ -0,0 +1,42 @@
1
+ import { queryOptions, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query'
2
+ import { useRouteContext } from '@tanstack/react-router'
3
+ import { AppIdentity } from '#/generated/app'
4
+ import { strings } from '#/generated/strings'
5
+ import { appearanceLabel, appearances, type Appearance } from './Appearance'
6
+ import type { SettingsRepository } from './SettingsRepository'
7
+
8
+ export const appearanceQuery = (repository: SettingsRepository) =>
9
+ queryOptions({
10
+ queryKey: ['settings', 'appearance'] as const,
11
+ queryFn: () => repository.getAppearance(),
12
+ staleTime: Infinity,
13
+ })
14
+
15
+ /** Applies the appearance everywhere at once (through the shared query), then saves it. */
16
+ export async function selectAppearance(queryClient: QueryClient, repository: SettingsRepository, appearance: Appearance) {
17
+ queryClient.setQueryData(appearanceQuery(repository).queryKey, appearance)
18
+ await repository.setAppearance(appearance)
19
+ }
20
+
21
+ export const versionText = () =>
22
+ strings.settingsAboutVersion({ version: AppIdentity.version, build: AppIdentity.build })
23
+
24
+ /** The current appearance. Used by the Settings screen and by AppearanceEffect. */
25
+ export function useAppearance() {
26
+ const { container } = useRouteContext({ from: '__root__' })
27
+ return useQuery(appearanceQuery(container.settingsRepository))
28
+ }
29
+
30
+ /** State and actions for the Settings screen. */
31
+ export function useSettingsViewModel() {
32
+ const { container } = useRouteContext({ from: '__root__' })
33
+ const queryClient = useQueryClient()
34
+ const { data: appearance = 'system' } = useAppearance()
35
+
36
+ return {
37
+ appearance,
38
+ appearanceOptions: appearances.map((id) => ({ id, label: appearanceLabel(id) })),
39
+ selectAppearance: (next: Appearance) => selectAppearance(queryClient, container.settingsRepository, next),
40
+ versionText: versionText(),
41
+ }
42
+ }
@@ -0,0 +1,9 @@
1
+ // GENERATED by geeknative from contracts/app.json. Do not edit: change the contract, then run `make gen`.
2
+
3
+ /** The app's name, identifier and version, shared with the iOS and Android apps. */
4
+ export const AppIdentity = {
5
+ name: "GeekNative",
6
+ id: "com.example.geeknative",
7
+ version: "1.0.0",
8
+ build: 1,
9
+ } as const