mobile bluesky app made with flutter lazurite.stormlightlabs.org/
mobile bluesky flutter
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: https app-links (#10)

* chore: remove and reorganize comments

* feat: add https app-link & callback for android

* consolidate static styles

* feat: iOS https app-link

* refactor: consolidate css

authored by

Owais and committed by
GitHub
da3f3870 a9f103a6

+1204 -1177
+11
android/app/src/main/AndroidManifest.xml
··· 48 48 49 49 <data android:scheme="org.stormlightlabs.lazurite" android:path="/oauth/callback" /> 50 50 </intent-filter> 51 + <intent-filter android:autoVerify="true"> 52 + <action android:name="android.intent.action.VIEW" /> 53 + 54 + <category android:name="android.intent.category.DEFAULT" /> 55 + <category android:name="android.intent.category.BROWSABLE" /> 56 + 57 + <data 58 + android:scheme="https" 59 + android:host="lazurite.stormlightlabs.org" 60 + android:path="/oauth/callback" /> 61 + </intent-filter> 51 62 </activity> 52 63 <!-- Don't delete the meta-data below. 53 64 This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
+67
integration_test/oauth_callback_https_integration_test.dart
··· 1 + import 'package:bloc_test/bloc_test.dart'; 2 + import 'package:flutter/material.dart'; 3 + import 'package:flutter_bloc/flutter_bloc.dart'; 4 + import 'package:flutter_test/flutter_test.dart'; 5 + import 'package:go_router/go_router.dart'; 6 + import 'package:integration_test/integration_test.dart'; 7 + import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 8 + import 'package:lazurite/features/auth/presentation/oauth_callback_screen.dart'; 9 + import 'package:mocktail/mocktail.dart'; 10 + 11 + class MockAuthBloc extends MockBloc<AuthEvent, AuthState> implements AuthBloc {} 12 + 13 + void main() { 14 + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); 15 + 16 + setUpAll(() { 17 + registerFallbackValue(Uri.parse('https://example.com/oauth/callback')); 18 + }); 19 + 20 + testWidgets('forwards HTTPS callback URI query params and returns to login', (tester) async { 21 + final authBloc = MockAuthBloc(); 22 + when(() => authBloc.state).thenReturn(const AuthState.unauthenticated()); 23 + whenListen(authBloc, const Stream<AuthState>.empty(), initialState: const AuthState.unauthenticated()); 24 + 25 + Uri? capturedUri; 26 + when(() => authBloc.handleOAuthRedirectUri(any())).thenAnswer((invocation) async { 27 + capturedUri = invocation.positionalArguments.first as Uri; 28 + return true; 29 + }); 30 + 31 + final router = GoRouter( 32 + initialLocation: OAuthCallbackScreen.routePath, 33 + routes: [ 34 + GoRoute( 35 + path: OAuthCallbackScreen.routePath, 36 + builder: (context, state) => OAuthCallbackScreen( 37 + callbackUri: Uri.parse( 38 + 'https://lazurite.stormlightlabs.org/oauth/callback?code=abc&state=xyz&iss=https%3A%2F%2Fbsky.social', 39 + ), 40 + ), 41 + ), 42 + GoRoute( 43 + path: '/login', 44 + builder: (context, state) => const Scaffold(body: Text('login')), 45 + ), 46 + ], 47 + ); 48 + 49 + await tester.pumpWidget( 50 + BlocProvider<AuthBloc>.value( 51 + value: authBloc, 52 + child: MaterialApp.router(routerConfig: router), 53 + ), 54 + ); 55 + await tester.pumpAndSettle(); 56 + 57 + verify(() => authBloc.handleOAuthRedirectUri(any())).called(1); 58 + expect(capturedUri, isNotNull); 59 + expect(capturedUri!.scheme, equals('https')); 60 + expect(capturedUri!.host, equals('lazurite.stormlightlabs.org')); 61 + expect(capturedUri!.path, equals('/oauth/callback')); 62 + expect(capturedUri!.queryParameters['code'], equals('abc')); 63 + expect(capturedUri!.queryParameters['state'], equals('xyz')); 64 + expect(capturedUri!.queryParameters['iss'], equals('https://bsky.social')); 65 + expect(find.text('login'), findsOneWidget); 66 + }); 67 + }
+4
ios/Runner/Runner.entitlements
··· 2 2 <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 3 3 <plist version="1.0"> 4 4 <dict> 5 + <key>com.apple.developer.associated-domains</key> 6 + <array> 7 + <string>applinks:lazurite.stormlightlabs.org</string> 8 + </array> 5 9 <key>aps-environment</key> 6 10 <string>development</string> 7 11 </dict>
+1 -3
lib/core/embedding/embedding_service.dart
··· 3 3 4 4 import 'package:flutter/foundation.dart'; 5 5 import 'package:flutter/services.dart'; 6 - import 'package:lazurite/core/logging/app_logger.dart'; 7 6 import 'package:lazurite/core/embedding/word_piece_tokenizer.dart'; 7 + import 'package:lazurite/core/logging/app_logger.dart'; 8 8 import 'package:tflite_flutter/tflite_flutter.dart'; 9 9 10 10 /// L2-normalize [vector], returning a new [Float32List]. ··· 94 94 } 95 95 } 96 96 97 - // Fallback mapping when tensor names are opaque or stripped. 98 97 if (!assignedInputIds && inputTensors.isNotEmpty) { 99 98 inputs[0] = inputIds; 100 99 } ··· 133 132 return Float32List.fromList(first); 134 133 } 135 134 136 - // [batch, seq, hidden] shape: mean-pool token embeddings. 137 135 if (first is List && first.isNotEmpty && first.first is List<double>) { 138 136 final tokenRows = first.cast<List<double>>(); 139 137 final hiddenSize = tokenRows.first.length;
+91 -11
lib/features/auth/data/auth_repository.dart
··· 68 68 static const String _fallbackService = 'bsky.social'; 69 69 static const String _mobileOAuthRedirectScheme = 'org.stormlightlabs.lazurite'; 70 70 static const String _mobileOAuthRedirectPath = '/oauth/callback'; 71 + static const String _httpsOAuthRedirectHost = 'lazurite.stormlightlabs.org'; 72 + static const String _httpsOAuthRedirectPath = '/oauth/callback'; 73 + static const bool _androidHttpsCallbackEnabled = bool.fromEnvironment( 74 + 'OAUTH_ANDROID_HTTPS_CALLBACK_ENABLED', 75 + defaultValue: true, 76 + ); 77 + static const bool _iosHttpsCallbackEnabled = bool.fromEnvironment( 78 + 'OAUTH_IOS_HTTPS_CALLBACK_ENABLED', 79 + defaultValue: true, 80 + ); 71 81 static final Uri _mobileOAuthRedirectUri = Uri.parse('$_mobileOAuthRedirectScheme:$_mobileOAuthRedirectPath'); 82 + static final Uri _httpsOAuthRedirectUri = Uri.https(_httpsOAuthRedirectHost, _httpsOAuthRedirectPath); 72 83 73 84 final AppDatabase _database; 74 85 final LaunchUrlWithMode _launchUrlWithMode; ··· 214 225 215 226 final metadata = await _loadClientMetadata(kClientId); 216 227 log.d('AuthRepository: Loaded client metadata with redirect URIs: ${metadata.redirectUris.join(', ')}'); 217 - final redirectUri = _selectOAuthRedirectUriTemplate(metadata.redirectUris); 218 - log.i('AuthRepository: Using custom-scheme OAuth callback redirect ${_sanitizeUriForLog(redirectUri)}'); 228 + final isAndroidNative = !kIsWeb && defaultTargetPlatform == TargetPlatform.android; 229 + final isIosNative = !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS; 230 + final redirectUri = _selectOAuthRedirectUriTemplate( 231 + metadata.redirectUris, 232 + isAndroid: isAndroidNative, 233 + httpsAndroidCallbackEnabled: _androidHttpsCallbackEnabled, 234 + isIos: isIosNative, 235 + httpsIosCallbackEnabled: _iosHttpsCallbackEnabled, 236 + ); 237 + log.d( 238 + 'AuthRepository: OAuth callback strategy ' 239 + 'androidNative=$isAndroidNative ' 240 + 'androidHttpsCallbackEnabled=$_androidHttpsCallbackEnabled ' 241 + 'iosNative=$isIosNative ' 242 + 'iosHttpsCallbackEnabled=$_iosHttpsCallbackEnabled', 243 + ); 244 + log.i('AuthRepository: Using OAuth callback redirect ${_sanitizeUriForLog(redirectUri)}'); 219 245 220 246 Object? lastAttemptError; 221 247 StackTrace? lastAttemptStackTrace; ··· 237 263 238 264 return await _oauthCompleter!.future.timeout( 239 265 const Duration(minutes: 3), 240 - onTimeout: () => throw TimeoutException('Timed out waiting for OAuth callback on custom scheme redirect'), 266 + onTimeout: () => throw TimeoutException('Timed out waiting for OAuth callback redirect'), 241 267 ); 242 268 } catch (error, stackTrace) { 243 269 lastAttemptError = error; ··· 793 819 return _oauthLaunchModeForPlatform(isWeb: isWeb, platform: platform); 794 820 } 795 821 822 + /// ATProto OAuth providers can enforce browser-like fetch metadata semantics. 823 + /// Prefer the system browser app on mobile for consistent behavior. 796 824 static LaunchMode _oauthLaunchModeForPlatform({required bool isWeb, required TargetPlatform platform}) { 797 825 if (isWeb) { 798 826 return LaunchMode.platformDefault; 799 827 } 800 828 801 829 return switch (platform) { 802 - // ATProto OAuth providers can enforce browser-like fetch metadata semantics 803 - // that are not always met by embedded WebViews. Prefer browser tab UX. 804 - TargetPlatform.android => LaunchMode.inAppBrowserView, 805 - TargetPlatform.iOS => LaunchMode.inAppBrowserView, 830 + TargetPlatform.android => LaunchMode.externalApplication, 831 + TargetPlatform.iOS => LaunchMode.externalApplication, 806 832 _ => LaunchMode.externalApplication, 807 833 }; 808 834 } ··· 817 843 return redirectUri.scheme == _mobileOAuthRedirectScheme && redirectUri.path == _mobileOAuthRedirectPath; 818 844 } 819 845 846 + bool _isSupportedHttpsRedirect(Uri redirectUri) { 847 + return redirectUri.scheme == 'https' && 848 + redirectUri.host == _httpsOAuthRedirectHost && 849 + redirectUri.path == _httpsOAuthRedirectPath; 850 + } 851 + 820 852 Uri? _normalizeOAuthCallbackUri(Uri callbackUri) { 821 853 if (_isSupportedCustomSchemeRedirect(callbackUri)) { 854 + return callbackUri; 855 + } 856 + 857 + if (_isSupportedHttpsRedirect(callbackUri)) { 822 858 return callbackUri; 823 859 } 824 860 ··· 834 870 return null; 835 871 } 836 872 837 - Uri _selectOAuthRedirectUriTemplate(List<String> redirectUris) { 873 + Uri _selectOAuthRedirectUriTemplate( 874 + List<String> redirectUris, { 875 + required bool isAndroid, 876 + required bool httpsAndroidCallbackEnabled, 877 + required bool isIos, 878 + required bool httpsIosCallbackEnabled, 879 + }) { 838 880 final candidates = redirectUris.map(Uri.parse).toList(growable: false); 839 881 if (candidates.isEmpty) { 840 882 throw UnsupportedError('OAuth client metadata does not declare any redirect URIs.'); 841 883 } 842 884 885 + Uri? customSchemeRedirect; 886 + Uri? httpsRedirect; 843 887 for (final candidate in candidates) { 844 888 if (_isSupportedCustomSchemeRedirect(candidate)) { 845 - return candidate; 889 + customSchemeRedirect ??= candidate; 890 + } 891 + if (_isSupportedHttpsRedirect(candidate)) { 892 + httpsRedirect ??= candidate; 846 893 } 847 894 } 848 895 896 + if (isAndroid && httpsAndroidCallbackEnabled && httpsRedirect != null) { 897 + return httpsRedirect; 898 + } 899 + if (isIos && httpsIosCallbackEnabled && httpsRedirect != null) { 900 + return httpsRedirect; 901 + } 902 + if (customSchemeRedirect != null) { 903 + return customSchemeRedirect; 904 + } 905 + if (httpsRedirect != null) { 906 + return httpsRedirect; 907 + } 908 + 849 909 throw UnsupportedError( 850 - 'No supported OAuth redirect URI found. Lazurite currently requires ' 851 - '${_mobileOAuthRedirectUri.toString()}.', 910 + 'No supported OAuth redirect URI found. Lazurite currently supports ' 911 + '${_mobileOAuthRedirectUri.toString()} and ${_httpsOAuthRedirectUri.toString()}.', 912 + ); 913 + } 914 + 915 + @visibleForTesting 916 + Uri? normalizeOAuthCallbackUriForTest(Uri callbackUri) => _normalizeOAuthCallbackUri(callbackUri); 917 + 918 + @visibleForTesting 919 + Uri selectOAuthRedirectUriTemplateForTest( 920 + List<String> redirectUris, { 921 + required bool isAndroid, 922 + required bool httpsAndroidCallbackEnabled, 923 + required bool isIos, 924 + required bool httpsIosCallbackEnabled, 925 + }) { 926 + return _selectOAuthRedirectUriTemplate( 927 + redirectUris, 928 + isAndroid: isAndroid, 929 + httpsAndroidCallbackEnabled: httpsAndroidCallbackEnabled, 930 + isIos: isIos, 931 + httpsIosCallbackEnabled: httpsIosCallbackEnabled, 852 932 ); 853 933 } 854 934
-1
lib/features/feed/data/liked_posts_repository.dart
··· 113 113 } 114 114 } 115 115 116 - // Deterministic fallback for malformed/missing timestamps. 117 116 return DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); 118 117 } 119 118
+3 -3
lib/features/notifications/background/notification_background_worker.dart
··· 66 66 class NotificationBackgroundScheduler { 67 67 NotificationBackgroundScheduler._(); 68 68 69 + /// iOS fetch/BGTask execution is system-managed. Workmanager's 70 + /// `registerPeriodicTask` channel method is Android-specific, 71 + /// so avoid calling it on iOS. 69 72 static Future<void> ensureScheduled() async { 70 73 if (Platform.isAndroid) { 71 74 await Workmanager().registerPeriodicTask( ··· 82 85 return; 83 86 } 84 87 85 - // iOS fetch/BGTask execution is system-managed. Workmanager's 86 - // `registerPeriodicTask` channel method is Android-specific, so avoid 87 - // calling it on iOS. 88 88 try { 89 89 await Workmanager().registerOneOffTask( 90 90 notificationReconcileUniqueName,
-1
lib/features/notifications/domain/notification_reason_utils.dart
··· 114 114 navigationMode: NotificationTapNavigationMode.push, 115 115 ); 116 116 } 117 - // Fallback to actor profile if the payload is missing starter pack context. 118 117 } 119 118 120 119 if (isProfileNavigationReason(notification.reason)) {
-1
lib/features/profile/data/follow_audit_repository.dart
··· 351 351 352 352 final sessionDid = _currentSessionDid(); 353 353 if (sessionDid == null) { 354 - // Test doubles and unauthenticated contexts may not expose session shape. 355 354 return; 356 355 } 357 356
-1
lib/features/profile/data/profile_context_repository.dart
··· 341 341 342 342 final sessionDid = _currentSessionDid(); 343 343 if (sessionDid == null) { 344 - // Test doubles and unauthenticated contexts may not expose session shape. 345 344 return; 346 345 } 347 346
+12 -11
lib/features/profile/data/profile_repository.dart
··· 76 76 rethrow; 77 77 } 78 78 79 - // Cache failures should not downgrade a fresh network response into stale fallback data. 80 79 unawaited(_cacheProfileSafely(profile)); 81 80 82 81 if (_moderationService?.shouldFilterProfileDetailedInView(profile) ?? false) { ··· 128 127 return suggestions.where((p) => !moderationService.shouldFilterProfileInList(p)).toList(); 129 128 } 130 129 130 + /// Likes transport matrix: 131 + /// - Self liked tab: app.bsky.feed.getActorLikes via viewer-auth context 132 + /// (PDS-routed, read-after-write behavior for the current account). 133 + /// - Non-self liked tab: actor repo scan on actor PDS via 134 + /// com.atproto.repo.listRecords(app.bsky.feed.like), then hydrate subjects 135 + /// on AppView via app.bsky.feed.getPosts. 136 + /// Never route non-self repo reads through the viewer PDS. 131 137 Future<ProfileActorLikesResult> getActorLikes({required String actor, String? cursor, int limit = 50}) async { 132 - // Likes transport matrix: 133 - // - Self liked tab: app.bsky.feed.getActorLikes via viewer-auth context 134 - // (PDS-routed, read-after-write behavior for the current account). 135 - // - Non-self liked tab: actor repo scan on actor PDS via 136 - // com.atproto.repo.listRecords(app.bsky.feed.like), then hydrate subjects 137 - // on AppView via app.bsky.feed.getPosts. 138 - // Never route non-self repo reads through the viewer PDS. 139 138 if (_isCurrentSessionActor(actor)) { 140 139 final headers = _appViewContext.appBskyHeadersWithoutProxy(await _moderationService?.headersForRequest()); 141 140 log.i( ··· 331 330 if (normalizedActor == sessionDid || normalizedActor == sessionHandle) { 332 331 return true; 333 332 } 334 - } catch (_) {} 333 + } catch (e) { 334 + log.d('ProfileRepository: Unable to parse current session actor', error: e); 335 + } 335 336 336 337 try { 337 338 final oauthSession = bluesky.oAuthSession; ··· 339 340 if (normalizedActor == oauthDid) { 340 341 return true; 341 342 } 342 - } catch (_) { 343 - // Ignore non-standard test doubles/wrappers missing OAuth shape. 343 + } catch (e) { 344 + log.d('ProfileRepository: Unable to parse current session actor', error: e); 344 345 } 345 346 return false; 346 347 }
+1 -1
lib/features/search/data/semantic_search_repository.dart
··· 74 74 if (postJson == null) { 75 75 continue; 76 76 } 77 - // FTS rank determines order; map position to a readable confidence range. 77 + 78 78 final score = (90.0 - (index * 2.5)).clamp(55.0, 95.0).toDouble(); 79 79 results.add(SemanticSearchResult(postUri: match.postUri, score: score, source: match.source, postJson: postJson)); 80 80 }
+2 -2
lib/shared/presentation/widgets/global_tap_outside_unfocus.dart
··· 9 9 10 10 final Widget child; 11 11 12 + /// On invoke, we preserve Flutter's default down-event behavior on touch so 13 + /// overlay interactions (like typeahead suggestion taps) are not interrupted. 12 14 @override 13 15 Widget build(BuildContext context) => Actions( 14 16 actions: <Type, Action<Intent>>{ 15 17 EditableTextTapOutsideIntent: CallbackAction<EditableTextTapOutsideIntent>( 16 18 onInvoke: (intent) { 17 - // Preserve Flutter's default down-event behavior on touch so overlay 18 - // interactions (like typeahead suggestion taps) are not interrupted. 19 19 if (intent.pointerDownEvent.kind != ui.PointerDeviceKind.touch) { 20 20 intent.focusNode.unfocus(); 21 21 }
+34
test/core/router/app_router_test.dart
··· 528 528 529 529 router.dispose(); 530 530 }); 531 + 532 + testWidgets('processes absolute HTTPS oauth callback route while authenticated', (tester) async { 533 + final router = AppRouter(authBloc: authBloc).router; 534 + final pendingCallback = Completer<bool>(); 535 + when(() => authBloc.handleOAuthRedirectUri(any())).thenAnswer((_) => pendingCallback.future); 536 + 537 + await tester.pumpWidget(buildSubjectWithRouter(router)); 538 + router.go('https://lazurite.stormlightlabs.org/oauth/callback?code=abc&state=xyz'); 539 + await tester.pump(); 540 + await tester.pump(const Duration(milliseconds: 100)); 541 + 542 + verify( 543 + () => authBloc.handleOAuthRedirectUri( 544 + any( 545 + that: predicate<Uri>( 546 + (uri) => 547 + uri.scheme == 'https' && 548 + uri.host == 'lazurite.stormlightlabs.org' && 549 + uri.path == OAuthCallbackScreen.routePath && 550 + uri.queryParameters['code'] == 'abc' && 551 + uri.queryParameters['state'] == 'xyz', 552 + ), 553 + ), 554 + ), 555 + ).called(1); 556 + 557 + pendingCallback.complete(true); 558 + await tester.pumpAndSettle(); 559 + 560 + expect(router.routeInformationProvider.value.uri.path, isNot(equals(OAuthCallbackScreen.routePath))); 561 + expect(find.text('No feeds pinned'), findsOneWidget); 562 + 563 + router.dispose(); 564 + }); 531 565 }
+145 -10
test/features/auth/data/auth_repository_test.dart
··· 353 353 database: mockDatabase, 354 354 resolveDidDocument: (_) async => { 355 355 'service': [ 356 - { 357 - 'id': '#atproto_pds', 358 - 'type': 'AtprotoPersonalDataServer', 359 - 'serviceEndpoint': 'https://pds.example', 360 - }, 356 + {'id': '#atproto_pds', 'type': 'AtprotoPersonalDataServer', 'serviceEndpoint': 'https://pds.example'}, 361 357 ], 362 358 }, 363 359 ); ··· 380 376 }); 381 377 }); 382 378 379 + group('oauth callback normalization', () { 380 + test('accepts canonical custom scheme callback URI', () { 381 + final normalized = authRepository.normalizeOAuthCallbackUriForTest( 382 + Uri.parse('org.stormlightlabs.lazurite:/oauth/callback?code=abc&state=xyz'), 383 + ); 384 + 385 + expect(normalized, isNotNull); 386 + expect(normalized!.scheme, equals('org.stormlightlabs.lazurite')); 387 + expect(normalized.path, equals('/oauth/callback')); 388 + }); 389 + 390 + test('normalizes path-only callback URI to canonical custom scheme', () { 391 + final normalized = authRepository.normalizeOAuthCallbackUriForTest( 392 + Uri.parse('/oauth/callback?code=abc&state=xyz'), 393 + ); 394 + 395 + expect(normalized, isNotNull); 396 + expect(normalized!.toString(), equals('org.stormlightlabs.lazurite:/oauth/callback?code=abc&state=xyz')); 397 + }); 398 + 399 + test('accepts exact HTTPS callback URI with oauth query parameters', () { 400 + final normalized = authRepository.normalizeOAuthCallbackUriForTest( 401 + Uri.parse( 402 + 'https://lazurite.stormlightlabs.org/oauth/callback?code=abc&state=xyz&iss=https%3A%2F%2Fbsky.social', 403 + ), 404 + ); 405 + 406 + expect(normalized, isNotNull); 407 + expect(normalized!.scheme, equals('https')); 408 + expect(normalized.host, equals('lazurite.stormlightlabs.org')); 409 + expect(normalized.path, equals('/oauth/callback')); 410 + expect(normalized.queryParameters['code'], equals('abc')); 411 + expect(normalized.queryParameters['state'], equals('xyz')); 412 + }); 413 + 414 + test('rejects HTTPS callback URI with unexpected host', () { 415 + final normalized = authRepository.normalizeOAuthCallbackUriForTest( 416 + Uri.parse('https://example.com/oauth/callback?code=abc&state=xyz'), 417 + ); 418 + 419 + expect(normalized, isNull); 420 + }); 421 + 422 + test('rejects HTTPS callback URI with unexpected path', () { 423 + final normalized = authRepository.normalizeOAuthCallbackUriForTest( 424 + Uri.parse('https://lazurite.stormlightlabs.org/callback?code=abc&state=xyz'), 425 + ); 426 + 427 + expect(normalized, isNull); 428 + }); 429 + }); 430 + 431 + group('oauth redirect URI selection', () { 432 + test('prefers HTTPS callback on Android when flag is enabled', () { 433 + final selected = authRepository.selectOAuthRedirectUriTemplateForTest( 434 + const ['org.stormlightlabs.lazurite:/oauth/callback', 'https://lazurite.stormlightlabs.org/oauth/callback'], 435 + isAndroid: true, 436 + httpsAndroidCallbackEnabled: true, 437 + isIos: false, 438 + httpsIosCallbackEnabled: true, 439 + ); 440 + 441 + expect(selected.toString(), equals('https://lazurite.stormlightlabs.org/oauth/callback')); 442 + }); 443 + 444 + test('uses custom scheme callback on Android when HTTPS flag is disabled', () { 445 + final selected = authRepository.selectOAuthRedirectUriTemplateForTest( 446 + const ['org.stormlightlabs.lazurite:/oauth/callback', 'https://lazurite.stormlightlabs.org/oauth/callback'], 447 + isAndroid: true, 448 + httpsAndroidCallbackEnabled: false, 449 + isIos: false, 450 + httpsIosCallbackEnabled: true, 451 + ); 452 + 453 + expect(selected.toString(), equals('org.stormlightlabs.lazurite:/oauth/callback')); 454 + }); 455 + 456 + test('uses custom scheme callback when HTTPS callback is unavailable', () { 457 + final selected = authRepository.selectOAuthRedirectUriTemplateForTest( 458 + const ['org.stormlightlabs.lazurite:/oauth/callback'], 459 + isAndroid: true, 460 + httpsAndroidCallbackEnabled: true, 461 + isIos: false, 462 + httpsIosCallbackEnabled: true, 463 + ); 464 + 465 + expect(selected.toString(), equals('org.stormlightlabs.lazurite:/oauth/callback')); 466 + }); 467 + 468 + test('uses HTTPS callback when custom scheme callback is unavailable', () { 469 + final selected = authRepository.selectOAuthRedirectUriTemplateForTest( 470 + const ['https://lazurite.stormlightlabs.org/oauth/callback'], 471 + isAndroid: true, 472 + httpsAndroidCallbackEnabled: true, 473 + isIos: false, 474 + httpsIosCallbackEnabled: true, 475 + ); 476 + 477 + expect(selected.toString(), equals('https://lazurite.stormlightlabs.org/oauth/callback')); 478 + }); 479 + 480 + test('prefers HTTPS callback on iOS when flag is enabled', () { 481 + final selected = authRepository.selectOAuthRedirectUriTemplateForTest( 482 + const ['org.stormlightlabs.lazurite:/oauth/callback', 'https://lazurite.stormlightlabs.org/oauth/callback'], 483 + isAndroid: false, 484 + httpsAndroidCallbackEnabled: true, 485 + isIos: true, 486 + httpsIosCallbackEnabled: true, 487 + ); 488 + 489 + expect(selected.toString(), equals('https://lazurite.stormlightlabs.org/oauth/callback')); 490 + }); 491 + 492 + test('uses custom scheme callback on iOS when HTTPS flag is disabled', () { 493 + final selected = authRepository.selectOAuthRedirectUriTemplateForTest( 494 + const ['org.stormlightlabs.lazurite:/oauth/callback', 'https://lazurite.stormlightlabs.org/oauth/callback'], 495 + isAndroid: false, 496 + httpsAndroidCallbackEnabled: true, 497 + isIos: true, 498 + httpsIosCallbackEnabled: false, 499 + ); 500 + 501 + expect(selected.toString(), equals('org.stormlightlabs.lazurite:/oauth/callback')); 502 + }); 503 + 504 + test('throws when no supported callback URI is present', () { 505 + expect( 506 + () => authRepository.selectOAuthRedirectUriTemplateForTest( 507 + const ['https://example.com/oauth/callback'], 508 + isAndroid: true, 509 + httpsAndroidCallbackEnabled: true, 510 + isIos: false, 511 + httpsIosCallbackEnabled: true, 512 + ), 513 + throwsA(isA<UnsupportedError>()), 514 + ); 515 + }); 516 + }); 517 + 383 518 group('clearSession', () { 384 519 test('should delete all accounts', () async { 385 520 when(() => mockDatabase.deleteAllAccounts()).thenAnswer((_) async => 1); ··· 407 542 }); 408 543 409 544 group('oauth browser launch mode', () { 410 - test('uses in-app browser view on iOS', () { 545 + test('uses external application on iOS', () { 411 546 expect( 412 547 AuthRepository.oauthLaunchModeForTest(isWeb: false, platform: TargetPlatform.iOS), 413 - equals(LaunchMode.inAppBrowserView), 548 + equals(LaunchMode.externalApplication), 414 549 ); 415 550 }); 416 551 417 - test('uses in-app browser view on Android', () { 552 + test('uses external application on Android', () { 418 553 expect( 419 554 AuthRepository.oauthLaunchModeForTest(isWeb: false, platform: TargetPlatform.android), 420 - equals(LaunchMode.inAppBrowserView), 555 + equals(LaunchMode.externalApplication), 421 556 ); 422 557 }); 423 558 ··· 507 642 applicationType: 'native', 508 643 clientName: 'Lazurite Test', 509 644 clientUri: 'https://lazurite.stormlightlabs.org', 510 - redirectUris: ['org.stormlightlabs.lazurite:/oauth/callback'], 645 + redirectUris: ['https://lazurite.stormlightlabs.org/oauth/callback', 'org.stormlightlabs.lazurite:/oauth/callback'], 511 646 responseTypes: ['code'], 512 647 grantTypes: ['authorization_code', 'refresh_token'], 513 648 scope: 'atproto',
+1 -2
test/features/profile/data/profile_repository_test.dart
··· 1 1 import 'dart:convert'; 2 2 3 - import 'package:drift/native.dart'; 4 3 import 'package:bluesky/app_bsky_actor_defs.dart'; 4 + import 'package:drift/native.dart'; 5 5 import 'package:flutter_test/flutter_test.dart'; 6 6 import 'package:lazurite/core/database/app_database.dart'; 7 7 import 'package:lazurite/features/profile/data/profile_repository.dart'; ··· 107 107 bluesky: _FakeBlueskyClient(actor: _FakeActorService(onGetProfile: (_) async => _FakeResponse(profile))), 108 108 ); 109 109 110 - // Force cache operations to fail while keeping network response successful. 111 110 await database.close(); 112 111 113 112 final result = await repository.getProfile(profile.did);
+14
www/.well-known/apple-app-site-association
··· 1 + { 2 + "applinks": { 3 + "apps": [], 4 + "details": [ 5 + { 6 + "appID": "8TVR4TPL9Y.org.stormlightlabs.lazurite", 7 + "paths": [ 8 + "/oauth/callback", 9 + "/oauth/callback/*" 10 + ] 11 + } 12 + ] 13 + } 14 + }
+13
www/.well-known/assetlinks.json
··· 1 + [ 2 + { 3 + "relation": ["delegate_permission/common.handle_all_urls"], 4 + "target": { 5 + "namespace": "android_app", 6 + "package_name": "org.stormlightlabs.lazurite", 7 + "sha256_cert_fingerprints": [ 8 + "63:86:09:5D:D0:5A:ED:B8:65:7B:CF:C1:ED:C1:1C:0E:A6:FA:F4:89:04:71:1B:63:C4:1F:8D:37:B8:D4:0E:0E", 9 + "25:25:9E:B8:32:9B:D2:B7:58:8F:53:07:AD:5C:D8:75:57:55:A3:59:52:E9:9A:CC:37:8F:E2:BE:2E:56:CB:61" 10 + ] 11 + } 12 + } 13 + ]
+2
www/_headers
··· 1 + /.well-known/apple-app-site-association 2 + Content-Type: application/json; charset=utf-8
+15 -12
www/client-metadata.json
··· 1 1 { 2 - "client_id": "https://lazurite.stormlightlabs.org/client-metadata.json", 3 - "client_name": "Lazurite", 4 - "client_uri": "https://lazurite.stormlightlabs.org", 5 - "redirect_uris": ["org.stormlightlabs.lazurite:/oauth/callback"], 6 - "scope": "atproto transition:generic transition:chat.bsky", 7 - "grant_types": ["authorization_code", "refresh_token"], 8 - "response_types": ["code"], 9 - "token_endpoint_auth_method": "none", 10 - "application_type": "native", 11 - "dpop_bound_access_tokens": true, 12 - "software_id": "org.stormlightlabs.lazurite", 13 - "software_version": "1.0.0" 2 + "client_id": "https://lazurite.stormlightlabs.org/client-metadata.json", 3 + "client_name": "Lazurite", 4 + "client_uri": "https://lazurite.stormlightlabs.org", 5 + "redirect_uris": [ 6 + "https://lazurite.stormlightlabs.org/oauth/callback", 7 + "org.stormlightlabs.lazurite:/oauth/callback" 8 + ], 9 + "scope": "atproto transition:generic transition:chat.bsky", 10 + "grant_types": ["authorization_code", "refresh_token"], 11 + "response_types": ["code"], 12 + "token_endpoint_auth_method": "none", 13 + "application_type": "native", 14 + "dpop_bound_access_tokens": true, 15 + "software_id": "org.stormlightlabs.lazurite", 16 + "software_version": "1.0.0" 14 17 }
+2 -210
www/csae-policy.html
··· 9 9 <meta name="author" content="Stormlight Labs" /> 10 10 <title>Lazurite CSAE Policy</title> 11 11 <link rel="icon" type="image/svg+xml" href="static/favicon.svg" /> 12 + <link rel="stylesheet" href="/static/shared.css" /> 12 13 <link rel="preconnect" href="https://fonts.googleapis.com" /> 13 14 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> 14 15 <link 15 16 href="https://fonts.googleapis.com/css2?family=Google+Sans+Code:ital,wght@0,300..800;1,300..800&family=Google+Sans:ital,opsz,wght@0,17..18,400..700;1,17..18,400..700&family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Lora:ital,wght@0,400..700;1,400..700&display=swap" 16 17 rel="stylesheet" /> 17 - <style> 18 - :root { 19 - --bg-dark: #000000; 20 - --surface-dark: #191919; 21 - --surface-variant: #1f1f1f; 22 - --outline: rgba(255, 255, 255, 0.1); 23 - --text-primary: #f4f6fb; 24 - --text-secondary: #ababab; 25 - --text-tertiary: #6f6f6f; 26 - --primary: #7dafff; 27 - --secondary: #0073de; 28 - --tertiary: #33b1ff; 29 - --danger: #ff8080; 30 - --font-title: "Lora", serif; 31 - --font-display: "Google Sans", sans-serif; 32 - --font-body: "Inter", sans-serif; 33 - } 34 - 35 - * { 36 - margin: 0; 37 - padding: 0; 38 - box-sizing: border-box; 39 - } 40 - 41 - body { 42 - font-family: var(--font-body); 43 - background-color: var(--bg-dark); 44 - color: var(--text-primary); 45 - line-height: 1.6; 46 - min-height: 100vh; 47 - display: flex; 48 - flex-direction: column; 49 - } 50 - 51 - .container { 52 - max-width: 920px; 53 - margin: 0 auto; 54 - padding: 2rem; 55 - flex: 1; 56 - } 57 - 58 - .top-nav { 59 - display: flex; 60 - justify-content: flex-start; 61 - margin-bottom: 2rem; 62 - } 63 - 64 - .top-nav a { 65 - color: var(--primary); 66 - text-decoration: none; 67 - font-size: 0.875rem; 68 - } 69 - 70 - .top-nav a:hover { 71 - color: var(--secondary); 72 - } 73 - 74 - .logo { 75 - font-family: var(--font-title); 76 - font-size: 2.4rem; 77 - font-weight: 700; 78 - background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 50%, var(--tertiary) 100%); 79 - -webkit-background-clip: text; 80 - -webkit-text-fill-color: transparent; 81 - background-clip: text; 82 - letter-spacing: -0.02em; 83 - display: inline-flex; 84 - align-items: center; 85 - gap: 0.5rem; 86 - } 87 - 88 - .logo-icon { 89 - width: 2rem; 90 - height: 2rem; 91 - mask-image: url("./static/logo.svg"); 92 - background-color: var(--primary); 93 - mask-repeat: no-repeat; 94 - mask-size: contain; 95 - mask-position: center; 96 - -webkit-mask-image: url("./static/logo.svg"); 97 - -webkit-mask-repeat: no-repeat; 98 - -webkit-mask-size: contain; 99 - -webkit-mask-position: center; 100 - } 101 - 102 - .legal-card { 103 - margin-top: 1.25rem; 104 - background: var(--surface-dark); 105 - border: 1px solid var(--outline); 106 - border-radius: 1rem; 107 - padding: 2rem; 108 - } 109 - 110 - h1 { 111 - font-family: var(--font-display); 112 - font-size: 2rem; 113 - line-height: 1.2; 114 - margin-bottom: 0.4rem; 115 - } 116 - 117 - .meta { 118 - font-size: 0.875rem; 119 - color: var(--text-tertiary); 120 - margin-bottom: 1.5rem; 121 - } 122 - 123 - .lead { 124 - color: var(--text-secondary); 125 - margin-bottom: 1.25rem; 126 - } 127 - 128 - .alert { 129 - margin-bottom: 1.5rem; 130 - background: rgba(255, 128, 128, 0.12); 131 - border: 1px solid rgba(255, 128, 128, 0.35); 132 - border-radius: 0.75rem; 133 - padding: 0.85rem 1rem; 134 - color: #ffd4d4; 135 - font-size: 0.95rem; 136 - } 137 - 138 - section { 139 - margin-bottom: 1.25rem; 140 - } 141 - 142 - section:last-of-type { 143 - margin-bottom: 0; 144 - } 145 - 146 - h2 { 147 - font-family: var(--font-display); 148 - font-size: 1.0625rem; 149 - color: var(--primary); 150 - margin-bottom: 0.45rem; 151 - } 152 - 153 - p { 154 - color: var(--text-secondary); 155 - font-size: 0.95rem; 156 - margin-bottom: 0.5rem; 157 - } 158 - 159 - ul { 160 - margin: 0.25rem 0 0.75rem 1.2rem; 161 - color: var(--text-secondary); 162 - font-size: 0.95rem; 163 - } 164 - 165 - li { 166 - margin-bottom: 0.35rem; 167 - } 168 - 169 - a { 170 - color: var(--primary); 171 - text-decoration: none; 172 - } 173 - 174 - a:hover { 175 - color: var(--secondary); 176 - } 177 - 178 - .links { 179 - margin-top: 1.25rem; 180 - background: var(--surface-variant); 181 - border: 1px solid var(--outline); 182 - border-radius: 0.75rem; 183 - padding: 1rem 1.2rem; 184 - } 185 - 186 - .links p { 187 - margin-bottom: 0.35rem; 188 - } 189 - 190 - .links p:last-child { 191 - margin-bottom: 0; 192 - } 193 - 194 - footer { 195 - text-align: center; 196 - padding: 2rem; 197 - color: var(--text-tertiary); 198 - font-size: 0.875rem; 199 - border-top: 1px solid var(--outline); 200 - margin-top: 2rem; 201 - } 202 - 203 - footer a { 204 - color: var(--primary); 205 - text-decoration: none; 206 - } 207 - 208 - footer a:hover { 209 - color: var(--secondary); 210 - } 211 - 212 - @media (max-width: 768px) { 213 - .container { 214 - padding: 1.25rem; 215 - } 216 - 217 - .legal-card { 218 - padding: 1.25rem; 219 - } 220 - 221 - h1 { 222 - font-size: 1.6rem; 223 - } 224 - } 225 - </style> 226 18 </head> 227 - <body> 19 + <body class="legal-page csae-page"> 228 20 <div class="container"> 229 21 <nav class="top-nav"><a href="./index.html">Back to home</a></nav> 230 22 <div class="logo"><span class="logo-icon"></span>Lazurite</div>
+6 -532
www/index.html
··· 8 8 content="Lazurite - A beautiful Bluesky client for mobile and desktop. Material You on iOS & Android, native desktop with semantic search." /> 9 9 <meta name="author" content="Stormlight Labs" /> 10 10 <title>Lazurite for BlueSky</title> 11 - <link rel="icon" type="image/svg+xml" href="static/favicon.svg" /> 11 + <link rel="icon" type="image/svg+xml" href="/static/favicon.svg" /> 12 + <link rel="stylesheet" href="/static/shared.css" /> 12 13 <link rel="preconnect" href="https://fonts.googleapis.com" /> 13 14 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> 14 15 <link 15 16 href="https://fonts.googleapis.com/css2?family=Google+Sans+Code:ital,wght@0,300..800;1,300..800&family=Google+Sans:ital,opsz,wght@0,17..18,400..700;1,17..18,400..700&family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Lora:ital,wght@0,400..700;1,400..700&display=swap" 16 17 rel="stylesheet" /> 17 - 18 - <style> 19 - :root { 20 - /* Matches LazuriteTheme.dark() from lazurite_theme.dart */ 21 - --bg-dark: #000000; /* darkSurfaceContainerLowest (scaffold) */ 22 - --surface-dark: #191919; /* darkSurfaceContainer */ 23 - --surface-variant: #1f1f1f; /* darkSurfaceContainerHigh */ 24 - --outline: rgba(255, 255, 255, 0.1); /* darkOutlineVariant */ 25 - --outline-bright: rgba(255, 255, 255, 0.2); /* darkOutline */ 26 - --text-primary: #f4f6fb; /* darkOnSurface / darkOnPrimaryContainer */ 27 - --text-secondary: #ababab; /* darkOnSurfaceVariant */ 28 - --text-tertiary: #6f6f6f; 29 - 30 - --primary: #7dafff; /* darkPrimary */ 31 - --secondary: #0073de; /* darkPrimaryContainer */ 32 - --tertiary: #33b1ff; 33 - --cyan: #08bdba; 34 - --purple: #be95ff; 35 - --error: #ff8080; /* darkError */ 36 - 37 - --font-title: "Lora", serif; 38 - --font-display: "Google Sans", sans-serif; 39 - --font-body: "Inter", sans-serif; 40 - --font-mono: "Google Sans Code", monospace; 41 - } 42 - 43 - * { 44 - margin: 0; 45 - padding: 0; 46 - box-sizing: border-box; 47 - } 48 - 49 - body { 50 - font-family: var(--font-body); 51 - background-color: var(--bg-dark); 52 - color: var(--text-primary); 53 - line-height: 1.6; 54 - min-height: 100vh; 55 - display: flex; 56 - flex-direction: column; 57 - } 58 - 59 - .container { 60 - max-width: 1200px; 61 - margin: 0 auto; 62 - padding: 2rem; 63 - flex: 1; 64 - display: flex; 65 - flex-direction: column; 66 - justify-content: center; 67 - } 68 - 69 - header { 70 - text-align: center; 71 - margin-bottom: 4rem; 72 - } 73 - 74 - .logo { 75 - font-family: var(--font-title); 76 - font-size: 3.5rem; 77 - font-weight: 700; 78 - background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 50%, var(--tertiary) 100%); 79 - -webkit-background-clip: text; 80 - -webkit-text-fill-color: transparent; 81 - background-clip: text; 82 - margin-bottom: 1rem; 83 - letter-spacing: -0.02em; 84 - display: inline-flex; 85 - align-items: center; 86 - gap: 0.5rem; 87 - } 88 - 89 - .logo-icon { 90 - width: 3rem; 91 - height: 3rem; 92 - mask-image: url('./static/logo.svg'); 93 - background-color: var(--primary); 94 - mask-repeat: no-repeat; 95 - mask-size: contain; 96 - mask-position: center; 97 - 98 - -webkit-mask-image: url('./static/logo.svg'); 99 - -webkit-mask-repeat: no-repeat; 100 - -webkit-mask-size: contain; 101 - -webkit-mask-position: center; 102 - } 103 - 104 - .tagline { 105 - font-size: 1.25rem; 106 - color: var(--text-secondary); 107 - font-weight: 400; 108 - letter-spacing: 0.01em; 109 - } 110 - 111 - main { 112 - max-width: 900px; 113 - margin: 0 auto; 114 - } 115 - 116 - .hero { 117 - text-align: center; 118 - margin-bottom: 4rem; 119 - } 120 - 121 - .hero h1 { 122 - font-family: var(--font-display); 123 - font-size: 2.5rem; 124 - font-weight: 600; 125 - margin-bottom: 1.5rem; 126 - color: var(--text-primary); 127 - line-height: 1.2; 128 - } 129 - 130 - .hero p { 131 - font-size: 1.125rem; 132 - color: var(--text-secondary); 133 - margin-bottom: 2rem; 134 - line-height: 1.8; 135 - } 136 - 137 - .status-badge { 138 - display: inline-flex; 139 - align-items: center; 140 - gap: 0.5rem; 141 - background: var(--surface-dark); 142 - padding: 0.75rem 1.5rem; 143 - border-radius: 2rem; 144 - border: 1px solid var(--outline); 145 - font-size: 0.875rem; 146 - font-weight: 500; 147 - letter-spacing: 0.02em; 148 - margin-bottom: 3rem; 149 - } 150 - 151 - .status-dot { 152 - width: 8px; 153 - height: 8px; 154 - border-radius: 50%; 155 - background: var(--primary); 156 - animation: pulse 2s ease-in-out infinite; 157 - } 158 - 159 - @keyframes pulse { 160 - 0%, 161 - 100% { 162 - opacity: 1; 163 - } 164 - 165 - 50% { 166 - opacity: 0.5; 167 - } 168 - } 169 - 170 - /* Platform sections */ 171 - .platforms { 172 - display: grid; 173 - grid-template-columns: 1fr 1fr; 174 - gap: 2rem; 175 - margin-bottom: 4rem; 176 - } 177 - 178 - .platform { 179 - background: var(--surface-dark); 180 - padding: 2rem; 181 - border-radius: 1rem; 182 - border: 1px solid var(--outline); 183 - } 184 - 185 - .platform-header { 186 - display: flex; 187 - align-items: center; 188 - gap: 0.75rem; 189 - margin-bottom: 1.25rem; 190 - } 191 - 192 - .platform-icon { 193 - width: 40px; 194 - height: 40px; 195 - border-radius: 10px; 196 - display: flex; 197 - align-items: center; 198 - justify-content: center; 199 - flex-shrink: 0; 200 - } 201 - 202 - .platform-icon.mobile { 203 - background: linear-gradient(135deg, var(--primary), var(--secondary)); 204 - } 205 - 206 - .platform-icon.desktop { 207 - background: linear-gradient(135deg, var(--purple), var(--tertiary)); 208 - } 209 - 210 - .platform-icon img { 211 - width: 22px; 212 - height: 22px; 213 - } 214 - 215 - .platform h2 { 216 - font-family: var(--font-display); 217 - font-size: 1.375rem; 218 - font-weight: 600; 219 - } 220 - 221 - .platform-badge { 222 - font-size: 0.6875rem; 223 - font-weight: 600; 224 - text-transform: uppercase; 225 - letter-spacing: 0.06em; 226 - padding: 0.2rem 0.5rem; 227 - border-radius: 0.25rem; 228 - margin-left: auto; 229 - } 230 - 231 - .platform-badge.alpha { 232 - background: rgba(190, 149, 255, 0.15); 233 - color: var(--purple); 234 - } 235 - 236 - .platform-badge.beta { 237 - background: rgba(125, 175, 255, 0.15); 238 - color: var(--primary); 239 - } 240 - 241 - .platform-desc { 242 - color: var(--text-secondary); 243 - font-size: 0.9375rem; 244 - line-height: 1.6; 245 - margin-bottom: 1.25rem; 246 - } 247 - 248 - .platform-targets { 249 - display: flex; 250 - gap: 0.5rem; 251 - flex-wrap: wrap; 252 - margin-bottom: 1.25rem; 253 - } 254 - 255 - .target-tag { 256 - background: var(--surface-variant); 257 - padding: 0.3rem 0.625rem; 258 - border-radius: 0.375rem; 259 - font-size: 0.8125rem; 260 - font-family: var(--font-mono); 261 - color: var(--text-secondary); 262 - } 263 - 264 - .platform-features { 265 - list-style: none; 266 - } 267 - 268 - .platform-features li { 269 - color: var(--text-secondary); 270 - font-size: 0.875rem; 271 - padding: 0.3rem 0; 272 - padding-left: 1.25rem; 273 - position: relative; 274 - } 275 - 276 - .platform-features li::before { 277 - content: ""; 278 - position: absolute; 279 - left: 0; 280 - top: 0.7rem; 281 - width: 6px; 282 - height: 6px; 283 - border-radius: 50%; 284 - background: var(--primary); 285 - opacity: 0.6; 286 - } 287 - 288 - .platform-screenshot { 289 - margin-top: 1.5rem; 290 - border-radius: 0.75rem; 291 - overflow: hidden; 292 - border: 1px solid var(--outline); 293 - } 294 - 295 - .platform-screenshot img { 296 - width: 100%; 297 - height: auto; 298 - display: block; 299 - } 300 - 301 - .platform-screenshot .caption { 302 - font-size: 0.75rem; 303 - color: var(--text-tertiary); 304 - text-align: center; 305 - padding: 0.5rem; 306 - background: var(--surface-variant); 307 - } 308 - 309 - /* Hero screenshots */ 310 - .hero-screenshots { 311 - display: flex; 312 - gap: 1.5rem; 313 - justify-content: center; 314 - align-items: flex-end; 315 - margin-bottom: 3rem; 316 - padding: 0 1rem; 317 - } 318 - 319 - .hero-screenshot { 320 - border-radius: 1rem; 321 - overflow: hidden; 322 - border: 1px solid var(--outline); 323 - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); 324 - } 325 - 326 - .hero-screenshot img { 327 - display: block; 328 - width: 100%; 329 - height: auto; 330 - } 331 - 332 - .hero-screenshot.phone { 333 - width: 200px; 334 - flex-shrink: 0; 335 - } 336 - 337 - .hero-screenshot.desktop-shot { 338 - width: 640px; 339 - flex-shrink: 0; 340 - } 341 - 342 - @media (max-width: 768px) { 343 - .hero-screenshots { 344 - flex-direction: column; 345 - align-items: center; 346 - } 347 - 348 - .hero-screenshot.phone { 349 - width: 160px; 350 - } 351 - 352 - .hero-screenshot.desktop-shot { 353 - width: 100%; 354 - max-width: 360px; 355 - } 356 - } 357 - 358 - /* Features grid */ 359 - .section-title { 360 - font-family: var(--font-display); 361 - font-size: 1.5rem; 362 - font-weight: 600; 363 - margin-bottom: 1.5rem; 364 - color: var(--text-primary); 365 - } 366 - 367 - .features { 368 - display: grid; 369 - grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); 370 - gap: 1.5rem; 371 - margin-bottom: 4rem; 372 - } 373 - 374 - .feature { 375 - background: var(--surface-dark); 376 - padding: 1.75rem; 377 - border-radius: 1rem; 378 - border: 1px solid var(--outline); 379 - transition: 380 - transform 0.2s ease, 381 - border-color 0.2s ease; 382 - } 383 - 384 - .feature:hover { 385 - transform: translateY(-2px); 386 - border-color: var(--outline-bright); 387 - } 388 - 389 - .feature h3 { 390 - font-family: var(--font-display); 391 - font-size: 1.125rem; 392 - margin-bottom: 0.5rem; 393 - color: var(--text-primary); 394 - } 395 - 396 - .feature p { 397 - color: var(--text-secondary); 398 - font-size: 0.875rem; 399 - line-height: 1.6; 400 - } 401 - 402 - .feature-icon { 403 - width: 44px; 404 - height: 44px; 405 - background: linear-gradient(135deg, var(--primary), var(--secondary)); 406 - border-radius: 10px; 407 - display: flex; 408 - align-items: center; 409 - justify-content: center; 410 - margin-bottom: 0.875rem; 411 - padding: 10px; 412 - } 413 - 414 - .feature-icon.alt { 415 - background: linear-gradient(135deg, var(--purple), var(--tertiary)); 416 - } 417 - 418 - .feature-icon img { 419 - width: 100%; 420 - height: 100%; 421 - } 422 - 423 - /* Tech stack */ 424 - .tech-stack { 425 - background: var(--surface-dark); 426 - padding: 2rem; 427 - border-radius: 1rem; 428 - border: 1px solid var(--outline); 429 - margin-bottom: 4rem; 430 - } 431 - 432 - .tech-stack h2 { 433 - font-family: var(--font-display); 434 - font-size: 1.5rem; 435 - margin-bottom: 1.5rem; 436 - color: var(--text-primary); 437 - } 438 - 439 - .tech-group { 440 - margin-bottom: 1rem; 441 - } 442 - 443 - .tech-group:last-child { 444 - margin-bottom: 0; 445 - } 446 - 447 - .tech-group-label { 448 - font-size: 0.75rem; 449 - font-weight: 600; 450 - text-transform: uppercase; 451 - letter-spacing: 0.08em; 452 - color: var(--text-tertiary); 453 - margin-bottom: 0.5rem; 454 - } 455 - 456 - .tech-list { 457 - display: flex; 458 - flex-wrap: wrap; 459 - gap: 0.5rem; 460 - } 461 - 462 - .tech-tag { 463 - background: var(--surface-variant); 464 - padding: 0.4rem 0.875rem; 465 - border-radius: 0.5rem; 466 - font-size: 0.8125rem; 467 - font-family: var(--font-mono); 468 - color: var(--text-secondary); 469 - border: 1px solid transparent; 470 - transition: border-color 0.2s ease; 471 - } 472 - 473 - .tech-tag:hover { 474 - border-color: var(--outline-bright); 475 - } 476 - 477 - .oauth-info { 478 - background: var(--surface-variant); 479 - padding: 1rem 1.5rem; 480 - border-radius: 0.75rem; 481 - border-left: 3px solid var(--primary); 482 - margin-top: 2rem; 483 - font-size: 0.875rem; 484 - color: var(--text-secondary); 485 - } 486 - 487 - .oauth-info a { 488 - color: var(--primary); 489 - text-decoration: none; 490 - font-weight: 500; 491 - transition: color 0.2s ease; 492 - } 493 - 494 - .oauth-info a:hover { 495 - color: var(--secondary); 496 - text-decoration: underline; 497 - } 498 - 499 - footer { 500 - text-align: center; 501 - padding: 2rem; 502 - color: var(--text-tertiary); 503 - font-size: 0.875rem; 504 - border-top: 1px solid var(--outline); 505 - margin-top: 4rem; 506 - } 507 - 508 - footer a { 509 - color: var(--primary); 510 - text-decoration: none; 511 - transition: color 0.2s ease; 512 - } 513 - 514 - footer a:hover { 515 - color: var(--secondary); 516 - } 517 - 518 - @media (max-width: 768px) { 519 - .logo { 520 - font-size: 2.5rem; 521 - } 522 - 523 - .hero h1 { 524 - font-size: 2rem; 525 - } 526 - 527 - .hero p { 528 - font-size: 1rem; 529 - } 530 - 531 - .platforms { 532 - grid-template-columns: 1fr; 533 - } 534 - 535 - .features { 536 - grid-template-columns: 1fr; 537 - } 538 - 539 - .container { 540 - padding: 1.5rem; 541 - } 542 - } 543 - </style> 544 18 </head> 545 19 546 - <body> 20 + <body class="home-page"> 547 21 <div class="container"> 548 22 <header> 549 23 <div class="logo"><span class="logo-icon"></span>Lazurite</div> 550 - <p class="tagline">A better Bluesky client</p> 24 + <p class="tagline">The ATmosphere client that <em>rocks</em>.</p> 551 25 </header> 552 26 553 27 <main> ··· 557 31 <span>In Active Development</span> 558 32 </div> 559 33 560 - <h1>Bluesky, everywhere you are</h1> 34 + <h1>Roam the ATmosphere</h1> 561 35 <p> 562 - Lazurite is a native Bluesky client built for people who want more from their social experience. 36 + Lazurite is a client for Bluesky & BlackSky built for people who want more from their social experience. 563 37 A full-featured mobile app for iOS and Android, paired with a powerful desktop companion 564 38 for macOS, Windows, and Linux. 565 39 </p>
+57
www/oauth/callback/index.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 + <title>Lazurite Authentication Complete</title> 7 + <link rel="preconnect" href="https://fonts.googleapis.com" /> 8 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> 9 + <link 10 + href="https://fonts.googleapis.com/css2?family=Google+Sans+Code:ital,wght@0,300..800;1,300..800&family=Google+Sans:ital,opsz,wght@0,17..18,400..700;1,17..18,400..700&family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Lora:ital,wght@0,400..700;1,400..700&display=swap" 11 + rel="stylesheet" /> 12 + <link rel="stylesheet" href="/static/shared.css" /> 13 + </head> 14 + <body class="legal-page oauth-callback-page"> 15 + <div class="container oauth-callback-container"> 16 + <main class="legal-card oauth-callback-main"> 17 + <h1>Authentication Complete</h1> 18 + <p class="lead oauth-callback-copy">Lazurite is finishing sign-in. If the app does not reopen automatically, tap below.</p> 19 + <a id="reopen-link" class="oauth-callback-button" href="#">Open Lazurite</a> 20 + <p class="meta oauth-callback-hint">If this still fails, use your browser menu and choose “Open in app”.</p> 21 + </main> 22 + </div> 23 + <iframe 24 + id="reopen-frame" 25 + title="Return to Lazurite" 26 + style="display: none; width: 0; height: 0; border: 0" 27 + ></iframe> 28 + <script> 29 + const query = window.location.search || ''; 30 + const fragment = window.location.hash || ''; 31 + const reopenUrl = `org.stormlightlabs.lazurite:/oauth/callback${query}${fragment}`; 32 + const reopenLink = document.getElementById('reopen-link'); 33 + const reopenFrame = document.getElementById('reopen-frame'); 34 + 35 + reopenLink.setAttribute('href', reopenUrl); 36 + 37 + let attemptCount = 0; 38 + function attemptReopen() { 39 + attemptCount += 1; 40 + if (reopenFrame) { 41 + reopenFrame.src = reopenUrl; 42 + } 43 + if (attemptCount === 1) { 44 + window.location.assign(reopenUrl); 45 + return; 46 + } 47 + window.location.href = reopenUrl; 48 + } 49 + 50 + window.addEventListener('load', function () { 51 + window.setTimeout(attemptReopen, 120); 52 + window.setTimeout(attemptReopen, 480); 53 + window.setTimeout(attemptReopen, 1000); 54 + }); 55 + </script> 56 + </body> 57 + </html>
+2 -187
www/privacy.html
··· 9 9 <meta name="author" content="Stormlight Labs" /> 10 10 <title>Lazurite Privacy Policy</title> 11 11 <link rel="icon" type="image/svg+xml" href="static/favicon.svg" /> 12 + <link rel="stylesheet" href="/static/shared.css" /> 12 13 <link rel="preconnect" href="https://fonts.googleapis.com" /> 13 14 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> 14 15 <link 15 16 href="https://fonts.googleapis.com/css2?family=Google+Sans+Code:ital,wght@0,300..800;1,300..800&family=Google+Sans:ital,opsz,wght@0,17..18,400..700;1,17..18,400..700&family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Lora:ital,wght@0,400..700;1,400..700&display=swap" 16 17 rel="stylesheet" /> 17 - <style> 18 - :root { 19 - --bg-dark: #000000; 20 - --surface-dark: #191919; 21 - --surface-variant: #1f1f1f; 22 - --outline: rgba(255, 255, 255, 0.1); 23 - --outline-bright: rgba(255, 255, 255, 0.2); 24 - --text-primary: #f4f6fb; 25 - --text-secondary: #ababab; 26 - --text-tertiary: #6f6f6f; 27 - --primary: #7dafff; 28 - --secondary: #0073de; 29 - --tertiary: #33b1ff; 30 - --font-title: "Lora", serif; 31 - --font-display: "Google Sans", sans-serif; 32 - --font-body: "Inter", sans-serif; 33 - --font-mono: "Google Sans Code", monospace; 34 - } 35 - 36 - * { 37 - margin: 0; 38 - padding: 0; 39 - box-sizing: border-box; 40 - } 41 - 42 - body { 43 - font-family: var(--font-body); 44 - background-color: var(--bg-dark); 45 - color: var(--text-primary); 46 - line-height: 1.6; 47 - min-height: 100vh; 48 - display: flex; 49 - flex-direction: column; 50 - } 51 - 52 - .container { 53 - max-width: 920px; 54 - margin: 0 auto; 55 - padding: 2rem; 56 - flex: 1; 57 - } 58 - 59 - .top-nav { 60 - display: flex; 61 - justify-content: flex-start; 62 - margin-bottom: 2rem; 63 - } 64 - 65 - .top-nav a { 66 - color: var(--primary); 67 - text-decoration: none; 68 - font-size: 0.875rem; 69 - } 70 - 71 - .top-nav a:hover { 72 - color: var(--secondary); 73 - } 74 - 75 - .logo { 76 - font-family: var(--font-title); 77 - font-size: 2.4rem; 78 - font-weight: 700; 79 - background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 50%, var(--tertiary) 100%); 80 - -webkit-background-clip: text; 81 - -webkit-text-fill-color: transparent; 82 - background-clip: text; 83 - letter-spacing: -0.02em; 84 - display: inline-flex; 85 - align-items: center; 86 - gap: 0.5rem; 87 - } 88 - 89 - .logo-icon { 90 - width: 2rem; 91 - height: 2rem; 92 - mask-image: url("./static/logo.svg"); 93 - background-color: var(--primary); 94 - mask-repeat: no-repeat; 95 - mask-size: contain; 96 - mask-position: center; 97 - -webkit-mask-image: url("./static/logo.svg"); 98 - -webkit-mask-repeat: no-repeat; 99 - -webkit-mask-size: contain; 100 - -webkit-mask-position: center; 101 - } 102 - 103 - .legal-card { 104 - margin-top: 1.25rem; 105 - background: var(--surface-dark); 106 - border: 1px solid var(--outline); 107 - border-radius: 1rem; 108 - padding: 2rem; 109 - } 110 - 111 - h1 { 112 - font-family: var(--font-display); 113 - font-size: 2rem; 114 - line-height: 1.2; 115 - margin-bottom: 0.4rem; 116 - } 117 - 118 - .meta { 119 - font-size: 0.875rem; 120 - color: var(--text-tertiary); 121 - margin-bottom: 1.5rem; 122 - } 123 - 124 - .lead { 125 - color: var(--text-secondary); 126 - margin-bottom: 1.5rem; 127 - } 128 - 129 - section { 130 - margin-bottom: 1.25rem; 131 - } 132 - 133 - section:last-of-type { 134 - margin-bottom: 0; 135 - } 136 - 137 - h2 { 138 - font-family: var(--font-display); 139 - font-size: 1.0625rem; 140 - color: var(--primary); 141 - margin-bottom: 0.45rem; 142 - } 143 - 144 - p { 145 - color: var(--text-secondary); 146 - font-size: 0.95rem; 147 - margin-bottom: 0.5rem; 148 - } 149 - 150 - a { 151 - color: var(--primary); 152 - text-decoration: none; 153 - } 154 - 155 - a:hover { 156 - color: var(--secondary); 157 - } 158 - 159 - .links { 160 - margin-top: 1.25rem; 161 - background: var(--surface-variant); 162 - border: 1px solid var(--outline); 163 - border-radius: 0.75rem; 164 - padding: 1rem 1.2rem; 165 - } 166 - 167 - .links p { 168 - margin-bottom: 0; 169 - } 170 - 171 - footer { 172 - text-align: center; 173 - padding: 2rem; 174 - color: var(--text-tertiary); 175 - font-size: 0.875rem; 176 - border-top: 1px solid var(--outline); 177 - margin-top: 2rem; 178 - } 179 - 180 - footer a { 181 - color: var(--primary); 182 - text-decoration: none; 183 - } 184 - 185 - footer a:hover { 186 - color: var(--secondary); 187 - } 188 - 189 - @media (max-width: 768px) { 190 - .container { 191 - padding: 1.25rem; 192 - } 193 - 194 - .legal-card { 195 - padding: 1.25rem; 196 - } 197 - 198 - h1 { 199 - font-size: 1.6rem; 200 - } 201 - } 202 - </style> 203 18 </head> 204 - <body> 19 + <body class="legal-page privacy-page"> 205 20 <div class="container"> 206 21 <nav class="top-nav"><a href="./index.html">Back to home</a></nav> 207 22 <div class="logo"><span class="logo-icon"></span>Lazurite</div>
+719
www/static/shared.css
··· 1 + :root { 2 + --bg-dark: #000000; 3 + --surface-dark: #191919; 4 + --surface-variant: #1f1f1f; 5 + --outline: rgba(255, 255, 255, 0.1); 6 + --outline-bright: rgba(255, 255, 255, 0.2); 7 + --text-primary: #f4f6fb; 8 + --text-secondary: #ababab; 9 + --text-tertiary: #6f6f6f; 10 + 11 + --primary: #7dafff; 12 + --secondary: #0073de; 13 + --tertiary: #33b1ff; 14 + --cyan: #08bdba; 15 + --purple: #be95ff; 16 + --error: #ff8080; 17 + 18 + --font-title: "Lora", serif; 19 + --font-display: "Google Sans", sans-serif; 20 + --font-body: "Inter", sans-serif; 21 + --font-mono: "Google Sans Code", monospace; 22 + } 23 + 24 + * { 25 + margin: 0; 26 + padding: 0; 27 + box-sizing: border-box; 28 + } 29 + 30 + body.home-page, 31 + body.legal-page, 32 + body.oauth-callback-page { 33 + font-family: var(--font-body); 34 + background-color: var(--bg-dark); 35 + color: var(--text-primary); 36 + line-height: 1.6; 37 + min-height: 100vh; 38 + display: flex; 39 + flex-direction: column; 40 + } 41 + 42 + body.home-page a:hover, 43 + body.legal-page a:hover { 44 + color: var(--secondary); 45 + } 46 + 47 + body.home-page a, 48 + body.legal-page a { 49 + color: var(--primary); 50 + text-decoration: none; 51 + } 52 + 53 + body.legal-page .container, 54 + .oauth-callback-container { 55 + max-width: 920px; 56 + margin: 0 auto; 57 + padding: 2rem; 58 + flex: 1; 59 + } 60 + 61 + body.home-page .platform, 62 + body.home-page .feature, 63 + body.home-page .tech-stack, 64 + body.legal-page .legal-card, 65 + .oauth-callback-main { 66 + background: var(--surface-dark); 67 + border: 1px solid var(--outline); 68 + border-radius: 1rem; 69 + } 70 + 71 + body.home-page .platform h2, 72 + body.home-page .section-title, 73 + body.home-page .feature h3, 74 + body.home-page .tech-stack h2, 75 + body.home-page .hero h1, 76 + body.legal-page h1, 77 + body.legal-page h2, 78 + body.oauth-callback-page h1 { 79 + font-family: var(--font-display); 80 + } 81 + 82 + body.home-page footer, 83 + body.legal-page footer, 84 + body.legal-page .meta, 85 + .oauth-callback-hint { 86 + font-size: 0.875rem; 87 + color: var(--text-tertiary); 88 + } 89 + 90 + body.home-page .container { 91 + max-width: 1200px; 92 + margin: 0 auto; 93 + padding: 2rem; 94 + flex: 1; 95 + display: flex; 96 + flex-direction: column; 97 + justify-content: center; 98 + } 99 + 100 + body.home-page header { 101 + text-align: center; 102 + margin-bottom: 4rem; 103 + } 104 + 105 + body.home-page .logo, 106 + body.legal-page .logo { 107 + font-family: var(--font-title); 108 + font-weight: 700; 109 + background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 50%, var(--tertiary) 100%); 110 + -webkit-background-clip: text; 111 + -webkit-text-fill-color: transparent; 112 + background-clip: text; 113 + letter-spacing: -0.02em; 114 + display: inline-flex; 115 + align-items: center; 116 + gap: 0.5rem; 117 + } 118 + 119 + body.home-page .logo { 120 + font-size: 3.5rem; 121 + margin-bottom: 1rem; 122 + } 123 + 124 + body.home-page .logo-icon, 125 + body.legal-page .logo-icon { 126 + mask-image: url("/static/logo.svg"); 127 + background-color: var(--primary); 128 + mask-repeat: no-repeat; 129 + mask-size: contain; 130 + mask-position: center; 131 + 132 + -webkit-mask-image: url("/static/logo.svg"); 133 + -webkit-mask-repeat: no-repeat; 134 + -webkit-mask-size: contain; 135 + -webkit-mask-position: center; 136 + } 137 + 138 + body.home-page .logo-icon { 139 + width: 3rem; 140 + height: 3rem; 141 + } 142 + 143 + body.home-page .tagline { 144 + font-size: 1.25rem; 145 + color: var(--text-secondary); 146 + font-weight: 400; 147 + letter-spacing: 0.01em; 148 + } 149 + 150 + body.home-page main { 151 + max-width: 900px; 152 + margin: 0 auto; 153 + } 154 + 155 + body.home-page .hero { 156 + text-align: center; 157 + margin-bottom: 4rem; 158 + } 159 + 160 + body.home-page .hero h1 { 161 + font-family: var(--font-display); 162 + font-size: 2.5rem; 163 + font-weight: 600; 164 + margin-bottom: 1.5rem; 165 + color: var(--text-primary); 166 + line-height: 1.2; 167 + } 168 + 169 + body.home-page .hero p { 170 + font-size: 1.125rem; 171 + color: var(--text-secondary); 172 + margin-bottom: 2rem; 173 + line-height: 1.8; 174 + } 175 + 176 + body.home-page .status-badge { 177 + display: inline-flex; 178 + align-items: center; 179 + gap: 0.5rem; 180 + background: var(--surface-dark); 181 + padding: 0.75rem 1.5rem; 182 + border-radius: 2rem; 183 + border: 1px solid var(--outline); 184 + font-size: 0.875rem; 185 + font-weight: 500; 186 + letter-spacing: 0.02em; 187 + margin-bottom: 3rem; 188 + } 189 + 190 + body.home-page .status-dot { 191 + width: 8px; 192 + height: 8px; 193 + border-radius: 50%; 194 + background: var(--primary); 195 + animation: pulse 2s ease-in-out infinite; 196 + } 197 + 198 + @keyframes pulse { 199 + 0%, 200 + 100% { 201 + opacity: 1; 202 + } 203 + 204 + 50% { 205 + opacity: 0.5; 206 + } 207 + } 208 + 209 + body.home-page .platforms { 210 + display: grid; 211 + grid-template-columns: 1fr 1fr; 212 + gap: 2rem; 213 + margin-bottom: 4rem; 214 + } 215 + 216 + body.home-page .platform { 217 + padding: 2rem; 218 + } 219 + 220 + body.home-page .platform-header { 221 + display: flex; 222 + align-items: center; 223 + gap: 0.75rem; 224 + margin-bottom: 1.25rem; 225 + } 226 + 227 + body.home-page .platform-icon { 228 + width: 40px; 229 + height: 40px; 230 + border-radius: 10px; 231 + display: flex; 232 + align-items: center; 233 + justify-content: center; 234 + flex-shrink: 0; 235 + } 236 + 237 + body.home-page .platform-icon.mobile { 238 + background: linear-gradient(135deg, var(--primary), var(--secondary)); 239 + } 240 + 241 + body.home-page .platform-icon img { 242 + width: 22px; 243 + height: 22px; 244 + } 245 + 246 + body.home-page .platform h2 { 247 + font-family: var(--font-display); 248 + font-size: 1.375rem; 249 + font-weight: 600; 250 + } 251 + 252 + body.home-page .platform-badge { 253 + font-size: 0.6875rem; 254 + font-weight: 600; 255 + text-transform: uppercase; 256 + letter-spacing: 0.06em; 257 + padding: 0.2rem 0.5rem; 258 + border-radius: 0.25rem; 259 + margin-left: auto; 260 + } 261 + 262 + body.home-page .platform-badge.alpha { 263 + background: rgba(190, 149, 255, 0.15); 264 + color: var(--purple); 265 + } 266 + 267 + body.home-page .platform-badge.beta { 268 + background: rgba(125, 175, 255, 0.15); 269 + color: var(--primary); 270 + } 271 + 272 + body.home-page .platform-desc { 273 + color: var(--text-secondary); 274 + font-size: 0.9375rem; 275 + line-height: 1.6; 276 + margin-bottom: 1.25rem; 277 + } 278 + 279 + body.home-page .platform-targets { 280 + display: flex; 281 + gap: 0.5rem; 282 + flex-wrap: wrap; 283 + margin-bottom: 1.25rem; 284 + } 285 + 286 + body.home-page .target-tag { 287 + background: var(--surface-variant); 288 + padding: 0.3rem 0.625rem; 289 + border-radius: 0.375rem; 290 + font-size: 0.8125rem; 291 + font-family: var(--font-mono); 292 + color: var(--text-secondary); 293 + } 294 + 295 + body.home-page .platform-features { 296 + list-style: none; 297 + } 298 + 299 + body.home-page .platform-features li { 300 + color: var(--text-secondary); 301 + font-size: 0.875rem; 302 + padding: 0.3rem 0; 303 + padding-left: 1.25rem; 304 + position: relative; 305 + } 306 + 307 + body.home-page .platform-features li::before { 308 + content: ""; 309 + position: absolute; 310 + left: 0; 311 + top: 0.7rem; 312 + width: 6px; 313 + height: 6px; 314 + border-radius: 50%; 315 + background: var(--primary); 316 + opacity: 0.6; 317 + } 318 + 319 + body.home-page .platform-screenshot { 320 + margin-top: 1.5rem; 321 + border-radius: 0.75rem; 322 + overflow: hidden; 323 + border: 1px solid var(--outline); 324 + } 325 + 326 + body.home-page .platform-screenshot img { 327 + width: 100%; 328 + height: auto; 329 + display: block; 330 + } 331 + 332 + body.home-page .platform-screenshot .caption { 333 + font-size: 0.75rem; 334 + color: var(--text-tertiary); 335 + text-align: center; 336 + padding: 0.5rem; 337 + background: var(--surface-variant); 338 + } 339 + 340 + body.home-page .hero-screenshots { 341 + display: flex; 342 + gap: 1.5rem; 343 + justify-content: center; 344 + align-items: flex-end; 345 + margin-bottom: 3rem; 346 + padding: 0 1rem; 347 + } 348 + 349 + body.home-page .hero-screenshot { 350 + border-radius: 1rem; 351 + overflow: hidden; 352 + border: 1px solid var(--outline); 353 + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); 354 + } 355 + 356 + body.home-page .hero-screenshot img { 357 + display: block; 358 + width: 100%; 359 + height: auto; 360 + } 361 + 362 + body.home-page .hero-screenshot.phone { 363 + width: 200px; 364 + flex-shrink: 0; 365 + } 366 + 367 + body.home-page .hero-screenshot.desktop-shot { 368 + width: 640px; 369 + flex-shrink: 0; 370 + } 371 + 372 + @media (max-width: 768px) { 373 + body.home-page .hero-screenshots { 374 + flex-direction: column; 375 + align-items: center; 376 + } 377 + 378 + body.home-page .hero-screenshot.phone { 379 + width: 160px; 380 + } 381 + 382 + body.home-page .hero-screenshot.desktop-shot { 383 + width: 100%; 384 + max-width: 360px; 385 + } 386 + } 387 + 388 + body.home-page .section-title { 389 + font-family: var(--font-display); 390 + font-size: 1.5rem; 391 + font-weight: 600; 392 + margin-bottom: 1.5rem; 393 + color: var(--text-primary); 394 + } 395 + 396 + body.home-page .features { 397 + display: grid; 398 + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); 399 + gap: 1.5rem; 400 + margin-bottom: 4rem; 401 + } 402 + 403 + body.home-page .feature { 404 + padding: 1.75rem; 405 + transition: 406 + transform 0.2s ease, 407 + border-color 0.2s ease; 408 + } 409 + 410 + body.home-page .feature:hover { 411 + transform: translateY(-2px); 412 + border-color: var(--outline-bright); 413 + } 414 + 415 + body.home-page .feature h3 { 416 + font-family: var(--font-display); 417 + font-size: 1.125rem; 418 + margin-bottom: 0.5rem; 419 + color: var(--text-primary); 420 + } 421 + 422 + body.home-page .feature p { 423 + color: var(--text-secondary); 424 + font-size: 0.875rem; 425 + line-height: 1.6; 426 + } 427 + 428 + body.home-page .feature-icon { 429 + width: 44px; 430 + height: 44px; 431 + background: linear-gradient(135deg, var(--primary), var(--secondary)); 432 + border-radius: 10px; 433 + display: flex; 434 + align-items: center; 435 + justify-content: center; 436 + margin-bottom: 0.875rem; 437 + padding: 10px; 438 + } 439 + 440 + body.home-page .platform-icon.desktop, 441 + body.home-page .feature-icon.alt { 442 + background: linear-gradient(135deg, var(--purple), var(--tertiary)); 443 + } 444 + 445 + body.home-page .feature-icon img { 446 + width: 100%; 447 + height: 100%; 448 + } 449 + 450 + body.home-page .tech-stack { 451 + padding: 2rem; 452 + margin-bottom: 4rem; 453 + } 454 + 455 + body.home-page .tech-stack h2 { 456 + font-family: var(--font-display); 457 + font-size: 1.5rem; 458 + margin-bottom: 1.5rem; 459 + color: var(--text-primary); 460 + } 461 + 462 + body.home-page .tech-group { 463 + margin-bottom: 1rem; 464 + } 465 + 466 + body.home-page .tech-group:last-child { 467 + margin-bottom: 0; 468 + } 469 + 470 + body.home-page .tech-group-label { 471 + font-size: 0.75rem; 472 + font-weight: 600; 473 + text-transform: uppercase; 474 + letter-spacing: 0.08em; 475 + color: var(--text-tertiary); 476 + margin-bottom: 0.5rem; 477 + } 478 + 479 + body.home-page .tech-list { 480 + display: flex; 481 + flex-wrap: wrap; 482 + gap: 0.5rem; 483 + } 484 + 485 + body.home-page .tech-tag { 486 + background: var(--surface-variant); 487 + padding: 0.4rem 0.875rem; 488 + border-radius: 0.5rem; 489 + font-size: 0.8125rem; 490 + font-family: var(--font-mono); 491 + color: var(--text-secondary); 492 + border: 1px solid transparent; 493 + transition: border-color 0.2s ease; 494 + } 495 + 496 + body.home-page .tech-tag:hover { 497 + border-color: var(--outline-bright); 498 + } 499 + 500 + body.home-page .oauth-info { 501 + background: var(--surface-variant); 502 + padding: 1rem 1.5rem; 503 + border-radius: 0.75rem; 504 + border-left: 3px solid var(--primary); 505 + margin-top: 2rem; 506 + font-size: 0.875rem; 507 + color: var(--text-secondary); 508 + } 509 + 510 + body.home-page .oauth-info a { 511 + font-weight: 500; 512 + transition: color 0.2s ease; 513 + } 514 + 515 + body.home-page .oauth-info a:hover { 516 + text-decoration: underline; 517 + } 518 + 519 + body.home-page footer, 520 + body.legal-page footer { 521 + text-align: center; 522 + padding: 2rem; 523 + color: var(--text-tertiary); 524 + font-size: 0.875rem; 525 + border-top: 1px solid var(--outline); 526 + } 527 + 528 + body.home-page footer { 529 + margin-top: 4rem; 530 + } 531 + 532 + body.home-page footer a { 533 + transition: color 0.2s ease; 534 + } 535 + 536 + @media (max-width: 768px) { 537 + body.home-page .logo { 538 + font-size: 2.5rem; 539 + } 540 + 541 + body.home-page .hero h1 { 542 + font-size: 2rem; 543 + } 544 + 545 + body.home-page .hero p { 546 + font-size: 1rem; 547 + } 548 + 549 + body.home-page .platforms { 550 + grid-template-columns: 1fr; 551 + } 552 + 553 + body.home-page .features { 554 + grid-template-columns: 1fr; 555 + } 556 + 557 + body.home-page .container { 558 + padding: 1.5rem; 559 + } 560 + } 561 + 562 + body.legal-page .top-nav { 563 + display: flex; 564 + justify-content: flex-start; 565 + margin-bottom: 2rem; 566 + } 567 + 568 + body.legal-page .top-nav a { 569 + font-size: 0.875rem; 570 + } 571 + 572 + body.legal-page .logo { 573 + font-size: 2.4rem; 574 + } 575 + 576 + body.legal-page .logo-icon { 577 + width: 2rem; 578 + height: 2rem; 579 + } 580 + 581 + body.legal-page .legal-card { 582 + margin-top: 1.25rem; 583 + padding: 2rem; 584 + } 585 + 586 + body.legal-page h1 { 587 + font-size: 2rem; 588 + line-height: 1.2; 589 + margin-bottom: 0.4rem; 590 + } 591 + 592 + body.legal-page .meta { 593 + margin-bottom: 1.5rem; 594 + } 595 + 596 + body.legal-page .lead { 597 + color: var(--text-secondary); 598 + margin-bottom: 1.5rem; 599 + } 600 + 601 + body.legal-page section { 602 + margin-bottom: 1.25rem; 603 + } 604 + 605 + body.legal-page section:last-of-type { 606 + margin-bottom: 0; 607 + } 608 + 609 + body.legal-page h2 { 610 + font-size: 1.0625rem; 611 + color: var(--primary); 612 + margin-bottom: 0.45rem; 613 + } 614 + 615 + body.legal-page p { 616 + color: var(--text-secondary); 617 + font-size: 0.95rem; 618 + margin-bottom: 0.5rem; 619 + } 620 + 621 + body.legal-page .links { 622 + margin-top: 1.25rem; 623 + background: var(--surface-variant); 624 + border: 1px solid var(--outline); 625 + border-radius: 0.75rem; 626 + padding: 1rem 1.2rem; 627 + } 628 + 629 + body.legal-page .links p { 630 + margin-bottom: 0.35rem; 631 + } 632 + 633 + body.legal-page .links p:last-child { 634 + margin-bottom: 0; 635 + } 636 + 637 + body.legal-page footer { 638 + margin-top: 2rem; 639 + } 640 + 641 + body.privacy-page .links p { 642 + margin-bottom: 0; 643 + } 644 + 645 + body.csae-page .lead { 646 + margin-bottom: 1.25rem; 647 + } 648 + 649 + body.csae-page .alert { 650 + margin-bottom: 1.5rem; 651 + background: rgba(255, 128, 128, 0.12); 652 + border: 1px solid rgba(255, 128, 128, 0.35); 653 + border-radius: 0.75rem; 654 + padding: 0.85rem 1rem; 655 + color: #ffd4d4; 656 + font-size: 0.95rem; 657 + } 658 + 659 + body.csae-page ul { 660 + margin: 0.25rem 0 0.75rem 1.2rem; 661 + color: var(--text-secondary); 662 + font-size: 0.95rem; 663 + } 664 + 665 + body.csae-page li { 666 + margin-bottom: 0.35rem; 667 + } 668 + 669 + @media (max-width: 768px) { 670 + body.legal-page .container, 671 + body.legal-page .legal-card { 672 + padding: 1.25rem; 673 + } 674 + 675 + body.legal-page h1 { 676 + font-size: 1.6rem; 677 + } 678 + } 679 + 680 + .oauth-callback-container { 681 + width: 100%; 682 + display: flex; 683 + align-items: center; 684 + justify-content: center; 685 + } 686 + 687 + .oauth-callback-main { 688 + width: min(520px, 100%); 689 + margin-top: 0; 690 + padding: 2rem; 691 + text-align: center; 692 + } 693 + 694 + .oauth-callback-copy { 695 + margin: 0 0 0.9rem; 696 + line-height: 1.6; 697 + } 698 + 699 + .oauth-callback-button { 700 + appearance: none; 701 + display: inline-block; 702 + border: 0; 703 + border-radius: 999px; 704 + background: var(--secondary); 705 + color: #ffffff; 706 + text-decoration: none; 707 + font-weight: 600; 708 + padding: 12px 20px; 709 + margin-top: 0.3rem; 710 + } 711 + 712 + .oauth-callback-button:hover { 713 + background: var(--primary); 714 + } 715 + 716 + .oauth-callback-hint { 717 + margin-top: 0.85rem; 718 + margin-bottom: 0; 719 + }
+2 -189
www/terms.html
··· 9 9 <meta name="author" content="Stormlight Labs" /> 10 10 <title>Lazurite Terms of Service</title> 11 11 <link rel="icon" type="image/svg+xml" href="static/favicon.svg" /> 12 + <link rel="stylesheet" href="/static/shared.css" /> 12 13 <link rel="preconnect" href="https://fonts.googleapis.com" /> 13 14 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> 14 15 <link 15 16 href="https://fonts.googleapis.com/css2?family=Google+Sans+Code:ital,wght@0,300..800;1,300..800&family=Google+Sans:ital,opsz,wght@0,17..18,400..700;1,17..18,400..700&family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Lora:ital,wght@0,400..700;1,400..700&display=swap" 16 17 rel="stylesheet" /> 17 - <style> 18 - :root { 19 - --bg-dark: #000000; 20 - --surface-dark: #191919; 21 - --surface-variant: #1f1f1f; 22 - --outline: rgba(255, 255, 255, 0.1); 23 - --text-primary: #f4f6fb; 24 - --text-secondary: #ababab; 25 - --text-tertiary: #6f6f6f; 26 - --primary: #7dafff; 27 - --secondary: #0073de; 28 - --tertiary: #33b1ff; 29 - --font-title: "Lora", serif; 30 - --font-display: "Google Sans", sans-serif; 31 - --font-body: "Inter", sans-serif; 32 - } 33 - 34 - * { 35 - margin: 0; 36 - padding: 0; 37 - box-sizing: border-box; 38 - } 39 - 40 - body { 41 - font-family: var(--font-body); 42 - background-color: var(--bg-dark); 43 - color: var(--text-primary); 44 - line-height: 1.6; 45 - min-height: 100vh; 46 - display: flex; 47 - flex-direction: column; 48 - } 49 - 50 - .container { 51 - max-width: 920px; 52 - margin: 0 auto; 53 - padding: 2rem; 54 - flex: 1; 55 - } 56 - 57 - .top-nav { 58 - display: flex; 59 - justify-content: flex-start; 60 - margin-bottom: 2rem; 61 - } 62 - 63 - .top-nav a { 64 - color: var(--primary); 65 - text-decoration: none; 66 - font-size: 0.875rem; 67 - } 68 - 69 - .top-nav a:hover { 70 - color: var(--secondary); 71 - } 72 - 73 - .logo { 74 - font-family: var(--font-title); 75 - font-size: 2.4rem; 76 - font-weight: 700; 77 - background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 50%, var(--tertiary) 100%); 78 - -webkit-background-clip: text; 79 - -webkit-text-fill-color: transparent; 80 - background-clip: text; 81 - letter-spacing: -0.02em; 82 - display: inline-flex; 83 - align-items: center; 84 - gap: 0.5rem; 85 - } 86 - 87 - .logo-icon { 88 - width: 2rem; 89 - height: 2rem; 90 - mask-image: url("./static/logo.svg"); 91 - background-color: var(--primary); 92 - mask-repeat: no-repeat; 93 - mask-size: contain; 94 - mask-position: center; 95 - -webkit-mask-image: url("./static/logo.svg"); 96 - -webkit-mask-repeat: no-repeat; 97 - -webkit-mask-size: contain; 98 - -webkit-mask-position: center; 99 - } 100 - 101 - .legal-card { 102 - margin-top: 1.25rem; 103 - background: var(--surface-dark); 104 - border: 1px solid var(--outline); 105 - border-radius: 1rem; 106 - padding: 2rem; 107 - } 108 - 109 - h1 { 110 - font-family: var(--font-display); 111 - font-size: 2rem; 112 - line-height: 1.2; 113 - margin-bottom: 0.4rem; 114 - } 115 - 116 - .meta { 117 - font-size: 0.875rem; 118 - color: var(--text-tertiary); 119 - margin-bottom: 1.5rem; 120 - } 121 - 122 - .lead { 123 - color: var(--text-secondary); 124 - margin-bottom: 1.5rem; 125 - } 126 - 127 - section { 128 - margin-bottom: 1.25rem; 129 - } 130 - 131 - section:last-of-type { 132 - margin-bottom: 0; 133 - } 134 - 135 - h2 { 136 - font-family: var(--font-display); 137 - font-size: 1.0625rem; 138 - color: var(--primary); 139 - margin-bottom: 0.45rem; 140 - } 141 - 142 - p { 143 - color: var(--text-secondary); 144 - font-size: 0.95rem; 145 - margin-bottom: 0.5rem; 146 - } 147 - 148 - a { 149 - color: var(--primary); 150 - text-decoration: none; 151 - } 152 - 153 - a:hover { 154 - color: var(--secondary); 155 - } 156 - 157 - .links { 158 - margin-top: 1.25rem; 159 - background: var(--surface-variant); 160 - border: 1px solid var(--outline); 161 - border-radius: 0.75rem; 162 - padding: 1rem 1.2rem; 163 - } 164 - 165 - .links p { 166 - margin-bottom: 0.35rem; 167 - } 168 - 169 - .links p:last-child { 170 - margin-bottom: 0; 171 - } 172 - 173 - footer { 174 - text-align: center; 175 - padding: 2rem; 176 - color: var(--text-tertiary); 177 - font-size: 0.875rem; 178 - border-top: 1px solid var(--outline); 179 - margin-top: 2rem; 180 - } 181 - 182 - footer a { 183 - color: var(--primary); 184 - text-decoration: none; 185 - } 186 - 187 - footer a:hover { 188 - color: var(--secondary); 189 - } 190 - 191 - @media (max-width: 768px) { 192 - .container { 193 - padding: 1.25rem; 194 - } 195 - 196 - .legal-card { 197 - padding: 1.25rem; 198 - } 199 - 200 - h1 { 201 - font-size: 1.6rem; 202 - } 203 - } 204 - </style> 205 18 </head> 206 - <body> 19 + <body class="legal-page terms-page"> 207 20 <div class="container"> 208 21 <nav class="top-nav"><a href="./index.html">Back to home</a></nav> 209 22 <div class="logo"><span class="logo-icon"></span>Lazurite</div>