feat(figgos): scaffold SvelteKit web app with neo-brutalist theme

SvelteKit 2 + Svelte 5 + Tailwind CSS 4 on port 5196.
Create and Collection pages matching the mobile neo-brutalist design system.
Follows monorepo patterns (adapter-node, shared-vite-config, type-check).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Chr1st1anG 2026-02-11 14:18:50 +01:00
parent 1c24ad9d5c
commit 95dd1d3e9e
16 changed files with 1003 additions and 139 deletions

View file

@ -8,7 +8,8 @@ A collectible figure game where users create and collect AI-generated fantasy fi
apps/figgos/
├── apps/
│ ├── backend/ # @figgos/backend - NestJS API (port 3025)
│ └── mobile/ # @figgos/mobile - Expo React Native app
│ ├── mobile/ # @figgos/mobile - Expo React Native app
│ └── web/ # @figgos/web - SvelteKit web app (port 5196)
├── packages/
│ └── shared/ # @figgos/shared - Shared types & constants
└── package.json
@ -20,6 +21,7 @@ apps/figgos/
```bash
pnpm dev:figgos:mobile # Start mobile app
pnpm dev:figgos:web # Start web app (port 5196)
pnpm dev:figgos:backend # Start backend
pnpm dev:figgos:app # Start web + backend together
pnpm dev:figgos:full # Start with auth + auto DB setup
@ -39,6 +41,7 @@ npx expo start --clear # Start with clean cache
## Technology Stack
- **Mobile**: React Native 0.81 + Expo SDK 54, NativeWind 4, Expo Router
- **Web**: SvelteKit 2.x, Svelte 5 (runes mode), Tailwind CSS 4
- **Backend**: NestJS 10, Drizzle ORM, PostgreSQL
- **Auth**: Mana Core Auth (JWT via @manacore/shared-nestjs-auth)
- **Shared**: `@figgos/shared` — all types inlined in `src/index.ts` (Node v24 ESM compat)
@ -50,7 +53,7 @@ npx expo start --clear # Start with clean cache
| App | Port |
|-----|------|
| Backend | 3025 |
| Web (planned) | 5181 |
| Web | 5196 |
## Environment Variables

23
apps/figgos/apps/web/.gitignore vendored Normal file
View file

@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

View file

@ -0,0 +1,30 @@
{
"name": "@figgos/web",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"type-check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
},
"devDependencies": {
"@manacore/shared-vite-config": "workspace:*",
"@sveltejs/adapter-node": "^5.4.0",
"@sveltejs/kit": "^2.50.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.1.7",
"svelte": "^5.49.2",
"svelte-check": "^4.3.6",
"tailwindcss": "^4.1.17",
"typescript": "^5.9.3",
"vite": "^7.3.1"
},
"dependencies": {
"@figgos/shared": "workspace:*"
}
}

View file

@ -0,0 +1,82 @@
@import "tailwindcss";
/* =============================================================================
Figgos Design System Neo-Brutalist Game UI (Tailwind v4)
Color strategy:
- Primary: Electric Yellow bold, in-your-face, action
- Secondary: Hot Pink accents, badges, highlights
- Surface: Deep navy dark but warm enough for contrast
- Borders: Thick, visible, part of the design language
- Shadows: Hard offset layers, not soft blurs
============================================================================= */
@theme {
--color-background: rgb(15, 15, 30);
--color-foreground: rgb(245, 245, 245);
--color-surface: rgb(26, 26, 53);
--color-surface-elevated: rgb(35, 35, 65);
--color-muted: rgb(55, 55, 80);
--color-muted-foreground: rgb(136, 136, 170);
/* Electric Yellow */
--color-primary: rgb(255, 204, 0);
--color-primary-foreground: rgb(15, 15, 30);
--color-primary-dark: rgb(179, 143, 0);
/* Hot Pink */
--color-secondary: rgb(255, 51, 102);
--color-secondary-foreground: rgb(255, 255, 255);
--color-secondary-dark: rgb(179, 35, 74);
/* Teal */
--color-accent: rgb(0, 210, 170);
--color-accent-foreground: rgb(15, 15, 30);
--color-accent-dark: rgb(0, 150, 120);
--color-destructive: rgb(255, 80, 80);
--color-destructive-foreground: rgb(15, 15, 30);
--color-border: rgb(255, 204, 0);
--color-border-muted: rgb(50, 50, 80);
--color-input: rgb(20, 20, 40);
--color-ring: rgb(255, 204, 0);
/* Rarity */
--color-rarity-common: rgb(136, 136, 170);
--color-rarity-common-foreground: rgb(245, 245, 245);
--color-rarity-rare: rgb(100, 180, 255);
--color-rarity-rare-foreground: rgb(15, 15, 30);
--color-rarity-epic: rgb(180, 130, 255);
--color-rarity-epic-foreground: rgb(15, 15, 30);
--color-rarity-legendary: rgb(255, 185, 30);
--color-rarity-legendary-foreground: rgb(25, 25, 25);
}
/* Base */
body {
background-color: var(--color-background);
color: var(--color-foreground);
font-family: system-ui, -apple-system, sans-serif;
min-height: 100dvh;
}
/* Neo-Brutalist utilities */
.brutal-shadow {
position: relative;
}
.brutal-shadow::before {
content: "";
position: absolute;
top: 5px;
left: 5px;
right: -5px;
bottom: -5px;
background-color: var(--color-primary-dark);
border-radius: inherit;
z-index: -1;
}

13
apps/figgos/apps/web/src/app.d.ts vendored Normal file
View file

@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

View file

@ -0,0 +1,52 @@
<script lang="ts">
import '../app.css';
import { page } from '$app/state';
let { children } = $props();
let isCreate = $derived(page.url.pathname === '/');
let isCollection = $derived(page.url.pathname === '/collection');
</script>
<svelte:head>
<title>Figgos</title>
</svelte:head>
<div class="min-h-dvh bg-background text-foreground">
<!-- Tab Navigation -->
<nav class="fixed bottom-0 left-0 right-0 z-50 border-t-3 border-border bg-surface">
<div class="mx-auto flex max-w-md items-center justify-around py-2">
<a
href="/"
class="flex flex-col items-center gap-1 px-6 py-2 transition-opacity hover:opacity-80 {isCreate
? 'text-primary'
: 'text-muted-foreground hover:text-primary'}"
>
<svg class="h-6 w-6" fill="currentColor" viewBox="0 0 24 24">
<path
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z"
/>
</svg>
<span class="text-xs font-black uppercase tracking-wider">Create</span>
</a>
<a
href="/collection"
class="flex flex-col items-center gap-1 px-6 py-2 transition-opacity hover:opacity-80 {isCollection
? 'text-primary'
: 'text-muted-foreground hover:text-primary'}"
>
<svg class="h-6 w-6" fill="currentColor" viewBox="0 0 24 24">
<path
d="M4 8h4V4H4v4zm6 12h4v-4h-4v4zm-6 0h4v-4H4v4zm0-6h4v-4H4v4zm6 0h4v-4h-4v4zm6-10v4h4V4h-4zm-6 4h4V4h-4v4zm6 6h4v-4h-4v4zm0 6h4v-4h-4v4z"
/>
</svg>
<span class="text-xs font-black uppercase tracking-wider">Collection</span>
</a>
</div>
</nav>
<!-- Page Content -->
<main class="pb-24">
{@render children()}
</main>
</div>

View file

@ -0,0 +1,214 @@
<script lang="ts">
import type { FigureResponse, FigureRarity } from '@figgos/shared';
let name = $state('');
let description = $state('');
let loading = $state(false);
let error = $state<string | null>(null);
let result = $state<FigureResponse | null>(null);
const RARITY_SHADOW: Record<FigureRarity, string> = {
common: 'rgb(80, 90, 100)',
rare: 'rgb(60, 120, 180)',
epic: 'rgb(120, 80, 180)',
legendary: 'rgb(180, 130, 20)',
};
async function handleGenerate() {
if (!name.trim() || !description.trim()) {
error = 'Give your figure a name and a story';
return;
}
loading = true;
error = null;
try {
await new Promise((r) => setTimeout(r, 1500));
const rarities: FigureRarity[] = ['common', 'common', 'common', 'rare', 'rare', 'epic', 'legendary'];
result = {
id: 'mock-id',
userId: 'mock-user',
name: name.trim(),
userInput: { description: description.trim() },
imageUrl: null,
rarity: rarities[Math.floor(Math.random() * rarities.length)],
isPublic: false,
isArchived: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
} catch (e: any) {
error = e.message || 'Something went wrong';
} finally {
loading = false;
}
}
function handleReset() {
name = '';
description = '';
result = null;
error = null;
}
</script>
{#if result}
<!-- ── Result Screen ── -->
<div class="mx-auto max-w-md px-6 pt-8">
<!-- Badge -->
<div class="mb-5 flex justify-center">
<span
class="inline-block rounded bg-secondary px-3.5 py-1 text-[11px] font-black uppercase tracking-[3px] text-secondary-foreground"
style="transform: rotate(-2deg)"
>
Unboxing
</span>
</div>
<!-- Figure Card -->
<div class="brutal-shadow rounded-lg">
<div
class="rounded-lg border-3 border-border bg-surface p-6"
>
<!-- Image placeholder -->
<div
class="mx-auto mb-5 flex h-[200px] w-[200px] items-center justify-center rounded-lg border-2 border-border-muted bg-input"
>
<span class="text-xs text-muted-foreground">Image coming soon</span>
</div>
<h2 class="text-center text-[22px] font-black tracking-tight text-foreground">
{result.name}
</h2>
<p class="mt-3 text-center text-sm leading-5 text-muted-foreground">
{result.userInput.description}
</p>
<!-- Rarity Badge -->
<div class="mt-4 flex justify-center">
<div class="relative">
<div
class="absolute rounded-full"
style="top: 3px; left: 2px; right: -2px; bottom: -3px; background-color: {RARITY_SHADOW[result.rarity]}"
></div>
<span
class="relative inline-block rounded-full border-2 border-white/20 px-5 py-2 text-xs font-black uppercase tracking-[2px]"
style="background-color: var(--color-rarity-{result.rarity}); color: var(--color-rarity-{result.rarity}-foreground)"
>
{result.rarity}
</span>
</div>
</div>
</div>
</div>
<!-- Create Another -->
<div class="mt-8">
<button onclick={handleReset} class="group w-full cursor-pointer">
<div class="relative">
<div
class="absolute rounded-lg bg-accent-dark"
style="top: 5px; left: 3px; right: -3px; bottom: -5px"
></div>
<div
class="relative rounded-lg border-2 border-white/15 bg-accent py-4 text-center text-base font-black uppercase tracking-wider text-accent-foreground transition-opacity group-hover:opacity-90"
>
Create Another
</div>
</div>
</button>
</div>
</div>
{:else}
<!-- ── Create Form ── -->
<div class="mx-auto max-w-md px-6">
<!-- Header -->
<div class="flex flex-col items-center pb-8 pt-10">
<span
class="mb-2 inline-block rounded bg-secondary px-3.5 py-1 text-[11px] font-black uppercase tracking-[3px] text-secondary-foreground"
style="transform: rotate(-2deg)"
>
New Drop
</span>
<h1 class="text-center text-[32px] font-black leading-tight tracking-tight text-foreground">
CREATE YOUR<br />FIGGO
</h1>
</div>
<!-- Form -->
<div>
<!-- Name -->
<label
class="mb-2 block text-[13px] font-black uppercase tracking-[3px] text-primary"
for="name"
>
Name
</label>
<div class="brutal-shadow mb-6 rounded-lg">
<input
id="name"
type="text"
bind:value={name}
maxlength={200}
placeholder="Captain Thunderstrike"
class="w-full rounded-lg border-3 border-border bg-input px-4 text-base text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
style="height: 52px"
/>
</div>
<!-- Story -->
<label
class="mb-2 block text-[13px] font-black uppercase tracking-[3px] text-primary"
for="story"
>
Story
</label>
<div class="brutal-shadow mb-6 rounded-lg">
<textarea
id="story"
bind:value={description}
maxlength={2000}
rows={4}
placeholder="A cyberpunk warrior with lightning gauntlets..."
class="w-full rounded-lg border-3 border-border bg-input px-4 py-3.5 text-base text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
style="min-height: 120px; resize: vertical"
></textarea>
</div>
<!-- Error -->
{#if error}
<div class="mb-4 rounded-lg border-2 border-destructive/30 bg-destructive/10 p-3">
<p class="text-center text-sm font-semibold text-destructive">{error}</p>
</div>
{/if}
<!-- Generate Button -->
<button
onclick={handleGenerate}
disabled={loading}
class="group w-full cursor-pointer disabled:cursor-not-allowed disabled:opacity-60"
>
<div class="relative">
<div
class="absolute rounded-lg bg-primary-dark"
style="top: 6px; left: 4px; right: -4px; bottom: -6px"
></div>
<div
class="relative rounded-lg border-3 border-[rgb(255,224,102)] bg-primary py-[18px] text-center text-lg font-black uppercase tracking-[2px] text-primary-foreground transition-opacity group-hover:opacity-90"
>
{#if loading}
<span class="inline-flex items-center gap-2">
<svg class="h-5 w-5 animate-spin" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Rolling...
</span>
{:else}
Generate Figgo
{/if}
</div>
</div>
</button>
</div>
</div>
{/if}

View file

@ -0,0 +1,23 @@
<script lang="ts">
</script>
<div class="flex min-h-[60vh] items-center justify-center px-8">
<!-- Empty state card -->
<div class="brutal-shadow rounded-lg">
<div
class="flex flex-col items-center rounded-lg border-3 border-border bg-surface px-8 py-8"
>
<div
class="mb-4 flex h-14 w-14 items-center justify-center rounded-lg border-2 border-border-muted bg-input"
>
<span class="text-2xl">📦</span>
</div>
<h2 class="text-lg font-black tracking-tight text-foreground">
No figures yet
</h2>
<p class="mt-2 text-center text-sm leading-5 text-muted-foreground">
Create your first Figgo<br />to start your collection.
</p>
</div>
</div>
</div>

View file

@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:

View file

@ -0,0 +1,15 @@
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
out: 'build',
}),
},
};
export default config;

View file

@ -0,0 +1,19 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
}

View file

@ -0,0 +1,18 @@
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
import { MANACORE_SHARED_PACKAGES } from '@manacore/shared-vite-config';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()],
server: {
port: 5196,
strictPort: true,
},
ssr: {
noExternal: [...MANACORE_SHARED_PACKAGES, '@figgos/shared'],
},
optimizeDeps: {
exclude: [...MANACORE_SHARED_PACKAGES, '@figgos/shared'],
},
});

629
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff