style(mobile): UI overhaul - warm dark, oxblood, wordmark, 2-col grid

Audit przez impeccable.style/slop: aktualny theme byl literal "AI default
palette" - deep navy #08090F + purple #8B5CF6 + glow #A78BFA + brak custom
typography. Plus user feedback "wieksze miniaturki, mniej tekstu - to portal
video".

theme.ts:
- Warm dark: bg #15110D (charcoal z orange undertone), card #26201A, fg
  warm off-white #F5EDE0
- Accent: oxblood #B23A48 + amber secondary #D89B4A (brak purple, brak glow)
- type + space scale (1.25 ratio, 8/16/24/32 spacing) eksportowane
- Backwards-compat: accentDeep/Glow/Secondary/good/warn/bad zachowane
- Font scaffold: komentarz z instrukcja jak dodac General Sans + Geist Mono
  (Fontshare/Vercel free) - czeka na expo-font install

GoonWordmark + GoonMark: custom letterform SVG (4 litery jako path geometry,
flat ellipses + descender hook). Monogram standalone dla icon/splash.
Wstrzykniety do TopTabs (header) zamiast plain "" title.

ScenesScreen:
- 2-col 16:9 grid (SceneTile) zamiast full-width SceneRow (6 lini tekstu)
- Title 1 linijka, studio uppercase micro
- Wyrzucone z listy: performers, release_date, sources count, "watched"
  string - replaced check badge + dim
- Duration badge bottom-right thumb, fav badge top-left

OTA: faad1f92-541a-4241-81fd-9cf159173b7e live, runtime 1.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jtrzupek 2026-05-29 15:06:34 +02:00
parent 172642d55c
commit aa647dcf97
4 changed files with 367 additions and 20 deletions

View file

@ -0,0 +1,124 @@
/**
* Goon wordmark custom letterform, NOT a typed text in a font.
*
* 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"
*
* Viewbox 200×56 4 litery × ~44px width, używamy aspect ratio scale.
*
* Użycie:
* <GoonWordmark width={120} color={theme.fg} />
* <GoonWordmark width={80} color={theme.accent} /> // dla splash
*/
import React from 'react';
import Svg, { Path } from 'react-native-svg';
interface Props {
width?: number;
color?: string;
}
export function GoonWordmark({ width = 120, color = '#F5EDE0' }: Props) {
const height = width * (56 / 200);
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>
);
}
/**
* Monogram tylko "g" + ucho descender, jako standalone icon.
* 56×56, używany jako adaptive-icon / splash mark.
*/
export function GoonMark({ size = 48, color = '#F5EDE0' }: { size?: number; color?: string }) {
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>
);
}

View file

