AitorConS commited on
Commit
b1deecb
·
verified ·
1 Parent(s): eea9cc3

Initial Gradio Space — Dream Museum

Browse files
.env.example ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ HF_TOKEN=hf_your_token_here
2
+ GALLERY_DATASET=your-hf-username/dream-museum-gallery
.gitattributes CHANGED
@@ -1,35 +1 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
- *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
8
- *.h5 filter=lfs diff=lfs merge=lfs -text
9
- *.joblib filter=lfs diff=lfs merge=lfs -text
10
- *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
- *.model filter=lfs diff=lfs merge=lfs -text
13
- *.msgpack filter=lfs diff=lfs merge=lfs -text
14
- *.npy filter=lfs diff=lfs merge=lfs -text
15
- *.npz filter=lfs diff=lfs merge=lfs -text
16
- *.onnx filter=lfs diff=lfs merge=lfs -text
17
- *.ot filter=lfs diff=lfs merge=lfs -text
18
- *.parquet filter=lfs diff=lfs merge=lfs -text
19
- *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
- *.pkl filter=lfs diff=lfs merge=lfs -text
22
- *.pt filter=lfs diff=lfs merge=lfs -text
23
- *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
- *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz filter=lfs diff=lfs merge=lfs -text
33
- *.zip filter=lfs diff=lfs merge=lfs -text
34
- *.zst filter=lfs diff=lfs merge=lfs -text
35
- *tfevents* filter=lfs diff=lfs merge=lfs -text
 
