MEDIABROWSER 배포 사이트 + 공개 릴리스 저장소 전환 (DEPLOYMENT_PLAN §5-ter, BACKLOG 12차) — site/: 프로모션(라이트 글래스모피즘, CSS scroll-driven 패럴랙스+IO 등장, 모션·투명도 축소 존중, no-JS 백지 방지 html.js 게이트)·릴리스(플랫폼 그룹, OS 자동 감지 권장, 이전 버전 접힘, 빈 상태)·사용 안내(설치→첫 다운로드→관리→휴대폰 연결→FAQ) 3페이지. 히어로/가이드 이미지는 VD_DATA_DIR 격리 프로필로 띄운 실제 앱 다운로드 패널 캡처(밝기 보정). 릴리스 데이터는 CORS 회피를 위해 배포 시 fetch-releases.mjs 가 releases.json 으로 정적 생성(git 무시). 공개 저장소 Video-Downloader/releases 신설(비공개 소스 저장소는 자산 익명 다운로드 불가) — CI 업로드를 env RELEASE_REPO 로 전환, draft 익명 비노출이라 QA 후 Publish 흐름 유지. build/deploy-site.ps1(wrangler pages deploy) 추가, 라이브: https://mediabrowser-7r4.pages.dev — Playwright 로 3페이지 전수 스크린샷 검증(빈 상태+샘플 데이터 렌더 포함)

This commit is contained in:
Yun Chan 2026-08-18 00:14:39 +09:00
parent b536622606
commit c8b061959b
15 changed files with 923 additions and 2 deletions

View file

@ -14,6 +14,9 @@ on:
env: env:
DOTNET_NOLOGO: "true" DOTNET_NOLOGO: "true"
DOTNET_CLI_TELEMETRY_OPTOUT: "true" DOTNET_CLI_TELEMETRY_OPTOUT: "true"
# 릴리스 자산은 공개 배포 저장소로 업로드한다(소스 저장소는 비공개라 익명 다운로드 불가).
# draft 상태는 익명에게 보이지 않으므로 QA 후 수동 Publish 흐름은 그대로다.
RELEASE_REPO: "Video-Downloader/releases"
jobs: jobs:
# ── 관문: 전 플랫폼 빌드 전에 단위+E2E 통과 ── # ── 관문: 전 플랫폼 빌드 전에 단위+E2E 통과 ──

5
.gitignore vendored
View file

@ -86,3 +86,8 @@ CLAUDE.local.md
._* ._*
Thumbs.db Thumbs.db
desktop.ini desktop.ini
# ── 배포 사이트 산출물 (deploy-site 가 배포 시점에 생성) ─────────────
site/releases.json
.playwright-mcp/
.wrangler/

View file

