ilhamdev commited on
Commit
cf30fac
·
1 Parent(s): e692546

feat: implement real before/after preview, Neubrutalism UI redesign, production readiness fixes, and update README

Browse files
.gitattributes CHANGED
@@ -33,3 +33,7 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ *.png filter=lfs diff=lfs merge=lfs -text
37
+ *.jpg filter=lfs diff=lfs merge=lfs -text
38
+ *.jpeg filter=lfs diff=lfs merge=lfs -text
39
+ *.webp filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dependencies
2
+ node_modules/
3
+ frontend/node_modules/
4
+
5
+ # Next.js Build Outputs
6
+ frontend/.next/
7
+ frontend/out/
8
+ build/
9
+ dist/
10
+
11
+ # Local Environment Secrets
12
+ .env
13
+ .env*.local
14
+ frontend/.env.local
15
+ frontend/.env*.local
16
+
17
+ # Logs and System Files
18
+ *.log
19
+ npm-debug.log*
20
+ yarn-debug.log*
21
+ yarn-error.log*
22
+ .DS_Store
23
+ Thumbs.db
24
+ .vercel
DESIGN.md ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ version: "alpha"
3
+ name: "Neubrutalism"
4
+ description: "Neubrutalist interface. Ideal for landing pages, saas. AI-ready template."
5
+ colors:
6
+ primary: "#FFEB3B"
7
+ secondary: "#FF5252"
8
+ tertiary: "#2196F3"
9
+ neutral: "#000000"
10
+ typography:
11
+ h1:
12
+ fontFamily: System UI stack
13
+ fontSize: 2.25rem
14
+ fontWeight: 700
15
+ body-md:
16
+ fontFamily: System UI stack
17
+ fontSize: 1rem
18
+ fontWeight: 400
19
+ label-caps:
20
+ fontFamily: System UI stack
21
+ fontSize: 0.75rem
22
+ fontWeight: 500
23
+ components:
24
+ button-primary:
25
+ backgroundColor: "{colors.primary}"
26
+ textColor: "{colors.neutral}"
27
+ padding: 12px
28
+ ---
29
+
30
+ ## Overview
31
+
32
+ Neubrutalist interface. Ideal for landing pages, saas. AI-ready template. Brutalism on the web was always a provocation. Raw HTML, system fonts, zero polish — it said "I don't care about your expectations." Neubrutalism kept the attitude but ditched the hostility. It's brutalism that actually wants you to click the button.
33
+
34
+ The turning point was Gumroad's 2021 redesign. Sahil Lavingia stripped the product down to thick black borders, flat primary colors, and offset drop shadows that looked like someone dragged a rectangle two pixels southeast. It was loud, playful, and unmistakably intentional. Designers noticed. Within months, the Figma community had dozens of neubrutalist UI kits climbing the charts.
35
+
36
+ What separates it from classic brutalism is warmth. Where brutalism embraced ugliness as ideology, neubrutalism uses bold geometry as decoration. The borders are thick but the corners can be rounded. The shadows are hard but the palette is candy-colored. It's confrontational aesthetics made approachable — punk rock in a good mood.
37
+
38
+ - Density: 5/10 — Balanced
39
+ - Variance: 4/10 — Moderate
40
+ - Motion: 4/10 — Subtle
41
+
42
+ - **Style:** Bold, Colorful, Raw, Playful
43
+ - **Keywords:** Bold borders, black outlines, primary colors, thick shadows, no gradients, flat colors, 45° shadows, playful, Gen Z
44
+ - **Era:** 2020s Modern
45
+ - **Light/Dark:** ✓ Full / ✓ Full
46
+
47
+ ## Colors
48
+
49
+ - **#FFEB3B** (#FFEB3B) — Primary surface or dominant color
50
+ - **#FF5252** (#FF5252) — Secondary surface or text color
51
+ - **#2196F3** (#2196F3) — Supporting palette color
52
+ - **#000000** (#000000) — Supporting palette color
53
+
54
+
55
+ ## Typography
56
+
57
+ - **Display / Hero:** System UI stack (-apple-system, sans-serif) — Weight 700, tight tracking, used for headline impact
58
+ - **Body:** System UI stack (-apple-system, sans-serif) — Weight 400, 16px/1.6 line-height, max 72ch per line
59
+ - **UI Labels / Captions:** System UI stack (-apple-system, sans-serif) — 0.875rem, weight 500, slight letter-spacing
60
+ - **Monospace:** JetBrains Mono — Used for code, metadata, and technical values
61
+
62
+ Scale:
63
+ - Hero: clamp(2.5rem, 5vw, 4rem)
64
+ - H1: 2.25rem
65
+ - H2: 1.5rem
66
+ - Body: 1rem / 1.6
67
+ - Small: 0.875rem
68
+
69
+
70
+ ## Layout
71
+
72
+ - **Grid:** CSS Grid primary. Max-width containment: 1280px centered with 1.5rem side padding.
73
+ - **Spacing rhythm:** Balanced. Base unit: 0.5rem (8px).
74
+ - **Section vertical gaps:** clamp(4rem, 8vw, 8rem).
75
+ - **Hero layout:** Split-screen (text left, visual right).
76
+ - **Feature sections:** Zig-zag alternating text+image rows. No 3-equal-columns.
77
+ - **Mobile collapse:** All multi-column layouts collapse below 768px. No horizontal overflow.
78
+ - **z-index contract:** base (0) / sticky-nav (100) / overlay (200) / modal (300) / toast (500).
79
+
80
+
81
+ ## Elevation & Depth
82
+
83
+ box-shadow: 4px 4px 0 #000, border: 3px solid #000, no gradients, sharp corners (0px), bold typography
84
+
85
+ - **Physics:** Ease-out curves, 200-300ms duration. Smooth and predictable.
86
+ - **Entry animations:** Fade + translate-Y (16px → 0) over 420ms ease-out. Staggered cascades for lists: 80ms between items.
87
+ - **Hover states:** Subtle color shift + shadow adjustment over 200ms.
88
+ - **Page transitions:** Fade only (200ms).
89
+ - **Performance:** Only transform and opacity animated. No layout-triggering properties.
90
+
91
+
92
+ ## Shapes
93
+
94
+ Base corner radius: 8px. See rounded tokens in front matter for the full scale.
95
+
96
+
97
+ ## Components
98
+
99
+ - **Primary Button:** Subtly rounded (0.5rem) shape. Accent color fill. Hover: 8% darken + subtle lift shadow. Active: -1px translate tactile press. Font weight 600. No outer glows.
100
+ - **Secondary / Ghost Button:** Outline variant. 1.5px border in muted color. Text in primary color. Hover: subtle background fill.
101
+ - **Cards:** Subtly rounded (0.5rem) corners. Surface background. Subtle shadow (0 2px 12px rgba(0,0,0,0.06)). 1px border stroke.
102
+ - **Inputs:** Label above input. 1px border stroke. Focus ring: 2px accent color offset 2px. Error text below in semantic red. No floating labels.
103
+ - **Navigation:** Primary surface background. Active item: accent color indicator. Font weight 500 when active.
104
+ - **Skeletons:** Shimmer animation matching component dimensions. No circular spinners.
105
+ - **Empty States:** Icon-based composition with descriptive text and action button.
106
+
107
+
108
+ ## Do's and Don'ts
109
+
110
+ - No emojis in UI — use icon system only (Lucide, Heroicons)
111
+ - No pure black (#000000) — use off-black or charcoal variants
112
+ - No oversaturated accent colors (saturation cap: 80%)
113
+ - No 3-column equal-width feature layouts — use zig-zag or asymmetric grid
114
+ - No `h-screen` — use `min-h-[100dvh]`
115
+ - No AI copywriting clichés: "Elevate", "Seamless", "Unleash", "Next-Gen"
116
+ - No broken external image links — use picsum.photos or inline SVG
117
+ - No generic lorem ipsum in demos
118
+
119
+ - Do Hard borders (2-4px)
120
+ - Do Hard offset shadows
121
+ - Do High saturation colors
122
+ - Do Bold typography
123
+ - Do No blurs/gradients
124
+ - Do Distinctive 'ugly-cute' look
125
+
126
+
127
+ ## Use Case
128
+
129
+ Landing pages, SaaS
README.md CHANGED
@@ -10,4 +10,159 @@ app_file: app.py
10
  pinned: true
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  pinned: true
11
  ---
12
 
13
+ # RemBG: Pemotong Latar Belakang Foto Berbasis AI
14
+
15
+ Aplikasi web modern untuk menghapus latar belakang foto secara otomatis dengan hasil PNG transparan 32-bit RGBA. Menggabungkan frontend **Next.js 14** bertema **Neubrutalism** dengan backend **Hugging Face ZeroGPU** bertenaga pustaka Python `rembg` (BiRefNet & ISNet).
16
+
17
+ ---
18
+
19
+ ## Pratinjau Tampilan Antarmuka
20
+
21
+ ### 1. Halaman Utama (Neubrutalism Light Mode)
22
+ ![Tampilan Halaman Utama](docs/screenshots/hero-preview.png)
23
+
24
+ ### 2. Pratinjau Interaktif Sebelum & Sesudah (Before/After Slider)
25
+ Bandingkan foto asli referensi dengan hasil pemisahan latar belakang riil dari model `BiRefNet Portrait`:
26
+ ![Slider Sebelum dan Sesudah](docs/screenshots/before-after-slider.png)
27
+
28
+ ### 3. Editor Kerja Utama (Workspace Pemrosesan)
29
+ Area unggah drag-and-drop, seleksi model segmentasi, opsi alpha matting, dan inspeksi hasil:
30
+ ![Editor Kerja Utama](docs/screenshots/main-workspace.png)
31
+
32
+ ---
33
+
34
+ ## Fitur Utama
35
+
36
+ - **Katalog 6 Model Segmentasi AI:**
37
+ - `BiRefNet Portrait`: Khusus potret manusia, mendeteksi helai rambut halus dan lekuk pakaian.
38
+ - `BiRefNet General`: Optimal untuk fotografi produk, katalog e-commerce, dan objek benda padat.
39
+ - `ISNet General`: Pemrosesan cepat dengan batas tepi tegas untuk subjek umum.
40
+ - `ISNet Anime`: Segmentasi spesifik ilustrasi 2D, karakter anime, dan grafik vektor.
41
+ - `U2Net Standard`: Model segmentasi serbaguna dengan stabilitas tinggi.
42
+ - `U2Net Human`: Segmentasi figur manusia ringan untuk kebutuhan cepat.
43
+ - **Alpha Matting Tepi Halus:** Opsi erosi piksel tepi dan ambang batas foreground/background untuk helai rambut tipis.
44
+ - **Slider Perbandingan Interaktif:** Tuas perbandingan Before/After responsif dengan kendali mouse, sentuhan layar (touch-friendly), dan aksesibilitas keyboard (`input[type="range"]`).
45
+ - **Keamanan & Anti-Spam:**
46
+ - Dilindungi Cloudflare Turnstile tanpa captcha yang mengganggu kenyamanan pengguna.
47
+ - Dilengkapi *in-memory sliding window IP rate limiter* (15 req/menit) untuk mencegah bot flood.
48
+ - **Privasi Data Penuh:** Berkas gambar hanya diproses sementara di memori GPU terisolasi (ZeroGPU) dan langsung dibuang setelah kanal alpha diekstrak. Tidak ada penyimpanan permanen di server.
49
+ - **Halaman Error 404 Kustom:** Tampilan 404 berdesain Neubrutalism yang aman tanpa kebocoran informasi internal server.
50
+ - **Telemetri Tanpa Pelacak Pribadi:** Modul telemetri aman yang siap dihubungkan dengan Google Analytics (`gtag`) atau Plausible tanpa merekam berkas gambar atau identitas pengguna.
51
+
52
+ ---
53
+
54
+ ## Arsitektur Sistem
55
+
56
+ ```mermaid
57
+ flowchart LR
58
+ A[Pengguna / Browser] -->|Unggah Gambar + Turnstile| B[Frontend Next.js 14]
59
+ B -->|Validasi Ukuran & Rate Limit| C[API Route /api/remove-bg]
60
+ C -->|Base64 Payload via SSE| D[Hugging Face Space ZeroGPU]
61
+ D -->|Inferensi Model BiRefNet / ISNet| E[Pustaka Python rembg]
62
+ E -->|Kanal Alpha Transparan RGBA| D
63
+ D -->|Stream Event complete| C
64
+ C -->|Data URL PNG 32-bit| B
65
+ B -->|Pratinjau Checkerboard & Unduh| A
66
+ ```
67
+
68
+ ---
69
+
70
+ ## Struktur Direktori Proyek
71
+
72
+ ```text
73
+ rembg/
74
+ ├── app.py # Entry point backend Gradio & API ZeroGPU
75
+ ├── requirements.txt # Dependensi Python (rembg, gradio, spaces, pillow)
76
+ ├── README.md # Dokumentasi proyek & konfigurasi HF Space
77
+ ├── DESIGN.md # Panduan desain Neubrutalism (warna, border, tipografi)
78
+ ├── docs/
79
+ │ └── screenshots/ # Tangkapan layar dokumentasi
80
+ └── frontend/ # Aplikasi web Next.js 14
81
+ ├── package.json
82
+ ├── next.config.js
83
+ ├── vercel.json # Konfigurasi deployment Vercel
84
+ ├── .env.example # Contoh variabel lingkungan
85
+ ├── public/
86
+ │ └── samples/ # Sampel foto sebelum dan sesudah AI
87
+ └── src/
88
+ ├── app/
89
+ │ ├── globals.css # Sistem desain Neubrutalism lengkap
90
+ │ ├── layout.js # Metadata, Schema.org JSON-LD, OpenGraph
91
+ │ ├── page.js # UI utama, dropzone, slider before/after
92
+ │ ├── not-found.js # Halaman 404 kustom
93
+ │ ├── robots.js # Generator robots.txt dinamis
94
+ │ ├── sitemap.js # Generator sitemap.xml dinamis
95
+ │ ├── icon.svg # Favicon Neubrutalist
96
+ │ └── api/
97
+ │ ├── remove-bg/ # Handler pemrosesan utama + Turnstile
98
+ │ └── demo-remove-bg/ # Handler live demo sampel
99
+ └── lib/
100
+ ├── analytics.js # Dispatcher telemetri aman privasi
101
+ ├── rate-limit.js # Sliding-window rate limiter
102
+ └── turnstile.js # Verifikator server-side Cloudflare Turnstile
103
+ ```
104
+
105
+ ---
106
+
107
+ ## Panduan Menjalankan Secara Lokal
108
+
109
+ ### 1. Menjalankan Backend Python (Gradio)
110
+
111
+ Pastikan Python 3.10+ terpasang:
112
+
113
+ ```bash
114
+ # Pasang dependensi Python
115
+ pip install -r requirements.txt
116
+
117
+ # Jalankan server Gradio
118
+ python app.py
119
+ ```
120
+ Aplikasi backend akan aktif di `http://localhost:7860`.
121
+
122
+ ### 2. Menjalankan Frontend Next.js
123
+
124
+ Masuk ke folder `frontend`:
125
+
126
+ ```bash
127
+ cd frontend
128
+
129
+ # Pasang dependensi Node.js
130
+ npm install
131
+
132
+ # Buat berkas environment lokal dari template
133
+ cp .env.example .env.local
134
+
135
+ # Jalankan server pengembang
136
+ npm run dev
137
+ ```
138
+ Buka browser di `http://localhost:3000`.
139
+
140
+ ### 3. Menjalankan Pengujian Unit
141
+
142
+ ```bash
143
+ cd frontend
144
+ npm test
145
+ ```
146
+
147
+ ---
148
+
149
+ ## Konfigurasi Variabel Lingkungan (`frontend/.env.local`)
150
+
151
+ | Variabel | Keterangan | Nilai Contoh |
152
+ |---|---|---|
153
+ | `NEXT_PUBLIC_SITE_URL` | Domain kanonikal untuk sitemap dan SEO | `https://rembg.vercel.app` |
154
+ | `NEXT_PUBLIC_HF_SPACE_URL` | Alamat backend Hugging Face ZeroGPU | `https://ilhamdev-rembg.hf.space` |
155
+ | `NEXT_PUBLIC_TURNSTILE_SITE_KEY` | Site Key Cloudflare Turnstile | Disediakan Cloudflare |
156
+ | `TURNSTILE_SECRET_KEY` | Secret Key Cloudflare Turnstile | Disediakan Cloudflare |
157
+ | `NEXT_PUBLIC_GSC_VERIFICATION` | Token verifikasi Google Search Console (opsional) | `google-site-verification-...` |
158
+ | `NEXT_PUBLIC_GA_ID` | Google Analytics Measurement ID (opsional) | `G-XXXXXXXXXX` |
159
+
160
+ > **Catatan Pengujian Lokal:** Jika variabel Turnstile dikosongkan saat pengujian lokal, sistem otomatis menggunakan Dummy Test Key resmi dari Cloudflare (`1x00000000000000000000AA`).
161
+
162
+ ---
163
+
164
+ ## Lisensi & Atribusi
165
+
166
+ - Pustaka Segmentasi: [danielgatis/rembg](https://github.com/danielgatis/rembg)
167
+ - Model BiRefNet: [ZhengPeng7/BiRefNet](https://github.com/ZhengPeng7/BiRefNet)
168
+ - Hosting Inferensi: [Hugging Face Spaces (ZeroGPU)](https://huggingface.co/spaces/ilhamdev/rembg)
docs/screenshots/before-after-slider.png ADDED

Git LFS Details

  • SHA256: 35ac66335b2961b3f0e48214ee9033ff4961da85620b5511e7141050a618a89f
  • Pointer size: 131 Bytes
  • Size of remote file: 303 kB
docs/screenshots/hero-preview.png ADDED

Git LFS Details

  • SHA256: 33fac1bf3303134dcf17bb6366874c7ec76baba948c568748ffefc30fe84a23c
  • Pointer size: 131 Bytes
  • Size of remote file: 136 kB
docs/screenshots/main-workspace.png ADDED

Git LFS Details

  • SHA256: a89c7d560dd4ffc65ff64df22be6a4a464bd23b5ac352bdfd6ab748bbaf273f3
  • Pointer size: 130 Bytes
  • Size of remote file: 77.5 kB
frontend/.env.example CHANGED
@@ -9,3 +9,9 @@ NEXT_PUBLIC_HF_SPACE_URL=https://ilhamdev-rembg.hf.space
9
  # Catatan: Jika dikosongkan saat dev lokal, sistem otomatis menggunakan Official Dummy Test Key Cloudflare.
10
  NEXT_PUBLIC_TURNSTILE_SITE_KEY=
11
  TURNSTILE_SECRET_KEY=
 
 
 
 
 
 
 
9
  # Catatan: Jika dikosongkan saat dev lokal, sistem otomatis menggunakan Official Dummy Test Key Cloudflare.
10
  NEXT_PUBLIC_TURNSTILE_SITE_KEY=
11
  TURNSTILE_SECRET_KEY=
12
+
13
+ # Google Search Console Verification Token (Opsional, jika menggunakan metode HTML tag)
14
+ # NEXT_PUBLIC_GSC_VERIFICATION=
15
+
16
+ # Web Analytics (Opsional: Google Analytics Measurement ID, misal: G-XXXXXXXXXX)
17
+ # NEXT_PUBLIC_GA_ID=
frontend/package.json CHANGED
@@ -6,7 +6,7 @@
6
  "dev": "next dev",
7
  "build": "next build",
8
  "start": "next start",
9
- "test": "node --test src/lib/turnstile.test.js"
10
  },
11
  "dependencies": {
12
  "@marsidev/react-turnstile": "^1.6.1",
 
6
  "dev": "next dev",
7
  "build": "next build",
8
  "start": "next start",
9
+ "test": "node --test \"src/lib/*.test.js\""
10
  },
11
  "dependencies": {
12
  "@marsidev/react-turnstile": "^1.6.1",
frontend/public/samples/background-removal-after.png ADDED

Git LFS Details

  • SHA256: 4bd6d043021334479ec2f2e4fc02b76123c90d96a515afb8c6625301c5ae0b66
  • Pointer size: 131 Bytes
  • Size of remote file: 152 kB
frontend/public/samples/background-removal-before.png ADDED

Git LFS Details

  • SHA256: 85a4cef42dd21b805651fcda32fd20297f3f7c1b9b8ec1a6fcfa9bc430cfb616
  • Pointer size: 131 Bytes
  • Size of remote file: 270 kB
frontend/src/app/api/demo-remove-bg/route.js ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * API Route: /api/demo-remove-bg
3
+ *
4
+ * Menjalankan inferensi model BiRefNet riil untuk foto contoh (background-removal-before.png).
5
+ * Memanggil HuggingFace ZeroGPU Gradio Space (ilhamdev-rembg) secara langsung.
6
+ */
7
+
8
+ import fs from 'fs'
9
+ import path from 'path'
10
+ import { checkRateLimit } from '@/lib/rate-limit'
11
+
12
+ const HF_SPACE_URL = (process.env.NEXT_PUBLIC_HF_SPACE_URL || 'https://ilhamdev-rembg.hf.space').replace(/\/+$/, '')
13
+ const TIMEOUT_MS = 90_000
14
+
15
+ export async function POST(request) {
16
+ try {
17
+ const clientIp = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || request.headers.get('cf-connecting-ip') || '127.0.0.1'
18
+ const rateLimit = checkRateLimit(`demo-${clientIp}`, 10, 60_000)
19
+ if (!rateLimit.allowed) {
20
+ return Response.json(
21
+ { error: 'Terlalu banyak permintaan demo. Harap tunggu sesaat sebelum mencoba lagi.' },
22
+ {
23
+ status: 429,
24
+ headers: { 'Retry-After': Math.ceil(rateLimit.resetMs / 1000).toString() },
25
+ }
26
+ )
27
+ }
28
+
29
+ let modelName = 'birefnet-portrait'
30
+ let alphaMatting = false
31
+
32
+ try {
33
+ const body = await request.json()
34
+ if (body.model_name) modelName = body.model_name
35
+ if (typeof body.alpha_matting === 'boolean') alphaMatting = body.alpha_matting
36
+ } catch {
37
+ // Default jika tidak ada body json
38
+ }
39
+
40
+ // ── STEP 1: Baca berkas foto referensi dari public/samples ───────────────
41
+ const samplePath = path.join(process.cwd(), 'public', 'samples', 'background-removal-before.png')
42
+ if (!fs.existsSync(samplePath)) {
43
+ return Response.json(
44
+ { error: 'Berkas contoh background-removal-before.png tidak ditemukan di server' },
45
+ { status: 404 }
46
+ )
47
+ }
48
+
49
+ const fileBuffer = fs.readFileSync(samplePath)
50
+ const base64Data = fileBuffer.toString('base64')
51
+ const dataUrl = `data:image/png;base64,${base64Data}`
52
+
53
+ const payload = {
54
+ data: [dataUrl, modelName, alphaMatting],
55
+ }
56
+
57
+ // ── STEP 2: Panggil HuggingFace Gradio Space ────────────────────────────
58
+ const endpoint = `${HF_SPACE_URL}/gradio_api/call/remove_bg`
59
+ console.log('[Demo API] Menghubungi endpoint model riil:', endpoint, 'Model:', modelName)
60
+
61
+ const callRes = await fetch(endpoint, {
62
+ method: 'POST',
63
+ headers: {
64
+ 'Content-Type': 'application/json',
65
+ 'Accept': 'application/json',
66
+ },
67
+ body: JSON.stringify(payload),
68
+ })
69
+
70
+ if (!callRes.ok) {
71
+ const errText = await callRes.text()
72
+ console.error('[Demo API] Call failed:', callRes.status, errText.slice(0, 100))
73
+ return Response.json(
74
+ { error: `Gagal memanggil model ZeroGPU: status ${callRes.status}` },
75
+ { status: 502 }
76
+ )
77
+ }
78
+
79
+ const { event_id } = await callRes.json()
80
+ if (!event_id) {
81
+ return Response.json({ error: 'Tidak ada event_id dari HuggingFace' }, { status: 502 })
82
+ }
83
+
84
+ // ── STEP 3: Baca SSE Stream sampai complete ────────────────────────────
85
+ const sseUrl = `${endpoint}/${event_id}`
86
+ const sseController = new AbortController()
87
+ const sseTimer = setTimeout(() => sseController.abort(), TIMEOUT_MS)
88
+
89
+ let sseRes
90
+ try {
91
+ sseRes = await fetch(sseUrl, {
92
+ headers: { Accept: 'text/event-stream' },
93
+ signal: sseController.signal,
94
+ })
95
+ } finally {
96
+ clearTimeout(sseTimer)
97
+ }
98
+
99
+ if (!sseRes.ok) {
100
+ return Response.json(
101
+ { error: `Gagal membaca SSE status: ${sseRes.status}` },
102
+ { status: 502 }
103
+ )
104
+ }
105
+
106
+ const sseText = await sseRes.text()
107
+ const lines = sseText.split('\n')
108
+ let currentEvent = ''
109
+ let resultOutput = null
110
+ let errorMsg = null
111
+
112
+ for (const line of lines) {
113
+ const trimmed = line.trim()
114
+ if (trimmed.startsWith('event: ')) {
115
+ currentEvent = trimmed.slice(7).trim()
116
+ } else if (trimmed.startsWith('data: ')) {
117
+ const raw = trimmed.slice(6).trim()
118
+ if (currentEvent === 'error') {
119
+ errorMsg = raw
120
+ break
121
+ }
122
+ if (currentEvent === 'complete') {
123
+ try {
124
+ const parsed = JSON.parse(raw)
125
+ if (Array.isArray(parsed) && parsed.length > 0) {
126
+ resultOutput = parsed[0]
127
+ }
128
+ } catch {
129
+ console.error('[Demo SSE parse error]', raw.slice(0, 100))
130
+ }
131
+ }
132
+ }
133
+ }
134
+
135
+ if (errorMsg) {
136
+ return Response.json({ error: `Gradio inference error: ${errorMsg}` }, { status: 502 })
137
+ }
138
+
139
+ if (!resultOutput) {
140
+ return Response.json(
141
+ { error: 'ZeroGPU belum selesai atau sedang antre. Silakan coba kembali.' },
142
+ { status: 502 }
143
+ )
144
+ }
145
+
146
+ // ── STEP 4: Format hasil ────────────────────────────────────────────��───
147
+ if (typeof resultOutput === 'string') {
148
+ const finalImage = resultOutput.startsWith('data:')
149
+ ? resultOutput
150
+ : `data:image/png;base64,${resultOutput}`
151
+
152
+ return Response.json({
153
+ success: true,
154
+ image: finalImage,
155
+ model: modelName,
156
+ source: 'real-zerogpu-inference',
157
+ })
158
+ }
159
+
160
+ return Response.json({ error: 'Format output dari HuggingFace tidak dikenali' }, { status: 502 })
161
+ } catch (err) {
162
+ console.error('[Demo API Error]', err)
163
+ return Response.json({ error: err.message || 'Terjadi kesalahan pada server' }, { status: 500 })
164
+ }
165
+ }
frontend/src/app/api/remove-bg/route.js CHANGED
@@ -6,20 +6,43 @@
6
  */
7
 
8
  import { verifyTurnstileToken } from '@/lib/turnstile'
 
9
 
10
  const HF_SPACE_URL = (process.env.NEXT_PUBLIC_HF_SPACE_URL || 'https://ilhamdev-rembg.hf.space').replace(/\/+$/, '')
11
  const TIMEOUT_MS = 90_000 // 90 detik untuk toleransi ZeroGPU cold start
 
 
 
 
 
 
 
 
 
12
 
13
  export async function POST(request) {
14
  try {
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  const formData = await request.formData()
16
  const imageFile = formData.get('image')
17
- const modelName = formData.get('model_name') || 'birefnet-portrait'
 
18
  const alphaMatting = formData.get('alpha_matting') === 'true'
19
  const turnstileToken = formData.get('turnstile_token')
20
 
21
- // ── STEP 0: Verifikasi Keamanan Cloudflare Turnstile ──────────────────
22
- const clientIp = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || request.headers.get('cf-connecting-ip') || undefined
23
  const turnstileVerification = await verifyTurnstileToken({
24
  token: turnstileToken,
25
  remoteIp: clientIp,
@@ -32,14 +55,22 @@ export async function POST(request) {
32
  )
33
  }
34
 
35
- if (!imageFile) {
36
- return Response.json({ error: 'Tidak ada file gambar yang dikirim' }, { status: 400 })
37
  }
38
 
39
- // ── STEP 1: Konversi file gambar ke Base64 Data URL ────────────────────
 
 
 
 
 
 
 
 
 
40
  const arrayBuffer = await imageFile.arrayBuffer()
41
  const base64 = Buffer.from(arrayBuffer).toString('base64')
42
- const mimeType = imageFile.type || 'image/png'
43
  const dataUrl = `data:${mimeType};base64,${base64}`
44
 
45
  const payload = {
 
6
  */
7
 
8
  import { verifyTurnstileToken } from '@/lib/turnstile'
9
+ import { checkRateLimit } from '@/lib/rate-limit'
10
 
11
  const HF_SPACE_URL = (process.env.NEXT_PUBLIC_HF_SPACE_URL || 'https://ilhamdev-rembg.hf.space').replace(/\/+$/, '')
12
  const TIMEOUT_MS = 90_000 // 90 detik untuk toleransi ZeroGPU cold start
13
+ const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20MB
14
+ const ALLOWED_MODELS = [
15
+ 'birefnet-portrait',
16
+ 'birefnet-general',
17
+ 'isnet-general-use',
18
+ 'isnet-anime',
19
+ 'u2net',
20
+ 'u2net_human_seg',
21
+ ]
22
 
23
  export async function POST(request) {
24
  try {
25
+ // ── STEP 0: Identifikasi IP & Rate Limiting ────────────────────────────
26
+ const clientIp = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || request.headers.get('cf-connecting-ip') || '127.0.0.1'
27
+ const rateLimit = checkRateLimit(clientIp, 15, 60_000)
28
+ if (!rateLimit.allowed) {
29
+ return Response.json(
30
+ { error: 'Terlalu banyak permintaan. Harap tunggu sesaat sebelum mencoba lagi.' },
31
+ {
32
+ status: 429,
33
+ headers: { 'Retry-After': Math.ceil(rateLimit.resetMs / 1000).toString() },
34
+ }
35
+ )
36
+ }
37
+
38
  const formData = await request.formData()
39
  const imageFile = formData.get('image')
40
+ const rawModelName = formData.get('model_name') || 'birefnet-portrait'
41
+ const modelName = ALLOWED_MODELS.includes(rawModelName) ? rawModelName : 'birefnet-portrait'
42
  const alphaMatting = formData.get('alpha_matting') === 'true'
43
  const turnstileToken = formData.get('turnstile_token')
44
 
45
+ // ── STEP 1: Verifikasi Keamanan Cloudflare Turnstile ──────────────────
 
46
  const turnstileVerification = await verifyTurnstileToken({
47
  token: turnstileToken,
48
  remoteIp: clientIp,
 
55
  )
56
  }
57
 
58
+ if (!imageFile || typeof imageFile.arrayBuffer !== 'function') {
59
+ return Response.json({ error: 'Tidak ada file gambar yang valid dikirim' }, { status: 400 })
60
  }
61
 
62
+ if (imageFile.size > MAX_FILE_SIZE) {
63
+ return Response.json({ error: 'Ukuran file gambar melebihi batas maksimum 20MB' }, { status: 400 })
64
+ }
65
+
66
+ const mimeType = imageFile.type || 'image/png'
67
+ if (!mimeType.startsWith('image/')) {
68
+ return Response.json({ error: 'Format berkas tidak didukung. Harap unggah gambar JPG, PNG, atau WEBP.' }, { status: 400 })
69
+ }
70
+
71
+ // ── STEP 2: Konversi file gambar ke Base64 Data URL ────────────────────
72
  const arrayBuffer = await imageFile.arrayBuffer()
73
  const base64 = Buffer.from(arrayBuffer).toString('base64')
 
74
  const dataUrl = `data:${mimeType};base64,${base64}`
75
 
76
  const payload = {
frontend/src/app/globals.css CHANGED
@@ -1,64 +1,69 @@
1
- /* ===== STUDIO MINIMALIST DESIGN SYSTEM ===== */
2
 
3
  :root {
4
- /* Dark Theme (Default) */
5
- --bg-base: #0c0d0e;
6
- --bg-surface: #141518;
7
- --bg-card: #18191d;
8
- --bg-card-subtle: #212226;
9
- --bg-input: #121316;
10
-
11
- --border: rgba(255, 255, 255, 0.12);
12
- --border-subtle: rgba(255, 255, 255, 0.07);
13
- --border-strong: rgba(255, 255, 255, 0.22);
14
- --border-focus: #f3f4f6;
15
-
16
- --text-primary: #f3f4f6;
17
- --text-secondary: #9ca3af;
18
- --text-muted: #6b7280;
19
-
20
- --accent-cta-bg: #f9fafb;
21
- --accent-cta-text: #0c0d0e;
22
- --accent-cta-hover: #ffffff;
23
-
24
- --status-success: #22c55e;
25
- --status-warning: #f59e0b;
26
- --status-error: #ef4444;
 
 
 
 
 
27
 
28
  --radius-xs: 4px;
29
  --radius-sm: 6px;
 
30
  --radius-md: 8px;
31
  --radius-lg: 12px;
32
 
33
- --shadow-card: 0 1px 3px rgba(0, 0, 0, 0.35);
34
- --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.25);
35
  }
36
 
37
- [data-theme="light"] {
38
- /* Light Theme */
39
- --bg-base: #f9fafb;
40
- --bg-surface: #ffffff;
41
- --bg-card: #ffffff;
42
- --bg-card-subtle: #f3f4f6;
43
- --bg-input: #f3f4f6;
 
44
 
45
- --border: rgba(0, 0, 0, 0.12);
46
- --border-subtle: rgba(0, 0, 0, 0.06);
47
- --border-strong: rgba(0, 0, 0, 0.22);
48
- --border-focus: #111827;
49
 
50
- --text-primary: #111827;
51
- --text-secondary: #4b5563;
52
- --text-muted: #6b7280;
53
 
54
- --accent-cta-bg: #111827;
55
- --accent-cta-text: #ffffff;
56
- --accent-cta-hover: #000000;
57
-
58
- --shadow-card: 0 1px 3px rgba(0, 0, 0, 0.08);
59
- --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04);
60
  }
61
 
 
62
  *, *::before, *::after {
63
  box-sizing: border-box;
64
  margin: 0;
@@ -67,48 +72,79 @@
67
 
68
  html {
69
  scroll-behavior: smooth;
 
70
  }
71
 
72
  body {
73
- font-family: inherit;
74
  background-color: var(--bg-base);
75
  color: var(--text-primary);
76
- min-height: 100vh;
77
  line-height: 1.6;
78
  -webkit-font-smoothing: antialiased;
79
  -moz-osx-font-smoothing: grayscale;
80
  transition: background-color 0.2s ease, color 0.2s ease;
81
  }
82
 
83
- /* Accessible focus visible ring */
84
  :focus-visible {
85
- outline: 2px solid var(--border-focus);
86
- outline-offset: 2px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  }
88
 
89
- /* ===== APP LAYOUT ===== */
 
 
 
 
90
  .app-wrapper {
91
- min-height: 100vh;
92
  display: flex;
93
  flex-direction: column;
94
  background-color: var(--bg-base);
 
 
95
  }
96
 
 
97
  .container {
98
  width: 100%;
99
- max-width: 920px;
100
  margin: 0 auto;
101
- padding: 0 20px;
102
  }
103
 
104
- /* ===== HEADER ===== */
105
  .header {
106
- padding: 14px 0;
107
- border-bottom: 1px solid var(--border-subtle);
108
  background-color: var(--bg-surface);
 
109
  position: sticky;
110
  top: 0;
111
- z-index: 50;
112
  transition: background-color 0.2s ease;
113
  }
114
 
@@ -116,12 +152,13 @@ body {
116
  display: flex;
117
  align-items: center;
118
  justify-content: space-between;
 
119
  }
120
 
121
  .logo {
122
  display: inline-flex;
123
  align-items: center;
124
- gap: 10px;
125
  text-decoration: none;
126
  color: var(--text-primary);
127
  }
@@ -130,238 +167,480 @@ body {
130
  display: inline-flex;
131
  align-items: center;
132
  justify-content: center;
133
- width: 32px;
134
- height: 32px;
135
- border-radius: var(--radius-sm);
136
- background-color: var(--bg-card-subtle);
137
- border: 1px solid var(--border);
138
- color: var(--text-primary);
 
 
 
 
 
 
 
 
 
 
 
 
139
  }
140
 
141
  .logo-text {
142
- font-size: 16px;
143
- font-weight: 700;
144
- letter-spacing: -0.01em;
 
 
 
 
 
 
 
 
 
145
  }
146
 
147
  .header-meta {
148
  display: flex;
149
  align-items: center;
150
- gap: 10px;
151
  }
152
 
153
- /* Theme Toggle Button */
154
  .theme-toggle-btn {
155
  display: inline-flex;
156
  align-items: center;
157
- gap: 6px;
158
  min-height: 44px;
159
- padding: 0 12px;
160
  background-color: var(--bg-card);
161
- border: 1px solid var(--border);
162
- border-radius: var(--radius-sm);
163
- color: var(--text-secondary);
164
- font-size: 13px;
 
 
 
165
  cursor: pointer;
166
- transition: all 0.15s ease;
 
167
  }
168
 
169
  .theme-toggle-btn:hover {
170
- color: var(--text-primary);
171
- border-color: var(--border-strong);
 
 
 
 
 
 
 
172
  }
173
 
174
  .theme-toggle-label {
175
- font-size: 12px;
176
- font-weight: 500;
 
177
  }
178
 
 
179
  .hf-link {
180
- font-size: 13px;
181
- color: var(--text-secondary);
 
 
182
  text-decoration: none;
183
  display: inline-flex;
184
  align-items: center;
185
- gap: 5px;
186
  min-height: 44px;
187
- padding: 0 10px;
188
- border-radius: var(--radius-sm);
189
- border: 1px solid transparent;
190
- transition: all 0.15s ease;
 
 
 
191
  }
192
 
193
  .hf-link:hover {
194
- color: var(--text-primary);
195
- border-color: var(--border-subtle);
 
 
 
 
 
 
196
  }
197
 
198
- /* ===== HERO ===== */
199
  .hero {
200
- padding: 40px 0 28px;
201
- text-align: center;
202
  }
203
 
204
- .hero h1 {
205
- font-size: clamp(24px, 4vw, 36px);
206
- font-weight: 700;
207
- line-height: 1.25;
208
- letter-spacing: -0.02em;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
  color: var(--text-primary);
210
- margin-bottom: 10px;
211
  }
212
 
213
  .hero-desc {
214
- font-size: 15px;
215
- color: var(--text-secondary);
216
- max-width: 580px;
217
- margin: 0 auto;
218
  line-height: 1.6;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
219
  }
220
 
221
- /* ===== MAIN TOOL CARD ===== */
222
  .main-card {
223
  background-color: var(--bg-card);
224
- border: 1px solid var(--border);
225
- border-radius: var(--radius-lg);
226
- padding: 24px;
227
  box-shadow: var(--shadow-card);
 
228
  }
229
 
230
  /* Configuration grid */
231
  .config-grid {
232
  display: grid;
233
- grid-template-columns: 1fr 220px;
234
- gap: 16px;
235
- margin-bottom: 20px;
236
  }
237
 
238
  .field-group {
239
  display: flex;
240
  flex-direction: column;
241
- gap: 6px;
242
  }
243
 
244
  .field-label {
245
- font-size: 13px;
246
- font-weight: 600;
247
- color: var(--text-secondary);
 
 
 
248
  }
249
 
250
  .select-control {
251
  width: 100%;
252
- min-height: 44px;
253
- padding: 10px 12px;
254
  background-color: var(--bg-input);
255
- border: 1px solid var(--border);
256
- border-radius: var(--radius-sm);
 
257
  color: var(--text-primary);
258
- font-size: 13px;
259
- font-family: inherit;
 
260
  cursor: pointer;
261
  outline: none;
262
- transition: border-color 0.15s ease;
263
  }
264
 
265
  .select-control:focus {
266
- border-color: var(--border-focus);
 
 
 
 
 
 
267
  }
268
 
 
269
  .toggle-card {
270
  display: flex;
271
  align-items: center;
272
- gap: 10px;
273
- padding: 10px 12px;
274
  background-color: var(--bg-input);
275
- border: 1px solid var(--border);
276
- border-radius: var(--radius-sm);
 
277
  cursor: pointer;
278
  user-select: none;
279
- min-height: 44px;
 
 
 
 
 
 
280
  }
281
 
282
  .toggle-card input[type="checkbox"] {
283
- width: 18px;
284
- height: 18px;
285
  cursor: pointer;
286
- accent-color: var(--accent-cta-bg);
287
  }
288
 
289
  .toggle-text {
290
- font-size: 13px;
291
- color: var(--text-secondary);
 
 
292
  line-height: 1.3;
293
  }
294
 
295
- /* ===== DROPZONE & UPLOAD ===== */
296
  .dropzone {
297
- border: 1px dashed var(--border-strong);
298
- border-radius: var(--radius-md);
299
- background-color: var(--bg-surface);
300
- padding: 44px 20px;
 
301
  text-align: center;
302
  cursor: pointer;
303
  position: relative;
304
- transition: border-color 0.15s ease, background-color 0.15s ease;
305
  }
306
 
307
  .dropzone:hover,
308
  .dropzone.drag-active {
309
- border-color: var(--border-focus);
310
- background-color: var(--bg-card-subtle);
 
311
  }
312
 
313
  .dropzone-inner {
314
  display: flex;
315
  flex-direction: column;
316
  align-items: center;
317
- gap: 10px;
318
  }
319
 
320
  .dropzone-icon {
321
- width: 44px;
322
- height: 44px;
323
- color: var(--text-secondary);
 
 
 
 
324
  display: flex;
325
  align-items: center;
326
  justify-content: center;
327
  }
328
 
329
  .dropzone-title {
330
- font-size: 15px;
331
- font-weight: 600;
332
  color: var(--text-primary);
333
  }
334
 
335
  .dropzone-hint {
336
- font-size: 13px;
337
- color: var(--text-muted);
338
- max-width: 440px;
 
339
  }
340
 
341
  /* Uploaded Image Preview */
342
  .preview-container {
343
  display: flex;
344
  flex-direction: column;
345
- gap: 12px;
346
  }
347
 
348
  .preview-media-box {
349
- background-color: var(--bg-input);
350
- border: 1px solid var(--border);
351
- border-radius: var(--radius-md);
352
- padding: 16px;
 
353
  display: flex;
354
  justify-content: center;
355
  align-items: center;
356
- min-height: 240px;
357
- max-height: 420px;
358
  overflow: hidden;
359
  }
360
 
361
  .preview-img {
362
  max-width: 100%;
363
- max-height: 380px;
364
  object-fit: contain;
 
365
  border-radius: var(--radius-xs);
366
  }
367
 
@@ -371,185 +650,232 @@ body {
371
  justify-content: space-between;
372
  padding: 0 4px;
373
  flex-wrap: wrap;
374
- gap: 8px;
375
  }
376
 
377
  .meta-info {
378
- font-size: 12px;
379
- color: var(--text-secondary);
380
  display: flex;
381
  align-items: center;
382
- gap: 8px;
383
  flex-wrap: wrap;
384
  }
385
 
386
  .meta-filename {
387
- font-weight: 500;
388
  color: var(--text-primary);
389
- max-width: 260px;
390
  overflow: hidden;
391
  text-overflow: ellipsis;
392
  white-space: nowrap;
393
  }
394
 
395
  .meta-badge {
396
- background-color: var(--bg-input);
397
- border: 1px solid var(--border-subtle);
398
- padding: 3px 8px;
 
 
399
  border-radius: var(--radius-xs);
400
- font-size: 11px;
401
- color: var(--text-muted);
402
  }
403
 
404
  .btn-ghost-small {
405
- background: transparent;
406
- border: 1px solid var(--border);
407
- border-radius: var(--radius-sm);
408
- color: var(--text-secondary);
409
- font-size: 13px;
410
- font-weight: 500;
411
- padding: 0 14px;
 
 
412
  cursor: pointer;
413
- transition: all 0.15s ease;
414
  min-height: 44px;
415
  display: inline-flex;
416
  align-items: center;
 
417
  }
418
 
419
- .btn-ghost-small:hover {
420
- color: var(--text-primary);
421
- border-color: var(--border-strong);
 
 
 
 
 
 
 
422
  }
423
 
424
- /* Progress bar */
425
  .progress-wrapper {
426
- margin-top: 16px;
427
  display: flex;
428
  flex-direction: column;
429
- gap: 6px;
430
  }
431
 
432
  .progress-header {
433
  display: flex;
434
  justify-content: space-between;
435
- font-size: 12px;
436
- color: var(--text-secondary);
 
 
437
  }
438
 
439
  .progress-value {
440
- font-weight: 600;
441
- color: var(--text-primary);
442
  }
443
 
444
  .progress-track {
445
- height: 6px;
446
- background-color: var(--bg-input);
447
- border-radius: var(--radius-xs);
 
 
448
  overflow: hidden;
449
  }
450
 
451
  .progress-bar-fill {
452
  height: 100%;
453
- background-color: var(--text-primary);
 
454
  transition: width 0.3s ease;
455
  }
456
 
457
- /* Error message */
458
  .error-banner {
459
- margin-top: 14px;
460
- padding: 12px 14px;
461
- background-color: rgba(239, 68, 68, 0.10);
462
- border: 1px solid rgba(239, 68, 68, 0.30);
463
- border-radius: var(--radius-sm);
464
- color: #ef4444;
465
- font-size: 13px;
 
 
 
466
  display: flex;
467
  align-items: center;
468
- gap: 10px;
469
- }
470
-
471
- [data-theme="dark"] .error-banner {
472
- color: #fca5a5;
473
  }
474
 
475
  /* Turnstile container */
476
  .turnstile-box {
477
- margin-top: 20px;
478
  display: flex;
479
  flex-direction: column;
480
  align-items: center;
481
  justify-content: center;
482
- gap: 6px;
483
  min-height: 65px;
484
  }
485
 
486
  .turnstile-notice {
487
- font-size: 12px;
488
- color: var(--text-muted);
 
 
489
  }
490
 
491
  /* Action button area */
492
  .action-row {
493
- margin-top: 20px;
494
  display: flex;
495
- gap: 12px;
496
  }
497
 
 
498
  .btn-primary {
499
  flex: 1;
500
  display: inline-flex;
501
  align-items: center;
502
  justify-content: center;
503
- gap: 8px;
504
- background-color: var(--accent-cta-bg);
505
- color: var(--accent-cta-text);
506
- border: none;
507
- border-radius: var(--radius-sm);
508
- font-size: 14px;
509
- font-weight: 600;
510
- padding: 12px 20px;
 
 
511
  cursor: pointer;
512
- transition: opacity 0.15s ease;
513
- min-height: 44px;
 
514
  }
515
 
516
  .btn-primary:hover:not(:disabled) {
517
- opacity: 0.90;
 
 
 
 
 
 
 
518
  }
519
 
520
  .btn-primary:disabled {
521
- opacity: 0.40;
522
  cursor: not-allowed;
 
 
523
  }
524
 
 
525
  .btn-secondary {
526
  display: inline-flex;
527
  align-items: center;
528
  justify-content: center;
529
- gap: 6px;
530
- background-color: transparent;
531
- color: var(--text-secondary);
532
- border: 1px solid var(--border);
533
- border-radius: var(--radius-sm);
534
- font-size: 13px;
535
- font-weight: 500;
536
- padding: 10px 16px;
 
 
537
  cursor: pointer;
538
- transition: all 0.15s ease;
539
- min-height: 44px;
 
540
  }
541
 
542
  .btn-secondary:hover:not(:disabled) {
543
- color: var(--text-primary);
544
- border-color: var(--border-strong);
 
 
545
  }
546
 
547
- /* Minimalist loading spinner */
 
 
 
 
 
 
 
 
 
 
 
548
  .spinner-icon {
549
- width: 16px;
550
- height: 16px;
551
- border: 2px solid rgba(125, 125, 125, 0.3);
552
- border-top-color: currentColor;
553
  border-radius: 50%;
554
  animation: spin 0.6s linear infinite;
555
  }
@@ -558,50 +884,57 @@ body {
558
  to { transform: rotate(360deg); }
559
  }
560
 
561
- /* ===== RESULT SECTION ===== */
562
  .result-section {
563
- margin-top: 24px;
564
  background-color: var(--bg-card);
565
- border: 1px solid var(--border);
566
- border-radius: var(--radius-lg);
567
- padding: 24px;
568
  box-shadow: var(--shadow-card);
 
569
  }
570
 
571
  .result-header {
572
  display: flex;
573
  align-items: center;
574
  justify-content: space-between;
575
- margin-bottom: 16px;
576
  flex-wrap: wrap;
577
- gap: 12px;
 
 
578
  }
579
 
580
  .result-title-group {
581
  display: flex;
582
  align-items: center;
583
- gap: 10px;
 
584
  }
585
 
586
  .result-title {
587
- font-size: 15px;
588
- font-weight: 600;
589
  color: var(--text-primary);
590
  }
591
 
592
  .result-meta-badge {
593
- font-size: 11px;
594
- background-color: var(--bg-input);
595
- border: 1px solid var(--border-subtle);
596
- color: var(--text-muted);
597
- padding: 2px 8px;
 
 
 
598
  border-radius: var(--radius-xs);
599
  }
600
 
601
  .result-controls-bar {
602
  display: flex;
603
  align-items: center;
604
- gap: 10px;
605
  flex-wrap: wrap;
606
  }
607
 
@@ -609,217 +942,351 @@ body {
609
  .view-mode-group {
610
  display: flex;
611
  align-items: center;
612
- background-color: var(--bg-input);
613
- padding: 3px;
614
- border-radius: var(--radius-sm);
615
- border: 1px solid var(--border-subtle);
 
616
  }
617
 
618
  .control-tab-btn {
619
  background: transparent;
620
- border: none;
621
- font-size: 12px;
622
- color: var(--text-muted);
623
- padding: 0 12px;
 
 
624
  border-radius: var(--radius-xs);
625
  cursor: pointer;
626
- transition: all 0.15s ease;
627
  min-height: 44px;
628
  display: inline-flex;
629
  align-items: center;
 
630
  }
631
 
632
  .control-tab-btn.active {
633
- background-color: var(--bg-card);
634
- color: var(--text-primary);
635
- font-weight: 600;
636
- box-shadow: var(--shadow-sm);
637
  }
638
 
639
  /* Preview background inspector toggle */
640
  .bg-inspector-group {
641
  display: flex;
642
  align-items: center;
643
- background-color: var(--bg-input);
644
- padding: 3px;
645
- border-radius: var(--radius-sm);
646
- border: 1px solid var(--border-subtle);
 
647
  }
648
 
649
  .bg-inspector-btn {
650
  background: transparent;
651
- border: none;
652
- font-size: 12px;
653
- color: var(--text-muted);
 
 
654
  padding: 0 12px;
655
  border-radius: var(--radius-xs);
656
  cursor: pointer;
657
- transition: all 0.15s ease;
658
  min-height: 44px;
659
  display: inline-flex;
660
  align-items: center;
 
661
  }
662
 
663
  .bg-inspector-btn.active {
664
  background-color: var(--bg-card);
665
  color: var(--text-primary);
666
- font-weight: 600;
667
- box-shadow: var(--shadow-sm);
668
  }
669
 
670
  /* Canvas backgrounds */
671
  .result-canvas {
672
- border: 1px solid var(--border);
673
- border-radius: var(--radius-md);
674
- padding: 16px;
 
675
  display: flex;
676
  align-items: center;
677
  justify-content: center;
678
- min-height: 320px;
679
- max-height: 520px;
680
  overflow: hidden;
681
  transition: background-color 0.2s ease;
682
  }
683
 
684
  .result-canvas.checkerboard {
685
  background-image:
686
- linear-gradient(45deg, #232328 25%, transparent 25%),
687
- linear-gradient(-45deg, #232328 25%, transparent 25%),
688
- linear-gradient(45deg, transparent 75%, #232328 75%),
689
- linear-gradient(-45deg, transparent 75%, #232328 75%);
690
- background-size: 16px 16px;
691
- background-position: 0 0, 0 8px, 8px -8px, -8px 0px;
692
- background-color: #17171b;
693
  }
694
 
695
- [data-theme="light"] .result-canvas.checkerboard {
696
  background-image:
697
- linear-gradient(45deg, #e5e7eb 25%, transparent 25%),
698
- linear-gradient(-45deg, #e5e7eb 25%, transparent 25%),
699
- linear-gradient(45deg, transparent 75%, #e5e7eb 75%),
700
- linear-gradient(-45deg, transparent 75%, #e5e7eb 75%);
701
- background-size: 16px 16px;
702
- background-position: 0 0, 0 8px, 8px -8px, -8px 0px;
703
- background-color: #f3f4f6;
704
  }
705
 
706
  .result-canvas.white {
707
- background-color: #ffffff;
708
  }
709
 
710
  .result-canvas.dark {
711
- background-color: #0c0d0e;
712
  }
713
 
714
  .result-canvas.original-bg {
715
- background-color: var(--bg-input);
716
  }
717
 
718
  .result-image {
719
  max-width: 100%;
720
  max-height: 480px;
721
  object-fit: contain;
 
 
 
722
  }
723
 
724
  .result-footer-actions {
725
- margin-top: 18px;
726
  display: flex;
727
- gap: 12px;
728
  }
729
 
730
  /* Reprocess panel in result card */
731
  .reprocess-panel {
732
- margin-top: 20px;
733
- padding: 16px;
734
- background-color: var(--bg-surface);
735
- border: 1px solid var(--border-subtle);
736
- border-radius: var(--radius-md);
 
737
  }
738
 
739
  .reprocess-header {
740
  display: flex;
741
  flex-direction: column;
742
  gap: 4px;
743
- margin-bottom: 12px;
744
  }
745
 
746
  .reprocess-title {
747
- font-size: 13px;
748
- font-weight: 600;
749
  color: var(--text-primary);
750
  }
751
 
752
  .reprocess-desc {
753
- font-size: 12px;
754
- color: var(--text-muted);
755
  }
756
 
757
  .reprocess-controls {
758
  display: flex;
759
  align-items: center;
760
- gap: 10px;
761
  flex-wrap: wrap;
762
  }
763
 
764
  .reprocess-field {
765
  flex: 1;
766
- min-width: 220px;
767
  }
768
 
769
  .reprocess-tip {
770
- margin-top: 8px;
771
- font-size: 12px;
 
 
772
  color: var(--text-muted);
773
  }
774
 
775
- /* ===== TECHNICAL / PROJECT INFORMATION ===== */
776
  .info-section {
777
- padding: 40px 0 24px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
778
  }
779
 
780
- .info-grid {
 
 
 
 
 
 
781
  display: grid;
782
- grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
783
- gap: 16px;
 
784
  }
785
 
786
  .info-card {
787
- background-color: var(--bg-surface);
788
- border: 1px solid var(--border-subtle);
789
- border-radius: var(--radius-md);
790
- padding: 20px;
 
 
 
 
 
 
 
 
 
 
 
 
 
791
  }
792
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
793
  .info-card h2 {
794
- font-size: 14px;
795
- font-weight: 600;
796
- color: var(--text-primary);
797
- margin-bottom: 10px;
798
- letter-spacing: -0.01em;
799
  }
800
 
801
  .model-list {
802
  list-style: none;
803
  display: flex;
804
  flex-direction: column;
805
- gap: 8px;
806
  }
807
 
808
- .model-list li {
809
- font-size: 12px;
810
  color: var(--text-secondary);
811
  line-height: 1.5;
 
 
 
 
812
  }
813
 
814
- .model-list strong {
815
  color: var(--text-primary);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
816
  }
817
 
818
  /* ===== FOOTER ===== */
819
  .footer {
820
  margin-top: auto;
821
- padding: 28px 0 24px;
822
- border-top: 1px solid var(--border-subtle);
 
823
  }
824
 
825
  .footer-content {
@@ -827,56 +1294,377 @@ body {
827
  align-items: center;
828
  justify-content: space-between;
829
  flex-wrap: wrap;
830
- gap: 12px;
831
  }
832
 
833
  .footer-text {
834
- font-size: 12px;
835
- color: var(--text-muted);
 
 
836
  }
837
 
838
  .footer-links {
839
  display: flex;
840
  gap: 16px;
 
841
  }
842
 
843
  .footer-link {
844
- font-size: 12px;
845
- color: var(--text-muted);
 
 
846
  text-decoration: none;
847
- transition: color 0.15s ease;
 
 
 
 
 
848
  }
849
 
850
  .footer-link:hover {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
851
  color: var(--text-primary);
 
852
  }
853
 
854
- /* ===== RESPONSIVE BREAKPOINTS ===== */
855
- @media (max-width: 720px) {
856
- .config-grid {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
857
  grid-template-columns: 1fr;
 
858
  }
859
 
860
- .hero {
861
- padding: 28px 0 20px;
862
  }
863
 
864
  .main-card {
865
- padding: 16px;
866
  }
867
 
868
  .result-section {
869
- padding: 16px;
870
  }
871
 
872
  .result-footer-actions {
873
  flex-direction: column;
874
  }
875
 
 
 
 
 
876
  .btn-primary, .btn-secondary {
877
  width: 100%;
878
  }
879
 
 
 
 
 
880
  .footer-content {
881
  flex-direction: column;
882
  align-items: flex-start;
 
1
+ /* ===== NEUBRUTALISM DESIGN SYSTEM (DESIGN.md) ===== */
2
 
3
  :root {
4
+ /* Neubrutalism Color Palette */
5
+ --color-primary: #FFEB3B; /* Dominant Yellow */
6
+ --color-secondary: #FF5252; /* Vibrant Coral / Red */
7
+ --color-tertiary: #2196F3; /* Supporting Blue */
8
+ --color-black: #000000; /* Pure black for outlines & shadows */
9
+
10
+ /* Light Theme Canvas */
11
+ --bg-base: #FFFDE7; /* Soft warm pastel yellow-cream base */
12
+ --bg-surface: #FFFFFF;
13
+ --bg-card: #FFFFFF;
14
+ --bg-card-muted: #F8F9FA;
15
+ --bg-input: #FFFFFF;
16
+ --bg-accent-soft: #FFF9C4; /* Light yellow tint */
17
+
18
+ --text-primary: #121212; /* Charcoal off-black */
19
+ --text-secondary: #27272A;
20
+ --text-muted: #52525B;
21
+
22
+ --border-color: #000000;
23
+ --border-width: 3px;
24
+ --border-stroke: 3px solid #000000;
25
+ --border-stroke-sm: 2px solid #000000;
26
+
27
+ --shadow-offset-xs: 2px 2px 0 #000000;
28
+ --shadow-offset: 4px 4px 0 #000000;
29
+ --shadow-card: 6px 6px 0 #000000;
30
+ --shadow-hover: 8px 8px 0 #000000;
31
+ --shadow-inset: inset 2px 2px 0 #000000;
32
 
33
  --radius-xs: 4px;
34
  --radius-sm: 6px;
35
+ --radius-base: 8px; /* 0.5rem base corner radius per DESIGN.md */
36
  --radius-md: 8px;
37
  --radius-lg: 12px;
38
 
39
+ --font-mono: var(--font-mono, "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace);
40
+ --font-sans: var(--font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif);
41
  }
42
 
43
+ [data-theme="dark"] {
44
+ /* Dark Theme Canvas (Full Dark Mode per DESIGN.md) */
45
+ --bg-base: #121215; /* Deep charcoal canvas */
46
+ --bg-surface: #1C1C21;
47
+ --bg-card: #1F1F24;
48
+ --bg-card-muted: #27272D;
49
+ --bg-input: #18181C;
50
+ --bg-accent-soft: #2B281B; /* Dark yellow tint */
51
 
52
+ --text-primary: #FAFAFA;
53
+ --text-secondary: #E4E4E7;
54
+ --text-muted: #A1A1AA;
 
55
 
56
+ --border-color: #000000;
57
+ --border-stroke: 3px solid #000000;
58
+ --border-stroke-sm: 2px solid #000000;
59
 
60
+ --shadow-offset-xs: 2px 2px 0 #000000;
61
+ --shadow-offset: 4px 4px 0 #000000;
62
+ --shadow-card: 6px 6px 0 #000000;
63
+ --shadow-hover: 8px 8px 0 #000000;
 
 
64
  }
65
 
66
+ /* ===== RESET & BASE STYLES ===== */
67
  *, *::before, *::after {
68
  box-sizing: border-box;
69
  margin: 0;
 
72
 
73
  html {
74
  scroll-behavior: smooth;
75
+ font-size: 16px;
76
  }
77
 
78
  body {
79
+ font-family: var(--font-sans);
80
  background-color: var(--bg-base);
81
  color: var(--text-primary);
82
+ min-height: 100dvh;
83
  line-height: 1.6;
84
  -webkit-font-smoothing: antialiased;
85
  -moz-osx-font-smoothing: grayscale;
86
  transition: background-color 0.2s ease, color 0.2s ease;
87
  }
88
 
89
+ /* Accessible high-contrast focus visible ring */
90
  :focus-visible {
91
+ outline: 3px solid var(--color-tertiary);
92
+ outline-offset: 3px;
93
+ }
94
+
95
+ /* Typography Scale per DESIGN.md */
96
+ h1 {
97
+ font-size: clamp(2.25rem, 4.5vw, 3.5rem);
98
+ font-weight: 700;
99
+ line-height: 1.15;
100
+ letter-spacing: -0.03em;
101
+ color: var(--text-primary);
102
+ }
103
+
104
+ h2 {
105
+ font-size: 1.5rem;
106
+ font-weight: 700;
107
+ line-height: 1.3;
108
+ letter-spacing: -0.02em;
109
+ color: var(--text-primary);
110
+ }
111
+
112
+ p {
113
+ font-size: 1rem;
114
+ line-height: 1.6;
115
+ max-width: 72ch;
116
  }
117
 
118
+ code, .mono {
119
+ font-family: var(--font-mono);
120
+ }
121
+
122
+ /* ===== APP LAYOUT & CONTAINER ===== */
123
  .app-wrapper {
124
+ min-height: 100dvh;
125
  display: flex;
126
  flex-direction: column;
127
  background-color: var(--bg-base);
128
+ position: relative;
129
+ overflow-x: hidden;
130
  }
131
 
132
+ /* Max-width containment: 1280px centered with 1.5rem side padding (DESIGN.md) */
133
  .container {
134
  width: 100%;
135
+ max-width: 1280px;
136
  margin: 0 auto;
137
+ padding: 0 1.5rem;
138
  }
139
 
140
+ /* ===== HEADER & NAVIGATION ===== */
141
  .header {
142
+ padding: 16px 0;
 
143
  background-color: var(--bg-surface);
144
+ border-bottom: 3px solid var(--color-black);
145
  position: sticky;
146
  top: 0;
147
+ z-index: 100; /* z-index contract: sticky-nav (100) */
148
  transition: background-color 0.2s ease;
149
  }
150
 
 
152
  display: flex;
153
  align-items: center;
154
  justify-content: space-between;
155
+ gap: 16px;
156
  }
157
 
158
  .logo {
159
  display: inline-flex;
160
  align-items: center;
161
+ gap: 12px;
162
  text-decoration: none;
163
  color: var(--text-primary);
164
  }
 
167
  display: inline-flex;
168
  align-items: center;
169
  justify-content: center;
170
+ width: 42px;
171
+ height: 42px;
172
+ background-color: var(--color-primary);
173
+ border: 3px solid var(--color-black);
174
+ border-radius: var(--radius-base);
175
+ box-shadow: 3px 3px 0 var(--color-black);
176
+ color: var(--color-black);
177
+ transition: transform 0.15s ease, box-shadow 0.15s ease;
178
+ }
179
+
180
+ .logo:hover .logo-badge {
181
+ transform: translate(-2px, -2px);
182
+ box-shadow: 5px 5px 0 var(--color-black);
183
+ }
184
+
185
+ .logo-title-group {
186
+ display: flex;
187
+ flex-direction: column;
188
  }
189
 
190
  .logo-text {
191
+ font-size: 1.25rem;
192
+ font-weight: 800;
193
+ letter-spacing: -0.02em;
194
+ color: var(--text-primary);
195
+ line-height: 1.1;
196
+ }
197
+
198
+ .logo-sub {
199
+ font-family: var(--font-mono);
200
+ font-size: 0.75rem;
201
+ font-weight: 600;
202
+ color: var(--text-muted);
203
  }
204
 
205
  .header-meta {
206
  display: flex;
207
  align-items: center;
208
+ gap: 12px;
209
  }
210
 
211
+ /* Theme Toggle Button (Neubrutalism) */
212
  .theme-toggle-btn {
213
  display: inline-flex;
214
  align-items: center;
215
+ gap: 8px;
216
  min-height: 44px;
217
+ padding: 0 16px;
218
  background-color: var(--bg-card);
219
+ border: 3px solid var(--color-black);
220
+ border-radius: var(--radius-base);
221
+ box-shadow: var(--shadow-offset);
222
+ color: var(--text-primary);
223
+ font-family: var(--font-sans);
224
+ font-size: 0.875rem;
225
+ font-weight: 700;
226
  cursor: pointer;
227
+ user-select: none;
228
+ transition: transform 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease;
229
  }
230
 
231
  .theme-toggle-btn:hover {
232
+ background-color: var(--color-primary);
233
+ color: var(--color-black);
234
+ transform: translate(-2px, -2px);
235
+ box-shadow: var(--shadow-hover);
236
+ }
237
+
238
+ .theme-toggle-btn:active {
239
+ transform: translate(2px, 2px);
240
+ box-shadow: var(--shadow-offset-xs);
241
  }
242
 
243
  .theme-toggle-label {
244
+ font-size: 0.8125rem;
245
+ font-weight: 700;
246
+ letter-spacing: 0.02em;
247
  }
248
 
249
+ /* Hugging Face Link Button */
250
  .hf-link {
251
+ font-family: var(--font-mono);
252
+ font-size: 0.8125rem;
253
+ font-weight: 700;
254
+ color: var(--text-primary);
255
  text-decoration: none;
256
  display: inline-flex;
257
  align-items: center;
258
+ gap: 8px;
259
  min-height: 44px;
260
+ padding: 0 16px;
261
+ background-color: var(--color-tertiary);
262
+ color: #FFFFFF;
263
+ border: 3px solid var(--color-black);
264
+ border-radius: var(--radius-base);
265
+ box-shadow: var(--shadow-offset);
266
+ transition: transform 0.15s ease, box-shadow 0.15s ease;
267
  }
268
 
269
  .hf-link:hover {
270
+ transform: translate(-2px, -2px);
271
+ box-shadow: var(--shadow-hover);
272
+ color: #FFFFFF;
273
+ }
274
+
275
+ .hf-link:active {
276
+ transform: translate(2px, 2px);
277
+ box-shadow: var(--shadow-offset-xs);
278
  }
279
 
280
+ /* ===== HERO SECTION: SPLIT-SCREEN (DESIGN.md) ===== */
281
  .hero {
282
+ padding: clamp(3rem, 6vw, 5rem) 0 clamp(2rem, 4vw, 3.5rem);
 
283
  }
284
 
285
+ .hero-split-grid {
286
+ display: grid;
287
+ grid-template-columns: 1.15fr 0.85fr;
288
+ gap: 40px;
289
+ align-items: center;
290
+ }
291
+
292
+ .hero-content {
293
+ display: flex;
294
+ flex-direction: column;
295
+ gap: 18px;
296
+ }
297
+
298
+ .hero-tag-badge {
299
+ display: inline-flex;
300
+ align-items: center;
301
+ gap: 8px;
302
+ align-self: flex-start;
303
+ padding: 6px 14px;
304
+ background-color: var(--color-primary);
305
+ color: var(--color-black);
306
+ border: 3px solid var(--color-black);
307
+ border-radius: var(--radius-base);
308
+ box-shadow: var(--shadow-offset-xs);
309
+ font-family: var(--font-mono);
310
+ font-size: 0.8125rem;
311
+ font-weight: 800;
312
+ text-transform: uppercase;
313
+ letter-spacing: 0.04em;
314
+ }
315
+
316
+ .hero-title {
317
+ margin: 0;
318
  color: var(--text-primary);
 
319
  }
320
 
321
  .hero-desc {
322
+ font-size: 1.125rem;
 
 
 
323
  line-height: 1.6;
324
+ color: var(--text-secondary);
325
+ }
326
+
327
+ .hero-feature-chips {
328
+ display: flex;
329
+ flex-wrap: wrap;
330
+ gap: 10px;
331
+ margin-top: 6px;
332
+ }
333
+
334
+ .feature-chip {
335
+ display: inline-flex;
336
+ align-items: center;
337
+ gap: 6px;
338
+ padding: 6px 12px;
339
+ border: 2px solid var(--color-black);
340
+ border-radius: var(--radius-xs);
341
+ font-family: var(--font-mono);
342
+ font-size: 0.75rem;
343
+ font-weight: 700;
344
+ box-shadow: var(--shadow-offset-xs);
345
+ }
346
+
347
+ .feature-chip.chip-yellow {
348
+ background-color: var(--color-primary);
349
+ color: var(--color-black);
350
+ }
351
+
352
+ .feature-chip.chip-blue {
353
+ background-color: var(--color-tertiary);
354
+ color: #FFFFFF;
355
+ }
356
+
357
+ .feature-chip.chip-red {
358
+ background-color: var(--color-secondary);
359
+ color: #FFFFFF;
360
+ }
361
+
362
+ /* Hero Visual Card (Neubrutalist Showcase) */
363
+ .hero-visual-card {
364
+ background-color: var(--bg-card);
365
+ border: 3px solid var(--color-black);
366
+ border-radius: var(--radius-base);
367
+ box-shadow: var(--shadow-card);
368
+ padding: 20px;
369
+ position: relative;
370
+ display: flex;
371
+ flex-direction: column;
372
+ gap: 16px;
373
+ }
374
+
375
+ .hero-visual-header {
376
+ display: flex;
377
+ align-items: center;
378
+ justify-content: space-between;
379
+ border-bottom: 2px solid var(--color-black);
380
+ padding-bottom: 12px;
381
+ }
382
+
383
+ .hero-visual-dots {
384
+ display: flex;
385
+ gap: 6px;
386
+ }
387
+
388
+ .dot {
389
+ width: 12px;
390
+ height: 12px;
391
+ border-radius: 50%;
392
+ border: 2px solid var(--color-black);
393
+ }
394
+
395
+ .dot-red { background-color: var(--color-secondary); }
396
+ .dot-yellow { background-color: var(--color-primary); }
397
+ .dot-blue { background-color: var(--color-tertiary); }
398
+
399
+ .hero-visual-title {
400
+ font-family: var(--font-mono);
401
+ font-size: 0.75rem;
402
+ font-weight: 700;
403
+ color: var(--text-muted);
404
+ }
405
+
406
+ .hero-visual-preview {
407
+ display: grid;
408
+ grid-template-columns: 1fr 1fr;
409
+ gap: 12px;
410
+ }
411
+
412
+ .visual-preview-box {
413
+ border: 2px solid var(--color-black);
414
+ border-radius: var(--radius-xs);
415
+ padding: 12px;
416
+ display: flex;
417
+ flex-direction: column;
418
+ align-items: center;
419
+ gap: 8px;
420
+ }
421
+
422
+ .visual-preview-box.box-original {
423
+ background-color: var(--bg-card-muted);
424
+ }
425
+
426
+ .visual-preview-box.box-cutout {
427
+ background-color: var(--color-accent-soft);
428
+ background-image:
429
+ linear-gradient(45deg, rgba(0,0,0,0.06) 25%, transparent 25%),
430
+ linear-gradient(-45deg, rgba(0,0,0,0.06) 25%, transparent 25%),
431
+ linear-gradient(45deg, transparent 75%, rgba(0,0,0,0.06) 75%),
432
+ linear-gradient(-45deg, transparent 75%, rgba(0,0,0,0.06) 75%);
433
+ background-size: 12px 12px;
434
+ }
435
+
436
+ .visual-mockup-graphic {
437
+ width: 100%;
438
+ height: 110px;
439
+ display: flex;
440
+ align-items: center;
441
+ justify-content: center;
442
+ border: 2px dashed var(--color-black);
443
+ border-radius: var(--radius-xs);
444
+ background-color: var(--bg-surface);
445
+ }
446
+
447
+ .visual-preview-label {
448
+ font-family: var(--font-mono);
449
+ font-size: 0.6875rem;
450
+ font-weight: 700;
451
+ text-transform: uppercase;
452
+ color: var(--text-primary);
453
+ }
454
+
455
+ .hero-visual-footer {
456
+ display: flex;
457
+ justify-content: space-between;
458
+ align-items: center;
459
+ background-color: var(--color-primary);
460
+ color: var(--color-black);
461
+ border: 2px solid var(--color-black);
462
+ border-radius: var(--radius-xs);
463
+ padding: 8px 12px;
464
+ font-family: var(--font-mono);
465
+ font-size: 0.75rem;
466
+ font-weight: 700;
467
  }
468
 
469
+ /* ===== MAIN TOOL CARD (NEUBRUTALISM) ===== */
470
  .main-card {
471
  background-color: var(--bg-card);
472
+ border: 3px solid var(--color-black);
473
+ border-radius: var(--radius-base);
474
+ padding: clamp(20px, 3vw, 32px);
475
  box-shadow: var(--shadow-card);
476
+ margin-bottom: clamp(3rem, 6vw, 5rem);
477
  }
478
 
479
  /* Configuration grid */
480
  .config-grid {
481
  display: grid;
482
+ grid-template-columns: 1fr 260px;
483
+ gap: 20px;
484
+ margin-bottom: 24px;
485
  }
486
 
487
  .field-group {
488
  display: flex;
489
  flex-direction: column;
490
+ gap: 8px;
491
  }
492
 
493
  .field-label {
494
+ font-family: var(--font-mono);
495
+ font-size: 0.8125rem;
496
+ font-weight: 700;
497
+ color: var(--text-primary);
498
+ text-transform: uppercase;
499
+ letter-spacing: 0.02em;
500
  }
501
 
502
  .select-control {
503
  width: 100%;
504
+ min-height: 48px;
505
+ padding: 10px 14px;
506
  background-color: var(--bg-input);
507
+ border: 3px solid var(--color-black);
508
+ border-radius: var(--radius-base);
509
+ box-shadow: var(--shadow-offset);
510
  color: var(--text-primary);
511
+ font-family: var(--font-sans);
512
+ font-size: 0.9375rem;
513
+ font-weight: 600;
514
  cursor: pointer;
515
  outline: none;
516
+ transition: transform 0.15s ease, box-shadow 0.15s ease;
517
  }
518
 
519
  .select-control:focus {
520
+ outline: 3px solid var(--color-tertiary);
521
+ outline-offset: 2px;
522
+ }
523
+
524
+ .select-control:disabled {
525
+ opacity: 0.6;
526
+ cursor: not-allowed;
527
  }
528
 
529
+ /* Toggle card (Alpha Matting) */
530
  .toggle-card {
531
  display: flex;
532
  align-items: center;
533
+ gap: 12px;
534
+ padding: 10px 14px;
535
  background-color: var(--bg-input);
536
+ border: 3px solid var(--color-black);
537
+ border-radius: var(--radius-base);
538
+ box-shadow: var(--shadow-offset);
539
  cursor: pointer;
540
  user-select: none;
541
+ min-height: 48px;
542
+ transition: transform 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease;
543
+ }
544
+
545
+ .toggle-card:hover {
546
+ transform: translate(-1px, -1px);
547
+ box-shadow: 5px 5px 0 var(--color-black);
548
  }
549
 
550
  .toggle-card input[type="checkbox"] {
551
+ width: 20px;
552
+ height: 20px;
553
  cursor: pointer;
554
+ accent-color: var(--color-primary);
555
  }
556
 
557
  .toggle-text {
558
+ font-family: var(--font-sans);
559
+ font-size: 0.875rem;
560
+ font-weight: 700;
561
+ color: var(--text-primary);
562
  line-height: 1.3;
563
  }
564
 
565
+ /* ===== DROPZONE & UPLOAD (NEUBRUTALISM) ===== */
566
  .dropzone {
567
+ border: 3px dashed var(--color-black);
568
+ border-radius: var(--radius-base);
569
+ background-color: var(--bg-card-muted);
570
+ box-shadow: var(--shadow-offset);
571
+ padding: 56px 24px;
572
  text-align: center;
573
  cursor: pointer;
574
  position: relative;
575
+ transition: background-color 0.15s ease, transform 0.15s ease, box-shadow 0.15s ease;
576
  }
577
 
578
  .dropzone:hover,
579
  .dropzone.drag-active {
580
+ background-color: var(--bg-accent-soft);
581
+ transform: translate(-2px, -2px);
582
+ box-shadow: var(--shadow-hover);
583
  }
584
 
585
  .dropzone-inner {
586
  display: flex;
587
  flex-direction: column;
588
  align-items: center;
589
+ gap: 14px;
590
  }
591
 
592
  .dropzone-icon {
593
+ width: 60px;
594
+ height: 60px;
595
+ background-color: var(--color-primary);
596
+ border: 3px solid var(--color-black);
597
+ border-radius: var(--radius-base);
598
+ box-shadow: var(--shadow-offset-xs);
599
+ color: var(--color-black);
600
  display: flex;
601
  align-items: center;
602
  justify-content: center;
603
  }
604
 
605
  .dropzone-title {
606
+ font-size: 1.125rem;
607
+ font-weight: 700;
608
  color: var(--text-primary);
609
  }
610
 
611
  .dropzone-hint {
612
+ font-size: 0.875rem;
613
+ color: var(--text-secondary);
614
+ max-width: 520px;
615
+ line-height: 1.5;
616
  }
617
 
618
  /* Uploaded Image Preview */
619
  .preview-container {
620
  display: flex;
621
  flex-direction: column;
622
+ gap: 16px;
623
  }
624
 
625
  .preview-media-box {
626
+ background-color: var(--bg-card-muted);
627
+ border: 3px solid var(--color-black);
628
+ border-radius: var(--radius-base);
629
+ box-shadow: var(--shadow-offset);
630
+ padding: 20px;
631
  display: flex;
632
  justify-content: center;
633
  align-items: center;
634
+ min-height: 280px;
635
+ max-height: 480px;
636
  overflow: hidden;
637
  }
638
 
639
  .preview-img {
640
  max-width: 100%;
641
+ max-height: 440px;
642
  object-fit: contain;
643
+ border: 2px solid var(--color-black);
644
  border-radius: var(--radius-xs);
645
  }
646
 
 
650
  justify-content: space-between;
651
  padding: 0 4px;
652
  flex-wrap: wrap;
653
+ gap: 12px;
654
  }
655
 
656
  .meta-info {
657
+ font-family: var(--font-mono);
658
+ font-size: 0.8125rem;
659
  display: flex;
660
  align-items: center;
661
+ gap: 10px;
662
  flex-wrap: wrap;
663
  }
664
 
665
  .meta-filename {
666
+ font-weight: 700;
667
  color: var(--text-primary);
668
+ max-width: 320px;
669
  overflow: hidden;
670
  text-overflow: ellipsis;
671
  white-space: nowrap;
672
  }
673
 
674
  .meta-badge {
675
+ background-color: var(--color-primary);
676
+ color: var(--color-black);
677
+ border: 2px solid var(--color-black);
678
+ box-shadow: var(--shadow-offset-xs);
679
+ padding: 4px 10px;
680
  border-radius: var(--radius-xs);
681
+ font-size: 0.75rem;
682
+ font-weight: 700;
683
  }
684
 
685
  .btn-ghost-small {
686
+ background-color: var(--bg-card);
687
+ border: 3px solid var(--color-black);
688
+ border-radius: var(--radius-base);
689
+ box-shadow: var(--shadow-offset-xs);
690
+ color: var(--text-primary);
691
+ font-family: var(--font-sans);
692
+ font-size: 0.8125rem;
693
+ font-weight: 700;
694
+ padding: 0 16px;
695
  cursor: pointer;
 
696
  min-height: 44px;
697
  display: inline-flex;
698
  align-items: center;
699
+ transition: transform 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease;
700
  }
701
 
702
+ .btn-ghost-small:hover:not(:disabled) {
703
+ background-color: var(--color-secondary);
704
+ color: #FFFFFF;
705
+ transform: translate(-1px, -1px);
706
+ box-shadow: var(--shadow-offset);
707
+ }
708
+
709
+ .btn-ghost-small:disabled {
710
+ opacity: 0.5;
711
+ cursor: not-allowed;
712
  }
713
 
714
+ /* Stepped Progress bar (Neubrutalism) */
715
  .progress-wrapper {
716
+ margin-top: 20px;
717
  display: flex;
718
  flex-direction: column;
719
+ gap: 8px;
720
  }
721
 
722
  .progress-header {
723
  display: flex;
724
  justify-content: space-between;
725
+ font-family: var(--font-mono);
726
+ font-size: 0.8125rem;
727
+ font-weight: 700;
728
+ color: var(--text-primary);
729
  }
730
 
731
  .progress-value {
732
+ font-weight: 800;
733
+ color: var(--color-secondary);
734
  }
735
 
736
  .progress-track {
737
+ height: 16px;
738
+ background-color: var(--bg-card-muted);
739
+ border: 3px solid var(--color-black);
740
+ border-radius: var(--radius-base);
741
+ box-shadow: var(--shadow-offset-xs);
742
  overflow: hidden;
743
  }
744
 
745
  .progress-bar-fill {
746
  height: 100%;
747
+ background-color: var(--color-primary);
748
+ border-right: 2px solid var(--color-black);
749
  transition: width 0.3s ease;
750
  }
751
 
752
+ /* Error message banner (Neubrutalism) */
753
  .error-banner {
754
+ margin-top: 20px;
755
+ padding: 14px 18px;
756
+ background-color: var(--color-secondary);
757
+ border: 3px solid var(--color-black);
758
+ border-radius: var(--radius-base);
759
+ box-shadow: var(--shadow-offset);
760
+ color: #FFFFFF;
761
+ font-family: var(--font-sans);
762
+ font-size: 0.875rem;
763
+ font-weight: 700;
764
  display: flex;
765
  align-items: center;
766
+ gap: 12px;
 
 
 
 
767
  }
768
 
769
  /* Turnstile container */
770
  .turnstile-box {
771
+ margin-top: 24px;
772
  display: flex;
773
  flex-direction: column;
774
  align-items: center;
775
  justify-content: center;
776
+ gap: 8px;
777
  min-height: 65px;
778
  }
779
 
780
  .turnstile-notice {
781
+ font-family: var(--font-mono);
782
+ font-size: 0.75rem;
783
+ font-weight: 600;
784
+ color: var(--text-secondary);
785
  }
786
 
787
  /* Action button area */
788
  .action-row {
789
+ margin-top: 24px;
790
  display: flex;
791
+ gap: 16px;
792
  }
793
 
794
+ /* Primary Button (Neubrutalism per DESIGN.md: Yellow fill, 3px black border, offset shadow, tactile press) */
795
  .btn-primary {
796
  flex: 1;
797
  display: inline-flex;
798
  align-items: center;
799
  justify-content: center;
800
+ gap: 10px;
801
+ background-color: var(--color-primary);
802
+ color: var(--color-black);
803
+ border: 3px solid var(--color-black);
804
+ border-radius: var(--radius-base);
805
+ box-shadow: var(--shadow-offset);
806
+ font-family: var(--font-sans);
807
+ font-size: 1rem;
808
+ font-weight: 700;
809
+ padding: 14px 24px;
810
  cursor: pointer;
811
+ user-select: none;
812
+ min-height: 48px;
813
+ transition: transform 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease;
814
  }
815
 
816
  .btn-primary:hover:not(:disabled) {
817
+ background-color: #FDD835; /* 8% darken per DESIGN.md */
818
+ transform: translate(-2px, -2px);
819
+ box-shadow: var(--shadow-hover);
820
+ }
821
+
822
+ .btn-primary:active:not(:disabled) {
823
+ transform: translate(2px, 2px);
824
+ box-shadow: var(--shadow-offset-xs);
825
  }
826
 
827
  .btn-primary:disabled {
828
+ opacity: 0.5;
829
  cursor: not-allowed;
830
+ transform: none;
831
+ box-shadow: var(--shadow-offset);
832
  }
833
 
834
+ /* Secondary Button (Outline variant with tactile press) */
835
  .btn-secondary {
836
  display: inline-flex;
837
  align-items: center;
838
  justify-content: center;
839
+ gap: 8px;
840
+ background-color: var(--bg-card);
841
+ color: var(--text-primary);
842
+ border: 3px solid var(--color-black);
843
+ border-radius: var(--radius-base);
844
+ box-shadow: var(--shadow-offset);
845
+ font-family: var(--font-sans);
846
+ font-size: 0.9375rem;
847
+ font-weight: 700;
848
+ padding: 12px 20px;
849
  cursor: pointer;
850
+ user-select: none;
851
+ min-height: 48px;
852
+ transition: transform 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease, color 0.15s ease;
853
  }
854
 
855
  .btn-secondary:hover:not(:disabled) {
856
+ background-color: var(--color-tertiary);
857
+ color: #FFFFFF;
858
+ transform: translate(-2px, -2px);
859
+ box-shadow: var(--shadow-hover);
860
  }
861
 
862
+ .btn-secondary:active:not(:disabled) {
863
+ transform: translate(2px, 2px);
864
+ box-shadow: var(--shadow-offset-xs);
865
+ }
866
+
867
+ .btn-secondary:disabled {
868
+ opacity: 0.5;
869
+ cursor: not-allowed;
870
+ transform: none;
871
+ }
872
+
873
+ /* Loading spinner */
874
  .spinner-icon {
875
+ width: 18px;
876
+ height: 18px;
877
+ border: 3px solid rgba(0, 0, 0, 0.2);
878
+ border-top-color: var(--color-black);
879
  border-radius: 50%;
880
  animation: spin 0.6s linear infinite;
881
  }
 
884
  to { transform: rotate(360deg); }
885
  }
886
 
887
+ /* ===== RESULT SECTION (NEUBRUTALISM) ===== */
888
  .result-section {
889
+ margin-top: 32px;
890
  background-color: var(--bg-card);
891
+ border: 3px solid var(--color-black);
892
+ border-radius: var(--radius-base);
893
+ padding: clamp(20px, 3vw, 32px);
894
  box-shadow: var(--shadow-card);
895
+ margin-bottom: clamp(3rem, 6vw, 5rem);
896
  }
897
 
898
  .result-header {
899
  display: flex;
900
  align-items: center;
901
  justify-content: space-between;
902
+ margin-bottom: 20px;
903
  flex-wrap: wrap;
904
+ gap: 16px;
905
+ border-bottom: 2px solid var(--color-black);
906
+ padding-bottom: 16px;
907
  }
908
 
909
  .result-title-group {
910
  display: flex;
911
  align-items: center;
912
+ gap: 12px;
913
+ flex-wrap: wrap;
914
  }
915
 
916
  .result-title {
917
+ font-size: 1.125rem;
918
+ font-weight: 800;
919
  color: var(--text-primary);
920
  }
921
 
922
  .result-meta-badge {
923
+ font-family: var(--font-mono);
924
+ font-size: 0.75rem;
925
+ font-weight: 700;
926
+ background-color: var(--color-tertiary);
927
+ color: #FFFFFF;
928
+ border: 2px solid var(--color-black);
929
+ box-shadow: var(--shadow-offset-xs);
930
+ padding: 4px 10px;
931
  border-radius: var(--radius-xs);
932
  }
933
 
934
  .result-controls-bar {
935
  display: flex;
936
  align-items: center;
937
+ gap: 12px;
938
  flex-wrap: wrap;
939
  }
940
 
 
942
  .view-mode-group {
943
  display: flex;
944
  align-items: center;
945
+ background-color: var(--bg-card-muted);
946
+ border: 2px solid var(--color-black);
947
+ border-radius: var(--radius-base);
948
+ padding: 4px;
949
+ gap: 4px;
950
  }
951
 
952
  .control-tab-btn {
953
  background: transparent;
954
+ border: 2px solid transparent;
955
+ font-family: var(--font-sans);
956
+ font-size: 0.8125rem;
957
+ font-weight: 700;
958
+ color: var(--text-secondary);
959
+ padding: 0 14px;
960
  border-radius: var(--radius-xs);
961
  cursor: pointer;
 
962
  min-height: 44px;
963
  display: inline-flex;
964
  align-items: center;
965
+ transition: all 0.15s ease;
966
  }
967
 
968
  .control-tab-btn.active {
969
+ background-color: var(--color-primary);
970
+ color: var(--color-black);
971
+ border: 2px solid var(--color-black);
972
+ box-shadow: var(--shadow-offset-xs);
973
  }
974
 
975
  /* Preview background inspector toggle */
976
  .bg-inspector-group {
977
  display: flex;
978
  align-items: center;
979
+ background-color: var(--bg-card-muted);
980
+ border: 2px solid var(--color-black);
981
+ border-radius: var(--radius-base);
982
+ padding: 4px;
983
+ gap: 4px;
984
  }
985
 
986
  .bg-inspector-btn {
987
  background: transparent;
988
+ border: 2px solid transparent;
989
+ font-family: var(--font-sans);
990
+ font-size: 0.8125rem;
991
+ font-weight: 700;
992
+ color: var(--text-secondary);
993
  padding: 0 12px;
994
  border-radius: var(--radius-xs);
995
  cursor: pointer;
 
996
  min-height: 44px;
997
  display: inline-flex;
998
  align-items: center;
999
+ transition: all 0.15s ease;
1000
  }
1001
 
1002
  .bg-inspector-btn.active {
1003
  background-color: var(--bg-card);
1004
  color: var(--text-primary);
1005
+ border: 2px solid var(--color-black);
1006
+ box-shadow: var(--shadow-offset-xs);
1007
  }
1008
 
1009
  /* Canvas backgrounds */
1010
  .result-canvas {
1011
+ border: 3px solid var(--color-black);
1012
+ border-radius: var(--radius-base);
1013
+ box-shadow: var(--shadow-offset);
1014
+ padding: 24px;
1015
  display: flex;
1016
  align-items: center;
1017
  justify-content: center;
1018
+ min-height: 340px;
1019
+ max-height: 540px;
1020
  overflow: hidden;
1021
  transition: background-color 0.2s ease;
1022
  }
1023
 
1024
  .result-canvas.checkerboard {
1025
  background-image:
1026
+ linear-gradient(45deg, #E0E0E0 25%, transparent 25%),
1027
+ linear-gradient(-45deg, #E0E0E0 25%, transparent 25%),
1028
+ linear-gradient(45deg, transparent 75%, #E0E0E0 75%),
1029
+ linear-gradient(-45deg, transparent 75%, #E0E0E0 75%);
1030
+ background-size: 20px 20px;
1031
+ background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
1032
+ background-color: #FFFFFF;
1033
  }
1034
 
1035
+ [data-theme="dark"] .result-canvas.checkerboard {
1036
  background-image:
1037
+ linear-gradient(45deg, #2B2B30 25%, transparent 25%),
1038
+ linear-gradient(-45deg, #2B2B30 25%, transparent 25%),
1039
+ linear-gradient(45deg, transparent 75%, #2B2B30 75%),
1040
+ linear-gradient(-45deg, transparent 75%, #2B2B30 75%);
1041
+ background-size: 20px 20px;
1042
+ background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
1043
+ background-color: #1A1A1F;
1044
  }
1045
 
1046
  .result-canvas.white {
1047
+ background-color: #FFFFFF;
1048
  }
1049
 
1050
  .result-canvas.dark {
1051
+ background-color: #121212;
1052
  }
1053
 
1054
  .result-canvas.original-bg {
1055
+ background-color: var(--bg-card-muted);
1056
  }
1057
 
1058
  .result-image {
1059
  max-width: 100%;
1060
  max-height: 480px;
1061
  object-fit: contain;
1062
+ border: 2px solid var(--color-black);
1063
+ border-radius: var(--radius-xs);
1064
+ box-shadow: var(--shadow-offset-xs);
1065
  }
1066
 
1067
  .result-footer-actions {
1068
+ margin-top: 24px;
1069
  display: flex;
1070
+ gap: 16px;
1071
  }
1072
 
1073
  /* Reprocess panel in result card */
1074
  .reprocess-panel {
1075
+ margin-top: 28px;
1076
+ padding: 20px;
1077
+ background-color: var(--bg-accent-soft);
1078
+ border: 3px solid var(--color-black);
1079
+ border-radius: var(--radius-base);
1080
+ box-shadow: var(--shadow-offset);
1081
  }
1082
 
1083
  .reprocess-header {
1084
  display: flex;
1085
  flex-direction: column;
1086
  gap: 4px;
1087
+ margin-bottom: 16px;
1088
  }
1089
 
1090
  .reprocess-title {
1091
+ font-size: 1rem;
1092
+ font-weight: 800;
1093
  color: var(--text-primary);
1094
  }
1095
 
1096
  .reprocess-desc {
1097
+ font-size: 0.875rem;
1098
+ color: var(--text-secondary);
1099
  }
1100
 
1101
  .reprocess-controls {
1102
  display: flex;
1103
  align-items: center;
1104
+ gap: 14px;
1105
  flex-wrap: wrap;
1106
  }
1107
 
1108
  .reprocess-field {
1109
  flex: 1;
1110
+ min-width: 240px;
1111
  }
1112
 
1113
  .reprocess-tip {
1114
+ margin-top: 10px;
1115
+ font-family: var(--font-mono);
1116
+ font-size: 0.75rem;
1117
+ font-weight: 600;
1118
  color: var(--text-muted);
1119
  }
1120
 
1121
+ /* ===== FEATURE / INFORMATION SECTION: ZIG-ZAG ROWS (DESIGN.md) ===== */
1122
  .info-section {
1123
+ padding: clamp(2rem, 4vw, 4rem) 0 clamp(3rem, 6vw, 5rem);
1124
+ }
1125
+
1126
+ .section-headline-group {
1127
+ margin-bottom: 32px;
1128
+ }
1129
+
1130
+ .section-tag {
1131
+ display: inline-block;
1132
+ padding: 4px 10px;
1133
+ background-color: var(--color-tertiary);
1134
+ color: #FFFFFF;
1135
+ border: 2px solid var(--color-black);
1136
+ border-radius: var(--radius-xs);
1137
+ box-shadow: var(--shadow-offset-xs);
1138
+ font-family: var(--font-mono);
1139
+ font-size: 0.75rem;
1140
+ font-weight: 800;
1141
+ text-transform: uppercase;
1142
+ margin-bottom: 8px;
1143
  }
1144
 
1145
+ .zigzag-container {
1146
+ display: flex;
1147
+ flex-direction: column;
1148
+ gap: 32px;
1149
+ }
1150
+
1151
+ .zigzag-row {
1152
  display: grid;
1153
+ grid-template-columns: 1fr 1fr;
1154
+ gap: 28px;
1155
+ align-items: stretch;
1156
  }
1157
 
1158
  .info-card {
1159
+ background-color: var(--bg-card);
1160
+ border: 3px solid var(--color-black);
1161
+ border-radius: var(--radius-base);
1162
+ box-shadow: var(--shadow-card);
1163
+ padding: 28px;
1164
+ display: flex;
1165
+ flex-direction: column;
1166
+ justify-content: space-between;
1167
+ }
1168
+
1169
+ .info-card-header {
1170
+ display: flex;
1171
+ align-items: center;
1172
+ gap: 10px;
1173
+ margin-bottom: 16px;
1174
+ border-bottom: 2px solid var(--color-black);
1175
+ padding-bottom: 12px;
1176
  }
1177
 
1178
+ .info-card-badge {
1179
+ padding: 4px 10px;
1180
+ border: 2px solid var(--color-black);
1181
+ border-radius: var(--radius-xs);
1182
+ font-family: var(--font-mono);
1183
+ font-size: 0.6875rem;
1184
+ font-weight: 800;
1185
+ text-transform: uppercase;
1186
+ }
1187
+
1188
+ .badge-yellow { background-color: var(--color-primary); color: var(--color-black); }
1189
+ .badge-blue { background-color: var(--color-tertiary); color: #FFFFFF; }
1190
+ .badge-red { background-color: var(--color-secondary); color: #FFFFFF; }
1191
+
1192
  .info-card h2 {
1193
+ font-size: 1.25rem;
1194
+ margin: 0;
 
 
 
1195
  }
1196
 
1197
  .model-list {
1198
  list-style: none;
1199
  display: flex;
1200
  flex-direction: column;
1201
+ gap: 12px;
1202
  }
1203
 
1204
+ .model-item {
1205
+ font-size: 0.875rem;
1206
  color: var(--text-secondary);
1207
  line-height: 1.5;
1208
+ padding: 8px 12px;
1209
+ border: 2px solid var(--color-black);
1210
+ border-radius: var(--radius-xs);
1211
+ background-color: var(--bg-card-muted);
1212
  }
1213
 
1214
+ .model-item strong {
1215
  color: var(--text-primary);
1216
+ font-family: var(--font-mono);
1217
+ display: inline-block;
1218
+ margin-right: 6px;
1219
+ }
1220
+
1221
+ /* Visual companion card for zigzag rows */
1222
+ .spec-highlight-card {
1223
+ background-color: var(--color-primary);
1224
+ color: var(--color-black);
1225
+ border: 3px solid var(--color-black);
1226
+ border-radius: var(--radius-base);
1227
+ box-shadow: var(--shadow-card);
1228
+ padding: 28px;
1229
+ display: flex;
1230
+ flex-direction: column;
1231
+ justify-content: space-between;
1232
+ gap: 16px;
1233
+ }
1234
+
1235
+ .spec-highlight-card.card-blue {
1236
+ background-color: var(--color-tertiary);
1237
+ color: #FFFFFF;
1238
+ }
1239
+
1240
+ .spec-highlight-card.card-blue h3,
1241
+ .spec-highlight-card.card-blue p,
1242
+ .spec-highlight-card.card-blue .spec-stat-label {
1243
+ color: #FFFFFF;
1244
+ }
1245
+
1246
+ .spec-highlight-title {
1247
+ font-size: 1.375rem;
1248
+ font-weight: 800;
1249
+ line-height: 1.2;
1250
+ }
1251
+
1252
+ .spec-stat-grid {
1253
+ display: grid;
1254
+ grid-template-columns: 1fr 1fr;
1255
+ gap: 12px;
1256
+ margin-top: 12px;
1257
+ }
1258
+
1259
+ .spec-stat-box {
1260
+ background-color: var(--bg-card);
1261
+ color: var(--text-primary);
1262
+ border: 2px solid var(--color-black);
1263
+ border-radius: var(--radius-xs);
1264
+ box-shadow: var(--shadow-offset-xs);
1265
+ padding: 12px;
1266
+ display: flex;
1267
+ flex-direction: column;
1268
+ gap: 4px;
1269
+ }
1270
+
1271
+ .spec-stat-val {
1272
+ font-family: var(--font-mono);
1273
+ font-size: 1.125rem;
1274
+ font-weight: 800;
1275
+ color: var(--color-secondary);
1276
+ }
1277
+
1278
+ .spec-stat-label {
1279
+ font-size: 0.75rem;
1280
+ font-weight: 700;
1281
+ color: var(--text-secondary);
1282
  }
1283
 
1284
  /* ===== FOOTER ===== */
1285
  .footer {
1286
  margin-top: auto;
1287
+ padding: 32px 0;
1288
+ background-color: var(--bg-surface);
1289
+ border-top: 3px solid var(--color-black);
1290
  }
1291
 
1292
  .footer-content {
 
1294
  align-items: center;
1295
  justify-content: space-between;
1296
  flex-wrap: wrap;
1297
+ gap: 16px;
1298
  }
1299
 
1300
  .footer-text {
1301
+ font-family: var(--font-mono);
1302
+ font-size: 0.8125rem;
1303
+ font-weight: 700;
1304
+ color: var(--text-primary);
1305
  }
1306
 
1307
  .footer-links {
1308
  display: flex;
1309
  gap: 16px;
1310
+ flex-wrap: wrap;
1311
  }
1312
 
1313
  .footer-link {
1314
+ font-family: var(--font-mono);
1315
+ font-size: 0.8125rem;
1316
+ font-weight: 700;
1317
+ color: var(--text-primary);
1318
  text-decoration: none;
1319
+ padding: 6px 12px;
1320
+ border: 2px solid var(--color-black);
1321
+ border-radius: var(--radius-xs);
1322
+ background-color: var(--bg-card);
1323
+ box-shadow: var(--shadow-offset-xs);
1324
+ transition: transform 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease;
1325
  }
1326
 
1327
  .footer-link:hover {
1328
+ background-color: var(--color-primary);
1329
+ color: var(--color-black);
1330
+ transform: translate(-1px, -1px);
1331
+ box-shadow: 4px 4px 0 var(--color-black);
1332
+ }
1333
+
1334
+ /* ===== BEFORE/AFTER INTERACTIVE DEMO SHOWCASE SECTION (DESIGN.md) ===== */
1335
+ .demo-section {
1336
+ padding: clamp(2rem, 4vw, 3.5rem) 0;
1337
+ margin-bottom: clamp(2rem, 4vw, 3.5rem);
1338
+ }
1339
+
1340
+ .demo-card {
1341
+ background-color: var(--bg-card);
1342
+ border: 3px solid var(--color-black);
1343
+ border-radius: var(--radius-base);
1344
+ box-shadow: var(--shadow-card);
1345
+ padding: clamp(20px, 3.5vw, 36px);
1346
+ }
1347
+
1348
+ .demo-header {
1349
+ display: flex;
1350
+ justify-content: space-between;
1351
+ align-items: flex-start;
1352
+ flex-wrap: wrap;
1353
+ gap: 16px;
1354
+ margin-bottom: 24px;
1355
+ border-bottom: 2px solid var(--color-black);
1356
+ padding-bottom: 16px;
1357
+ }
1358
+
1359
+ .demo-header-info {
1360
+ display: flex;
1361
+ flex-direction: column;
1362
+ gap: 8px;
1363
+ max-width: 680px;
1364
+ }
1365
+
1366
+ .demo-tag {
1367
+ display: inline-flex;
1368
+ align-items: center;
1369
+ gap: 6px;
1370
+ align-self: flex-start;
1371
+ padding: 4px 10px;
1372
+ background-color: var(--color-primary);
1373
+ color: var(--color-black);
1374
+ border: 2px solid var(--color-black);
1375
+ border-radius: var(--radius-xs);
1376
+ box-shadow: var(--shadow-offset-xs);
1377
+ font-family: var(--font-mono);
1378
+ font-size: 0.75rem;
1379
+ font-weight: 800;
1380
+ text-transform: uppercase;
1381
+ }
1382
+
1383
+ .demo-title {
1384
+ font-size: clamp(1.375rem, 2.5vw, 1.75rem);
1385
+ font-weight: 800;
1386
  color: var(--text-primary);
1387
+ margin: 0;
1388
  }
1389
 
1390
+ .demo-desc {
1391
+ font-size: 0.9375rem;
1392
+ color: var(--text-secondary);
1393
+ line-height: 1.5;
1394
+ }
1395
+
1396
+ .demo-status-pill {
1397
+ font-family: var(--font-mono);
1398
+ font-size: 0.75rem;
1399
+ font-weight: 700;
1400
+ padding: 6px 12px;
1401
+ border: 2px solid var(--color-black);
1402
+ border-radius: var(--radius-xs);
1403
+ box-shadow: var(--shadow-offset-xs);
1404
+ display: inline-flex;
1405
+ align-items: center;
1406
+ gap: 6px;
1407
+ }
1408
+
1409
+ .demo-status-pill.pill-ready {
1410
+ background-color: var(--color-primary);
1411
+ color: var(--color-black);
1412
+ }
1413
+
1414
+ .demo-status-pill.pill-processing {
1415
+ background-color: var(--color-tertiary);
1416
+ color: #FFFFFF;
1417
+ }
1418
+
1419
+ .demo-status-pill.pill-idle {
1420
+ background-color: var(--bg-card-muted);
1421
+ color: var(--text-secondary);
1422
+ }
1423
+
1424
+ /* Slider frame & container */
1425
+ .demo-stage {
1426
+ display: flex;
1427
+ flex-direction: column;
1428
+ align-items: center;
1429
+ gap: 20px;
1430
+ }
1431
+
1432
+ .demo-slider-frame {
1433
+ position: relative;
1434
+ width: 100%;
1435
+ max-width: 520px;
1436
+ aspect-ratio: 1 / 1;
1437
+ border: 3px solid var(--color-black);
1438
+ border-radius: var(--radius-base);
1439
+ box-shadow: var(--shadow-card);
1440
+ overflow: hidden;
1441
+ user-select: none;
1442
+ background-color: var(--bg-card-muted);
1443
+ }
1444
+
1445
+ /* Checkerboard for transparent after layer */
1446
+ .demo-layer-after {
1447
+ position: absolute;
1448
+ inset: 0;
1449
+ width: 100%;
1450
+ height: 100%;
1451
+ overflow: hidden;
1452
+ background-image:
1453
+ linear-gradient(45deg, #E0E0E0 25%, transparent 25%),
1454
+ linear-gradient(-45deg, #E0E0E0 25%, transparent 25%),
1455
+ linear-gradient(45deg, transparent 75%, #E0E0E0 75%),
1456
+ linear-gradient(-45deg, transparent 75%, #E0E0E0 75%);
1457
+ background-size: 20px 20px;
1458
+ background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
1459
+ background-color: #FFFFFF;
1460
+ }
1461
+
1462
+ [data-theme="dark"] .demo-layer-after {
1463
+ background-image:
1464
+ linear-gradient(45deg, #2A2A30 25%, transparent 25%),
1465
+ linear-gradient(-45deg, #2A2A30 25%, transparent 25%),
1466
+ linear-gradient(45deg, transparent 75%, #2A2A30 75%),
1467
+ linear-gradient(-45deg, transparent 75%, #2A2A30 75%);
1468
+ background-size: 20px 20px;
1469
+ background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
1470
+ background-color: #16161B;
1471
+ }
1472
+
1473
+ .demo-layer-before {
1474
+ position: absolute;
1475
+ inset: 0;
1476
+ width: 100%;
1477
+ height: 100%;
1478
+ overflow: hidden;
1479
+ background-color: var(--bg-card-muted);
1480
+ }
1481
+
1482
+ .demo-img {
1483
+ width: 100%;
1484
+ max-width: 100%;
1485
+ height: 100%;
1486
+ object-fit: contain;
1487
+ display: block;
1488
+ pointer-events: none;
1489
+ }
1490
+
1491
+ /* Vertical divider line */
1492
+ .demo-divider {
1493
+ position: absolute;
1494
+ top: 0;
1495
+ bottom: 0;
1496
+ width: 3px;
1497
+ background-color: var(--color-black);
1498
+ z-index: 10;
1499
+ pointer-events: none;
1500
+ }
1501
+
1502
+ /* Draggable handle */
1503
+ .demo-handle {
1504
+ position: absolute;
1505
+ top: 50%;
1506
+ left: 50%;
1507
+ transform: translate(-50%, -50%);
1508
+ width: 44px;
1509
+ height: 44px;
1510
+ background-color: var(--color-primary);
1511
+ border: 3px solid var(--color-black);
1512
+ border-radius: 50%;
1513
+ box-shadow: var(--shadow-offset-xs);
1514
+ display: flex;
1515
+ align-items: center;
1516
+ justify-content: center;
1517
+ color: var(--color-black);
1518
+ cursor: ew-resize;
1519
+ pointer-events: auto;
1520
+ touch-action: none;
1521
+ transition: transform 0.1s ease;
1522
+ }
1523
+
1524
+ .demo-handle:hover {
1525
+ transform: translate(-50%, -50%) scale(1.08);
1526
+ }
1527
+
1528
+ .demo-handle:active {
1529
+ transform: translate(-50%, -50%) scale(0.96);
1530
+ }
1531
+
1532
+ /* Badges inside slider */
1533
+ .demo-badge-floating {
1534
+ position: absolute;
1535
+ top: 14px;
1536
+ z-index: 12;
1537
+ font-family: var(--font-mono);
1538
+ font-size: 0.75rem;
1539
+ font-weight: 800;
1540
+ padding: 4px 10px;
1541
+ border: 2px solid var(--color-black);
1542
+ border-radius: var(--radius-xs);
1543
+ box-shadow: var(--shadow-offset-xs);
1544
+ pointer-events: none;
1545
+ }
1546
+
1547
+ .demo-badge-before {
1548
+ left: 14px;
1549
+ background-color: #FFFFFF;
1550
+ color: #000000;
1551
+ }
1552
+
1553
+ .demo-badge-after {
1554
+ right: 14px;
1555
+ background-color: var(--color-primary);
1556
+ color: var(--color-black);
1557
+ }
1558
+
1559
+ /* Native accessible range input overlaid for keyboard control */
1560
+ .demo-range-input {
1561
+ position: absolute;
1562
+ inset: 0;
1563
+ width: 100%;
1564
+ height: 100%;
1565
+ opacity: 0;
1566
+ cursor: ew-resize;
1567
+ z-index: 15;
1568
+ margin: 0;
1569
+ }
1570
+
1571
+ .demo-range-input:focus-visible + .demo-divider .demo-handle {
1572
+ outline: 3px solid var(--color-tertiary);
1573
+ outline-offset: 4px;
1574
+ }
1575
+
1576
+ /* Slider quick controls / presets */
1577
+ .demo-quick-bar {
1578
+ display: flex;
1579
+ align-items: center;
1580
+ gap: 10px;
1581
+ flex-wrap: wrap;
1582
+ justify-content: center;
1583
+ }
1584
+
1585
+ .demo-quick-btn {
1586
+ font-family: var(--font-sans);
1587
+ font-size: 0.8125rem;
1588
+ font-weight: 700;
1589
+ padding: 8px 16px;
1590
+ background-color: var(--bg-card);
1591
+ color: var(--text-primary);
1592
+ border: 2px solid var(--color-black);
1593
+ border-radius: var(--radius-xs);
1594
+ box-shadow: var(--shadow-offset-xs);
1595
+ cursor: pointer;
1596
+ min-height: 44px;
1597
+ display: inline-flex;
1598
+ align-items: center;
1599
+ transition: transform 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease;
1600
+ }
1601
+
1602
+ .demo-quick-btn:hover {
1603
+ background-color: var(--color-primary);
1604
+ color: var(--color-black);
1605
+ transform: translate(-1px, -1px);
1606
+ box-shadow: 3px 3px 0 var(--color-black);
1607
+ }
1608
+
1609
+ .demo-quick-btn.active {
1610
+ background-color: var(--color-primary);
1611
+ color: var(--color-black);
1612
+ box-shadow: inset 2px 2px 0 var(--color-black);
1613
+ }
1614
+
1615
+ /* Actions below comparison */
1616
+ .demo-actions {
1617
+ display: flex;
1618
+ gap: 14px;
1619
+ flex-wrap: wrap;
1620
+ justify-content: center;
1621
+ width: 100%;
1622
+ max-width: 580px;
1623
+ }
1624
+
1625
+ .demo-helper-note {
1626
+ font-family: var(--font-mono);
1627
+ font-size: 0.75rem;
1628
+ font-weight: 600;
1629
+ color: var(--text-muted);
1630
+ text-align: center;
1631
+ }
1632
+
1633
+ /* ===== RESPONSIVE BREAKPOINTS (COLLAPSE BELOW 768px per DESIGN.md) ===== */
1634
+ @media (max-width: 768px) {
1635
+ .hero-split-grid {
1636
  grid-template-columns: 1fr;
1637
+ gap: 32px;
1638
  }
1639
 
1640
+ .config-grid {
1641
+ grid-template-columns: 1fr;
1642
  }
1643
 
1644
  .main-card {
1645
+ padding: 18px;
1646
  }
1647
 
1648
  .result-section {
1649
+ padding: 18px;
1650
  }
1651
 
1652
  .result-footer-actions {
1653
  flex-direction: column;
1654
  }
1655
 
1656
+ .action-row {
1657
+ flex-direction: column;
1658
+ }
1659
+
1660
  .btn-primary, .btn-secondary {
1661
  width: 100%;
1662
  }
1663
 
1664
+ .zigzag-row {
1665
+ grid-template-columns: 1fr;
1666
+ }
1667
+
1668
  .footer-content {
1669
  flex-direction: column;
1670
  align-items: flex-start;
frontend/src/app/icon.svg CHANGED
frontend/src/app/layout.js CHANGED
@@ -1,15 +1,22 @@
1
- import { Inter } from 'next/font/google'
2
  import './globals.css'
3
 
4
  const inter = Inter({
5
  subsets: ['latin'],
6
  display: 'swap',
 
 
 
 
 
 
 
7
  })
8
 
9
  const SITE_URL = (process.env.NEXT_PUBLIC_SITE_URL || 'https://rembg.vercel.app').replace(/\/+$/, '')
10
 
11
  export const viewport = {
12
- themeColor: '#09090b',
13
  width: 'device-width',
14
  initialScale: 1,
15
  }
@@ -56,6 +63,9 @@ export const metadata = {
56
  'max-snippet': -1,
57
  },
58
  },
 
 
 
59
  }
60
 
61
  const jsonLd = {
@@ -111,7 +121,7 @@ export default function RootLayout({ children }) {
111
  dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
112
  />
113
  </head>
114
- <body className={inter.className}>{children}</body>
115
  </html>
116
  )
117
  }
 
1
+ import { Inter, JetBrains_Mono } from 'next/font/google'
2
  import './globals.css'
3
 
4
  const inter = Inter({
5
  subsets: ['latin'],
6
  display: 'swap',
7
+ variable: '--font-sans',
8
+ })
9
+
10
+ const jetbrainsMono = JetBrains_Mono({
11
+ subsets: ['latin'],
12
+ display: 'swap',
13
+ variable: '--font-mono',
14
  })
15
 
16
  const SITE_URL = (process.env.NEXT_PUBLIC_SITE_URL || 'https://rembg.vercel.app').replace(/\/+$/, '')
17
 
18
  export const viewport = {
19
+ themeColor: '#FFEB3B',
20
  width: 'device-width',
21
  initialScale: 1,
22
  }
 
63
  'max-snippet': -1,
64
  },
65
  },
66
+ verification: process.env.NEXT_PUBLIC_GSC_VERIFICATION
67
+ ? { google: process.env.NEXT_PUBLIC_GSC_VERIFICATION }
68
+ : undefined,
69
  }
70
 
71
  const jsonLd = {
 
121
  dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
122
  />
123
  </head>
124
+ <body className={`${inter.variable} ${jetbrainsMono.variable}`}>{children}</body>
125
  </html>
126
  )
127
  }
frontend/src/app/not-found.js ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Link from 'next/link'
2
+
3
+ export const metadata = {
4
+ title: '404: Halaman Tidak Ditemukan - RemBG',
5
+ description: 'Halaman yang Anda tuju tidak ditemukan atau telah dipindahkan.',
6
+ }
7
+
8
+ export default function NotFound() {
9
+ return (
10
+ <div className="layout-root">
11
+ <header className="header">
12
+ <div className="container">
13
+ <div className="header-content">
14
+ <Link href="/" className="logo-group" style={{ textDecoration: 'none' }}>
15
+ <div className="logo-box">
16
+ <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
17
+ <circle cx="6" cy="6" r="3" />
18
+ <circle cx="6" cy="18" r="3" />
19
+ <line x1="20" y1="4" x2="8.12" y2="15.88" />
20
+ <line x1="14.47" y1="14.48" x2="20" y2="20" />
21
+ <line x1="8.12" y1="8.12" x2="12" y2="12" />
22
+ </svg>
23
+ </div>
24
+ <div className="logo-text">
25
+ <span className="logo-title">RemBG</span>
26
+ <span className="logo-badge">404</span>
27
+ </div>
28
+ </Link>
29
+ </div>
30
+ </div>
31
+ </header>
32
+
33
+ <main style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '40px 16px' }}>
34
+ <div className="container" style={{ maxWidth: '640px' }}>
35
+ <div
36
+ className="main-card"
37
+ style={{
38
+ textAlign: 'center',
39
+ padding: '48px 24px',
40
+ display: 'flex',
41
+ flexDirection: 'column',
42
+ alignItems: 'center',
43
+ gap: '20px',
44
+ }}
45
+ >
46
+ <div
47
+ style={{
48
+ display: 'inline-block',
49
+ background: '#FFEB3B',
50
+ color: '#000000',
51
+ border: '3px solid #000000',
52
+ boxShadow: '4px 4px 0px #000000',
53
+ padding: '6px 16px',
54
+ fontWeight: 900,
55
+ fontSize: '1.25rem',
56
+ fontFamily: 'var(--font-mono, monospace)',
57
+ textTransform: 'uppercase',
58
+ letterSpacing: '0.05em',
59
+ }}
60
+ >
61
+ Kesalahan 404
62
+ </div>
63
+
64
+ <h1
65
+ style={{
66
+ fontSize: 'clamp(1.75rem, 4vw, 2.5rem)',
67
+ fontWeight: 900,
68
+ lineHeight: 1.15,
69
+ margin: '8px 0',
70
+ letterSpacing: '-0.02em',
71
+ }}
72
+ >
73
+ Halaman Tidak Ditemukan
74
+ </h1>
75
+
76
+ <p
77
+ style={{
78
+ color: 'var(--text-secondary, #52525b)',
79
+ fontSize: '1rem',
80
+ maxWidth: '460px',
81
+ margin: 0,
82
+ lineHeight: 1.6,
83
+ }}
84
+ >
85
+ Tautan yang Anda tuju mungkin salah ketik, sudah dihapus, atau berkas telah dipindahkan ke direktori lain.
86
+ </p>
87
+
88
+ <div style={{ marginTop: '12px' }}>
89
+ <Link
90
+ href="/"
91
+ className="btn-primary"
92
+ style={{
93
+ display: 'inline-flex',
94
+ alignItems: 'center',
95
+ gap: '8px',
96
+ textDecoration: 'none',
97
+ fontSize: '0.95rem',
98
+ padding: '12px 24px',
99
+ }}
100
+ >
101
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
102
+ <line x1="19" y1="12" x2="5" y2="12" />
103
+ <polyline points="12 19 5 12 12 5" />
104
+ </svg>
105
+ Kembali ke Halaman Utama
106
+ </Link>
107
+ </div>
108
+ </div>
109
+ </div>
110
+ </main>
111
+
112
+ <footer className="footer">
113
+ <div className="container">
114
+ <div className="footer-content">
115
+ <span className="footer-text">
116
+ RemBG • Pemotong Latar Belakang Foto
117
+ </span>
118
+ </div>
119
+ </div>
120
+ </footer>
121
+ </div>
122
+ )
123
+ }
frontend/src/app/page.js CHANGED
@@ -3,6 +3,7 @@
3
  import { useState, useRef, useCallback, useEffect } from 'react'
4
  import { Turnstile } from '@marsidev/react-turnstile'
5
  import { DEFAULT_TEST_SITE_KEY } from '@/lib/turnstile'
 
6
 
7
  const TURNSTILE_SITE_KEY = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || DEFAULT_TEST_SITE_KEY
8
 
@@ -39,15 +40,28 @@ export default function Home() {
39
  const [viewMode, setViewMode] = useState('result') // 'result' | 'original'
40
  const [theme, setTheme] = useState('dark')
41
 
 
 
 
 
 
 
 
 
42
  const fileInputRef = useRef(null)
43
  const turnstileRef = useRef(null)
44
 
45
- // Inisialisasi tema tampilan
46
  useEffect(() => {
47
  const saved = localStorage.getItem('rembg-theme')
48
  const initial = saved || (window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark')
49
  setTheme(initial)
50
  document.documentElement.setAttribute('data-theme', initial)
 
 
 
 
 
51
  }, [])
52
 
53
  const toggleTheme = () => {
@@ -57,6 +71,61 @@ export default function Home() {
57
  localStorage.setItem('rembg-theme', nextTheme)
58
  }
59
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  const handleFile = useCallback((file) => {
61
  if (!file || !file.type.startsWith('image/')) {
62
  setError('File harus berupa gambar (JPG, PNG, atau WEBP)')
@@ -80,6 +149,19 @@ export default function Home() {
80
  img.src = url
81
  }, [])
82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  // Dukungan paste gambar dari clipboard
84
  useEffect(() => {
85
  const handlePaste = (e) => {
@@ -162,9 +244,17 @@ export default function Home() {
162
  setProgress(100)
163
  setResultUrl(data.image)
164
  setViewMode('result')
 
 
 
 
165
  } catch (err) {
166
  clearInterval(progressInterval)
167
  setError(err.message)
 
 
 
 
168
  } finally {
169
  setLoading(false)
170
  setTimeout(() => setProgress(0), 1000)
@@ -188,6 +278,7 @@ export default function Home() {
188
 
189
  const handleDownload = () => {
190
  if (!resultUrl) return
 
191
  const a = document.createElement('a')
192
  a.href = resultUrl
193
  const baseName = imageFile?.name?.replace(/\.[^.]+$/, '') || 'hasil'
@@ -203,7 +294,7 @@ export default function Home() {
203
  <div className="header-inner">
204
  <a className="logo" href="/" aria-label="RemBG Beranda">
205
  <span className="logo-badge" aria-hidden="true">
206
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
207
  <circle cx="6" cy="6" r="3"/>
208
  <circle cx="6" cy="18" r="3"/>
209
  <line x1="20" y1="4" x2="8.12" y2="15.88"/>
@@ -211,7 +302,10 @@ export default function Home() {
211
  <line x1="8.12" y1="8.12" x2="12" y2="12"/>
212
  </svg>
213
  </span>
214
- <span className="logo-text">RemBG</span>
 
 
 
215
  </a>
216
  <div className="header-meta">
217
  <button
@@ -248,7 +342,7 @@ export default function Home() {
248
  className="hf-link"
249
  >
250
  HF Space
251
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
252
  <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
253
  <polyline points="15 3 21 3 21 9"/>
254
  <line x1="10" y1="14" x2="21" y2="3"/>
@@ -259,13 +353,282 @@ export default function Home() {
259
  </div>
260
  </header>
261
 
262
- {/* Hero */}
263
  <section className="hero">
264
  <div className="container">
265
- <h1>Pemisah Latar Belakang Foto</h1>
266
- <p className="hero-desc">
267
- Pemisahan subjek foto otomatis menggunakan model segmentasi BiRefNet dan ISNet. Hasil transparan resolusi penuh dalam format PNG.
268
- </p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
269
  </div>
270
  </section>
271
 
@@ -347,6 +710,17 @@ export default function Home() {
347
  <div className="dropzone-hint">
348
  Format JPG, PNG, atau WEBP hingga 20MB. Bisa juga tempel langsung (Ctrl+V).
349
  </div>
 
 
 
 
 
 
 
 
 
 
 
350
  </div>
351
  </div>
352
  ) : (
@@ -626,29 +1000,91 @@ export default function Home() {
626
  </div>
627
  )}
628
 
629
- {/* Informasi Teknis & Spesifikasi Model */}
630
  <section className="info-section">
631
- <div className="info-grid">
632
- <article className="info-card">
633
- <h2>Karakteristik Model</h2>
634
- <ul className="model-list">
635
- <li><strong>BiRefNet Portrait:</strong> Optimal untuk potret manusia, mendeteksi lekuk pakaian dan helai rambut secara halus.</li>
636
- <li><strong>BiRefNet General:</strong> Optimal untuk foto produk, kemasan dagang, dan objek benda padat.</li>
637
- <li><strong>ISNet General:</strong> Inferensi cepat dengan batas tepi yang tajam untuk berbagai subjek umum.</li>
638
- <li><strong>ISNet Anime:</strong> Dikhususkan untuk ilustrasi 2D, karakter kartun, anime, dan grafik vektor.</li>
639
- <li><strong>U2Net Standard:</strong> Model segmentasi klasik dengan stabilitas tinggi pada berbagai tipe objek.</li>
640
- <li><strong>U2Net Human:</strong> Segmentasi figur manusia ringan untuk kebutuhan pemrosesan cepat.</li>
641
- </ul>
642
- </article>
643
-
644
- <article className="info-card">
645
- <h2>Format & Pemrosesan</h2>
646
- <ul className="model-list">
647
- <li><strong>Format Masukan:</strong> Mendukung file JPG, JPEG, PNG, dan WEBP dengan ukuran hingga 20MB.</li>
648
- <li><strong>Format Keluaran:</strong> Format PNG dengan kanal alpha transparan penuh tanpa penurunan resolusi dimensi gambar.</li>
649
- <li><strong>Privasi Data:</strong> Berkas gambar diproses sementara di memori inferensi Hugging Face ZeroGPU dan tidak disimpan di server.</li>
650
- </ul>
651
- </article>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
652
  </div>
653
  </section>
654
  </div>
 
3
  import { useState, useRef, useCallback, useEffect } from 'react'
4
  import { Turnstile } from '@marsidev/react-turnstile'
5
  import { DEFAULT_TEST_SITE_KEY } from '@/lib/turnstile'
6
+ import { trackEvent } from '@/lib/analytics'
7
 
8
  const TURNSTILE_SITE_KEY = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || DEFAULT_TEST_SITE_KEY
9
 
 
40
  const [viewMode, setViewMode] = useState('result') // 'result' | 'original'
41
  const [theme, setTheme] = useState('dark')
42
 
43
+ // State untuk Demo Before / After Preview (Reference Image)
44
+ const [demoResultUrl, setDemoResultUrl] = useState('/samples/background-removal-after.png')
45
+ const [demoLoading, setDemoLoading] = useState(false)
46
+ const [demoError, setDemoError] = useState(null)
47
+ const [sliderPos, setSliderPos] = useState(50)
48
+ const [isDraggingSlider, setIsDraggingSlider] = useState(false)
49
+ const sliderFrameRef = useRef(null)
50
+
51
  const fileInputRef = useRef(null)
52
  const turnstileRef = useRef(null)
53
 
54
+ // Inisialisasi tema tampilan dan cache demo
55
  useEffect(() => {
56
  const saved = localStorage.getItem('rembg-theme')
57
  const initial = saved || (window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark')
58
  setTheme(initial)
59
  document.documentElement.setAttribute('data-theme', initial)
60
+
61
+ const cachedDemo = sessionStorage.getItem('rembg-demo-result')
62
+ if (cachedDemo) {
63
+ setDemoResultUrl(cachedDemo)
64
+ }
65
  }, [])
66
 
67
  const toggleTheme = () => {
 
71
  localStorage.setItem('rembg-theme', nextTheme)
72
  }
73
 
74
+ const handleProcessDemo = async () => {
75
+ setDemoLoading(true)
76
+ setDemoError(null)
77
+ try {
78
+ const res = await fetch('/api/demo-remove-bg', {
79
+ method: 'POST',
80
+ headers: { 'Content-Type': 'application/json' },
81
+ body: JSON.stringify({ model_name: 'birefnet-portrait' }),
82
+ })
83
+ const data = await res.json()
84
+ if (!res.ok || !data.success) {
85
+ throw new Error(data.error || 'Gagal memproses gambar contoh')
86
+ }
87
+ setDemoResultUrl(data.image)
88
+ sessionStorage.setItem('rembg-demo-result', data.image)
89
+ trackEvent('remove_bg_demo_success', { model: 'birefnet-portrait' })
90
+ } catch (err) {
91
+ setDemoError(err.message)
92
+ trackEvent('remove_bg_demo_error', { error: err.message })
93
+ } finally {
94
+ setDemoLoading(false)
95
+ }
96
+ }
97
+
98
+ const handleDownloadDemo = () => {
99
+ if (!demoResultUrl) return
100
+ trackEvent('cta_click', { action: 'download_demo' })
101
+ const a = document.createElement('a')
102
+ a.href = demoResultUrl
103
+ a.download = 'person-cap-nobg.png'
104
+ a.click()
105
+ }
106
+
107
+ const updateSliderPosition = (clientX) => {
108
+ if (!sliderFrameRef.current) return
109
+ const rect = sliderFrameRef.current.getBoundingClientRect()
110
+ const x = clientX - rect.left
111
+ const pct = Math.max(0, Math.min(100, (x / rect.width) * 100))
112
+ setSliderPos(Math.round(pct))
113
+ }
114
+
115
+ const handlePointerDown = (e) => {
116
+ setIsDraggingSlider(true)
117
+ updateSliderPosition(e.clientX)
118
+ }
119
+
120
+ const handlePointerMove = (e) => {
121
+ if (!isDraggingSlider) return
122
+ updateSliderPosition(e.clientX)
123
+ }
124
+
125
+ const handlePointerUp = () => {
126
+ setIsDraggingSlider(false)
127
+ }
128
+
129
  const handleFile = useCallback((file) => {
130
  if (!file || !file.type.startsWith('image/')) {
131
  setError('File harus berupa gambar (JPG, PNG, atau WEBP)')
 
149
  img.src = url
150
  }, [])
151
 
152
+ const handleLoadDemoToEditor = useCallback(async () => {
153
+ try {
154
+ const res = await fetch('/samples/background-removal-before.png')
155
+ const blob = await res.blob()
156
+ const file = new File([blob], 'person-cap-reference.png', { type: 'image/png' })
157
+ handleFile(file)
158
+ const mainCard = document.querySelector('.main-card')
159
+ if (mainCard) mainCard.scrollIntoView({ behavior: 'smooth' })
160
+ } catch (err) {
161
+ setError('Gagal memuat foto contoh: ' + err.message)
162
+ }
163
+ }, [handleFile])
164
+
165
  // Dukungan paste gambar dari clipboard
166
  useEffect(() => {
167
  const handlePaste = (e) => {
 
244
  setProgress(100)
245
  setResultUrl(data.image)
246
  setViewMode('result')
247
+ trackEvent('remove_bg_success', {
248
+ model,
249
+ file_size_kb: Math.round((imageFile?.size || 0) / 1024),
250
+ })
251
  } catch (err) {
252
  clearInterval(progressInterval)
253
  setError(err.message)
254
+ trackEvent('remove_bg_error', {
255
+ model,
256
+ error: err.message,
257
+ })
258
  } finally {
259
  setLoading(false)
260
  setTimeout(() => setProgress(0), 1000)
 
278
 
279
  const handleDownload = () => {
280
  if (!resultUrl) return
281
+ trackEvent('cta_click', { action: 'download_result', model })
282
  const a = document.createElement('a')
283
  a.href = resultUrl
284
  const baseName = imageFile?.name?.replace(/\.[^.]+$/, '') || 'hasil'
 
294
  <div className="header-inner">
295
  <a className="logo" href="/" aria-label="RemBG Beranda">
296
  <span className="logo-badge" aria-hidden="true">
297
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
298
  <circle cx="6" cy="6" r="3"/>
299
  <circle cx="6" cy="18" r="3"/>
300
  <line x1="20" y1="4" x2="8.12" y2="15.88"/>
 
302
  <line x1="8.12" y1="8.12" x2="12" y2="12"/>
303
  </svg>
304
  </span>
305
+ <div className="logo-title-group">
306
+ <span className="logo-text">RemBG</span>
307
+ <span className="logo-sub">AI CUTOUT</span>
308
+ </div>
309
  </a>
310
  <div className="header-meta">
311
  <button
 
342
  className="hf-link"
343
  >
344
  HF Space
345
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
346
  <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
347
  <polyline points="15 3 21 3 21 9"/>
348
  <line x1="10" y1="14" x2="21" y2="3"/>
 
353
  </div>
354
  </header>
355
 
356
+ {/* Hero: Split-Screen per DESIGN.md */}
357
  <section className="hero">
358
  <div className="container">
359
+ <div className="hero-split-grid">
360
+ <div className="hero-content">
361
+ <div className="hero-tag-badge">
362
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
363
+ <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
364
+ </svg>
365
+ Segmentasi AI Otomatis
366
+ </div>
367
+ <h1 className="hero-title">Pemisah Latar Belakang Foto</h1>
368
+ <p className="hero-desc">
369
+ Pisahkan subjek foto menjadi format PNG transparan secara cepat menggunakan model BiRefNet dan ISNet. Resolusi penuh tanpa kompresi, aman, dan siap pakai.
370
+ </p>
371
+ <div className="hero-feature-chips">
372
+ <span className="feature-chip chip-yellow">PNG Transparan RGBA</span>
373
+ <span className="feature-chip chip-blue">BiRefNet &amp; ISNet</span>
374
+ <span className="feature-chip chip-red">ZeroGPU Accelerated</span>
375
+ </div>
376
+ </div>
377
+
378
+ <div className="hero-visual-card">
379
+ <div className="hero-visual-header">
380
+ <div className="hero-visual-dots">
381
+ <span className="dot dot-red" />
382
+ <span className="dot dot-yellow" />
383
+ <span className="dot dot-blue" />
384
+ </div>
385
+ <span className="hero-visual-title">HASIL MODEL NYATA</span>
386
+ </div>
387
+ <div className="hero-visual-preview">
388
+ <div className="visual-preview-box box-original">
389
+ <img
390
+ src="/samples/background-removal-before.png"
391
+ alt="Foto asli contoh referensi person with cap"
392
+ style={{ width: '100%', height: '110px', objectFit: 'contain' }}
393
+ />
394
+ <span className="visual-preview-label">Foto Asli</span>
395
+ </div>
396
+ <div className="visual-preview-box box-cutout">
397
+ <img
398
+ src={demoResultUrl || '/samples/background-removal-after.png'}
399
+ alt="Hasil potongan transparan model BiRefNet Portrait"
400
+ style={{ width: '100%', height: '110px', objectFit: 'contain' }}
401
+ />
402
+ <span className="visual-preview-label">Tanpa Background</span>
403
+ </div>
404
+ </div>
405
+ <div className="hero-visual-footer" style={{ gap: '8px' }}>
406
+ <a
407
+ href="#demo-preview-section"
408
+ style={{ color: '#000000', textDecoration: 'none', fontWeight: 800, display: 'flex', alignItems: 'center', gap: '4px' }}
409
+ >
410
+ Buka Slider Interaktif ↓
411
+ </a>
412
+ <span>BiRefNet Portrait</span>
413
+ </div>
414
+ </div>
415
+ </div>
416
+ </div>
417
+ </section>
418
+
419
+ {/* Interactive Before/After Preview Slider Section (MISSION REQUIREMENT) */}
420
+ <section className="demo-section" id="demo-preview-section">
421
+ <div className="container">
422
+ <div className="demo-card">
423
+ <div className="demo-header">
424
+ <div className="demo-header-info">
425
+ <div className="demo-tag">
426
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
427
+ <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
428
+ </svg>
429
+ Perbandingan Interaktif
430
+ </div>
431
+ <h2 className="demo-title">Pratinjau Nyata AI: Sebelum &amp; Sesudah Pemisahan</h2>
432
+ <p className="demo-desc">
433
+ Uji coba langsung kemampuan segmentasi model <strong>BiRefNet Portrait</strong> menggunakan foto referensi asli. Geser pembatas horizontal untuk membandingkan foto asli dengan hasil potongan transparan riil tanpa rekayasa.
434
+ </p>
435
+ </div>
436
+
437
+ <div className="demo-status-pill pill-ready">
438
+ <span className="dot dot-yellow" />
439
+ Model: BiRefNet Portrait (ZeroGPU)
440
+ </div>
441
+ </div>
442
+
443
+ <div className="demo-stage">
444
+ {/* Slider Frame */}
445
+ <div
446
+ ref={sliderFrameRef}
447
+ className="demo-slider-frame"
448
+ onPointerDown={handlePointerDown}
449
+ onPointerMove={handlePointerMove}
450
+ onPointerUp={handlePointerUp}
451
+ onPointerLeave={handlePointerUp}
452
+ role="region"
453
+ aria-label="Area perbandingan foto asli dan hasil potongan AI"
454
+ >
455
+ {/* Layer After (Background Removed with Transparency Checkerboard) */}
456
+ <div className="demo-layer-after">
457
+ {demoResultUrl ? (
458
+ <img
459
+ src={demoResultUrl}
460
+ alt="Hasil potongan transparan model BiRefNet Portrait"
461
+ className="demo-img"
462
+ />
463
+ ) : (
464
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', color: 'var(--text-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.875rem' }}>
465
+ Menunggu pemrosesan model...
466
+ </div>
467
+ )}
468
+ </div>
469
+
470
+ {/* Layer Before (Original Image, Clipped to sliderPos %) */}
471
+ <div
472
+ className="demo-layer-before"
473
+ style={{
474
+ clipPath: `polygon(0 0, ${sliderPos}% 0, ${sliderPos}% 100%, 0 100%)`,
475
+ }}
476
+ >
477
+ <img
478
+ src="/samples/background-removal-before.png"
479
+ alt="Foto asli sebelum pemisahan latar belakang"
480
+ className="demo-img"
481
+ />
482
+ </div>
483
+
484
+ {/* Badges inside frame */}
485
+ <span className="demo-badge-floating demo-badge-before">
486
+ Foto Asli ({sliderPos}%)
487
+ </span>
488
+ <span className="demo-badge-floating demo-badge-after">
489
+ Hasil AI ({100 - sliderPos}%)
490
+ </span>
491
+
492
+ {/* Vertical Divider Line & Draggable Handle */}
493
+ <div
494
+ className="demo-divider"
495
+ style={{ left: `${sliderPos}%` }}
496
+ >
497
+ <div
498
+ className="demo-handle"
499
+ title="Tarik ke kiri atau kanan untuk membandingkan"
500
+ aria-hidden="true"
501
+ >
502
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
503
+ <polyline points="15 18 9 12 15 6" />
504
+ </svg>
505
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ marginLeft: '-10px' }}>
506
+ <polyline points="9 18 15 12 9 6" />
507
+ </svg>
508
+ </div>
509
+ </div>
510
+
511
+ {/* Accessible range input for screen readers and keyboard navigation */}
512
+ <input
513
+ type="range"
514
+ min="0"
515
+ max="100"
516
+ value={sliderPos}
517
+ onChange={(e) => setSliderPos(Number(e.target.value))}
518
+ className="demo-range-input"
519
+ aria-label="Penggeser perbandingan sebelum dan sesudah pemisahan latar belakang"
520
+ />
521
+ </div>
522
+
523
+ {/* Quick Presets */}
524
+ <div className="demo-quick-bar" role="group" aria-label="Preset posisi perbandingan">
525
+ <button
526
+ type="button"
527
+ className={`demo-quick-btn ${sliderPos === 100 ? 'active' : ''}`}
528
+ onClick={() => setSliderPos(100)}
529
+ >
530
+ Foto Asli Penuh (100%)
531
+ </button>
532
+ <button
533
+ type="button"
534
+ className={`demo-quick-btn ${sliderPos === 50 ? 'active' : ''}`}
535
+ onClick={() => setSliderPos(50)}
536
+ >
537
+ Bandingkan Imbang (50%)
538
+ </button>
539
+ <button
540
+ type="button"
541
+ className={`demo-quick-btn ${sliderPos === 0 ? 'active' : ''}`}
542
+ onClick={() => setSliderPos(0)}
543
+ >
544
+ Hasil AI Penuh (0%)
545
+ </button>
546
+ </div>
547
+
548
+ {/* Progress and Error States */}
549
+ {demoLoading && (
550
+ <div className="progress-wrapper" style={{ width: '100%', maxWidth: '520px' }}>
551
+ <div className="progress-header">
552
+ <span>Menjalankan inferensi model BiRefNet di Hugging Face ZeroGPU...</span>
553
+ </div>
554
+ <div className="progress-track" role="progressbar" aria-busy="true">
555
+ <div className="progress-bar-fill" style={{ width: '100%' }} />
556
+ </div>
557
+ </div>
558
+ )}
559
+
560
+ {demoError && (
561
+ <div className="error-banner" style={{ width: '100%', maxWidth: '520px' }}>
562
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
563
+ <circle cx="12" cy="12" r="10" />
564
+ <line x1="12" y1="8" x2="12" y2="12" />
565
+ <line x1="12" y1="16" x2="12.01" y2="16" />
566
+ </svg>
567
+ <span>{demoError}</span>
568
+ </div>
569
+ )}
570
+
571
+ {/* Actions */}
572
+ <div className="demo-actions">
573
+ <button
574
+ type="button"
575
+ className="btn-primary"
576
+ onClick={handleProcessDemo}
577
+ disabled={demoLoading}
578
+ style={{ minWidth: '220px' }}
579
+ >
580
+ {demoLoading ? (
581
+ <>
582
+ <span className="spinner-icon" aria-hidden="true" />
583
+ Memproses ZeroGPU...
584
+ </>
585
+ ) : (
586
+ <>
587
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
588
+ <polyline points="23 4 23 10 17 10" />
589
+ <path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
590
+ </svg>
591
+ {demoResultUrl ? 'Proses Ulang (Live ZeroGPU)' : 'Hapus Background dengan AI'}
592
+ </>
593
+ )}
594
+ </button>
595
+
596
+ {demoResultUrl && (
597
+ <button
598
+ type="button"
599
+ className="btn-secondary"
600
+ onClick={handleDownloadDemo}
601
+ title="Unduh berkas PNG transparan hasil model AI"
602
+ >
603
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
604
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
605
+ <polyline points="7 10 12 15 17 10" />
606
+ <line x1="12" y1="15" x2="12" y2="3" />
607
+ </svg>
608
+ Unduh PNG Transparan
609
+ </button>
610
+ )}
611
+
612
+ <button
613
+ type="button"
614
+ className="btn-secondary"
615
+ onClick={handleLoadDemoToEditor}
616
+ title="Buka foto contoh ini di formulir upload bawah untuk mencoba model segmentasi lain"
617
+ >
618
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
619
+ <rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
620
+ <circle cx="8.5" cy="8.5" r="1.5" />
621
+ <polyline points="21 15 16 10 5 21" />
622
+ </svg>
623
+ Gunakan di Editor Utama ↓
624
+ </button>
625
+ </div>
626
+
627
+ <span className="demo-helper-note">
628
+ Tekan tombol panah keyboard atau geser tuas pembatas untuk membandingkan transparansi piksel.
629
+ </span>
630
+ </div>
631
+ </div>
632
  </div>
633
  </section>
634
 
 
710
  <div className="dropzone-hint">
711
  Format JPG, PNG, atau WEBP hingga 20MB. Bisa juga tempel langsung (Ctrl+V).
712
  </div>
713
+ <button
714
+ type="button"
715
+ className="btn-ghost-small"
716
+ style={{ marginTop: '8px' }}
717
+ onClick={(e) => {
718
+ e.stopPropagation()
719
+ handleLoadDemoToEditor()
720
+ }}
721
+ >
722
+ Atau Coba Foto Contoh (Person with Cap)
723
+ </button>
724
  </div>
725
  </div>
726
  ) : (
 
1000
  </div>
1001
  )}
1002
 
1003
+ {/* Informasi Teknis & Spesifikasi Model: Zig-Zag Alternating Layout per DESIGN.md */}
1004
  <section className="info-section">
1005
+ <div className="section-headline-group">
1006
+ <span className="section-tag">Spesifikasi Sistem</span>
1007
+ <h2>Karakteristik &amp; Arsitektur Pemrosesan</h2>
1008
+ </div>
1009
+
1010
+ <div className="zigzag-container">
1011
+ {/* Row 1: Model Segmentasi (Text on left, Spec highlight on right) */}
1012
+ <div className="zigzag-row">
1013
+ <article className="info-card">
1014
+ <div>
1015
+ <div className="info-card-header">
1016
+ <span className="info-card-badge badge-yellow">Katalog Model</span>
1017
+ <h2>Karakteristik Model AI</h2>
1018
+ </div>
1019
+ <ul className="model-list">
1020
+ <li className="model-item"><strong>BiRefNet Portrait:</strong> Optimal untuk potret manusia, mendeteksi lekuk pakaian dan helai rambut halus.</li>
1021
+ <li className="model-item"><strong>BiRefNet General:</strong> Optimal untuk foto produk, kemasan dagang, dan benda padat.</li>
1022
+ <li className="model-item"><strong>ISNet General:</strong> Inferensi cepat dengan batas tepi tajam untuk berbagai subjek umum.</li>
1023
+ <li className="model-item"><strong>ISNet Anime:</strong> Dikhususkan untuk ilustrasi 2D, karakter kartun, anime, dan grafik vektor.</li>
1024
+ <li className="model-item"><strong>U2Net Standard:</strong> Model segmentasi klasik dengan stabilitas tinggi pada aneka tipe objek.</li>
1025
+ <li className="model-item"><strong>U2Net Human:</strong> Segmentasi figur manusia ringan untuk kebutuhan pemrosesan cepat.</li>
1026
+ </ul>
1027
+ </div>
1028
+ </article>
1029
+
1030
+ <aside className="spec-highlight-card" aria-label="Ringkasan Kapabilitas Model">
1031
+ <div>
1032
+ <span className="info-card-badge badge-yellow" style={{ background: '#000000', color: '#FFEB3B' }}>Kinerja Inferensi</span>
1033
+ <h3 className="spec-highlight-title" style={{ marginTop: '12px' }}>Segmentasi Birefnet Tingkat Lanjut</h3>
1034
+ <p style={{ marginTop: '8px', fontSize: '0.875rem' }}>
1035
+ Algoritma BiRefNet memetakan peta kedalaman bilateral resolusi tinggi untuk memisahkan latar belakang tanpa kehilangan detail rambut dan batas tepi tipis.
1036
+ </p>
1037
+ </div>
1038
+ <div className="spec-stat-grid">
1039
+ <div className="spec-stat-box">
1040
+ <span className="spec-stat-val">6 Model</span>
1041
+ <span className="spec-stat-label">Pilihan Segmentasi</span>
1042
+ </div>
1043
+ <div className="spec-stat-box">
1044
+ <span className="spec-stat-val">20 MB</span>
1045
+ <span className="spec-stat-label">Maksimal Berkas</span>
1046
+ </div>
1047
+ </div>
1048
+ </aside>
1049
+ </div>
1050
+
1051
+ {/* Row 2: Format & Privasi Data (Spec highlight on left, Text on right - ZIG ZAG) */}
1052
+ <div className="zigzag-row">
1053
+ <aside className="spec-highlight-card card-blue" aria-label="Keamanan dan Format">
1054
+ <div>
1055
+ <span className="info-card-badge badge-blue" style={{ background: '#000000', color: '#2196F3' }}>Standar Privasi</span>
1056
+ <h3 className="spec-highlight-title" style={{ marginTop: '12px' }}>ZeroGPU Ephemeral Processing</h3>
1057
+ <p style={{ marginTop: '8px', fontSize: '0.875rem' }}>
1058
+ Berkas gambar dieksekusi langsung di memori GPU terisolasi dan dihapus seketika setelah keluaran PNG dikirimkan. Tidak ada penyimpanan permanen di server.
1059
+ </p>
1060
+ </div>
1061
+ <div className="spec-stat-grid">
1062
+ <div className="spec-stat-box">
1063
+ <span className="spec-stat-val">32-Bit</span>
1064
+ <span className="spec-stat-label">PNG Kanal RGBA</span>
1065
+ </div>
1066
+ <div className="spec-stat-box">
1067
+ <span className="spec-stat-val">100%</span>
1068
+ <span className="spec-stat-label">Dimensi Asli Utuh</span>
1069
+ </div>
1070
+ </div>
1071
+ </aside>
1072
+
1073
+ <article className="info-card">
1074
+ <div>
1075
+ <div className="info-card-header">
1076
+ <span className="info-card-badge badge-red">Format &amp; Keamanan</span>
1077
+ <h2>Format &amp; Integritas Data</h2>
1078
+ </div>
1079
+ <ul className="model-list">
1080
+ <li className="model-item"><strong>Format Masukan:</strong> Mendukung berkas JPG, JPEG, PNG, dan WEBP hingga ukuran 20MB.</li>
1081
+ <li className="model-item"><strong>Format Keluaran:</strong> Format PNG dengan kanal alpha transparan penuh tanpa penurunan resolusi dimensi gambar asli.</li>
1082
+ <li className="model-item"><strong>Privasi Pengguna:</strong> Seluruh pemrosesan inferensi berjalan sementara di infrastruktur Hugging Face ZeroGPU tanpa pencatatan log konten gambar.</li>
1083
+ <li className="model-item"><strong>Perlindungan Bot:</strong> Dilindungi oleh Cloudflare Turnstile tanpa captcha yang mengganggu kenyamanan pengguna.</li>
1084
+ </ul>
1085
+ </div>
1086
+ </article>
1087
+ </div>
1088
  </div>
1089
  </section>
1090
  </div>
frontend/src/lib/analytics.js ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Safe, privacy-first telemetry dispatcher.
3
+ * Dispatches events only if an authorized provider (Google Analytics gtag or Plausible) is mounted.
4
+ * Never collects or transmits personal data, filenames, or image content.
5
+ */
6
+
7
+ export function trackEvent(eventName, params = {}) {
8
+ if (typeof window === 'undefined') return
9
+
10
+ // 1. Google Analytics (gtag)
11
+ if (typeof window.gtag === 'function') {
12
+ try {
13
+ window.gtag('event', eventName, params)
14
+ } catch (e) {
15
+ // Suppress analytics dispatch errors to never disrupt UI
16
+ }
17
+ }
18
+
19
+ // 2. Plausible Analytics
20
+ if (typeof window.plausible === 'function') {
21
+ try {
22
+ window.plausible(eventName, { props: params })
23
+ } catch (e) {
24
+ // Suppress analytics dispatch errors
25
+ }
26
+ }
27
+
28
+ // 3. Custom Event for extensible listeners
29
+ try {
30
+ window.dispatchEvent(
31
+ new CustomEvent('rembg:track', {
32
+ detail: { eventName, params },
33
+ })
34
+ )
35
+ } catch (e) {
36
+ // Suppress custom event errors
37
+ }
38
+ }
frontend/src/lib/rate-limit.js ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * In-memory sliding-window rate limiter for API endpoints.
3
+ * Minimal zero-dependency implementation.
4
+ */
5
+
6
+ const ipRequests = new Map()
7
+
8
+ // Clean up expired entries every 5 minutes to prevent memory leaks
9
+ const CLEANUP_INTERVAL_MS = 5 * 60 * 1000
10
+ let lastCleanup = Date.now()
11
+
12
+ function cleanupExpired(windowMs) {
13
+ const now = Date.now()
14
+ if (now - lastCleanup < CLEANUP_INTERVAL_MS) return
15
+ lastCleanup = now
16
+
17
+ for (const [ip, timestamps] of ipRequests.entries()) {
18
+ const valid = timestamps.filter((t) => now - t < windowMs)
19
+ if (valid.length === 0) {
20
+ ipRequests.delete(ip)
21
+ } else {
22
+ ipRequests.set(ip, valid)
23
+ }
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Check if a request from an IP should be rate limited.
29
+ * @param {string} ip - Client IP identifier
30
+ * @param {number} maxRequests - Maximum allowed requests in the time window
31
+ * @param {number} windowMs - Window duration in milliseconds (default: 60,000ms = 1 minute)
32
+ * @returns {{ allowed: boolean, remaining: number, resetMs: number }}
33
+ */
34
+ export function checkRateLimit(ip = '127.0.0.1', maxRequests = 15, windowMs = 60_000) {
35
+ const now = Date.now()
36
+ cleanupExpired(windowMs)
37
+
38
+ const timestamps = ipRequests.get(ip) || []
39
+ const validTimestamps = timestamps.filter((t) => now - t < windowMs)
40
+
41
+ if (validTimestamps.length >= maxRequests) {
42
+ const oldestTimestamp = validTimestamps[0]
43
+ const resetMs = windowMs - (now - oldestTimestamp)
44
+ return {
45
+ allowed: false,
46
+ remaining: 0,
47
+ resetMs: Math.max(0, resetMs),
48
+ }
49
+ }
50
+
51
+ validTimestamps.push(now)
52
+ ipRequests.set(ip, validTimestamps)
53
+
54
+ return {
55
+ allowed: true,
56
+ remaining: maxRequests - validTimestamps.length,
57
+ resetMs: windowMs,
58
+ }
59
+ }
frontend/src/lib/rate-limit.test.js ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { checkRateLimit } from './rate-limit.js'
4
+
5
+ test('rate limiter allows requests within threshold', () => {
6
+ const ip = 'test-ip-1'
7
+ const result1 = checkRateLimit(ip, 3, 1000)
8
+ assert.equal(result1.allowed, true)
9
+ assert.equal(result1.remaining, 2)
10
+
11
+ const result2 = checkRateLimit(ip, 3, 1000)
12
+ assert.equal(result2.allowed, true)
13
+ assert.equal(result2.remaining, 1)
14
+
15
+ const result3 = checkRateLimit(ip, 3, 1000)
16
+ assert.equal(result3.allowed, true)
17
+ assert.equal(result3.remaining, 0)
18
+ })
19
+
20
+ test('rate limiter blocks requests exceeding threshold', () => {
21
+ const ip = 'test-ip-blocked'
22
+ checkRateLimit(ip, 2, 5000)
23
+ checkRateLimit(ip, 2, 5000)
24
+
25
+ const blocked = checkRateLimit(ip, 2, 5000)
26
+ assert.equal(blocked.allowed, false)
27
+ assert.equal(blocked.remaining, 0)
28
+ assert.ok(blocked.resetMs > 0)
29
+ })