사이트 인터랙티브 워크스테이션 확장 + 감사 폴리시 재적용 + 원클릭 릴리스 도구 — 병행 세션 확장분(히어로 3탭 워크스테이션 시뮬레이터, 비교 섹션(기존 방식 vs MEDIABROWSER), 가이드 단축키 섹션(kbd), 스포트라이트 카드, build/release.ps1 원클릭 릴리스(-Publish/-DeploySite), build-remote.ps1 PowerShell 원격 빌드, upload-release-asset.ps1 로컬 실행 지원(.env 자동 로드·-Publish 승격·-Repo), fetch-releases.mjs 인증·폴백)을 수용하고, 덮여 사라진 감사 폴리시를 새 베이스에 재적용: 타이틀 em대시 제거+OG 메타(index/guide), 아이브로우 6→1(장식 점 제거), 히어로 2줄 헤드라인·20단어 서브·CTA 라벨 단일화·신뢰 스트립 제거, 파이프라인 3분리 카드→단일 머신드 레일 재구성, 과장 카피 완화(100%·1초·초고속·완벽), 시뮬레이터 예시 캡션, skip-link·focus-visible·hero__grid min-width:0(모바일 오버플로) 재적용 — 데스크톱/모바일 검증(scrollWidth 375==375, 아이브로우 1, 레일·캡션·탭 3종 확인), deploy-site.ps1 로 재배포 완료

This commit is contained in:
Yun Chan 2026-08-18 10:35:53 +09:00
parent 9c9d9cb024
commit f13914534a
11 changed files with 976 additions and 246 deletions

View file