@ -1,6 +1,7 @@
# Forgejo 릴리스(draft)에 산출물을 업로드한다. 릴리스가 없으면 만들고, 동명 자산은 교체한다(재실행 멱등). # Forgejo 릴리스(draft)에 산출물을 업로드한다. 릴리스가 없으면 만들고, 동명 자산은 교체한다(재실행 멱등).
# 사용: ./build/ci/upload-release-asset.ps1 -Tag v1.0.0 -Files out/a.exe, out/b.exe # 사용: ./build/ci/upload-release-asset.ps1 -Tag v1.0.0 -Files out/a.exe, out/b.exe
# 필요 환경: RELEASE_TOKEN + (Actions 기본 제공) GITHUB_SERVER_URL, GITHUB_REPOSITORY # 필요 환경: RELEASE_TOKEN + (Actions 기본 제공) GITHUB_SERVER_URL, GITHUB_REPOSITORY
# RELEASE_REPO 를 주면 해당 저장소로 업로드(공개 배포 저장소용), 없으면 현재 저장소
# RELEASE_PRERELEASE=true 면 prerelease 로 생성(리허설용) # RELEASE_PRERELEASE=true 면 prerelease 로 생성(리허설용)
param( param(
[Parameter(Mandatory)][string]$Tag, [Parameter(Mandatory)][string]$Tag,
@ -8,7 +9,8 @@ param(
) )
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
if (-not $env:RELEASE_TOKEN) { throw "RELEASE_TOKEN 환경변수가 없습니다" } if (-not $env:RELEASE_TOKEN) { throw "RELEASE_TOKEN 환경변수가 없습니다" }
$api = "$($env:GITHUB_SERVER_URL.TrimEnd('/'))/api/v1/repos/$($env:GITHUB_REPOSITORY)" $repo = if ($env:RELEASE_REPO) { $env:RELEASE_REPO } else { $env:GITHUB_REPOSITORY }
$api = "$($env:GITHUB_SERVER_URL.TrimEnd('/'))/api/v1/repos/$repo"
$hdr = @{ Authorization = "token $env:RELEASE_TOKEN" } $hdr = @{ Authorization = "token $env:RELEASE_TOKEN" }
# draft 릴리스는 tags/<tag> 단건 조회에 안 잡힐 수 있어 목록에서 tag_name 으로 찾는다 # draft 릴리스는 tags/<tag> 단건 조회에 안 잡힐 수 있어 목록에서 tag_name 으로 찾는다

View file

@ -2,12 +2,13 @@
# Forgejo 릴리스(draft)에 산출물을 업로드한다. 릴리스가 없으면 만들고, 동명 자산은 교체한다(재실행 멱등). # Forgejo 릴리스(draft)에 산출물을 업로드한다. 릴리스가 없으면 만들고, 동명 자산은 교체한다(재실행 멱등).
# 사용: build/ci/upload-release-asset.sh <릴리스태그> <파일...> # 사용: build/ci/upload-release-asset.sh <릴리스태그> <파일...>
# 필요 환경: RELEASE_TOKEN + (Actions 기본 제공) GITHUB_SERVER_URL, GITHUB_REPOSITORY # 필요 환경: RELEASE_TOKEN + (Actions 기본 제공) GITHUB_SERVER_URL, GITHUB_REPOSITORY
# RELEASE_REPO 를 주면 해당 저장소로 업로드(공개 배포 저장소용), 없으면 현재 저장소
# RELEASE_PRERELEASE=true 면 prerelease 로 생성(리허설용) # RELEASE_PRERELEASE=true 면 prerelease 로 생성(리허설용)
set -euo pipefail set -euo pipefail
[ $# -ge 2 ] || { echo "사용법: $0 <태그> <파일...>" >&2; exit 2; } [ $# -ge 2 ] || { echo "사용법: $0 <태그> <파일...>" >&2; exit 2; }
[ -n "${RELEASE_TOKEN:-}" ] || { echo "RELEASE_TOKEN 환경변수가 없습니다" >&2; exit 2; } [ -n "${RELEASE_TOKEN:-}" ] || { echo "RELEASE_TOKEN 환경변수가 없습니다" >&2; exit 2; }
tag="$1"; shift tag="$1"; shift
api="${GITHUB_SERVER_URL%/}/api/v1/repos/${GITHUB_REPOSITORY}" api="${GITHUB_SERVER_URL%/}/api/v1/repos/${RELEASE_REPO:-$GITHUB_REPOSITORY}"
auth="Authorization: token ${RELEASE_TOKEN}" auth="Authorization: token ${RELEASE_TOKEN}"
# draft 릴리스는 tags/<tag> 단건 조회에 안 잡힐 수 있어 목록에서 tag_name 으로 찾는다 # draft 릴리스는 tags/<tag> 단건 조회에 안 잡힐 수 있어 목록에서 tag_name 으로 찾는다

29
build/deploy-site.ps1 Normal file
View file

@ -0,0 +1,29 @@
# MEDIABROWSER 사이트(site/)를 Cloudflare Pages 에 배포한다.
# 사용: ./build/deploy-site.ps1 [-Project mediabrowser]
# 필요: .env 의 CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID · node/npx(wrangler)
# 절차: 공개 릴리스 저장소에서 releases.json 생성 → wrangler pages deploy
param([string]$Project = "mediabrowser")
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent $PSScriptRoot
# .env 로드 (값은 출력하지 않는다)
$envFile = Join-Path $root ".env"
if (-not (Test-Path $envFile)) { throw ".env 가 없습니다. .env.example 을 복사해 채우세요." }
foreach ($line in Get-Content $envFile) {
if ($line -match '^\s*(CLOUDFLARE_API_TOKEN|CLOUDFLARE_ACCOUNT_ID)\s*=\s*(.+)$') {
Set-Item "env:$($Matches[1])" $Matches[2].Trim()
}
}
if (-not $env:CLOUDFLARE_API_TOKEN) { throw ".env 에 CLOUDFLARE_API_TOKEN 이 없습니다." }
if (-not $env:CLOUDFLARE_ACCOUNT_ID) { throw ".env 에 CLOUDFLARE_ACCOUNT_ID 가 없습니다." }
Write-Host "1) releases.json 생성" -ForegroundColor Cyan
node (Join-Path $root "site\fetch-releases.mjs")
if ($LASTEXITCODE -ne 0) { throw "releases.json 생성 실패" }
Write-Host "2) Cloudflare Pages 배포 ($Project)" -ForegroundColor Cyan
npx wrangler pages deploy (Join-Path $root "site") --project-name $Project --branch main --commit-dirty=true
if ($LASTEXITCODE -ne 0) { throw "wrangler 배포 실패" }
Write-Host "완료: https://$Project.pages.dev" -ForegroundColor Green

View file

@ -259,3 +259,25 @@
- 리허설 1차(b05e8bf): test **453/453 통과**(실창 E2E 포함 — 러너가 사용자 세션이라 창 생성 가능) · windows/linux/macos 성공 · android 실패(MSB3030). - 리허설 1차(b05e8bf): test **453/453 통과**(실창 E2E 포함 — 러너가 사용자 세션이라 창 생성 가능) · windows/linux/macos 성공 · android 실패(MSB3030).
- 리허설 2차(d98c734, 수정 후): **5/5 잡 전부 성공** — `ci-rehearsal` draft prerelease 자산 7개: Setup x64 57.2MB·arm64 51.5MB / linux tar.gz x64 71.1MB·arm64 68.1MB / osx tar.gz arm64 69.6MB·x64 72.7MB / APK(arm64-v8a 단독) 23.3MB. - 리허설 2차(d98c734, 수정 후): **5/5 잡 전부 성공** — `ci-rehearsal` draft prerelease 자산 7개: Setup x64 57.2MB·arm64 51.5MB / linux tar.gz x64 71.1MB·arm64 68.1MB / osx tar.gz arm64 69.6MB·x64 72.7MB / APK(arm64-v8a 단독) 23.3MB.
- 정식 배포: `git tag v1.0.0 && git push origin v1.0.0` → 동일 파이프라인이 `v1.0.0` draft 릴리스 생성 → QA 후 수동 Publish (§7.5 런북 그대로). - 정식 배포: `git tag v1.0.0 && git push origin v1.0.0` → 동일 파이프라인이 `v1.0.0` draft 릴리스 생성 → QA 후 수동 Publish (§7.5 런북 그대로).
## 12차 (2026-08-18) — MEDIABROWSER 배포 사이트 + 공개 릴리스 저장소
> 사용자: "릴리즈 페이지 만들자 — 프로모션(패럴랙스·글래스모피즘·라이트) + 릴리스 + 사용 안내,
> wrangler 로 Cloudflare 배포, 파일은 NAS 호스팅, 배포명은 MEDIABROWSER". DEPLOYMENT_PLAN §5-ter 참조.
- [x] **`site/` 3페이지** — index(프로모션: 히어로 스플릿+벤토+기능 그리드+모바일+플랫폼 밴드,
CSS scroll-driven 패럴랙스 + IntersectionObserver 등장, `prefers-reduced-motion/transparency` 존중,
JS 실패 시에도 내용이 보이는 `html.js` 게이트) · releases(플랫폼 그룹·OS 자동 감지 권장·이전 버전 접힘·빈 상태)
· guide(설치→첫 다운로드→관리→휴대폰 연결→FAQ). Wanted Sans Variable + Phosphor 스프라이트.
- [x] **실앱 자산** — 히어로/가이드의 다운로드 패널 이미지는 `VD_DATA_DIR` 격리 프로필로 띄운 실제 앱
창 캡처(밝기 보정). 가짜 목업 없음.
- [x] **공개 릴리스 저장소** — 소스(client)가 비공개라 자산 익명 다운로드 불가 → `Video-Downloader/releases`
(공개) 신설, CI 업로드를 env `RELEASE_REPO` 로 전환. draft 는 익명에 안 보여 QA 후 Publish 흐름 유지.
생성 직후 저장소 설명이 인코딩 깨짐(EFBFBD)으로 올라간 문제(사용자 보고)는 UTF-8 파일 기반 PATCH 로 교정.
- [x] **배포** — `build/deploy-site.ps1`(releases.json 생성 → wrangler pages deploy).
라이브: https://mediabrowser-7r4.pages.dev (mediabrowser 서브도메인 선점 → 접미).
### 검증 (12차)
- Playwright 로 3페이지 전체 스크린샷 검증(1440px): 히어로/벤토/기능/모바일/밴드 렌더, 릴리스 빈 상태,
샘플 데이터 주입 렌더(플랫폼 4그룹·권장 강조·프리뷰 필), 가이드 목차·FAQ. 라이브 URL 에서 재확인.
- 도중 결함 2건 수정: fullPage 캡처로 드러난 reveal 무한 숨김 위험(no-JS 백지) → `html.js` 게이트,
패널 캡처의 사용자 세션 노출 위험 → 격리 프로필 재캡처로 교체.

View file

@ -174,10 +174,32 @@ jobs:
- **리허설**: workflow_dispatch → `ci-rehearsal` draft prerelease 로 태그 없이 전 파이프라인 검증. - **리허설**: workflow_dispatch → `ci-rehearsal` draft prerelease 로 태그 없이 전 파이프라인 검증.
- **시크릿**: `RELEASE_TOKEN`(Forgejo PAT, `write:repository`) — 릴리스 생성·자산 업로드. 체크아웃은 잡 - **시크릿**: `RELEASE_TOKEN`(Forgejo PAT, `write:repository`) — 릴리스 생성·자산 업로드. 체크아웃은 잡
자동 토큰(`github.token`). `ANDROID_KEYSTORE/ALIAS/PASS` 등록 전까지 APK 는 디버그 서명. 자동 토큰(`github.token`). `ANDROID_KEYSTORE/ALIAS/PASS` 등록 전까지 APK 는 디버그 서명.
- **업로드 대상**: 공개 배포 저장소 **`Video-Downloader/releases`**(워크플로 env `RELEASE_REPO`).
소스 저장소(client)는 비공개라 자산 익명 다운로드가 불가해 분리했다. draft 는 익명에게 보이지
않으므로 "빌드 → draft → QA → 수동 Publish" 흐름은 그대로다. Publish 하면 §5-ter 사이트가 노출한다.
- **머신 전제**: win = SDK 8/10 + `maui-android` 워크로드 + JDK 17 + Inno Setup 6 · - **머신 전제**: win = SDK 8/10 + `maui-android` 워크로드 + JDK 17 + Inno Setup 6 ·
linux/mac = `~/.dotnet` SDK 8.0.424 + 10.0.400 (§0.5). linux/mac = `~/.dotnet` SDK 8.0.424 + 10.0.400 (§0.5).
- **갭**: §6 자동 업데이트 v1(`UpdateChecker`)은 GitHub Releases API 를 폴링하므로 GitHub 공개 전까지 - **갭**: §6 자동 업데이트 v1(`UpdateChecker`)은 GitHub Releases API 를 폴링하므로 GitHub 공개 전까지
미동작 — Forgejo Releases API(`/repos/{owner}/{repo}/releases/latest`, 동일 형태) 대응은 후속 항목. 미동작 — Forgejo Releases API(`/repos/{owner}/{repo}/releases/latest`, 동일 형태) 대응은 후속 항목.
대상은 공개 저장소 `Video-Downloader/releases` 가 되어야 한다.
## 5-ter. 배포 사이트 — MEDIABROWSER (Cloudflare Pages, 2026-08-18 가동)
제품 배포명은 **MEDIABROWSER** 로 하고, 프로모션/다운로드/사용 안내 3페이지 정적 사이트를
Cloudflare Pages 에 올렸다. **https://mediabrowser-7r4.pages.dev** (mediabrowser 서브도메인 선점으로
접미 부여 — 커스텀 도메인 연결은 후속).
- **소스**: `site/` — `index.html`(프로모션: 라이트 글래스모피즘 + CSS scroll-driven 패럴랙스,
모션 축소·투명도 축소 미디어쿼리 존중) · `releases.html`(플랫폼별 다운로드) · `guide.html`(사용 안내).
히어로/가이드의 다운로드 패널 이미지는 격리 프로필(`VD_DATA_DIR`)로 띄운 실제 앱 캡처다.
- **릴리스 데이터**: 브라우저에서 Forgejo API 를 직접 부르면 CORS 에 막히므로, 배포 시점에
`site/fetch-releases.mjs` 가 공개 저장소 릴리스를 `site/releases.json`(git 무시)으로 굽는다.
published 릴리스만 포함(draft 제외), 최신판 강조 + 이전 버전 접힘 + OS 자동 감지 권장 표시.
- **배포**: `./build/deploy-site.ps1` — .env 의 `CLOUDFLARE_API_TOKEN/ACCOUNT_ID` 로
`wrangler pages deploy site --project-name mediabrowser`. **릴리스를 Publish 한 뒤 이 스크립트를
다시 실행해야 사이트 목록이 갱신된다**(후속: 릴리스 CI 에 사이트 재배포 잡 추가).
- **후속**: 커스텀 도메인 · 설치자/실행 파일의 MEDIABROWSER 개명(제품명 통일) · Obtainium `app.json`
의 URL 을 공개 저장소로 갱신.
## 6. 업데이트 체계 ## 6. 업데이트 체계

1
site/assets/icons.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

35
site/fetch-releases.mjs Normal file
View file

@ -0,0 +1,35 @@
// 배포 전에 공개 릴리스 저장소의 릴리스 목록을 releases.json 으로 굽는다.
// (브라우저에서 Forgejo API 를 직접 호출하면 CORS 에 막히므로 빌드 시점에 정적 생성)
// 사용: node site/fetch-releases.mjs
import { writeFileSync } from "node:fs";
const API = "https://git.chanpaca.net/api/v1/repos/Video-Downloader/releases/releases?limit=50";
const res = await fetch(API, { headers: { Accept: "application/json" } });
if (!res.ok) throw new Error(`릴리스 API 실패: HTTP ${res.status}`);
const raw = await res.json();
const releases = raw
.filter((r) => !r.draft)
.sort((a, b) => new Date(b.published_at) - new Date(a.published_at))
.map((r) => ({
tag: r.tag_name,
name: r.name || r.tag_name,
published_at: r.published_at,
prerelease: r.prerelease,
notes: r.body || "",
assets: (r.assets || []).map((a) => ({
name: a.name,
size: a.size,
url: a.browser_download_url,
})),
}));
const out = {
generated_at: new Date().toISOString(),
repo_url: "https://git.chanpaca.net/Video-Downloader/releases",
releases,
};
writeFileSync(new URL("./releases.json", import.meta.url), JSON.stringify(out, null, 2));
console.log(`releases.json 생성: 릴리스 ${releases.length}건`);

149
site/guide.html Normal file
View file

@ -0,0 +1,149 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>사용 안내 - MEDIABROWSER</title>
<meta name="description" content="MEDIABROWSER 설치부터 첫 다운로드, 다운로드 관리, 휴대폰 연결까지의 사용 안내.">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='24' fill='%232050f0'/><path d='M50 22v36M36 44l14 14 14-14M28 74h44' stroke='white' stroke-width='9' stroke-linecap='round' stroke-linejoin='round' fill='none'/></svg>">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/wanteddev/wanted-sans@v1.0.3/packages/wanted-sans/fonts/webfonts/variable/split/WantedSansVariable.min.css">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<nav class="nav glass" aria-label="주 메뉴">
<a class="nav__brand" href="index.html">
<span class="nav__mark"><svg class="icon" aria-hidden="true"><use href="assets/icons.svg#download-simple"/></svg></span>
MEDIABROWSER
</a>
<div class="nav__links">
<a href="index.html#features">기능</a>
<a href="index.html#mobile">모바일</a>
<a href="guide.html" aria-current="page">사용 안내</a>
</div>
<a class="btn btn--primary btn--sm" href="releases.html">다운로드</a>
</nav>
<header class="pagehead">
<div class="orbs" aria-hidden="true">
<div class="orb orb--a"></div>
<div class="orb orb--b"></div>
</div>
<div class="wrap">
<h1>사용 안내</h1>
<p class="sub">설치부터 첫 다운로드, 휴대폰 연결까지. 처음이라면 위에서부터 순서대로 따라오세요.</p>
</div>
</header>
<div class="wrap guide-layout">
<aside class="guide-toc glass" aria-label="목차">
<a href="#install">1. 설치</a>
<a href="#first">2. 첫 다운로드</a>
<a href="#manage">3. 다운로드 관리</a>
<a href="#phone">4. 휴대폰 연결</a>
<a href="#faq">자주 묻는 질문</a>
</aside>
<main>
<section class="guide-step" id="install">
<h2><span class="featlist__icon"><svg class="icon" aria-hidden="true"><use href="assets/icons.svg#rocket-launch"/></svg></span>설치</h2>
<p><a href="releases.html" style="color:var(--accent);font-weight:700;text-decoration:none">다운로드 페이지</a>에서 플랫폼에 맞는 빌드를 받으세요.</p>
<ul>
<li><svg class="icon" aria-hidden="true"><use href="assets/icons.svg#windows-logo"/></svg>
<span><b>Windows</b>: 설치자(exe)를 실행하고 안내를 따르면 끝입니다. x64 와 arm64 를 제공합니다.</span></li>
<li><svg class="icon" aria-hidden="true"><use href="assets/icons.svg#apple-logo"/></svg>
<span><b>macOS 프리뷰</b>: tar.gz 를 풀고 앱을 실행합니다. 아직 서명이 없어 처음 실행 시 보안 확인이 필요할 수 있습니다.</span></li>
<li><svg class="icon" aria-hidden="true"><use href="assets/icons.svg#linux-logo"/></svg>
<span><b>Linux 프리뷰</b>: tar.gz 를 풀고 실행 파일을 실행합니다.</span></li>
<li><svg class="icon" aria-hidden="true"><use href="assets/icons.svg#android-logo"/></svg>
<span><b>Android</b>: APK 를 설치합니다. 데스크톱 앱과 페어링해 쓰는 컴패니언입니다.</span></li>
</ul>
<p>다운로드에 필요한 도구(yt-dlp, ffmpeg, deno)는 앱이 필요할 때 자동으로 설치합니다. 따로 준비할 것은 없습니다.</p>
</section>
<section class="guide-step" id="first">
<h2><span class="featlist__icon"><svg class="icon" aria-hidden="true"><use href="assets/icons.svg#cursor-click"/></svg></span>첫 다운로드</h2>
<p>MEDIABROWSER 는 브라우저입니다. 평소처럼 사이트에 들어가 영상을 재생하면 나머지는 앱이 합니다.</p>
<ul>
<li><svg class="icon" aria-hidden="true"><use href="assets/icons.svg#globe-hemisphere-west"/></svg>
<span>주소창에 사이트를 입력해 이동하고, 원하는 영상을 <b>재생</b>합니다.</span></li>
<li><svg class="icon" aria-hidden="true"><use href="assets/icons.svg#waveform"/></svg>
<span>재생이 시작되면 스트림(HLS, DASH, MP4)이 감지되어 오른쪽 <b>다운로드 패널</b>에 나타납니다.</span></li>
<li><svg class="icon" aria-hidden="true"><use href="assets/icons.svg#download-simple"/></svg>
<span>목록에서 <b>다운로드</b>를 누르면 로그인 상태 그대로 내려받기가 시작됩니다.</span></li>
</ul>
<figure class="guide-media glass">
<img src="assets/panel-downloads.png" alt="다운로드 패널의 빈 상태 화면. 영상을 재생하면 여기에 나타납니다 라는 안내 문구가 표시되어 있다." width="426" height="330">
<figcaption>감지 전의 다운로드 패널. 재생을 시작하면 이 목록이 채워집니다.</figcaption>
</figure>
</section>
<section class="guide-step" id="manage">
<h2><span class="featlist__icon"><svg class="icon" aria-hidden="true"><use href="assets/icons.svg#stack"/></svg></span>다운로드 관리</h2>
<p>패널의 탭으로 전체, 진행, 완료, 실패를 나눠 보고, 항목마다 속도와 남은 시간, 세그먼트 진행을 확인합니다.</p>
<ul>
<li><svg class="icon" aria-hidden="true"><use href="assets/icons.svg#check-circle"/></svg>
<span><b>모두 일시정지 / 모두 재개</b>로 한 번에 멈추고 다시 시작합니다. 항목별 일시정지도 됩니다.</span></li>
<li><svg class="icon" aria-hidden="true"><use href="assets/icons.svg#check-circle"/></svg>
<span><b>예약</b>을 걸면 지정한 시간부터 내려받습니다. 이미 받은 영상은 중복 확인으로 알려줍니다.</span></li>
<li><svg class="icon" aria-hidden="true"><use href="assets/icons.svg#folders"/></svg>
<span>받은 영상은 <b>라이브러리</b>에서 모아 보고, 재생목록과 즐겨찾기, 아카이브로 정리합니다.</span></li>
<li><svg class="icon" aria-hidden="true"><use href="assets/icons.svg#check-circle"/></svg>
<span>목록은 <b>CSV</b> 로 내보내 기록으로 남길 수 있습니다.</span></li>
</ul>
</section>
<section class="guide-step" id="phone">
<h2><span class="featlist__icon"><svg class="icon" aria-hidden="true"><use href="assets/icons.svg#device-mobile"/></svg></span>휴대폰 연결</h2>
<p>데스크톱 앱에 내장된 서버가 휴대폰과 라이브러리를 이어줍니다. 같은 네트워크에서 페어링 한 번이면 됩니다.</p>
<ul>
<li><svg class="icon" aria-hidden="true"><use href="assets/icons.svg#qr-code"/></svg>
<span>휴대폰에서 컴패니언 앱을 열고, 데스크톱 앱이 보여주는 <b>QR 코드</b>를 스캔합니다.</span></li>
<li><svg class="icon" aria-hidden="true"><use href="assets/icons.svg#play-circle"/></svg>
<span>페어링이 끝나면 라이브러리의 영상을 휴대폰에서 <b>바로 재생</b>하거나 <b>파일로 전송</b>받습니다.</span></li>
<li><svg class="icon" aria-hidden="true"><use href="assets/icons.svg#shield-check"/></svg>
<span>연결은 내 네트워크 안에서만 이루어집니다. 외부 서버를 거치지 않습니다.</span></li>
</ul>
</section>
<section class="guide-step" id="faq">
<h2><span class="featlist__icon"><svg class="icon" aria-hidden="true"><use href="assets/icons.svg#gear"/></svg></span>자주 묻는 질문</h2>
<div class="guide-faq">
<details>
<summary>ffmpeg 나 yt-dlp 를 따로 설치해야 하나요?</summary>
<p>아니요. 필요한 시점에 앱이 자동으로 내려받아 관리합니다. 설정에서 직접 지정한 경로가 있으면 그걸 우선합니다.</p>
</details>
<details>
<summary>받은 파일은 어디에 저장되나요?</summary>
<p>기본 저장 폴더에 저장되며, 설정에서 폴더를 바꿀 수 있습니다. 완료 목록에서 파일 위치를 바로 열 수 있습니다.</p>
</details>
<details>
<summary>다운로드가 중간에 끊기면 처음부터 다시 받나요?</summary>
<p>아니요. 세그먼트 단위로 이어받기를 지원해서 받다 만 지점부터 다시 진행합니다.</p>
</details>
<details>
<summary>업데이트는 어떻게 하나요?</summary>
<p>설정의 앱 업데이트 항목에서 새 버전을 확인할 수 있고, 다운로드 페이지에서 새 설치자를 받아 덮어 설치하면 됩니다.</p>
</details>
<details>
<summary>어떤 사이트를 지원하나요?</summary>
<p>표준 스트리밍 형식(HLS, DASH, MP4)을 쓰는 사이트에서 동작합니다. 콘텐츠의 이용 약관과 저작권을 지키는 범위에서 사용해 주세요.</p>
</details>
</div>
</section>
</main>
</div>
<footer>
<div class="wrap footer__row">
<span class="footer__brand">MEDIABROWSER</span>
<a href="releases.html">다운로드</a>
<a href="guide.html">사용 안내</a>
<a href="https://git.chanpaca.net/Video-Downloader/releases" rel="noopener">릴리스 저장소</a>
<span class="footer__copy">© 2026 MEDIABROWSER</span>
</div>
</footer>
<script src="site.js" defer></script>
</body>
</html>

156
site/index.html Normal file
View file

@ -0,0 +1,156 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MEDIABROWSER - 보던 영상, 그 자리에서 내 것으로</title>
<meta name="description" content="MEDIABROWSER 는 재생 중인 미디어를 감지해 내려받는 데스크톱 브라우저입니다. 받은 영상은 라이브러리로 관리하고 휴대폰으로 이어봅니다.">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='24' fill='%232050f0'/><path d='M50 22v36M36 44l14 14 14-14M28 74h44' stroke='white' stroke-width='9' stroke-linecap='round' stroke-linejoin='round' fill='none'/></svg>">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/wanteddev/wanted-sans@v1.0.3/packages/wanted-sans/fonts/webfonts/variable/split/WantedSansVariable.min.css">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<nav class="nav glass" aria-label="주 메뉴">
<a class="nav__brand" href="index.html" aria-current="page">
<span class="nav__mark"><svg class="icon" aria-hidden="true"><use href="assets/icons.svg#download-simple"/></svg></span>
MEDIABROWSER
</a>
<div class="nav__links">
<a href="#features">기능</a>
<a href="#mobile">모바일</a>
<a href="guide.html">사용 안내</a>
</div>
<a class="btn btn--primary btn--sm" href="releases.html">다운로드</a>
</nav>
<header class="hero">
<div class="orbs" aria-hidden="true">
<div class="orb orb--a"></div>
<div class="orb orb--b"></div>
<div class="orb orb--c"></div>
</div>
<div class="wrap hero__grid">
<div>
<h1>보던 영상,<br>그 자리에서 내 것으로.</h1>
<p class="sub">MEDIABROWSER 는 재생 중인 미디어를 감지해 내려받는 브라우저입니다.
받은 영상은 라이브러리로 관리하고, 휴대폰으로 이어봅니다.</p>
<div class="hero__cta">
<a class="btn btn--primary" href="releases.html">다운로드</a>
<a class="btn btn--ghost" href="guide.html">사용 안내</a>
</div>
</div>
<div class="hero__visual reveal">
<figure class="hero__panel glass">
<img src="assets/panel-downloads.png" alt="MEDIABROWSER 다운로드 패널. 전체·진행·완료·실패 탭과 모두 일시정지, 모두 재개, CSV 버튼이 있고, 영상을 재생하면 여기에 나타납니다 라는 안내가 보인다." width="426" height="330">
<figcaption class="hero__caption">앱의 다운로드 패널. 재생을 감지하면 이 목록에 자동으로 쌓입니다.</figcaption>
</figure>
</div>
</div>
</header>
<section id="features" class="section-pad">
<div class="wrap">
<h2 class="reveal" style="font-size:clamp(28px,3.6vw,40px)">주소를 붙여넣는 게 아니라,<br>그냥 봅니다.</h2>
<div class="bento">
<div class="bento__cell bento__cell--wide reveal">
<div class="bento__wave"><svg class="icon" aria-hidden="true"><use href="assets/icons.svg#waveform"/></svg></div>
<div>
<div class="bento__chipline" aria-label="지원 형식">
<span class="chip">HLS</span><span class="chip">DASH</span><span class="chip">MP4</span>
</div>
<h3>재생하면 감지됩니다</h3>
<p>내장 브라우저에서 영상이 재생되는 순간 스트림을 인식해 다운로드 목록에 올립니다.
별도 확장이나 주소 복사가 필요 없습니다.</p>
</div>
</div>
<div class="bento__cell glass reveal">
<h3>로그인 그대로</h3>
<p>탭의 쿠키와 Referer 를 그대로 물려받아 내려받습니다. 브라우저에서 보이던 영상은 그대로 받아집니다.</p>
</div>
<div class="bento__cell glass reveal">
<h3>끊겨도 이어서</h3>
<p>병렬 분할 다운로드와 이어받기, 그리고 ffmpeg 리먹스까지 자동으로 처리합니다. AES-128 스트림도 지원합니다.</p>
</div>
</div>
</div>
</section>
<section class="section-pad" style="background:linear-gradient(180deg,transparent,var(--bg-deep) 30%,var(--bg-deep) 70%,transparent)">
<div class="wrap">
<h2 class="reveal" style="font-size:clamp(28px,3.6vw,40px)">다운로드는 관리가 절반입니다.</h2>
<div class="featlist">
<div class="featlist__item reveal">
<div class="featlist__icon"><svg class="icon" aria-hidden="true"><use href="assets/icons.svg#stack"/></svg></div>
<div><h3>속도와 세그먼트가 보입니다</h3><p>항목마다 속도, 남은 시간, 세그먼트 맵을 실시간으로 표시합니다.</p></div>
</div>
<div class="featlist__item reveal">
<div class="featlist__icon"><svg class="icon" aria-hidden="true"><use href="assets/icons.svg#play-circle"/></svg></div>
<div><h3>전체 일시정지와 재개</h3><p>한 번에 멈추고 한 번에 다시 시작합니다. 예약 시간을 걸어둘 수도 있습니다.</p></div>
</div>
<div class="featlist__item reveal">
<div class="featlist__icon"><svg class="icon" aria-hidden="true"><use href="assets/icons.svg#check-circle"/></svg></div>
<div><h3>중복은 미리 알려줍니다</h3><p>이미 받은 영상을 다시 받으려 하면 내려받기 전에 확인해 줍니다.</p></div>
</div>
<div class="featlist__item reveal">
<div class="featlist__icon"><svg class="icon" aria-hidden="true"><use href="assets/icons.svg#folders"/></svg></div>
<div><h3>라이브러리와 아카이브</h3><p>받은 영상을 모아 보고, 정리하고, 재생목록과 즐겨찾기로 관리합니다.</p></div>
</div>
</div>
</div>
</section>
<section id="mobile" class="section-pad">
<div class="wrap mobile__grid">
<div class="mobile__stage reveal" aria-hidden="true">
<div class="mobile__card mobile__card--a glass">
<svg class="icon"><use href="assets/icons.svg#qr-code"/></svg>
<div><strong>QR 페어링</strong><span>화면의 코드를 한 번 스캔</span></div>
</div>
<div class="mobile__card mobile__card--b glass">
<svg class="icon"><use href="assets/icons.svg#device-mobile"/></svg>
<div><strong>스트리밍</strong><span>같은 네트워크에서 바로 재생</span></div>
</div>
<div class="mobile__card mobile__card--c glass">
<svg class="icon"><use href="assets/icons.svg#download-simple"/></svg>
<div><strong>전송</strong><span>파일을 휴대폰에 저장</span></div>
</div>
</div>
<div>
<h2 class="reveal" style="font-size:clamp(28px,3.6vw,40px)">받은 영상,<br>휴대폰에서 이어보기.</h2>
<p class="sub reveal" style="margin-top:20px">데스크톱 앱에 내장된 서버가 라이브러리를 휴대폰으로 스트리밍하거나 전송합니다.
QR 코드 한 번으로 페어링하고, Android 컴패니언 앱으로 어디서든 이어봅니다. iOS 는 준비 중입니다.</p>
</div>
</div>
</section>
<section class="section-pad" style="padding-top:0">
<div class="wrap">
<div class="band glass reveal">
<h2>Windows 에서 시작하세요.</h2>
<p class="sub">Windows 는 설치자로 바로 시작할 수 있습니다.
macOS 와 Linux 는 새 셸의 프리뷰 빌드, Android 는 컴패니언 앱을 제공합니다.</p>
<div class="band__oses">
<div class="band__os"><svg class="icon" aria-hidden="true"><use href="assets/icons.svg#windows-logo"/></svg>Windows</div>
<div class="band__os"><svg class="icon" aria-hidden="true"><use href="assets/icons.svg#apple-logo"/></svg>macOS 프리뷰</div>
<div class="band__os"><svg class="icon" aria-hidden="true"><use href="assets/icons.svg#linux-logo"/></svg>Linux 프리뷰</div>
<div class="band__os"><svg class="icon" aria-hidden="true"><use href="assets/icons.svg#android-logo"/></svg>Android</div>
</div>
<a class="btn btn--primary" href="releases.html">다운로드</a>
</div>
</div>
</section>
<footer>
<div class="wrap footer__row">
<span class="footer__brand">MEDIABROWSER</span>
<a href="releases.html">다운로드</a>
<a href="guide.html">사용 안내</a>
<a href="https://git.chanpaca.net/Video-Downloader/releases" rel="noopener">릴리스 저장소</a>
<span class="footer__copy">© 2026 MEDIABROWSER</span>
</div>
</footer>
<script src="site.js" defer></script>
</body>
</html>

76
site/releases.html Normal file
View file

@ -0,0 +1,76 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>다운로드 - MEDIABROWSER</title>
<meta name="description" content="MEDIABROWSER 릴리스 다운로드. Windows 설치자, macOS·Linux 프리뷰 빌드, Android 컴패니언 APK.">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='24' fill='%232050f0'/><path d='M50 22v36M36 44l14 14 14-14M28 74h44' stroke='white' stroke-width='9' stroke-linecap='round' stroke-linejoin='round' fill='none'/></svg>">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/wanteddev/wanted-sans@v1.0.3/packages/wanted-sans/fonts/webfonts/variable/split/WantedSansVariable.min.css">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<nav class="nav glass" aria-label="주 메뉴">
<a class="nav__brand" href="index.html">
<span class="nav__mark"><svg class="icon" aria-hidden="true"><use href="assets/icons.svg#download-simple"/></svg></span>
MEDIABROWSER
</a>
<div class="nav__links">
<a href="index.html#features">기능</a>
<a href="index.html#mobile">모바일</a>
<a href="guide.html">사용 안내</a>
</div>
<a class="btn btn--primary btn--sm" href="releases.html" aria-current="page">다운로드</a>
</nav>
<header class="pagehead">
<div class="orbs" aria-hidden="true">
<div class="orb orb--a"></div>
<div class="orb orb--b"></div>
</div>
<div class="wrap">
<h1>다운로드</h1>
<p class="sub">사용하는 플랫폼에 맞는 빌드를 받으세요. 설치가 끝나면
<a href="guide.html" style="color:var(--accent);font-weight:700;text-decoration:none">사용 안내</a>에서 첫 다운로드까지 3분이면 충분합니다.</p>
</div>
</header>
<div class="wrap rel-layout">
<aside class="rel-rail">
<div class="rel-rail__box glass">
<h3>플랫폼</h3>
<a class="rel-rail__os" href="#windows"><svg class="icon" aria-hidden="true"><use href="assets/icons.svg#windows-logo"/></svg>Windows</a>
<a class="rel-rail__os" href="#macos"><svg class="icon" aria-hidden="true"><use href="assets/icons.svg#apple-logo"/></svg>macOS 프리뷰</a>
<a class="rel-rail__os" href="#linux"><svg class="icon" aria-hidden="true"><use href="assets/icons.svg#linux-logo"/></svg>Linux 프리뷰</a>
<a class="rel-rail__os" href="#android"><svg class="icon" aria-hidden="true"><use href="assets/icons.svg#android-logo"/></svg>Android</a>
</div>
<div class="rel-rail__box glass">
<p class="rel-rail__note">Windows 는 설치자(exe)를 실행하면 됩니다.
macOS 와 Linux 는 tar.gz 를 풀어 실행하는 프리뷰 빌드입니다.
전체 버전 기록은 <a href="https://git.chanpaca.net/Video-Downloader/releases" rel="noopener">릴리스 저장소</a>에 있습니다.</p>
</div>
</aside>
<main id="release-root" aria-live="polite">
<div class="rel-empty glass" id="release-loading">
<svg class="icon" aria-hidden="true"><use href="assets/icons.svg#download-simple"/></svg>
<h2>릴리스 목록을 불러오는 중</h2>
<p>잠시만 기다려 주세요.</p>
</div>
</main>
</div>
<footer>
<div class="wrap footer__row">
<span class="footer__brand">MEDIABROWSER</span>
<a href="releases.html">다운로드</a>
<a href="guide.html">사용 안내</a>
<a href="https://git.chanpaca.net/Video-Downloader/releases" rel="noopener">릴리스 저장소</a>
<span class="footer__copy">© 2026 MEDIABROWSER</span>
</div>
</footer>
<script src="site.js" defer></script>
</body>
</html>

131
site/site.js Normal file
View file

@ -0,0 +1,131 @@
// MEDIABROWSER 사이트 공용 스크립트
// 1) 스크롤 등장(reveal) — IntersectionObserver, 모션 축소 설정이면 즉시 표시
// 2) releases.html — releases.json(배포 시 생성)을 읽어 릴리스 목록 렌더
(function initReveal() {
const items = document.querySelectorAll(".reveal");
if (!items.length) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches || !("IntersectionObserver" in window)) {
return; // .js 게이트를 켜지 않으면 CSS 기본값(표시 상태) 그대로
}
document.documentElement.classList.add("js");
const io = new IntersectionObserver((entries) => {
for (const e of entries) {
if (e.isIntersecting) { e.target.classList.add("in"); io.unobserve(e.target); }
}
}, { threshold: 0.15, rootMargin: "0px 0px -8% 0px" });
items.forEach((el) => io.observe(el));
})();
// ── 릴리스 렌더 ──────────────────────────────────────────────
const PLATFORMS = [
{ id: "windows", label: "Windows", icon: "windows-logo", sub: "설치자를 실행하면 됩니다",
match: (n) => /setup.*\.exe$/i.test(n) || /win/i.test(n) },
{ id: "macos", label: "macOS 프리뷰", icon: "apple-logo", sub: "tar.gz 를 풀어 실행하는 프리뷰 빌드",
match: (n) => /osx|darwin|mac/i.test(n) },
{ id: "linux", label: "Linux 프리뷰", icon: "linux-logo", sub: "tar.gz 를 풀어 실행하는 프리뷰 빌드",
match: (n) => /linux/i.test(n) },
{ id: "android", label: "Android", icon: "android-logo", sub: "컴패니언 앱 APK",
match: (n) => /\.apk$/i.test(n) },
];
function detectPreferredAsset(name) {
const ua = navigator.userAgent;
if (/Windows/i.test(ua)) return /setup-x64\.exe$/i.test(name);
if (/Android/i.test(ua)) return /\.apk$/i.test(name);
if (/Mac/i.test(ua)) return /osx-arm64/i.test(name);
if (/Linux/i.test(ua)) return /linux-x64/i.test(name);
return false;
}
function fmtSize(bytes) {
if (!bytes) return "";
const mb = bytes / 1048576;
return mb >= 1024 ? (mb / 1024).toFixed(1) + " GB" : mb.toFixed(1) + " MB";
}
function fmtDate(iso) {
if (!iso) return "";
const d = new Date(iso);
return `${d.getFullYear()}년 ${d.getMonth() + 1}월 ${d.getDate()}일`;
}
function esc(s) {
return String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
}
function assetRow(a, recommended) {
return `<div class="dl-asset">
<div><div class="dl-asset__name">${esc(a.name)}</div><div class="dl-asset__size">${fmtSize(a.size)}</div></div>
<a class="btn btn--primary btn--sm ${recommended ? "dl-reco" : ""}" href="${esc(a.url)}" title="${recommended ? "이 기기에 권장" : ""}">받기</a>
</div>`;
}
function groupsHtml(assets) {
const used = new Set();
const groups = PLATFORMS.map((p) => {
const list = assets.filter((a) => !used.has(a.name) && p.match(a.name));
list.forEach((a) => used.add(a.name));
if (!list.length) return "";
return `<div class="dl-group" id="${p.id}">
<div class="dl-group__head"><svg class="icon" aria-hidden="true"><use href="assets/icons.svg#${p.icon}"/></svg>${p.label}</div>
<div class="dl-group__sub">${p.sub}</div>
${list.map((a) => assetRow(a, detectPreferredAsset(a.name))).join("")}
</div>`;
}).filter(Boolean);
const rest = assets.filter((a) => !used.has(a.name));
if (rest.length) {
groups.push(`<div class="dl-group"><div class="dl-group__head">기타</div><div class="dl-group__sub">그 밖의 파일</div>
${rest.map((a) => assetRow(a, false)).join("")}</div>`);
}
return `<div class="dl-groups">${groups.join("")}</div>`;
}
function releaseCard(r, featured) {
const pill = r.prerelease ? `<span class="rel-pill rel-pill--pre">프리뷰</span>` : `<span class="rel-pill">안정판</span>`;
const meta = `<div class="rel-featured__meta">
<span class="rel-featured__tag">${esc(r.name || r.tag)}</span>${pill}
<span class="rel-featured__date">${fmtDate(r.published_at)}</span>
</div>
${r.notes ? `<p class="rel-featured__notes">${esc(r.notes)}</p>` : ""}
${groupsHtml(r.assets)}`;
if (featured) return `<article class="rel-featured glass">${meta}</article>`;
return `<details><summary><span class="rel-featured__tag" style="font-size:19px">${esc(r.name || r.tag)}</span>${pill}
<span class="rel-featured__date">${fmtDate(r.published_at)}</span></summary>
<div class="rel-old__body">${r.notes ? `<p class="rel-featured__notes">${esc(r.notes)}</p>` : ""}${groupsHtml(r.assets)}</div>
</details>`;
}
function emptyState() {
return `<div class="rel-empty glass">
<svg class="icon" aria-hidden="true"><use href="assets/icons.svg#rocket-launch"/></svg>
<h2>첫 공개 릴리스를 준비하고 있어요</h2>
<p>빌드 파이프라인은 이미 돌아가고 있습니다. 곧 이 자리에서 설치자를 받을 수 있습니다.
먼저 <a href="guide.html" style="color:var(--accent);font-weight:700;text-decoration:none">사용 안내</a>를 둘러보세요.</p>
</div>`;
}
(async function initReleases() {
const root = document.getElementById("release-root");
if (!root) return;
try {
const res = await fetch("releases.json", { cache: "no-cache" });
if (!res.ok) throw new Error("HTTP " + res.status);
const data = await res.json();
const releases = (data.releases || []);
if (!releases.length) { root.innerHTML = emptyState(); return; }
const [latest, ...older] = releases;
let html = releaseCard(latest, true);
if (older.length) {
html += `<div class="rel-old"><h2 style="font-size:20px;margin-top:14px">이전 버전</h2>
${older.map((r) => releaseCard(r, false)).join("")}</div>`;
}
root.innerHTML = html;
} catch (err) {
root.innerHTML = `<div class="rel-empty glass">
<h2>목록을 불러오지 못했어요</h2>
<p>잠시 후 새로고침해 주세요. 급하다면
<a href="https://git.chanpaca.net/Video-Downloader/releases" style="color:var(--accent);font-weight:700;text-decoration:none" rel="noopener">릴리스 저장소</a>에서 직접 받을 수 있습니다.</p>
</div>`;
}
})();

