Lonelyguyse1 commited on
Commit
275f947
·
verified ·
1 Parent(s): 6a5958c

Deploy Project Halide Gradio Space

Browse files
Files changed (9) hide show
  1. README.md +43 -43
  2. app.py +2 -0
  3. assets/logo.jpg +0 -0
  4. config.py +1 -1
  5. requirements.txt +9 -30
  6. ui/app.py +136 -32
  7. ui/components.py +177 -10
  8. ui/server.py +2 -0
  9. ui/theme.py +439 -206
README.md CHANGED
@@ -1,43 +1,43 @@
1
- ---
2
- title: Project Halide
3
- sdk: gradio
4
- sdk_version: 6.10.0
5
- app_file: app.py
6
- license: apache-2.0
7
- models:
8
- - Lonelyguyse1/halide-vision
9
- - openbmb/MiniCPM-V-4.6
10
- - nvidia/Nemotron-Mini-4B-Instruct
11
- tags:
12
- - gradio
13
- - film
14
- - computer-vision
15
- - diagnostics
16
- - track:backyard
17
- - sponsor:openbmb
18
- - sponsor:nvidia
19
- - sponsor:modal
20
- - sponsor:openai
21
- - achievement:offgrid
22
- - achievement:welltuned
23
- - achievement:offbrand
24
- ---
25
-
26
- # Project Halide
27
-
28
- Project Halide is an edge-native diagnostic workbench for analog film scans by
29
- [Lonelyguyse1](https://huggingface.co/Lonelyguyse1).
30
-
31
- The runtime uses MiniCPM-V 4.6 for defect extraction and
32
- Nemotron-Mini-4B-Instruct for diagnostic reasoning. Model inference runs on the
33
- Space GPU runtime without cloud inference APIs.
34
-
35
- Fine-tuned vision model:
36
- <https://huggingface.co/Lonelyguyse1/halide-vision>
37
-
38
- Source repository:
39
- <https://github.com/Lonelyguyse1/Project-Halide>
40
-
41
- Demo video: pending final evaluation run.
42
-
43
- Social post: pending final evaluation run.
 
1
+ ---
2
+ title: Project Halide
3
+ sdk: gradio
4
+ sdk_version: 6.10.0
5
+ app_file: app.py
6
+ license: apache-2.0
7
+ models:
8
+ - Lonelyguyse1/halide-vision
9
+ - openbmb/MiniCPM-V-4.6
10
+ - nvidia/Nemotron-Mini-4B-Instruct
11
+ tags:
12
+ - gradio
13
+ - film
14
+ - computer-vision
15
+ - diagnostics
16
+ - track:backyard
17
+ - sponsor:openbmb
18
+ - sponsor:nvidia
19
+ - sponsor:modal
20
+ - sponsor:openai
21
+ - achievement:offgrid
22
+ - achievement:welltuned
23
+ - achievement:offbrand
24
+ ---
25
+
26
+ # Project Halide
27
+
28
+ Project Halide is an edge-native diagnostic workbench for analog film scans by
29
+ [Lonelyguyse1](https://huggingface.co/Lonelyguyse1).
30
+
31
+ The runtime uses MiniCPM-V 4.6 for defect extraction and
32
+ Nemotron-Mini-4B-Instruct for diagnostic reasoning. Model inference runs on the
33
+ Space GPU runtime without cloud inference APIs.
34
+
35
+ Fine-tuned vision model:
36
+ <https://huggingface.co/Lonelyguyse1/halide-vision>
37
+
38
+ Source repository:
39
+ <https://github.com/Lonelyguyse1/Project-Halide>
40
+
41
+ Demo video: pending final evaluation run.
42
+
43
+ Social post: pending final evaluation run.
app.py CHANGED
@@ -21,6 +21,8 @@ def main() -> None:
21
  show_error=True,
22
  theme=build_theme(),
23
  css=THEME_CSS,
 
 
24
  )
25
 
26
 
 
21
  show_error=True,
22
  theme=build_theme(),
23
  css=THEME_CSS,
24
+ allowed_paths=["assets"],
25
+ max_file_size="60mb",
26
  )
27
 
28
 
assets/logo.jpg ADDED
config.py CHANGED
@@ -83,7 +83,7 @@ def get_vision_config() -> VisionConfig:
83
  ),
84
  local_model_path=env_path(
85
  "HALIDE_VISION_LOCAL_MODEL_PATH",
86
- CHECKPOINT_DIR / "minicpm-v-4.6-merged-v3",
87
  ),
88
  use_finetuned=env_bool("HALIDE_USE_FINETUNED_VISION", False),
89
  downsample_mode=os.getenv("HALIDE_DOWNSAMPLE_MODE", "4x"),
 
83
  ),
84
  local_model_path=env_path(
85
  "HALIDE_VISION_LOCAL_MODEL_PATH",
86
+ CHECKPOINT_DIR / "minicpm-v-4.6-merged-v4-stage1",
87
  ),
88
  use_finetuned=env_bool("HALIDE_USE_FINETUNED_VISION", False),
89
  downsample_mode=os.getenv("HALIDE_DOWNSAMPLE_MODE", "4x"),
requirements.txt CHANGED
@@ -1,30 +1,9 @@
1
- # Project Halide Dependencies
2
- # Core runtime
3
- gradio>=6.10.0
4
- spaces>=0.40.0
5
- torch>=2.4.0
6
- torchvision>=0.19.0
7
-
8
- # HuggingFace transformers stack (pinned to MiniCPM-V 4.6 minimums)
9
- transformers>=5.7.0
10
- accelerate>=1.0.0
11
- huggingface_hub>=0.20.0
12
- pillow>=10.0.0
13
-
14
- # Optional: video decoding for MiniCPM-V 4.6 (we use image-only)
15
- # torchcodec or av
16
-
17
- # Local development extras
18
- datasets>=2.0.0
19
- numpy>=1.24.0
20
- opencv-python>=4.8.0
21
- pyyaml>=6.0
22
- tqdm>=4.65.0
23
- requests>=2.28.0
24
- pytest>=8.0.0
25
-
26
- # Storage
27
- # sqlite is part of the Python stdlib; no extra dep needed
28
-
29
- # Modal (for offline training jobs only, not used at runtime)
30
- modal>=0.50.0
 
1
+ gradio>=6.10.0,<7.0.0
2
+ spaces>=0.40.0
3
+ torch>=2.4.0
4
+ torchvision>=0.19.0
5
+ transformers>=5.7.0,<6.0.0
6
+ accelerate>=1.0.0
7
+ huggingface_hub>=0.20.0
8
+ pillow>=10.0.0
9
+ numpy>=1.24.0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ui/app.py CHANGED
@@ -7,6 +7,7 @@ import logging
7
  from typing import Any
8
 
9
  import gradio as gr
 
10
 
11
  from config import get_app_config
12
  from data.preprocessing import draw_defects, image_to_png_bytes, load_image
