feat(mobile): custom fonts (General Sans + Geist Mono) + logo rework

FONTY:
- Dodane assets/fonts/: GeneralSans Regular/Medium/Semibold (Fontshare, free
  commercial) + GeistMono Regular (Vercel OFL). Pobrane jako .ttf.
- expo-font ~13.0.4 (matchuje SDK 52). Native module jest w APK bo `expo`
  ciagnie expo-font jako bezposrednia zaleznosc -> useFonts dziala przez OTA
  bez rebuildu.
- App.tsx: useFonts() gate (blokuje render do zaladowania, .ttf z bundla <100ms)
  + globalny Text.defaultProps fontFamily=GeneralSans-Regular dla body.
- theme.ts: fonts = { body, medium, display, mono }. RN nie syntezuje weightow
  dla custom fontow, wiec 4 osobne rodziny per-weight (gotcha udokumentowany).
- Jawne fonty na high-traffic: SceneTile (title=display, meta+dur=mono),
  MoviePosterCard (j.w.), navigation (taby display/medium, header display).

LOGO:
- GoonWordmark przepisany: zamiast krzywych recznych SVG path (o-ka jako
  nachodzace elipsy, zniekształcone n) renderuje PRAWDZIWY tekst w General Sans
  Semibold. Dwutonowy twist: "g[oo]n" ze srodkowym "oo" w oxblood.
- GoonMark (monogram): czysty SVG koncentryczny ring + dot (oxblood) — motyw
  soczewki/oka. Dla app-icon/splash gdzie font niedostepny.
- Wpiety na AgeGate (wordmark 40), Login (mark 44 + wordmark 44), nav header.

OTA: c986c911-0868-44f7-9f4a-fc2a74e53095 live (23 assets, 4 fonty serwuja 200).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jtrzupek 2026-05-30 22:25:26 +02:00
parent a0481060f3
commit d87263dde9
15 changed files with 140 additions and 159 deletions

View file

