diff --git a/README.md b/README.md
index a4ad3b6bb7..fc28cecca4 100644
--- a/README.md
+++ b/README.md
@@ -73,45 +73,31 @@ Tzudong Map turns mukbang video evidence into a usable restaurant map: users dis
-## Local architecture
+## Architecture
-System map: [interactive system architecture](docs/architecture/tzudong-system-architecture.html) · [Archify source](docs/architecture/tzudong-system-architecture.archify.json)
+Public map, admin, and the evidence pipeline meet in Supabase. Long work stays off the web request.
-Worker admission flow: [interactive worker boosting flow](docs/architecture/tzudong-worker-boosting.workflow.html) · [Archify source](docs/architecture/tzudong-worker-boosting.workflow.archify.json)
+```mermaid
+flowchart LR
+ web["apps/web"] --> db[("Supabase")]
+ ops["CI / cron"] --> control["pipeline-api"]
+ control --> worker["pipeline-worker"]
+ worker --> crawl["crawler / evaluation"]
+ crawl --> db
+ crawl --> ext["External providers"]
+ worker --> db
+```
-Interactive workflow: [storyboard-local-mlx.html](docs/architecture/storyboard-local-mlx/storyboard-local-mlx.html)
+Open a diagram to pan, zoom, and follow a path. Sources sit next to each HTML file.
-Workflow source: [storyboard-local-mlx.workflow.json](docs/architecture/storyboard-local-mlx/storyboard-local-mlx.workflow.json)
-
-Interactive lifecycle: [storyboard-local-mlx.lifecycle.html](docs/architecture/storyboard-local-mlx/storyboard-local-mlx.lifecycle.html)
-
-Lifecycle source: [storyboard-local-mlx.lifecycle.json](docs/architecture/storyboard-local-mlx/storyboard-local-mlx.lifecycle.json)
-
-Interactive map discovery: [map-discovery.html](docs/architecture/map-discovery/map-discovery.html)
-
-Live browser captures: [workflow](docs/operations/evidence/storyboard-restore-20260920/archify-storyboard-workflow-desktop.png) · [lifecycle](docs/operations/evidence/storyboard-restore-20260920/archify-storyboard-lifecycle-desktop.png) · [map discovery](docs/operations/evidence/storyboard-restore-20260920/archify-map-discovery-desktop.png)
-
-Map discovery source: [map-discovery.workflow.json](docs/architecture/map-discovery/map-discovery.workflow.json)
-
-Evidence pipeline: [interactive data pipeline](docs/architecture/data-pipeline/tzudong-data-pipeline.html) · [dataflow source](docs/architecture/data-pipeline/tzudong-data-pipeline.dataflow.json)
-
-Worker flow: [interactive worker boosting flow](docs/architecture/data-pipeline/worker-boosting.html) · [workflow source](docs/architecture/data-pipeline/worker-boosting.workflow.json)
-
-The worker keeps image concurrency at `c=1`. A future bounded boost must pass memory admission and observed queue/run/RSS evidence before changing that cap; dynamic concurrency increase is not claimed as implemented. Queue claim and lease ownership, heartbeat freshness, revision checks, conditional save/readback, and late-write rejection define the persistence boundary. Operator observations are bounded and non-authoritative, with secrets and raw payloads excluded.
-
-The local workspace separates **new project creation**, **scene editing**, **version history**, and **manual result import**. Saved projects open directly on the scene editor; connection diagnostics are under Settings. Text and image providers remain independent: local MLX, ChatGPT manual import, and Grok manual import are selectable with external AI off. OpenAI/xAI official APIs stay gated and currently unconfigured. There is no automatic cloud fallback.
-
-Storyboard provider policy keeps `local-mlx`, `chatgpt-manual`, and `grok-manual` independently selectable for text and image without requiring an official external API; the manual choices return through user import. The `externalAI` consent gate applies only to the official API provider IDs `openai-api` and `xai-api`.
-
-Historical restoration now uses immutable DB snapshots. Version preview and a confirmed whole-project or single-scene restore preserve the historical text, scene order, asset references, provenance and original image bytes. Restoration writes a new revision without calling a model or queueing a job; current provider settings and consent are retained. Applied projects start history from the state captured by the migration, not from invented earlier versions. Owner/admin checks, current revision, busy-project rejection and idempotent request IDs protect the operation.
-
-The memory admission model is implemented in `apps/web/lib/admin/storyboard/resource-invariants.ts`: `used + additional_peak_estimate + reserve <= physical`, with initial reserve `max(16 GiB, 12.5% of physical RAM)`. The worker now uses host-wide `os.freemem()` by default and only adds model residency in the explicit RSS fallback; measurements and peak estimates are not absolute guarantees. The [runtime audit on 2026-09-21](docs/operations/evidence/storyboard-restore-20260920/runtime-audit-20260921.md) records the remaining stress-test and peak-inference limits. The queue-wait helper is currently unit-tested but has no runtime caller. Image concurrency starts at one. Queue wait follows `W[i+1] = max(0, W[i] + S[i] - A[i])`; absent a live worker, estimated wait is unknown. Atomic claim, DB-clock leases, heartbeat, current-job ownership and project/scene revisions reject stale writes. Cancellation forbids late writes; retry is explicit. Original assets are immutable. The Archify viewer's fixed controls fall back to English for Korean-authored diagrams.
-
-The [2026-09-21 architecture and goal audit](docs/operations/evidence/storyboard-restore-20260920/architecture-goal-audit-20260921.md) joins the MLX, queue/lease, FSM, memory, map/filter, and hosted-feed readback evidence without treating hosted writes or deployment as complete.
-
-**Local evidence (2026-09-20):** real-model project v12 → edit v13 → scene restore v14 → full undo v15 → full restore v16. All 20 exported PNG/WebP files matched their original hashes and decoded successfully; these three restores created zero jobs. See the [verification report and responsive screenshots](docs/operations/evidence/storyboard-restore-20260920/README.md). Local migration and UI verification do not establish hosted migration, external Web review or production deployment.
+| Diagram | Open |
+| --- | --- |
+| System map | [tzudong-system-architecture.html](docs/architecture/tzudong-system-architecture.html) |
+| Map discovery | [map-discovery.html](docs/architecture/map-discovery/map-discovery.html) |
+| Evidence pipeline | [tzudong-data-pipeline.html](docs/architecture/data-pipeline/tzudong-data-pipeline.html) |
+| Storyboard workflow | [storyboard-local-mlx.html](docs/architecture/storyboard-local-mlx/storyboard-local-mlx.html) |
-**Map evidence (2026-09-20, updated):** Seoul cluster expand kept individual markers and opened the restaurant list (66 places after the hot-view filter). YouTube play control stayed centered on 16:9 `sddefault`. GPS remains fail-closed with `DEVICE_LOCATION_OPERATOR_EVIDENCE_REQUIRED`. In the explicit hosted development mode, local `/feed` was rechecked on 2026-09-21 against the hosted Supabase project: the public feed rendered two verified reviews for 데일리픽스 and 스시린 under `쯔동마스터`, with no Nightly identity. The default local command remains isolated from hosted data. See [hosted readback and limitations](docs/operations/evidence/storyboard-restore-20260920/feed-local-readback-20260921.md). Theme chips live-rechecked: hot-view 125 (Seoul cluster 65 → list 66), comment-hot 125 (Seoul 65), fan-signal 63 (Seoul 32), recent-video 32 (Seoul 17), repeat 20 (Seoul cluster 12 → list 13), using hosted-read YouTube KPI snapshots copied locally (not invented). Public overlay/map panel elevation is `shadow-sm`; dropdown menus keep their `shadow-2xl` contract. Mobile `/feed` hides the write FAB while login or detail sheets are open. Desktop/390/320 screenshots: [public UI evidence](docs/operations/evidence/storyboard-restore-20260920/). Hosted deployment and protected promotion are still separate.
+Promotion is `develop -> data -> main`. A production build runs only when `TZUDONG_APPROVED_PRODUCTION_SHA` matches that commit. Detail lives in [docs/README.md](docs/README.md).
### Measured query and normalization optimizations (2026-09-21)
diff --git a/apps/web/.env.example b/apps/web/.env.example
index 6c3716e7c4..8006a22d82 100644
--- a/apps/web/.env.example
+++ b/apps/web/.env.example
@@ -121,18 +121,6 @@ THUMBNAIL_AGENT_ROOT=../../backend/thumbnail-agent
THUMBNAIL_AGENT_PYTHON=python3
THUMBNAIL_AGENT_RUNTIME=local_graph
THUMBNAIL_AGENT_TIMEOUT_MS=120000
-# Device-location release evidence (server-only; default is blocked).
-# Exact evidence references are operator inputs, not a legal-compliance flag.
-DEVICE_LOCATION_RELEASE_DECISION=
-# Allowed only: not_applicable_verified | filing_receipt_verified
-DEVICE_LOCATION_EXTERNAL_STATUS=
-# Each evidence reference is a SHA-256 digest (64 lowercase hex characters).
-DEVICE_LOCATION_OPERATOR_EVIDENCE_HASH=
-DEVICE_LOCATION_PROVIDER_EVIDENCE_HASH=
-DEVICE_LOCATION_EXTERNAL_EVIDENCE_HASH=
-DEVICE_LOCATION_LEGAL_EVIDENCE_HASH=
-# Canonical UTC ISO timestamp, for example 2026-07-12T00:00:00.000Z
-DEVICE_LOCATION_RELEASE_CONFIRMED_AT=
# Keyed minimization for request IP/user-agent audit references (server-only, >=32 bytes).
PRIVACY_AUDIT_HASH_KEY=
# Durable account-deletion worker: independent server-only capability (minimum 32 UTF-8 bytes).
diff --git a/apps/web/app/admin/banners/page.tsx b/apps/web/app/admin/banners/page.tsx
index f4f5d1ca57..6eee4c54eb 100644
--- a/apps/web/app/admin/banners/page.tsx
+++ b/apps/web/app/admin/banners/page.tsx
@@ -1,6 +1,7 @@
"use client";
-import { useState, useRef, useMemo, Suspense, useEffect } from 'react';
+import { useState, useRef, useMemo, Suspense, useEffect, useLayoutEffect } from 'react';
+import { useFilledSkeletonCount } from '@/lib/use-filled-skeleton-count';
import Image from 'next/image';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/contexts/AuthContext';
@@ -120,13 +121,14 @@ const isNestedUploadInteractiveTarget = (target: EventTarget | null) => {
type BannerManagementPageWrapperProps = {
embedded?: boolean;
+ onInitialContentReady?: () => void;
};
// Suspense 래퍼
-function BannerManagementPageWrapper({ embedded = false }: BannerManagementPageWrapperProps = {}) {
+function BannerManagementPageWrapper({ embedded = false, onInitialContentReady }: BannerManagementPageWrapperProps = {}) {
return (
-
+
);
}
@@ -140,12 +142,19 @@ BannerManagementRoutePage.Embedded = BannerManagementPageWrapper;
export default BannerManagementRoutePage;
-function BannerManagementPage({ embedded }: Required) {
+function BannerManagementPage({ embedded, onInitialContentReady }: BannerManagementPageWrapperProps & { embedded: boolean }) {
const router = useRouter();
const { user, isAdmin, isLoading: authLoading } = useAuth();
// 배너 데이터
const { data: banners = [], isLoading: bannersLoading } = useAdBannersAdmin();
+ const initialLoadPendingRef = useRef(true);
+ if (initialLoadPendingRef.current && !authLoading && !bannersLoading) initialLoadPendingRef.current = false;
+ useLayoutEffect(() => {
+ if (!onInitialContentReady || authLoading || initialLoadPendingRef.current) return;
+ onInitialContentReady();
+ }, [authLoading, bannersLoading, onInitialContentReady]);
+ const bannerListSkeleton = useFilledSkeletonCount(88, 5);
const createBanner = useCreateAdBanner();
const updateBanner = useUpdateAdBanner();
const deleteBanner = useDeleteAdBanner();
@@ -547,7 +556,8 @@ function BannerManagementPage({ embedded }: Required
)}
@@ -592,11 +602,11 @@ function BannerManagementPage({ embedded }: Required
-