feat: implement real before/after preview, Neubrutalism UI redesign, production readiness fixes, and update README
Browse files- .gitattributes +4 -0
- .gitignore +24 -0
- DESIGN.md +129 -0
- README.md +156 -1
- docs/screenshots/before-after-slider.png +3 -0
- docs/screenshots/hero-preview.png +3 -0
- docs/screenshots/main-workspace.png +3 -0
- frontend/.env.example +6 -0
- frontend/package.json +1 -1
- frontend/public/samples/background-removal-after.png +3 -0
- frontend/public/samples/background-removal-before.png +3 -0
- frontend/src/app/api/demo-remove-bg/route.js +165 -0
- frontend/src/app/api/remove-bg/route.js +38 -7
- frontend/src/app/globals.css +1138 -350
- frontend/src/app/icon.svg +9 -7
- frontend/src/app/layout.js +13 -3
- frontend/src/app/not-found.js +123 -0
- frontend/src/app/page.js +467 -31
- frontend/src/lib/analytics.js +38 -0
- frontend/src/lib/rate-limit.js +59 -0
- frontend/src/lib/rate-limit.test.js +29 -0
.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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+

|
| 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 |
+

|
| 27 |
+
|
| 28 |
+
### 3. Editor Kerja Utama (Workspace Pemrosesan)
|
| 29 |
+
Area unggah drag-and-drop, seleksi model segmentasi, opsi alpha matting, dan inspeksi hasil:
|
| 30 |
+