@ -6,6 +6,8 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import * as Sentry from '@sentry/react-native';
import Constants from 'expo-constants';
import { registerRootComponent } from 'expo';
import { useFonts } from 'expo-font';
import { Text as RNText } from 'react-native';
import * as ScreenCapture from 'expo-screen-capture';
import { StatusBar } from 'expo-status-bar';
import * as Updates from 'expo-updates';
@ -75,7 +77,30 @@ const queryClient = new QueryClient({
},
});
// Globalny default fontu dla całego <Text> — General Sans Regular jako body.
// Komponenty które chcą display/mono nadpisują fontFamily jawnie (bo RN nie
// syntezuje weightów dla custom fontów). Bold-ale-bez-fontFamily tekst zostanie
// Regular weightem General Sans — wciąż distinctive face, akceptowalne dla
// nietkniętych ekranów; high-traffic komponenty mają jawny Semibold.
let _textDefaultApplied = false;
function applyDefaultFont() {
if (_textDefaultApplied) return;
_textDefaultApplied = true;
const T = RNText as unknown as { defaultProps?: { style?: unknown } };
T.defaultProps = T.defaultProps || {};
const prev = T.defaultProps.style;
T.defaultProps.style = [{ fontFamily: 'GeneralSans-Regular' }, prev].filter(Boolean);
}
export default function App() {
const [fontsLoaded] = useFonts({
'GeneralSans-Regular': require('./assets/fonts/GeneralSans-Regular.ttf'),
'GeneralSans-Medium': require('./assets/fonts/GeneralSans-Medium.ttf'),
'GeneralSans-Semibold': require('./assets/fonts/GeneralSans-Semibold.ttf'),
'GeistMono-Regular': require('./assets/fonts/GeistMono-Regular.ttf'),
});
if (fontsLoaded) applyDefaultFont();
const [hydrated, setHydrated] = useState(false);
const [ageAccepted, setAgeAccepted] = useState(false);
const [client, setClient] = useState<GoonClient | null>(null);
@ -245,7 +270,7 @@ export default function App() {
return () => sub.remove();
}, []);
if (!hydrated || !lockReady) {
if (!fontsLoaded || !hydrated || !lockReady) {
return (
<View style={{ flex: 1, backgroundColor: theme.bg, justifyContent: 'center' }}>
<ActivityIndicator color={theme.fg} />

View file

@ -44,11 +44,12 @@
}
],
"expo-video",
"@sentry/react-native/expo"
"@sentry/react-native/expo",
"expo-font"
],
"extra": {
"sentryDsn": "",
"sentryEnvironment": "production"
}
}
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -16,6 +16,7 @@
"expo-asset": "~11.0.5",
"expo-build-properties": "~0.13.3",
"expo-clipboard": "~7.0.1",
"expo-font": "~13.0.4",
"expo-haptics": "~14.0.1",
"expo-image": "~2.0.7",
"expo-intent-launcher": "~12.0.2",
@ -6246,6 +6247,19 @@
"integrity": "sha512-t+1F1tiDocSot8iSnrn/CjTUMvVvPV2DpafSVcticpbSzMGybEN7wcamO1t18fK7WxGXpZE9gxtd80qwv/LLqQ==",
"license": "MIT"
},
"node_modules/expo-font": {
"version": "13.0.4",
"resolved": "https://registry.npmjs.org/expo-font/-/expo-font-13.0.4.tgz",
"integrity": "sha512-eAP5hyBgC8gafFtprsz0HMaB795qZfgJWqTmU0NfbSin1wUuVySFMEPMOrTkTgmazU73v4Cb4x7p86jY1XXYUw==",
"license": "MIT",
"dependencies": {
"fontfaceobserver": "^2.1.0"
},
"peerDependencies": {
"expo": "*",
"react": "*"
}
},
"node_modules/expo-haptics": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/expo-haptics/-/expo-haptics-14.0.1.tgz",
@ -6500,19 +6514,6 @@
"react-native": "*"
}
},
"node_modules/expo/node_modules/expo-font": {
"version": "13.0.4",
"resolved": "https://registry.npmjs.org/expo-font/-/expo-font-13.0.4.tgz",
"integrity": "sha512-eAP5hyBgC8gafFtprsz0HMaB795qZfgJWqTmU0NfbSin1wUuVySFMEPMOrTkTgmazU73v4Cb4x7p86jY1XXYUw==",
"license": "MIT",
"dependencies": {
"fontfaceobserver": "^2.1.0"
},
"peerDependencies": {
"expo": "*",
"react": "*"
}
},
"node_modules/expo/node_modules/expo-keep-awake": {
"version": "14.0.3",
"resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-14.0.3.tgz",

View file

@ -18,6 +18,7 @@
"expo-asset": "~11.0.5",
"expo-build-properties": "~0.13.3",
"expo-clipboard": "~7.0.1",
"expo-font": "~13.0.4",
"expo-haptics": "~14.0.1",
"expo-image": "~2.0.7",
"expo-intent-launcher": "~12.0.2",

View file

@ -1,124 +1,76 @@
/**
* Goon wordmark custom letterform, NOT a typed text in a font.
* Goon brand marks.
*
* 4 litery jako path geometry. Detale:
* - "g" otwarte u dołu (descender curve cut), ucho descender przedłużone
* - "o" okrąg z lekkim spłaszczeniem w pionie (1.0×0.95 aspect), counter
* z 60% wysokości (cięższe niż typical grotesk daje "weight")
* - "n" minimal grotesk, stem łączy się z arc bez serif joint
* - Kerning naturalny, ale "oo" lekko ciaśniejsze niż "go"/"on"
* Rework 2026-05-30: poprzednia wersja rysowała litery ręcznie jako SVG path
* geometry wychodziło krzywo (o-ka jako nachodzące elipsy, zniekształcone n).
* Teraz mamy General Sans Semibold jako realny font (useFonts w App.tsx), więc
* wordmark renderuje PRAWDZIWY tekst w tej rodzinie czysto i spójnie z resztą
* typografii.
*
* Viewbox 200×56 4 litery × ~44px width, używamy aspect ratio scale.
* Distinctive twist: dwutonowe "g[oo]n" środkowe "oo" w oxblood (brand accent),
* "g"+"n" w foreground. Czytelne nawet w małym headerze, wiąże logo z paletą.
*
* Użycie:
* <GoonWordmark width={120} color={theme.fg} />
* <GoonWordmark width={80} color={theme.accent} /> // dla splash
* <GoonWordmark size={26} /> // header
* <GoonWordmark size={48} mono /> // splash, jednolity kolor
*/
import React from 'react';
import Svg, { Path } from 'react-native-svg';
import { Text, View } from 'react-native';
import Svg, { Circle } from 'react-native-svg';
interface Props {
width?: number;
import { fonts, theme } from '../theme';
interface WordmarkProps {
/** fontSize wordmarku w px. */
size?: number;
/** Kolor liter g+n (oo zawsze accent, chyba że `mono`). */
color?: string;
/** Jednolity kolor (bez dwutonu) — np. na splash gdzie tło = accent. */
mono?: boolean;
}
export function GoonWordmark({ width = 120, color = '#F5EDE0' }: Props) {
const height = width * (56 / 200);
export function GoonWordmark({ size = 26, color = theme.fg, mono = false }: WordmarkProps) {
const base = {
fontFamily: fonts.display,
fontSize: size,
letterSpacing: -size * 0.03,
includeFontPadding: false as const,
};
const accent = mono ? color : theme.accent;
return (
<Svg width={width} height={height} viewBox="0 0 200 56" fill="none">
{/*
g descender extends below baseline. Counter = 12 radius hole.
Path: outer ellipse, inner counter (evenodd), descender hook from
bottom-right going down-left.
*/}
<Path
d="M 28 14
A 14 13 0 1 1 28 40
A 14 13 0 1 1 28 14
Z
M 28 22
A 6 5 0 1 0 28 32
A 6 5 0 1 0 28 22
Z
M 38 28
L 38 48
A 12 8 0 0 1 18 50"
fill={color}
stroke={color}
strokeWidth="0.5"
fillRule="evenodd"
/>
{/*
o slightly flat ellipse, heavy counter.
*/}
<Path
d="M 76 14
A 14 13 0 1 1 76 40
A 14 13 0 1 1 76 14
Z
M 76 22
A 6 5 0 1 0 76 32
A 6 5 0 1 0 76 22
Z"
fill={color}
fillRule="evenodd"
/>
{/* o #2 — tight pair z poprzednim o (kerning -2px) */}
<Path
d="M 118 14
A 14 13 0 1 1 118 40
A 14 13 0 1 1 118 14
Z
M 118 22
A 6 5 0 1 0 118 32
A 6 5 0 1 0 118 22
Z"
fill={color}
fillRule="evenodd"
/>
{/*
n grotesk z arc bez serif. Lewa stem prosty, prawa wychodzi z arc.
*/}
<Path
d="M 144 16
L 144 40
L 150 40
L 150 26
A 8 10 0 0 1 166 26
L 166 40
L 172 40
L 172 24
A 12 14 0 0 0 150 22
L 150 16
Z"
fill={color}
/>
</Svg>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<Text style={[base, { color }]}>g</Text>
<Text style={[base, { color: accent }]}>oo</Text>
<Text style={[base, { color }]}>n</Text>
</View>
);
}
/**
* Monogram tylko "g" + ucho descender, jako standalone icon.
* 56×56, używany jako adaptive-icon / splash mark.
* Monogram koncentryczny ring + wypełniona kropka (oxblood). Czyta się jako
* "o" z wordmarku, motyw soczewki/oka (watching) bez dosłowności. Czysty SVG
* (bez fontu) używany do generowania app-icon / adaptive-icon / splash PNG,
* gdzie custom font nie jest dostępny.
*
* <GoonMark size={48} />
*/
export function GoonMark({ size = 48, color = '#F5EDE0' }: { size?: number; color?: string }) {
export function GoonMark({
size = 48,
ringColor = theme.fg,
dotColor = theme.accent,
bg,
}: {
size?: number;
ringColor?: string;
dotColor?: string;
/** Opcjonalne tło (dla icon — np. theme.bg). Pominięte = przezroczyste. */
bg?: string;
}) {
// viewBox 100×100. Ring: cx50 cy50 r36, stroke 11. Dot: r15 wypełniony.
return (
<Svg width={size} height={size} viewBox="0 0 56 56" fill="none">
<Path
d="M 28 10
A 14 13 0 1 1 28 36
A 14 13 0 1 1 28 10
Z
M 28 18
A 6 5 0 1 0 28 28
A 6 5 0 1 0 28 18
Z
M 38 24
L 38 44
A 12 8 0 0 1 18 46"
fill={color}
fillRule="evenodd"
/>
<Svg width={size} height={size} viewBox="0 0 100 100" fill="none">
{bg ? <Circle cx={50} cy={50} r={50} fill={bg} /> : null}
<Circle cx={50} cy={50} r={36} stroke={ringColor} strokeWidth={11} fill="none" />
<Circle cx={50} cy={50} r={15} fill={dotColor} />
</Svg>
);
}

View file

@ -3,7 +3,7 @@
import { Image } from 'expo-image';
import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { theme } from '../theme';
import { fonts, theme } from '../theme';
import type { MovieOut } from '../types';
export function MoviePosterCard({
@ -101,7 +101,7 @@ const styles = StyleSheet.create({
paddingHorizontal: 6,
paddingVertical: 2,
},
newBadgeText: { color: theme.fg, fontSize: 10, fontWeight: '800', letterSpacing: 0.5 },
newBadgeText: { color: theme.fg, fontSize: 9, fontFamily: fonts.mono, fontWeight: '700', letterSpacing: 0.6 },
posterDimmed: { opacity: 0.45 },
watchedBadge: {
position: 'absolute',
@ -124,7 +124,7 @@ const styles = StyleSheet.create({
backgroundColor: 'rgba(0,0,0,0.5)',
},
progressFg: { height: 3, backgroundColor: theme.accent },
title: { color: theme.fg, fontSize: 13, fontWeight: '600', marginTop: 6 },
title: { color: theme.fg, fontSize: 13, fontFamily: fonts.display, marginTop: 6, letterSpacing: -0.2 },
titleDimmed: { color: theme.muted },
meta: { color: theme.muted, fontSize: 11, marginTop: 2 },
meta: { color: theme.muted, fontSize: 10, fontFamily: fonts.mono, marginTop: 2, letterSpacing: 0.5, textTransform: 'uppercase' },
});

View file

@ -27,7 +27,7 @@ import React, { useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import type { RootStackParamList } from '../navigation';
import { theme } from '../theme';
import { fonts, theme } from '../theme';
import type { SceneOut } from '../types';
import { Thumb } from './Thumb';
@ -190,22 +190,23 @@ const styles = StyleSheet.create({
durText: {
color: theme.fg,
fontSize: 11,
fontWeight: '600',
fontFamily: fonts.mono,
fontVariant: ['tabular-nums'],
},
title: {
color: theme.fg,
fontSize: 14,
fontWeight: '600',
fontFamily: fonts.display,
marginTop: 8,
letterSpacing: -0.2,
},
titleDim: { color: theme.muted },
meta: {
color: theme.muted,
fontSize: 11,
marginTop: 2,
letterSpacing: 0.3,
fontSize: 10,
fontFamily: fonts.mono,
marginTop: 3,
letterSpacing: 0.5,
textTransform: 'uppercase',
},
});

View file

@ -24,7 +24,7 @@ import { SitesScreen } from './screens/SitesScreen';
import { StudioScenesScreen } from './screens/StudioScenesScreen';
import { TagScenesScreen } from './screens/TagScenesScreen';
import { TagsScreen } from './screens/TagsScreen';
import { theme } from './theme';
import { fonts, theme } from './theme';
export type RootStackParamList = {
Scenes: undefined;
@ -90,8 +90,8 @@ function TopTabs({
const tabs: TopTab[] = ['Scenes', 'Movies', 'Sites'];
return (
<View style={{ flexDirection: 'row', gap: 16, paddingHorizontal: 12, alignItems: 'center' }}>
{/* Wordmark — branding po lewej, dystans 14px do pierwszego tabu. */}
<GoonWordmark width={64} color={theme.fg} />
{/* Wordmark — branding po lewej, dystans do pierwszego tabu. */}
<GoonWordmark size={24} />
<View style={{ width: 1, height: 18, backgroundColor: theme.border, marginRight: 2 }} />
{tabs.map((t) => {
const active = t === current;
@ -101,7 +101,7 @@ function TopTabs({
style={{
color: active ? theme.accent : theme.muted,
fontSize: 13,
fontWeight: active ? '700' : '500',
fontFamily: active ? fonts.display : fonts.medium,
letterSpacing: 0.3,
}}
>
@ -145,7 +145,7 @@ export function AppNavigator({ onLogout, client, appVersion }: AppNavigatorProps
<Stack.Navigator
screenOptions={{
headerStyle: { backgroundColor: theme.card },
headerTitleStyle: { color: theme.fg },
headerTitleStyle: { color: theme.fg, fontFamily: fonts.display },
headerTintColor: theme.accent,
contentStyle: { backgroundColor: theme.bg },
}}
@ -248,7 +248,7 @@ export function AppNavigator({ onLogout, client, appVersion }: AppNavigatorProps
/>
<Stack.Screen
name="AppLockSettings"
options={{ title: 'Blokada aplikacji' }}
options={{ title: 'App lock' }}
>
{() => <AppLockSettingsScreen />}
</Stack.Screen>

View file

@ -9,6 +9,7 @@ import {
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { GoonWordmark } from '../components/GoonWordmark';
import { markAccepted } from '../lib/agegate';
import { theme } from '../theme';
@ -65,7 +66,7 @@ export function AgeGateScreen({ onAccept }: Props) {
contentContainerStyle={styles.scroll}
showsVerticalScrollIndicator={false}
>
<Text style={styles.title}>goon</Text>
<GoonWordmark size={40} />
<Text style={styles.subtitle}>Adult content · Self-hosted only</Text>
<View style={styles.section}>

View file

@ -11,6 +11,7 @@ import {
View,
} from 'react-native';
import { GoonClient } from '../api';
import { GoonMark, GoonWordmark } from '../components/GoonWordmark';
import { saveCredentials } from '../storage';
import { theme } from '../theme';
@ -57,8 +58,9 @@ export function LoginScreen({ onAuthenticated }: Props) {
keyboardShouldPersistTaps="handled"
>
<View style={styles.brandBlock}>
<View style={styles.brandDot} />
<Text style={styles.title}>goon</Text>
<GoonMark size={44} bg="transparent" />
<View style={{ height: 14 }} />
<GoonWordmark size={44} />
<Text style={styles.subtitle}>self-hosted scene catalog</Text>
</View>

View file

@ -41,29 +41,26 @@ export const theme = {
} as const;
/**
* Font family pusty string = system default na iOS/Android (uniknięcie
* "font not found" warningów). Po zainstalowaniu custom fontów (TODO niżej):
* 1. `npx expo install expo-font expo-splash-screen`
* 2. Pobierz fonty:
* - General Sans (Fontshare, free): https://www.fontshare.com/fonts/general-sans
* - Geist Mono (Vercel, free OFL): https://github.com/vercel/geist-font
* 3. Skopiuj .ttf do `mobile/assets/fonts/`
* 4. W App.tsx:
* const [loaded] = useFonts({
* 'GeneralSans-Semibold': require('./assets/fonts/GeneralSans-Semibold.ttf'),
* 'GeneralSans-Regular': require('./assets/fonts/GeneralSans-Regular.ttf'),
* 'GeistMono-Regular': require('./assets/fonts/GeistMono-Regular.ttf'),
* });
* if (!loaded) return null; // lub SplashScreen.preventAutoHideAsync()
* 5. Zmień stałe poniżej na konkretne fontFamily strings.
* Font family General Sans (display + body, Fontshare) + Geist Mono (meta,
* Vercel OFL). Pliki .ttf w `mobile/assets/fonts/`, ładowane przez useFonts()
* w App.tsx (runtime load działa przez OTA bo expo-font native jest w APK).
*
* Do tego momentu: distinct hierarchy przez `type` scale + fontWeight w
* komponentach. System font (San Francisco / Roboto) jest OK temporary.
* WAŻNE (custom-font gotcha): RN NIE syntezuje weightów dla custom fontów
* fontWeight:'700' na fontFamily:'GeneralSans-Regular' NIE pogrubi. Trzeba
* jawnie wskazać rodzinę per-weight. Stąd 4 osobne sloty:
* - body Regular (400) domyślny tekst
* - medium Medium (500) labels, tab inactive
* - display Semibold (600) headingi, tytuły, bold
* - mono Geist Mono meta, duration, liczby, kategorie
*
* Gdy useFonts jeszcze nie ready, App.tsx blokuje render (fonty z bundla
* ładują się <100ms), więc te stałe zawsze valid przy pierwszym paint.
*/
export const fonts = {
display: undefined as string | undefined,
displayRegular: undefined as string | undefined,
mono: undefined as string | undefined,
body: 'GeneralSans-Regular',
medium: 'GeneralSans-Medium',
display: 'GeneralSans-Semibold',
mono: 'GeistMono-Regular',
} as const;
export function scoreColor(score: number): string {