Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion frontend/tests/ui/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -560,7 +560,7 @@ export const adminUsers: AdminUser[] = [
];

export const invites: Invite[] = [
{ id: "inv-1", token: "lmn_7QxV2pKc9RfT4wZb", target_role: "user", max_uses: 5, uses: 2, expires_at: "2026-11-01T00:00:00Z", created_at: "2026-10-01T12:00:00Z" },
{ id: "inv-1", token: "lmn_7QxV2pKc9RfT4wZb", target_role: "user", max_uses: 5, uses: 2, expires_at: "2099-11-01T00:00:00Z", created_at: "2026-10-01T12:00:00Z" },
{ id: "inv-2", token: "lmn_Hd83LwQe1NvY6sAa", target_role: "admin", max_uses: 1, uses: 0, expires_at: "2026-10-18T00:00:00Z", created_at: "2026-10-09T08:30:00Z" },
{ id: "inv-3", token: "lmn_Zp4mKt7BcXr2Ue9J", target_role: "user", max_uses: 10, uses: 10, expires_at: null, created_at: "2026-05-20T17:45:00Z" },
];
Expand Down
5 changes: 3 additions & 2 deletions mobile/app/(auth)/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,8 @@ export default function LoginScreen() {
opacity: pressed ? 0.6 : 1,
})}
>
<Text style={{ color: theme.color.accent, fontSize: 15 }}>
Have an invite? Create an account
<Text style={{ color: theme.color.fgSubtle, fontSize: 15 }}>
I have an invite
</Text>
</Pressable>
</ScrollView>
Expand All @@ -156,6 +156,7 @@ export default function LoginScreen() {
>
<PrimaryButton
label="Sign in"
tone="inverse"
onPress={onSubmit}
loading={pending}
disabled={!username || !password}
Expand Down
18 changes: 9 additions & 9 deletions mobile/app/(auth)/register.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ export default function RegisterScreen() {
accessibilityRole="header"
style={{
fontSize: 34,
fontWeight: "700",
fontWeight: "600",
color: theme.color.fg,
letterSpacing: -0.4,
}}
Expand Down Expand Up @@ -236,7 +236,7 @@ export default function RegisterScreen() {
accessibilityLiveRegion="assertive"
style={{ fontSize: 16, color: theme.color.danger }}
>
This invite is invalid, expired, revoked, or has no uses remaining.
This invite is invalid, expired, revoked, or has no uses remaining
</Text>
) : (
<Text
Expand All @@ -252,7 +252,7 @@ export default function RegisterScreen() {
<>
<FormField
label="Invite token or link"
hint="Paste the token or Lumen invite link you received."
hint="Paste the token or Lumen invite link you received"
>
<FormTextInput
accessibilityLabel="Invite token or link"
Expand All @@ -271,7 +271,7 @@ export default function RegisterScreen() {
<View accessibilityLiveRegion="assertive">
<FormError message={inviteInputError} />
</View>
<PrimaryButton
<PrimaryButton tone="inverse"
label="Continue"
onPress={onContinue}
disabled={!inviteInput.trim()}
Expand All @@ -280,12 +280,12 @@ export default function RegisterScreen() {
) : null}

{activeInviteState.status === "invalid" ? (
<PrimaryButton label="Enter another invite" onPress={enterAnotherInvite} />
<PrimaryButton tone="inverse" label="Enter another invite" onPress={enterAnotherInvite} />
) : null}

{activeInviteState.status === "verification-error" ? (
<>
<PrimaryButton
<PrimaryButton tone="inverse"
label="Try again"
onPress={() => void verifyInvite(activeInviteState.token)}
/>
Expand All @@ -296,7 +296,7 @@ export default function RegisterScreen() {
{activeInviteState.status === "valid" ? (
<>
<View style={{ gap: theme.space.md }}>
<FormField label="Username" hint="2 characters or more.">
<FormField label="Username" hint="2 characters or more">
<FormTextInput
accessibilityLabel="Username"
placeholder="Username"
Expand All @@ -313,7 +313,7 @@ export default function RegisterScreen() {
message={username ? validation.usernameError : null}
/>
</FormField>
<FormField label="Password" hint="At least 8 characters, up to 256 bytes.">
<FormField label="Password" hint="At least 8 characters, up to 256 bytes">
<FormTextInput
accessibilityLabel="Password"
placeholder="Password"
Expand All @@ -338,7 +338,7 @@ export default function RegisterScreen() {
<FormError message={error} />
</View>

<PrimaryButton
<PrimaryButton tone="inverse"
label="Create account"
onPress={onSubmit}
loading={pending}
Expand Down
5 changes: 3 additions & 2 deletions mobile/app/(auth)/reset-password.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,15 +57,15 @@ export default function ResetPasswordScreen() {
<Text
style={{
fontSize: 34,
fontWeight: "700",
fontWeight: "600",
color: theme.color.fg,
letterSpacing: -0.4,
}}
>
Set a new password
</Text>
<Text style={{ fontSize: 16, color: theme.color.fgMuted }}>
Choose a new password before continuing.
Choose a new password before continuing
</Text>
</View>

Expand Down Expand Up @@ -114,6 +114,7 @@ export default function ResetPasswordScreen() {
<FormError message={error} />

<PrimaryButton
tone="inverse"
label="Update password"
onPress={onSubmit}
loading={pending}
Expand Down
173 changes: 93 additions & 80 deletions mobile/app/(auth)/welcome.tsx
Original file line number Diff line number Diff line change
@@ -1,132 +1,145 @@
import { Pressable, ScrollView, Text, View } from "react-native";
import { useState } from "react";
import {
Pressable,
ScrollView,
Text,
useWindowDimensions,
View,
type TextStyle,
} from "react-native";
import { useRouter } from "expo-router";
import { Image } from "expo-image";
import * as Haptics from "expo-haptics";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
import Animated, { FadeInDown } from "react-native-reanimated";
import { getBaseUrl } from "@music-library/core";
import { BouncingTitle } from "../../components/auth/bouncing-title";
import { BubbleParade } from "../../components/auth/bubble-parade";
import { useAuthMotionClock } from "../../components/auth/motion";
import { WELCOME_RECORD } from "../../components/auth/welcome-art";
import { WelcomeHero } from "../../components/auth/welcome-hero";
import { PrimaryButton } from "../../components/buttons";
import { useTheme } from "../../theme/theme";

/** Room for the largest bubble plus its bob, so the parade never collapses. */
const PARADE_MIN_HEIGHT = 240;
const RECORD_SIZE = 32;
/** Room for the record, even when large text pushes the words up the screen. */
const HERO_MIN_HEIGHT = 240;
const ENTER = FadeInDown.delay(120)
.duration(400)
.withInitialValues({ opacity: 0, transform: [{ translateY: 12 }] });

/**
* Where signed-out users land: the wordmark, a parade of glass bubbles drifting
* across the upper half, and the two ways in (sign in, or register with an invite).
* Where signed-out users land: a scene from Lumen's world over the headline,
* then the two ways in (sign in, or register with an invite).
*/
export default function WelcomeScreen() {
const theme = useTheme();
const router = useRouter();
const insets = useSafeAreaInsets();
const clock = useAuthMotionClock();
const { fontScale } = useWindowDimensions();
// The scene ends where the words start, whatever the screen and text size.
const [wordsTop, setWordsTop] = useState<number | null>(null);
const host = serverHost();

const headline: TextStyle = {
color: theme.color.fg,
fontSize: 36,
lineHeight: 44,
fontWeight: "500",
letterSpacing: -0.4,
};
const recordSize = RECORD_SIZE * Math.min(fontScale, 1.6);

return (
// Scrolls only when the content outgrows the screen (large text sizes);
// otherwise the bubbles take whatever height is left.
// otherwise the scene takes whatever height is left.
<ScrollView
style={{ flex: 1, backgroundColor: theme.color.bg }}
contentContainerStyle={{ flexGrow: 1 }}
alwaysBounceVertical={false}
>
<View style={{ paddingTop: insets.top + theme.space.lg, alignItems: "center" }}>
<BouncingTitle
text="Lumen"
clock={clock}
style={{
fontSize: 28,
fontWeight: "700",
color: theme.color.fg,
letterSpacing: -0.4,
}}
/>
</View>
{wordsTop !== null ? (
<WelcomeHero height={wordsTop + theme.space.md} />
) : null}

<BubbleParade clock={clock} style={{ flex: 1, minHeight: PARADE_MIN_HEIGHT }} />
<View style={{ flex: 1, minHeight: HERO_MIN_HEIGHT }} />

<View
<Animated.View
entering={ENTER}
onLayout={(event) => setWordsTop(Math.round(event.nativeEvent.layout.y))}
style={{
paddingHorizontal: theme.space.xl,
paddingBottom: insets.bottom + theme.space.md,
gap: theme.space.xl,
}}
>
<Animated.View
entering={FadeInDown.delay(250).duration(500)}
style={{ gap: theme.space.sm }}
<View
accessible
accessibilityRole="header"
accessibilityLabel="Your music, on your server"
>
<Text
<Text style={headline}>Your music,</Text>
<View
style={{
fontSize: 34,
fontWeight: "700",
color: theme.color.fg,
letterSpacing: -0.4,
textAlign: "center",
flexDirection: "row",
flexWrap: "wrap",
alignItems: "center",
columnGap: theme.space.sm,
}}
>
{"Your music library,\nlit up."}
</Text>
<Text
style={{ fontSize: 16, color: theme.color.fgMuted, textAlign: "center" }}
>
Sign in to your server, or join with an invite.
</Text>
</Animated.View>
<Text style={headline}>on your</Text>
<Image
source={WELCOME_RECORD}
style={{ width: recordSize, height: recordSize }}
contentFit="contain"
/>
<Text style={headline}>server</Text>
</View>
</View>

<Animated.View
entering={FadeInDown.delay(380).duration(500)}
style={{ gap: theme.space.md }}
<Text
style={{
color: theme.color.fgMuted,
fontSize: 15,
lineHeight: 20,
marginTop: theme.space.md,
}}
>
Lumen plays your library from {host ?? "your server"} on your phone, in
the car and offline
</Text>

<View style={{ marginTop: theme.space.xl }}>
<PrimaryButton
label="Sign in"
tone="inverse"
onPress={() => {
void Haptics.selectionAsync();
router.push("/(auth)/login");
}}
/>
<Pressable
onPress={() => {
void Haptics.selectionAsync();
router.push("/(auth)/register");
}}
accessibilityRole="button"
accessibilityLabel="Create an account with an invite"
style={({ pressed }) => ({
alignItems: "center",
paddingVertical: 8,
opacity: pressed ? 0.6 : 1,
})}
>
<Text
style={{ color: theme.color.accent, fontSize: 15, textAlign: "center" }}
>
Have an invite? Create an account
</Text>
</Pressable>
</Animated.View>
</View>

<Animated.Text
entering={FadeIn.delay(600).duration(400)}
style={{ fontSize: 13, color: theme.color.fgMuted, textAlign: "center" }}
<Pressable
onPress={() => {
void Haptics.selectionAsync();
router.push("/(auth)/register");
}}
accessibilityRole="button"
accessibilityLabel="Create an account with an invite"
style={({ pressed }) => ({
alignItems: "center",
marginTop: theme.space.xs,
paddingVertical: theme.space.md,
opacity: pressed ? 0.6 : 1,
})}
>
{host ? (
<>
Accounts on <Text style={{ color: theme.color.fg }}>{host}</Text>{" "}
are invite-only.
</>
) : (
"Accounts are invite-only."
)}
</Animated.Text>
</View>
<Text style={{ color: theme.color.fgSubtle, fontSize: 15 }}>
I have an invite
</Text>
</Pressable>
</Animated.View>
</ScrollView>
);
}

/** Host of the server this build talks to, for the footer; null if unset. */
/** Host of the server this build talks to, for the subhead; null if unset. */
function serverHost(): string | null {
try {
return new URL(getBaseUrl()).hostname || null;
Expand Down
6 changes: 3 additions & 3 deletions mobile/app/(tabs)/(favorites)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ export default function FavoritesStackLayout() {
<Stack.Screen name="index" options={{ title: "Favorites", headerShown: false }} />
<Stack.Screen
name="track/[id]"
options={{ headerLargeTitle: false, title: "Track Info" }}
options={{ headerLargeTitle: false, title: "Track info" }}
/>
<Stack.Screen
name="track/edit"
options={{ headerLargeTitle: false, title: "Edit Track" }}
options={{ headerLargeTitle: false, title: "Edit track" }}
/>
<Stack.Screen
name="albums/[id]"
Expand All @@ -31,7 +31,7 @@ export default function FavoritesStackLayout() {
/>
<Stack.Screen
name="albums/edit"
options={{ headerLargeTitle: false, title: "Edit Album" }}
options={{ headerLargeTitle: false, title: "Edit album" }}
/>
</Stack>
);
Expand Down
Loading
Loading