289
site/styles.css Normal file
View file

@ -0,0 +1,289 @@
/* MEDIABROWSER 사이트 공용 디자인 시스템
라이트 단일 테마(사용자 지정) · 글래스모피즘 · 절제된 스크롤 모션
반경 체계: 버튼=필, 카드/패널=20px, 칩/입력=12px */
:root {
--bg: #f5f7fc;
--bg-deep: #e9edf8;
--ink: #171a23;
--muted: #5b6273;
--line: rgba(23, 26, 35, 0.08);
--accent: #2050f0;
--accent-deep: #1740c8;
--accent-soft: rgba(32, 80, 240, 0.09);
--glass: rgba(255, 255, 255, 0.58);
--glass-strong: rgba(255, 255, 255, 0.78);
--glass-brd: rgba(255, 255, 255, 0.72);
--shadow-soft: 0 24px 60px -24px rgba(32, 60, 160, 0.18);
--shadow-lift: 0 32px 80px -28px rgba(32, 60, 160, 0.28);
--r-card: 20px;
--r-chip: 12px;
--nav-h: 68px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { scroll-behavior: smooth; }
@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } }
body {
font-family: "Wanted Sans Variable", "Wanted Sans", system-ui, "Apple SD Gothic Neo", "Malgun Gothic", sans-serif;
background: var(--bg);
color: var(--ink);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
overflow-x: hidden;
}
::selection { background: var(--accent); color: #fff; }
.icon { width: 1.25em; height: 1.25em; fill: currentColor; flex: none; vertical-align: -0.25em; }
/* ── 배경 오브(패럴랙스 레이어) ─────────────────────────────── */
.orbs { position: absolute; inset: 0; overflow: hidden; pointer-events: none; z-index: -1; }
.orb { position: absolute; border-radius: 50%; filter: blur(70px); opacity: 0.55; }
.orb--a { width: 560px; height: 560px; left: -140px; top: -120px;
background: radial-gradient(circle at 35% 35%, #c9d6ff, #eef1fb 70%); }
.orb--b { width: 480px; height: 480px; right: -120px; top: 160px;
background: radial-gradient(circle at 40% 40%, #d8e4ff, #f0f3fc 70%); }
.orb--c { width: 380px; height: 380px; left: 34%; top: 560px;
background: radial-gradient(circle at 50% 40%, #dfe9ff, #f4f6fd 72%); opacity: 0.45; }
/* 스크롤 패럴랙스: 지원 브라우저에서만, 모션 축소 설정이면 정지 */
@media (prefers-reduced-motion: no-preference) {
@supports (animation-timeline: scroll()) {
.orb { animation: orb-drift linear both; animation-timeline: scroll(); }
.orb--a { --drift: 180px; }
.orb--b { --drift: -240px; }
.orb--c { --drift: 120px; }
}
}
@keyframes orb-drift { to { transform: translateY(var(--drift, 0px)); } }
/* ── 글래스 재질 ────────────────────────────────────────────── */
.glass {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.42), rgba(255, 255, 255, 0.16)), var(--glass);
backdrop-filter: blur(18px) saturate(160%);
-webkit-backdrop-filter: blur(18px) saturate(160%);
border: 1px solid var(--glass-brd);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65), var(--shadow-soft);
}
@media (prefers-reduced-transparency: reduce) {
.glass { background: rgba(255, 255, 255, 0.96); backdrop-filter: none; -webkit-backdrop-filter: none; }
}
/* ── 내비게이션 ─────────────────────────────────────────────── */
.nav {
position: fixed; top: 14px; left: 50%; transform: translateX(-50%);
width: min(1120px, calc(100% - 32px)); height: var(--nav-h);
display: flex; align-items: center; gap: 28px; padding: 0 22px;
border-radius: 999px; z-index: 50;
}
.nav__brand {
font-weight: 800; letter-spacing: 0.04em; font-size: 15px;
color: var(--ink); text-decoration: none; display: flex; align-items: center; gap: 9px;
}
.nav__mark {
width: 30px; height: 30px; border-radius: 9px; display: grid; place-items: center;
background: linear-gradient(135deg, var(--accent), #4d79ff); color: #fff;
}
.nav__mark .icon { width: 16px; height: 16px; }
.nav__links { display: flex; gap: 22px; margin-left: auto; }
.nav__links a { color: var(--muted); text-decoration: none; font-size: 14.5px; font-weight: 600; }
.nav__links a:hover, .nav__links a[aria-current="page"] { color: var(--ink); }
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
border-radius: 999px; font-weight: 700; text-decoration: none; cursor: pointer;
border: 0; white-space: nowrap;
transition: transform 0.18s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.18s, background 0.18s;
}
.btn:active { transform: translateY(1px) scale(0.98); }
.btn--primary {
background: var(--accent); color: #fff; padding: 12px 22px; font-size: 15px;
box-shadow: 0 10px 24px -10px rgba(32, 80, 240, 0.55);
}
.btn--primary:hover { background: var(--accent-deep); }
.btn--ghost {
background: rgba(255, 255, 255, 0.6); color: var(--ink); padding: 12px 22px; font-size: 15px;
border: 1px solid var(--line);
}
.btn--ghost:hover { background: #fff; }
.btn--sm { padding: 9px 16px; font-size: 14px; }
/* ── 공통 레이아웃 ──────────────────────────────────────────── */
.wrap { width: min(1120px, calc(100% - 40px)); margin: 0 auto; }
section { position: relative; }
.section-pad { padding: 108px 0; }
h1, h2, h3 { line-height: 1.22; letter-spacing: -0.02em; font-weight: 800; }
.sub { color: var(--muted); font-size: 17px; max-width: 58ch; }
/* 스크롤 등장 — JS 가 html 에 .js 를 붙였을 때만 숨김(no-JS 에서도 내용이 보이는 점진적 향상) */
html.js .reveal { opacity: 0; transform: translateY(22px); transition: opacity 0.7s cubic-bezier(0.16, 1, 0.3, 1), transform 0.7s cubic-bezier(0.16, 1, 0.3, 1); }
html.js .reveal.in { opacity: 1; transform: none; }
@media (prefers-reduced-motion: reduce) { html.js .reveal { opacity: 1; transform: none; transition: none; } }
/* ── 히어로 ─────────────────────────────────────────────────── */
.hero { padding: calc(var(--nav-h) + 78px) 0 96px; min-height: 86dvh; display: flex; align-items: center; }
.hero__grid { display: grid; grid-template-columns: 7fr 5fr; gap: 56px; align-items: center; }
.hero h1 { font-size: clamp(38px, 5.2vw, 62px); }
.hero .sub { margin-top: 22px; font-size: 18px; }
.hero__cta { display: flex; gap: 14px; margin-top: 34px; }
.hero__visual { position: relative; }
.hero__panel {
border-radius: var(--r-card); padding: 14px;
transform: rotate(1.8deg);
}
.hero__panel img {
display: block; width: 100%; border-radius: 12px;
box-shadow: 0 18px 44px -18px rgba(10, 16, 40, 0.45);
}
.hero__caption { margin-top: 12px; font-size: 13.5px; color: var(--muted); text-align: center; }
/* ── 벤토 ───────────────────────────────────────────────────── */
.bento { display: grid; grid-template-columns: 3fr 2fr; grid-template-rows: auto auto; gap: 20px; margin-top: 52px; }
.bento__cell { border-radius: var(--r-card); padding: 34px; position: relative; overflow: hidden; }
.bento__cell--wide { grid-row: span 2; display: flex; flex-direction: column; justify-content: flex-end; min-height: 380px;
background: linear-gradient(160deg, #1b2440 0%, #22346e 55%, #2050f0 130%); color: #fff; }
.bento__cell--wide h3, .bento__cell--wide p { color: #fff; }
.bento__cell--wide p { opacity: 0.82; }
.bento__wave { position: absolute; top: 30px; left: 34px; opacity: 0.9; }
.bento__wave .icon { width: 44px; height: 44px; color: #9db6ff; }
.bento__cell h3 { font-size: 21px; margin-bottom: 10px; }
.bento__cell p { color: var(--muted); font-size: 15.5px; }
.bento__chipline { display: flex; gap: 8px; margin-bottom: 18px; flex-wrap: wrap; }
.chip {
display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px;
border-radius: 999px; font-size: 13px; font-weight: 700;
background: rgba(255, 255, 255, 0.14); border: 1px solid rgba(255, 255, 255, 0.22); color: #dfe8ff;
}
/* ── 기능 그리드(카드 없는 정리) ────────────────────────────── */
.featlist { display: grid; grid-template-columns: repeat(2, 1fr); gap: 40px 64px; margin-top: 56px; }
.featlist__item { display: flex; gap: 16px; align-items: flex-start; }
.featlist__icon {
width: 42px; height: 42px; flex: none; border-radius: var(--r-chip); display: grid; place-items: center;
background: var(--accent-soft); color: var(--accent);
}
.featlist__icon .icon { width: 21px; height: 21px; }
.featlist__item h3 { font-size: 17px; margin-bottom: 4px; }
.featlist__item p { color: var(--muted); font-size: 15px; }
/* ── 모바일 섹션 ────────────────────────────────────────────── */
.mobile__grid { display: grid; grid-template-columns: 5fr 7fr; gap: 56px; align-items: center; }
.mobile__stage { position: relative; min-height: 320px; }
.mobile__card { position: absolute; border-radius: var(--r-card); display: flex; align-items: center; gap: 14px; padding: 20px 24px; }
.mobile__card .icon { width: 30px; height: 30px; color: var(--accent); }
.mobile__card strong { display: block; font-size: 15.5px; }
.mobile__card span { font-size: 13.5px; color: var(--muted); }
.mobile__card--a { top: 8%; left: 4%; transform: rotate(-2.4deg); }
.mobile__card--b { top: 40%; right: 6%; transform: rotate(1.6deg); }
.mobile__card--c { bottom: 4%; left: 14%; transform: rotate(-1deg); }
/* ── 플랫폼 밴드 ────────────────────────────────────────────── */
.band { border-radius: 28px; padding: 64px 56px; text-align: center; }
.band h2 { font-size: clamp(28px, 3.6vw, 40px); }
.band .sub { margin: 16px auto 0; }
.band__oses { display: flex; gap: 34px; justify-content: center; margin: 36px 0 40px; color: var(--muted); }
.band__os { display: flex; flex-direction: column; align-items: center; gap: 8px; font-size: 13.5px; font-weight: 600; }
.band__os .icon { width: 34px; height: 34px; color: var(--ink); }
.band__os--soon { opacity: 0.55; }
/* ── 푸터 ───────────────────────────────────────────────────── */
footer { padding: 48px 0 60px; }
.footer__row { display: flex; align-items: center; gap: 26px; flex-wrap: wrap; border-top: 1px solid var(--line); padding-top: 32px; }
.footer__row a { color: var(--muted); text-decoration: none; font-size: 14px; font-weight: 600; }
.footer__row a:hover { color: var(--ink); }
.footer__brand { font-weight: 800; letter-spacing: 0.04em; font-size: 14px; }
.footer__copy { margin-left: auto; color: var(--muted); font-size: 13.5px; }
/* ── 릴리스 페이지 ──────────────────────────────────────────── */
.pagehead { padding: calc(var(--nav-h) + 74px) 0 30px; }
.pagehead h1 { font-size: clamp(32px, 4.2vw, 48px); }
.pagehead .sub { margin-top: 16px; }
.rel-layout { display: grid; grid-template-columns: 280px 1fr; gap: 44px; align-items: start; padding-bottom: 110px; }
.rel-rail { position: sticky; top: calc(var(--nav-h) + 30px); display: flex; flex-direction: column; gap: 10px; }
.rel-rail__box { border-radius: var(--r-card); padding: 22px; }
.rel-rail__box h3 { font-size: 15px; margin-bottom: 12px; }
.rel-rail__os { display: flex; align-items: center; gap: 10px; padding: 9px 10px; border-radius: var(--r-chip);
color: var(--ink); text-decoration: none; font-size: 14.5px; font-weight: 600; }
.rel-rail__os:hover { background: rgba(255, 255, 255, 0.75); }
.rel-rail__os .icon { width: 19px; height: 19px; color: var(--muted); }
.rel-rail__note { font-size: 13.5px; color: var(--muted); line-height: 1.55; }
.rel-rail__note a { color: var(--accent); font-weight: 700; text-decoration: none; }
.rel-featured { border-radius: 24px; padding: 40px; }
.rel-featured__meta { display: flex; align-items: baseline; gap: 14px; flex-wrap: wrap; }
.rel-featured__tag { font-size: 30px; font-weight: 800; letter-spacing: -0.02em; }
.rel-featured__date { color: var(--muted); font-size: 14.5px; }
.rel-pill { display: inline-flex; padding: 4px 12px; border-radius: 999px; font-size: 12.5px; font-weight: 800;
background: var(--accent-soft); color: var(--accent); }
.rel-pill--pre { background: rgba(214, 122, 20, 0.12); color: #a35a00; }
.rel-featured__notes { margin-top: 18px; color: var(--muted); font-size: 15px; white-space: pre-line; max-width: 70ch; }
.dl-groups { display: grid; grid-template-columns: repeat(2, 1fr); gap: 18px; margin-top: 30px; }
.dl-group { border-radius: var(--r-card); background: rgba(255, 255, 255, 0.66); border: 1px solid var(--line); padding: 22px; }
.dl-group__head { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; font-weight: 800; font-size: 16px; }
.dl-group__head .icon { width: 21px; height: 21px; color: var(--ink); }
.dl-group__sub { font-size: 13.5px; color: var(--muted); margin-bottom: 14px; }
.dl-asset { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 9px 0; }
.dl-asset + .dl-asset { border-top: 1px solid var(--line); }
.dl-asset__name { font-size: 14px; font-weight: 600; overflow-wrap: anywhere; }
.dl-asset__size { color: var(--muted); font-size: 12.5px; }
.dl-asset .btn { flex: none; }
.dl-reco { outline: 2px solid var(--accent); outline-offset: 2px; }
.rel-old { margin-top: 30px; display: flex; flex-direction: column; gap: 14px; }
.rel-old details { border-radius: var(--r-card); border: 1px solid var(--line); background: rgba(255, 255, 255, 0.55); }
.rel-old summary { cursor: pointer; padding: 18px 22px; font-weight: 700; display: flex; align-items: center; gap: 12px; list-style: none; }
.rel-old summary::-webkit-details-marker { display: none; }
.rel-old summary .rel-featured__date { margin-left: auto; }
.rel-old__body { padding: 0 22px 20px; }
.rel-empty { border-radius: 24px; padding: 72px 40px; text-align: center; }
.rel-empty .icon { width: 44px; height: 44px; color: var(--accent); }
.rel-empty h2 { font-size: 24px; margin: 18px 0 10px; }
.rel-empty p { color: var(--muted); max-width: 48ch; margin: 0 auto; }
/* ── 가이드 페이지 ──────────────────────────────────────────── */
.guide-layout { display: grid; grid-template-columns: 240px 1fr; gap: 52px; align-items: start; padding-bottom: 110px; }
.guide-toc { position: sticky; top: calc(var(--nav-h) + 30px); border-radius: var(--r-card); padding: 22px; }
.guide-toc a { display: block; padding: 7px 10px; border-radius: 10px; color: var(--muted); text-decoration: none; font-size: 14.5px; font-weight: 600; }
.guide-toc a:hover { color: var(--ink); background: rgba(255, 255, 255, 0.75); }
.guide-step { padding: 44px 0; border-top: 1px solid var(--line); }
.guide-step:first-of-type { border-top: 0; padding-top: 10px; }
.guide-step h2 { font-size: 24px; display: flex; align-items: center; gap: 13px; }
.guide-step h2 .featlist__icon { width: 38px; height: 38px; }
.guide-step > p { color: var(--muted); margin-top: 14px; max-width: 66ch; }
.guide-step ul { margin: 16px 0 0 2px; list-style: none; display: flex; flex-direction: column; gap: 9px; }
.guide-step li { display: flex; gap: 10px; color: var(--ink); font-size: 15px; }
.guide-step li .icon { width: 18px; height: 18px; color: var(--accent); margin-top: 4px; }
.guide-step li span b { font-weight: 750; }
.guide-media { margin-top: 24px; border-radius: var(--r-card); padding: 12px; max-width: 460px; }
.guide-media img { display: block; width: 100%; border-radius: 12px; }
.guide-media figcaption { font-size: 13px; color: var(--muted); padding: 10px 6px 2px; }
.guide-faq { margin-top: 18px; display: flex; flex-direction: column; gap: 10px; }
.guide-faq details { border: 1px solid var(--line); border-radius: var(--r-chip); background: rgba(255, 255, 255, 0.55); }
.guide-faq summary { cursor: pointer; padding: 15px 18px; font-weight: 700; font-size: 15px; list-style: none; }
.guide-faq summary::-webkit-details-marker { display: none; }
.guide-faq p { padding: 0 18px 16px; color: var(--muted); font-size: 14.5px; }
/* ── 모바일 반응형 ──────────────────────────────────────────── */
@media (max-width: 860px) {
.nav { gap: 14px; }
.nav__links { display: none; }
.hero { min-height: auto; padding-bottom: 64px; }
.hero__grid, .mobile__grid { grid-template-columns: 1fr; gap: 40px; }
.bento { grid-template-columns: 1fr; }
.bento__cell--wide { min-height: 300px; }
.featlist { grid-template-columns: 1fr; gap: 28px; }
.section-pad { padding: 72px 0; }
.band { padding: 48px 24px; }
.band__oses { gap: 22px; flex-wrap: wrap; }
.rel-layout, .guide-layout { grid-template-columns: 1fr; gap: 28px; }
.rel-rail, .guide-toc { position: static; }
.dl-groups { grid-template-columns: 1fr; }
.mobile__stage { min-height: 380px; }
.rel-featured { padding: 26px; }
}