|
| 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
|
docs/screenshots/hero-preview.png
ADDED
|
Git LFS Details
|
docs/screenshots/main-workspace.png
ADDED
|
Git LFS Details
|
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/
|
| 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
|
frontend/public/samples/background-removal-before.png
ADDED
|
Git LFS Details
|
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
|
|
|
|
| 18 |
const alphaMatting = formData.get('alpha_matting') === 'true'
|
| 19 |
const turnstileToken = formData.get('turnstile_token')
|
| 20 |
|
| 21 |
-
// ── STEP
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
/* =====
|
| 2 |
|
| 3 |
:root {
|
| 4 |
-
/*
|
| 5 |
-
--
|
| 6 |
-
--
|
| 7 |
-
--
|
| 8 |
-
--
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
--
|
| 12 |
-
--
|
| 13 |
-
--
|
| 14 |
-
--
|
| 15 |
-
|
| 16 |
-
--
|
| 17 |
-
|
| 18 |
-
--text-
|
| 19 |
-
|
| 20 |
-
--
|
| 21 |
-
|
| 22 |
-
--
|
| 23 |
-
|
| 24 |
-
--
|
| 25 |
-
--
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
--radius-xs: 4px;
|
| 29 |
--radius-sm: 6px;
|
|
|
|
| 30 |
--radius-md: 8px;
|
| 31 |
--radius-lg: 12px;
|
| 32 |
|
| 33 |
-
--
|
| 34 |
-
--
|
| 35 |
}
|
| 36 |
|
| 37 |
-
[data-theme="
|
| 38 |
-
/*
|
| 39 |
-
--bg-base: #
|
| 40 |
-
--bg-surface: #
|
| 41 |
-
--bg-card: #
|
| 42 |
-
--bg-card-
|
| 43 |
-
--bg-input: #
|
|
|
|
| 44 |
|
| 45 |
-
--
|
| 46 |
-
--
|
| 47 |
-
--
|
| 48 |
-
--border-focus: #111827;
|
| 49 |
|
| 50 |
-
--
|
| 51 |
-
--
|
| 52 |
-
--
|
| 53 |
|
| 54 |
-
--
|
| 55 |
-
--
|
| 56 |
-
--
|
| 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:
|
| 74 |
background-color: var(--bg-base);
|
| 75 |
color: var(--text-primary);
|
| 76 |
-
min-height:
|
| 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:
|
| 86 |
-
outline-offset:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
}
|
| 88 |
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
.app-wrapper {
|
| 91 |
-
min-height:
|
| 92 |
display: flex;
|
| 93 |
flex-direction: column;
|
| 94 |
background-color: var(--bg-base);
|
|
|
|
|
|
|
| 95 |
}
|
| 96 |
|
|
|
|
| 97 |
.container {
|
| 98 |
width: 100%;
|
| 99 |
-
max-width:
|
| 100 |
margin: 0 auto;
|
| 101 |
-
padding: 0
|
| 102 |
}
|
| 103 |
|
| 104 |
-
/* ===== HEADER ===== */
|
| 105 |
.header {
|
| 106 |
-
padding:
|
| 107 |
-
border-bottom: 1px solid var(--border-subtle);
|
| 108 |
background-color: var(--bg-surface);
|
|
|
|
| 109 |
position: sticky;
|
| 110 |
top: 0;
|
| 111 |
-
z-index:
|
| 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:
|
| 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:
|
| 134 |
-
height:
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
border:
|
| 138 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
}
|
| 140 |
|
| 141 |
.logo-text {
|
| 142 |
-
font-size:
|
| 143 |
-
font-weight:
|
| 144 |
-
letter-spacing: -0.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
}
|
| 146 |
|
| 147 |
.header-meta {
|
| 148 |
display: flex;
|
| 149 |
align-items: center;
|
| 150 |
-
gap:
|
| 151 |
}
|
| 152 |
|
| 153 |
-
/* Theme Toggle Button */
|
| 154 |
.theme-toggle-btn {
|
| 155 |
display: inline-flex;
|
| 156 |
align-items: center;
|
| 157 |
-
gap:
|
| 158 |
min-height: 44px;
|
| 159 |
-
padding: 0
|
| 160 |
background-color: var(--bg-card);
|
| 161 |
-
border:
|
| 162 |
-
border-radius: var(--radius-
|
| 163 |
-
|
| 164 |
-
|
|
|
|
|
|
|
|
|
|
| 165 |
cursor: pointer;
|
| 166 |
-
|
|
|
|
| 167 |
}
|
| 168 |
|
| 169 |
.theme-toggle-btn:hover {
|
| 170 |
-
color: var(--
|
| 171 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
}
|
| 173 |
|
| 174 |
.theme-toggle-label {
|
| 175 |
-
font-size:
|
| 176 |
-
font-weight:
|
|
|
|
| 177 |
}
|
| 178 |
|
|
|
|
| 179 |
.hf-link {
|
| 180 |
-
font-
|
| 181 |
-
|
|
|
|
|
|
|
| 182 |
text-decoration: none;
|
| 183 |
display: inline-flex;
|
| 184 |
align-items: center;
|
| 185 |
-
gap:
|
| 186 |
min-height: 44px;
|
| 187 |
-
padding: 0
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
|
|
|
|
|
|
|
|
|
| 191 |
}
|
| 192 |
|
| 193 |
.hf-link:hover {
|
| 194 |
-
|
| 195 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
}
|
| 197 |
|
| 198 |
-
/* ===== HERO ===== */
|
| 199 |
.hero {
|
| 200 |
-
padding:
|
| 201 |
-
text-align: center;
|
| 202 |
}
|
| 203 |
|
| 204 |
-
.hero
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
color: var(--text-primary);
|
| 210 |
-
margin-bottom: 10px;
|
| 211 |
}
|
| 212 |
|
| 213 |
.hero-desc {
|
| 214 |
-
font-size:
|
| 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:
|
| 225 |
-
border-radius: var(--radius-
|
| 226 |
-
padding:
|
| 227 |
box-shadow: var(--shadow-card);
|
|
|
|
| 228 |
}
|
| 229 |
|
| 230 |
/* Configuration grid */
|
| 231 |
.config-grid {
|
| 232 |
display: grid;
|
| 233 |
-
grid-template-columns: 1fr
|
| 234 |
-
gap:
|
| 235 |
-
margin-bottom:
|
| 236 |
}
|
| 237 |
|
| 238 |
.field-group {
|
| 239 |
display: flex;
|
| 240 |
flex-direction: column;
|
| 241 |
-
gap:
|
| 242 |
}
|
| 243 |
|
| 244 |
.field-label {
|
| 245 |
-
font-
|
| 246 |
-
font-
|
| 247 |
-
|
|
|
|
|
|
|
|
|
|
| 248 |
}
|
| 249 |
|
| 250 |
.select-control {
|
| 251 |
width: 100%;
|
| 252 |
-
min-height:
|
| 253 |
-
padding: 10px
|
| 254 |
background-color: var(--bg-input);
|
| 255 |
-
border:
|
| 256 |
-
border-radius: var(--radius-
|
|
|
|
| 257 |
color: var(--text-primary);
|
| 258 |
-
font-
|
| 259 |
-
font-
|
|
|
|
| 260 |
cursor: pointer;
|
| 261 |
outline: none;
|
| 262 |
-
transition:
|
| 263 |
}
|
| 264 |
|
| 265 |
.select-control:focus {
|
| 266 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
}
|
| 268 |
|
|
|
|
| 269 |
.toggle-card {
|
| 270 |
display: flex;
|
| 271 |
align-items: center;
|
| 272 |
-
gap:
|
| 273 |
-
padding: 10px
|
| 274 |
background-color: var(--bg-input);
|
| 275 |
-
border:
|
| 276 |
-
border-radius: var(--radius-
|
|
|
|
| 277 |
cursor: pointer;
|
| 278 |
user-select: none;
|
| 279 |
-
min-height:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 280 |
}
|
| 281 |
|
| 282 |
.toggle-card input[type="checkbox"] {
|
| 283 |
-
width:
|
| 284 |
-
height:
|
| 285 |
cursor: pointer;
|
| 286 |
-
accent-color: var(--
|
| 287 |
}
|
| 288 |
|
| 289 |
.toggle-text {
|
| 290 |
-
font-
|
| 291 |
-
|
|
|
|
|
|
|
| 292 |
line-height: 1.3;
|
| 293 |
}
|
| 294 |
|
| 295 |
-
/* ===== DROPZONE & UPLOAD ===== */
|
| 296 |
.dropzone {
|
| 297 |
-
border:
|
| 298 |
-
border-radius: var(--radius-
|
| 299 |
-
background-color: var(--bg-
|
| 300 |
-
|
|
|
|
| 301 |
text-align: center;
|
| 302 |
cursor: pointer;
|
| 303 |
position: relative;
|
| 304 |
-
transition:
|
| 305 |
}
|
| 306 |
|
| 307 |
.dropzone:hover,
|
| 308 |
.dropzone.drag-active {
|
| 309 |
-
|
| 310 |
-
|
|
|
|
| 311 |
}
|
| 312 |
|
| 313 |
.dropzone-inner {
|
| 314 |
display: flex;
|
| 315 |
flex-direction: column;
|
| 316 |
align-items: center;
|
| 317 |
-
gap:
|
| 318 |
}
|
| 319 |
|
| 320 |
.dropzone-icon {
|
| 321 |
-
width:
|
| 322 |
-
height:
|
| 323 |
-
color: var(--
|
|
|
|
|
|
|
|
|
|
|
|
|
| 324 |
display: flex;
|
| 325 |
align-items: center;
|
| 326 |
justify-content: center;
|
| 327 |
}
|
| 328 |
|
| 329 |
.dropzone-title {
|
| 330 |
-
font-size:
|
| 331 |
-
font-weight:
|
| 332 |
color: var(--text-primary);
|
| 333 |
}
|
| 334 |
|
| 335 |
.dropzone-hint {
|
| 336 |
-
font-size:
|
| 337 |
-
color: var(--text-
|
| 338 |
-
max-width:
|
|
|
|
| 339 |
}
|
| 340 |
|
| 341 |
/* Uploaded Image Preview */
|
| 342 |
.preview-container {
|
| 343 |
display: flex;
|
| 344 |
flex-direction: column;
|
| 345 |
-
gap:
|
| 346 |
}
|
| 347 |
|
| 348 |
.preview-media-box {
|
| 349 |
-
background-color: var(--bg-
|
| 350 |
-
border:
|
| 351 |
-
border-radius: var(--radius-
|
| 352 |
-
|
|
|
|
| 353 |
display: flex;
|
| 354 |
justify-content: center;
|
| 355 |
align-items: center;
|
| 356 |
-
min-height:
|
| 357 |
-
max-height:
|
| 358 |
overflow: hidden;
|
| 359 |
}
|
| 360 |
|
| 361 |
.preview-img {
|
| 362 |
max-width: 100%;
|
| 363 |
-
max-height:
|
| 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:
|
| 375 |
}
|
| 376 |
|
| 377 |
.meta-info {
|
| 378 |
-
font-
|
| 379 |
-
|
| 380 |
display: flex;
|
| 381 |
align-items: center;
|
| 382 |
-
gap:
|
| 383 |
flex-wrap: wrap;
|
| 384 |
}
|
| 385 |
|
| 386 |
.meta-filename {
|
| 387 |
-
font-weight:
|
| 388 |
color: var(--text-primary);
|
| 389 |
-
max-width:
|
| 390 |
overflow: hidden;
|
| 391 |
text-overflow: ellipsis;
|
| 392 |
white-space: nowrap;
|
| 393 |
}
|
| 394 |
|
| 395 |
.meta-badge {
|
| 396 |
-
background-color: var(--
|
| 397 |
-
|
| 398 |
-
|
|
|
|
|
|
|
| 399 |
border-radius: var(--radius-xs);
|
| 400 |
-
font-size:
|
| 401 |
-
|
| 402 |
}
|
| 403 |
|
| 404 |
.btn-ghost-small {
|
| 405 |
-
background:
|
| 406 |
-
border:
|
| 407 |
-
border-radius: var(--radius-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
font-
|
| 411 |
-
|
|
|
|
|
|
|
| 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(--
|
| 421 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 422 |
}
|
| 423 |
|
| 424 |
-
/* Progress bar */
|
| 425 |
.progress-wrapper {
|
| 426 |
-
margin-top:
|
| 427 |
display: flex;
|
| 428 |
flex-direction: column;
|
| 429 |
-
gap:
|
| 430 |
}
|
| 431 |
|
| 432 |
.progress-header {
|
| 433 |
display: flex;
|
| 434 |
justify-content: space-between;
|
| 435 |
-
font-
|
| 436 |
-
|
|
|
|
|
|
|
| 437 |
}
|
| 438 |
|
| 439 |
.progress-value {
|
| 440 |
-
font-weight:
|
| 441 |
-
color: var(--
|
| 442 |
}
|
| 443 |
|
| 444 |
.progress-track {
|
| 445 |
-
height:
|
| 446 |
-
background-color: var(--bg-
|
| 447 |
-
border
|
|
|
|
|
|
|
| 448 |
overflow: hidden;
|
| 449 |
}
|
| 450 |
|
| 451 |
.progress-bar-fill {
|
| 452 |
height: 100%;
|
| 453 |
-
background-color: var(--
|
|
|
|
| 454 |
transition: width 0.3s ease;
|
| 455 |
}
|
| 456 |
|
| 457 |
-
/* Error message */
|
| 458 |
.error-banner {
|
| 459 |
-
margin-top:
|
| 460 |
-
padding:
|
| 461 |
-
background-color:
|
| 462 |
-
border:
|
| 463 |
-
border-radius: var(--radius-
|
| 464 |
-
|
| 465 |
-
|
|
|
|
|
|
|
|
|
|
| 466 |
display: flex;
|
| 467 |
align-items: center;
|
| 468 |
-
gap:
|
| 469 |
-
}
|
| 470 |
-
|
| 471 |
-
[data-theme="dark"] .error-banner {
|
| 472 |
-
color: #fca5a5;
|
| 473 |
}
|
| 474 |
|
| 475 |
/* Turnstile container */
|
| 476 |
.turnstile-box {
|
| 477 |
-
margin-top:
|
| 478 |
display: flex;
|
| 479 |
flex-direction: column;
|
| 480 |
align-items: center;
|
| 481 |
justify-content: center;
|
| 482 |
-
gap:
|
| 483 |
min-height: 65px;
|
| 484 |
}
|
| 485 |
|
| 486 |
.turnstile-notice {
|
| 487 |
-
font-
|
| 488 |
-
|
|
|
|
|
|
|
| 489 |
}
|
| 490 |
|
| 491 |
/* Action button area */
|
| 492 |
.action-row {
|
| 493 |
-
margin-top:
|
| 494 |
display: flex;
|
| 495 |
-
gap:
|
| 496 |
}
|
| 497 |
|
|
|
|
| 498 |
.btn-primary {
|
| 499 |
flex: 1;
|
| 500 |
display: inline-flex;
|
| 501 |
align-items: center;
|
| 502 |
justify-content: center;
|
| 503 |
-
gap:
|
| 504 |
-
background-color: var(--
|
| 505 |
-
color: var(--
|
| 506 |
-
border:
|
| 507 |
-
border-radius: var(--radius-
|
| 508 |
-
|
| 509 |
-
font-
|
| 510 |
-
|
|
|
|
|
|
|
| 511 |
cursor: pointer;
|
| 512 |
-
|
| 513 |
-
min-height:
|
|
|
|
| 514 |
}
|
| 515 |
|
| 516 |
.btn-primary:hover:not(:disabled) {
|
| 517 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 518 |
}
|
| 519 |
|
| 520 |
.btn-primary:disabled {
|
| 521 |
-
opacity: 0.
|
| 522 |
cursor: not-allowed;
|
|
|
|
|
|
|
| 523 |
}
|
| 524 |
|
|
|
|
| 525 |
.btn-secondary {
|
| 526 |
display: inline-flex;
|
| 527 |
align-items: center;
|
| 528 |
justify-content: center;
|
| 529 |
-
gap:
|
| 530 |
-
background-color:
|
| 531 |
-
color: var(--text-
|
| 532 |
-
border:
|
| 533 |
-
border-radius: var(--radius-
|
| 534 |
-
|
| 535 |
-
font-
|
| 536 |
-
|
|
|
|
|
|
|
| 537 |
cursor: pointer;
|
| 538 |
-
|
| 539 |
-
min-height:
|
|
|
|
| 540 |
}
|
| 541 |
|
| 542 |
.btn-secondary:hover:not(:disabled) {
|
| 543 |
-
color: var(--
|
| 544 |
-
|
|
|
|
|
|
|
| 545 |
}
|
| 546 |
|
| 547 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 548 |
.spinner-icon {
|
| 549 |
-
width:
|
| 550 |
-
height:
|
| 551 |
-
border:
|
| 552 |
-
border-top-color:
|
| 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:
|
| 564 |
background-color: var(--bg-card);
|
| 565 |
-
border:
|
| 566 |
-
border-radius: var(--radius-
|
| 567 |
-
padding:
|
| 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:
|
| 576 |
flex-wrap: wrap;
|
| 577 |
-
gap:
|
|
|
|
|
|
|
| 578 |
}
|
| 579 |
|
| 580 |
.result-title-group {
|
| 581 |
display: flex;
|
| 582 |
align-items: center;
|
| 583 |
-
gap:
|
|
|
|
| 584 |
}
|
| 585 |
|
| 586 |
.result-title {
|
| 587 |
-
font-size:
|
| 588 |
-
font-weight:
|
| 589 |
color: var(--text-primary);
|
| 590 |
}
|
| 591 |
|
| 592 |
.result-meta-badge {
|
| 593 |
-
font-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
color: var(--
|
| 597 |
-
|
|
|
|
|
|
|
|
|
|
| 598 |
border-radius: var(--radius-xs);
|
| 599 |
}
|
| 600 |
|
| 601 |
.result-controls-bar {
|
| 602 |
display: flex;
|
| 603 |
align-items: center;
|
| 604 |
-
gap:
|
| 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-
|
| 613 |
-
|
| 614 |
-
border-radius: var(--radius-
|
| 615 |
-
|
|
|
|
| 616 |
}
|
| 617 |
|
| 618 |
.control-tab-btn {
|
| 619 |
background: transparent;
|
| 620 |
-
border:
|
| 621 |
-
font-
|
| 622 |
-
|
| 623 |
-
|
|
|
|
|
|
|
| 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(--
|
| 634 |
-
color: var(--
|
| 635 |
-
|
| 636 |
-
box-shadow: var(--shadow-
|
| 637 |
}
|
| 638 |
|
| 639 |
/* Preview background inspector toggle */
|
| 640 |
.bg-inspector-group {
|
| 641 |
display: flex;
|
| 642 |
align-items: center;
|
| 643 |
-
background-color: var(--bg-
|
| 644 |
-
|
| 645 |
-
border-radius: var(--radius-
|
| 646 |
-
|
|
|
|
| 647 |
}
|
| 648 |
|
| 649 |
.bg-inspector-btn {
|
| 650 |
background: transparent;
|
| 651 |
-
border:
|
| 652 |
-
font-
|
| 653 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 667 |
-
box-shadow: var(--shadow-
|
| 668 |
}
|
| 669 |
|
| 670 |
/* Canvas backgrounds */
|
| 671 |
.result-canvas {
|
| 672 |
-
border:
|
| 673 |
-
border-radius: var(--radius-
|
| 674 |
-
|
|
|
|
| 675 |
display: flex;
|
| 676 |
align-items: center;
|
| 677 |
justify-content: center;
|
| 678 |
-
min-height:
|
| 679 |
-
max-height:
|
| 680 |
overflow: hidden;
|
| 681 |
transition: background-color 0.2s ease;
|
| 682 |
}
|
| 683 |
|
| 684 |
.result-canvas.checkerboard {
|
| 685 |
background-image:
|
| 686 |
-
linear-gradient(45deg, #
|
| 687 |
-
linear-gradient(-45deg, #
|
| 688 |
-
linear-gradient(45deg, transparent 75%, #
|
| 689 |
-
linear-gradient(-45deg, transparent 75%, #
|
| 690 |
-
background-size:
|
| 691 |
-
background-position: 0 0, 0
|
| 692 |
-
background-color: #
|
| 693 |
}
|
| 694 |
|
| 695 |
-
[data-theme="
|
| 696 |
background-image:
|
| 697 |
-
linear-gradient(45deg, #
|
| 698 |
-
linear-gradient(-45deg, #
|
| 699 |
-
linear-gradient(45deg, transparent 75%, #
|
| 700 |
-
linear-gradient(-45deg, transparent 75%, #
|
| 701 |
-
background-size:
|
| 702 |
-
background-position: 0 0, 0
|
| 703 |
-
background-color: #
|
| 704 |
}
|
| 705 |
|
| 706 |
.result-canvas.white {
|
| 707 |
-
background-color: #
|
| 708 |
}
|
| 709 |
|
| 710 |
.result-canvas.dark {
|
| 711 |
-
background-color: #
|
| 712 |
}
|
| 713 |
|
| 714 |
.result-canvas.original-bg {
|
| 715 |
-
background-color: var(--bg-
|
| 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:
|
| 726 |
display: flex;
|
| 727 |
-
gap:
|
| 728 |
}
|
| 729 |
|
| 730 |
/* Reprocess panel in result card */
|
| 731 |
.reprocess-panel {
|
| 732 |
-
margin-top:
|
| 733 |
-
padding:
|
| 734 |
-
background-color: var(--bg-
|
| 735 |
-
border:
|
| 736 |
-
border-radius: var(--radius-
|
|
|
|
| 737 |
}
|
| 738 |
|
| 739 |
.reprocess-header {
|
| 740 |
display: flex;
|
| 741 |
flex-direction: column;
|
| 742 |
gap: 4px;
|
| 743 |
-
margin-bottom:
|
| 744 |
}
|
| 745 |
|
| 746 |
.reprocess-title {
|
| 747 |
-
font-size:
|
| 748 |
-
font-weight:
|
| 749 |
color: var(--text-primary);
|
| 750 |
}
|
| 751 |
|
| 752 |
.reprocess-desc {
|
| 753 |
-
font-size:
|
| 754 |
-
color: var(--text-
|
| 755 |
}
|
| 756 |
|
| 757 |
.reprocess-controls {
|
| 758 |
display: flex;
|
| 759 |
align-items: center;
|
| 760 |
-
gap:
|
| 761 |
flex-wrap: wrap;
|
| 762 |
}
|
| 763 |
|
| 764 |
.reprocess-field {
|
| 765 |
flex: 1;
|
| 766 |
-
min-width:
|
| 767 |
}
|
| 768 |
|
| 769 |
.reprocess-tip {
|
| 770 |
-
margin-top:
|
| 771 |
-
font-
|
|
|
|
|
|
|
| 772 |
color: var(--text-muted);
|
| 773 |
}
|
| 774 |
|
| 775 |
-
/* =====
|
| 776 |
.info-section {
|
| 777 |
-
padding:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 778 |
}
|
| 779 |
|
| 780 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 781 |
display: grid;
|
| 782 |
-
grid-template-columns:
|
| 783 |
-
gap:
|
|
|
|
| 784 |
}
|
| 785 |
|
| 786 |
.info-card {
|
| 787 |
-
background-color: var(--bg-
|
| 788 |
-
border:
|
| 789 |
-
border-radius: var(--radius-
|
| 790 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 791 |
}
|
| 792 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 793 |
.info-card h2 {
|
| 794 |
-
font-size:
|
| 795 |
-
|
| 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:
|
| 806 |
}
|
| 807 |
|
| 808 |
-
.model-
|
| 809 |
-
font-size:
|
| 810 |
color: var(--text-secondary);
|
| 811 |
line-height: 1.5;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 812 |
}
|
| 813 |
|
| 814 |
-
.model-
|
| 815 |
color: var(--text-primary);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 816 |
}
|
| 817 |
|
| 818 |
/* ===== FOOTER ===== */
|
| 819 |
.footer {
|
| 820 |
margin-top: auto;
|
| 821 |
-
padding:
|
| 822 |
-
|
|
|
|
| 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:
|
| 831 |
}
|
| 832 |
|
| 833 |
.footer-text {
|
| 834 |
-
font-
|
| 835 |
-
|
|
|
|
|
|
|
| 836 |
}
|
| 837 |
|
| 838 |
.footer-links {
|
| 839 |
display: flex;
|
| 840 |
gap: 16px;
|
|
|
|
| 841 |
}
|
| 842 |
|
| 843 |
.footer-link {
|
| 844 |
-
font-
|
| 845 |
-
|
|
|
|
|
|
|
| 846 |
text-decoration: none;
|
| 847 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 848 |
}
|
| 849 |
|
| 850 |
.footer-link:hover {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 851 |
color: var(--text-primary);
|
|
|
|
| 852 |
}
|
| 853 |
|
| 854 |
-
|
| 855 |
-
|
| 856 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 857 |
grid-template-columns: 1fr;
|
|
|
|
| 858 |
}
|
| 859 |
|
| 860 |
-
.
|
| 861 |
-
|
| 862 |
}
|
| 863 |
|
| 864 |
.main-card {
|
| 865 |
-
padding:
|
| 866 |
}
|
| 867 |
|
| 868 |
.result-section {
|
| 869 |
-
padding:
|
| 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: '#
|
| 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.
|
| 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="
|
| 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 |
-
<
|
|
|
|
|
|
|
|
|
|
| 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 |
-
<
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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="
|
| 632 |
-
<
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
<
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 & 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 & 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 & 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 & Keamanan</span>
|
| 1077 |
+
<h2>Format & 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 |
+
})
|