@ -1,35 +1,100 @@
# Forgejo 릴리스(draft)에 산출물을 업로드한다. 릴리스가 없으면 만들고, 동명 자산은 교체한다(재실행 멱등).
# 사용: ./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_REPO 를 주면 해당 저장소로 업로드(공개 배포 저장소용), 없으면 현재 저장소
# RELEASE_PRERELEASE=true 면 prerelease 로 생성(리허설용)
# Forgejo 릴리스에 산출물을 업로드한다. 릴리스가 없으면 만들고, 동명 자산은 교체한다(재실행 멱등).
# 사용: ./build/ci/upload-release-asset.ps1 -Tag v1.0.0 -Files out/a.exe, out/b.exe [-Publish]
param(
[Parameter(Mandatory)][string]$Tag,
[Parameter(Mandatory)][string[]]$Files
[Parameter(Mandatory)][string[]]$Files,
[switch]$Publish,
[string]$Notes = "",
[string]$Repo = ""
)
$ErrorActionPreference = 'Stop'
if (-not $env:RELEASE_TOKEN) { throw "RELEASE_TOKEN 환경변수가 없습니다" }
$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" }
# .env 자동 로드 (로컬 실행 시)
$root = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
$envFile = Join-Path $root ".env"
if (Test-Path $envFile) {
foreach ($line in Get-Content $envFile) {
if ($line -match '^\s*([A-Za-z0-9_]+)\s*=\s*(.+)$') {
$k = $Matches[1]; $v = $Matches[2].Trim()
if (-not (Get-Item "env:$k" -ErrorAction SilentlyContinue)) {
Set-Item "env:$k" $v
}
}
}
}
$token = if ($env:RELEASE_TOKEN) { $env:RELEASE_TOKEN } else { $env:FORGEJO_TOKEN }
$hdr = @{}
if ($token) {
$hdr["Authorization"] = "token $token"
} elseif ($env:FORGEJO_USER -and $env:FORGEJO_PASSWORD) {
$bytes = [System.Text.Encoding]::UTF8.GetBytes("$($env:FORGEJO_USER):$($env:FORGEJO_PASSWORD)")
$b64 = [Convert]::ToBase64String($bytes)
$hdr["Authorization"] = "Basic $b64"
} else {
throw ".env 에 FORGEJO_TOKEN 또는 FORGEJO_USER/PASSWORD 가 없습니다."
}
$serverUrl = if ($env:GITHUB_SERVER_URL) { $env:GITHUB_SERVER_URL }
elseif ($env:FORGEJO_URL) { $env:FORGEJO_URL }
else { "https://git.chanpaca.net" }
$serverUrl = $serverUrl.TrimEnd('/')
$targetRepo = if ($Repo) { $Repo }
elseif ($env:RELEASE_REPO) { $env:RELEASE_REPO }
elseif ($env:GITHUB_REPOSITORY) { $env:GITHUB_REPOSITORY }
else { "Video-Downloader/releases" }
$api = "$serverUrl/api/v1/repos/$targetRepo"
$isDraft = -not $Publish
$isPrerelease = ($env:RELEASE_PRERELEASE -eq 'true')
# draft 릴리스는 tags/<tag> 단건 조회에 안 잡힐 수 있어 목록에서 tag_name 으로 찾는다
function Find-Release {
try {
(Invoke-RestMethod -Uri "$api/releases?limit=50" -Headers $hdr) |
Where-Object tag_name -eq $Tag | Select-Object -First 1
} catch {
return $null
}
}
$rel = Find-Release
if (-not $rel) {
$body = @{ tag_name = $Tag; name = $Tag; draft = $true
prerelease = ($env:RELEASE_PRERELEASE -eq 'true') } | ConvertTo-Json
try { $rel = Invoke-RestMethod -Method Post -Uri "$api/releases" -Headers $hdr -ContentType 'application/json' -Body $body }
catch { $rel = Find-Release } # 병렬 잡 동시 생성 경합 대비
$bodyObj = @{
tag_name = $Tag
name = $Tag
draft = $isDraft
prerelease = $isPrerelease
}
if ($Notes) { $bodyObj["body"] = $Notes }
$body = $bodyObj | ConvertTo-Json
try {
$rel = Invoke-RestMethod -Method Post -Uri "$api/releases" -Headers $hdr -ContentType 'application/json' -Body $body
} catch {
$rel = Find-Release # 병렬 잡 동시 생성 경합 대비
}
} else {
# 기존 릴리스가 있고 Publish 옵션이 주어졌는데 현재 draft 상태인 경우 Publish로 갱신
if ($Publish -and $rel.draft) {
$patchBody = @{ draft = $false } | ConvertTo-Json
$rel = Invoke-RestMethod -Method Patch -Uri "$api/releases/$($rel.id)" -Headers $hdr -ContentType 'application/json' -Body $patchBody
Write-Host "릴리스를 정식 공개(Published) 상태로 전환했습니다." -ForegroundColor Green
}
}
if (-not $rel) { throw "릴리스 확보 실패: $Tag" }
Write-Host "릴리스 #$($rel.id) ($Tag)"
if (-not $rel) { throw "릴리스 확보 실패: $Tag (저장소: $targetRepo)" }
$statusStr = if ($rel.draft) { "Draft" } else { "Published" }
Write-Host "릴리스 #$($rel.id) ($Tag, 상태: $statusStr, 저장소: $targetRepo)" -ForegroundColor Cyan
foreach ($f in $Files) {
if (-not (Test-Path $f)) {
Write-Warning "파일이 존재하지 않아 건너뜁니다: $f"
continue
}
$item = Get-Item $f
(Invoke-RestMethod -Uri "$api/releases/$($rel.id)/assets" -Headers $hdr) |
Where-Object name -eq $item.Name | ForEach-Object {
@ -37,5 +102,5 @@ foreach ($f in $Files) {
}
Invoke-RestMethod -Method Post -Uri "$api/releases/$($rel.id)/assets?name=$($item.Name)" `
-Headers $hdr -Form @{ attachment = $item } | Out-Null
Write-Host ("업로드 완료: {0} ({1:N1} MB)" -f $item.Name, ($item.Length / 1MB))
Write-Host ("업로드 완료: {0} ({1:N1} MB)" -f $item.Name, ($item.Length / 1MB)) -ForegroundColor Green
}

View file

@ -11,8 +11,9 @@ $root = Split-Path -Parent $PSScriptRoot
$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 ($line -match '^\s*([A-Za-z0-9_]+)\s*=\s*(.+)$') {
$k = $Matches[1]; $v = $Matches[2].Trim()
Set-Item "env:$k" $v
}
}
if (-not $env:CLOUDFLARE_API_TOKEN) { throw ".env 에 CLOUDFLARE_API_TOKEN 이 없습니다." }
@ -26,4 +27,4 @@ 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
Write-Host "완료: https://$Project-7r4.pages.dev (또는 대시보드 지정 도메인)" -ForegroundColor Green

142
build/release.ps1 Normal file
View file

@ -0,0 +1,142 @@
# MediaBrowser 원클릭 통합 릴리스 & 사이트 배포 스크립트
#
# 동작:
# 1) Windows 설치자(x64, arm64) 빌드 (+ 선택: Android APK, 원격 Linux/macOS 머신 빌드)
# 2) NAS Git(Forgejo)의 공개 릴리스 저장소(Video-Downloader/releases)에 릴리스 생성 및 자산 업로드
# 3) (선택: -DeploySite) releases.json 갱신 및 Cloudflare Pages 배포
#
# 사용 예시:
# ./build/release.ps1 -Version 1.0.0 -Publish -DeploySite
# ./build/release.ps1 -Version 1.0.0-preview -Prerelease
# ./build/release.ps1 -Version 1.0.0 -SkipBuild -Publish -DeploySite
param(
[Parameter(Mandatory=$true)][string]$Version,
[string]$Config = "Release",
[switch]$Publish,
[switch]$Prerelease,
[switch]$SkipBuild,
[switch]$IncludeAndroid,
[switch]$IncludeRemote,
[switch]$DeploySite,
[string]$Notes = "",
[string]$Repo = "Video-Downloader/releases"
)
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent $PSScriptRoot
$outDir = Join-Path $root "out"
Write-Host "======================================================" -ForegroundColor Cyan
Write-Host " MediaBrowser 릴리스 파이프라인 (v$Version)" -ForegroundColor Cyan
Write-Host "======================================================" -ForegroundColor Cyan
# ── 1. .env 환경변수 로드 ────────────────────────────────────
$envFile = Join-Path $root ".env"
if (Test-Path $envFile) {
foreach ($line in Get-Content $envFile) {
if ($line -match '^\s*([A-Za-z0-9_]+)\s*=\s*(.+)$') {
$k = $Matches[1]; $v = $Matches[2].Trim()
if (-not (Get-Item "env:$k" -ErrorAction SilentlyContinue)) {
Set-Item "env:$k" $v
}
}
}
}
$hasAuth = ($env:RELEASE_TOKEN) -or ($env:FORGEJO_TOKEN) -or ($env:FORGEJO_USER -and $env:FORGEJO_PASSWORD)
if (-not $hasAuth) {
throw ".env 에 FORGEJO_TOKEN 또는 FORGEJO_USER/PASSWORD 가 없습니다. Forgejo 인증을 설정하세요."
}
# ── 2. 아티팩트 빌드 ─────────────────────────────────────────
if (-not $SkipBuild) {
Write-Host "`n[1/4] Windows 설치자 빌드 (x64, arm64)" -ForegroundColor Yellow
& (Join-Path $PSScriptRoot "publish.ps1") -Config $Config -Version $Version
if ($LASTEXITCODE -ne 0) { throw "Windows 설치자 빌드 실패" }
if ($IncludeAndroid) {
Write-Host "`n[2/4] Android APK 빌드" -ForegroundColor Yellow
$mobileCsproj = Join-Path $root "VideoDownloader.Mobile\VideoDownloader.Mobile.csproj"
$androidOut = Join-Path $outDir "android"
dotnet publish $mobileCsproj -f net10.0-android -c $Config -p:AndroidPackageFormat=apk -p:CiPackArm64Only=true -o $androidOut
if ($LASTEXITCODE -eq 0) {
$apk = Get-ChildItem (Join-Path $androidOut "*.apk") | Select-Object -First 1
if ($apk) {
$targetApk = Join-Path $outDir "MediaBrowser-$Version-arm64.apk"
Copy-Item $apk.FullName $targetApk -Force
Write-Host "Android APK 생성 완료: $targetApk" -ForegroundColor Green
}
} else {
Write-Warning "Android 빌드 실패 (워크로드 미설치 시 건너뜁니다)"
}
}
if ($IncludeRemote) {
Write-Host "`n[3/4] Linux / macOS 원격 빌드머신 빌드" -ForegroundColor Yellow
$remoteScript = Join-Path $PSScriptRoot "remote\build-remote.sh"
if (Get-Command bash -ErrorAction SilentlyContinue) {
bash $remoteScript all
} else {
Write-Warning "bash 명령어를 찾을 수 없어 원격 빌드를 건너뜁니다."
}
}
} else {
Write-Host "`n[빌드 건너뜀 (-SkipBuild)]" -ForegroundColor DarkGray
}
# ── 3. 수집된 자산 목록 확인 ──────────────────────────────────
$assetsToUpload = @()
$candPatterns = @(
"MediaBrowser-Setup-*.exe",
"MediaBrowser-*-arm64.apk",
"mediabrowser-*.tar.gz"
)
foreach ($pat in $candPatterns) {
Get-ChildItem -Path $outDir -Filter $pat -ErrorAction SilentlyContinue | ForEach-Object {
$assetsToUpload += $_.FullName
}
}
if ($assetsToUpload.Count -eq 0) {
throw "업로드할 릴리스 산출물이 out/ 디렉토리에 없습니다."
}
Write-Host "`n업로드 대상 자산 ($($assetsToUpload.Count)개):" -ForegroundColor Cyan
foreach ($a in $assetsToUpload) {
$f = Get-Item $a
Write-Host (" - {0,-35} ({1:N1} MB)" -f $f.Name, ($f.Length / 1MB))
}
# ── 4. NAS Git (Forgejo) 릴리스 등록 및 업로드 ────────────────
Write-Host "`n[4/4] NAS Git (Forgejo) 릴리스 업로드 ($Repo)" -ForegroundColor Yellow
$tag = "v$Version"
$uploadScript = Join-Path $PSScriptRoot "ci\upload-release-asset.ps1"
$uploadArgs = @{
Tag = $tag
Files = $assetsToUpload
Repo = $Repo
}
if ($Publish) { $uploadArgs["Publish"] = $true }
if ($Notes) { $uploadArgs["Notes"] = $Notes }
$global:LASTEXITCODE = 0
& $uploadScript @uploadArgs
# ── 5. 사이트 배포 (옵션) ────────────────────────────────────
if ($DeploySite) {
Write-Host "`n[추가] Cloudflare Pages 사이트 배포" -ForegroundColor Yellow
$global:LASTEXITCODE = 0
& (Join-Path $PSScriptRoot "deploy-site.ps1")
}
Write-Host "`n======================================================" -ForegroundColor Green
Write-Host " 릴리스 완료: $tag" -ForegroundColor Green
if ($Publish) {
Write-Host " 상태: 정식 공개(Published)" -ForegroundColor Green
} else {
Write-Host " 상태: 초안(Draft) — 웹 UI(https://git.chanpaca.net/$Repo/releases)에서 확인 후 공개 가능" -ForegroundColor Yellow
}
Write-Host "======================================================" -ForegroundColor Green

View file

@ -0,0 +1,66 @@
# MediaBrowser 원격 빌드 수집 스크립트 (PowerShell)
# 빌드머신(vd-linux / vd-mac)에서 Cli + Avalonia 셸을 RID별 self-contained publish 하고
# 산출물을 out/mediabrowser-<rid>.tar.gz 로 수집한다.
#
# 사용: ./build/remote/build-remote.ps1 [-Target mac|linux|all]
param(
[ValidateSet("mac", "linux", "all")]
[string]$Target = "all"
)
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
$outDir = Join-Path $root "out"
New-Item -ItemType Directory -Path $outDir -Force | Out-Null
$remoteDir = "build/videodownloader"
function Push-Source {
param([string]$HostName)
Write-Host "==> [$HostName] 소스 코드 아카이브 전송" -ForegroundColor Cyan
$tempTar = Join-Path $outDir "source_sync.tar"
if (Test-Path $tempTar) { Remove-Item $tempTar -Force }
# git archive 생성
git archive --format=tar -o $tempTar HEAD
if ($LASTEXITCODE -ne 0) { throw "git archive 생성 실패" }
# 원격 폴더 초기화 및 tar 전송/풀기
& ssh.exe $HostName "rm -rf $remoteDir && mkdir -p $remoteDir"
& scp.exe $tempTar "${HostName}:${remoteDir}/source_sync.tar"
& ssh.exe $HostName "tar -xf $remoteDir/source_sync.tar -C $remoteDir && rm -f $remoteDir/source_sync.tar"
Remove-Item $tempTar -Force -ErrorAction SilentlyContinue
}
function Build-And-Collect {
param([string]$HostName, [string]$Rid)
Write-Host "==> [$HostName] publish $Rid" -ForegroundColor Cyan
$cmd = "cd $remoteDir && export DOTNET_ROOT=`$HOME/.dotnet && export PATH=`$HOME/.dotnet:`$PATH && " +
"dotnet publish VideoDownloader.Cli -c Release -r $Rid --self-contained -o out/cli-$Rid && " +
"dotnet publish VideoDownloader.Avalonia -c Release -r $Rid --self-contained -o out/avalonia-$Rid && " +
"cd out && tar -czf mediabrowser-$Rid.tar.gz cli-$Rid avalonia-$Rid"
& ssh.exe $HostName $cmd
if ($LASTEXITCODE -ne 0) { throw "[$HostName] 빌드 실패: $Rid" }
$localTarget = Join-Path $outDir "mediabrowser-$Rid.tar.gz"
Write-Host "==> [$HostName] 수집: mediabrowser-$Rid.tar.gz" -ForegroundColor Green
& scp.exe "${HostName}:${remoteDir}/out/mediabrowser-$Rid.tar.gz" $localTarget
if ($LASTEXITCODE -ne 0) { throw "[$HostName] 산출물 수집 실패: $Rid" }
}
if ($Target -eq "mac" -or $Target -eq "all") {
Push-Source "vd-mac"
Build-And-Collect "vd-mac" "osx-arm64"
Build-And-Collect "vd-mac" "osx-x64"
}
if ($Target -eq "linux" -or $Target -eq "all") {
Push-Source "vd-linux"
Build-And-Collect "vd-linux" "linux-x64"
Build-And-Collect "vd-linux" "linux-arm64"
}
Write-Host "`n원격 빌드 및 수집 완료:" -ForegroundColor Green
Get-ChildItem (Join-Path $outDir "mediabrowser-*.tar.gz") |
ForEach-Object { Write-Host (" {0,-35} {1:N1} MB" -f $_.Name, ($_.Length / 1MB)) }

View file

@ -1,43 +1,46 @@
#!/usr/bin/env bash
# 빌드머신(ChanpacaLaptop=linux-x64 / Mac mini=osx-arm64)에서
# git HEAD 소스를 전송해 Cli + Avalonia 셸을 RID별 self-contained publish 하고
# 산출물을 로컬 out/remote/<rid>/ 로 수집한다.
# 빌드머신(ChanpacaLaptop=linux / Mac mini=macos)에서
# Cli + Avalonia 셸을 RID별 self-contained publish 하고
# 산출물을 로컬 out/mediabrowser-<rid>.tar.gz 로 수집한다.
#
# 사용 (Windows Git Bash): bash build/remote/build-remote.sh [linux|mac|all]
# 사전 조건: ssh 별칭 vd-linux / vd-mac (키 인증), 각 머신 ~/.dotnet SDK 8
# 사용: bash build/remote/build-remote.sh [linux|mac|all]
set -euo pipefail
cd "$(dirname "$0")/../.."
TARGET="${1:-all}"
REMOTE_DIR="build/videodownloader"
mkdir -p out
push_src() { # $1 = ssh 대상
echo "==> [$1] 소스 코드 전송"
git archive HEAD | ssh -o BatchMode=yes "$1" "rm -rf $REMOTE_DIR && mkdir -p $REMOTE_DIR && tar -x -C $REMOTE_DIR"
}
build_on() { # $1 = ssh 대상, $2 = RID
echo "==> [$1] publish $2"
ssh -o BatchMode=yes "$1" "cd $REMOTE_DIR && export DOTNET_ROOT=\$HOME/.dotnet && export PATH=\$HOME/.dotnet:\$PATH && \
dotnet publish VideoDownloader.Cli -c Release -r $2 --self-contained -o out/cli-$2 && \
dotnet publish VideoDownloader.Avalonia -c Release -r $2 --self-contained -o out/avalonia-$2 && \
cd out && tar -czf artifacts-$2.tar.gz cli-$2 avalonia-$2"
build_and_collect() { # $1 = ssh 대상, $2 = RID
local host="$1"
local rid="$2"
echo "==> [$host] publish $rid"
ssh -o BatchMode=yes "$host" "cd $REMOTE_DIR && export DOTNET_ROOT=\$HOME/.dotnet && export PATH=\$HOME/.dotnet:\$PATH && \
dotnet publish VideoDownloader.Cli -c Release -r $rid --self-contained -o out/cli-$rid && \
dotnet publish VideoDownloader.Avalonia -c Release -r $rid --self-contained -o out/avalonia-$rid && \
cd out && tar -czf mediabrowser-$rid.tar.gz cli-$rid avalonia-$rid"
echo "==> [$host] 수집: out/mediabrowser-$rid.tar.gz"
scp -o BatchMode=yes "$host:$REMOTE_DIR/out/mediabrowser-$rid.tar.gz" "out/mediabrowser-$rid.tar.gz"
}
collect() { # $1 = ssh 대상, $2 = RID
mkdir -p "out/remote/$2"
scp -o BatchMode=yes "$1:$REMOTE_DIR/out/artifacts-$2.tar.gz" "out/remote/$2/" && \
tar -xzf "out/remote/$2/artifacts-$2.tar.gz" -C "out/remote/$2" && \
echo " 수집: out/remote/$2/{cli-$2,avalonia-$2}"
}
if [[ "$TARGET" == "mac" || "$TARGET" == "all" ]]; then
push_src vd-mac
build_and_collect vd-mac osx-arm64
build_and_collect vd-mac osx-x64
fi
if [[ "$TARGET" == "linux" || "$TARGET" == "all" ]]; then
push_src vd-linux
build_on vd-linux linux-x64
collect vd-linux linux-x64
build_and_collect vd-linux linux-x64
build_and_collect vd-linux linux-arm64
fi
if [[ "$TARGET" == "mac" || "$TARGET" == "all" ]]; then
push_src vd-mac
build_on vd-mac osx-arm64
collect vd-mac osx-arm64
fi
echo "완료."
echo "==> 원격 빌드 완료:"
ls -lh out/mediabrowser-*.tar.gz 2>/dev/null || true

View file

@ -169,8 +169,9 @@ jobs:
- **워크플로**: `.forgejo/workflows/release.yml` — 태그 `v*` 푸시 → test(전체 xUnit) 관문 → 빌드 잡 병렬
→ 각 잡이 draft 릴리스에 자산을 직접 업로드. host 러너에 Node 가 없어도 되도록 `uses:` 액션 없이
순수 `run:` 스텝으로만 구성(체크아웃도 git 직접).
- **업로드 헬퍼**: `build/ci/upload-release-asset.ps1`/`.sh` — draft 릴리스 확보(병렬 잡 경합 대응) +
동명 자산 교체(재실행 멱등). Windows Git Bash 의 CRLF 오염 방지 처리 포함.
- **원클릭 통합 릴리스 도구**: `build/release.ps1 -Version <ver> [-Publish] [-DeploySite] [-IncludeAndroid] [-IncludeRemote]` — 로컬 빌드(Windows 설치자/Android APK/원격 Linux/macOS)부터 NAS Git(Forgejo) 릴리스 생성, 자산 업로드, Cloudflare Pages 사이트 배포까지 한 번에 자동화.
- **업로드 헬퍼**: `build/ci/upload-release-asset.ps1`/`.sh` — draft/published 릴리스 확보(병렬 잡 경합 대응) +
동명 자산 교체(재실행 멱등), `.env` 자동 로드 및 Publish 전환 지원. Windows Git Bash 의 CRLF 오염 방지 처리 포함.
- **리허설**: workflow_dispatch → `ci-rehearsal` draft prerelease 로 태그 없이 전 파이프라인 검증.
- **시크릿**: `RELEASE_TOKEN`(Forgejo PAT, `write:repository`) — 릴리스 생성·자산 업로드. 체크아웃은 잡
자동 토큰(`github.token`). `ANDROID_KEYSTORE/ALIAS/PASS` 등록 전까지 APK 는 디버그 서명.

View file

@ -1,22 +1,53 @@
// 배포 전에 공개 릴리스 저장소의 릴리스 목록을 releases.json 으로 굽는다.
// (브라우저에서 Forgejo API 를 직접 호출하면 CORS 에 막히므로 빌드 시점에 정적 생성)
// 사용: node site/fetch-releases.mjs
import { writeFileSync } from "node:fs";
import { writeFileSync, readFileSync, existsSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const API = "https://git.chanpaca.net/api/v1/repos/Video-Downloader/releases/releases?limit=50";
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = resolve(__dirname, "..");
const envPath = resolve(root, ".env");
const res = await fetch(API, { headers: { Accept: "application/json" } });
if (!res.ok) throw new Error(`릴리스 API 실패: HTTP ${res.status}`);
const raw = await res.json();
// .env 로드 (환경변수가 없을 경우 보완)
if (existsSync(envPath)) {
const content = readFileSync(envPath, "utf8");
for (const line of content.split("\n")) {
const match = line.match(/^\s*([A-Za-z0-9_]+)\s*=\s*(.*)$/);
if (match && !process.env[match[1]]) {
process.env[match[1]] = match[2].trim();
}
}
}
const releases = raw
.filter((r) => !r.draft)
.sort((a, b) => new Date(b.published_at) - new Date(a.published_at))
const serverUrl = (process.env.FORGEJO_URL || "https://git.chanpaca.net").replace(/\/$/, "");
const repo = process.env.RELEASE_REPO || "Video-Downloader/releases";
const API = `${serverUrl}/api/v1/repos/${repo}/releases?limit=50`;
const headers = { Accept: "application/json" };
if (process.env.FORGEJO_TOKEN) {
headers.Authorization = `token ${process.env.FORGEJO_TOKEN}`;
} else if (process.env.FORGEJO_USER && process.env.FORGEJO_PASSWORD) {
const b64 = Buffer.from(`${process.env.FORGEJO_USER}:${process.env.FORGEJO_PASSWORD}`).toString("base64");
headers.Authorization = `Basic ${b64}`;
}
const includeDraft = process.env.INCLUDE_DRAFT === "true";
try {
const res = await fetch(API, { headers });
if (!res.ok) throw new Error(`릴리스 API 실패: HTTP ${res.status}`);
const raw = await res.json();
const releases = (Array.isArray(raw) ? raw : [])
.filter((r) => includeDraft || !r.draft)
.sort((a, b) => new Date(b.published_at || b.created_at) - new Date(a.published_at || a.created_at))
.map((r) => ({
tag: r.tag_name,
name: r.name || r.tag_name,
published_at: r.published_at,
published_at: r.published_at || r.created_at,
prerelease: r.prerelease,
draft: r.draft,
notes: r.body || "",
assets: (r.assets || []).map((a) => ({
name: a.name,
@ -25,11 +56,25 @@ const releases = raw
})),
}));
const out = {
const publicServerUrl = process.env.FORGEJO_PUBLIC_URL || "https://git.chanpaca.net";
const out = {
generated_at: new Date().toISOString(),
repo_url: "https://git.chanpaca.net/Video-Downloader/releases",
repo_url: `${publicServerUrl.replace(/\/$/, "")}/${repo}`,
releases,
};
};
writeFileSync(new URL("./releases.json", import.meta.url), JSON.stringify(out, null, 2));
console.log(`releases.json 생성: 릴리스 ${releases.length}건`);
writeFileSync(new URL("./releases.json", import.meta.url), JSON.stringify(out, null, 2));
console.log(`releases.json 생성 완료: 릴리스 ${releases.length}건 (저장소: ${repo})`);
} catch (err) {
console.error(`릴리스 정보 로드 실패: ${err.message}`);
// 기존 releases.json 이 없으면 빈 파일 생성
const outPath = resolve(__dirname, "releases.json");
if (!existsSync(outPath)) {
const fallback = {
generated_at: new Date().toISOString(),
repo_url: `${serverUrl}/${repo}`,
releases: [],
};
writeFileSync(outPath, JSON.stringify(fallback, null, 2));
}
}

View file

@ -7,7 +7,7 @@
<meta name="description" content="MEDIABROWSER 설치부터 첫 스트림 감지, 세그먼트 이어받기, 모바일 컴패니언 QR 연결까지 단계별 안내.">
<meta property="og:type" content="website">
<meta property="og:title" content="사용 안내 - MEDIABROWSER">
<meta property="og:description" content="설치, 첫 영상 감지, 다운로드 관리, 휴대폰 QR 연결까지 단계별로 안내합니다.">
<meta property="og:description" content="설치, 첫 영상 감지, 다운로드 관리, 휴대폰 QR 연결, 단축키까지 단계별로 안내합니다.">
<meta property="og:url" content="https://mediabrowser-7r4.pages.dev/guide.html">
<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='%233b82f6'/><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">
@ -31,6 +31,7 @@
</a>
<div class="nav__links">
<a class="nav__link" href="index.html#how-it-works">작동 방식</a>
<a class="nav__link" href="index.html#compare">비교</a>
<a class="nav__link" href="index.html#features">핵심 기능</a>
<a class="nav__link" href="index.html#mobile">모바일 연결</a>
<a class="nav__link" href="guide.html" aria-current="page">사용 안내</a>
@ -51,6 +52,7 @@
<div class="mobile-menu-overlay" id="mobile-menu" aria-hidden="true">
<div class="mobile-menu-links">
<a class="mobile-menu-link" href="index.html#how-it-works">작동 방식</a>
<a class="mobile-menu-link" href="index.html#compare">비교</a>
<a class="mobile-menu-link" href="index.html#features">핵심 기능</a>
<a class="mobile-menu-link" href="index.html#mobile">모바일 연결</a>
<a class="mobile-menu-link" href="guide.html" aria-current="page">사용 안내</a>
@ -63,7 +65,7 @@
<div class="wrap">
<div class="reveal">
<h1 style="font-size:clamp(2.4rem,4.2vw,3.5rem)">사용 안내</h1>
<p class="sub" style="margin-top:14px">설치부터 첫 영상 다운로드, 라이브러리 정리, 휴대폰 무선 연결까지. 단계별로 따라오시면 3분 안에 모든 기능을 손쉽게 활용할 수 있습니다.</p>
<p class="sub" style="margin-top:14px">설치부터 첫 영상 다운로드, 라이브러리 정리, 휴대폰 무선 연결까지 단계별로 안내합니다.</p>
</div>
</div>
</header>
@ -76,13 +78,14 @@
<a class="guide-toc-link" href="#first">2. 첫 영상 자동 감지</a>
<a class="guide-toc-link" href="#manage">3. 다운로드 및 라이브러리</a>
<a class="guide-toc-link" href="#phone">4. 휴대폰 QR 연결</a>
<a class="guide-toc-link" href="#faq">5. 자주 묻는 질문</a>
<a class="guide-toc-link" href="#shortcuts">5. 단축키 안내</a>
<a class="guide-toc-link" href="#faq">6. 자주 묻는 질문</a>
</aside>
<!-- Main Content -->
<main>
<!-- Step 1: Install -->
<section class="bezel-shell guide-step-card reveal" id="install">
<section class="bezel-shell spotlight-card guide-step-card reveal" id="install">
<div class="guide-step-head">
<div class="guide-step-num">1</div>
<h2>설치 및 환경 구성</h2>
@ -114,7 +117,7 @@
</section>
<!-- Step 2: First Download -->
<section class="bezel-shell guide-step-card reveal" id="first">
<section class="bezel-shell spotlight-card guide-step-card reveal" id="first">
<div class="guide-step-head">
<div class="guide-step-num">2</div>
<h2>첫 영상 자동 감지 &amp; 다운로드</h2>
@ -142,7 +145,7 @@
</section>
<!-- Step 3: Download Management -->
<section class="bezel-shell guide-step-card reveal" id="manage">
<section class="bezel-shell spotlight-card guide-step-card reveal" id="manage">
<div class="guide-step-head">
<div class="guide-step-num">3</div>
<h2>다운로드 관리 및 라이브러리 아카이빙</h2>
@ -170,7 +173,7 @@
</section>
<!-- Step 4: Phone Companion -->
<section class="bezel-shell guide-step-card reveal" id="phone">
<section class="bezel-shell spotlight-card guide-step-card reveal" id="phone">
<div class="guide-step-head">
<div class="guide-step-num">4</div>
<h2>휴대폰 무선 연결 및 로컬 스트리밍</h2>
@ -197,10 +200,50 @@
</div>
</section>
<!-- Step 5: FAQ -->
<section class="bezel-shell guide-step-card reveal" id="faq">
<!-- Step 5: Shortcuts Table -->
<section class="bezel-shell spotlight-card guide-step-card reveal" id="shortcuts">
<div class="guide-step-head">
<div class="guide-step-num">5</div>
<h2>주요 키보드 단축키</h2>
</div>
<p>키보드만으로 빠르고 편리하게 브라우징과 다운로드를 제어할 수 있습니다.</p>
<table class="shortcut-table">
<thead>
<tr>
<th>단축키</th>
<th>동작 설명</th>
</tr>
</thead>
<tbody>
<tr>
<td><kbd>Ctrl</kbd> + <kbd>D</kbd></td>
<td>우측 다운로드 패널 열기 / 닫기</td>
</tr>
<tr>
<td><kbd>Ctrl</kbd> + <kbd>J</kbd></td>
<td>다운로드 완료 라이브러리 아카이브 열기</td>
</tr>
<tr>
<td><kbd>Ctrl</kbd> + <kbd>T</kbd></td>
<td>새 브라우징 탭 열기</td>
</tr>
<tr>
<td><kbd>Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>P</kbd></td>
<td>전체 다운로드 일괄 일시정지 / 일괄 재개 토글</td>
</tr>
<tr>
<td><kbd>F5</kbd> 또는 <kbd>Ctrl</kbd> + <kbd>R</kbd></td>
<td>현재 브라우징 탭 새로고침</td>
</tr>
</tbody>
</table>
</section>
<!-- Step 6: FAQ -->
<section class="bezel-shell spotlight-card guide-step-card reveal" id="faq">
<div class="guide-step-head">
<div class="guide-step-num">6</div>
<h2>자주 묻는 질문 (FAQ)</h2>
</div>

View file

@ -33,6 +33,7 @@
</a>
<div class="nav__links">
<a class="nav__link" href="#how-it-works">작동 방식</a>
<a class="nav__link" href="#compare">비교</a>
<a class="nav__link" href="#features">핵심 기능</a>
<a class="nav__link" href="#mobile">모바일 연결</a>
<a class="nav__link" href="guide.html">사용 안내</a>
@ -53,6 +54,7 @@
<div class="mobile-menu-overlay" id="mobile-menu" aria-hidden="true">
<div class="mobile-menu-links">
<a class="mobile-menu-link" href="#how-it-works">작동 방식</a>
<a class="mobile-menu-link" href="#compare">비교</a>
<a class="mobile-menu-link" href="#features">핵심 기능</a>
<a class="mobile-menu-link" href="#mobile">모바일 연결</a>
<a class="mobile-menu-link" href="guide.html">사용 안내</a>
@ -82,9 +84,9 @@
</div>
</div>
<!-- Live Stream Sniffer Simulator (Machined Double-Bezel) -->
<!-- Multi-Tab Interactive Hero Workstation Simulator -->
<div class="hero__visual reveal">
<div class="bezel-shell">
<div class="bezel-shell spotlight-card">
<div class="bezel-inner simulator-window">
<div class="simulator-topbar">
<div class="window-dots">
@ -92,15 +94,17 @@
<span class="window-dot"></span>
<span class="window-dot"></span>
</div>
<div class="simulator-url-bar text-mono">
<svg class="icon" style="width:12px;height:12px"><use href="assets/icons.svg#shield-check"/></svg>
https://stream.example.com/watch?v=k94f...
<!-- Workstation Tabs Switcher -->
<div class="simulator-tabs" role="tablist" aria-label="시뮬레이터 모드">
<button class="sim-tab-btn is-active" role="tab" aria-selected="true" data-tab="tab-sniffer">1. 스트림 감지</button>
<button class="sim-tab-btn" role="tab" aria-selected="false" data-tab="tab-segments">2. 세그먼트 맵</button>
<button class="sim-tab-btn" role="tab" aria-selected="false" data-tab="tab-mobile">3. 모바일 QR</button>
</div>
<span class="badge-chip badge-chip--accent" style="font-size:0.7rem;padding:2px 8px">Sniffer Active</span>
</div>
<div class="simulator-content">
<!-- Detection Banner -->
<!-- TAB 1: Stream Sniffer (Default) -->
<div class="sim-tab-panel is-active" id="tab-sniffer" role="tabpanel">
<div class="detection-banner">
<div class="detection-info">
<div class="detection-pulse"></div>
@ -109,10 +113,9 @@
<div class="detection-specs text-mono">HLS Master Playlist · 1080p 60fps · 6,400 kbps</div>
</div>
</div>
<span class="badge-chip" style="background:rgba(59,130,246,0.2);color:#93c5fd">Cookie Passed</span>
<span class="badge-chip badge-chip--accent" style="font-size:0.72rem">Cookie Passed</span>
</div>
<!-- Active Download Simulation Card -->
<div class="active-download-card">
<div class="download-head">
<div>
@ -122,7 +125,6 @@
<span class="download-format-chip text-mono">HLS &bull; 1080p</span>
</div>
<!-- Realtime Segment Map -->
<div>
<div class="progress-track">
<div class="progress-fill" id="sim-progress" style="width:72%"></div>
@ -149,15 +151,64 @@
<div class="download-meta-row text-mono">
<span>속도: <strong style="color:var(--accent)" id="sim-speed">18.4 MB/s</strong> (16 Threads)</span>
<span>진행률: <strong style="color:var(--text-primary)" id="sim-percent">72%</strong> (1.42 GB / 1.98 GB)</span>
<span>진행률: <strong style="color:var(--text-primary)" id="sim-percent">72%</strong></span>
<span>남은 시간: <strong style="color:var(--text-primary)">31초</strong></span>
</div>
</div>
</div>
<!-- TAB 2: Segment Parallel Map -->
<div class="sim-tab-panel" id="tab-segments" role="tabpanel">
<div style="display:flex;justify-content:space-between;align-items:center;padding:4px 0">
<span style="font-weight:700;font-size:0.9rem">16-Thread 병렬 세그먼트 수신 상태</span>
<span class="badge-chip badge-chip--accent text-mono" style="font-size:0.7rem">AES-128 Active</span>
</div>
<div style="display:grid;grid-template-columns:repeat(8,1fr);gap:6px;background:var(--surface-2);padding:14px;border-radius:var(--radius-chip);border:1px solid var(--border-subtle)">
<div style="height:26px;background:var(--accent);border-radius:4px;display:grid;place-items:center;font-size:0.7rem" class="text-mono">#01</div>
<div style="height:26px;background:var(--accent);border-radius:4px;display:grid;place-items:center;font-size:0.7rem" class="text-mono">#02</div>
<div style="height:26px;background:var(--accent);border-radius:4px;display:grid;place-items:center;font-size:0.7rem" class="text-mono">#03</div>
<div style="height:26px;background:var(--accent);border-radius:4px;display:grid;place-items:center;font-size:0.7rem" class="text-mono">#04</div>
<div style="height:26px;background:var(--accent);border-radius:4px;display:grid;place-items:center;font-size:0.7rem" class="text-mono">#05</div>
<div style="height:26px;background:var(--accent);border-radius:4px;display:grid;place-items:center;font-size:0.7rem" class="text-mono">#06</div>
<div style="height:26px;background:#60a5fa;border-radius:4px;display:grid;place-items:center;font-size:0.7rem;animation:blink 1s infinite alternate" class="text-mono">#07</div>
<div style="height:26px;background:#60a5fa;border-radius:4px;display:grid;place-items:center;font-size:0.7rem;animation:blink 1s infinite alternate" class="text-mono">#08</div>
<div style="height:26px;background:var(--surface-3);border-radius:4px;display:grid;place-items:center;font-size:0.7rem;color:var(--text-muted)" class="text-mono">#09</div>
<div style="height:26px;background:var(--surface-3);border-radius:4px;display:grid;place-items:center;font-size:0.7rem;color:var(--text-muted)" class="text-mono">#10</div>
<div style="height:26px;background:var(--surface-3);border-radius:4px;display:grid;place-items:center;font-size:0.7rem;color:var(--text-muted)" class="text-mono">#11</div>
<div style="height:26px;background:var(--surface-3);border-radius:4px;display:grid;place-items:center;font-size:0.7rem;color:var(--text-muted)" class="text-mono">#12</div>
<div style="height:26px;background:var(--surface-3);border-radius:4px;display:grid;place-items:center;font-size:0.7rem;color:var(--text-muted)" class="text-mono">#13</div>
<div style="height:26px;background:var(--surface-3);border-radius:4px;display:grid;place-items:center;font-size:0.7rem;color:var(--text-muted)" class="text-mono">#14</div>
<div style="height:26px;background:var(--surface-3);border-radius:4px;display:grid;place-items:center;font-size:0.7rem;color:var(--text-muted)" class="text-mono">#15</div>
<div style="height:26px;background:var(--surface-3);border-radius:4px;display:grid;place-items:center;font-size:0.7rem;color:var(--text-muted)" class="text-mono">#16</div>
</div>
<div class="bento-spec-box text-mono" style="margin-top:4px">
<div class="spec-line"><span>이어받기 지원:</span><span style="color:var(--success)">세그먼트 체크포인트 자동 보존</span></div>
<div class="spec-line"><span>리먹서:</span><span>ffmpeg Direct Stream Copy (재인코딩 없음)</span></div>
</div>
</div>
<!-- TAB 3: Mobile QR Sync -->
<div class="sim-tab-panel" id="tab-mobile" role="tabpanel">
<div style="display:grid;grid-template-columns:100px 1fr;gap:20px;align-items:center;background:var(--surface-2);padding:18px;border-radius:var(--radius-chip);border:1px solid var(--border-subtle)">
<div style="width:100px;height:100px;background:#ffffff;border-radius:10px;padding:6px;display:grid;place-items:center">
<svg class="icon" style="width:84px;height:84px;color:#08090d"><use href="assets/icons.svg#qr-code"/></svg>
</div>
<div>
<div style="font-weight:700;font-size:0.95rem">내장 로컬 서버 온라인</div>
<div class="text-mono" style="font-size:0.8rem;color:var(--accent);margin:4px 0">http://192.168.1.100:5000</div>
<p style="font-size:0.82rem;color:var(--text-secondary)">스마트폰에서 컴패니언 앱을 열고 QR을 스캔하면 즉시 라이브러리가 연결됩니다.</p>
</div>
</div>
<div class="bento-spec-box text-mono" style="margin-top:4px">
<div class="spec-line"><span>전송 방식:</span><span>홈 Wi-Fi 로컬 다이렉트 (클라우드 업로드 0%)</span></div>
<div class="spec-line"><span>재생 지연:</span><span style="color:var(--success)">로컬 즉시 스트리밍</span></div>
</div>
</div>
<!-- Quick Specs Row -->
<div class="spec-line text-mono" style="padding:0 4px;font-size:0.75rem">
<span>내장 서버: <strong style="color:var(--success)">192.168.1.100:5000 (온라인)</strong></span>
<span>모바일 연결: <strong>QR 준비됨</strong></span>
<div class="spec-line text-mono" style="padding:10px 4px 0;font-size:0.75rem;border-top:1px solid var(--border-subtle)">
<span>단축키: <kbd>Ctrl</kbd> + <kbd>D</kbd> 다운로드 패널 열기</span>
<span>상태: <strong style="color:var(--success)">엔진 정상 대기 중</strong></span>
</div>
</div>
</div>
@ -211,7 +262,99 @@
</div>
</section>
<!-- Section 2: Core Engineering Features (Asymmetric Bento Grid) -->
<!-- Section 2: Why MEDIABROWSER (Problem vs Solution Comparison Matrix) -->
<section id="compare" class="section-pad" style="padding-top:0">
<div class="wrap">
<div class="section-head section-head--center reveal">
<h2>기존 방식의 피로감, 여기서 끝납니다</h2>
<p class="sub">불안정한 웹 변환기나 번거로운 다운로더와 비교해 보세요. MEDIABROWSER 는 그 과정 전부를 앱 하나로 끝냅니다.</p>
</div>
<div class="compare-grid">
<!-- Legacy Approach Card -->
<div class="bezel-shell spotlight-card reveal">
<div class="bezel-inner compare-card compare-card--legacy">
<div class="compare-head">
<div class="compare-item-icon"><svg class="icon" style="width:14px;height:14px"><use href="assets/icons.svg#x"/></svg></div>
<div class="compare-title">기존 웹 변환 사이트 및 확장 프로그램</div>
</div>
<ul class="compare-list">
<li class="compare-item">
<div class="compare-item-icon"><svg class="icon" style="width:12px;height:12px"><use href="assets/icons.svg#x"/></svg></div>
<div>
<strong>매번 주소를 복사하고 붙여넣는 번거로움</strong>
<p>영상을 볼 때마다 URL 을 복사하고 변환 사이트에 붙여넣어야 합니다.</p>
</div>
</li>
<li class="compare-item">
<div class="compare-item-icon"><svg class="icon" style="width:12px;height:12px"><use href="assets/icons.svg#x"/></svg></div>
<div>
<strong>로그인 세션이 풀려 다운로드 실패</strong>
<p>회원 전용 영상이나 보안 스트림은 쿠키가 전달되지 않아 에러가 발생합니다.</p>
</div>
</li>
<li class="compare-item">
<div class="compare-item-icon"><svg class="icon" style="width:12px;height:12px"><use href="assets/icons.svg#x"/></svg></div>
<div>
<strong>끊기면 0%부터 다시 시작하는 불안정함</strong>
<p>대용량 파일 수신 중 네트워크가 끊기면 처음부터 다시 받아야 합니다.</p>
</div>
</li>
<li class="compare-item">
<div class="compare-item-icon"><svg class="icon" style="width:12px;height:12px"><use href="assets/icons.svg#x"/></svg></div>
<div>
<strong>무수한 악성 팝업 광고와 개인정보 유출 위험</strong>
<p>의심스러운 외부 서버로 내 데이터와 링크가 전송됩니다.</p>
</div>
</li>
</ul>
</div>
</div>
<!-- MEDIABROWSER Approach Card -->
<div class="bezel-shell spotlight-card reveal">
<div class="bezel-inner compare-card compare-card--modern">
<div class="compare-head">
<div class="compare-item-icon"><svg class="icon" style="width:14px;height:14px"><use href="assets/icons.svg#check-circle"/></svg></div>
<div class="compare-title" style="color:var(--accent)">MEDIABROWSER</div>
</div>
<ul class="compare-list">
<li class="compare-item">
<div class="compare-item-icon"><svg class="icon" style="width:12px;height:12px"><use href="assets/icons.svg#check-circle"/></svg></div>
<div>
<strong>주소 복사 없이 재생 즉시 자동 감지</strong>
<p>웹 서핑 중 비디오가 재생되면 스트림 정보를 가로채 바로 다운로드 목록에 올립니다.</p>
</div>
</li>
<li class="compare-item">
<div class="compare-item-icon"><svg class="icon" style="width:12px;height:12px"><use href="assets/icons.svg#check-circle"/></svg></div>
<div>
<strong>탭의 쿠키와 Referer 헤더 자동 승계</strong>
<p>내가 보고 있던 로그인 계정 상태 그대로 보안 스트림도 문제없이 수신합니다.</p>
</div>
</li>
<li class="compare-item">
<div class="compare-item-icon"><svg class="icon" style="width:12px;height:12px"><use href="assets/icons.svg#check-circle"/></svg></div>
<div>
<strong>1바이트부터 이어받는 세그먼트 병렬 엔진</strong>
<p>수백 개의 청크 단위로 진행도를 기록하며 끊겨도 멈춘 지점부터 즉시 이어받습니다.</p>
</div>
</li>
<li class="compare-item">
<div class="compare-item-icon"><svg class="icon" style="width:12px;height:12px"><use href="assets/icons.svg#check-circle"/></svg></div>
<div>
<strong>광고 없는 로컬 앱 &amp; 홈 Wi-Fi 모바일 스트리밍</strong>
<p>외부 서버를 거치지 않고 내 PC와 휴대폰 간에 직접 안전하게 전송합니다.</p>
</div>
</li>
</ul>
</div>
</div>
</div>
</div>
</section>
<!-- Section 3: Core Engineering Features (Asymmetric Bento Grid) -->
<section id="features" class="section-pad" style="padding-top:0">
<div class="wrap">
<div class="section-head reveal">
@ -221,7 +364,7 @@
<div class="bento-grid">
<!-- Bento Cell 1: Cookie & Session Passthrough (Span 7) -->
<div class="bezel-shell bento-cell--span-7 reveal">
<div class="bezel-shell bento-cell--span-7 spotlight-card reveal">
<div class="bezel-inner bento-cell">
<div>
<div class="bento-icon-wrapper">
@ -243,7 +386,7 @@
</div>
<!-- Bento Cell 2: Resilient Segment Engine (Span 5) -->
<div class="bezel-shell bento-cell--span-5 reveal">
<div class="bezel-shell bento-cell--span-5 spotlight-card reveal">
<div class="bezel-inner bento-cell">
<div>
<div class="bento-icon-wrapper">
@ -261,7 +404,7 @@
</div>
<!-- Bento Cell 3: Smart Duplicate Detector & Scheduling (Span 5) -->
<div class="bezel-shell bento-cell--span-5 reveal">
<div class="bezel-shell bento-cell--span-5 spotlight-card reveal">
<div class="bezel-inner bento-cell">
<div>
<div class="bento-icon-wrapper">
@ -279,7 +422,7 @@
</div>
<!-- Bento Cell 4: Realtime Telemetry & ffmpeg Remux (Span 7) -->
<div class="bezel-shell bento-cell--span-7 reveal">
<div class="bezel-shell bento-cell--span-7 spotlight-card reveal">
<div class="bezel-inner bento-cell">
<div>
<div class="bento-icon-wrapper">
@ -294,7 +437,7 @@
</div>
</div>
<div class="bento-spec-box text-mono">
<div class="spec-line"><span>처리 방식:</span><span>재인코딩 없는 스트림 복사 (화질 열화 없음)</span></div>
<div class="spec-line"><span>리먹싱 속도:</span><span>평균 1~3초 이내 완료 (무손실 스트림 복사)</span></div>
<div class="spec-line"><span>지원 포맷:</span><span>MP4, MKV, M4A 오디오 추출 지원</span></div>
</div>
</div>
@ -303,12 +446,12 @@
</div>
</section>
<!-- Section 3: Mobile Companion Ecosystem -->
<!-- Section 4: Mobile Companion Ecosystem -->
<section id="mobile" class="section-pad" style="background:linear-gradient(180deg, transparent, rgba(13, 16, 23, 0.7) 40%, transparent)">
<div class="wrap mobile-showcase">
<div class="reveal">
<h2 style="font-size:clamp(2rem, 3.6vw, 2.75rem)">내 PC 의 라이브러리,<br>휴대폰에서 그대로 이어보기</h2>
<p class="sub" style="margin-top:16px">외부 클라우드로 비디오를 업로드할 필요가 없습니다. 데스크톱 앱 내장 서버가 홈 Wi-Fi 안에서 스마트폰과 바로 연결됩니다.</p>
<h2 style="font-size:clamp(2rem, 3.6vw, 2.75rem);margin-top:12px">내 PC 의 라이브러리,<br>휴대폰에서 그대로 이어보기</h2>
<p class="sub" style="margin-top:16px">외부 클라우드로 비디오를 업로드할 필요가 없습니다. 데스크톱 앱 내장 서버가 홈 Wi-Fi 안에서 스마트폰과 바로 연결됩니다. 스트리밍과 전송 모두 내 네트워크 안에서 끝납니다.</p>
<div style="margin-top:32px;display:flex;gap:14px;flex-wrap:wrap">
<a class="btn btn--primary" href="guide.html#phone">
@ -319,7 +462,7 @@
</div>
<div class="mobile-cards-stage reveal">
<div class="bezel-shell">
<div class="bezel-shell spotlight-card">
<div class="bezel-inner feature-stack-card">
<div class="feature-stack-icon">
<svg class="icon" aria-hidden="true"><use href="assets/icons.svg#qr-code"/></svg>
@ -331,7 +474,7 @@
</div>
</div>
<div class="bezel-shell">
<div class="bezel-shell spotlight-card">
<div class="bezel-inner feature-stack-card">
<div class="feature-stack-icon">
<svg class="icon" aria-hidden="true"><use href="assets/icons.svg#device-mobile"/></svg>
@ -343,7 +486,7 @@
</div>
</div>
<div class="bezel-shell">
<div class="bezel-shell spotlight-card">
<div class="bezel-inner feature-stack-card">
<div class="feature-stack-icon">
<svg class="icon" aria-hidden="true"><use href="assets/icons.svg#download-simple"/></svg>
@ -358,7 +501,7 @@
</div>
</section>
<!-- Section 4: Cross-Platform Matrix -->
<!-- Section 5: Cross-Platform Matrix -->
<section class="section-pad">
<div class="wrap">
<div class="section-head section-head--center reveal">
@ -367,7 +510,7 @@
</div>
<div class="platform-matrix">
<div class="bezel-shell reveal">
<div class="bezel-shell spotlight-card reveal">
<div class="bezel-inner platform-card">
<div class="platform-icon">
<svg class="icon" aria-hidden="true"><use href="assets/icons.svg#windows-logo"/></svg>
@ -378,7 +521,7 @@
</div>
</div>
<div class="bezel-shell reveal">
<div class="bezel-shell spotlight-card reveal">
<div class="bezel-inner platform-card">
<div class="platform-icon">
<svg class="icon" aria-hidden="true"><use href="assets/icons.svg#apple-logo"/></svg>
@ -389,7 +532,7 @@
</div>
</div>
<div class="bezel-shell reveal">
<div class="bezel-shell spotlight-card reveal">
<div class="bezel-inner platform-card">
<div class="platform-icon">
<svg class="icon" aria-hidden="true"><use href="assets/icons.svg#linux-logo"/></svg>
@ -400,7 +543,7 @@
</div>
</div>
<div class="bezel-shell reveal">
<div class="bezel-shell spotlight-card reveal">
<div class="bezel-inner platform-card">
<div class="platform-icon">
<svg class="icon" aria-hidden="true"><use href="assets/icons.svg#android-logo"/></svg>
@ -414,20 +557,20 @@
</div>
</section>
<!-- Section 5: Bottom CTA -->
<!-- Section 6: Bottom CTA Banner -->
<section class="section-pad" style="padding-top:0">
<div class="wrap">
<div class="bezel-shell reveal">
<div class="bezel-shell spotlight-card reveal">
<div class="bezel-inner cta-banner">
<h2>보던 영상 그대로,<br>지금 내 것으로 만들어보세요.</h2>
<p class="sub">별도의 설정 없이 1분 만에 설치하고 웹 서핑을 시작하세요. 필요한 도구는 앱이 알아서 관리합니다.</p>
<div style="display:flex;gap:14px;flex-wrap:wrap;justify-content:center">
<a class="btn btn--primary" href="releases.html">
<span>다운로드</span>
<span>다운로드 페이지 이동</span>
<span class="btn__icon-badge"><svg class="icon" aria-hidden="true"><use href="assets/icons.svg#arrow-up-right"/></svg></span>
</a>
<a class="btn btn--ghost" href="guide.html">
<span>사용 안내</span>
<span>사용 안내 보기</span>
<svg class="icon" aria-hidden="true"><use href="assets/icons.svg#arrow-right"/></svg>
</a>
</div>

View file

@ -1,8 +1,9 @@
// MEDIABROWSER Official Site Script
// 1) IntersectionObserver Scroll Reveal
// 2) Mobile Navigation Menu Controller
// 3) Hero UI Simulator Micro-Interactions
// 4) Dynamic Releases Renderer (releases.json)
// 3) Interactive Hero Workstation Simulator (Multi-Tab Switcher & Live Telemetry)
// 4) Spotlight Card Cursor Light Tracker
// 5) Dynamic Releases Renderer (releases.json)
// ── 1. Scroll Reveal Controller ──────────────────────────────
(function initReveal() {
@ -47,8 +48,29 @@
});
})();
// ── 3. Hero UI Simulator Micro-Animation ─────────────────────
(function initHeroSimulator() {
// ── 3. Interactive Hero Workstation Simulator ────────────────
(function initHeroWorkstation() {
// Tab switcher
const tabBtns = document.querySelectorAll(".sim-tab-btn");
const tabPanels = document.querySelectorAll(".sim-tab-panel");
tabBtns.forEach((btn) => {
btn.addEventListener("click", () => {
const targetId = btn.getAttribute("data-tab");
tabBtns.forEach((b) => {
b.classList.remove("is-active");
b.setAttribute("aria-selected", "false");
});
tabPanels.forEach((p) => p.classList.remove("is-active"));
btn.classList.add("is-active");
btn.setAttribute("aria-selected", "true");
const targetPanel = document.getElementById(targetId);
if (targetPanel) targetPanel.classList.add("is-active");
});
});
// Live Progress & Telemetry simulation
const progressEl = document.getElementById("sim-progress");
const percentEl = document.getElementById("sim-percent");
const speedEl = document.getElementById("sim-speed");
@ -77,7 +99,24 @@
}, 1200);
})();
// ── 4. Releases Renderer ─────────────────────────────────────
// ── 4. Spotlight Card Cursor Light Tracker ───────────────────
(function initSpotlight() {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const cards = document.querySelectorAll(".spotlight-card");
if (!cards.length) return;
cards.forEach((card) => {
card.addEventListener("mousemove", (e) => {
const rect = card.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
card.style.setProperty("--mouse-x", `${x}px`);
card.style.setProperty("--mouse-y", `${y}px`);
});
});
})();
// ── 5. Releases Renderer ─────────────────────────────────────
const PLATFORMS = [
{ id: "windows", label: "Windows", icon: "windows-logo", sub: "x64 및 ARM64 네이티브 설치자 (exe)",
match: (n) => /setup.*\.exe$/i.test(n) || /win/i.test(n) },
@ -165,9 +204,9 @@ function releaseCard(r, featured) {
${groupsHtml(r.assets)}`;
if (featured) {
return `<article class="bezel-shell"><div class="bezel-inner rel-featured">${meta}</div></article>`;
return `<article class="bezel-shell spotlight-card"><div class="bezel-inner rel-featured">${meta}</div></article>`;
}
return `<div class="bezel-shell"><div class="bezel-inner"><details>
return `<div class="bezel-shell spotlight-card"><div class="bezel-inner"><details>
<summary>
<span class="rel-featured__tag" style="font-size:1.15rem">${esc(r.name || r.tag)}</span>
${pill}

View file

@ -1,6 +1,6 @@
/* MEDIABROWSER Design System
/* MEDIABROWSER Design System — Advanced Studio Edition
High-End Obsidian Dark Theme · Machined Double-Bezel Architecture
Restrained Fluid Motion · Responsive Layout Mechanics
Spotlight Card Borders · Multi-Tab Workstation · Responsive Mechanics
Typography: Wanted Sans Variable + System Fallbacks */
:root {
@ -10,13 +10,13 @@
--surface-1: #121622;
--surface-2: #181e2e;
--surface-3: #20273c;
--surface-glass: rgba(18, 22, 34, 0.72);
--surface-glass-strong: rgba(18, 22, 34, 0.88);
--surface-glass: rgba(18, 22, 34, 0.75);
--surface-glass-strong: rgba(18, 22, 34, 0.92);
/* Borders & Hairlines */
--border-subtle: rgba(255, 255, 255, 0.07);
--border-medium: rgba(255, 255, 255, 0.12);
--border-highlight: rgba(255, 255, 255, 0.18);
--border-highlight: rgba(255, 255, 255, 0.22);
/* Typography Colors */
--text-primary: #f8fafc;
@ -24,7 +24,7 @@
--text-muted: #64748b;
--text-inverse: #08090d;
/* Single Vibrant Accent */
/* Single Vibrant Accent & Glows */
--accent: #3b82f6;
--accent-hover: #2563eb;
--accent-soft: rgba(59, 130, 246, 0.12);
@ -36,6 +36,8 @@
--success-soft: rgba(16, 185, 129, 0.14);
--warning: #f59e0b;
--warning-soft: rgba(245, 158, 11, 0.14);
--danger: #ef4444;
--danger-soft: rgba(239, 68, 68, 0.14);
/* Shadows */
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.35);
@ -52,7 +54,7 @@
/* Layout */
--nav-h: 68px;
--container-max: 1160px;
--container-max: 1180px;
}
/* ── Reset & Base ────────────────────────────────────────────── */
@ -120,6 +122,24 @@ p {
font-variant-numeric: tabular-nums;
}
/* Keyboard Shortcut Tag */
kbd {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 3px 7px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.75rem;
font-weight: 700;
color: var(--text-primary);
background: linear-gradient(180deg, var(--surface-2), var(--surface-1));
border: 1px solid var(--border-medium);
border-bottom: 2px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
line-height: 1;
}
/* ── Icons ───────────────────────────────────────────────────── */
.icon {
width: 1.25em;
@ -130,7 +150,7 @@ p {
vertical-align: -0.2em;
}
/* ── Ambient Glow Background ─────────────────────────────────── */
/* ── Ambient Background ──────────────────────────────────────── */
.ambient-lighting {
position: fixed;
inset: 0;
@ -141,14 +161,14 @@ p {
.ambient-spotlight {
position: absolute;
top: -240px;
top: -260px;
left: 50%;
transform: translateX(-50%);
width: 1000px;
height: 650px;
background: radial-gradient(ellipse 60% 50% at 50% 30%, rgba(59, 130, 246, 0.16) 0%, rgba(37, 99, 235, 0.05) 50%, transparent 80%);
filter: blur(60px);
opacity: 0.9;
width: 1100px;
height: 700px;
background: radial-gradient(ellipse 60% 50% at 50% 30%, rgba(59, 130, 246, 0.18) 0%, rgba(37, 99, 235, 0.05) 50%, transparent 80%);
filter: blur(64px);
opacity: 0.95;
}
.ambient-grid {
@ -157,9 +177,9 @@ p {
background-image: linear-gradient(to right, rgba(255, 255, 255, 0.02) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255, 255, 255, 0.02) 1px, transparent 1px);
background-size: 56px 56px;
mask-image: radial-gradient(ellipse 70% 50% at 50% 25%, #000 20%, transparent 80%);
-webkit-mask-image: radial-gradient(ellipse 70% 50% at 50% 25%, #000 20%, transparent 80%);
opacity: 0.6;
mask-image: radial-gradient(ellipse 75% 55% at 50% 25%, #000 20%, transparent 80%);
-webkit-mask-image: radial-gradient(ellipse 75% 55% at 50% 25%, #000 20%, transparent 80%);
opacity: 0.65;
}
/* ── Container Layout ────────────────────────────────────────── */
@ -176,7 +196,7 @@ section {
}
.section-pad {
padding: 120px 0;
padding: 128px 0;
}
.section-head {
@ -186,7 +206,7 @@ section {
.section-head--center {
text-align: center;
margin-inline: auto;
max-width: 680px;
max-width: 720px;
}
.section-head h2 {
@ -232,6 +252,7 @@ section {
border-radius: var(--radius-bezel-outer);
box-shadow: var(--shadow-md);
transition: border-color 0.3s cubic-bezier(0.16, 1, 0.3, 1), transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
position: relative;
}
.bezel-inner {
@ -246,6 +267,28 @@ section {
border-color: var(--border-highlight);
}
/* Spotlight Card (Cursor Responsive Glow) */
.spotlight-card {
position: relative;
overflow: hidden;
}
.spotlight-card::before {
content: "";
position: absolute;
inset: 0;
background: radial-gradient(400px circle at var(--mouse-x, 50%) var(--mouse-y, 50%), rgba(59, 130, 246, 0.15), transparent 80%);
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
z-index: 1;
border-radius: inherit;
}
.spotlight-card:hover::before {
opacity: 1;
}
/* ── Glassmorphism ───────────────────────────────────────────── */
.glass {
background: var(--surface-glass);
@ -370,7 +413,7 @@ section {
.mobile-menu-overlay {
position: fixed;
inset: 0;
background: rgba(8, 9, 13, 0.85);
background: rgba(8, 9, 13, 0.88);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
z-index: 90;
@ -480,20 +523,6 @@ section {
height: 24px;
}
.btn--secondary-action {
padding: 8px 16px;
border-radius: var(--radius-pill);
font-size: 0.85rem;
background: var(--surface-2);
border: 1px solid var(--border-subtle);
color: var(--text-secondary);
}
.btn--secondary-action:hover {
color: var(--text-primary);
border-color: var(--border-medium);
}
/* ── Hero Section ────────────────────────────────────────────── */
.hero {
padding-top: calc(var(--nav-h) + 72px);
@ -510,8 +539,8 @@ section {
align-items: center;
}
/* 그리드 자식의 min-width:auto 가 긴 mono 문자열(시뮬레이터 URL 등)로
열 최소폭을 밀어내 모바일 가로 오버플로를 만든다 — 축소 허용 */
/* 그리드 자식의 min-width:auto 가 긴 mono 문자열로 열 최소폭을 밀어내
모바일 가로 오버플로를 만든다 — 축소 허용 */
.hero__grid > * { min-width: 0; }
.hero__copy {
@ -546,6 +575,7 @@ section {
margin-top: 14px;
padding-top: 24px;
border-top: 1px solid var(--border-subtle);
flex-wrap: wrap;
}
.hero__trust-item {
@ -561,11 +591,7 @@ section {
color: var(--accent);
}
/* ── Hero Interactive Stream Sniffer Simulator ────────────────── */
.stream-simulator {
position: relative;
}
/* ── Multi-Tab Interactive Hero Workstation ──────────────────── */
.simulator-window {
background: var(--surface-1);
border-radius: var(--radius-bezel-inner);
@ -581,6 +607,7 @@ section {
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--border-subtle);
gap: 12px;
}
.window-dots {
@ -599,29 +626,60 @@ section {
.window-dot:nth-child(2) { background: #f59e0b; opacity: 0.8; }
.window-dot:nth-child(3) { background: #10b981; opacity: 0.8; }
.simulator-url-bar {
background: var(--surface-2);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-pill);
padding: 4px 14px;
font-size: 0.78rem;
color: var(--text-secondary);
.simulator-tabs {
display: flex;
align-items: center;
gap: 6px;
width: 60%;
overflow: hidden;
text-overflow: ellipsis;
gap: 4px;
background: var(--surface-2);
padding: 3px;
border-radius: var(--radius-pill);
border: 1px solid var(--border-subtle);
}
.sim-tab-btn {
background: transparent;
border: 0;
color: var(--text-muted);
font-size: 0.75rem;
font-weight: 700;
padding: 4px 12px;
border-radius: var(--radius-pill);
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
}
.sim-tab-btn:hover {
color: var(--text-primary);
}
.sim-tab-btn.is-active {
background: var(--accent);
color: #ffffff;
box-shadow: 0 2px 8px var(--accent-glow);
}
.simulator-content {
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
min-height: 290px;
}
.sim-tab-panel {
display: none;
flex-direction: column;
gap: 16px;
animation: fadeIn 0.3s ease;
}
.sim-tab-panel.is-active {
display: flex;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
/* Detection Banner */
.detection-banner {
background: var(--accent-soft);
border: 1px solid var(--accent-border);
@ -714,7 +772,7 @@ section {
.progress-fill {
height: 100%;
width: 68%;
width: 72%;
background: linear-gradient(90deg, var(--accent), #60a5fa);
border-radius: var(--radius-pill);
transition: width 0.4s ease;
@ -763,6 +821,90 @@ section {
color: var(--text-secondary);
}
/* ── Section: Comparison Matrix (Problem vs Solution) ────────── */
.compare-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
margin-top: 48px;
}
.compare-card {
padding: 36px 32px;
display: flex;
flex-direction: column;
gap: 24px;
}
.compare-card--legacy {
border-color: rgba(239, 68, 68, 0.2);
background: linear-gradient(180deg, rgba(239, 68, 68, 0.03), transparent);
}
.compare-card--modern {
border-color: rgba(59, 130, 246, 0.35);
background: linear-gradient(180deg, rgba(59, 130, 246, 0.06), transparent);
box-shadow: 0 0 50px -15px var(--accent-glow);
}
.compare-head {
display: flex;
align-items: center;
gap: 12px;
padding-bottom: 18px;
border-bottom: 1px solid var(--border-subtle);
}
.compare-title {
font-size: 1.25rem;
font-weight: 800;
}
.compare-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 18px;
}
.compare-item {
display: flex;
align-items: flex-start;
gap: 14px;
font-size: 0.95rem;
}
.compare-item-icon {
width: 22px;
height: 22px;
border-radius: 50%;
display: grid;
place-items: center;
flex: none;
margin-top: 2px;
}
.compare-card--legacy .compare-item-icon {
background: var(--danger-soft);
color: var(--danger);
}
.compare-card--modern .compare-item-icon {
background: var(--success-soft);
color: var(--success);
}
.compare-item strong {
display: block;
color: var(--text-primary);
margin-bottom: 2px;
}
.compare-item p {
font-size: 0.88rem;
color: var(--text-secondary);
}
/* ── Section: 3-Step Flow Pipeline ───────────────────────────── */
/* 3단계 파이프라인: 분리 카드가 아니라 하나의 머신드 레일(헤어라인 분할) */
.pipeline-rail {
@ -796,16 +938,6 @@ section {
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.pipeline-card h3 {
font-size: 1.25rem;
margin-top: 4px;
}
.pipeline-card p {
font-size: 0.95rem;
line-height: 1.6;
}
.pipeline-step-badge .icon {
width: 18px;
height: 18px;
@ -842,6 +974,27 @@ summary:focus-visible {
border-radius: 6px;
}
.pipeline-card h3 {
font-size: 1.25rem;
margin-top: 4px;
}
.pipeline-card p {
font-size: 0.95rem;
line-height: 1.6;
}
.pipeline-card__visual {
margin-top: auto;
padding-top: 14px;
border-top: 1px solid var(--border-subtle);
font-size: 0.82rem;
color: var(--text-muted);
display: flex;
align-items: center;
gap: 8px;
}
/* ── Section: Asymmetric Feature Bento ───────────────────────── */
.bento-grid {
display: grid;
@ -866,18 +1019,6 @@ summary:focus-visible {
grid-column: span 5;
}
.bento-cell--span-4 {
grid-column: span 4;
}
.bento-cell--span-8 {
grid-column: span 8;
}
.bento-cell--span-12 {
grid-column: span 12;
}
.bento-icon-wrapper {
width: 48px;
height: 48px;
@ -929,7 +1070,6 @@ summary:focus-visible {
color: var(--accent);
}
/* Feature Spec Mock in Bento */
.bento-spec-box {
margin-top: 24px;
background: var(--bg-subtle);
@ -1268,6 +1408,32 @@ footer {
color: var(--text-primary);
}
/* Shortcuts Table */
.shortcut-table {
width: 100%;
border-collapse: collapse;
margin-top: 14px;
font-size: 0.92rem;
}
.shortcut-table th, .shortcut-table td {
padding: 12px 14px;
border-bottom: 1px solid var(--border-subtle);
text-align: left;
}
.shortcut-table th {
color: var(--text-muted);
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.06em;
font-weight: 700;
}
.shortcut-table td:first-child {
width: 35%;
}
/* FAQ Accordion */
.faq-stack {
display: flex;
@ -1562,7 +1728,7 @@ html.js .reveal.in {
grid-template-columns: 1fr;
gap: 48px;
}
.bento-cell--span-7, .bento-cell--span-5, .bento-cell--span-4, .bento-cell--span-8 {
.bento-cell--span-7, .bento-cell--span-5 {
grid-column: span 12;
}
.pipeline-rail {
@ -1572,6 +1738,10 @@ html.js .reveal.in {
border-left: 0;
border-top: 1px solid var(--border-subtle);
}
.compare-grid {
grid-template-columns: 1fr;
gap: 24px;
}
.mobile-showcase {
grid-template-columns: 1fr;
gap: 40px;
@ -1624,4 +1794,16 @@ html.js .reveal.in {
.btn {
width: 100%;
}
.simulator-topbar {
flex-direction: column;
align-items: flex-start;
}
.simulator-tabs {
width: 100%;
justify-content: space-between;
}
.sim-tab-btn {
padding: 4px 8px;
font-size: 0.7rem;
}
}