@@ -16,6 +17,7 @@ from storage.database import get_diagnosis, init_db, list_recent, record_diagnos
16
  from ui.components import (
17
  EMPTY_STATE,
18
  HEADER_HTML,
 
19
  confidence_notice_html,
20
  defect_table_rows,
21
  defect_pills_html,
@@ -26,6 +28,7 @@ from ui.components import (
26
  metadata_html,
27
  render_history,
28
  raw_json_text,
 
29
  stats_html,
30
  )
31
  from ui.theme import THEME_CSS, build_theme
@@ -95,6 +98,74 @@ def _history_state(
95
  return render_history(entries), selected, gr.update(choices=choices, value=value), history_table_rows(entries)
96
 
97
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  @_gpu_decorator()
99
  def run_pipeline(
100
  image: Any,
@@ -104,25 +175,10 @@ def run_pipeline(
104
  scan_dpi: int,
105
  metadata_confidence: str = "low",
106
  progress: gr.Progress = gr.Progress(),
107
- ) -> tuple[Any, str, str, str, str, str, str, list[list[str]], str, str, Any, list[list[str]]]:
108
  """Gradio handler for the diagnose button."""
109
- history_html, selected_entry, selector_update, history_rows = _history_state()
110
  if image is None:
111
- empty = '<p class="halide-muted">No image provided.</p>'
112
- return (
113
- None,
114
- empty,
115
- empty,
116
- empty,
117
- empty,
118
- empty,
119
- "{}",
120
- [],
121
- history_html,
122
- history_detail_html(selected_entry),
123
- selector_update,
124
- history_rows,
125
- )
126
 
127
  try:
128
  progress(0.0, "Hashing image for cache lookup...")
@@ -168,6 +224,8 @@ def run_pipeline(
168
  title=f"Halide: {len(defects)} validated defects",
169
  )
170
  image_pair = (pil_image, annotated)
 
 
171
  stats = stats_html(result)
172
  notice = confidence_notice_html(result)
173
  pills = defect_pills_html(counts)
@@ -178,6 +236,8 @@ def run_pipeline(
178
  history, selected_entry, selector_update, history_rows = _history_state(result.get("diagnosis_id"))
179
  return (
180
  image_pair,
 
 
181
  stats,
182
  notice,
183
  pills,
@@ -202,6 +262,8 @@ def run_pipeline(
202
  history, selected_entry, selector_update, history_rows = _history_state()
203
  return (
204
  None,
 
 
205
  err,
206
  "",
207
  "",
@@ -247,11 +309,15 @@ def build_app() -> gr.Blocks:
247
  gr.HTML(HEADER_HTML)
248
 
249
  with gr.Row(elem_classes="halide-workbench", equal_height=False):
250
- with gr.Column(scale=3, min_width=320, elem_classes="halide-intake-panel"):
 
 
 
 
251
  image_input = gr.Image(
252
  label="Film scan",
253
  type="pil",
254
- height=330,
255
  sources=["upload", "clipboard"],
256
  buttons=["download", "fullscreen"],
257
  elem_classes="halide-upload",
@@ -292,30 +358,54 @@ def build_app() -> gr.Blocks:
292
  size="lg",
293
  elem_id="halide-run-button",
294
  )
 
 
 
 
 
 
 
295
 
296
- with gr.Column(scale=7, min_width=560, elem_classes="halide-main-stage"):
297
- with gr.Row(elem_classes="halide-status-band", equal_height=True):
298
- notice_output = gr.HTML(value=EMPTY_STATE, elem_classes="halide-status-cell")
299
- defect_summary = gr.HTML(value=EMPTY_STATE, elem_classes="halide-status-cell")
300
 
301
  with gr.Group(elem_classes="halide-lighttable"):
302
- gr.HTML('<div class="halide-section-title">Light table</div>')
 
 
 
 
 
303
  compare_output = gr.ImageSlider(
 
304
  label="Original / overlay",
305
  type="pil",
306
- height="58vh",
307
  max_height=760,
308
  slider_position=52,
309
  interactive=False,
310
  buttons=["download", "fullscreen"],
 
 
 
 
 
 
 
 
 
 
 
 
 
311
  )
312
 
313
- with gr.Group(elem_classes="halide-diagnosis-panel"):
314
- gr.HTML('<div class="halide-section-title">Diagnosis and physical fixes</div>')
315
- diagnosis_output = gr.HTML(value=EMPTY_STATE)
316
-
317
- with gr.Column(scale=3, min_width=360, elem_classes="halide-inspector"):
318
- with gr.Tabs(selected="evidence", elem_classes="halide-inspector-tabs"):
319
  with gr.Tab("Evidence", id="evidence"):
320
  stats_output = gr.HTML(value=EMPTY_STATE)
321
  metadata_output = gr.HTML(value=EMPTY_STATE)
@@ -370,7 +460,12 @@ def build_app() -> gr.Blocks:
370
  "Vision: MiniCPM-V 4.6. Reasoning: Nemotron-Mini-4B-Instruct.</footer>"
371
  )
372
 
373
- run_btn.click(
 
 
 
 
 
374
  fn=run_pipeline,
375
  inputs=[
376
  image_input,
@@ -382,6 +477,8 @@ def build_app() -> gr.Blocks:
382
  ],
383
  outputs=[
384
  compare_output,
 
 
385
  stats_output,
386
  notice_output,
387
  defect_summary,
@@ -394,6 +491,10 @@ def build_app() -> gr.Blocks:
394
  history_select,
395
  history_table,
396
  ],
 
 
 
 
397
  )
398
  refresh_btn.click(
399
  fn=refresh_history,
@@ -414,8 +515,11 @@ def main() -> None:
414
  app.queue(max_size=8).launch(
415
  server_name="0.0.0.0",
416
  server_port=7860,
 
417
  theme=build_theme(),
418
  css=THEME_CSS,
 
 
419
  )
420
 
421
 
 
7
  from typing import Any
8
 
9
  import gradio as gr
10
+ from PIL import Image, ImageDraw, ImageFont
11
 
12
  from config import get_app_config
13
  from data.preprocessing import draw_defects, image_to_png_bytes, load_image
 
17
  from ui.components import (
18
  EMPTY_STATE,
19
  HEADER_HTML,
20
+ REPORT_EMPTY_STATE,
21
  confidence_notice_html,
22
  defect_table_rows,
23
  defect_pills_html,
 
28
  metadata_html,
29
  render_history,
30
  raw_json_text,
31
+ run_state_html,
32
  stats_html,
33
  )
34
  from ui.theme import THEME_CSS, build_theme
 
98
  return render_history(entries), selected, gr.update(choices=choices, value=value), history_table_rows(entries)
99
 
100
 
101
+ def _empty_outputs(
102
+ message: str = "Awaiting scan.",
103
+ ) -> tuple[Any, Any, str, str, str, str, str, str, str, list[list[str]], str, str, Any, list[list[str]]]:
104
+ history_html, selected_entry, selector_update, history_rows = _history_state()
105
+ empty = f'<p class="halide-muted">{html.escape(message)}</p>'
106
+ return (
107
+ None,
108
+ gr.update(value=[], visible=False),
109
+ run_state_html(None),
110
+ empty,
111
+ empty,
112
+ "",
113
+ "",
114
+ "",
115
+ "{}",
116
+ [],
117
+ history_html,
118
+ history_detail_html(selected_entry),
119
+ selector_update,
120
+ history_rows,
121
+ )
122
+
123
+
124
+ def _review_gallery(pil_image: Any, annotated: Any) -> list[tuple[Any, str]]:
125
+ return [
126
+ (pil_image, "Original scan"),
127
+ (annotated, "Validated overlay"),
128
+ ]
129
+
130
+
131
+ def _placeholder_image() -> Image.Image:
132
+ image = Image.new("RGB", (1200, 760), (5, 5, 5))
133
+ draw = ImageDraw.Draw(image)
134
+ frame = (28, 28, 1172, 732)
135
+ draw.rectangle(frame, outline=(68, 62, 52), width=2)
136
+ draw.rectangle((48, 48, 1152, 712), outline=(28, 27, 25), width=1)
137
+ try:
138
+ font = ImageFont.load_default(size=32)
139
+ small = ImageFont.load_default(size=18)
140
+ except TypeError:
141
+ font = ImageFont.load_default()
142
+ small = font
143
+ title = "Awaiting scan"
144
+ subtitle = "Validated overlay will appear here"
145
+ title_box = draw.textbbox((0, 0), title, font=font)
146
+ subtitle_box = draw.textbbox((0, 0), subtitle, font=small)
147
+ center_x = image.width // 2
148
+ center_y = image.height // 2
149
+ draw.text(
150
+ (center_x - (title_box[2] - title_box[0]) // 2, center_y - 26),
151
+ title,
152
+ fill=(243, 234, 219),
153
+ font=font,
154
+ )
155
+ draw.text(
156
+ (center_x - (subtitle_box[2] - subtitle_box[0]) // 2, center_y + 18),
157
+ subtitle,
158
+ fill=(169, 155, 136),
159
+ font=small,
160
+ )
161
+ return image
162
+
163
+
164
+ def _placeholder_pair() -> tuple[Image.Image, Image.Image]:
165
+ placeholder = _placeholder_image()
166
+ return placeholder, placeholder.copy()
167
+
168
+
169
  @_gpu_decorator()
170
  def run_pipeline(
171
  image: Any,
 
175
  scan_dpi: int,
176
  metadata_confidence: str = "low",
177
  progress: gr.Progress = gr.Progress(),
178
+ ) -> tuple[Any, Any, str, str, str, str, str, str, str, list[list[str]], str, str, Any, list[list[str]]]:
179
  """Gradio handler for the diagnose button."""
 
180
  if image is None:
181
+ return _empty_outputs("No image provided.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
182
 
183
  try:
184
  progress(0.0, "Hashing image for cache lookup...")
 
224
  title=f"Halide: {len(defects)} validated defects",
225
  )
226
  image_pair = (pil_image, annotated)
227
+ gallery = gr.update(value=_review_gallery(pil_image, annotated), visible=True)
228
+ run_state = run_state_html(result)
229
  stats = stats_html(result)
230
  notice = confidence_notice_html(result)
231
  pills = defect_pills_html(counts)
 
236
  history, selected_entry, selector_update, history_rows = _history_state(result.get("diagnosis_id"))
237
  return (
238
  image_pair,
239
+ gallery,
240
+ run_state,
241
  stats,
242
  notice,
243
  pills,
 
262
  history, selected_entry, selector_update, history_rows = _history_state()
263
  return (
264
  None,
265
+ gr.update(value=[], visible=False),
266
+ err,
267
  err,
268
  "",
269
  "",
 
309
  gr.HTML(HEADER_HTML)
310
 
311
  with gr.Row(elem_classes="halide-workbench", equal_height=False):
312
+ with gr.Column(scale=2, min_width=300, elem_classes="halide-intake-panel"):
313
+ gr.HTML(
314
+ '<div class="halide-panel-title">Scan intake</div>'
315
+ '<p class="halide-rail-copy">Metadata is context, visible evidence is primary.</p>'
316
+ )
317
  image_input = gr.Image(
318
  label="Film scan",
319
  type="pil",
320
+ height=300,
321
  sources=["upload", "clipboard"],
322
  buttons=["download", "fullscreen"],
323
  elem_classes="halide-upload",
 
358
  size="lg",
359
  elem_id="halide-run-button",
360
  )
361
+ gr.HTML(
362
+ '<div class="halide-model-card">'
363
+ '<span>Runtime</span>'
364
+ '<strong>Open weights, GPU only</strong>'
365
+ '<p>MiniCPM-V extracts evidence. Nemotron writes the lab report.</p>'
366
+ "</div>"
367
+ )
368
 
369
+ with gr.Column(scale=6, min_width=560, elem_classes="halide-main-stage"):
370
+ run_state_output = gr.HTML(value=run_state_html(None))
 
 
371
 
372
  with gr.Group(elem_classes="halide-lighttable"):
373
+ gr.HTML(
374
+ '<div class="halide-section-header">'
375
+ '<div><span>Light table</span><strong>Original versus validated overlay</strong></div>'
376
+ '<small>Review</small>'
377
+ "</div>"
378
+ )
379
  compare_output = gr.ImageSlider(
380
+ value=_placeholder_pair(),
381
  label="Original / overlay",
382
  type="pil",
383
+ height="54vh",
384
  max_height=760,
385
  slider_position=52,
386
  interactive=False,
387
  buttons=["download", "fullscreen"],
388
+ elem_id="halide-compare",
389
+ )
390
+ review_gallery = gr.Gallery(
391
+ value=[],
392
+ label="Review frames",
393
+ columns=2,
394
+ rows=1,
395
+ height=220,
396
+ allow_preview=True,
397
+ object_fit="contain",
398
+ buttons=["download", "fullscreen"],
399
+ elem_classes="halide-review-gallery",
400
+ visible=False,
401
  )
402
 
403
+ with gr.Column(scale=3, min_width=390, elem_classes="halide-inspector"):
404
+ with gr.Tabs(selected="report", elem_classes="halide-inspector-tabs"):
405
+ with gr.Tab("Report", id="report"):
406
+ notice_output = gr.HTML(value=REPORT_EMPTY_STATE)
407
+ defect_summary = gr.HTML(value="")
408
+ diagnosis_output = gr.HTML(value="")
409
  with gr.Tab("Evidence", id="evidence"):
410
  stats_output = gr.HTML(value=EMPTY_STATE)
411
  metadata_output = gr.HTML(value=EMPTY_STATE)
 
460
  "Vision: MiniCPM-V 4.6. Reasoning: Nemotron-Mini-4B-Instruct.</footer>"
461
  )
462
 
463
+ run_event = run_btn.click(
464
+ fn=lambda: gr.update(interactive=False, value="Diagnosing..."),
465
+ outputs=[run_btn],
466
+ queue=False,
467
+ )
468
+ run_event.then(
469
  fn=run_pipeline,
470
  inputs=[
471
  image_input,
 
477
  ],
478
  outputs=[
479
  compare_output,
480
+ review_gallery,
481
+ run_state_output,
482
  stats_output,
483
  notice_output,
484
  defect_summary,
 
491
  history_select,
492
  history_table,
493
  ],
494
+ ).then(
495
+ fn=lambda: gr.update(interactive=True, value="Diagnose scan"),
496
+ outputs=[run_btn],
497
+ queue=False,
498
  )
499
  refresh_btn.click(
500
  fn=refresh_history,
 
515
  app.queue(max_size=8).launch(
516
  server_name="0.0.0.0",
517
  server_port=7860,
518
+ show_error=True,
519
  theme=build_theme(),
520
  css=THEME_CSS,
521
+ allowed_paths=["assets"],
522
+ max_file_size="60mb",
523
  )
524
 
525
 
ui/components.py CHANGED
@@ -2,19 +2,36 @@
2
 
3
  from __future__ import annotations
4
 
 
5
  import html
6
  import json
7
  import time
 
 
8
  from typing import Iterable
9
 
10
  from data.schemas import LABEL_DISPLAY_NAMES
11
 
12
 
13
- HEADER_HTML = """
 
 
 
 
 
 
 
 
 
 
 
14
  <div id="halide-header">
15
  <div class="halide-brand-lockup">
16
- <span class="halide-kicker">Project Halide</span>
17
- <h1>Analog Film Diagnostic Workbench</h1>
 
 
 
18
  </div>
19
  <div class="halide-model-strip">
20
  <a href="https://huggingface.co/Lonelyguyse1" target="_blank" rel="noreferrer">Lonelyguyse1</a>
@@ -26,6 +43,21 @@ HEADER_HTML = """
26
  """
27
 
28
  EMPTY_STATE = '<p class="halide-muted">Awaiting scan.</p>'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
 
30
 
31
  def defect_pills_html(label_counts: dict[str, int]) -> str:
@@ -88,9 +120,12 @@ def stats_html(result: dict) -> str:
88
  rows.append(_stat_row("Vision inference", f"{vision_s:.2f}s"))
89
  rows.append(_stat_row("Reasoning", f"{reasoning_s:.2f}s"))
90
  rows.append(_stat_row("Total", f"{total:.2f}s"))
91
- rows.append(_stat_row("Vision model", _truncate(defects.get("model_path", ""), 50)))
92
- rows.append(_stat_row("Reasoning model", _truncate(diagnosis.get("model_path", ""), 50)))
93
- return f'<div class="halide-stats">{"".join(rows)}</div>'
 
 
 
94
 
95
 
96
  def _stat_row(label: str, value: str) -> str:
@@ -109,9 +144,101 @@ def _truncate(s: str, n: int) -> str:
109
 
110
 
111
  def diagnosis_html(text: str) -> str:
112
- """Wrap diagnosis text in the styled card."""
113
- safe = html.escape(text or "(no diagnosis produced)").replace("\n", "<br>")
114
- return f'<div class="halide-diagnosis">{safe}</div>'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
 
116
 
117
  def metadata_html(result: dict) -> str:
@@ -125,7 +252,10 @@ def metadata_html(result: dict) -> str:
125
  _stat_row("Scan DPI", str(meta.get("scan_resolution_dpi", "unknown"))),
126
  _stat_row("Metadata confidence", confidence.title()),
127
  ]
128
- return f'<div class="halide-stats compact">{"".join(rows)}</div>'
 
 
 
129
 
130
 
131
  def confidence_notice_html(result: dict) -> str:
@@ -152,6 +282,40 @@ def confidence_notice_html(result: dict) -> str:
152
  return f'<div class="halide-notice {tone}">{html.escape(message)}</div>'
153
 
154
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
155
  def history_label(entry: dict) -> str:
156
  """Return a compact plain-text label for the history dropdown."""
157
  stamp = time.strftime(
@@ -259,6 +423,7 @@ def raw_json_text(result_or_entry: dict | None) -> str:
259
  __all__ = [
260
  "HEADER_HTML",
261
  "EMPTY_STATE",
 
262
  "compact_label_counts",
263
  "confidence_notice_html",
264
  "defect_table_rows",
@@ -269,7 +434,9 @@ __all__ = [
269
  "history_label",
270
  "history_table_rows",
271
  "metadata_html",
 
272
  "render_history",
 
273
  "raw_json_text",
274
  "stats_html",
275
  ]
 
2
 
3
  from __future__ import annotations
4
 
5
+ import base64
6
  import html
7
  import json
8
  import time
9
+ from pathlib import Path
10
+ import re
11
  from typing import Iterable
12
 
13
  from data.schemas import LABEL_DISPLAY_NAMES
14
 
15
 
16
+ def _logo_html() -> str:
17
+ path = Path(__file__).resolve().parents[1] / "assets" / "logo.jpg"
18
+ if not path.exists():
19
+ return '<span class="halide-brand-mark halide-brand-mark-text">H</span>'
20
+ encoded = base64.b64encode(path.read_bytes()).decode("ascii")
21
+ return (
22
+ '<img class="halide-brand-mark" '
23
+ f'src="data:image/jpeg;base64,{encoded}" alt="" />'
24
+ )
25
+
26
+
27
+ HEADER_HTML = f"""
28
  <div id="halide-header">
29
  <div class="halide-brand-lockup">
30
+ {_logo_html()}
31
+ <div>
32
+ <span class="halide-kicker">Project Halide</span>
33
+ <h1>Analog Film Diagnostic Workbench</h1>
34
+ </div>
35
  </div>
36
  <div class="halide-model-strip">
37
  <a href="https://huggingface.co/Lonelyguyse1" target="_blank" rel="noreferrer">Lonelyguyse1</a>
 
43
  """
44
 
45
  EMPTY_STATE = '<p class="halide-muted">Awaiting scan.</p>'
46
+ REPORT_EMPTY_STATE = (
47
+ '<div class="halide-empty-card">'
48
+ '<span>Report</span>'
49
+ '<strong>No scan analyzed yet</strong>'
50
+ '<p>Results, evidence counts, and physical fixes will appear here after a GPU run.</p>'
51
+ "</div>"
52
+ )
53
+
54
+ REPORT_SECTIONS = {
55
+ "root cause": "Root cause",
56
+ "evidence": "Evidence",
57
+ "physical fixes": "Physical fixes",
58
+ "confidence": "Confidence",
59
+ "next inspection": "Next inspection",
60
+ }
61
 
62
 
63
  def defect_pills_html(label_counts: dict[str, int]) -> str:
 
120
  rows.append(_stat_row("Vision inference", f"{vision_s:.2f}s"))
121
  rows.append(_stat_row("Reasoning", f"{reasoning_s:.2f}s"))
122
  rows.append(_stat_row("Total", f"{total:.2f}s"))
123
+ rows.append(_stat_row("Vision model", _truncate(defects.get("model_path", ""), 46)))
124
+ rows.append(_stat_row("Reasoning model", _truncate(diagnosis.get("model_path", ""), 46)))
125
+ return (
126
+ '<div class="halide-panel-title">Run telemetry</div>'
127
+ f'<div class="halide-stats">{"".join(rows)}</div>'
128
+ )
129
 
130
 
131
  def _stat_row(label: str, value: str) -> str:
 
144
 
145
 
146
  def diagnosis_html(text: str) -> str:
147
+ """Render the Nemotron Markdown report into structured HTML."""
148
+ return render_markdown_report(text or "(no diagnosis produced)")
149
+
150
+
151
+ def render_markdown_report(text: str) -> str:
152
+ """Render the constrained diagnosis Markdown used by Nemotron.
153
+
154
+ This intentionally supports only the report shapes we request from the
155
+ model: section headings, paragraphs, bullets, and numbered fixes.
156
+ """
157
+ sections: list[dict[str, list[str] | str]] = []
158
+ current: dict[str, list[str] | str] = {
159
+ "title": "Report",
160
+ "lines": [],
161
+ }
162
+
163
+ for raw_line in text.replace("\r\n", "\n").split("\n"):
164
+ line = raw_line.strip()
165
+ if line.startswith("## "):
166
+ if current["lines"]:
167
+ sections.append(current)
168
+ title_key = line[3:].strip().lower()
169
+ current = {
170
+ "title": REPORT_SECTIONS.get(title_key, line[3:].strip() or "Report"),
171
+ "lines": [],
172
+ }
173
+ continue
174
+ current["lines"].append(line)
175
+ if current["lines"] or not sections:
176
+ sections.append(current)
177
+
178
+ rendered = []
179
+ for section in sections:
180
+ title = html.escape(str(section["title"]))
181
+ body = _render_report_lines(section["lines"]) # type: ignore[arg-type]
182
+ rendered.append(
183
+ '<section class="halide-report-section">'
184
+ f'<div class="halide-report-heading">{title}</div>'
185
+ f'<div class="halide-report-body">{body}</div>'
186
+ "</section>"
187
+ )
188
+ return '<div class="halide-report">' + "".join(rendered) + "</div>"
189
+
190
+
191
+ def _render_report_lines(lines: list[str]) -> str:
192
+ blocks: list[str] = []
193
+ paragraph: list[str] = []
194
+ bullet_items: list[str] = []
195
+ ordered_items: list[str] = []
196
+
197
+ def flush_paragraph() -> None:
198
+ if paragraph:
199
+ blocks.append(
200
+ "<p>"
201
+ + " ".join(html.escape(part) for part in paragraph if part)
202
+ + "</p>"
203
+ )
204
+ paragraph.clear()
205
+
206
+ def flush_bullets() -> None:
207
+ if bullet_items:
208
+ items = "".join(f"<li>{item}</li>" for item in bullet_items)
209
+ blocks.append(f"<ul>{items}</ul>")
210
+ bullet_items.clear()
211
+
212
+ def flush_ordered() -> None:
213
+ if ordered_items:
214
+ items = "".join(f"<li>{item}</li>" for item in ordered_items)
215
+ blocks.append(f"<ol>{items}</ol>")
216
+ ordered_items.clear()
217
+
218
+ for line in lines:
219
+ if not line:
220
+ flush_paragraph()
221
+ flush_bullets()
222
+ flush_ordered()
223
+ continue
224
+ numbered = re.match(r"^\d+\.\s+(.*)$", line)
225
+ if line.startswith("- "):
226
+ flush_paragraph()
227
+ flush_ordered()
228
+ bullet_items.append(html.escape(line[2:].strip()))
229
+ elif numbered:
230
+ flush_paragraph()
231
+ flush_bullets()
232
+ ordered_items.append(html.escape(numbered.group(1).strip()))
233
+ else:
234
+ flush_bullets()
235
+ flush_ordered()
236
+ paragraph.append(line)
237
+
238
+ flush_paragraph()
239
+ flush_bullets()
240
+ flush_ordered()
241
+ return "".join(blocks) or '<p class="halide-muted">No report text.</p>'
242
 
243
 
244
  def metadata_html(result: dict) -> str:
 
252
  _stat_row("Scan DPI", str(meta.get("scan_resolution_dpi", "unknown"))),
253
  _stat_row("Metadata confidence", confidence.title()),
254
  ]
255
+ return (
256
+ '<div class="halide-panel-title">Film dossier</div>'
257
+ f'<div class="halide-stats compact">{"".join(rows)}</div>'
258
+ )
259
 
260
 
261
  def confidence_notice_html(result: dict) -> str:
 
282
  return f'<div class="halide-notice {tone}">{html.escape(message)}</div>'
283
 
284
 
285
+ def run_state_html(result: dict | None) -> str:
286
+ """Render a top-level status block for the current diagnosis."""
287
+ if not result:
288
+ return (
289
+ '<div class="halide-run-state idle">'
290
+ '<span class="halide-run-eyebrow">Ready</span>'
291
+ "<strong>Load a scan to begin inspection.</strong>"
292
+ "</div>"
293
+ )
294
+
295
+ defects = result.get("defects", {}) or {}
296
+ count = int(defects.get("defect_count", 0) or 0)
297
+ dropped = int(defects.get("dropped_count", 0) or 0)
298
+ duplicates = int(defects.get("duplicate_count", 0) or 0)
299
+ if count:
300
+ title = f"{count} validated defect{'s' if count != 1 else ''}"
301
+ tone = "active"
302
+ else:
303
+ title = "No validated defects"
304
+ tone = "quiet"
305
+
306
+ detail = (
307
+ f"{dropped} invalid removed, {duplicates} duplicate removed, "
308
+ f"{float(result.get('total_seconds', 0.0) or 0.0):.2f}s total"
309
+ )
310
+ return (
311
+ f'<div class="halide-run-state {tone}">'
312
+ '<span class="halide-run-eyebrow">Current scan</span>'
313
+ f"<strong>{html.escape(title)}</strong>"
314
+ f"<span>{html.escape(detail)}</span>"
315
+ "</div>"
316
+ )
317
+
318
+
319
  def history_label(entry: dict) -> str:
320
  """Return a compact plain-text label for the history dropdown."""
321
  stamp = time.strftime(
 
423
  __all__ = [
424
  "HEADER_HTML",
425
  "EMPTY_STATE",
426
+ "REPORT_EMPTY_STATE",
427
  "compact_label_counts",
428
  "confidence_notice_html",
429
  "defect_table_rows",
 
434
  "history_label",
435
  "history_table_rows",
436
  "metadata_html",
437
+ "render_markdown_report",
438
  "render_history",
439
+ "run_state_html",
440
  "raw_json_text",
441
  "stats_html",
442
  ]
ui/server.py CHANGED
@@ -32,6 +32,8 @@ def build_server(blocks: gr.Blocks | None = None) -> gr.Server:
32
  theme=build_theme(),
33
  css=THEME_CSS,
34
  show_error=True,
 
 
35
  )
36
  return server
37
 
 
32
  theme=build_theme(),
33
  css=THEME_CSS,
34
  show_error=True,
35
+ allowed_paths=["assets"],
36
+ max_file_size="60mb",
37
  )
38
  return server
39
 
ui/theme.py CHANGED
@@ -4,52 +4,74 @@ from __future__ import annotations
4
 
5
  import gradio as gr
6
 
7
- OXIDE = "#c96f36"
8
- OXIDE_DEEP = "#8f3f27"
9
- AMBER = "#f6c85f"
10
- GREEN = "#6ee7b7"
11
- VIOLET = "#a78bfa"
12
- RED = "#ef4444"
13
-
14
- INK = "#0b0a08"
15
- SURFACE = "#151311"
16
- SURFACE_SOFT = "#1d1916"
17
- SURFACE_LIFT = "#27211c"
18
- PAPER = "#f6efe2"
19
- MUTED = "#b9afa2"
20
- BORDER = "#3b332b"
21
- BLACK = "#050403"
 
 
22
 
23
  THEME_CSS = f"""
24
  :root {{
25
- --halide-oxide: {OXIDE};
26
- --halide-oxide-deep: {OXIDE_DEEP};
27
- --halide-amber: {AMBER};
28
- --halide-green: {GREEN};
29
  --halide-violet: {VIOLET};
30
  --halide-red: {RED};
31
  --halide-ink: {INK};
 
32
  --halide-surface: {SURFACE};
33
  --halide-surface-soft: {SURFACE_SOFT};
34
  --halide-surface-lift: {SURFACE_LIFT};
35
  --halide-paper: {PAPER};
 
36
  --halide-muted: {MUTED};
37
  --halide-border: {BORDER};
38
  --halide-black: {BLACK};
39
  }}
40
 
 
41
  body,
42
  .gradio-container {{
43
- background:
44
- linear-gradient(180deg, #17110d 0%, #0d0a08 48%, #070605 100%) !important;
45
  color: var(--halide-paper) !important;
46
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
47
  "Segoe UI", sans-serif !important;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  }}
49
 
50
  .gradio-container {{
51
  max-width: none !important;
52
- padding: 0 1rem 1rem !important;
 
53
  }}
54
 
55
  #halide-app {{
@@ -60,30 +82,44 @@ body,
60
  display: flex;
61
  align-items: center;
62
  justify-content: space-between;
63
- gap: 1rem;
64
- padding: 0.95rem 0.25rem 0.85rem;
65
- margin: 0 0 0.75rem;
66
- border-bottom: 1px solid rgba(246, 200, 95, 0.25);
 
67
  }}
68
 
69
  .halide-brand-lockup {{
 
 
 
70
  min-width: 0;
71
  }}
72
 
 
 
 
 
 
 
 
 
 
73
  #halide-header h1 {{
74
  color: var(--halide-paper);
75
- font-size: clamp(1.35rem, 2.1vw, 2.15rem);
76
- line-height: 1.08;
77
- margin: 0.15rem 0 0;
78
- letter-spacing: 0;
 
79
  }}
80
 
81
  .halide-kicker {{
82
- color: var(--halide-green);
83
  display: inline-block;
84
- font-size: 0.75rem;
85
- font-weight: 850;
86
- letter-spacing: 0.12em;
87
  text-transform: uppercase;
88
  }}
89
 
@@ -91,26 +127,33 @@ body,
91
  display: flex;
92
  flex-wrap: wrap;
93
  justify-content: flex-end;
94
- gap: 0.45rem;
95
- min-width: min(40vw, 34rem);
96
  }}
97
 
98
  .halide-model-strip span,
99
  .halide-model-strip a {{
100
- border: 1px solid rgba(110, 231, 183, 0.34);
101
- color: #ddfff3;
102
- background: rgba(110, 231, 183, 0.08);
103
  border-radius: 8px;
104
- padding: 0.36rem 0.56rem;
105
  font-size: 0.76rem;
106
- font-weight: 760;
107
  line-height: 1;
108
  text-decoration: none;
109
  white-space: nowrap;
110
  }}
111
 
 
 
 
 
 
112
  .halide-workbench {{
113
- gap: 0.85rem !important;
 
 
114
  align-items: flex-start;
115
  }}
116
 
@@ -120,100 +163,204 @@ body,
120
  min-width: 0 !important;
121
  }}
122
 
123
- .halide-status-band {{
124
- gap: 0.7rem !important;
125
- margin-bottom: 0.75rem;
 
 
 
126
  }}
127
 
128
- .halide-status-cell {{
129
- min-width: 0;
 
 
 
 
 
130
  }}
131
 
132
- .halide-lighttable,
133
- .halide-diagnosis-panel,
134
- .halide-panel {{
135
- background: rgba(21, 19, 17, 0.96) !important;
136
- border: 1px solid var(--halide-border) !important;
137
- border-radius: 8px !important;
138
- padding: 0.9rem !important;
139
- box-shadow: 0 18px 34px rgba(0, 0, 0, 0.34) !important;
140
  }}
141
 
142
- .halide-lighttable {{
143
- border-color: rgba(246, 200, 95, 0.24) !important;
144
- padding-bottom: 0.7rem !important;
 
 
 
145
  }}
146
 
147
- .halide-diagnosis-panel {{
148
- margin-top: 0.85rem;
149
- border-left: 3px solid var(--halide-oxide) !important;
 
 
 
 
 
150
  }}
151
 
152
- .halide-intake-panel .block,
153
- .halide-inspector .block,
154
- .halide-lighttable .block,
155
- .halide-diagnosis-panel .block {{
156
- background: rgba(29, 25, 22, 0.88) !important;
157
- border-color: rgba(59, 51, 43, 0.92) !important;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
  border-radius: 8px !important;
 
 
159
  }}
160
 
161
- .halide-upload img,
162
- .halide-lighttable img {{
163
- background: #050403 !important;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
  }}
165
 
166
- .halide-lighttable .wrap,
167
- .halide-lighttable .image-container,
168
- .halide-upload .wrap,
169
- .halide-upload .image-container {{
170
- border-color: rgba(246, 239, 226, 0.34) !important;
 
 
 
 
171
  }}
172
 
173
  .halide-inline-controls {{
174
- gap: 0.55rem !important;
175
  }}
176
 
177
  #halide-run-button,
178
  button.primary,
179
  .primary button {{
180
- background: linear-gradient(135deg, var(--halide-oxide), var(--halide-red)) !important;
181
  color: white !important;
182
- border: 1px solid rgba(255, 255, 255, 0.14) !important;
183
  border-radius: 8px !important;
184
- font-weight: 850 !important;
185
  letter-spacing: 0 !important;
186
- box-shadow: 0 12px 28px rgba(201, 111, 54, 0.28) !important;
 
187
  }}
188
 
189
  #halide-run-button:hover,
190
  button.primary:hover,
191
  .primary button:hover {{
192
- filter: brightness(1.06);
193
  }}
194
 
195
  button.secondary,
196
  .secondary button,
197
  button {{
198
  border-radius: 8px !important;
199
- font-weight: 740 !important;
200
  }}
201
 
202
  .halide-section-title {{
203
  color: var(--halide-paper);
204
- font-size: 0.78rem;
205
- font-weight: 850;
206
- letter-spacing: 0.09em;
207
  text-transform: uppercase;
208
- margin: 0 0 0.7rem;
209
  }}
210
 
211
  .halide-subsection {{
212
  color: var(--halide-muted);
213
  font-size: 0.72rem;
214
- font-weight: 850;
215
- letter-spacing: 0.08em;
216
- margin: 0.95rem 0 0.45rem;
217
  text-transform: uppercase;
218
  }}
219
 
@@ -222,47 +369,78 @@ button {{
222
  margin: 0;
223
  }}
224
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
  .halide-notice {{
226
  border-radius: 8px;
227
- padding: 0.74rem 0.82rem;
228
  border: 1px solid var(--halide-border);
229
  color: var(--halide-paper);
230
- background: rgba(39, 33, 28, 0.86);
231
  font-size: 0.88rem;
232
- line-height: 1.38;
233
- min-height: 3rem;
234
  }}
235
 
236
  .halide-notice.good {{
237
- border-color: rgba(110, 231, 183, 0.38);
238
- background: rgba(110, 231, 183, 0.08);
239
  }}
240
 
241
  .halide-notice.caution {{
242
- border-color: rgba(246, 200, 95, 0.42);
243
- background: rgba(246, 200, 95, 0.09);
244
  }}
245
 
246
  .halide-notice.neutral {{
247
- border-color: rgba(167, 139, 250, 0.36);
248
- background: rgba(167, 139, 250, 0.08);
249
  }}
250
 
251
  .halide-stats {{
252
  display: grid;
253
- gap: 0.42rem;
254
  }}
255
 
256
  .halide-stats.compact {{
257
- gap: 0.32rem;
258
  }}
259
 
260
  .halide-stat {{
261
  display: grid;
262
- grid-template-columns: minmax(7.2rem, 0.42fr) minmax(0, 1fr);
263
- gap: 0.7rem;
264
- padding: 0.42rem 0;
265
- border-bottom: 1px solid rgba(59, 51, 43, 0.88);
266
  color: var(--halide-paper);
267
  min-width: 0;
268
  }}
@@ -273,7 +451,7 @@ button {{
273
 
274
  .halide-stat-label {{
275
  color: var(--halide-muted);
276
- font-weight: 760;
277
  }}
278
 
279
  .halide-stat span:last-child {{
@@ -281,34 +459,71 @@ button {{
281
  text-align: right;
282
  }}
283
 
284
- .halide-diagnosis {{
285
- background: rgba(39, 33, 28, 0.78);
286
- border: 1px solid rgba(201, 111, 54, 0.28);
287
- padding: 1rem 1.05rem;
 
 
 
 
 
288
  border-radius: 8px;
289
- white-space: pre-wrap;
290
- font-size: 0.95rem;
291
- line-height: 1.58;
 
292
  color: var(--halide-paper);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
293
  }}
294
 
295
  .halide-pill-row {{
296
  display: flex;
297
  flex-wrap: wrap;
298
- gap: 0.42rem;
 
299
  }}
300
 
301
  .halide-defect-pill {{
302
  display: inline-flex;
303
  align-items: center;
304
- gap: 0.4rem;
305
- background: rgba(201, 111, 54, 0.15);
306
- color: #ffe6cf;
307
- border: 1px solid rgba(201, 111, 54, 0.36);
308
- padding: 0.33rem 0.58rem;
309
- border-radius: 8px;
310
- font-size: 0.8rem;
311
- font-weight: 760;
312
  white-space: nowrap;
313
  }}
314
 
@@ -317,38 +532,38 @@ button {{
317
  }}
318
 
319
  .halide-defect-pill.dust {{
320
- background: rgba(246, 200, 95, 0.13);
321
- border-color: rgba(246, 200, 95, 0.35);
322
  }}
323
 
324
  .halide-defect-pill.dirt {{
325
- background: rgba(201, 111, 54, 0.15);
326
- border-color: rgba(201, 111, 54, 0.36);
327
  }}
328
 
329
  .halide-defect-pill.scratch {{
330
- background: rgba(239, 68, 68, 0.14);
331
- border-color: rgba(239, 68, 68, 0.4);
332
  }}
333
 
334
  .halide-defect-pill.long_hair {{
335
- background: rgba(167, 139, 250, 0.13);
336
- border-color: rgba(167, 139, 250, 0.36);
337
  }}
338
 
339
  .halide-defect-pill.short_hair {{
340
- background: rgba(110, 231, 183, 0.11);
341
- border-color: rgba(110, 231, 183, 0.34);
342
  }}
343
 
344
  .halide-defect-pill.emulsion_damage {{
345
- background: rgba(226, 232, 240, 0.13);
346
- border-color: rgba(226, 232, 240, 0.34);
347
  }}
348
 
349
  .halide-defect-pill.chemical_stain {{
350
- background: rgba(34, 197, 94, 0.12);
351
- border-color: rgba(34, 197, 94, 0.34);
352
  }}
353
 
354
  .halide-defect-pill.light_leak {{
@@ -357,30 +572,30 @@ button {{
357
  }}
358
 
359
  .halide-history-item {{
360
- background: rgba(29, 25, 22, 0.78);
361
- border: 1px solid rgba(59, 51, 43, 0.92);
362
  border-radius: 8px;
363
- margin-bottom: 0.65rem;
364
- padding: 0.75rem;
365
  }}
366
 
367
  .halide-history-title {{
368
  color: var(--halide-paper);
369
- font-weight: 850;
370
- margin-bottom: 0.25rem;
371
  overflow-wrap: anywhere;
372
  }}
373
 
374
  .halide-history-meta {{
375
  color: var(--halide-muted);
376
  font-size: 0.78rem;
377
- line-height: 1.35;
378
- margin: 0.3rem 0;
379
  }}
380
 
381
  .halide-history-detail {{
382
  display: grid;
383
- gap: 0.4rem;
384
  }}
385
 
386
  .halide-history-feed {{
@@ -388,6 +603,15 @@ button {{
388
  overflow: auto;
389
  }}
390
 
 
 
 
 
 
 
 
 
 
391
  input,
392
  textarea,
393
  select,
@@ -408,19 +632,13 @@ label,
408
  }}
409
 
410
  .label-wrap,
411
- .label-wrap span {{
412
- background: rgba(39, 33, 28, 0.96) !important;
413
- border-color: rgba(246, 200, 95, 0.22) !important;
414
- color: var(--halide-paper) !important;
415
- border-radius: 6px !important;
416
- font-weight: 760 !important;
417
- }}
418
-
419
  label[data-testid="block-label"] {{
420
- background: rgba(39, 33, 28, 0.96) !important;
421
- border: 1px solid rgba(246, 200, 95, 0.22) !important;
422
  color: var(--halide-paper) !important;
423
  border-radius: 6px !important;
 
424
  box-shadow: none !important;
425
  }}
426
 
@@ -429,16 +647,16 @@ label[data-testid="block-label"] span {{
429
  }}
430
 
431
  label[data-testid$="-radio-label"] {{
432
- background: rgba(39, 33, 28, 0.98) !important;
433
- border: 1px solid rgba(246, 239, 226, 0.18) !important;
434
- border-radius: 7px !important;
435
  box-shadow: none !important;
436
  opacity: 1 !important;
437
  }}
438
 
439
  label[data-testid$="-radio-label"].selected {{
440
- background: rgba(201, 111, 54, 0.26) !important;
441
- border-color: rgba(246, 200, 95, 0.45) !important;
442
  }}
443
 
444
  label[data-testid$="-radio-label"] span,
@@ -449,17 +667,23 @@ label[data-testid$="-radio-label"] input,
449
  }}
450
 
451
  label[data-testid$="-radio-label"] input {{
452
- accent-color: var(--halide-oxide);
 
 
 
 
453
  }}
454
 
455
  .tabs button {{
456
  color: var(--halide-muted) !important;
457
  font-weight: 820 !important;
 
458
  }}
459
 
460
  .tabs button.selected {{
461
  color: var(--halide-paper) !important;
462
- border-color: var(--halide-oxide) !important;
 
463
  }}
464
 
465
  table,
@@ -477,13 +701,31 @@ code {{
477
  }}
478
 
479
  footer {{
 
 
480
  color: var(--halide-muted) !important;
481
  text-align: center;
482
- padding: 0.9rem;
483
  font-size: 0.82rem;
484
  }}
485
 
486
- @media (max-width: 980px) {{
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
487
  #halide-header {{
488
  align-items: flex-start;
489
  flex-direction: column;
@@ -494,23 +736,14 @@ footer {{
494
  min-width: 0;
495
  }}
496
 
497
- .halide-status-band {{
498
- flex-direction: column;
499
- }}
500
-
501
- .halide-workbench {{
502
- flex-direction: column !important;
503
- }}
504
-
505
- .halide-intake-panel,
506
- .halide-main-stage,
507
- .halide-inspector {{
508
- width: 100% !important;
509
  }}
510
 
511
  .halide-stat {{
512
  grid-template-columns: 1fr;
513
- gap: 0.18rem;
514
  }}
515
 
516
  .halide-stat span:last-child {{
@@ -524,39 +757,39 @@ def build_theme() -> gr.Theme:
524
  """Build a compact custom workbench theme."""
525
  return gr.themes.Base(
526
  primary_hue=gr.themes.Color(
527
- c50="#fff7ed",
528
- c100="#ffead5",
529
- c200="#fed1a8",
530
- c300="#f6aa6a",
531
- c400="#e88b45",
532
- c500=OXIDE,
533
- c600=OXIDE_DEEP,
534
- c700="#74311f",
535
- c800="#5c281d",
536
- c900="#3f1b14",
537
- c950="#24100b",
538
  ),
539
  secondary_hue=gr.themes.Color(
540
- c50="#ecfdf5",
541
- c100="#d1fae5",
542
- c200="#a7f3d0",
543
- c300=GREEN,
544
- c400="#34d399",
545
- c500="#10b981",
546
- c600="#059669",
547
- c700="#047857",
548
- c800="#065f46",
549
- c900="#064e3b",
550
- c950="#022c22",
551
  ),
552
  neutral_hue=gr.themes.Color(
553
- c50="#faf7f0",
554
  c100=PAPER,
555
- c200="#d8cec0",
556
  c300=MUTED,
557
- c400="#918477",
558
- c500="#75695e",
559
- c600="#5f5248",
560
  c700=BORDER,
561
  c800=SURFACE_SOFT,
562
  c900=SURFACE,
@@ -569,8 +802,8 @@ def build_theme() -> gr.Theme:
569
  body_background_fill_dark=INK,
570
  body_text_color=PAPER,
571
  body_text_color_dark=PAPER,
572
- button_primary_background_fill=OXIDE,
573
- button_primary_background_fill_dark=OXIDE,
574
  button_primary_text_color="#ffffff",
575
  button_primary_text_color_dark="#ffffff",
576
  block_background_fill=SURFACE,
 
4
 
5
  import gradio as gr
6
 
7
+ BRASS = "#c59a52"
8
+ BRASS_DARK = "#8a6431"
9
+ COPPER = "#b85f3f"
10
+ TEAL = "#66d4c1"
11
+ VIOLET = "#9d8cff"
12
+ RED = "#ef5d52"
13
+
14
+ INK = "#0a0a0a"
15
+ CARBON = "#111111"
16
+ SURFACE = "#181715"
17
+ SURFACE_SOFT = "#211f1c"
18
+ SURFACE_LIFT = "#2c2924"
19
+ PAPER = "#f3eadb"
20
+ PAPER_SOFT = "#d7cbb8"
21
+ MUTED = "#a99b88"
22
+ BORDER = "#3a352e"
23
+ BLACK = "#050505"
24
 
25
  THEME_CSS = f"""
26
  :root {{
27
+ --halide-brass: {BRASS};
28
+ --halide-brass-dark: {BRASS_DARK};
29
+ --halide-copper: {COPPER};
30
+ --halide-teal: {TEAL};
31
  --halide-violet: {VIOLET};
32
  --halide-red: {RED};
33
  --halide-ink: {INK};
34
+ --halide-carbon: {CARBON};
35
  --halide-surface: {SURFACE};
36
  --halide-surface-soft: {SURFACE_SOFT};
37
  --halide-surface-lift: {SURFACE_LIFT};
38
  --halide-paper: {PAPER};
39
+ --halide-paper-soft: {PAPER_SOFT};
40
  --halide-muted: {MUTED};
41
  --halide-border: {BORDER};
42
  --halide-black: {BLACK};
43
  }}
44
 
45
+ html,
46
  body,
47
  .gradio-container {{
48
+ background: var(--halide-ink) !important;
 
49
  color: var(--halide-paper) !important;
50
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
51
  "Segoe UI", sans-serif !important;
52
+ letter-spacing: 0 !important;
53
+ }}
54
+
55
+ body::before {{
56
+ content: "";
57
+ position: fixed;
58
+ inset: 0;
59
+ pointer-events: none;
60
+ background:
61
+ repeating-linear-gradient(
62
+ 90deg,
63
+ rgba(255, 255, 255, 0.018) 0,
64
+ rgba(255, 255, 255, 0.018) 1px,
65
+ transparent 1px,
66
+ transparent 16px
67
+ );
68
+ opacity: 0.45;
69
  }}
70
 
71
  .gradio-container {{
72
  max-width: none !important;
73
+ min-height: 100vh !important;
74
+ padding: 0 18px 18px !important;
75
  }}
76
 
77
  #halide-app {{
 
82
  display: flex;
83
  align-items: center;
84
  justify-content: space-between;
85
+ gap: 18px;
86
+ max-width: 1720px;
87
+ margin: 0 auto;
88
+ padding: 18px 0 14px;
89
+ border-bottom: 1px solid rgba(197, 154, 82, 0.34);
90
  }}
91
 
92
  .halide-brand-lockup {{
93
+ display: flex;
94
+ align-items: center;
95
+ gap: 14px;
96
  min-width: 0;
97
  }}
98
 
99
+ .halide-brand-mark {{
100
+ width: 46px;
101
+ height: 46px;
102
+ border-radius: 8px;
103
+ object-fit: cover;
104
+ border: 1px solid rgba(197, 154, 82, 0.45);
105
+ background: var(--halide-black);
106
+ }}
107
+
108
  #halide-header h1 {{
109
  color: var(--halide-paper);
110
+ font-size: clamp(1.5rem, 2.2vw, 2.35rem);
111
+ line-height: 1.04;
112
+ margin: 2px 0 0;
113
+ font-weight: 860;
114
+ letter-spacing: 0 !important;
115
  }}
116
 
117
  .halide-kicker {{
118
+ color: var(--halide-teal);
119
  display: inline-block;
120
+ font-size: 0.72rem;
121
+ font-weight: 840;
122
+ letter-spacing: 0.12em !important;
123
  text-transform: uppercase;
124
  }}
125
 
 
127
  display: flex;
128
  flex-wrap: wrap;
129
  justify-content: flex-end;
130
+ gap: 8px;
131
+ min-width: min(42vw, 36rem);
132
  }}
133
 
134
  .halide-model-strip span,
135
  .halide-model-strip a {{
136
+ border: 1px solid rgba(197, 154, 82, 0.32);
137
+ color: var(--halide-paper);
138
+ background: rgba(24, 23, 21, 0.92);
139
  border-radius: 8px;
140
+ padding: 8px 10px;
141
  font-size: 0.76rem;
142
+ font-weight: 780;
143
  line-height: 1;
144
  text-decoration: none;
145
  white-space: nowrap;
146
  }}
147
 
148
+ .halide-model-strip a {{
149
+ color: #dffcf6;
150
+ border-color: rgba(102, 212, 193, 0.38);
151
+ }}
152
+
153
  .halide-workbench {{
154
+ max-width: 1720px;
155
+ margin: 14px auto 0 !important;
156
+ gap: 14px !important;
157
  align-items: flex-start;
158
  }}
159
 
 
163
  min-width: 0 !important;
164
  }}
165
 
166
+ .halide-intake-panel,
167
+ .halide-inspector {{
168
+ background: rgba(17, 17, 17, 0.98);
169
+ border: 1px solid rgba(58, 53, 46, 0.95);
170
+ border-radius: 8px;
171
+ padding: 12px;
172
  }}
173
 
174
+ .halide-panel-title {{
175
+ color: var(--halide-paper);
176
+ font-size: 0.76rem;
177
+ font-weight: 880;
178
+ letter-spacing: 0.1em !important;
179
+ text-transform: uppercase;
180
+ margin: 0 0 8px;
181
  }}
182
 
183
+ .halide-rail-copy {{
184
+ color: var(--halide-muted);
185
+ font-size: 0.84rem;
186
+ line-height: 1.42;
187
+ margin: 0 0 12px;
 
 
 
188
  }}
189
 
190
+ .halide-model-card {{
191
+ border: 1px solid rgba(102, 212, 193, 0.28);
192
+ background: rgba(102, 212, 193, 0.055);
193
+ border-radius: 8px;
194
+ padding: 12px;
195
+ margin-top: 12px;
196
  }}
197
 
198
+ .halide-model-card span {{
199
+ color: var(--halide-teal);
200
+ display: block;
201
+ font-size: 0.68rem;
202
+ font-weight: 860;
203
+ letter-spacing: 0.11em !important;
204
+ text-transform: uppercase;
205
+ margin-bottom: 5px;
206
  }}
207
 
208
+ .halide-model-card strong {{
209
+ color: var(--halide-paper);
210
+ display: block;
211
+ font-size: 0.98rem;
212
+ line-height: 1.2;
213
+ }}
214
+
215
+ .halide-model-card p {{
216
+ color: var(--halide-muted);
217
+ margin: 6px 0 0;
218
+ font-size: 0.82rem;
219
+ line-height: 1.35;
220
+ }}
221
+
222
+ .halide-run-state {{
223
+ display: grid;
224
+ grid-template-columns: minmax(0, 1fr);
225
+ gap: 5px;
226
+ border-radius: 8px;
227
+ padding: 13px 15px;
228
+ margin-bottom: 12px;
229
+ background: var(--halide-surface);
230
+ border: 1px solid rgba(197, 154, 82, 0.30);
231
+ min-height: 76px;
232
+ }}
233
+
234
+ .halide-run-state strong {{
235
+ color: var(--halide-paper);
236
+ font-size: clamp(1.08rem, 1.5vw, 1.45rem);
237
+ line-height: 1.1;
238
+ }}
239
+
240
+ .halide-run-state span:last-child {{
241
+ color: var(--halide-muted);
242
+ font-size: 0.84rem;
243
+ }}
244
+
245
+ .halide-run-state.active {{
246
+ border-color: rgba(102, 212, 193, 0.46);
247
+ }}
248
+
249
+ .halide-run-state.quiet {{
250
+ border-color: rgba(157, 140, 255, 0.35);
251
+ }}
252
+
253
+ .halide-run-eyebrow {{
254
+ color: var(--halide-brass);
255
+ font-size: 0.68rem;
256
+ font-weight: 860;
257
+ letter-spacing: 0.12em !important;
258
+ text-transform: uppercase;
259
+ }}
260
+
261
+ .halide-lighttable {{
262
+ background: #0f0f0e !important;
263
+ border: 1px solid rgba(197, 154, 82, 0.34) !important;
264
  border-radius: 8px !important;
265
+ padding: 13px !important;
266
+ box-shadow: 0 22px 52px rgba(0, 0, 0, 0.42) !important;
267
  }}
268
 
269
+ .halide-section-header {{
270
+ display: flex;
271
+ align-items: flex-start;
272
+ justify-content: space-between;
273
+ gap: 12px;
274
+ margin-bottom: 12px;
275
+ }}
276
+
277
+ .halide-section-header span {{
278
+ color: var(--halide-brass);
279
+ display: block;
280
+ font-size: 0.68rem;
281
+ font-weight: 880;
282
+ letter-spacing: 0.12em !important;
283
+ text-transform: uppercase;
284
+ }}
285
+
286
+ .halide-section-header strong {{
287
+ color: var(--halide-paper);
288
+ display: block;
289
+ font-size: 1rem;
290
+ line-height: 1.2;
291
+ margin-top: 3px;
292
+ }}
293
+
294
+ .halide-section-header small {{
295
+ color: var(--halide-muted);
296
+ border: 1px solid rgba(197, 154, 82, 0.26);
297
+ border-radius: 999px;
298
+ padding: 5px 9px;
299
+ font-size: 0.72rem;
300
+ line-height: 1;
301
+ }}
302
+
303
+ #halide-compare,
304
+ .halide-review-gallery {{
305
+ background: var(--halide-black) !important;
306
  }}
307
 
308
+ .halide-review-gallery {{
309
+ margin-top: 12px !important;
310
+ }}
311
+
312
+ .halide-upload img,
313
+ #halide-compare img,
314
+ .halide-review-gallery img {{
315
+ background: var(--halide-black) !important;
316
+ object-fit: contain !important;
317
  }}
318
 
319
  .halide-inline-controls {{
320
+ gap: 8px !important;
321
  }}
322
 
323
  #halide-run-button,
324
  button.primary,
325
  .primary button {{
326
+ background: var(--halide-copper) !important;
327
  color: white !important;
328
+ border: 1px solid rgba(255, 255, 255, 0.16) !important;
329
  border-radius: 8px !important;
330
+ font-weight: 860 !important;
331
  letter-spacing: 0 !important;
332
+ min-height: 46px !important;
333
+ box-shadow: 0 16px 30px rgba(184, 95, 63, 0.24) !important;
334
  }}
335
 
336
  #halide-run-button:hover,
337
  button.primary:hover,
338
  .primary button:hover {{
339
+ background: #ca6f4c !important;
340
  }}
341
 
342
  button.secondary,
343
  .secondary button,
344
  button {{
345
  border-radius: 8px !important;
346
+ font-weight: 760 !important;
347
  }}
348
 
349
  .halide-section-title {{
350
  color: var(--halide-paper);
351
+ font-size: 0.76rem;
352
+ font-weight: 880;
353
+ letter-spacing: 0.1em !important;
354
  text-transform: uppercase;
355
+ margin: 0 0 10px;
356
  }}
357
 
358
  .halide-subsection {{
359
  color: var(--halide-muted);
360
  font-size: 0.72rem;
361
+ font-weight: 860;
362
+ letter-spacing: 0.08em !important;
363
+ margin: 14px 0 7px;
364
  text-transform: uppercase;
365
  }}
366
 
 
369
  margin: 0;
370
  }}
371
 
372
+ .halide-empty-card {{
373
+ background: rgba(33, 31, 28, 0.82);
374
+ border: 1px solid rgba(197, 154, 82, 0.28);
375
+ border-radius: 8px;
376
+ padding: 13px;
377
+ }}
378
+
379
+ .halide-empty-card span {{
380
+ color: var(--halide-brass);
381
+ display: block;
382
+ font-size: 0.68rem;
383
+ font-weight: 880;
384
+ letter-spacing: 0.12em !important;
385
+ text-transform: uppercase;
386
+ margin-bottom: 6px;
387
+ }}
388
+
389
+ .halide-empty-card strong {{
390
+ color: var(--halide-paper);
391
+ display: block;
392
+ font-size: 1rem;
393
+ line-height: 1.2;
394
+ }}
395
+
396
+ .halide-empty-card p {{
397
+ color: var(--halide-muted);
398
+ font-size: 0.84rem;
399
+ line-height: 1.4;
400
+ margin: 7px 0 0;
401
+ }}
402
+
403
  .halide-notice {{
404
  border-radius: 8px;
405
+ padding: 11px 12px;
406
  border: 1px solid var(--halide-border);
407
  color: var(--halide-paper);
408
+ background: rgba(33, 31, 28, 0.9);
409
  font-size: 0.88rem;
410
+ line-height: 1.42;
411
+ margin-bottom: 10px;
412
  }}
413
 
414
  .halide-notice.good {{
415
+ border-color: rgba(102, 212, 193, 0.38);
416
+ background: rgba(102, 212, 193, 0.075);
417
  }}
418
 
419
  .halide-notice.caution {{
420
+ border-color: rgba(197, 154, 82, 0.48);
421
+ background: rgba(197, 154, 82, 0.08);
422
  }}
423
 
424
  .halide-notice.neutral {{
425
+ border-color: rgba(157, 140, 255, 0.36);
426
+ background: rgba(157, 140, 255, 0.075);
427
  }}
428
 
429
  .halide-stats {{
430
  display: grid;
431
+ gap: 0;
432
  }}
433
 
434
  .halide-stats.compact {{
435
+ gap: 0;
436
  }}
437
 
438
  .halide-stat {{
439
  display: grid;
440
+ grid-template-columns: minmax(7rem, 0.42fr) minmax(0, 1fr);
441
+ gap: 12px;
442
+ padding: 8px 0;
443
+ border-bottom: 1px solid rgba(58, 53, 46, 0.85);
444
  color: var(--halide-paper);
445
  min-width: 0;
446
  }}
 
451
 
452
  .halide-stat-label {{
453
  color: var(--halide-muted);
454
+ font-weight: 780;
455
  }}
456
 
457
  .halide-stat span:last-child {{
 
459
  text-align: right;
460
  }}
461
 
462
+ .halide-report {{
463
+ display: grid;
464
+ gap: 10px;
465
+ }}
466
+
467
+ .halide-report-section {{
468
+ background: rgba(33, 31, 28, 0.82);
469
+ border: 1px solid rgba(58, 53, 46, 0.94);
470
+ border-left: 3px solid var(--halide-brass);
471
  border-radius: 8px;
472
+ padding: 12px 13px;
473
+ }}
474
+
475
+ .halide-report-heading {{
476
  color: var(--halide-paper);
477
+ font-size: 0.78rem;
478
+ font-weight: 880;
479
+ letter-spacing: 0.1em !important;
480
+ margin-bottom: 8px;
481
+ text-transform: uppercase;
482
+ }}
483
+
484
+ .halide-report-body {{
485
+ color: var(--halide-paper-soft);
486
+ font-size: 0.93rem;
487
+ line-height: 1.56;
488
+ }}
489
+
490
+ .halide-report-body p {{
491
+ margin: 0 0 8px;
492
+ }}
493
+
494
+ .halide-report-body p:last-child {{
495
+ margin-bottom: 0;
496
+ }}
497
+
498
+ .halide-report-body ul,
499
+ .halide-report-body ol {{
500
+ margin: 0;
501
+ padding-left: 1.15rem;
502
+ }}
503
+
504
+ .halide-report-body li {{
505
+ margin: 5px 0;
506
+ padding-left: 2px;
507
  }}
508
 
509
  .halide-pill-row {{
510
  display: flex;
511
  flex-wrap: wrap;
512
+ gap: 7px;
513
+ margin-bottom: 10px;
514
  }}
515
 
516
  .halide-defect-pill {{
517
  display: inline-flex;
518
  align-items: center;
519
+ gap: 6px;
520
+ background: rgba(184, 95, 63, 0.13);
521
+ color: #ffe4d8;
522
+ border: 1px solid rgba(184, 95, 63, 0.38);
523
+ padding: 6px 9px;
524
+ border-radius: 999px;
525
+ font-size: 0.78rem;
526
+ font-weight: 780;
527
  white-space: nowrap;
528
  }}
529
 
 
532
  }}
533
 
534
  .halide-defect-pill.dust {{
535
+ background: rgba(197, 154, 82, 0.13);
536
+ border-color: rgba(197, 154, 82, 0.38);
537
  }}
538
 
539
  .halide-defect-pill.dirt {{
540
+ background: rgba(184, 95, 63, 0.13);
541
+ border-color: rgba(184, 95, 63, 0.38);
542
  }}
543
 
544
  .halide-defect-pill.scratch {{
545
+ background: rgba(239, 93, 82, 0.14);
546
+ border-color: rgba(239, 93, 82, 0.42);
547
  }}
548
 
549
  .halide-defect-pill.long_hair {{
550
+ background: rgba(157, 140, 255, 0.13);
551
+ border-color: rgba(157, 140, 255, 0.38);
552
  }}
553
 
554
  .halide-defect-pill.short_hair {{
555
+ background: rgba(102, 212, 193, 0.11);
556
+ border-color: rgba(102, 212, 193, 0.36);
557
  }}
558
 
559
  .halide-defect-pill.emulsion_damage {{
560
+ background: rgba(215, 203, 184, 0.12);
561
+ border-color: rgba(215, 203, 184, 0.34);
562
  }}
563
 
564
  .halide-defect-pill.chemical_stain {{
565
+ background: rgba(74, 222, 128, 0.12);
566
+ border-color: rgba(74, 222, 128, 0.34);
567
  }}
568
 
569
  .halide-defect-pill.light_leak {{
 
572
  }}
573
 
574
  .halide-history-item {{
575
+ background: rgba(33, 31, 28, 0.82);
576
+ border: 1px solid rgba(58, 53, 46, 0.94);
577
  border-radius: 8px;
578
+ margin-bottom: 9px;
579
+ padding: 11px;
580
  }}
581
 
582
  .halide-history-title {{
583
  color: var(--halide-paper);
584
+ font-weight: 860;
585
+ margin-bottom: 4px;
586
  overflow-wrap: anywhere;
587
  }}
588
 
589
  .halide-history-meta {{
590
  color: var(--halide-muted);
591
  font-size: 0.78rem;
592
+ line-height: 1.36;
593
+ margin: 4px 0;
594
  }}
595
 
596
  .halide-history-detail {{
597
  display: grid;
598
+ gap: 8px;
599
  }}
600
 
601
  .halide-history-feed {{
 
603
  overflow: auto;
604
  }}
605
 
606
+ .halide-intake-panel .block,
607
+ .halide-inspector .block,
608
+ .halide-lighttable .block {{
609
+ background: rgba(24, 23, 21, 0.95) !important;
610
+ border-color: rgba(58, 53, 46, 0.95) !important;
611
+ border-radius: 8px !important;
612
+ box-shadow: none !important;
613
+ }}
614
+
615
  input,
616
  textarea,
617
  select,
 
632
  }}
633
 
634
  .label-wrap,
635
+ .label-wrap span,
 
 
 
 
 
 
 
636
  label[data-testid="block-label"] {{
637
+ background: rgba(33, 31, 28, 0.98) !important;
638
+ border-color: rgba(197, 154, 82, 0.24) !important;
639
  color: var(--halide-paper) !important;
640
  border-radius: 6px !important;
641
+ font-weight: 780 !important;
642
  box-shadow: none !important;
643
  }}
644
 
 
647
  }}
648
 
649
  label[data-testid$="-radio-label"] {{
650
+ background: rgba(33, 31, 28, 0.98) !important;
651
+ border: 1px solid rgba(243, 234, 219, 0.18) !important;
652
+ border-radius: 8px !important;
653
  box-shadow: none !important;
654
  opacity: 1 !important;
655
  }}
656
 
657
  label[data-testid$="-radio-label"].selected {{
658
+ background: rgba(197, 154, 82, 0.16) !important;
659
+ border-color: rgba(197, 154, 82, 0.5) !important;
660
  }}
661
 
662
  label[data-testid$="-radio-label"] span,
 
667
  }}
668
 
669
  label[data-testid$="-radio-label"] input {{
670
+ accent-color: var(--halide-brass);
671
+ }}
672
+
673
+ .tabs {{
674
+ gap: 4px !important;
675
  }}
676
 
677
  .tabs button {{
678
  color: var(--halide-muted) !important;
679
  font-weight: 820 !important;
680
+ border-radius: 8px !important;
681
  }}
682
 
683
  .tabs button.selected {{
684
  color: var(--halide-paper) !important;
685
+ border-color: var(--halide-brass) !important;
686
+ background: rgba(197, 154, 82, 0.10) !important;
687
  }}
688
 
689
  table,
 
701
  }}
702
 
703
  footer {{
704
+ max-width: 1720px;
705
+ margin: 0 auto;
706
  color: var(--halide-muted) !important;
707
  text-align: center;
708
+ padding: 14px 8px 4px;
709
  font-size: 0.82rem;
710
  }}
711
 
712
+ @media (max-width: 1180px) {{
713
+ .halide-workbench {{
714
+ flex-direction: column !important;
715
+ }}
716
+
717
+ .halide-intake-panel,
718
+ .halide-main-stage,
719
+ .halide-inspector {{
720
+ width: 100% !important;
721
+ }}
722
+ }}
723
+
724
+ @media (max-width: 760px) {{
725
+ .gradio-container {{
726
+ padding: 0 10px 12px !important;
727
+ }}
728
+
729
  #halide-header {{
730
  align-items: flex-start;
731
  flex-direction: column;
 
736
  min-width: 0;
737
  }}
738
 
739
+ .halide-brand-mark {{
740
+ width: 40px;
741
+ height: 40px;
 
 
 
 
 
 
 
 
 
742
  }}
743
 
744
  .halide-stat {{
745
  grid-template-columns: 1fr;
746
+ gap: 3px;
747
  }}
748
 
749
  .halide-stat span:last-child {{
 
757
  """Build a compact custom workbench theme."""
758
  return gr.themes.Base(
759
  primary_hue=gr.themes.Color(
760
+ c50="#fff8ed",
761
+ c100="#f8ead2",
762
+ c200="#ebd1a3",
763
+ c300="#d9b36d",
764
+ c400=BRASS,
765
+ c500=BRASS,
766
+ c600=BRASS_DARK,
767
+ c700="#694a24",
768
+ c800="#4d361d",
769
+ c900="#332313",
770
+ c950="#1c1209",
771
  ),
772
  secondary_hue=gr.themes.Color(
773
+ c50="#effffb",
774
+ c100="#d5fff7",
775
+ c200="#a8f5ea",
776
+ c300=TEAL,
777
+ c400="#3fbfae",
778
+ c500="#259d90",
779
+ c600="#1d7d74",
780
+ c700="#1b645e",
781
+ c800="#174d49",
782
+ c900="#123c39",
783
+ c950="#071f1d",
784
  ),
785
  neutral_hue=gr.themes.Color(
786
+ c50="#fbf7ef",
787
  c100=PAPER,
788
+ c200=PAPER_SOFT,
789
  c300=MUTED,
790
+ c400="#85796a",
791
+ c500="#6e6357",
792
+ c600="#544d44",
793
  c700=BORDER,
794
  c800=SURFACE_SOFT,
795
  c900=SURFACE,
 
802
  body_background_fill_dark=INK,
803
  body_text_color=PAPER,
804
  body_text_color_dark=PAPER,
805
+ button_primary_background_fill=COPPER,
806
+ button_primary_background_fill_dark=COPPER,
807
  button_primary_text_color="#ffffff",
808
  button_primary_text_color_dark="#ffffff",
809
  block_background_fill=SURFACE,