@ -4,6 +4,7 @@ import {
useNavigationContainerRef,
} from '@react-navigation/native';
import { BugReportFAB } from './components/BugReportFAB';
import { GoonWordmark } from './components/GoonWordmark';
import { GoonClient } from './api';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import React from 'react';
@ -88,7 +89,10 @@ function TopTabs({
}) {
const tabs: TopTab[] = ['Scenes', 'Movies', 'Sites'];
return (
<View style={{ flexDirection: 'row', gap: 14, paddingHorizontal: 12, alignItems: 'center' }}>
<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} />
<View style={{ width: 1, height: 18, backgroundColor: theme.border, marginRight: 2 }} />
{tabs.map((t) => {
const active = t === current;
return (
@ -96,8 +100,9 @@ function TopTabs({
<Text
style={{
color: active ? theme.accent : theme.muted,
fontSize: 14,
fontWeight: active ? '700' : '400',
fontSize: 13,
fontWeight: active ? '700' : '500',
letterSpacing: 0.3,
}}
>
{t}

View file

@ -118,7 +118,9 @@ export function ScenesScreen() {
<FlatList
data={items}
keyExtractor={(s) => s.id}
renderItem={({ item }) => <SceneRow scene={item} />}
numColumns={2}
renderItem={({ item }) => <SceneTile scene={item} />}
columnWrapperStyle={styles.gridRow}
ListHeaderComponent={!debouncedQ && activeCount === 0 ? <ContinueWatchingRail /> : null}
refreshing={isRefetching}
onRefresh={refetch}
@ -221,6 +223,80 @@ function FavoritesButton() {
);
}
/**
* SceneTile 2-col 16:9 grid item, minimal text (video portal).
*
* Per impeccable.style/slop + Jan feedback "większe miniaturki, mniej tekstu":
* - Thumb wypełnia szerokość kolumny (16:9 aspect ratio)
* - Title 1 linijka, system font, weight 600
* - Overlay: studio (mono small) + duration tylko na thumb, NIE pod
* - Wyrzucone: performers, release_date, sources count, "✓ watched" string
* (zastąpione check badge na thumb)
* - Long-press animated preview (zachowane)
*/
function SceneTile({ scene }: { scene: SceneOut }) {
const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList, 'Scenes'>>();
const [isPreviewing, setIsPreviewing] = useState(false);
const animatedUrl = scene.playback_sources.find((s) => s.animated_thumbnail_url)
?.animated_thumbnail_url;
const staticUrl = scene.playback_sources.find((s) => s.thumbnail_url)?.thumbnail_url;
const displayUrl = isPreviewing && animatedUrl ? animatedUrl : staticUrl ?? animatedUrl;
const startPreview = () => {
if (!animatedUrl) return;
setIsPreviewing(true);
Haptics.selectionAsync().catch(() => {});
};
const dim = scene.finished === true;
const dur = scene.duration_sec;
const durLabel =
dur && dur > 0
? dur >= 3600
? `${Math.floor(dur / 3600)}h${String(Math.floor((dur % 3600) / 60)).padStart(2, '0')}`
: `${Math.floor(dur / 60)}m`
: null;
return (
<Pressable
style={styles.tile}
onPress={() => navigation.navigate('SceneDetail', { id: scene.id })}
onLongPress={startPreview}
onPressOut={() => setIsPreviewing(false)}
delayLongPress={180}
>
<View style={[styles.tileThumbWrap, dim && styles.tileThumbDim]}>
<Thumb url={displayUrl} style={styles.tileThumb} />
{scene.is_favorite ? (
<View style={styles.tileFavBadge}>
<Text style={styles.tileFavBadgeText}></Text>
</View>
) : null}
{durLabel ? (
<View style={styles.tileDurBadge}>
<Text style={styles.tileDurText}>{durLabel}</Text>
</View>
) : null}
{dim ? (
<View style={styles.tileWatchedBadge}>
<Text style={styles.tileWatchedText}></Text>
</View>
) : null}
</View>
<Text style={[styles.tileTitle, dim && styles.tileTitleDim]} numberOfLines={1}>
{scene.title}
</Text>
{scene.studio?.name ? (
<Text style={styles.tileStudio} numberOfLines={1}>
{scene.studio.name}
</Text>
) : null}
</Pressable>
);
}
function SceneRow({ scene }: { scene: SceneOut }) {
const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList, 'Scenes'>>();
@ -429,4 +505,72 @@ const styles = StyleSheet.create({
marginTop: 6,
lineHeight: 15,
},
// 2-col grid (SceneTile) — wprowadzone 2026-05-29 (UI overhaul, Jan feedback
// "większe miniaturki, mniej tekstu"). Wcześniej był full-width SceneRow z 6
// liniami tekstu — sloppy density dla video portalu.
gridRow: { gap: 10, marginBottom: 14 },
tile: { flex: 1 },
tileThumbWrap: {
width: '100%',
aspectRatio: 16 / 9,
borderRadius: 6,
overflow: 'hidden',
position: 'relative',
backgroundColor: theme.bgElevated,
},
tileThumb: { width: '100%', height: '100%' },
tileThumbDim: { opacity: 0.45 },
tileFavBadge: {
position: 'absolute',
top: 6,
left: 6,
backgroundColor: 'rgba(0,0,0,0.7)',
paddingHorizontal: 5,
paddingVertical: 1,
borderRadius: 6,
},
tileFavBadgeText: { color: theme.accent, fontSize: 11, fontWeight: '700' },
tileDurBadge: {
position: 'absolute',
bottom: 6,
right: 6,
backgroundColor: 'rgba(0,0,0,0.78)',
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 4,
},
tileDurText: {
color: theme.fg,
fontSize: 11,
fontWeight: '600',
fontVariant: ['tabular-nums'],
},
tileWatchedBadge: {
position: 'absolute',
top: 6,
right: 6,
backgroundColor: 'rgba(0,0,0,0.78)',
width: 20,
height: 20,
borderRadius: 999,
alignItems: 'center',
justifyContent: 'center',
},
tileWatchedText: { color: theme.fg, fontSize: 11, fontWeight: '700' },
tileTitle: {
color: theme.fg,
fontSize: 14,
fontWeight: '600',
marginTop: 8,
letterSpacing: -0.2,
},
tileTitleDim: { color: theme.muted },
tileStudio: {
color: theme.muted,
fontSize: 11,
marginTop: 2,
letterSpacing: 0.3,
textTransform: 'uppercase',
},
});

View file

@ -1,26 +1,100 @@
/**
* Goon theme warm dark + oxblood, NOT generic navy/purple AI-default.
*
* Audit 2026-05-29 (impeccable.style/slop):
* - Wcześniej: bg #08090F (deep navy), accent #8B5CF6 (purple) + glow #A78BFA
* literal example "AI default palette" z impeccable.
* - Teraz: bg #15110D (warm charcoal orange undertone), accent #B23A48
* (oxblood/rust), brak glowów, brak gradientów neon.
*
* Typography: General Sans (display, Fontshare) + Geist Mono (meta, Vercel
* fonts). Font files w `mobile/assets/fonts/` `useFonts()` w App.tsx
* graceful-fallback do system gdy brak (development).
*/
export const theme = {
bg: '#08090F',
bgElevated: '#11131C',
card: '#1A1D2A',
border: '#262A3D',
borderFocus: '#8B5CF6',
// Warm dark — charcoal z orange undertone (NIE navy).
// Filmowy, premium feel — jak Letterboxd/Mubi/A24.
bg: '#15110D',
bgElevated: '#1E1A14',
card: '#26201A',
border: '#3A3128',
borderFocus: '#B23A48',
fg: '#F4F4F8',
muted: '#9CA0B5',
mutedDim: '#6B6F85',
// Foreground — warm off-white (nie pure white żeby nie biło na warm dark).
fg: '#F5EDE0',
muted: '#A89B85',
mutedDim: '#6F6555',
accent: '#8B5CF6',
accentGlow: '#A78BFA',
accentDeep: '#5B21B6',
accentSecondary: '#3B82F6',
// Accent — oxblood/rust. Distinctive, niekonwencjonalne dla "media app".
// Brak glow/neon. Deep wariant dla pressed states.
accent: '#B23A48',
accentGlow: '#B23A48', // alias — back-compat, ale bez prawdziwego glow
accentDeep: '#7A1F2A',
accentSecondary: '#D89B4A', // muted amber dla secondary CTAs
good: '#10B981',
bad: '#EF4444',
warn: '#F59E0B',
};
// Status — tonowane do palety (nie generic green/red).
good: '#5E8C5A', // muted olive-green
bad: '#C44545',
warn: '#D89B4A', // amber
} 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.
*
* Do tego momentu: distinct hierarchy przez `type` scale + fontWeight w
* komponentach. System font (San Francisco / Roboto) jest OK temporary.
*/
export const fonts = {
display: undefined as string | undefined,
displayRegular: undefined as string | undefined,
mono: undefined as string | undefined,
} as const;
export function scoreColor(score: number): string {
if (score >= 0.92) return theme.good;
if (score >= 0.75) return theme.warn;
return theme.bad;
}
/**
* Type-scale (impeccable: "at least 1.25 ratio between steps").
* Base 14 17 22 28 36. Użyj zamiast hardcodowanego fontSize.
*/
export const type = {
micro: 11,
meta: 13,
body: 14,
bodyLarge: 17,
title: 22,
display: 28,
hero: 36,
} as const;
/**
* Spacing scale "tight groupings, generous between sections" (impeccable).
* Pomijamy 12/20 preferuj 8/16/24/32 dla widocznej hierarchii sekcji.
*/
export const space = {
xs: 4,
sm: 8,
md: 16,
lg: 24,
xl: 32,
xxl: 48,
} as const;