1
+ *.glb filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.gitignore ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ env/
2
+ not-the-louvre/
3
+ __pycache__/
4
+ *.pyc
5
+ .env
6
+ *.egg-info/
7
+ .DS_Store
8
+ Thumbs.db
9
+ static/marco.html
10
+ static/marco.css
11
+ static/marco.js
Dockerfile ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM nvidia/cuda:12.1.0-cudnn8-runtime-ubuntu22.04
2
+
3
+ WORKDIR /app
4
+
5
+ ENV DEBIAN_FRONTEND=noninteractive
6
+ ENV PYTHONUNBUFFERED=1
7
+
8
+ # Python + git
9
+ RUN apt-get update && \
10
+ apt-get install -y python3.11 python3.11-dev python3-pip git curl && \
11
+ ln -sf python3.11 /usr/bin/python && \
12
+ ln -sf python3.11 /usr/bin/python3 && \
13
+ rm -rf /var/lib/apt/lists/*
14
+
15
+ # Torch with CUDA 12.1 first (avoids pip installing CPU version later)
16
+ RUN pip install --no-cache-dir \
17
+ torch==2.3.0 torchvision \
18
+ --index-url https://download.pytorch.org/whl/cu121
19
+
20
+ # Remaining dependencies
21
+ COPY requirements.txt .
22
+ RUN pip install --no-cache-dir -r requirements.txt
23
+
24
+ # App files
25
+ COPY . .
26
+
27
+ EXPOSE 7860
28
+
29
+ CMD ["python", "app.py"]
README.md CHANGED
@@ -1,13 +1,42 @@
1
  ---
2
  title: Dream Museum
3
- emoji: 📊
4
- colorFrom: green
5
- colorTo: indigo
6
  sdk: gradio
7
  sdk_version: 6.16.0
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: Dream Museum
3
+ emoji: 🌙
4
+ colorFrom: purple
5
+ colorTo: pink
6
  sdk: gradio
7
  sdk_version: 6.16.0
 
8
  app_file: app.py
9
  pinned: false
10
+ license: mit
11
+ short_description: Draw a dream · Describe it · Watch it materialize
12
  ---
13
 
14
+ # Dream Museum
15
+
16
+ *Draw a dream · Describe it · Watch it materialize · Hang it in the museum*
17
+
18
+ Built for the **HuggingFace Build Small Hackathon 2026** — Thousand Token Wood track.
19
+
20
+ ## How it works
21
+
22
+ 1. Open the Gradio interface and sketch your dream on the canvas
23
+ 2. Describe it in words
24
+ 3. SDXL + ControlNet-scribble materializes it into an image
25
+ 4. Save it to the public museum or keep it private
26
+ 5. Visit the 3D museum to see all exhibited dreams
27
+
28
+ ## Models used
29
+
30
+ | Model | Params | Role |
31
+ |---|---|---|
32
+ | [stabilityai/stable-diffusion-xl-base-1.0](https://huggingface.co/stabilityai/stable-diffusion-xl-base-1.0) | ~3.5B | Image generation |
33
+ | [xinsir/controlnet-scribble-sdxl-1.0](https://huggingface.co/xinsir/controlnet-scribble-sdxl-1.0) | ~1.4B | Sketch guidance |
34
+
35
+ **Total: ~5B parameters** — well within the 32B limit.
36
+
37
+ ## Environment variables (Space secrets)
38
+
39
+ | Variable | Description |
40
+ |---|---|
41
+ | `HF_TOKEN` | HuggingFace token with write access to the gallery dataset |
42
+ | `GALLERY_DATASET` | Dataset repo ID, e.g. `your-username/dream-museum-gallery` |
app.py ADDED
@@ -0,0 +1,209 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import io
2
+ import base64
3
+ from pathlib import Path
4
+
5
+ from dotenv import load_dotenv
6
+ load_dotenv()
7
+
8
+ from fastapi import FastAPI, Request
9
+ from fastapi.responses import FileResponse, JSONResponse
10
+ from fastapi.staticfiles import StaticFiles
11
+ from PIL import Image
12
+ import gradio as gr
13
+ import uvicorn
14
+
15
+ import gallery as gallery_module
16
+ import inference as inference_module
17
+
18
+ STATIC = Path(__file__).parent / "static"
19
+
20
+ # ── FastAPI app (serves the 3D frontend + JSON API) ───────────────────────────
21
+
22
+ app = FastAPI()
23
+ app.mount("/static", StaticFiles(directory=str(STATIC)), name="static")
24
+
25
+
26
+ @app.get("/museum")
27
+ async def museum():
28
+ return FileResponse(str(STATIC / "index.html"))
29
+
30
+
31
+ @app.get("/galeria")
32
+ async def galeria():
33
+ return FileResponse(str(STATIC / "galeria.html"))
34
+
35
+
36
+ @app.post("/generate")
37
+ async def generate_endpoint(request: Request):
38
+ body = await request.json()
39
+ sketch_b64 = body.get("sketch_b64", "")
40
+ description = body.get("description", "").strip()
41
+ strength = float(body.get("strength", 0.7))
42
+ if not sketch_b64 or not description:
43
+ return JSONResponse({"ok": False, "error": "Sketch and description are required"})
44
+ try:
45
+ image_b64 = inference_module.generate(sketch_b64, description, strength)
46
+ return JSONResponse({"ok": True, "image": image_b64})
47
+ except Exception as e:
48
+ return JSONResponse({"ok": False, "error": str(e)})
49
+
50
+
51
+ @app.post("/gallery/public")
52
+ async def public_gallery(request: Request):
53
+ body = await request.json()
54
+ dreams = gallery_module.get_public_dreams(body.get("limit", 50), body.get("offset", 0))
55
+ return JSONResponse({"ok": True, "dreams": dreams})
56
+
57
+
58
+ @app.post("/gallery/user")
59
+ async def user_gallery(request: Request):
60
+ body = await request.json()
61
+ user_id = body.get("user_id", "")
62
+ if not user_id:
63
+ return JSONResponse({"ok": False, "error": "user_id required"})
64
+ return JSONResponse({"ok": True, "dreams": gallery_module.get_user_dreams(user_id)})
65
+
66
+
67
+ @app.post("/gallery/save")
68
+ async def save_dream_endpoint(request: Request):
69
+ body = await request.json()
70
+ user_id = body.get("user_id", "")
71
+ if not user_id:
72
+ return JSONResponse({"ok": False, "error": "Login required to save dreams"})
73
+ result = gallery_module.save_dream(
74
+ user_id,
75
+ body.get("sketch_b64", ""),
76
+ body.get("image_b64", ""),
77
+ body.get("description", ""),
78
+ body.get("visibility", "public"),
79
+ )
80
+ return JSONResponse(result)
81
+
82
+
83
+ @app.post("/gallery/delete")
84
+ async def delete_dream_endpoint(request: Request):
85
+ body = await request.json()
86
+ result = gallery_module.delete_dream(body.get("dream_id", ""), body.get("user_id", ""))
87
+ return JSONResponse(result)
88
+
89
+
90
+ @app.post("/gallery/toggle")
91
+ async def toggle_visibility_endpoint(request: Request):
92
+ body = await request.json()
93
+ result = gallery_module.toggle_visibility(body.get("dream_id", ""), body.get("user_id", ""))
94
+ return JSONResponse(result)
95
+
96
+
97
+ # ── Gradio generation function ────────────────────────────────────────────────
98
+
99
+ def _gradio_generate(sketch_data, description: str, strength: float):
100
+ """Gradio event handler: sketchpad → dream image."""
101
+ if sketch_data is None or not description.strip():
102
+ return None
103
+
104
+ # gr.Sketchpad returns {"composite": PIL, "background": PIL, "layers": [...]}
105
+ sketch_img = (
106
+ sketch_data.get("composite")
107
+ if isinstance(sketch_data, dict)
108
+ else sketch_data
109
+ )
110
+ if sketch_img is None:
111
+ return None
112
+
113
+ buf = io.BytesIO()
114
+ sketch_img.convert("RGB").save(buf, format="PNG")
115
+ sketch_b64 = "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
116
+
117
+ image_b64 = inference_module.generate(sketch_b64, description.strip(), float(strength))
118
+
119
+ img_bytes = base64.b64decode(image_b64)
120
+ return Image.open(io.BytesIO(img_bytes)).convert("RGB")
121
+
122
+
123
+ # ── Gradio Blocks UI ──────────────────────────────────────────────────────────
124
+
125
+ theme_css = """
126
+ body, html {
127
+ background-color: #0b0813 !important;
128
+ }
129
+ .gradio-container {
130
+ max-width: 1400px !important;
131
+ background: #0f0b1e !important;
132
+ border: 1px solid #2d244a !important;
133
+ border-radius: 16px !important;
134
+ box-shadow: 0 20px 50px rgba(0,0,0,0.5) !important;
135
+ }
136
+ .tab-nav button {
137
+ font-family: 'Cormorant Garamond', Georgia, serif !important;
138
+ font-size: 1.15rem !important;
139
+ color: #9880a8 !important;
140
+ }
141
+ .tab-nav button.selected {
142
+ color: #f0cc6e !important;
143
+ }
144
+ """
145
+
146
+ with gr.Blocks(title="Dream Museum", css=theme_css) as demo:
147
+
148
+ gr.HTML("""
149
+ <style>
150
+ footer { display: none !important; }
151
+ @import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;1,300;1,400&display=swap');
152
+ </style>
153
+ <div style="text-align:center;padding:24px 20px 10px;font-family:'Cormorant Garamond',Georgia,serif">
154
+ <p style="color:#c9a030;font-size:1.5rem;margin:0">◈</p>
155
+ <h1 style="font-size:2.5rem;font-weight:300;font-style:italic;letter-spacing:.15em;color:#f0cc6e;margin:6px 0">Dream Museum</h1>
156
+ <p style="color:#9880a8;font-style:italic;font-size:.95rem;margin:0">Draw a dream · Describe it · Watch it come alive</p>
157
+ </div>
158
+ """)
159
+
160
+ with gr.Row(equal_height=False):
161
+ with gr.Column(scale=1):
162
+ sketch_input = gr.Sketchpad(
163
+ label="Sketch your dream",
164
+ type="pil",
165
+ height=440,
166
+ )
167
+
168
+ with gr.Column(scale=1):
169
+ desc_input = gr.Textbox(
170
+ label="Describe your dream",
171
+ placeholder=(
172
+ "A cathedral of clouds, golden light through impossible windows, "
173
+ "the sensation of floating between colours…"
174
+ ),
175
+ lines=5,
176
+ )
177
+ strength_slider = gr.Slider(
178
+ minimum=0.3, maximum=1.0, value=0.7, step=0.05,
179
+ label="Sketch faithfulness",
180
+ info="Low = free interpretation · High = follows your sketch",
181
+ )
182
+ generate_btn = gr.Button("✶ Materialize Dream", variant="primary", size="lg")
183
+ dream_output = gr.Image(
184
+ label="Your dream",
185
+ type="pil",
186
+ height=300,
187
+ )
188
+
189
+ generate_btn.click(
190
+ fn=_gradio_generate,
191
+ inputs=[sketch_input, desc_input, strength_slider],
192
+ outputs=[dream_output],
193
+ )
194
+
195
+ gr.HTML("""
196
+ <div style="text-align: center; margin-top: 15px; font-family: 'Cormorant Garamond', Georgia, serif;">
197
+ <a href="/museum" target="_blank" style="color: #f0cc6e; text-decoration: none; font-size: 1.1rem; letter-spacing: 0.1em; transition: color 0.3s;">
198
+ ◈ &nbsp; Open 3D Dream Museum →
199
+ </a>
200
+ </div>
201
+ """)
202
+
203
+
204
+ # Mount Gradio at / — the 3D museum is served at /museum
205
+ app = gr.mount_gradio_app(app, demo, path="/")
206
+
207
+
208
+ if __name__ == "__main__":
209
+ uvicorn.run(app, host="0.0.0.0", port=7860)
gallery.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ import io
3
+ import json
4
+ import os
5
+ import uuid
6
+ from datetime import datetime
7
+
8
+ from huggingface_hub import HfApi, hf_hub_download
9
+ from PIL import Image
10
+
11
+ DATASET_REPO = os.environ.get("GALLERY_DATASET", "")
12
+ HF_TOKEN = os.environ.get("HF_TOKEN", "")
13
+
14
+ _api = None
15
+
16
+
17
+ def _get_api() -> HfApi:
18
+ global _api
19
+ if _api is None:
20
+ _api = HfApi(token=HF_TOKEN)
21
+ return _api
22
+
23
+
24
+ def _pil_from_b64(b64: str) -> Image.Image:
25
+ if "," in b64:
26
+ b64 = b64.split(",", 1)[1]
27
+ return Image.open(io.BytesIO(base64.b64decode(b64))).convert("RGB")
28
+
29
+
30
+ def _upload_image(img: Image.Image, path_in_repo: str) -> str:
31
+ buf = io.BytesIO()
32
+ img.save(buf, format="PNG")
33
+ buf.seek(0)
34
+ api = _get_api()
35
+ api.upload_file(
36
+ path_or_fileobj=buf,
37
+ path_in_repo=path_in_repo,
38
+ repo_id=DATASET_REPO,
39
+ repo_type="dataset",
40
+ )
41
+ return f"https://huggingface.co/datasets/{DATASET_REPO}/resolve/main/{path_in_repo}"
42
+
43
+
44
+ def _load_index() -> list[dict]:
45
+ if not DATASET_REPO:
46
+ return []
47
+ try:
48
+ path = hf_hub_download(
49
+ repo_id=DATASET_REPO,
50
+ filename="index.json",
51
+ repo_type="dataset",
52
+ token=HF_TOKEN,
53
+ )
54
+ with open(path) as f:
55
+ return json.load(f)
56
+ except Exception:
57
+ return []
58
+
59
+
60
+ def _save_index(entries: list[dict]):
61
+ api = _get_api()
62
+ data = json.dumps(entries, ensure_ascii=False, indent=2).encode()
63
+ api.upload_file(
64
+ path_or_fileobj=io.BytesIO(data),
65
+ path_in_repo="index.json",
66
+ repo_id=DATASET_REPO,
67
+ repo_type="dataset",
68
+ )
69
+
70
+
71
+ def save_dream(
72
+ user_id: str,
73
+ sketch_b64: str,
74
+ image_b64: str,
75
+ description: str,
76
+ visibility: str, # "public" | "private"
77
+ ) -> dict:
78
+ if not DATASET_REPO:
79
+ return {"ok": False, "error": "Gallery not configured"}
80
+
81
+ dream_id = str(uuid.uuid4())[:8]
82
+ timestamp = datetime.utcnow().isoformat()
83
+
84
+ sketch_url = _upload_image(
85
+ _pil_from_b64(sketch_b64), f"dreams/{dream_id}/sketch.png"
86
+ )
87
+ image_url = _upload_image(
88
+ _pil_from_b64(image_b64), f"dreams/{dream_id}/dream.png"
89
+ )
90
+
91
+ entry = {
92
+ "id": dream_id,
93
+ "user_id": user_id,
94
+ "description": description,
95
+ "visibility": visibility,
96
+ "sketch_url": sketch_url,
97
+ "image_url": image_url,
98
+ "timestamp": timestamp,
99
+ }
100
+
101
+ entries = _load_index()
102
+ entries.insert(0, entry)
103
+ _save_index(entries)
104
+
105
+ return {"ok": True, "id": dream_id}
106
+
107
+
108
+ def get_public_dreams(limit: int = 50, offset: int = 0) -> list[dict]:
109
+ entries = _load_index()
110
+ public = [e for e in entries if e.get("visibility") == "public"]
111
+ return public[offset : offset + limit]
112
+
113
+
114
+ def get_user_dreams(user_id: str, limit: int = 50) -> list[dict]:
115
+ entries = _load_index()
116
+ return [e for e in entries if e.get("user_id") == user_id][:limit]
117
+
118
+
119
+ def delete_dream(dream_id: str, user_id: str) -> dict:
120
+ entries = _load_index()
121
+ original_len = len(entries)
122
+ entries = [
123
+ e for e in entries
124
+ if not (e["id"] == dream_id and e["user_id"] == user_id)
125
+ ]
126
+ if len(entries) == original_len:
127
+ return {"ok": False, "error": "Not found or unauthorized"}
128
+ _save_index(entries)
129
+ return {"ok": True}
130
+
131
+
132
+ def toggle_visibility(dream_id: str, user_id: str) -> dict:
133
+ entries = _load_index()
134
+ for e in entries:
135
+ if e["id"] == dream_id and e["user_id"] == user_id:
136
+ e["visibility"] = "private" if e["visibility"] == "public" else "public"
137
+ _save_index(entries)
138
+ return {"ok": True, "visibility": e["visibility"]}
139
+ return {"ok": False, "error": "Not found or unauthorized"}
inference.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ import io
3
+ import torch
4
+ from PIL import Image
5
+ from diffusers import StableDiffusionXLControlNetPipeline, ControlNetModel
6
+
7
+ _pipe = None
8
+
9
+
10
+ def _load_pipeline():
11
+ global _pipe
12
+ if _pipe is not None:
13
+ return _pipe
14
+
15
+ print("Loading ControlNet...")
16
+ controlnet = ControlNetModel.from_pretrained(
17
+ "xinsir/controlnet-scribble-sdxl-1.0",
18
+ torch_dtype=torch.float16,
19
+ )
20
+
21
+ print("Loading SDXL...")
22
+ _pipe = StableDiffusionXLControlNetPipeline.from_pretrained(
23
+ "stabilityai/stable-diffusion-xl-base-1.0",
24
+ controlnet=controlnet,
25
+ torch_dtype=torch.float16,
26
+ variant="fp16",
27
+ use_safetensors=True,
28
+ ).to("cuda")
29
+
30
+ _pipe.enable_attention_slicing()
31
+ _pipe.enable_vae_slicing()
32
+
33
+ print("Pipeline ready.")
34
+ return _pipe
35
+
36
+
37
+ def _b64_to_pil(b64: str) -> Image.Image:
38
+ # strip data URI prefix if present
39
+ if "," in b64:
40
+ b64 = b64.split(",", 1)[1]
41
+ return Image.open(io.BytesIO(base64.b64decode(b64))).convert("RGB")
42
+
43
+
44
+ def _pil_to_b64(img: Image.Image) -> str:
45
+ buf = io.BytesIO()
46
+ img.save(buf, format="PNG")
47
+ return base64.b64encode(buf.getvalue()).decode()
48
+
49
+
50
+ def generate(sketch_b64: str, description: str, strength: float = 0.7) -> str:
51
+ pipe = _load_pipeline()
52
+
53
+ sketch = _b64_to_pil(sketch_b64).resize((1024, 1024))
54
+
55
+ negative_prompt = (
56
+ "ugly, blurry, low quality, distorted, deformed, watermark, text, signature"
57
+ )
58
+
59
+ result = pipe(
60
+ prompt=description,
61
+ negative_prompt=negative_prompt,
62
+ image=sketch,
63
+ controlnet_conditioning_scale=strength,
64
+ num_inference_steps=30,
65
+ guidance_scale=7.5,
66
+ height=1024,
67
+ width=1024,
68
+ ).images[0]
69
+
70
+ return _pil_to_b64(result)
requirements.txt ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ gradio==6.16.0
2
+ fastapi>=0.111.0
3
+ uvicorn>=0.29.0
4
+ python-multipart>=0.0.9
5
+ diffusers==0.30.3
6
+ transformers==4.44.2
7
+ accelerate==0.33.0
8
+ # torch installed separately in Dockerfile with CUDA support
9
+ # for local dev: pip install torch torchvision --index-url https://download.pytorch.org/whl/cu128
10
+ controlnet-aux>=0.0.9
11
+ Pillow>=10.0.0
12
+ huggingface_hub>=0.24.0
13
+ python-dotenv
static/app.js ADDED
@@ -0,0 +1,357 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { init as initScene, setPaintings } from '/static/scene.js';
2
+
3
+ // ── State ─────────────────────────────────────────────────────────────────────
4
+ const state = {
5
+ userId: localStorage.getItem('dm_user') || '',
6
+ lastSketch: null,
7
+ lastDream: null,
8
+ activePanel: null,
9
+ };
10
+
11
+ // ── API helper ────────────────────────────────────────────────────────────────
12
+ async function api(path, body) {
13
+ const r = await fetch(path, {
14
+ method: 'POST',
15
+ headers: { 'Content-Type': 'application/json' },
16
+ body: JSON.stringify(body),
17
+ });
18
+ return r.json();
19
+ }
20
+
21
+ // ── Boot ──────────────────────────────────────────────────────────────────────
22
+ document.addEventListener('DOMContentLoaded', async () => {
23
+ initScene(document.getElementById('museum-canvas'), openDreamModal);
24
+
25
+ if (state.userId) _applyAuth(state.userId);
26
+
27
+ await _refreshMuseum();
28
+
29
+ _initHUD();
30
+ _initAuth();
31
+ _initDrawCanvas();
32
+ _initControls();
33
+ _initModal();
34
+ _initSaveBtns();
35
+ });
36
+
37
+ // ── Museum ────────────────────────────────────────────────────────────────────
38
+ async function _refreshMuseum() {
39
+ const data = await api('/gallery/public', { limit: 12, offset: 0 });
40
+ if (data.ok) setPaintings(data.dreams);
41
+ }
42
+
43
+ // ── HUD / panels ──────────────────────────────────────────────────────────────
44
+ function _initHUD() {
45
+ document.getElementById('btn-create').addEventListener('click', () => _openPanel('create'));
46
+ document.getElementById('btn-my-gallery').addEventListener('click', () => _openPanel('gallery'));
47
+ document.getElementById('close-create').addEventListener('click', () => _openPanel(null));
48
+ document.getElementById('close-gallery').addEventListener('click', () => _openPanel(null));
49
+ }
50
+
51
+ function _openPanel(name) {
52
+ if (state.activePanel) {
53
+ document.getElementById(`panel-${state.activePanel}`).classList.remove('open');
54
+ }
55
+ state.activePanel = name;
56
+ const canvas = document.getElementById('museum-canvas');
57
+
58
+ if (name) {
59
+ const panel = document.getElementById(`panel-${name}`);
60
+ panel.classList.remove('hidden');
61
+ requestAnimationFrame(() => panel.classList.add('open'));
62
+ canvas.classList.add('dimmed');
63
+ document.getElementById('room-hint').classList.add('hidden');
64
+ if (name === 'gallery') _loadUserGallery();
65
+ } else {
66
+ canvas.classList.remove('dimmed');
67
+ document.getElementById('room-hint').classList.remove('hidden');
68
+ }
69
+ }
70
+
71
+ // Hide panel after slide-out transition ends
72
+ document.addEventListener('transitionend', e => {
73
+ const el = e.target;
74
+ if (el.classList.contains('side-panel') && !el.classList.contains('open')) {
75
+ el.classList.add('hidden');
76
+ }
77
+ });
78
+
79
+ // ── Auth ──────────────────────────────────────────────────────────────────────
80
+ function _initAuth() {
81
+ const input = document.getElementById('auth-input');
82
+ const btn = document.getElementById('auth-btn');
83
+ btn.addEventListener('click', () => { const v = input.value.trim(); if (v) _applyAuth(v); });
84
+ input.addEventListener('keydown', e => { if (e.key === 'Enter') btn.click(); });
85
+ }
86
+
87
+ function _applyAuth(name) {
88
+ state.userId = name;
89
+ localStorage.setItem('dm_user', name);
90
+
91
+ document.getElementById('auth-input').classList.add('hidden');
92
+ document.getElementById('auth-btn').classList.add('hidden');
93
+ const nameEl = document.getElementById('auth-name');
94
+ nameEl.textContent = `✦ ${name}`;
95
+ nameEl.classList.remove('hidden');
96
+
97
+ _updateSaveArea();
98
+ }
99
+
100
+ // ── Drawing canvas ─────────────────────────────────────────────────────────────
101
+ function _initDrawCanvas() {
102
+ const canvas = document.getElementById('draw-canvas');
103
+ const ctx = canvas.getContext('2d');
104
+ ctx.fillStyle = '#0f0d1e';
105
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
106
+
107
+ let drawing = false, tool = 'pen', color = '#ffffff', size = 6;
108
+
109
+ // Brush preview dot
110
+ const preview = document.getElementById('brush-preview');
111
+ function _updatePreview() {
112
+ preview.style.width = Math.min(size, 24) + 'px';
113
+ preview.style.height = Math.min(size, 24) + 'px';
114
+ preview.style.background = tool === 'eraser' ? 'rgba(255,255,255,.3)' : color;
115
+ }
116
+ _updatePreview();
117
+
118
+ function pos(e) {
119
+ const r = canvas.getBoundingClientRect();
120
+ const sx = canvas.width / r.width;
121
+ const sy = canvas.height / r.height;
122
+ const s = e.touches ? e.touches[0] : e;
123
+ return { x: (s.clientX - r.left) * sx, y: (s.clientY - r.top) * sy };
124
+ }
125
+
126
+ function down(e) {
127
+ e.preventDefault();
128
+ drawing = true;
129
+ const { x, y } = pos(e);
130
+ ctx.beginPath(); ctx.moveTo(x, y);
131
+ }
132
+
133
+ function move(e) {
134
+ e.preventDefault();
135
+ if (!drawing) return;
136
+ const { x, y } = pos(e);
137
+ ctx.lineWidth = tool === 'eraser' ? size * 3 : size;
138
+ ctx.strokeStyle = tool === 'eraser' ? '#0f0d1e' : color;
139
+ ctx.lineCap = ctx.lineJoin = 'round';
140
+ ctx.globalAlpha = 1;
141
+ ctx.lineTo(x, y); ctx.stroke();
142
+ ctx.beginPath(); ctx.moveTo(x, y);
143
+ }
144
+
145
+ function up() { drawing = false; ctx.beginPath(); }
146
+
147
+ canvas.addEventListener('mousedown', down);
148
+ canvas.addEventListener('mousemove', move);
149
+ canvas.addEventListener('mouseup', up);
150
+ canvas.addEventListener('mouseleave', up);
151
+ canvas.addEventListener('touchstart', down, { passive: false });
152
+ canvas.addEventListener('touchmove', move, { passive: false });
153
+ canvas.addEventListener('touchend', up);
154
+
155
+ // Tool buttons
156
+ document.getElementById('t-pen').addEventListener('click', () => {
157
+ tool = 'pen'; _setActiveTool('t-pen'); _updatePreview();
158
+ });
159
+ document.getElementById('t-eraser').addEventListener('click', () => {
160
+ tool = 'eraser'; _setActiveTool('t-eraser'); _updatePreview();
161
+ });
162
+
163
+ // Color picker
164
+ document.getElementById('t-color').addEventListener('input', e => {
165
+ color = e.target.value;
166
+ tool = 'pen'; _setActiveTool('t-pen'); _updatePreview();
167
+ });
168
+
169
+ // Color swatches
170
+ document.querySelectorAll('.swatch').forEach(s => {
171
+ s.addEventListener('click', () => {
172
+ color = s.dataset.color;
173
+ document.getElementById('t-color').value = color;
174
+ tool = 'pen'; _setActiveTool('t-pen'); _updatePreview();
175
+ });
176
+ });
177
+
178
+ // Size slider
179
+ document.getElementById('t-size').addEventListener('input', e => {
180
+ size = +e.target.value; _updatePreview();
181
+ });
182
+
183
+ // Clear
184
+ document.getElementById('t-clear').addEventListener('click', () => {
185
+ ctx.fillStyle = '#0f0d1e';
186
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
187
+ });
188
+ }
189
+
190
+ function _setActiveTool(id) {
191
+ document.querySelectorAll('.tool').forEach(t => t.classList.remove('active'));
192
+ document.getElementById(id).classList.add('active');
193
+ }
194
+
195
+ // ── Generation ─────────────────────────────────────────────────────────────────
196
+ function _initControls() {
197
+ const slider = document.getElementById('strength');
198
+ const valEl = document.getElementById('strength-val');
199
+ slider.addEventListener('input', () => { valEl.textContent = slider.value; });
200
+ document.getElementById('generate-btn').addEventListener('click', _generate);
201
+ }
202
+
203
+ async function _generate() {
204
+ const desc = document.getElementById('dream-desc').value.trim();
205
+ if (!desc) { alert('Please describe your dream first.'); return; }
206
+
207
+ const canvas = document.getElementById('draw-canvas');
208
+ const sketch = canvas.toDataURL('image/png');
209
+ const strength = parseFloat(document.getElementById('strength').value);
210
+
211
+ const btn = document.getElementById('generate-btn');
212
+ const label = document.getElementById('gen-label');
213
+ const spin = document.getElementById('gen-spin');
214
+ btn.disabled = true;
215
+ label.textContent = '✦ Materializing…';
216
+ spin.classList.remove('hidden');
217
+
218
+ try {
219
+ const data = await api('/generate', { sketch_b64: sketch, description: desc, strength });
220
+ if (!data.ok) { alert('Error: ' + data.error); return; }
221
+
222
+ state.lastSketch = sketch;
223
+ state.lastDream = 'data:image/png;base64,' + data.image;
224
+
225
+ document.getElementById('res-sketch').src = state.lastSketch;
226
+ document.getElementById('res-dream').src = state.lastDream;
227
+ document.getElementById('result-area').classList.remove('hidden');
228
+ _updateSaveArea();
229
+ } finally {
230
+ btn.disabled = false;
231
+ label.textContent = '✦ Materialize Dream';
232
+ spin.classList.add('hidden');
233
+ }
234
+ }
235
+
236
+ // ── Save buttons ───────────────────────────────────────────────────────────────
237
+ function _initSaveBtns() {
238
+ document.getElementById('btn-publish').addEventListener('click', () => _save('public'));
239
+ document.getElementById('btn-private').addEventListener('click', () => _save('private'));
240
+ document.getElementById('btn-download').addEventListener('click', _download);
241
+ }
242
+
243
+ function _updateSaveArea() {
244
+ if (!state.lastDream) return;
245
+ document.getElementById('need-auth').classList.toggle('hidden', !!state.userId);
246
+ document.getElementById('save-btns').classList.toggle('hidden', !state.userId);
247
+ }
248
+
249
+ async function _save(visibility) {
250
+ const desc = document.getElementById('dream-desc').value.trim();
251
+ const data = await api('/gallery/save', {
252
+ user_id: state.userId,
253
+ sketch_b64: state.lastSketch,
254
+ image_b64: state.lastDream,
255
+ description: desc,
256
+ visibility,
257
+ });
258
+ if (data.ok) {
259
+ alert(visibility === 'public'
260
+ ? '◈ Dream exhibited in the Museum.'
261
+ : 'Dream saved to your private gallery.');
262
+ if (visibility === 'public') _refreshMuseum();
263
+ } else {
264
+ alert('Could not save: ' + data.error);
265
+ }
266
+ }
267
+
268
+ function _download() {
269
+ if (!state.lastDream) return;
270
+ const a = document.createElement('a');
271
+ a.href = state.lastDream;
272
+ a.download = 'sueno.png';
273
+ a.click();
274
+ }
275
+
276
+ // ── My Gallery ─────────────────────────────────────────────────────────────────
277
+ async function _loadUserGallery() {
278
+ const grid = document.getElementById('my-grid');
279
+
280
+ if (!state.userId) {
281
+ grid.innerHTML = '<div class="empty-state">Enter your name to see your saved dreams.</div>';
282
+ return;
283
+ }
284
+
285
+ document.getElementById('gallery-subtitle').textContent = `${state.userId}'s archive`;
286
+ grid.innerHTML = '<div class="empty-state">Loading…</div>';
287
+
288
+ const data = await api('/gallery/user', { user_id: state.userId });
289
+ if (!data.ok || !data.dreams.length) {
290
+ grid.innerHTML = '<div class="empty-state">No dreams saved yet.</div>';
291
+ return;
292
+ }
293
+
294
+ grid.innerHTML = '';
295
+ data.dreams.forEach(d => grid.appendChild(_makeCard(d, true)));
296
+ }
297
+
298
+ function _makeCard(dream, isOwner = false) {
299
+ const card = document.createElement('div');
300
+ card.className = 'dream-card';
301
+ card.innerHTML = `
302
+ <div class="card-diptych">
303
+ <img src="${dream.sketch_url}" loading="lazy" alt="boceto" />
304
+ <img src="${dream.image_url}" loading="lazy" alt="sueño" />
305
+ </div>
306
+ <div class="card-info">
307
+ <div class="card-desc">${dream.description}</div>
308
+ <div class="card-vis">${dream.visibility === 'public' ? '◈ Public' : '◆ Private'}</div>
309
+ </div>
310
+ `;
311
+ card.addEventListener('click', () => openDreamModal(dream, isOwner));
312
+ return card;
313
+ }
314
+
315
+ // ── Dream modal ────────────────────────────────────────────────────────────────
316
+ function _initModal() {
317
+ document.querySelector('.modal-close').addEventListener('click', _closeModal);
318
+ document.querySelector('.modal-backdrop').addEventListener('click', _closeModal);
319
+ document.addEventListener('keydown', e => { if (e.key === 'Escape') _closeModal(); });
320
+ }
321
+
322
+ function openDreamModal(dream, isOwner = false) {
323
+ document.getElementById('modal-sketch').src = dream.sketch_url;
324
+ document.getElementById('modal-dream').src = dream.image_url;
325
+ document.getElementById('modal-desc').textContent = `"${dream.description}"`;
326
+ document.getElementById('modal-meta').textContent =
327
+ `${dream.user_id} · ${dream.timestamp?.slice(0, 10) ?? ''}`;
328
+
329
+ const ownerArea = document.getElementById('modal-owner');
330
+ const isMyDream = state.userId && dream.user_id === state.userId;
331
+ ownerArea.classList.toggle('hidden', !isMyDream);
332
+
333
+ if (isMyDream) {
334
+ const toggleBtn = document.getElementById('modal-toggle');
335
+ toggleBtn.textContent = dream.visibility === 'public' ? 'Make private' : 'Exhibit publicly';
336
+ toggleBtn.onclick = async () => {
337
+ const r = await api('/gallery/toggle', { dream_id: dream.id, user_id: state.userId });
338
+ if (r.ok) {
339
+ dream.visibility = r.visibility;
340
+ toggleBtn.textContent = dream.visibility === 'public' ? 'Make private' : 'Exhibit publicly';
341
+ _refreshMuseum();
342
+ _loadUserGallery();
343
+ }
344
+ };
345
+ document.getElementById('modal-delete').onclick = async () => {
346
+ if (!confirm('Delete this dream permanently?')) return;
347
+ const r = await api('/gallery/delete', { dream_id: dream.id, user_id: state.userId });
348
+ if (r.ok) { _closeModal(); _loadUserGallery(); _refreshMuseum(); }
349
+ };
350
+ }
351
+
352
+ document.getElementById('dream-modal').classList.remove('hidden');
353
+ }
354
+
355
+ function _closeModal() {
356
+ document.getElementById('dream-modal').classList.add('hidden');
357
+ }
static/frame.js ADDED
@@ -0,0 +1,265 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Procedural ornate museum frame + diptych canvas
2
+ // Inspired by not-the-louvre's artwork-frame-canvas approach
3
+
4
+ function seededRNG(seed) {
5
+ let s = typeof seed === 'string'
6
+ ? seed.split('').reduce((a, c) => (a * 31 + c.charCodeAt(0)) | 0, 0)
7
+ : seed | 0;
8
+ if (s === 0) s = 1;
9
+ return () => {
10
+ s = Math.imul(s, 16807) % 2147483647;
11
+ return (s & 0x7fffffff) / 2147483647;
12
+ };
13
+ }
14
+
15
+ async function loadImage(url) {
16
+ return new Promise((resolve, reject) => {
17
+ const img = new Image();
18
+ img.crossOrigin = 'anonymous';
19
+ img.onload = () => resolve(img);
20
+ img.onerror = () => reject(new Error('img load fail: ' + url));
21
+ img.src = url;
22
+ });
23
+ }
24
+
25
+ // Public: build a 1024×576 canvas with ornate frame + diptych inside
26
+ export async function buildDiptychCanvas(dream) {
27
+ const W = 1024, H = 576;
28
+ const FRAME = 44;
29
+ const MAT = 10;
30
+ const DIV = 3;
31
+
32
+ const canvas = document.createElement('canvas');
33
+ canvas.width = W;
34
+ canvas.height = H;
35
+ const ctx = canvas.getContext('2d');
36
+
37
+ // 1 — Frame
38
+ drawOrnateFrame(ctx, W, H, FRAME, seededRNG(dream.id || dream.description || 0));
39
+
40
+ // 2 — Mat (dark mounting board)
41
+ ctx.fillStyle = '#16132a';
42
+ ctx.fillRect(FRAME, FRAME, W - FRAME * 2, H - FRAME * 2);
43
+
44
+ // 3 — Images
45
+ const innerW = W - FRAME * 2;
46
+ const innerH = H - FRAME * 2;
47
+ const halfIW = Math.floor(innerW / 2);
48
+
49
+ try {
50
+ const [sketchImg, dreamImg] = await Promise.all([
51
+ loadImage(dream.sketch_url),
52
+ loadImage(dream.image_url),
53
+ ]);
54
+
55
+ const ix = FRAME + MAT;
56
+ const iy = FRAME + MAT;
57
+ const iw = halfIW - MAT * 2 - DIV;
58
+ const ih = innerH - MAT * 2;
59
+
60
+ ctx.drawImage(sketchImg, ix, iy, iw, ih);
61
+ ctx.drawImage(dreamImg, ix + halfIW + MAT + DIV, iy, iw, ih);
62
+
63
+ // Subtle label bands
64
+ for (const [lx, txt] of [[ix, 'SKETCH'], [ix + halfIW + MAT + DIV, 'DREAM']]) {
65
+ ctx.fillStyle = 'rgba(0,0,0,.55)';
66
+ ctx.fillRect(lx, iy + ih - 22, iw, 22);
67
+ ctx.fillStyle = 'rgba(210,185,110,.8)';
68
+ ctx.font = '11px Georgia, serif';
69
+ ctx.textAlign = 'center';
70
+ ctx.fillText(txt, lx + iw / 2, iy + ih - 7);
71
+ }
72
+ } catch {
73
+ // Fallback placeholder
74
+ ctx.fillStyle = '#1a1630';
75
+ ctx.fillRect(FRAME + MAT, FRAME + MAT, innerW - MAT * 2, innerH - MAT * 2);
76
+ }
77
+
78
+ // 4 — Divider between sketch and dream
79
+ ctx.fillStyle = '#0f0d1a';
80
+ ctx.fillRect(FRAME + halfIW - DIV, FRAME + MAT, DIV * 2, innerH - MAT * 2);
81
+
82
+ return canvas;
83
+ }
84
+
85
+ // ── Frame drawing ────────────────────────────────────────────────────────────
86
+
87
+ function drawOrnateFrame(ctx, W, H, depth, rng) {
88
+ // Base gold fill
89
+ ctx.fillStyle = '#a07820';
90
+ ctx.fillRect(0, 0, W, H);
91
+
92
+ // Top bar — highlighted (light source top-left)
93
+ const topGrad = ctx.createLinearGradient(0, 0, 0, depth);
94
+ topGrad.addColorStop(0, '#f5d870');
95
+ topGrad.addColorStop(0.4, '#d4a830');
96
+ topGrad.addColorStop(1, '#906018');
97
+ ctx.fillStyle = topGrad;
98
+ ctx.fillRect(0, 0, W, depth);
99
+
100
+ // Bottom bar — in shadow
101
+ const botGrad = ctx.createLinearGradient(0, H - depth, 0, H);
102
+ botGrad.addColorStop(0, '#6a4808');
103
+ botGrad.addColorStop(1, '#4a3005');
104
+ ctx.fillStyle = botGrad;
105
+ ctx.fillRect(0, H - depth, W, depth);
106
+
107
+ // Left bar
108
+ const leftGrad = ctx.createLinearGradient(0, 0, depth, 0);
109
+ leftGrad.addColorStop(0, '#e8c050');
110
+ leftGrad.addColorStop(0.5, '#c09028');
111
+ leftGrad.addColorStop(1, '#906018');
112
+ ctx.fillStyle = leftGrad;
113
+ ctx.fillRect(0, 0, depth, H);
114
+
115
+ // Right bar — in shadow
116
+ ctx.fillStyle = '#7a5010';
117
+ ctx.fillRect(W - depth, 0, depth, H);
118
+
119
+ // Inner bevel — highlight top & left edges
120
+ ctx.strokeStyle = 'rgba(255,230,110,.55)';
121
+ ctx.lineWidth = 1.5;
122
+ ctx.beginPath();
123
+ ctx.moveTo(depth, depth);
124
+ ctx.lineTo(W - depth, depth);
125
+ ctx.moveTo(depth, depth);
126
+ ctx.lineTo(depth, H - depth);
127
+ ctx.stroke();
128
+
129
+ // Inner bevel — shadow right & bottom edges
130
+ ctx.strokeStyle = 'rgba(0,0,0,.45)';
131
+ ctx.beginPath();
132
+ ctx.moveTo(W - depth, depth);
133
+ ctx.lineTo(W - depth, H - depth);
134
+ ctx.moveTo(depth, H - depth);
135
+ ctx.lineTo(W - depth, H - depth);
136
+ ctx.stroke();
137
+
138
+ // Outer shadow (depth illusion)
139
+ const shadowW = 5;
140
+ ctx.fillStyle = 'rgba(0,0,0,.35)';
141
+ ctx.fillRect(W - shadowW, 0, shadowW, H);
142
+ ctx.fillRect(0, H - shadowW, W, shadowW);
143
+
144
+ // Corner ornaments
145
+ const cx = depth / 2;
146
+ const cy = depth / 2;
147
+ drawCorner(ctx, cx, cy, depth * 0.38, rng);
148
+ drawCorner(ctx, W - cx, cy, depth * 0.38, rng);
149
+ drawCorner(ctx, cx, H - cy, depth * 0.38, rng);
150
+ drawCorner(ctx, W - cx, H - cy, depth * 0.38, rng);
151
+
152
+ // Fine fluted lines along bars
153
+ drawFlutes(ctx, W, H, depth);
154
+
155
+ // Aging effects
156
+ applyAging(ctx, W, H, depth, rng);
157
+ }
158
+
159
+ function drawCorner(ctx, cx, cy, size, rng) {
160
+ // Outer diamond
161
+ ctx.fillStyle = '#6a4808';
162
+ ctx.beginPath();
163
+ ctx.moveTo(cx, cy - size);
164
+ ctx.lineTo(cx + size, cy);
165
+ ctx.lineTo(cx, cy + size);
166
+ ctx.lineTo(cx - size, cy);
167
+ ctx.closePath();
168
+ ctx.fill();
169
+
170
+ // Highlight face
171
+ ctx.fillStyle = 'rgba(255,220,90,.65)';
172
+ const s2 = size * 0.55;
173
+ ctx.beginPath();
174
+ ctx.moveTo(cx, cy - s2);
175
+ ctx.lineTo(cx + s2, cy);
176
+ ctx.lineTo(cx, cy);
177
+ ctx.lineTo(cx - s2, cy);
178
+ ctx.closePath();
179
+ ctx.fill();
180
+
181
+ // Shadow face
182
+ ctx.fillStyle = 'rgba(0,0,0,.35)';
183
+ ctx.beginPath();
184
+ ctx.moveTo(cx, cy + s2);
185
+ ctx.lineTo(cx + s2, cy);
186
+ ctx.lineTo(cx, cy);
187
+ ctx.lineTo(cx - s2, cy);
188
+ ctx.closePath();
189
+ ctx.fill();
190
+
191
+ // Centre dot
192
+ ctx.fillStyle = '#3a2005';
193
+ ctx.beginPath();
194
+ ctx.arc(cx, cy, size * 0.18, 0, Math.PI * 2);
195
+ ctx.fill();
196
+ }
197
+
198
+ function drawFlutes(ctx, W, H, depth) {
199
+ ctx.strokeStyle = 'rgba(255,215,60,.15)';
200
+ ctx.lineWidth = 0.8;
201
+ const step = 8;
202
+ const inset = 6;
203
+
204
+ // Top bar flutes (horizontal lines)
205
+ for (let y = inset; y < depth - inset; y += step) {
206
+ ctx.beginPath();
207
+ ctx.moveTo(depth, y);
208
+ ctx.lineTo(W - depth, y);
209
+ ctx.stroke();
210
+ }
211
+ // Bottom bar
212
+ for (let y = H - depth + inset; y < H - inset; y += step) {
213
+ ctx.beginPath();
214
+ ctx.moveTo(depth, y);
215
+ ctx.lineTo(W - depth, y);
216
+ ctx.stroke();
217
+ }
218
+ // Left bar (vertical)
219
+ for (let x = inset; x < depth - inset; x += step) {
220
+ ctx.beginPath();
221
+ ctx.moveTo(x, depth);
222
+ ctx.lineTo(x, H - depth);
223
+ ctx.stroke();
224
+ }
225
+ // Right bar
226
+ for (let x = W - depth + inset; x < W - inset; x += step) {
227
+ ctx.beginPath();
228
+ ctx.moveTo(x, depth);
229
+ ctx.lineTo(x, H - depth);
230
+ ctx.stroke();
231
+ }
232
+ }
233
+
234
+ function applyAging(ctx, W, H, depth, rng) {
235
+ // Dust accumulation at bottom
236
+ const dust = ctx.createLinearGradient(0, H - depth * 1.5, 0, H);
237
+ dust.addColorStop(0, 'rgba(160,130,50,0)');
238
+ dust.addColorStop(1, 'rgba(140,110,40,.2)');
239
+ ctx.fillStyle = dust;
240
+ ctx.fillRect(0, 0, W, H);
241
+
242
+ // Age spots on frame surface
243
+ for (let i = 0; i < 10; i++) {
244
+ const x = rng() * W;
245
+ const y = rng() * H;
246
+ const inFrame = x < depth || x > W - depth || y < depth || y > H - depth;
247
+ if (!inFrame) continue;
248
+ ctx.fillStyle = `rgba(30,18,5,${0.08 + rng() * 0.14})`;
249
+ ctx.beginPath();
250
+ ctx.ellipse(x, y, 2 + rng() * 7, 1.5 + rng() * 5, rng() * Math.PI, 0, Math.PI * 2);
251
+ ctx.fill();
252
+ }
253
+
254
+ // Verdigris (blue-green oxidation) — rare
255
+ for (let i = 0; i < 3; i++) {
256
+ const x = rng() * W;
257
+ const y = rng() * H;
258
+ const inFrame = x < depth - 3 || x > W - depth + 3 || y < depth - 3 || y > H - depth + 3;
259
+ if (!inFrame) continue;
260
+ ctx.fillStyle = `rgba(50,110,70,${0.06 + rng() * 0.1})`;
261
+ ctx.beginPath();
262
+ ctx.ellipse(x, y, 3 + rng() * 6, 2 + rng() * 4, rng() * Math.PI, 0, Math.PI * 2);
263
+ ctx.fill();
264
+ }
265
+ }
static/galeria.css ADDED
@@ -0,0 +1,377 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
2
+
3
+ :root {
4
+ --ink: #0d0a16;
5
+ --paper: #f5ede8;
6
+ --gold: #c9a030;
7
+ --gold-lt: #e8c96a;
8
+ --rose: #e8b4c4;
9
+ --text: #f0e8f0;
10
+ --text-dim: #9880a8;
11
+ --border: rgba(201,160,48,.25);
12
+ --card-bg: rgba(22, 14, 32, 0.92);
13
+ }
14
+
15
+ html { scroll-behavior: smooth; }
16
+
17
+ body {
18
+ min-height: 100vh;
19
+ background: var(--ink);
20
+ color: var(--text);
21
+ font-family: 'Cormorant Garamond', Georgia, serif;
22
+ overflow-x: hidden;
23
+ }
24
+
25
+ /* ── Background glow ─────────────────────────────────────────────────── */
26
+ body::before {
27
+ content: '';
28
+ position: fixed;
29
+ inset: 0;
30
+ background:
31
+ radial-gradient(ellipse 80% 50% at 20% 10%, rgba(200,120,180,.12) 0%, transparent 60%),
32
+ radial-gradient(ellipse 60% 40% at 80% 90%, rgba(100,80,200,.10) 0%, transparent 60%),
33
+ radial-gradient(ellipse 100% 80% at 50% 50%, rgba(13,10,22,1) 60%, transparent 100%);
34
+ pointer-events: none;
35
+ z-index: 0;
36
+ }
37
+
38
+ .vignette {
39
+ position: fixed;
40
+ inset: 0;
41
+ background: radial-gradient(ellipse at center, transparent 40%, rgba(5,3,10,.7) 100%);
42
+ pointer-events: none;
43
+ z-index: 1;
44
+ }
45
+
46
+ /* ── Floating particles ──────────────────────────────────────────────── */
47
+ .particles {
48
+ position: fixed;
49
+ inset: 0;
50
+ pointer-events: none;
51
+ z-index: 1;
52
+ }
53
+
54
+ .particles span {
55
+ position: absolute;
56
+ display: block;
57
+ border-radius: 50%;
58
+ background: var(--gold);
59
+ opacity: 0;
60
+ animation: float-up 8s infinite ease-in;
61
+ }
62
+
63
+ .particles span:nth-child(1) { width:3px; height:3px; left:15%; animation-delay:0s; animation-duration:9s; }
64
+ .particles span:nth-child(2) { width:2px; height:2px; left:28%; animation-delay:1.5s; animation-duration:11s; }
65
+ .particles span:nth-child(3) { width:4px; height:4px; left:42%; animation-delay:3s; animation-duration:8s; }
66
+ .particles span:nth-child(4) { width:2px; height:2px; left:58%; animation-delay:0.8s; animation-duration:12s; }
67
+ .particles span:nth-child(5) { width:3px; height:3px; left:72%; animation-delay:2.2s; animation-duration:9s; }
68
+ .particles span:nth-child(6) { width:2px; height:2px; left:85%; animation-delay:4s; animation-duration:10s; }
69
+ .particles span:nth-child(7) { width:3px; height:3px; left:8%; animation-delay:5s; animation-duration:13s; }
70
+ .particles span:nth-child(8) { width:2px; height:2px; left:35%; animation-delay:1s; animation-duration:7s; }
71
+ .particles span:nth-child(9) { width:4px; height:4px; left:62%; animation-delay:3.5s; animation-duration:11s; }
72
+ .particles span:nth-child(10) { width:2px; height:2px; left:90%; animation-delay:0.3s; animation-duration:8s; }
73
+
74
+ @keyframes float-up {
75
+ 0% { opacity: 0; transform: translateY(100vh) scale(.5); }
76
+ 10% { opacity: .6; }
77
+ 90% { opacity: .2; }
78
+ 100% { opacity: 0; transform: translateY(-10vh) scale(1.2); }
79
+ }
80
+
81
+ /* ── Header ──────────────────────────────────────────────────────────── */
82
+ .marco-header {
83
+ position: relative;
84
+ z-index: 10;
85
+ display: grid;
86
+ grid-template-columns: 1fr auto 1fr;
87
+ align-items: center;
88
+ padding: 40px 60px 32px;
89
+ border-bottom: 1px solid var(--border);
90
+ }
91
+
92
+ .back-link {
93
+ display: flex;
94
+ align-items: center;
95
+ gap: 8px;
96
+ color: var(--text-dim);
97
+ text-decoration: none;
98
+ font-size: 1rem;
99
+ font-style: italic;
100
+ letter-spacing: .05em;
101
+ transition: color .2s;
102
+ }
103
+
104
+ .back-link:hover { color: var(--gold-lt); }
105
+
106
+ .marco-title-block {
107
+ text-align: center;
108
+ }
109
+
110
+ .marco-glyph {
111
+ display: block;
112
+ color: var(--gold);
113
+ font-size: 1.8rem;
114
+ margin-bottom: 6px;
115
+ animation: pulse-glow 3s ease-in-out infinite;
116
+ }
117
+
118
+ @keyframes pulse-glow {
119
+ 0%, 100% { text-shadow: 0 0 8px rgba(201,160,48,.4); }
120
+ 50% { text-shadow: 0 0 20px rgba(201,160,48,.8), 0 0 40px rgba(201,160,48,.3); }
121
+ }
122
+
123
+ .marco-title {
124
+ font-size: 3.8rem;
125
+ font-weight: 300;
126
+ letter-spacing: .25em;
127
+ color: var(--gold-lt);
128
+ line-height: 1;
129
+ font-style: italic;
130
+ }
131
+
132
+ .marco-sub {
133
+ font-size: 1rem;
134
+ color: var(--text-dim);
135
+ font-style: italic;
136
+ letter-spacing: .12em;
137
+ margin-top: 6px;
138
+ }
139
+
140
+ .marco-count {
141
+ text-align: right;
142
+ font-size: .85rem;
143
+ color: var(--text-dim);
144
+ font-style: italic;
145
+ letter-spacing: .05em;
146
+ }
147
+
148
+ /* ── Main grid ───────────────────────────────────────────────────────── */
149
+ .marco-main {
150
+ position: relative;
151
+ z-index: 10;
152
+ padding: 56px 60px 80px;
153
+ }
154
+
155
+ .marco-grid {
156
+ columns: 4;
157
+ column-gap: 24px;
158
+ }
159
+
160
+ @media (max-width: 1300px) { .marco-grid { columns: 3; } }
161
+ @media (max-width: 900px) { .marco-grid { columns: 2; } }
162
+ @media (max-width: 560px) { .marco-grid { columns: 1; } }
163
+
164
+ .marco-loading {
165
+ column-span: all;
166
+ text-align: center;
167
+ padding: 100px 20px;
168
+ color: var(--text-dim);
169
+ }
170
+
171
+ .loading-glyph {
172
+ display: block;
173
+ font-size: 2rem;
174
+ color: var(--gold);
175
+ margin-bottom: 16px;
176
+ animation: pulse-glow 2s ease-in-out infinite;
177
+ }
178
+
179
+ /* ── Dream card ──────────────────────────────────────────────────────── */
180
+ .marco-card {
181
+ break-inside: avoid;
182
+ margin-bottom: 28px;
183
+ cursor: pointer;
184
+ position: relative;
185
+ }
186
+
187
+ .marco-card-inner {
188
+ background: var(--card-bg);
189
+ border: 1px solid var(--border);
190
+ border-radius: 2px;
191
+ overflow: hidden;
192
+ transition: transform .35s ease, box-shadow .35s ease, border-color .35s ease;
193
+ }
194
+
195
+ .marco-card:hover .marco-card-inner {
196
+ transform: translateY(-4px) scale(1.01);
197
+ box-shadow: 0 12px 40px rgba(201,160,48,.15), 0 0 0 1px rgba(201,160,48,.35);
198
+ border-color: rgba(201,160,48,.5);
199
+ }
200
+
201
+ /* Ornate corner accents */
202
+ .marco-card-inner::before,
203
+ .marco-card-inner::after {
204
+ content: '◈';
205
+ position: absolute;
206
+ color: rgba(201,160,48,.3);
207
+ font-size: .6rem;
208
+ transition: color .35s;
209
+ z-index: 2;
210
+ }
211
+
212
+ .marco-card-inner::before { top: 6px; left: 8px; }
213
+ .marco-card-inner::after { bottom: 6px; right: 8px; }
214
+ .marco-card:hover .marco-card-inner::before,
215
+ .marco-card:hover .marco-card-inner::after { color: rgba(201,160,48,.7); }
216
+
217
+ .marco-card-img {
218
+ width: 100%;
219
+ display: block;
220
+ aspect-ratio: 1;
221
+ object-fit: cover;
222
+ transition: filter .35s;
223
+ }
224
+
225
+ .marco-card:hover .marco-card-img { filter: brightness(1.08) saturate(1.05); }
226
+
227
+ .marco-card-body {
228
+ padding: 14px 16px 16px;
229
+ border-top: 1px solid var(--border);
230
+ }
231
+
232
+ .marco-card-desc {
233
+ font-size: 1rem;
234
+ font-style: italic;
235
+ color: var(--text);
236
+ line-height: 1.55;
237
+ display: -webkit-box;
238
+ -webkit-line-clamp: 2;
239
+ -webkit-box-orient: vertical;
240
+ overflow: hidden;
241
+ }
242
+
243
+ .marco-card-author {
244
+ display: block;
245
+ margin-top: 8px;
246
+ font-size: .78rem;
247
+ color: var(--gold);
248
+ letter-spacing: .1em;
249
+ font-style: normal;
250
+ font-family: 'Cormorant SC', serif;
251
+ }
252
+
253
+ /* ── Footer ──────────────────────────────────────────────────────────── */
254
+ .marco-footer {
255
+ position: relative;
256
+ z-index: 10;
257
+ text-align: center;
258
+ padding: 28px;
259
+ border-top: 1px solid var(--border);
260
+ font-size: .82rem;
261
+ color: var(--text-dim);
262
+ font-style: italic;
263
+ letter-spacing: .08em;
264
+ display: flex;
265
+ align-items: center;
266
+ justify-content: center;
267
+ gap: 12px;
268
+ }
269
+
270
+ .marco-footer .logo-glyph { color: var(--gold); font-size: 1rem; }
271
+
272
+ /* ── Lightbox ────────────────────────────────────────────────────────── */
273
+ .lightbox {
274
+ position: fixed;
275
+ inset: 0;
276
+ z-index: 100;
277
+ display: flex;
278
+ align-items: center;
279
+ justify-content: center;
280
+ padding: 24px;
281
+ }
282
+
283
+ .lightbox.hidden { display: none; }
284
+
285
+ .lightbox-backdrop {
286
+ position: absolute;
287
+ inset: 0;
288
+ background: rgba(5, 3, 10, .92);
289
+ backdrop-filter: blur(12px);
290
+ }
291
+
292
+ .lightbox-inner {
293
+ position: relative;
294
+ max-width: 900px;
295
+ width: 100%;
296
+ background: rgba(16, 10, 24, .98);
297
+ border: 1px solid var(--border);
298
+ border-radius: 4px;
299
+ padding: 40px;
300
+ display: flex;
301
+ flex-direction: column;
302
+ gap: 20px;
303
+ max-height: 90vh;
304
+ overflow-y: auto;
305
+ box-shadow: 0 0 60px rgba(201,160,48,.08);
306
+ }
307
+
308
+ .lightbox-close {
309
+ position: absolute;
310
+ top: 16px; right: 16px;
311
+ background: none;
312
+ border: 1px solid var(--border);
313
+ color: var(--text-dim);
314
+ width: 32px; height: 32px;
315
+ border-radius: 4px;
316
+ cursor: pointer;
317
+ font-size: .9rem;
318
+ font-family: inherit;
319
+ transition: color .2s, border-color .2s;
320
+ }
321
+
322
+ .lightbox-close:hover { color: var(--text); border-color: var(--gold); }
323
+
324
+ .lightbox-diptych {
325
+ display: grid;
326
+ grid-template-columns: 1fr auto 1fr;
327
+ gap: 16px;
328
+ align-items: center;
329
+ }
330
+
331
+ .lb-panel { display: flex; flex-direction: column; gap: 8px; }
332
+
333
+ .lb-lbl {
334
+ text-align: center;
335
+ font-size: .72rem;
336
+ letter-spacing: .14em;
337
+ text-transform: uppercase;
338
+ color: var(--text-dim);
339
+ font-style: normal;
340
+ font-family: 'Cormorant SC', serif;
341
+ }
342
+
343
+ .lb-panel img {
344
+ width: 100%;
345
+ aspect-ratio: 1;
346
+ object-fit: cover;
347
+ border-radius: 2px;
348
+ border: 1px solid var(--border);
349
+ display: block;
350
+ }
351
+
352
+ .lb-divider {
353
+ color: var(--gold);
354
+ font-size: 1.8rem;
355
+ flex-shrink: 0;
356
+ font-style: italic;
357
+ font-family: 'Cormorant Garamond', Georgia, serif;
358
+ }
359
+
360
+ .lb-desc {
361
+ text-align: center;
362
+ font-style: italic;
363
+ font-size: 1.2rem;
364
+ color: var(--text);
365
+ line-height: 1.7;
366
+ padding: 0 20px;
367
+ }
368
+
369
+ .lb-meta {
370
+ text-align: center;
371
+ font-size: .82rem;
372
+ color: var(--gold);
373
+ letter-spacing: .1em;
374
+ font-family: 'Cormorant SC', serif;
375
+ }
376
+
377
+ .hidden { display: none !important; }
static/galeria.html ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <meta name="description" content="Explore the collective dreams of the community. A public exhibition of AI-materialized sketches in the Dream Museum." />
7
+ <title>Public Gallery — Dream Museum</title>
8
+ <link rel="preconnect" href="https://fonts.googleapis.com">
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
+ <link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,600;1,300;1,400;1,600&family=Cormorant+SC:wght@300;400&display=swap" rel="stylesheet">
11
+ <link rel="stylesheet" href="/static/galeria.css" />
12
+ </head>
13
+ <body>
14
+
15
+ <div class="vignette"></div>
16
+
17
+ <div class="particles" aria-hidden="true">
18
+ <span></span><span></span><span></span><span></span><span></span>
19
+ <span></span><span></span><span></span><span></span><span></span>
20
+ </div>
21
+
22
+ <header class="marco-header">
23
+ <a href="/museum" class="back-link" title="Back to museum">
24
+ <svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16">
25
+ <path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/>
26
+ </svg>
27
+ Back to Museum
28
+ </a>
29
+ <div class="marco-title-block">
30
+ <span class="marco-glyph">◈</span>
31
+ <h1 class="marco-title">Public Gallery</h1>
32
+ <p class="marco-sub">The collective dream</p>
33
+ </div>
34
+ <div class="marco-count"><span id="dream-count">—</span> dreams on display</div>
35
+ </header>
36
+
37
+ <main class="marco-main">
38
+ <div id="marco-grid" class="marco-grid">
39
+ <div class="marco-loading">
40
+ <span class="loading-glyph">◈</span>
41
+ <p>Summoning dreams…</p>
42
+ </div>
43
+ </div>
44
+ </main>
45
+
46
+ <footer class="marco-footer">
47
+ <span class="logo-glyph">◈</span>
48
+ <span>Dream Museum · Build Small Hackathon 2026</span>
49
+ </footer>
50
+
51
+ <div id="lightbox" class="lightbox hidden">
52
+ <div class="lightbox-backdrop"></div>
53
+ <div class="lightbox-inner">
54
+ <button class="lightbox-close">✕</button>
55
+ <div class="lightbox-diptych">
56
+ <div class="lb-panel">
57
+ <p class="lb-lbl">Sketch</p>
58
+ <img id="lb-sketch" alt="" />
59
+ </div>
60
+ <div class="lb-divider">→</div>
61
+ <div class="lb-panel">
62
+ <p class="lb-lbl">Dream</p>
63
+ <img id="lb-dream" alt="" />
64
+ </div>
65
+ </div>
66
+ <p id="lb-desc" class="lb-desc"></p>
67
+ <p id="lb-meta" class="lb-meta"></p>
68
+ </div>
69
+ </div>
70
+
71
+ <script src="/static/galeria.js"></script>
72
+ </body>
73
+ </html>
static/galeria.js ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ (async () => {
2
+ const grid = document.getElementById('marco-grid');
3
+ const countEl = document.getElementById('dream-count');
4
+
5
+ let dreams = [];
6
+
7
+ try {
8
+ const res = await fetch('/gallery/public', {
9
+ method: 'POST',
10
+ headers: { 'Content-Type': 'application/json' },
11
+ body: JSON.stringify({ limit: 100, offset: 0 }),
12
+ });
13
+ const data = await res.json();
14
+ if (!data.ok) throw new Error(data.error || 'Error');
15
+ dreams = data.dreams;
16
+ } catch {
17
+ grid.innerHTML = `<div class="marco-loading"><span class="loading-glyph">◈</span><p>Could not load dreams.</p></div>`;
18
+ return;
19
+ }
20
+
21
+ countEl.textContent = dreams.length;
22
+
23
+ if (!dreams.length) {
24
+ grid.innerHTML = `<div class="marco-loading"><span class="loading-glyph">◈</span><p>No dreams on display yet.</p></div>`;
25
+ return;
26
+ }
27
+
28
+ grid.innerHTML = '';
29
+ dreams.forEach(d => {
30
+ const card = document.createElement('div');
31
+ card.className = 'marco-card';
32
+ card.innerHTML = `
33
+ <div class="marco-card-inner">
34
+ <img class="marco-card-img" src="${esc(d.image_url)}" alt="${esc(d.description)}" loading="lazy" />
35
+ <div class="marco-card-body">
36
+ <p class="marco-card-desc">${esc(d.description)}</p>
37
+ <span class="marco-card-author">✦ ${esc(d.user_id)}</span>
38
+ </div>
39
+ </div>
40
+ `;
41
+ card.addEventListener('click', () => openLightbox(d));
42
+ grid.appendChild(card);
43
+ });
44
+
45
+ // Lightbox
46
+ const lb = document.getElementById('lightbox');
47
+ const lbSketch = document.getElementById('lb-sketch');
48
+ const lbDream = document.getElementById('lb-dream');
49
+ const lbDesc = document.getElementById('lb-desc');
50
+ const lbMeta = document.getElementById('lb-meta');
51
+
52
+ function openLightbox(d) {
53
+ lbSketch.src = d.sketch_url;
54
+ lbDream.src = d.image_url;
55
+ lbDesc.textContent = `"${d.description}"`;
56
+ lbMeta.textContent = `${d.user_id} · ${(d.timestamp || '').slice(0, 10)}`;
57
+ lb.classList.remove('hidden');
58
+ }
59
+
60
+ function closeLightbox() { lb.classList.add('hidden'); }
61
+
62
+ document.querySelector('.lightbox-close').addEventListener('click', closeLightbox);
63
+ document.querySelector('.lightbox-backdrop').addEventListener('click', closeLightbox);
64
+ document.addEventListener('keydown', e => { if (e.key === 'Escape') closeLightbox(); });
65
+ })();
66
+
67
+ function esc(s) {
68
+ return String(s ?? '')
69
+ .replace(/&/g, '&amp;')
70
+ .replace(/</g, '&lt;')
71
+ .replace(/>/g, '&gt;')
72
+ .replace(/"/g, '&quot;');
73
+ }
static/index.html ADDED
@@ -0,0 +1,223 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <meta name="description" content="Draw your dreams and see them come to life in an interactive 3D art museum. Powered by SDXL and ControlNet scribble guidance." />
7
+ <title>Dream Museum — Materialize Your Dreams in 3D</title>
8
+ <link rel="preconnect" href="https://fonts.googleapis.com">
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
+ <link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,600;1,300;1,400;1,600&display=swap" rel="stylesheet">
11
+ <link rel="stylesheet" href="/static/style.css" />
12
+ <script type="importmap">
13
+ {
14
+ "imports": {
15
+ "three": "https://cdn.jsdelivr.net/npm/three@0.183.0/build/three.module.js",
16
+ "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.183.0/examples/jsm/"
17
+ }
18
+ }
19
+ </script>
20
+ </head>
21
+ <body>
22
+
23
+ <canvas id="museum-canvas"></canvas>
24
+
25
+ <!-- Floating logo — top left -->
26
+ <div id="museum-logo">
27
+ <span class="logo-glyph">◈</span>
28
+ <span class="logo-text">Dream Museum</span>
29
+ </div>
30
+
31
+ <!-- Top right — auth + my gallery -->
32
+ <div id="hud-topright">
33
+ <div class="auth-area">
34
+ <span id="auth-name" class="auth-name hidden"></span>
35
+ <input id="auth-input" class="auth-input" type="text" placeholder="Enter as…" />
36
+ <button id="auth-btn" class="btn-outline-sm">✦</button>
37
+ </div>
38
+ <button class="hud-pill" id="btn-my-gallery">My Gallery</button>
39
+ </div>
40
+
41
+ <!-- Bottom-left FAB — Paint -->
42
+ <button class="fab fab--left" id="btn-create">
43
+ <span class="fab-icon">
44
+ <svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20">
45
+ <path d="M7 14c-1.66 0-3 1.34-3 3 0 1.31-1.16 2-2 2 .92 1.22 2.49 2 4 2 2.21 0 4-1.79 4-4 0-1.66-1.34-3-3-3zm13.71-9.37l-1.34-1.34c-.39-.39-1.02-.39-1.41 0L9 12.25 11.75 15l8.96-8.96c.39-.39.39-1.02 0-1.41z"/>
46
+ </svg>
47
+ </span>
48
+ <span class="fab-label">Paint</span>
49
+ </button>
50
+
51
+ <!-- Bottom-right FAB — Gallery -->
52
+ <a class="fab fab--right" href="/galeria">
53
+ <span class="fab-icon">◈</span>
54
+ <span class="fab-label">Gallery</span>
55
+ </a>
56
+
57
+ <!-- Hint -->
58
+ <div id="room-hint">
59
+ <span>Click a painting to view it</span>
60
+ </div>
61
+
62
+ <!-- ── Paint / Create panel ───────────────────────────────────────── -->
63
+ <div id="panel-create" class="side-panel hidden">
64
+ <button class="panel-close" id="close-create">✕</button>
65
+ <div class="panel-scroll">
66
+
67
+ <div class="panel-head">
68
+ <h2>Create a Dream</h2>
69
+ <p>Draw its shape. Describe its soul. Let the museum do the rest.</p>
70
+ </div>
71
+
72
+ <div class="create-layout">
73
+
74
+ <!-- Canvas column -->
75
+ <div class="canvas-col">
76
+ <div class="draw-toolbar">
77
+ <button class="tool active" id="t-pen" title="Brush">
78
+ <svg viewBox="0 0 24 24" fill="currentColor" width="15" height="15">
79
+ <path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/>
80
+ </svg>
81
+ </button>
82
+ <button class="tool" id="t-eraser" title="Eraser">
83
+ <svg viewBox="0 0 24 24" fill="currentColor" width="15" height="15">
84
+ <path d="M15.14 3c-.51 0-1.02.2-1.41.59L2.59 14.73c-.78.77-.78 2.04 0 2.83L5.03 20h7.66l8.72-8.72c.79-.78.79-2.05 0-2.83l-4.86-4.86c-.39-.39-.9-.59-1.41-.59zm-3.73 15H5.17l-1.8-1.8 4.86-4.86 5.18 5.18V18z"/>
85
+ </svg>
86
+ </button>
87
+
88
+ <div class="tool-sep"></div>
89
+
90
+ <input type="color" id="t-color" value="#ffffff" class="color-picker" title="Color" />
91
+ <div class="color-swatches">
92
+ <button class="swatch" style="--c:#ffffff" data-color="#ffffff"></button>
93
+ <button class="swatch" style="--c:#f9c6da" data-color="#f9c6da"></button>
94
+ <button class="swatch" style="--c:#c9a0dc" data-color="#c9a0dc"></button>
95
+ <button class="swatch" style="--c:#7ec8e3" data-color="#7ec8e3"></button>
96
+ <button class="swatch" style="--c:#f0cc6e" data-color="#f0cc6e"></button>
97
+ <button class="swatch" style="--c:#ff8fab" data-color="#ff8fab"></button>
98
+ <button class="swatch" style="--c:#8888ee" data-color="#8888ee"></button>
99
+ <button class="swatch" style="--c:#1a0f2e" data-color="#1a0f2e"></button>
100
+ </div>
101
+
102
+ <div class="tool-sep"></div>
103
+
104
+ <div class="brush-row">
105
+ <input type="range" id="t-size" min="1" max="40" value="6" />
106
+ <div id="brush-preview" class="brush-preview"></div>
107
+ </div>
108
+
109
+ <div class="tool-sep"></div>
110
+
111
+ <button class="tool" id="t-clear" title="Clear">
112
+ <svg viewBox="0 0 24 24" fill="currentColor" width="15" height="15">
113
+ <path d="M19 4h-3.5l-1-1h-5l-1 1H5v2h14V4zM6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12z"/>
114
+ </svg>
115
+ </button>
116
+ </div>
117
+
118
+ <div class="canvas-frame">
119
+ <canvas id="draw-canvas" width="512" height="512"></canvas>
120
+ </div>
121
+ </div>
122
+
123
+ <!-- Controls column -->
124
+ <div class="ctrl-col">
125
+
126
+ <div class="field-group">
127
+ <label class="field-label" for="dream-desc">Describe your dream</label>
128
+ <textarea id="dream-desc"
129
+ placeholder="A cathedral of clouds, golden light through impossible windows, the feeling of floating between colours…"
130
+ rows="5"></textarea>
131
+ </div>
132
+
133
+ <div class="field-group">
134
+ <div class="slider-label">
135
+ <span>Sketch faithfulness</span>
136
+ <span id="strength-val" class="slider-num">0.7</span>
137
+ </div>
138
+ <input type="range" id="strength" min="0.3" max="1.0" step="0.05" value="0.7" />
139
+ <div class="slider-hints">
140
+ <span>Free interpretation</span>
141
+ <span>Follows sketch</span>
142
+ </div>
143
+ </div>
144
+
145
+ <button id="generate-btn" class="btn-materialize">
146
+ <span id="gen-label">✦ Materialize Dream</span>
147
+ <span id="gen-spin" class="spinner hidden"></span>
148
+ </button>
149
+
150
+ <div id="result-area" class="result-area hidden">
151
+ <div class="result-diptych">
152
+ <div class="res-side">
153
+ <div class="res-label">Sketch</div>
154
+ <img id="res-sketch" alt="sketch" />
155
+ </div>
156
+ <div class="res-arrow">→</div>
157
+ <div class="res-side">
158
+ <div class="res-label">Dream</div>
159
+ <img id="res-dream" alt="dream" />
160
+ </div>
161
+ </div>
162
+
163
+ <div id="save-area" class="save-area">
164
+ <p id="need-auth" class="auth-notice hidden">
165
+ Enter your name above to save this dream.
166
+ </p>
167
+ <div id="save-btns" class="save-btns hidden">
168
+ <button id="btn-publish" class="btn-gold">Exhibit in the Museum</button>
169
+ <button id="btn-private" class="btn-outline">Save privately</button>
170
+ </div>
171
+ <button id="btn-download" class="btn-ghost">Download image</button>
172
+ </div>
173
+ </div>
174
+
175
+ </div>
176
+ </div>
177
+
178
+ </div>
179
+ </div>
180
+
181
+ <!-- ── My Gallery panel ───────────────────────────────────────────── -->
182
+ <div id="panel-gallery" class="side-panel hidden">
183
+ <button class="panel-close" id="close-gallery">✕</button>
184
+ <div class="panel-scroll">
185
+ <div class="panel-head">
186
+ <h2>My Gallery</h2>
187
+ <p id="gallery-subtitle">Your private dream archive</p>
188
+ </div>
189
+ <div id="my-grid" class="dream-grid">
190
+ <div class="empty-state">No dreams saved yet.</div>
191
+ </div>
192
+ </div>
193
+ </div>
194
+
195
+ <!-- ── Dream detail modal ────────────────────────────────────────── -->
196
+ <div id="dream-modal" class="modal hidden">
197
+ <div class="modal-backdrop"></div>
198
+ <div class="modal-box">
199
+ <button class="modal-close">✕</button>
200
+ <div class="modal-diptych">
201
+ <div class="modal-side">
202
+ <div class="modal-lbl">Sketch</div>
203
+ <img id="modal-sketch" alt="sketch" />
204
+ </div>
205
+ <div class="modal-arrow">→</div>
206
+ <div class="modal-side">
207
+ <div class="modal-lbl">Dream</div>
208
+ <img id="modal-dream" alt="dream" />
209
+ </div>
210
+ </div>
211
+ <p id="modal-desc" class="modal-desc"></p>
212
+ <div id="modal-meta" class="modal-meta"></div>
213
+ <div id="modal-owner" class="modal-owner hidden">
214
+ <button id="modal-toggle" class="btn-outline"></button>
215
+ <button id="modal-delete" class="btn-danger">Delete</button>
216
+ </div>
217
+ </div>
218
+ </div>
219
+
220
+ <script src="https://cdn.jsdelivr.net/npm/gsap@3.12.2/dist/gsap.min.js"></script>
221
+ <script type="module" src="/static/app.js"></script>
222
+ </body>
223
+ </html>
static/models/studio-transformed.glb ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b3f6b0b139df2a4d207d46a5cd77e9106dae9782888f4347b79d0f3171b9d21f
3
+ size 578468
static/scene.js ADDED
@@ -0,0 +1,385 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 3D scene — based on not-the-louvre StudioScene.svelte + SceneTextureBindings.svelte
2
+ import * as THREE from 'three';
3
+ import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
4
+ import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
5
+
6
+ const gsap = window.gsap;
7
+
8
+ // ── Camera poses (exact values from StudioScene.svelte) ──────────────────────
9
+ const DEFAULT_CAMERA = {
10
+ cameraX: 0, cameraDepth: 16, cameraHeight: 7.8,
11
+ rotationX: -0.22, rotationY: 0,
12
+ targetX: 0, targetY: 0.8, targetZ: 0,
13
+ zoom: 62
14
+ };
15
+ const LEFT_WALL_CAMERA = {
16
+ cameraX: 0, cameraDepth: 16, cameraHeight: 0.8,
17
+ rotationX: 0, rotationY: 0,
18
+ targetX: 0, targetY: 0.8, targetZ: 0,
19
+ zoom: 12000
20
+ };
21
+ const TOP_CLOSE_CAMERA = {
22
+ cameraX: 1.8, cameraDepth: 0.6, cameraHeight: 0,
23
+ rotationX: 0, rotationY: 0,
24
+ targetX: 1.8, targetY: -1.8, targetZ: 0.6,
25
+ zoom: 450
26
+ };
27
+
28
+ // Animation durations (from StudioScene.svelte)
29
+ const ENTER_DURATION = 2.08;
30
+ const LEFT_WALL_ROTATE_DUR = 1.1;
31
+ const LEFT_WALL_ZOOM_DUR = 2.2;
32
+ const LEFT_WALL_EXIT_ZOOM_DUR = 1.1;
33
+ const LEFT_WALL_EXIT_ROT_DUR = 1.75;
34
+ const TOP_CLOSE_ENTER_DUR = 2.0;
35
+ const TOP_CLOSE_EXIT_DUR = 1.4;
36
+
37
+ // Artwork slot names discovered from GLB (console log confirmed)
38
+ const ARTWORK_SLOTS = ['a1','a2','a3','a4','a5','a6','a7','a8','a9','a10','a11','art1'];
39
+
40
+ // ── Module state ──────────────────────────────────────────────────────────────
41
+ let renderer, scene, camera;
42
+ let modelRoot = null;
43
+ let nodes = {}; // mesh name → Object3D
44
+ let appliedMaterials = []; // track for cleanup
45
+ let activeTimeline = null;
46
+ let dreamsBySlot = {}; // slotName → dream object
47
+ let interactables = []; // meshes with click
48
+ let hoveredMesh = null;
49
+ let _onClickCb = null;
50
+ let raycaster, pointer;
51
+
52
+ const studioMotion = { ...DEFAULT_CAMERA };
53
+
54
+ function _syncCamera() {
55
+ camera.position.set(studioMotion.cameraX, studioMotion.cameraHeight, studioMotion.cameraDepth);
56
+ camera.lookAt(studioMotion.targetX, studioMotion.targetY, studioMotion.targetZ);
57
+ camera.zoom = studioMotion.zoom;
58
+ camera.updateProjectionMatrix();
59
+ if (modelRoot) {
60
+ modelRoot.rotation.x = studioMotion.rotationX;
61
+ modelRoot.rotation.y = studioMotion.rotationY;
62
+ }
63
+ }
64
+
65
+ // ── Public API ────────────────────────────────────────────────────────────────
66
+
67
+ export function init(canvas, onPaintingClick) {
68
+ _onClickCb = onPaintingClick;
69
+
70
+ _initRenderer(canvas);
71
+ _initScene();
72
+ _initCamera();
73
+ _loadModel();
74
+
75
+ raycaster = new THREE.Raycaster();
76
+ pointer = new THREE.Vector2(-999, -999);
77
+
78
+ canvas.addEventListener('pointermove', _onMove);
79
+ canvas.addEventListener('click', _onClick);
80
+ window.addEventListener('resize', _onResize);
81
+ _tick();
82
+ }
83
+
84
+ // Apply dreams to artwork slots. dreams = array of dream objects (max 12).
85
+ export function setPaintings(dreams) {
86
+ // Clear previous
87
+ for (const { mesh, origMat, mat } of appliedMaterials) {
88
+ if (mesh.material === mat) mesh.material = origMat;
89
+ mat.dispose();
90
+ }
91
+ appliedMaterials = [];
92
+ dreamsBySlot = {};
93
+ interactables = [];
94
+
95
+ const loader = new THREE.TextureLoader();
96
+
97
+ dreams.forEach((dream, i) => {
98
+ const slotName = ARTWORK_SLOTS[i];
99
+ if (!slotName) return;
100
+
101
+ const mesh = nodes[slotName];
102
+ if (!mesh || Array.isArray(mesh.material)) return;
103
+
104
+ dreamsBySlot[slotName] = dream;
105
+ interactables.push(mesh);
106
+
107
+ loader.loadAsync(dream.image_url).then(tex => {
108
+ tex.colorSpace = THREE.SRGBColorSpace;
109
+ tex.flipY = false;
110
+ tex.needsUpdate = true;
111
+
112
+ const mat = new THREE.MeshBasicMaterial({
113
+ map: tex, side: THREE.DoubleSide, transparent: true,
114
+ });
115
+ appliedMaterials.push({ mesh, origMat: mesh.material, mat });
116
+ mesh.material = mat;
117
+ }).catch(() => {});
118
+ });
119
+ }
120
+
121
+ // ── Pose transitions (from StudioScene.svelte effect) ───────────────────────
122
+
123
+ export function enterDefault() {
124
+ _kill();
125
+ activeTimeline = gsap.timeline()
126
+ .to(studioMotion, {
127
+ cameraX: 0, zoom: 60, cameraDepth: 16, cameraHeight: 7.8,
128
+ targetX: 0, targetY: 0.8, targetZ: 0, rotationX: -0.22,
129
+ duration: ENTER_DURATION, ease: 'power4.inOut', onUpdate: _syncCamera
130
+ })
131
+ .to(studioMotion, {
132
+ rotationY: -Math.PI / 4,
133
+ duration: ENTER_DURATION - 0.55, ease: 'power3.inOut', onUpdate: _syncCamera
134
+ }, 0.55);
135
+ }
136
+
137
+ export function goToLeftWall() {
138
+ _kill();
139
+ activeTimeline = gsap.timeline()
140
+ .to(studioMotion, {
141
+ rotationY: 0,
142
+ duration: LEFT_WALL_ROTATE_DUR, ease: 'power3.inOut', onUpdate: _syncCamera
143
+ })
144
+ .to(studioMotion, {
145
+ cameraX: 0, cameraDepth: 16, cameraHeight: 0.8,
146
+ targetX: 0, targetY: 0.8, targetZ: 0, rotationX: 0, zoom: 12000,
147
+ duration: LEFT_WALL_ZOOM_DUR, ease: 'power4.inOut', onUpdate: _syncCamera
148
+ });
149
+ }
150
+
151
+ export function leaveLeftWall() {
152
+ _kill();
153
+ activeTimeline = gsap.timeline()
154
+ .to(studioMotion, {
155
+ cameraX: 0, zoom: 60, cameraDepth: 16, cameraHeight: 7.8,
156
+ targetX: 0, targetY: 0.8, targetZ: 0, rotationX: -0.22,
157
+ duration: LEFT_WALL_EXIT_ZOOM_DUR, ease: 'power3.out', onUpdate: _syncCamera
158
+ })
159
+ .to(studioMotion, {
160
+ rotationY: -Math.PI / 4,
161
+ duration: LEFT_WALL_EXIT_ROT_DUR, ease: 'power3.inOut', onUpdate: _syncCamera
162
+ });
163
+ }
164
+
165
+ export function goToTopClose() {
166
+ _kill();
167
+ activeTimeline = gsap.to(studioMotion, {
168
+ zoom: 450, cameraX: 1.8, cameraDepth: 0.6, cameraHeight: 0,
169
+ targetX: 1.8, targetY: -1.8, targetZ: 0.6, rotationX: 0, rotationY: 0,
170
+ duration: TOP_CLOSE_ENTER_DUR, ease: 'power3.inOut', onUpdate: _syncCamera
171
+ });
172
+ }
173
+
174
+ export function leaveTopClose() {
175
+ _kill();
176
+ activeTimeline = gsap.timeline()
177
+ .to(studioMotion, {
178
+ cameraX: 0, zoom: 60, cameraDepth: 16, cameraHeight: 7.8,
179
+ targetX: 0, targetY: 0.8, targetZ: 0, rotationX: -0.22,
180
+ duration: TOP_CLOSE_EXIT_DUR, ease: 'power3.inOut', onUpdate: _syncCamera
181
+ })
182
+ .to(studioMotion, {
183
+ rotationY: -Math.PI / 4,
184
+ duration: 0.8, ease: 'power3.inOut', onUpdate: _syncCamera
185
+ });
186
+ }
187
+
188
+ // ── Three.js init ─────────────────────────────────────────────────────────────
189
+
190
+ function _initRenderer(canvas) {
191
+ renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
192
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
193
+ renderer.setSize(window.innerWidth, window.innerHeight);
194
+ renderer.shadowMap.enabled = true;
195
+ renderer.shadowMap.type = THREE.VSMShadowMap;
196
+ renderer.toneMapping = THREE.ACESFilmicToneMapping;
197
+ renderer.toneMappingExposure = 1.15;
198
+ }
199
+
200
+ function _initScene() {
201
+ scene = new THREE.Scene();
202
+ scene.background = new THREE.Color('#f5cedd');
203
+ scene.fog = new THREE.Fog('#f9dce8', 30, 68);
204
+
205
+ // Dreamy pink-warm lights
206
+ scene.add(new THREE.AmbientLight(0xffb8cc, 0.28));
207
+
208
+ const dirLight = new THREE.DirectionalLight(0xfff0f5, 1.5);
209
+ dirLight.position.set(5, 16, 6);
210
+ dirLight.castShadow = true;
211
+ scene.add(dirLight);
212
+
213
+ // Soft bounce from below
214
+ const fillLight = new THREE.DirectionalLight(0xff99bb, 0.2);
215
+ fillLight.position.set(-3, -4, 8);
216
+ scene.add(fillLight);
217
+
218
+ // Shadow receiver circle (exact from StudioScene.svelte)
219
+ const shadowCircle = new THREE.Mesh(
220
+ new THREE.CircleGeometry(18, 48),
221
+ new THREE.ShadowMaterial({ transparent: true, opacity: 0.2 })
222
+ );
223
+ shadowCircle.rotation.set(-Math.PI / 2, 0, 0.5);
224
+ shadowCircle.position.set(0, -2.62, 0);
225
+ shadowCircle.receiveShadow = true;
226
+ scene.add(shadowCircle);
227
+ }
228
+
229
+ function _initCamera() {
230
+ const w = window.innerWidth / 2;
231
+ const h = window.innerHeight / 2;
232
+ camera = new THREE.OrthographicCamera(-w, w, h, -h, -1000, 1000);
233
+ camera.position.set(DEFAULT_CAMERA.cameraX, DEFAULT_CAMERA.cameraHeight, DEFAULT_CAMERA.cameraDepth);
234
+ camera.zoom = DEFAULT_CAMERA.zoom;
235
+ camera.lookAt(DEFAULT_CAMERA.targetX, DEFAULT_CAMERA.targetY, DEFAULT_CAMERA.targetZ);
236
+ camera.updateProjectionMatrix();
237
+ }
238
+
239
+ function _loadModel() {
240
+ const dracoLoader = new DRACOLoader();
241
+ // Use Google's CDN for DRACO decoder (reliable)
242
+ dracoLoader.setDecoderPath('https://www.gstatic.com/draco/versioned/decoders/1.5.6/');
243
+
244
+ const loader = new GLTFLoader();
245
+ loader.setDRACOLoader(dracoLoader);
246
+
247
+ loader.load(
248
+ '/static/models/studio-transformed.glb',
249
+ (gltf) => {
250
+ modelRoot = gltf.scene;
251
+ modelRoot.scale.setScalar(5.7);
252
+ modelRoot.position.set(0, -6, 0);
253
+ // -Math.PI/4 on Y = the "inside the museum" diagonal view from not-the-louvre
254
+ modelRoot.rotation.set(DEFAULT_CAMERA.rotationX, -Math.PI / 4, 0);
255
+ studioMotion.rotationX = DEFAULT_CAMERA.rotationX;
256
+ studioMotion.rotationY = -Math.PI / 4;
257
+
258
+ modelRoot.traverse(obj => {
259
+ obj.castShadow = true;
260
+ obj.receiveShadow = true;
261
+ if (obj.name) nodes[obj.name] = obj;
262
+ });
263
+
264
+ console.log('[Dream Museum] GLB nodes:', Object.keys(nodes).join(', '));
265
+
266
+ scene.add(modelRoot);
267
+ },
268
+ undefined,
269
+ (err) => console.error('[Dream Museum] GLB load error:', err)
270
+ );
271
+ }
272
+
273
+ // ── Clouds ────────────────────────────────────────────────────────────────────
274
+
275
+ function _buildClouds() {
276
+ const mat = new THREE.MeshStandardMaterial({
277
+ color: 0xfff0f5,
278
+ transparent: true,
279
+ opacity: 0.82,
280
+ roughness: 1.0,
281
+ metalness: 0,
282
+ depthWrite: false,
283
+ });
284
+
285
+ // [x, y, z, size] — peripheral ring, never over the studio center
286
+ const configs = [
287
+ // Left side wall
288
+ [-40, -4, 8, 5.5 ],
289
+ [-44, 2, -4, 4.5 ],
290
+ [-36, -8, -8, 5.0 ],
291
+ // Right side wall
292
+ [ 40, -4, 8, 5.5 ],
293
+ [ 44, 2, -4, 4.5 ],
294
+ [ 36, -8, -8, 5.0 ],
295
+ // Far background (behind the room)
296
+ [-18, -2, -28, 7.0 ],
297
+ [ 18, -2, -28, 7.0 ],
298
+ [-36, 0, -22, 5.5 ],
299
+ [ 36, 0, -22, 5.5 ],
300
+ // Front left / right corners (toward the camera)
301
+ [-38, -6, 20, 5.0 ],
302
+ [ 38, -6, 20, 5.0 ],
303
+ // High sides
304
+ [-30, 14, -6, 5.0 ],
305
+ [ 30, 14, -6, 5.0 ],
306
+ [-20, 10, 16, 4.5 ],
307
+ [ 20, 10, 16, 4.5 ],
308
+ ];
309
+
310
+ configs.forEach(([x, y, z, s]) => scene.add(_cloudGroup(x, y, z, s, mat)));
311
+ }
312
+
313
+ function _cloudGroup(x, y, z, size, mat) {
314
+ const g = new THREE.Group();
315
+
316
+ // Cluster of spheres at varying offsets — gives a fluffy cumulus shape
317
+ const puffs = [
318
+ [ 0, 0, 0, 1.00 ],
319
+ [ size * 0.88, size * 0.1, 0, 0.72 ],
320
+ [-size * 0.88, size * 0.1, 0, 0.72 ],
321
+ [ size * 0.42, size * 0.52, 0, 0.62 ],
322
+ [-size * 0.42, size * 0.52, 0, 0.62 ],
323
+ [ 0, size * 0.62, 0, 0.52 ],
324
+ [ size * 0.28, size * 0.22, size * 0.38, 0.58 ],
325
+ [-size * 0.28, size * 0.18,-size * 0.32, 0.55 ],
326
+ [ size * 1.35, size * 0.05, size * 0.2, 0.50 ],
327
+ [-size * 1.35, size * 0.05,-size * 0.2, 0.50 ],
328
+ ];
329
+
330
+ puffs.forEach(([ox, oy, oz, r]) => {
331
+ const m = new THREE.Mesh(new THREE.SphereGeometry(size * r, 8, 6), mat);
332
+ m.position.set(ox, oy, oz);
333
+ g.add(m);
334
+ });
335
+
336
+ g.position.set(x, y, z);
337
+ return g;
338
+ }
339
+
340
+ // ── Interaction ───────────────────────────────────────────────────────────────
341
+
342
+ function _onMove(e) {
343
+ pointer.x = (e.clientX / window.innerWidth) * 2 - 1;
344
+ pointer.y = -(e.clientY / window.innerHeight) * 2 + 1;
345
+
346
+ raycaster.setFromCamera(pointer, camera);
347
+ const hit = raycaster.intersectObjects(interactables, true)[0]?.object ?? null;
348
+
349
+ // Walk up to find the interactable root
350
+ const hitRoot = hit ? interactables.find(m => m === hit || m.parent === hit) ?? null : null;
351
+
352
+ if (hitRoot !== hoveredMesh) {
353
+ if (hoveredMesh) document.body.style.cursor = 'default';
354
+ hoveredMesh = hitRoot;
355
+ if (hoveredMesh) document.body.style.cursor = 'pointer';
356
+ }
357
+ }
358
+
359
+ function _onClick() {
360
+ if (!hoveredMesh || !_onClickCb) return;
361
+ const slotName = Object.keys(dreamsBySlot).find(k => nodes[k] === hoveredMesh);
362
+ if (slotName) _onClickCb(dreamsBySlot[slotName]);
363
+ }
364
+
365
+ function _kill() {
366
+ activeTimeline?.kill();
367
+ gsap.killTweensOf(studioMotion);
368
+ activeTimeline = null;
369
+ }
370
+
371
+ function _tick() {
372
+ requestAnimationFrame(_tick);
373
+ renderer.render(scene, camera);
374
+ }
375
+
376
+ function _onResize() {
377
+ const w = window.innerWidth / 2;
378
+ const h = window.innerHeight / 2;
379
+ camera.left = -w;
380
+ camera.right = w;
381
+ camera.top = h;
382
+ camera.bottom = -h;
383
+ camera.updateProjectionMatrix();
384
+ renderer.setSize(window.innerWidth, window.innerHeight);
385
+ }
static/style.css ADDED
@@ -0,0 +1,812 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ── Reset ──────────────────────────────────────────────────────────────── */
2
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
3
+
4
+ :root {
5
+ --gold: #c9a030;
6
+ --gold-light: #f0cc6e;
7
+ --gold-dark: #806018;
8
+ --bg: #07070c;
9
+ --panel-bg: rgba(8, 7, 14, 0.97);
10
+ --card-bg: #100f1c;
11
+ --border: #2a2545;
12
+ --accent: #7c6af7;
13
+ --text: #e8e0f0;
14
+ --text-dim: #8880aa;
15
+ --danger: #c0392b;
16
+ --radius: 10px;
17
+ --font: 'Cormorant Garamond', Georgia, 'Times New Roman', serif;
18
+ }
19
+
20
+ html, body {
21
+ height: 100%;
22
+ overflow: hidden;
23
+ font-family: var(--font);
24
+ background: var(--bg);
25
+ color: var(--text);
26
+ }
27
+
28
+ /* ── 3D Canvas ───────────────────────────────────────────────────────────── */
29
+ #museum-canvas {
30
+ position: fixed;
31
+ inset: 0;
32
+ width: 100%;
33
+ height: 100%;
34
+ display: block;
35
+ transition: filter 0.5s ease;
36
+ }
37
+
38
+ #museum-canvas.dimmed {
39
+ filter: blur(6px) brightness(0.3);
40
+ }
41
+
42
+ /* ── Top gradient veil (contrast for corner HUD elements) ────────────────── */
43
+ body::before {
44
+ content: '';
45
+ position: fixed;
46
+ top: 0; left: 0; right: 0;
47
+ height: 110px;
48
+ background: linear-gradient(to bottom, rgba(4,3,10,.72) 0%, transparent 100%);
49
+ z-index: 45;
50
+ pointer-events: none;
51
+ }
52
+
53
+ /* ── Floating logo ───────────────────────────────────────────────────────── */
54
+ #museum-logo {
55
+ position: fixed;
56
+ top: 24px; left: 28px;
57
+ z-index: 50;
58
+ display: flex;
59
+ align-items: center;
60
+ gap: 10px;
61
+ pointer-events: none;
62
+ user-select: none;
63
+ }
64
+
65
+ .logo-glyph {
66
+ font-size: 1.3rem;
67
+ color: var(--gold-light);
68
+ text-shadow: 0 1px 6px rgba(0,0,0,.8);
69
+ }
70
+
71
+ .logo-text {
72
+ font-size: 1.05rem;
73
+ letter-spacing: .14em;
74
+ color: #f5e8c0;
75
+ font-style: italic;
76
+ font-weight: 300;
77
+ text-shadow: 0 1px 6px rgba(0,0,0,.9);
78
+ }
79
+
80
+ /* ── Top-right HUD ───────────────────────────────────────────────────────── */
81
+ #hud-topright {
82
+ position: fixed;
83
+ top: 18px; right: 22px;
84
+ z-index: 50;
85
+ display: flex;
86
+ align-items: center;
87
+ gap: 10px;
88
+ }
89
+
90
+ .auth-area {
91
+ display: flex;
92
+ align-items: center;
93
+ gap: 7px;
94
+ }
95
+
96
+ .auth-name {
97
+ color: #f5e8c0;
98
+ font-style: italic;
99
+ font-size: 1rem;
100
+ letter-spacing: .04em;
101
+ text-shadow: 0 1px 5px rgba(0,0,0,.9);
102
+ }
103
+
104
+ .auth-input {
105
+ background: rgba(4,3,10,.7);
106
+ border: 1px solid rgba(201,160,48,.35);
107
+ color: #f0e8d8;
108
+ padding: 7px 14px;
109
+ border-radius: 6px;
110
+ font-size: .88rem;
111
+ width: 160px;
112
+ font-family: inherit;
113
+ outline: none;
114
+ transition: border-color .2s, background .2s;
115
+ backdrop-filter: blur(10px);
116
+ }
117
+
118
+ .auth-input:focus { border-color: var(--gold); background: rgba(4,3,10,.88); }
119
+ .auth-input::placeholder { color: rgba(200,180,140,.5); font-style: italic; }
120
+
121
+ .hud-pill {
122
+ background: rgba(4,3,10,.78);
123
+ border: 1px solid rgba(201,160,48,.45);
124
+ color: #f0d98a;
125
+ padding: 7px 18px;
126
+ border-radius: 20px;
127
+ font-family: inherit;
128
+ font-size: .88rem;
129
+ font-style: italic;
130
+ letter-spacing: .06em;
131
+ cursor: pointer;
132
+ backdrop-filter: blur(12px);
133
+ transition: background .2s, border-color .2s, color .2s;
134
+ text-shadow: 0 1px 4px rgba(0,0,0,.7);
135
+ }
136
+
137
+ .hud-pill:hover {
138
+ background: rgba(201,160,48,.15);
139
+ border-color: rgba(201,160,48,.7);
140
+ color: var(--gold-light);
141
+ }
142
+
143
+ /* ── FAB buttons (bottom corners) ───────────────────────────────────────── */
144
+ .fab {
145
+ position: fixed;
146
+ bottom: 32px;
147
+ z-index: 50;
148
+ display: flex;
149
+ align-items: center;
150
+ gap: 10px;
151
+ padding: 12px 22px;
152
+ background: rgba(8,7,14,.82);
153
+ border: 1px solid rgba(201,160,48,.35);
154
+ border-radius: 40px;
155
+ color: rgba(240,204,110,.9);
156
+ font-family: inherit;
157
+ font-size: 1rem;
158
+ font-style: italic;
159
+ letter-spacing: .08em;
160
+ cursor: pointer;
161
+ text-decoration: none;
162
+ backdrop-filter: blur(12px);
163
+ transition: background .25s, border-color .25s, color .25s, transform .2s, box-shadow .25s;
164
+ box-shadow: 0 4px 20px rgba(0,0,0,.4);
165
+ }
166
+
167
+ .fab:hover {
168
+ background: rgba(201,160,48,.14);
169
+ border-color: rgba(201,160,48,.7);
170
+ color: var(--gold-light);
171
+ transform: translateY(-2px);
172
+ box-shadow: 0 6px 28px rgba(201,160,48,.15);
173
+ }
174
+
175
+ .fab--left { left: 32px; }
176
+ .fab--right { right: 32px; }
177
+
178
+ .fab-icon {
179
+ display: flex;
180
+ align-items: center;
181
+ justify-content: center;
182
+ width: 20px; height: 20px;
183
+ flex-shrink: 0;
184
+ font-size: 1rem;
185
+ }
186
+
187
+ .fab-label {
188
+ font-size: .95rem;
189
+ }
190
+
191
+ /* ── Room hint ──��────────────────────────────────────────────────────────── */
192
+ #room-hint {
193
+ position: fixed;
194
+ bottom: 40px;
195
+ left: 50%;
196
+ transform: translateX(-50%);
197
+ z-index: 40;
198
+ background: rgba(7,7,12,.6);
199
+ border: 1px solid rgba(201,160,48,.22);
200
+ border-radius: 20px;
201
+ padding: 7px 22px;
202
+ font-size: .78rem;
203
+ font-style: italic;
204
+ color: rgba(201,160,48,.65);
205
+ letter-spacing: .1em;
206
+ backdrop-filter: blur(6px);
207
+ pointer-events: all;
208
+ cursor: default;
209
+ transition:
210
+ opacity .5s,
211
+ color .25s,
212
+ border-color .25s,
213
+ background .25s,
214
+ box-shadow .25s,
215
+ transform .25s;
216
+ white-space: nowrap;
217
+ user-select: none;
218
+ }
219
+
220
+ #room-hint:hover {
221
+ color: #f0cc6e;
222
+ border-color: rgba(201,160,48,.65);
223
+ background: rgba(201,160,48,.1);
224
+ box-shadow: 0 0 18px rgba(201,160,48,.18), 0 0 6px rgba(201,160,48,.12);
225
+ transform: translateX(-50%) translateY(-2px);
226
+ }
227
+
228
+ #room-hint.hidden { opacity: 0; pointer-events: none; }
229
+
230
+ /* ── Side panels ─────────────────────────────────────────────────────────── */
231
+ .side-panel {
232
+ position: fixed;
233
+ top: 0; right: 0; bottom: 0;
234
+ width: min(960px, 100vw);
235
+ z-index: 60;
236
+ background: var(--panel-bg);
237
+ border-left: 1px solid var(--border);
238
+ transform: translateX(100%);
239
+ transition: transform .45s cubic-bezier(.22,.68,0,1.1);
240
+ overflow: hidden;
241
+ }
242
+
243
+ .side-panel.open { transform: translateX(0); }
244
+
245
+ .panel-scroll {
246
+ height: 100%;
247
+ overflow-y: auto;
248
+ padding: 64px 44px 60px;
249
+ scrollbar-width: thin;
250
+ scrollbar-color: var(--border) transparent;
251
+ }
252
+
253
+ .panel-close {
254
+ position: absolute;
255
+ top: 20px; right: 22px;
256
+ background: none;
257
+ border: 1px solid var(--border);
258
+ color: var(--text-dim);
259
+ width: 34px; height: 34px;
260
+ border-radius: 6px;
261
+ cursor: pointer;
262
+ font-size: .9rem;
263
+ font-family: inherit;
264
+ transition: color .2s, border-color .2s;
265
+ z-index: 2;
266
+ }
267
+
268
+ .panel-close:hover { color: var(--text); border-color: var(--text-dim); }
269
+
270
+ .panel-head {
271
+ margin-bottom: 36px;
272
+ }
273
+
274
+ .panel-head h2 {
275
+ font-size: 2.2rem;
276
+ font-weight: 300;
277
+ font-style: italic;
278
+ letter-spacing: .1em;
279
+ color: var(--gold-light);
280
+ margin-bottom: 8px;
281
+ line-height: 1.1;
282
+ }
283
+
284
+ .panel-head p {
285
+ color: var(--text-dim);
286
+ font-style: italic;
287
+ font-size: 1rem;
288
+ letter-spacing: .04em;
289
+ }
290
+
291
+ /* ── Create layout ───────────────────────────────────────────────────────── */
292
+ .create-layout {
293
+ display: grid;
294
+ grid-template-columns: 460px 1fr;
295
+ gap: 40px;
296
+ align-items: start;
297
+ }
298
+
299
+ @media (max-width: 860px) {
300
+ .create-layout { grid-template-columns: 1fr; }
301
+ }
302
+
303
+ /* ── Drawing toolbar ─────────────────────────────────────────────────────── */
304
+ .canvas-col {
305
+ display: flex;
306
+ flex-direction: column;
307
+ gap: 12px;
308
+ }
309
+
310
+ .draw-toolbar {
311
+ display: flex;
312
+ align-items: center;
313
+ gap: 8px;
314
+ flex-wrap: wrap;
315
+ padding: 8px 12px;
316
+ background: rgba(255,255,255,.04);
317
+ border: 1px solid var(--border);
318
+ border-radius: 8px;
319
+ }
320
+
321
+ .tool {
322
+ background: transparent;
323
+ border: 1px solid transparent;
324
+ color: var(--text-dim);
325
+ width: 32px; height: 32px;
326
+ border-radius: 6px;
327
+ cursor: pointer;
328
+ display: flex;
329
+ align-items: center;
330
+ justify-content: center;
331
+ transition: color .15s, border-color .15s, background .15s;
332
+ flex-shrink: 0;
333
+ }
334
+
335
+ .tool:hover { color: var(--text); border-color: var(--border); background: rgba(255,255,255,.05); }
336
+ .tool.active { color: var(--gold); border-color: rgba(201,160,48,.5); background: rgba(201,160,48,.1); }
337
+
338
+ .tool-sep {
339
+ width: 1px; height: 20px;
340
+ background: var(--border);
341
+ flex-shrink: 0;
342
+ }
343
+
344
+ .color-picker {
345
+ width: 32px; height: 32px;
346
+ border: 1px solid var(--border);
347
+ border-radius: 6px;
348
+ padding: 2px;
349
+ background: rgba(255,255,255,.06);
350
+ cursor: pointer;
351
+ flex-shrink: 0;
352
+ }
353
+
354
+ .color-swatches {
355
+ display: flex;
356
+ gap: 4px;
357
+ flex-wrap: wrap;
358
+ }
359
+
360
+ .swatch {
361
+ width: 18px; height: 18px;
362
+ border-radius: 50%;
363
+ border: 1px solid rgba(255,255,255,.15);
364
+ background: var(--c);
365
+ cursor: pointer;
366
+ transition: transform .15s, box-shadow .15s;
367
+ flex-shrink: 0;
368
+ }
369
+
370
+ .swatch:hover { transform: scale(1.25); box-shadow: 0 0 6px rgba(255,255,255,.3); }
371
+
372
+ .brush-row {
373
+ display: flex;
374
+ align-items: center;
375
+ gap: 10px;
376
+ flex: 1;
377
+ min-width: 100px;
378
+ }
379
+
380
+ .brush-row input[type=range] {
381
+ flex: 1;
382
+ accent-color: var(--gold);
383
+ }
384
+
385
+ .brush-preview {
386
+ width: 8px; height: 8px;
387
+ border-radius: 50%;
388
+ background: #ffffff;
389
+ flex-shrink: 0;
390
+ transition: width .1s, height .1s;
391
+ box-shadow: 0 0 4px rgba(255,255,255,.4);
392
+ }
393
+
394
+ /* ── Canvas frame ───────────���────────────────────────────────────────────── */
395
+ .canvas-frame {
396
+ position: relative;
397
+ border-radius: 4px;
398
+ overflow: hidden;
399
+ box-shadow:
400
+ 0 0 0 1px var(--border),
401
+ 0 0 0 3px rgba(201,160,48,.08),
402
+ 0 0 0 4px rgba(42,37,69,.6),
403
+ 0 8px 32px rgba(0,0,0,.5);
404
+ }
405
+
406
+ #draw-canvas {
407
+ width: 100%;
408
+ aspect-ratio: 1;
409
+ display: block;
410
+ background: #0f0d1e;
411
+ cursor: crosshair;
412
+ touch-action: none;
413
+ }
414
+
415
+ /* ── Controls column ─────────────────────────────────────────────────────── */
416
+ .ctrl-col {
417
+ display: flex;
418
+ flex-direction: column;
419
+ gap: 18px;
420
+ }
421
+
422
+ .field-group {
423
+ display: flex;
424
+ flex-direction: column;
425
+ gap: 8px;
426
+ }
427
+
428
+ .field-label {
429
+ font-size: .72rem;
430
+ letter-spacing: .14em;
431
+ text-transform: uppercase;
432
+ color: var(--text-dim);
433
+ font-style: normal;
434
+ font-family: inherit;
435
+ }
436
+
437
+ textarea {
438
+ background: rgba(255,255,255,.05);
439
+ border: 1px solid var(--border);
440
+ border-radius: 8px;
441
+ color: var(--text);
442
+ font-family: inherit;
443
+ font-size: 1rem;
444
+ font-style: italic;
445
+ line-height: 1.7;
446
+ padding: 14px 16px;
447
+ resize: vertical;
448
+ outline: none;
449
+ width: 100%;
450
+ transition: border-color .2s;
451
+ }
452
+
453
+ textarea:focus { border-color: rgba(201,160,48,.5); }
454
+ textarea::placeholder { color: var(--text-dim); font-style: italic; }
455
+
456
+ .slider-label {
457
+ display: flex;
458
+ justify-content: space-between;
459
+ font-size: .88rem;
460
+ color: var(--text-dim);
461
+ font-style: italic;
462
+ }
463
+
464
+ .slider-num { color: var(--gold); font-style: normal; }
465
+
466
+ .field-group input[type=range] { width: 100%; accent-color: var(--gold); }
467
+
468
+ .slider-hints {
469
+ display: flex;
470
+ justify-content: space-between;
471
+ font-size: .72rem;
472
+ color: var(--text-dim);
473
+ font-style: italic;
474
+ }
475
+
476
+ /* ── Materialize button ──────────────────────────────────────────────────── */
477
+ .btn-materialize {
478
+ background: linear-gradient(135deg, rgba(201,160,48,.9), rgba(140,100,20,.9));
479
+ color: #0a0808;
480
+ border: none;
481
+ padding: 14px 24px;
482
+ border-radius: 8px;
483
+ font-family: inherit;
484
+ font-size: 1.05rem;
485
+ font-style: italic;
486
+ font-weight: 600;
487
+ letter-spacing: .06em;
488
+ cursor: pointer;
489
+ display: flex;
490
+ align-items: center;
491
+ justify-content: center;
492
+ gap: 10px;
493
+ width: 100%;
494
+ transition: opacity .2s, transform .15s, box-shadow .2s;
495
+ box-shadow: 0 4px 20px rgba(201,160,48,.25);
496
+ }
497
+
498
+ .btn-materialize:hover {
499
+ opacity: .9;
500
+ transform: translateY(-1px);
501
+ box-shadow: 0 6px 28px rgba(201,160,48,.35);
502
+ }
503
+
504
+ .btn-materialize:disabled { opacity: .45; cursor: not-allowed; transform: none; }
505
+
506
+ /* ── Common buttons ──────────────────────────────────────────────────────── */
507
+ .btn-gold {
508
+ background: var(--gold);
509
+ color: #0a0808;
510
+ border: none;
511
+ padding: 11px 20px;
512
+ border-radius: 8px;
513
+ font-family: inherit;
514
+ font-size: .92rem;
515
+ font-weight: 600;
516
+ letter-spacing: .04em;
517
+ cursor: pointer;
518
+ display: flex;
519
+ align-items: center;
520
+ justify-content: center;
521
+ gap: 8px;
522
+ transition: background .2s, opacity .2s;
523
+ }
524
+
525
+ .btn-gold:hover { background: var(--gold-light); }
526
+ .btn-gold:disabled { opacity: .5; cursor: not-allowed; }
527
+
528
+ .btn-outline {
529
+ background: transparent;
530
+ color: var(--text);
531
+ border: 1px solid var(--border);
532
+ padding: 10px 20px;
533
+ border-radius: 8px;
534
+ font-family: inherit;
535
+ font-size: .9rem;
536
+ cursor: pointer;
537
+ transition: border-color .2s, color .2s;
538
+ }
539
+
540
+ .btn-outline:hover { border-color: var(--gold); color: var(--gold); }
541
+
542
+ .btn-outline-sm {
543
+ background: transparent;
544
+ color: var(--text-dim);
545
+ border: 1px solid var(--border);
546
+ padding: 6px 12px;
547
+ border-radius: 6px;
548
+ font-family: inherit;
549
+ font-size: .82rem;
550
+ cursor: pointer;
551
+ transition: border-color .2s, color .2s;
552
+ }
553
+
554
+ .btn-outline-sm:hover { border-color: var(--gold); color: var(--gold); }
555
+
556
+ .btn-ghost {
557
+ background: transparent;
558
+ color: var(--text-dim);
559
+ border: none;
560
+ padding: 8px 16px;
561
+ border-radius: 8px;
562
+ font-family: inherit;
563
+ font-size: .88rem;
564
+ font-style: italic;
565
+ cursor: pointer;
566
+ transition: color .2s;
567
+ width: 100%;
568
+ }
569
+
570
+ .btn-ghost:hover { color: var(--text); }
571
+
572
+ .btn-danger {
573
+ background: transparent;
574
+ color: var(--danger);
575
+ border: 1px solid var(--danger);
576
+ padding: 10px 20px;
577
+ border-radius: 8px;
578
+ font-family: inherit;
579
+ font-size: .9rem;
580
+ cursor: pointer;
581
+ }
582
+
583
+ .btn-danger:hover { background: rgba(192,57,43,.1); }
584
+
585
+ /* ── Spinner ─────────────────────────────────────────────────────────────── */
586
+ .spinner {
587
+ width: 16px; height: 16px;
588
+ border: 2px solid rgba(0,0,0,.3);
589
+ border-top-color: #0a0808;
590
+ border-radius: 50%;
591
+ animation: spin .7s linear infinite;
592
+ display: inline-block;
593
+ flex-shrink: 0;
594
+ }
595
+
596
+ @keyframes spin { to { transform: rotate(360deg); } }
597
+
598
+ /* ── Result diptych ──────────────────────────────────────────────────────── */
599
+ .result-area { display: flex; flex-direction: column; gap: 14px; }
600
+
601
+ .result-diptych {
602
+ display: grid;
603
+ grid-template-columns: 1fr auto 1fr;
604
+ gap: 10px;
605
+ align-items: center;
606
+ }
607
+
608
+ .res-side { display: flex; flex-direction: column; gap: 5px; }
609
+
610
+ .res-label {
611
+ font-size: .68rem;
612
+ letter-spacing: .14em;
613
+ text-transform: uppercase;
614
+ color: var(--text-dim);
615
+ text-align: center;
616
+ font-style: normal;
617
+ }
618
+
619
+ .result-diptych img {
620
+ width: 100%;
621
+ aspect-ratio: 1;
622
+ object-fit: cover;
623
+ border-radius: 4px;
624
+ border: 1px solid var(--border);
625
+ display: block;
626
+ }
627
+
628
+ .res-arrow { color: var(--gold); font-size: 1.4rem; flex-shrink: 0; }
629
+
630
+ .save-area { display: flex; flex-direction: column; gap: 8px; }
631
+
632
+ .save-btns {
633
+ display: grid;
634
+ grid-template-columns: 1fr 1fr;
635
+ gap: 8px;
636
+ }
637
+
638
+ .save-btns .btn-gold { font-size: .85rem; padding: 10px 12px; }
639
+
640
+ .auth-notice {
641
+ text-align: center;
642
+ color: var(--text-dim);
643
+ font-style: italic;
644
+ font-size: .9rem;
645
+ padding: 8px;
646
+ }
647
+
648
+ /* ── My Gallery grid ─────────────────────────────────────────────────────── */
649
+ .dream-grid {
650
+ columns: 2;
651
+ column-gap: 18px;
652
+ }
653
+
654
+ @media (max-width: 600px) { .dream-grid { columns: 1; } }
655
+
656
+ .dream-card {
657
+ break-inside: avoid;
658
+ margin-bottom: 18px;
659
+ background: var(--card-bg);
660
+ border: 1px solid var(--border);
661
+ border-radius: var(--radius);
662
+ overflow: hidden;
663
+ cursor: pointer;
664
+ transition: transform .2s, border-color .2s;
665
+ }
666
+
667
+ .dream-card:hover { transform: translateY(-2px); border-color: rgba(201,160,48,.4); }
668
+
669
+ .card-diptych {
670
+ display: grid;
671
+ grid-template-columns: 1fr 1fr;
672
+ }
673
+
674
+ .card-diptych img {
675
+ width: 100%;
676
+ aspect-ratio: 1;
677
+ object-fit: cover;
678
+ display: block;
679
+ }
680
+
681
+ .card-info { padding: 10px 12px; }
682
+
683
+ .card-desc {
684
+ font-size: .9rem;
685
+ color: var(--text-dim);
686
+ font-style: italic;
687
+ overflow: hidden;
688
+ display: -webkit-box;
689
+ -webkit-line-clamp: 2;
690
+ -webkit-box-orient: vertical;
691
+ }
692
+
693
+ .card-vis {
694
+ font-size: .72rem;
695
+ color: var(--gold-dark);
696
+ margin-top: 5px;
697
+ letter-spacing: .05em;
698
+ text-transform: uppercase;
699
+ }
700
+
701
+ .empty-state {
702
+ color: var(--text-dim);
703
+ font-style: italic;
704
+ text-align: center;
705
+ padding: 50px 20px;
706
+ font-size: 1rem;
707
+ }
708
+
709
+ /* ── Modal ───────────────────────────────────────────────────────────────── */
710
+ .modal {
711
+ position: fixed;
712
+ inset: 0;
713
+ z-index: 80;
714
+ display: flex;
715
+ align-items: center;
716
+ justify-content: center;
717
+ padding: 24px;
718
+ }
719
+
720
+ .modal.hidden { display: none; }
721
+
722
+ .modal-backdrop {
723
+ position: absolute;
724
+ inset: 0;
725
+ background: rgba(0,0,0,.85);
726
+ backdrop-filter: blur(10px);
727
+ }
728
+
729
+ .modal-box {
730
+ position: relative;
731
+ background: rgba(10,9,18,.97);
732
+ border: 1px solid var(--border);
733
+ border-radius: 12px;
734
+ padding: 40px;
735
+ max-width: 860px;
736
+ width: 100%;
737
+ display: flex;
738
+ flex-direction: column;
739
+ gap: 18px;
740
+ max-height: 90vh;
741
+ overflow-y: auto;
742
+ box-shadow: 0 0 60px rgba(0,0,0,.6);
743
+ }
744
+
745
+ .modal-close {
746
+ position: absolute;
747
+ top: 16px; right: 16px;
748
+ background: none;
749
+ border: 1px solid var(--border);
750
+ color: var(--text-dim);
751
+ width: 32px; height: 32px;
752
+ border-radius: 6px;
753
+ cursor: pointer;
754
+ font-size: .9rem;
755
+ font-family: inherit;
756
+ transition: color .2s;
757
+ }
758
+
759
+ .modal-close:hover { color: var(--text); }
760
+
761
+ .modal-diptych {
762
+ display: grid;
763
+ grid-template-columns: 1fr auto 1fr;
764
+ gap: 14px;
765
+ align-items: center;
766
+ }
767
+
768
+ .modal-side { display: flex; flex-direction: column; gap: 6px; }
769
+
770
+ .modal-lbl {
771
+ font-size: .68rem;
772
+ letter-spacing: .14em;
773
+ text-transform: uppercase;
774
+ color: var(--text-dim);
775
+ text-align: center;
776
+ }
777
+
778
+ .modal-diptych img {
779
+ width: 100%;
780
+ aspect-ratio: 1;
781
+ object-fit: cover;
782
+ border-radius: 6px;
783
+ border: 1px solid var(--border);
784
+ display: block;
785
+ }
786
+
787
+ .modal-arrow { color: var(--gold); font-size: 2rem; flex-shrink: 0; }
788
+
789
+ .modal-desc {
790
+ text-align: center;
791
+ font-style: italic;
792
+ font-size: 1.15rem;
793
+ color: var(--text-dim);
794
+ line-height: 1.7;
795
+ padding: 0 20px;
796
+ }
797
+
798
+ .modal-meta {
799
+ text-align: center;
800
+ font-size: .82rem;
801
+ color: rgba(201,160,48,.5);
802
+ letter-spacing: .06em;
803
+ }
804
+
805
+ .modal-owner {
806
+ display: flex;
807
+ gap: 10px;
808
+ justify-content: center;
809
+ }
810
+
811
+ /* ── Utility ─────────────────────────────────────────────────────────────── */
812
+ .hidden { display: none !important; }