kavyabhand commited on
Commit
8ec00bd
·
verified ·
1 Parent(s): 07e6e32

Deploy Aether Garden application

Browse files
Files changed (6) hide show
  1. app.py +315 -158
  2. ui/book_display.py +74 -0
  3. ui/entity_card.py +44 -0
  4. ui/place_ribbon.py +37 -0
  5. ui/relationship_graph.py +46 -32
  6. ui/styles.css +394 -9
app.py CHANGED
@@ -31,8 +31,15 @@ from world.locations import get_all_locations, get_location_by_id, update_entity
31
  from ai.generation import generate_entity
32
  from persistence.backup import backup_database, restore_database
33
  from ui import assets
34
- from ui.book_display import render_activity_feed, render_book_of_ages, render_current_event
35
- from ui.entity_card import render_entity_card, render_entity_grid
 
 
 
 
 
 
 
36
  from ui.explore import (
37
  place_gallery_items,
38
  place_ids,
@@ -46,7 +53,7 @@ from ui.featured import render_featured_characters, render_world_vignette
46
  from ui.hero import render_hero_banner
47
  from ui.map import render_world_map
48
  from ui.pulse import render_realm_pulse
49
- from ui.relationship_graph import render_relationship_graph
50
  from ui.share import build_soul_card, share_caption
51
 
52
  CSS_PATH = Path(__file__).parent / "ui" / "styles.css"
@@ -127,7 +134,6 @@ def opening_gate_html() -> str:
127
  <button class="realm-toc-link" data-spread="book">III. The Book of Ages</button>
128
  <button class="realm-toc-link" data-spread="entities">IV. Souls of the Garden</button>
129
  <button class="realm-toc-link" data-spread="bonds">V. Bonds and Alliances</button>
130
- <button class="realm-toc-link" data-spread="summon">VI. Summon a New Soul</button>
131
  </div>
132
  </div>
133
  </div>
@@ -255,7 +261,8 @@ def _summon_error(message: str):
255
  render_location_panel(None),
256
  _HIDE_CARD,
257
  _HIDE_CAPTION,
258
- "",
 
259
  )
260
 
261
 
@@ -296,7 +303,8 @@ def summon_entity(description: str, session_id: str):
296
  render_location_panel(entity["location_id"]),
297
  card_update,
298
  caption_update,
299
- render_diorama(entity["location_id"]),
 
300
  )
301
 
302
 
@@ -332,15 +340,39 @@ def filter_entities(location_filter, type_filter, status_filter, search):
332
  return render_entity_grid(entities)
333
 
334
 
335
- def filter_book(entry_type, search):
336
- return render_book_of_ages(
 
337
  entry_type=entry_type,
338
  search=search,
339
- limit=8,
340
- include_header=False,
341
  )
342
 
343
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
344
  def chat_with_soul(soul_id: str, player_message: str) -> str:
345
  """AI response from a soul given a visitor's message. Called from diorama iframe."""
346
  if not soul_id or not player_message or not player_message.strip():
@@ -514,28 +546,48 @@ def refresh_all_views():
514
  _SOUL_HINT = '<div class="feed-empty">Click a soul above to meet them.</div>'
515
 
516
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
517
  def enter_place(evt: gr.SelectData):
518
  ids = place_ids()
519
  if evt.index is None or evt.index >= len(ids):
520
- return None, render_diorama(None), render_room(None), gr.update(value=[]), [], _SOUL_HINT
521
  loc_id = ids[evt.index]
522
- items, soul_ids = soul_gallery(loc_id)
523
- return loc_id, render_diorama(loc_id), render_room(loc_id), gr.update(value=items), soul_ids, _SOUL_HINT
524
 
525
 
526
  def wander_to_place():
527
  loc_id = random_place_id()
528
  if not loc_id:
529
- return None, render_diorama(None), render_room(None), gr.update(value=[]), [], _SOUL_HINT
530
- items, soul_ids = soul_gallery(loc_id)
531
- return loc_id, render_diorama(loc_id), render_room(loc_id), gr.update(value=items), soul_ids, _SOUL_HINT
532
 
533
 
534
  def open_diorama_from_map(location_id: int | None):
535
- if not location_id:
536
- return render_diorama(None), render_room(None), gr.update(value=[]), [], _SOUL_HINT
537
- items, soul_ids = soul_gallery(location_id)
538
- return render_diorama(location_id), render_room(location_id), gr.update(value=items), soul_ids, _SOUL_HINT
 
539
 
540
 
541
  def meet_soul(evt: gr.SelectData, soul_ids: list):
@@ -565,6 +617,10 @@ def build_app() -> gr.Blocks:
565
  with gr.Column(elem_classes=["tome-spread-shell"]):
566
  with gr.Row(elem_classes=["tome-spread-row"]):
567
  with gr.Column(scale=1, elem_classes=["tome-page-left", "tome-printed-page"]):
 
 
 
 
568
  world_map = gr.HTML(
569
  value=render_world_map(),
570
  elem_id="world-map",
@@ -574,12 +630,47 @@ def build_app() -> gr.Blocks:
574
  )
575
 
576
  with gr.Column(scale=1, elem_classes=["tome-page-right", "tome-printed-page"]):
 
577
  current_event = gr.HTML(value=render_current_event())
578
- activity_feed = gr.HTML(value=render_activity_feed(limit=3))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
579
 
580
  with gr.Column(elem_classes=["tome-offpage-store"]):
581
  world_vignette = gr.HTML(value=render_world_vignette())
582
- realm_pulse = gr.HTML(value=render_realm_pulse())
583
  realm_diorama = gr.HTML(value="")
584
  map_pick = gr.Textbox(visible=False, elem_id="map_location_pick")
585
  with gr.Accordion("How this world works", open=False):
@@ -614,31 +705,31 @@ def build_app() -> gr.Blocks:
614
  explore_loc = gr.State(value=_default_loc_id)
615
  soul_ids_state = gr.State(value=[])
616
 
617
- gr.HTML(
618
- '<div class="explore-intro">'
619
- '<h2 class="section-title">Walk the Eight Sacred Places</h2>'
620
- '<p class="section-desc">Pick a place below — then <strong>WASD to walk</strong>, '
621
- '<strong>drag to look</strong>, <strong>E to talk</strong> to any soul you find. '
622
- 'Other live visitors appear as golden wisps when they step into the same place. '
623
- 'Or press ✦ to wander somewhere at random.</p>'
624
- '</div>'
625
- )
626
- wander_btn = gr.Button("✦ Wander somewhere at random", variant="secondary")
627
-
628
- places_gallery = gr.Gallery(
629
- value=place_gallery_items(),
630
- columns=4,
631
- height="auto",
632
- object_fit="cover",
633
- allow_preview=False,
634
- show_label=False,
635
- elem_classes=["places-gallery"],
636
- )
637
 
638
- diorama_view = gr.HTML(value=render_diorama(_default_loc_id))
639
- portal_pick = gr.Textbox(visible=False, elem_id="portal_location_pick")
 
 
 
 
 
640
 
641
- with gr.Accordion("Classic view (if 3D does not load)", open=False):
 
 
 
 
 
 
 
 
 
642
  room_view = gr.HTML(value=render_room(_default_loc_id))
643
  souls_gallery = gr.Gallery(
644
  value=[],
@@ -652,15 +743,15 @@ def build_app() -> gr.Blocks:
652
  soul_card = gr.HTML()
653
 
654
  with gr.Tab("Book of Ages", id="book"):
 
 
 
 
655
  with gr.Column(elem_classes=["tome-spread-shell"]):
656
  with gr.Row(elem_classes=["tome-spread-row"]):
657
  with gr.Column(scale=1, elem_classes=["tome-page-left", "tome-printed-page", "tome-book-index"]):
658
- gr.HTML(
659
- '<div class="tome-printed-heading">'
660
- '<h2 class="book-title">Book of Ages</h2>'
661
- '<p class="book-subtitle">Every creation, encounter, and event — remembered forever</p>'
662
- '</div>'
663
- )
664
  book_filter = gr.Dropdown(
665
  choices=[
666
  ("All", "all"),
@@ -679,83 +770,57 @@ def build_app() -> gr.Blocks:
679
  )
680
  with gr.Column(scale=1, elem_classes=["tome-page-right", "tome-printed-page"]):
681
  book_display = gr.HTML(
682
- value=render_book_of_ages(limit=8, include_header=False)
683
  )
684
 
685
  with gr.Tab("Bonds and Alliances", id="bonds"):
686
- gr.HTML(
687
- '<div class="explore-intro">'
688
- '<h2 class="section-title">The Web of Bonds</h2>'
689
- '<p class="section-desc">Every alliance, rivalry, and debt forged by the simulation — '
690
- 'not scripted, only remembered.</p></div>'
691
- )
692
- rel_graph = gr.HTML(value=render_relationship_graph())
693
 
694
  with gr.Tab("Souls of the Garden", id="entities"):
695
  featured_chars = gr.HTML(value=render_featured_characters())
696
- with gr.Row():
697
- loc_filter = gr.Dropdown(
698
- choices=["all"] + [l["name"] for l in get_all_locations()],
699
- value="all",
700
- label="Location",
701
- )
702
- type_filter = gr.Dropdown(
703
- choices=["all", "character", "creature", "object", "place"],
704
- value="all",
705
- label="Type",
706
- )
707
- status_filter = gr.Dropdown(
708
- choices=["all", "active", "dormant", "legendary"],
709
- value="all",
710
- label="Status",
711
- )
712
- entity_search = gr.Textbox(placeholder="Search...", label="Search")
713
- entity_grid = gr.HTML(value=render_entity_grid(get_all_entities()))
714
- entity_detail = gr.HTML()
715
-
716
- entity_names = gr.Dropdown(
717
- choices=[e["display_name"] for e in get_all_entities()],
718
- label="View Entity Profile",
719
- )
720
 
721
- with gr.Tab("Summon a New Soul", id="summon"):
722
- gr.HTML('<div id="summon-spread" class="summon-section"><h3 class="summon-title">Summon Something Into the Realm</h3></div>')
723
- with gr.Row():
724
- summon_input = gr.Textbox(
725
- placeholder="Describe what you are bringing into the world...",
726
- label=None,
727
- show_label=False,
728
- lines=5,
729
- scale=4,
730
- )
731
- summon_btn = gr.Button("Seal & Summon", variant="primary", scale=1, elem_classes=["seal-summon-btn"])
732
-
733
- gr.HTML(
734
- '<p class="summon-hint">Hint: "A wizard" is forgotten. '
735
- '"A wizard who collects the sound of doors closing" is remembered.</p>'
736
- )
737
- entity_result = gr.HTML()
738
-
739
- with gr.Group(visible=True):
740
- soul_card_img = gr.Image(
741
- label="Your Soul Card — download & share it",
742
- visible=False,
743
- show_label=True,
744
- interactive=False,
745
- type="filepath",
746
- elem_classes=["soul-card-image"],
747
- height=420,
748
- )
749
- share_caption_box = gr.Textbox(
750
- label="Share caption (copy this with your card)",
751
- visible=False,
752
- lines=3,
753
- interactive=True,
754
- )
755
 
756
  _summon_outputs = [
757
  world_map, header, activity_feed, entity_result, location_panel,
758
- soul_card_img, share_caption_box, realm_diorama,
 
 
 
 
759
  ]
760
 
761
  summon_btn.click(
@@ -812,47 +877,39 @@ def build_app() -> gr.Blocks:
812
  """,
813
  )
814
 
815
- def _realm_location_change(loc_id):
816
- d = render_diorama(loc_id) if loc_id else ""
817
- return render_world_map(loc_id), render_location_panel(loc_id), d
818
-
819
- location_select.change(
820
- fn=_realm_location_change,
821
- inputs=[location_select],
822
- outputs=[world_map, location_panel, realm_diorama],
823
- )
824
-
825
- def _map_open_diorama(pick):
826
  try:
827
  loc_id = int(pick) if pick else None
828
  except (TypeError, ValueError):
829
  loc_id = None
830
- if not loc_id:
831
- return "", render_diorama(None), render_room(None), gr.update(value=[]), [], _SOUL_HINT
832
- items, soul_ids = soul_gallery(loc_id)
833
- return (
834
- render_diorama(loc_id),
835
- render_diorama(loc_id),
836
- render_room(loc_id),
837
- gr.update(value=items),
838
- soul_ids,
839
- _SOUL_HINT,
840
- )
841
 
842
  map_pick.change(
843
- _map_open_diorama,
844
  inputs=[map_pick],
845
- outputs=[realm_diorama, diorama_view, room_view, souls_gallery, soul_ids_state, soul_card],
 
 
 
 
 
 
846
  ).then(
847
  lambda pick: int(pick) if pick and str(pick).isdigit() else None,
848
- inputs=[map_pick],
849
  outputs=[explore_loc],
850
  )
851
 
852
  portal_pick.change(
853
  open_diorama_from_map,
854
  inputs=[portal_pick],
855
- outputs=[diorama_view, room_view, souls_gallery, soul_ids_state, soul_card],
856
  ).then(
857
  lambda pick: int(pick) if pick and str(pick).isdigit() else None,
858
  inputs=[portal_pick],
@@ -862,12 +919,12 @@ def build_app() -> gr.Blocks:
862
  places_gallery.select(
863
  enter_place,
864
  inputs=None,
865
- outputs=[explore_loc, diorama_view, room_view, souls_gallery, soul_ids_state, soul_card],
866
  )
867
  wander_btn.click(
868
  wander_to_place,
869
  inputs=None,
870
- outputs=[explore_loc, diorama_view, room_view, souls_gallery, soul_ids_state, soul_card],
871
  )
872
  souls_gallery.select(meet_soul, inputs=[soul_ids_state], outputs=[soul_card])
873
 
@@ -878,7 +935,7 @@ def build_app() -> gr.Blocks:
878
  ).then(
879
  fn=refresh_all_views,
880
  outputs=[world_map, header, current_event, activity_feed, world_vignette, featured_chars, realm_pulse],
881
- )
882
 
883
  # ── Soul chat API ──
884
  chat_soul_id_comp = gr.Textbox(visible=False, elem_id="chat_soul_id_input")
@@ -930,8 +987,20 @@ def build_app() -> gr.Blocks:
930
  outputs=[presence_bar],
931
  )
932
 
933
- book_filter.change(filter_book, [book_filter, book_search], [book_display])
934
- book_search.change(filter_book, [book_filter, book_search], [book_display])
 
 
 
 
 
 
 
 
 
 
 
 
935
 
936
  for f in [loc_filter, type_filter, status_filter, entity_search]:
937
  f.change(
@@ -940,13 +1009,15 @@ def build_app() -> gr.Blocks:
940
  [entity_grid],
941
  )
942
 
943
- entity_names.change(select_entity_from_dropdown, [entity_names], [entity_detail])
944
 
945
  refresh_timer = gr.Timer(30)
946
  refresh_timer.tick(
947
  fn=refresh_all_views,
948
  outputs=[world_map, header, current_event, activity_feed, world_vignette, featured_chars, realm_pulse],
949
- ).then(fn=render_relationship_graph, outputs=[rel_graph])
 
 
950
 
951
  MAP_CLICK_JS = """
952
  () => {
@@ -957,8 +1028,7 @@ def build_app() -> gr.Blocks:
957
  { key: 'explore', label: 'Explore the Realm', kind: 'tab', panel: 1 },
958
  { key: 'book', label: 'Book of Ages', kind: 'tab', panel: 2 },
959
  { key: 'entities', label: 'Souls of the Garden', kind: 'tab', panel: 4 },
960
- { key: 'bonds', label: 'Bonds and Alliances', kind: 'tab', panel: 3 },
961
- { key: 'summon', label: 'Summon a New Soul', kind: 'tab', panel: 5 }
962
  ];
963
  let currentSpread = 0;
964
 
@@ -1072,7 +1142,40 @@ def build_app() -> gr.Blocks:
1072
  if (floating) floating.classList.remove('open');
1073
  };
1074
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1075
  const goToSpread = (idx, direction = 'next') => {
 
 
 
 
1076
  const clamped = Math.max(0, Math.min(sections.length - 1, idx));
1077
  const spread = sections[clamped];
1078
  if (!spread) return;
@@ -1106,11 +1209,11 @@ def build_app() -> gr.Blocks:
1106
  <button type="button" id="tome-toc-open" class="tome-ribbon" aria-label="Open table of contents">Table of Contents</button>
1107
  <div id="tome-page-indicator" class="tome-page-indicator">Page I</div>
1108
  <div id="tome-floating-toc" class="tome-floating-toc">
1109
- ${sections.map((s, i) => `<button type="button" class="tome-floating-link" data-index="${i}">${s.key === 'summon' ? 'VI. Summon a New Soul' : `${toRoman(i + 1)}. ${s.label}`}</button>`).join('')}
1110
  </div>
1111
  <label class="sr-only" for="tome-jump">Jump to section</label>
1112
  <select id="tome-jump" class="tome-jump-select" aria-label="Jump to section">
1113
- ${sections.map((s, i) => `<option value="${i}">${s.key === 'summon' ? 'VI. Summon a New Soul' : `${toRoman(i + 1)}. ${s.label}`}</option>`).join('')}
1114
  </select>
1115
  `;
1116
  document.body.appendChild(nav);
@@ -1127,7 +1230,13 @@ def build_app() -> gr.Blocks:
1127
  }
1128
  if (next && !next.dataset.bound) {
1129
  next.dataset.bound = '1';
1130
- next.addEventListener('click', () => goToSpread(currentSpread + 1, 'next'));
 
 
 
 
 
 
1131
  }
1132
  if (tocOpen && !tocOpen.dataset.bound) {
1133
  tocOpen.dataset.bound = '1';
@@ -1158,7 +1267,10 @@ def build_app() -> gr.Blocks:
1158
  window.addEventListener('keydown', (e) => {
1159
  const tag = document.activeElement?.tagName;
1160
  if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
1161
- if (e.key === 'ArrowRight') goToSpread(currentSpread + 1, 'next');
 
 
 
1162
  if (e.key === 'ArrowLeft') goToSpread(currentSpread - 1, 'prev');
1163
  if (e.key === 'Escape') closeToc();
1164
  }, { passive: true });
@@ -1267,6 +1379,7 @@ def build_app() -> gr.Blocks:
1267
  if (!prologueReady) return;
1268
  playOpeningChime();
1269
  ensureAmbientMelody();
 
1270
  gate.classList.add('is-opened');
1271
  closeToc();
1272
  setTimeout(() => gate.classList.add('is-closed'), 700);
@@ -1290,6 +1403,12 @@ def build_app() -> gr.Blocks:
1290
  gate.addEventListener('click', (e) => {
1291
  const target = e.target;
1292
  if (!(target instanceof Element)) return;
 
 
 
 
 
 
1293
  if (target.id === 'realm-opening-enter') {
1294
  e.preventDefault();
1295
  e.stopPropagation();
@@ -1333,8 +1452,46 @@ def build_app() -> gr.Blocks:
1333
  });
1334
  });
1335
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1336
  bind();
1337
- new MutationObserver(bind).observe(document.body, { childList: true, subtree: true });
 
1338
 
1339
  window._aetherHoverDiorama = false;
1340
  window._aetherDioramaActive = false;
 
31
  from ai.generation import generate_entity
32
  from persistence.backup import backup_database, restore_database
33
  from ui import assets
34
+ from ui.book_display import (
35
+ render_activity_feed,
36
+ render_book_for_day,
37
+ render_book_of_ages,
38
+ render_current_event,
39
+ render_day_tabs,
40
+ )
41
+ from ui.entity_card import render_entity_card, render_entity_grid, render_entity_hologram
42
+ from ui.place_ribbon import render_place_ribbon
43
  from ui.explore import (
44
  place_gallery_items,
45
  place_ids,
 
53
  from ui.hero import render_hero_banner
54
  from ui.map import render_world_map
55
  from ui.pulse import render_realm_pulse
56
+ from ui.relationship_graph import render_bonds_sidebar, render_relationship_graph
57
  from ui.share import build_soul_card, share_caption
58
 
59
  CSS_PATH = Path(__file__).parent / "ui" / "styles.css"
 
134
  <button class="realm-toc-link" data-spread="book">III. The Book of Ages</button>
135
  <button class="realm-toc-link" data-spread="entities">IV. Souls of the Garden</button>
136
  <button class="realm-toc-link" data-spread="bonds">V. Bonds and Alliances</button>
 
137
  </div>
138
  </div>
139
  </div>
 
261
  render_location_panel(None),
262
  _HIDE_CARD,
263
  _HIDE_CAPTION,
264
+ render_realm_pulse(),
265
+ render_realm_stats(),
266
  )
267
 
268
 
 
303
  render_location_panel(entity["location_id"]),
304
  card_update,
305
  caption_update,
306
+ render_realm_pulse(),
307
+ render_realm_stats(),
308
  )
309
 
310
 
 
340
  return render_entity_grid(entities)
341
 
342
 
343
+ def filter_book(day, entry_type, search):
344
+ return render_book_for_day(
345
+ day=int(day) if day else None,
346
  entry_type=entry_type,
347
  search=search,
348
+ limit=12,
 
349
  )
350
 
351
 
352
+ def select_entity_by_id(entity_id: str):
353
+ if not entity_id:
354
+ return render_entity_hologram(None)
355
+ from world.entities import get_entity
356
+
357
+ entity = get_entity(entity_id)
358
+ if not entity:
359
+ return render_entity_hologram(None)
360
+ return render_entity_hologram(entity)
361
+
362
+
363
+ def render_realm_stats() -> str:
364
+ state = get_world_state()
365
+ day = int(state.get("current_day", 1) or 1)
366
+ count = len(get_all_entities())
367
+ return f"""
368
+ <div class="realm-stats-strip">
369
+ <span>World Day <b>{day}</b></span>
370
+ <span>{count} souls awake</span>
371
+ <span>8 sacred places</span>
372
+ </div>
373
+ """
374
+
375
+
376
  def chat_with_soul(soul_id: str, player_message: str) -> str:
377
  """AI response from a soul given a visitor's message. Called from diorama iframe."""
378
  if not soul_id or not player_message or not player_message.strip():
 
546
  _SOUL_HINT = '<div class="feed-empty">Click a soul above to meet them.</div>'
547
 
548
 
549
+ def _explore_scene(loc_id: int | None):
550
+ if not loc_id:
551
+ return (
552
+ render_diorama(None),
553
+ render_room(None),
554
+ gr.update(value=[]),
555
+ [],
556
+ _SOUL_HINT,
557
+ render_place_ribbon(None),
558
+ )
559
+ items, soul_ids = soul_gallery(loc_id)
560
+ return (
561
+ render_diorama(loc_id),
562
+ render_room(loc_id),
563
+ gr.update(value=items),
564
+ soul_ids,
565
+ _SOUL_HINT,
566
+ render_place_ribbon(loc_id),
567
+ )
568
+
569
+
570
  def enter_place(evt: gr.SelectData):
571
  ids = place_ids()
572
  if evt.index is None or evt.index >= len(ids):
573
+ return (None, *_explore_scene(None))
574
  loc_id = ids[evt.index]
575
+ return (loc_id, *_explore_scene(loc_id))
 
576
 
577
 
578
  def wander_to_place():
579
  loc_id = random_place_id()
580
  if not loc_id:
581
+ return (None, *_explore_scene(None))
582
+ return (loc_id, *_explore_scene(loc_id))
 
583
 
584
 
585
  def open_diorama_from_map(location_id: int | None):
586
+ try:
587
+ loc_id = int(location_id) if location_id else None
588
+ except (TypeError, ValueError):
589
+ loc_id = None
590
+ return _explore_scene(loc_id)
591
 
592
 
593
  def meet_soul(evt: gr.SelectData, soul_ids: list):
 
617
  with gr.Column(elem_classes=["tome-spread-shell"]):
618
  with gr.Row(elem_classes=["tome-spread-row"]):
619
  with gr.Column(scale=1, elem_classes=["tome-page-left", "tome-printed-page"]):
620
+ gr.HTML(
621
+ '<p class="tome-printed-kicker">The Living Realm</p>'
622
+ '<p class="tome-printed-hint">Tap a place on the map — its tale unfolds below.</p>'
623
+ )
624
  world_map = gr.HTML(
625
  value=render_world_map(),
626
  elem_id="world-map",
 
630
  )
631
 
632
  with gr.Column(scale=1, elem_classes=["tome-page-right", "tome-printed-page"]):
633
+ realm_stats = gr.HTML(value=render_realm_stats())
634
  current_event = gr.HTML(value=render_current_event())
635
+ realm_pulse = gr.HTML(value=render_realm_pulse(limit=4))
636
+ activity_feed = gr.HTML(value=render_activity_feed(limit=4))
637
+ gr.HTML(
638
+ '<div class="realm-summon-block">'
639
+ '<p class="tome-printed-kicker">Summon Into the Realm</p>'
640
+ '<p class="realm-summon-hint">Describe what you bring — the Garden will remember.</p>'
641
+ '</div>'
642
+ )
643
+ summon_input = gr.Textbox(
644
+ placeholder="A clockmaker who weaves time from fallen leaves…",
645
+ label=None,
646
+ show_label=False,
647
+ lines=3,
648
+ elem_classes=["realm-summon-input"],
649
+ )
650
+ summon_btn = gr.Button(
651
+ "Seal & Summon",
652
+ variant="primary",
653
+ elem_classes=["seal-summon-btn"],
654
+ )
655
+ entity_result = gr.HTML()
656
+ soul_card_img = gr.Image(
657
+ label="Your Soul Card",
658
+ visible=False,
659
+ show_label=True,
660
+ interactive=False,
661
+ type="filepath",
662
+ elem_classes=["soul-card-image"],
663
+ height=280,
664
+ )
665
+ share_caption_box = gr.Textbox(
666
+ label="Share caption",
667
+ visible=False,
668
+ lines=2,
669
+ interactive=True,
670
+ )
671
 
672
  with gr.Column(elem_classes=["tome-offpage-store"]):
673
  world_vignette = gr.HTML(value=render_world_vignette())
 
674
  realm_diorama = gr.HTML(value="")
675
  map_pick = gr.Textbox(visible=False, elem_id="map_location_pick")
676
  with gr.Accordion("How this world works", open=False):
 
705
  explore_loc = gr.State(value=_default_loc_id)
706
  soul_ids_state = gr.State(value=[])
707
 
708
+ with gr.Column(elem_classes=["tome-spread-shell"]):
709
+ with gr.Row(elem_classes=["tome-spread-row"]):
710
+ with gr.Column(scale=1, elem_classes=["tome-page-left", "tome-printed-page", "tome-explore-index"]):
711
+ place_ribbon = gr.HTML(value=render_place_ribbon(_default_loc_id))
712
+ explore_place_pick = gr.Textbox(visible=False, elem_id="explore_place_pick")
713
+ wander_btn = gr.Button("✦ Wander at random", variant="secondary", size="sm")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
714
 
715
+ with gr.Column(scale=1, elem_classes=["tome-page-right", "tome-printed-page", "tome-explore-stage"]):
716
+ gr.HTML(
717
+ '<p class="tome-printed-kicker">You step inside</p>'
718
+ '<p class="explore-controls-hint">WASD walk · drag look · E talk · golden wisps are live visitors</p>'
719
+ )
720
+ diorama_view = gr.HTML(value=render_diorama(_default_loc_id))
721
+ portal_pick = gr.Textbox(visible=False, elem_id="portal_location_pick")
722
 
723
+ with gr.Column(elem_classes=["tome-offpage-store"]):
724
+ places_gallery = gr.Gallery(
725
+ value=place_gallery_items(),
726
+ columns=4,
727
+ height="auto",
728
+ object_fit="cover",
729
+ allow_preview=False,
730
+ show_label=False,
731
+ elem_classes=["places-gallery"],
732
+ )
733
  room_view = gr.HTML(value=render_room(_default_loc_id))
734
  souls_gallery = gr.Gallery(
735
  value=[],
 
743
  soul_card = gr.HTML()
744
 
745
  with gr.Tab("Book of Ages", id="book"):
746
+ _book_state = get_world_state()
747
+ _init_book_day = int(_book_state.get("current_day", 1) or 1)
748
+ book_day = gr.State(value=_init_book_day)
749
+
750
  with gr.Column(elem_classes=["tome-spread-shell"]):
751
  with gr.Row(elem_classes=["tome-spread-row"]):
752
  with gr.Column(scale=1, elem_classes=["tome-page-left", "tome-printed-page", "tome-book-index"]):
753
+ day_tabs = gr.HTML(value=render_day_tabs(_init_book_day))
754
+ book_day_pick = gr.Textbox(visible=False, elem_id="book_day_pick")
 
 
 
 
755
  book_filter = gr.Dropdown(
756
  choices=[
757
  ("All", "all"),
 
770
  )
771
  with gr.Column(scale=1, elem_classes=["tome-page-right", "tome-printed-page"]):
772
  book_display = gr.HTML(
773
+ value=render_book_for_day(_init_book_day, limit=12)
774
  )
775
 
776
  with gr.Tab("Bonds and Alliances", id="bonds"):
777
+ with gr.Column(elem_classes=["tome-spread-shell"]):
778
+ with gr.Row(elem_classes=["tome-spread-row"]):
779
+ with gr.Column(scale=1, elem_classes=["tome-page-left", "tome-printed-page"]):
780
+ bonds_sidebar = gr.HTML(value=render_bonds_sidebar())
781
+ with gr.Column(scale=1, elem_classes=["tome-page-right", "tome-printed-page"]):
782
+ rel_graph = gr.HTML(value=render_relationship_graph())
 
783
 
784
  with gr.Tab("Souls of the Garden", id="entities"):
785
  featured_chars = gr.HTML(value=render_featured_characters())
786
+ entity_pick = gr.Textbox(visible=False, elem_id="entity_pick")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
787
 
788
+ with gr.Column(elem_classes=["tome-spread-shell"]):
789
+ with gr.Row(elem_classes=["tome-spread-row"]):
790
+ with gr.Column(scale=1, elem_classes=["tome-page-left", "tome-printed-page", "tome-souls-index"]):
791
+ gr.HTML('<p class="tome-printed-kicker">Souls of the Garden</p>')
792
+ with gr.Row(elem_classes=["souls-filter-row"]):
793
+ loc_filter = gr.Dropdown(
794
+ choices=["all"] + [l["name"] for l in get_all_locations()],
795
+ value="all",
796
+ label="Place",
797
+ scale=1,
798
+ )
799
+ type_filter = gr.Dropdown(
800
+ choices=["all", "character", "creature", "object", "place"],
801
+ value="all",
802
+ label="Kind",
803
+ scale=1,
804
+ )
805
+ with gr.Row(elem_classes=["souls-filter-row"]):
806
+ status_filter = gr.Dropdown(
807
+ choices=["all", "active", "dormant", "legendary"],
808
+ value="all",
809
+ label="Status",
810
+ scale=1,
811
+ )
812
+ entity_search = gr.Textbox(placeholder="Search…", label="Find", scale=1)
813
+ entity_grid = gr.HTML(value=render_entity_grid(get_all_entities()))
814
+ with gr.Column(scale=1, elem_classes=["tome-page-right", "tome-printed-page"]):
815
+ entity_detail = gr.HTML(value=render_entity_hologram(None))
 
 
 
 
 
 
816
 
817
  _summon_outputs = [
818
  world_map, header, activity_feed, entity_result, location_panel,
819
+ soul_card_img, share_caption_box, realm_pulse, realm_stats,
820
+ ]
821
+
822
+ _explore_outputs = [
823
+ diorama_view, room_view, souls_gallery, soul_ids_state, soul_card, place_ribbon,
824
  ]
825
 
826
  summon_btn.click(
 
877
  """,
878
  )
879
 
880
+ def _realm_map_pick(pick):
 
 
 
 
 
 
 
 
 
 
881
  try:
882
  loc_id = int(pick) if pick else None
883
  except (TypeError, ValueError):
884
  loc_id = None
885
+ return render_world_map(loc_id), render_location_panel(loc_id)
886
+
887
+ location_select.change(
888
+ fn=_realm_map_pick,
889
+ inputs=[location_select],
890
+ outputs=[world_map, location_panel],
891
+ )
 
 
 
 
892
 
893
  map_pick.change(
894
+ _realm_map_pick,
895
  inputs=[map_pick],
896
+ outputs=[world_map, location_panel],
897
+ )
898
+
899
+ explore_place_pick.change(
900
+ open_diorama_from_map,
901
+ inputs=[explore_place_pick],
902
+ outputs=_explore_outputs,
903
  ).then(
904
  lambda pick: int(pick) if pick and str(pick).isdigit() else None,
905
+ inputs=[explore_place_pick],
906
  outputs=[explore_loc],
907
  )
908
 
909
  portal_pick.change(
910
  open_diorama_from_map,
911
  inputs=[portal_pick],
912
+ outputs=_explore_outputs,
913
  ).then(
914
  lambda pick: int(pick) if pick and str(pick).isdigit() else None,
915
  inputs=[portal_pick],
 
919
  places_gallery.select(
920
  enter_place,
921
  inputs=None,
922
+ outputs=[explore_loc, *_explore_outputs],
923
  )
924
  wander_btn.click(
925
  wander_to_place,
926
  inputs=None,
927
+ outputs=[explore_loc, *_explore_outputs],
928
  )
929
  souls_gallery.select(meet_soul, inputs=[soul_ids_state], outputs=[soul_card])
930
 
 
935
  ).then(
936
  fn=refresh_all_views,
937
  outputs=[world_map, header, current_event, activity_feed, world_vignette, featured_chars, realm_pulse],
938
+ ).then(fn=render_realm_stats, outputs=[realm_stats])
939
 
940
  # ── Soul chat API ──
941
  chat_soul_id_comp = gr.Textbox(visible=False, elem_id="chat_soul_id_input")
 
987
  outputs=[presence_bar],
988
  )
989
 
990
+ def _book_day_change(day_pick, day_state, entry_type, search):
991
+ try:
992
+ day = int(day_pick) if day_pick else int(day_state or 1)
993
+ except (TypeError, ValueError):
994
+ day = int(day_state or 1)
995
+ return day, render_day_tabs(day), filter_book(day, entry_type, search)
996
+
997
+ book_day_pick.change(
998
+ _book_day_change,
999
+ [book_day_pick, book_day, book_filter, book_search],
1000
+ [book_day, day_tabs, book_display],
1001
+ )
1002
+ book_filter.change(filter_book, [book_day, book_filter, book_search], [book_display])
1003
+ book_search.change(filter_book, [book_day, book_filter, book_search], [book_display])
1004
 
1005
  for f in [loc_filter, type_filter, status_filter, entity_search]:
1006
  f.change(
 
1009
  [entity_grid],
1010
  )
1011
 
1012
+ entity_pick.change(select_entity_by_id, [entity_pick], [entity_detail])
1013
 
1014
  refresh_timer = gr.Timer(30)
1015
  refresh_timer.tick(
1016
  fn=refresh_all_views,
1017
  outputs=[world_map, header, current_event, activity_feed, world_vignette, featured_chars, realm_pulse],
1018
+ ).then(fn=render_relationship_graph, outputs=[rel_graph]).then(
1019
+ fn=render_bonds_sidebar, outputs=[bonds_sidebar]
1020
+ ).then(fn=render_realm_stats, outputs=[realm_stats])
1021
 
1022
  MAP_CLICK_JS = """
1023
  () => {
 
1028
  { key: 'explore', label: 'Explore the Realm', kind: 'tab', panel: 1 },
1029
  { key: 'book', label: 'Book of Ages', kind: 'tab', panel: 2 },
1030
  { key: 'entities', label: 'Souls of the Garden', kind: 'tab', panel: 4 },
1031
+ { key: 'bonds', label: 'Bonds and Alliances', kind: 'tab', panel: 3 }
 
1032
  ];
1033
  let currentSpread = 0;
1034
 
 
1142
  if (floating) floating.classList.remove('open');
1143
  };
1144
 
1145
+ const closeBook = () => {
1146
+ const gate = document.getElementById('realm-opening');
1147
+ if (!gate) return;
1148
+ gate.classList.remove('is-closed', 'is-opened', 'is-prologue', 'is-unfolded', 'is-unfolding');
1149
+ gate.classList.add('is-reclosed');
1150
+ const closedBook = document.getElementById('realm-book-closed');
1151
+ const openBook = document.getElementById('realm-book-open');
1152
+ const prologue = document.getElementById('realm-prologue');
1153
+ if (closedBook) {
1154
+ closedBook.classList.remove('is-opening');
1155
+ closedBook.style.opacity = '';
1156
+ closedBook.style.pointerEvents = '';
1157
+ }
1158
+ if (openBook) openBook.setAttribute('aria-hidden', 'true');
1159
+ if (prologue) {
1160
+ prologue.classList.remove('visible');
1161
+ prologue.setAttribute('aria-hidden', 'true');
1162
+ }
1163
+ applyTurnFx('prev');
1164
+ };
1165
+
1166
+ const reopenBook = () => {
1167
+ const gate = document.getElementById('realm-opening');
1168
+ if (!gate) return;
1169
+ gate.classList.remove('is-reclosed');
1170
+ gate.classList.add('is-opened', 'is-closed');
1171
+ playOpeningChime();
1172
+ };
1173
+
1174
  const goToSpread = (idx, direction = 'next') => {
1175
+ if (idx >= sections.length && direction === 'next') {
1176
+ closeBook();
1177
+ return;
1178
+ }
1179
  const clamped = Math.max(0, Math.min(sections.length - 1, idx));
1180
  const spread = sections[clamped];
1181
  if (!spread) return;
 
1209
  <button type="button" id="tome-toc-open" class="tome-ribbon" aria-label="Open table of contents">Table of Contents</button>
1210
  <div id="tome-page-indicator" class="tome-page-indicator">Page I</div>
1211
  <div id="tome-floating-toc" class="tome-floating-toc">
1212
+ ${sections.map((s, i) => `<button type="button" class="tome-floating-link" data-index="${i}">${toRoman(i + 1)}. ${s.label}</button>`).join('')}
1213
  </div>
1214
  <label class="sr-only" for="tome-jump">Jump to section</label>
1215
  <select id="tome-jump" class="tome-jump-select" aria-label="Jump to section">
1216
+ ${sections.map((s, i) => `<option value="${i}">${toRoman(i + 1)}. ${s.label}</option>`).join('')}
1217
  </select>
1218
  `;
1219
  document.body.appendChild(nav);
 
1230
  }
1231
  if (next && !next.dataset.bound) {
1232
  next.dataset.bound = '1';
1233
+ next.addEventListener('click', () => {
1234
+ if (currentSpread >= sections.length - 1) {
1235
+ closeBook();
1236
+ return;
1237
+ }
1238
+ goToSpread(currentSpread + 1, 'next');
1239
+ });
1240
  }
1241
  if (tocOpen && !tocOpen.dataset.bound) {
1242
  tocOpen.dataset.bound = '1';
 
1267
  window.addEventListener('keydown', (e) => {
1268
  const tag = document.activeElement?.tagName;
1269
  if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
1270
+ if (e.key === 'ArrowRight') {
1271
+ if (currentSpread >= sections.length - 1) closeBook();
1272
+ else goToSpread(currentSpread + 1, 'next');
1273
+ }
1274
  if (e.key === 'ArrowLeft') goToSpread(currentSpread - 1, 'prev');
1275
  if (e.key === 'Escape') closeToc();
1276
  }, { passive: true });
 
1379
  if (!prologueReady) return;
1380
  playOpeningChime();
1381
  ensureAmbientMelody();
1382
+ gate.classList.remove('is-reclosed');
1383
  gate.classList.add('is-opened');
1384
  closeToc();
1385
  setTimeout(() => gate.classList.add('is-closed'), 700);
 
1403
  gate.addEventListener('click', (e) => {
1404
  const target = e.target;
1405
  if (!(target instanceof Element)) return;
1406
+ if (gate.classList.contains('is-reclosed') && target.id === 'realm-opening-enter') {
1407
+ e.preventDefault();
1408
+ e.stopPropagation();
1409
+ reopenBook();
1410
+ return;
1411
+ }
1412
  if (target.id === 'realm-opening-enter') {
1413
  e.preventDefault();
1414
  e.stopPropagation();
 
1452
  });
1453
  });
1454
  };
1455
+ const fireHiddenInput = (id, value) => {
1456
+ const root = document.querySelector(id);
1457
+ const input = root && (root.querySelector('textarea') || root.querySelector('input'));
1458
+ if (input) {
1459
+ input.value = value;
1460
+ input.dispatchEvent(new Event('input', { bubbles: true }));
1461
+ }
1462
+ };
1463
+
1464
+ const bindSpreadClicks = () => {
1465
+ document.querySelectorAll('.place-ribbon-item').forEach((btn) => {
1466
+ if (btn.dataset.ribbonBound) return;
1467
+ btn.dataset.ribbonBound = '1';
1468
+ btn.addEventListener('click', () => {
1469
+ const id = btn.getAttribute('data-location-id');
1470
+ if (id) fireHiddenInput('#explore_place_pick', id);
1471
+ });
1472
+ });
1473
+ document.querySelectorAll('.day-tab').forEach((btn) => {
1474
+ if (btn.dataset.dayBound) return;
1475
+ btn.dataset.dayBound = '1';
1476
+ btn.addEventListener('click', () => {
1477
+ const day = btn.getAttribute('data-day');
1478
+ if (day) fireHiddenInput('#book_day_pick', day);
1479
+ });
1480
+ });
1481
+ document.querySelectorAll('.entity-grid-card').forEach((card) => {
1482
+ if (card.dataset.entityBound) return;
1483
+ card.dataset.entityBound = '1';
1484
+ card.style.cursor = 'pointer';
1485
+ card.addEventListener('click', () => {
1486
+ const id = card.getAttribute('data-entity-id');
1487
+ if (id) fireHiddenInput('#entity_pick', id);
1488
+ });
1489
+ });
1490
+ };
1491
+
1492
  bind();
1493
+ bindSpreadClicks();
1494
+ new MutationObserver(() => { bind(); bindSpreadClicks(); }).observe(document.body, { childList: true, subtree: true });
1495
 
1496
  window._aetherHoverDiorama = false;
1497
  window._aetherDioramaActive = false;
ui/book_display.py CHANGED
@@ -3,6 +3,7 @@
3
  from __future__ import annotations
4
 
5
  from world.book_of_ages import get_entries
 
6
 
7
  ENTRY_TYPE_LABELS = {
8
  "arrival": "Arrival",
@@ -82,6 +83,79 @@ def render_book_of_ages(
82
  return "\n".join(parts)
83
 
84
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  def render_activity_feed(limit: int = 5) -> str:
86
  entries = get_entries(limit=limit)
87
 
 
3
  from __future__ import annotations
4
 
5
  from world.book_of_ages import get_entries
6
+ from world.database import get_world_state
7
 
8
  ENTRY_TYPE_LABELS = {
9
  "arrival": "Arrival",
 
83
  return "\n".join(parts)
84
 
85
 
86
+ def render_day_tabs(selected_day: int | None = None) -> str:
87
+ """Day selector for the Book of Ages left page."""
88
+ state = get_world_state()
89
+ current = int(state.get("current_day", 1) or 1)
90
+ days = list(range(max(1, current - 7), current + 1))
91
+ if selected_day is None:
92
+ selected_day = current
93
+
94
+ buttons = []
95
+ for day in reversed(days):
96
+ sel = " is-active" if day == selected_day else ""
97
+ buttons.append(
98
+ f'<button type="button" class="day-tab{sel}" data-day="{day}">Day {day}</button>'
99
+ )
100
+
101
+ return f"""
102
+ <div class="day-tabs-wrap">
103
+ <p class="tome-printed-kicker">Chronicle by Day</p>
104
+ <h3 class="day-tabs-title">Book of Ages</h3>
105
+ <p class="day-tabs-sub">Pick a day — its deeds appear on the right leaf.</p>
106
+ <div class="day-tabs">{"".join(buttons)}</div>
107
+ </div>
108
+ """
109
+
110
+
111
+ def render_book_for_day(
112
+ day: int | None = None,
113
+ entry_type: str = "all",
114
+ search: str = "",
115
+ limit: int = 10,
116
+ ) -> str:
117
+ """Entries for a single world day (right page)."""
118
+ if day is None:
119
+ state = get_world_state()
120
+ day = int(state.get("current_day", 1) or 1)
121
+
122
+ entries = get_entries(
123
+ limit=limit * 3,
124
+ entry_type=entry_type if entry_type != "all" else None,
125
+ search=search or None,
126
+ )
127
+ entries = [e for e in entries if e["world_day"] == day][:limit]
128
+
129
+ if not entries:
130
+ return f"""
131
+ <div class="book-container book-day-view">
132
+ <p class="book-day-empty">Day {day} rests in quiet ink — no deeds recorded yet.</p>
133
+ </div>
134
+ """
135
+
136
+ parts = [
137
+ '<div class="book-container book-day-view">',
138
+ f'<p class="book-day-banner">Day {day}</p>',
139
+ '<div class="book-entries">',
140
+ ]
141
+ for entry in entries:
142
+ type_label = ENTRY_TYPE_LABELS.get(entry["entry_type"], entry["entry_type"])
143
+ type_class = ENTRY_TYPE_CLASSES.get(entry["entry_type"], "")
144
+ title_html = f'<span class="entry-title">{entry["title"]}: </span>' if entry.get("title") else ""
145
+ content = entry["content"]
146
+ if entry["entry_type"] == "dream_fragment":
147
+ content = f"<em>{content}</em>"
148
+ parts.append(f"""
149
+ <div class="book-entry {type_class}">
150
+ <span class="entry-type-badge">{type_label}</span>
151
+ <p class="entry-content">{title_html}{content}</p>
152
+ </div>
153
+ """)
154
+
155
+ parts.append("</div></div>")
156
+ return "\n".join(parts)
157
+
158
+
159
  def render_activity_feed(limit: int = 5) -> str:
160
  entries = get_entries(limit=limit)
161
 
ui/entity_card.py CHANGED
@@ -166,6 +166,50 @@ def render_entity_card(entity: dict) -> str:
166
  """
167
 
168
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
  def render_entity_grid(entities: list[dict]) -> str:
170
  if not entities:
171
  return '<div class="empty-state">The Realm waits for its first arrivals.</div>'
 
166
  """
167
 
168
 
169
+ def render_entity_hologram(entity: dict | None) -> str:
170
+ """3D-styled entity detail panel for the Souls spread right page."""
171
+ if not entity:
172
+ return """
173
+ <div class="entity-hologram entity-hologram-empty">
174
+ <div class="hologram-stage">
175
+ <p class="hologram-hint">Select a soul on the left leaf to unveil their story.</p>
176
+ </div>
177
+ </div>
178
+ """
179
+
180
+ location = get_location_by_id(entity["location_id"])
181
+ loc_name = location["name"].replace("The ", "") if location else "Unknown"
182
+ slug = location["slug"] if location else None
183
+ avatar = render_avatar(entity, size=120, location_slug=slug)
184
+ loc_img = assets.location_image_url(slug)
185
+ banner = f"background-image:url('{loc_img}');" if loc_img else ""
186
+ type_label = TYPE_LABELS.get(entity["type"], entity["type"].title())
187
+ status = STATUS_LABELS.get(entity["status"], entity["status"].title())
188
+
189
+ return f"""
190
+ <div class="entity-hologram" data-entity-id="{entity['id']}">
191
+ <div class="hologram-beam" aria-hidden="true"></div>
192
+ <div class="hologram-stage">
193
+ <div class="hologram-banner" style="{banner}">
194
+ <div class="hologram-banner-scrim"></div>
195
+ </div>
196
+ <div class="hologram-portrait-wrap">{avatar}</div>
197
+ <div class="hologram-body">
198
+ <p class="hologram-meta">{type_label} · {loc_name} · Day {entity['days_in_realm']} · {status}</p>
199
+ <h2 class="hologram-name">{entity['display_name']}</h2>
200
+ <p class="hologram-appearance">{entity['appearance']}</p>
201
+ <div class="hologram-goals">
202
+ <p><b>Seeks</b> {entity['primary_goal']}</p>
203
+ <p><b>Fears</b> {entity['primary_fear']}</p>
204
+ </div>
205
+ <blockquote class="hologram-greeting">"{entity['greeting']}"</blockquote>
206
+ <p class="hologram-memory">{entity['memory_summary'] or 'They have only just arrived.'}</p>
207
+ </div>
208
+ </div>
209
+ </div>
210
+ """
211
+
212
+
213
  def render_entity_grid(entities: list[dict]) -> str:
214
  if not entities:
215
  return '<div class="empty-state">The Realm waits for its first arrivals.</div>'
ui/place_ribbon.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Vertical place ribbon for the Explore spread — left page navigation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ui import assets
6
+ from world.locations import get_all_locations
7
+
8
+
9
+ def render_place_ribbon(selected_id: int | None = None) -> str:
10
+ locations = get_all_locations()
11
+ items = []
12
+ for loc in locations:
13
+ slug = loc.get("slug", "")
14
+ img = assets.location_image_url(slug)
15
+ sel = " is-selected" if selected_id and loc["id"] == selected_id else ""
16
+ short = loc["name"].replace("The ", "")
17
+ thumb = (
18
+ f'<span class="ribbon-thumb" style="background-image:url(\'{img}\')"></span>'
19
+ if img
20
+ else '<span class="ribbon-thumb ribbon-thumb-empty"></span>'
21
+ )
22
+ items.append(f"""
23
+ <button type="button" class="place-ribbon-item{sel}" data-location-id="{loc['id']}"
24
+ title="{loc['name']}">
25
+ {thumb}
26
+ <span class="ribbon-label">{short}</span>
27
+ <span class="ribbon-count">{loc.get('entity_count', 0)} souls</span>
28
+ </button>
29
+ """)
30
+
31
+ return f"""
32
+ <div class="place-ribbon">
33
+ <p class="place-ribbon-kicker">Sacred Places</p>
34
+ <p class="place-ribbon-hint">Choose a realm — the world opens on the right page.</p>
35
+ <div class="place-ribbon-list">{"".join(items)}</div>
36
+ </div>
37
+ """
ui/relationship_graph.py CHANGED
@@ -156,20 +156,11 @@ def _encode_scene(data: dict) -> str:
156
  return "data=" + base64.b64encode(raw).decode()
157
 
158
 
159
- def render_relationship_graph(selected_entity_id: str | None = None) -> str:
160
  entities = get_all_entities(limit=80)
161
  relationships = get_all_relationships()
162
-
163
- if not entities:
164
- return '<div class="empty-state">No souls yet — the web of bonds is unwritten.</div>'
165
-
166
- if not relationships:
167
- return """
168
- <div class="rel-graph-wrap">
169
- <p class="rel-graph-empty">Souls wander alone for now. After the next simulation tick,
170
- bonds will appear here as a living web.</p>
171
- </div>
172
- """
173
 
174
  entity_map = {e["id"]: e for e in entities}
175
  connected_ids: set[str] = set()
@@ -182,11 +173,7 @@ def render_relationship_graph(selected_entity_id: str | None = None) -> str:
182
  edge_pairs.append((a, b))
183
 
184
  if not connected_ids:
185
- return """
186
- <div class="rel-graph-wrap">
187
- <p class="rel-graph-empty">Bonds are forming — check back after the next realm tick.</p>
188
- </div>
189
- """
190
 
191
  node_ids = sorted(connected_ids)
192
  positions_2d = _force_layout(entity_map, node_ids, edge_pairs)
@@ -229,34 +216,61 @@ def render_relationship_graph(selected_entity_id: str | None = None) -> str:
229
 
230
  scene = {"nodes": scene_nodes, "edges": scene_edges}
231
  hash_data = _encode_scene(scene)
232
- graph_src = f"{assets.file_url('bonds_graph.html')}#{hash_data}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
 
234
  legend = "".join(
235
  f'<span class="rel-legend-item"><i style="background:{c}"></i>{k}</span>'
236
  for k, c in REL_COLORS.items()
237
  )
238
-
239
  rel_list = []
240
- for rel in relationships[:10]:
241
  t = TYPE_SHORT.get(rel["relationship_type"], rel["relationship_type"])
242
  rel_list.append(
243
  f'<li><b>{rel["entity_a_name"]}</b> ↔ <b>{rel["entity_b_name"]}</b> '
244
- f'· {t} (strength {rel["strength"]}) — <em>{rel["description"][:90]}</em></li>'
245
  )
246
 
247
  return f"""
248
- <div class="rel-graph-wrap">
249
- <p class="rel-graph-desc">Every bond forged by the simulation — {len(relationships)} threads between {len(node_ids)} souls.</p>
250
- <div class="rel-graph-stage">
251
- <iframe
252
- class="rel-graph-frame"
253
- src="{graph_src}"
254
- title="3D web of bonds in the Realm"
255
- loading="lazy"
256
- tabindex="-1"
257
- ></iframe>
258
- </div>
259
  <div class="rel-legend">{legend}</div>
260
  <ul class="rel-list">{"".join(rel_list)}</ul>
261
  </div>
262
  """
 
 
 
156
  return "data=" + base64.b64encode(raw).decode()
157
 
158
 
159
+ def _bonds_graph_src(selected_entity_id: str | None = None) -> str | None:
160
  entities = get_all_entities(limit=80)
161
  relationships = get_all_relationships()
162
+ if not entities or not relationships:
163
+ return None
 
 
 
 
 
 
 
 
 
164
 
165
  entity_map = {e["id"]: e for e in entities}
166
  connected_ids: set[str] = set()
 
173
  edge_pairs.append((a, b))
174
 
175
  if not connected_ids:
176
+ return None
 
 
 
 
177
 
178
  node_ids = sorted(connected_ids)
179
  positions_2d = _force_layout(entity_map, node_ids, edge_pairs)
 
216
 
217
  scene = {"nodes": scene_nodes, "edges": scene_edges}
218
  hash_data = _encode_scene(scene)
219
+ return f"{assets.file_url('bonds_graph.html')}#{hash_data}"
220
+
221
+
222
+ def render_bonds_graph_frame(scene_src: str | None = None) -> str:
223
+ if not scene_src:
224
+ return '<div class="rel-graph-stage rel-graph-empty"><p>Bonds are forming…</p></div>'
225
+ return f"""
226
+ <div class="rel-graph-stage">
227
+ <iframe
228
+ class="rel-graph-frame"
229
+ src="{scene_src}"
230
+ title="3D web of bonds in the Realm"
231
+ loading="lazy"
232
+ tabindex="-1"
233
+ ></iframe>
234
+ </div>
235
+ """
236
+
237
+
238
+ def render_relationship_graph(selected_entity_id: str | None = None) -> str:
239
+ src = _bonds_graph_src(selected_entity_id)
240
+ if not src:
241
+ return '<div class="rel-graph-stage rel-graph-empty"><p>No bonds woven yet.</p></div>'
242
+ return render_bonds_graph_frame(src)
243
+
244
+
245
+ def render_bonds_sidebar() -> str:
246
+ relationships = get_all_relationships()
247
+ if not relationships:
248
+ return """
249
+ <div class="bonds-sidebar">
250
+ <p class="tome-printed-kicker">The Web of Bonds</p>
251
+ <p class="bonds-sidebar-empty">No alliances forged yet — wait for the next realm tick.</p>
252
+ </div>
253
+ """
254
 
255
  legend = "".join(
256
  f'<span class="rel-legend-item"><i style="background:{c}"></i>{k}</span>'
257
  for k, c in REL_COLORS.items()
258
  )
 
259
  rel_list = []
260
+ for rel in relationships[:12]:
261
  t = TYPE_SHORT.get(rel["relationship_type"], rel["relationship_type"])
262
  rel_list.append(
263
  f'<li><b>{rel["entity_a_name"]}</b> ↔ <b>{rel["entity_b_name"]}</b> '
264
+ f'· {t} — <em>{rel["description"][:72]}</em></li>'
265
  )
266
 
267
  return f"""
268
+ <div class="bonds-sidebar">
269
+ <p class="tome-printed-kicker">The Web of Bonds</p>
270
+ <p class="bonds-sidebar-desc">{len(relationships)} threads between living souls.</p>
 
 
 
 
 
 
 
 
271
  <div class="rel-legend">{legend}</div>
272
  <ul class="rel-list">{"".join(rel_list)}</ul>
273
  </div>
274
  """
275
+
276
+
ui/styles.css CHANGED
@@ -3687,16 +3687,10 @@ body,
3687
  flex-direction: column !important;
3688
  min-height: 0 !important;
3689
  max-height: 100% !important;
3690
- overflow: hidden !important;
3691
  padding: 0.85rem 1.15rem 0.75rem !important;
3692
- background:
3693
- radial-gradient(ellipse 110px 85px at 94% 90%, rgba(72, 44, 18, 0.14) 0%, transparent 64%),
3694
- radial-gradient(ellipse 95px 75px at 6% 12%, rgba(58, 34, 14, 0.1) 0%, transparent 58%),
3695
- radial-gradient(ellipse 80px 60px at 88% 8%, rgba(54, 32, 12, 0.08) 0%, transparent 55%),
3696
- radial-gradient(circle at 5% 38%, rgba(88, 62, 32, 0.09) 0 2px, transparent 3px),
3697
- radial-gradient(circle at 94% 62%, rgba(78, 54, 28, 0.08) 0 2px, transparent 3px),
3698
- linear-gradient(168deg, #ebe0c4 0%, #e0d0aa 45%, #d4c094 100%);
3699
- box-shadow: inset 0 0 28px rgba(88, 56, 24, 0.08);
3700
  isolation: isolate;
3701
  }
3702
 
@@ -3969,3 +3963,394 @@ body,
3969
  font-size: 0.82rem;
3970
  color: #5c4228;
3971
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3687
  flex-direction: column !important;
3688
  min-height: 0 !important;
3689
  max-height: 100% !important;
3690
+ overflow: hidden auto !important;
3691
  padding: 0.85rem 1.15rem 0.75rem !important;
3692
+ background: transparent !important;
3693
+ box-shadow: none !important;
 
 
 
 
 
 
3694
  isolation: isolate;
3695
  }
3696
 
 
3963
  font-size: 0.82rem;
3964
  color: #5c4228;
3965
  }
3966
+
3967
+ /* Spread helpers — left/right page content */
3968
+ .tome-printed-kicker {
3969
+ margin: 0 0 0.2rem;
3970
+ font-family: 'Cinzel', serif;
3971
+ font-size: 0.72rem;
3972
+ letter-spacing: 0.14em;
3973
+ text-transform: uppercase;
3974
+ color: #8a6424;
3975
+ }
3976
+
3977
+ .tome-printed-hint,
3978
+ .explore-controls-hint,
3979
+ .realm-summon-hint {
3980
+ margin: 0 0 0.55rem;
3981
+ font-family: 'IM Fell English', serif;
3982
+ font-style: italic;
3983
+ font-size: 0.82rem;
3984
+ color: #5c4228;
3985
+ }
3986
+
3987
+ .realm-stats-strip {
3988
+ display: flex;
3989
+ flex-wrap: wrap;
3990
+ gap: 0.65rem 1rem;
3991
+ margin-bottom: 0.55rem;
3992
+ padding: 0.45rem 0.65rem;
3993
+ border-radius: 8px;
3994
+ background: rgba(255, 249, 232, 0.55);
3995
+ font-family: 'IM Fell English', serif;
3996
+ font-size: 0.82rem;
3997
+ color: #4a3420;
3998
+ }
3999
+
4000
+ .realm-summon-block {
4001
+ margin-top: 0.35rem;
4002
+ }
4003
+
4004
+ .realm-summon-input textarea {
4005
+ min-height: 4.2rem !important;
4006
+ }
4007
+
4008
+ /* Place ribbon — Explore left page */
4009
+ .place-ribbon {
4010
+ display: flex;
4011
+ flex-direction: column;
4012
+ gap: 0.45rem;
4013
+ min-height: 0;
4014
+ flex: 1 1 auto;
4015
+ overflow: hidden;
4016
+ }
4017
+
4018
+ .place-ribbon-kicker,
4019
+ .day-tabs-title {
4020
+ margin: 0;
4021
+ font-family: 'Cinzel Decorative', 'Cinzel', serif;
4022
+ font-size: 1rem;
4023
+ color: var(--tome-ink);
4024
+ }
4025
+
4026
+ .place-ribbon-hint,
4027
+ .day-tabs-sub {
4028
+ margin: 0 0 0.35rem;
4029
+ font-family: 'IM Fell English', serif;
4030
+ font-style: italic;
4031
+ font-size: 0.8rem;
4032
+ color: #5c4228;
4033
+ }
4034
+
4035
+ .place-ribbon-list {
4036
+ display: flex;
4037
+ flex-direction: column;
4038
+ gap: 0.35rem;
4039
+ overflow-y: auto;
4040
+ min-height: 0;
4041
+ flex: 1 1 auto;
4042
+ padding-right: 0.15rem;
4043
+ }
4044
+
4045
+ .place-ribbon-item {
4046
+ display: grid;
4047
+ grid-template-columns: 42px 1fr auto;
4048
+ gap: 0.45rem;
4049
+ align-items: center;
4050
+ padding: 0.35rem 0.45rem;
4051
+ border: 1px solid rgba(138, 106, 58, 0.28);
4052
+ border-radius: 8px;
4053
+ background: rgba(255, 249, 232, 0.72);
4054
+ cursor: pointer;
4055
+ text-align: left;
4056
+ color: var(--tome-ink);
4057
+ font-family: 'IM Fell English', serif;
4058
+ transition: transform 0.15s ease, box-shadow 0.15s ease;
4059
+ }
4060
+
4061
+ .place-ribbon-item:hover,
4062
+ .place-ribbon-item.is-selected {
4063
+ transform: translateX(2px);
4064
+ box-shadow: 0 4px 12px rgba(76, 50, 22, 0.14);
4065
+ border-color: rgba(138, 106, 58, 0.5);
4066
+ }
4067
+
4068
+ .ribbon-thumb {
4069
+ width: 42px;
4070
+ height: 42px;
4071
+ border-radius: 6px;
4072
+ background-size: cover;
4073
+ background-position: center;
4074
+ border: 1px solid rgba(138, 106, 58, 0.35);
4075
+ }
4076
+
4077
+ .ribbon-thumb-empty {
4078
+ background: linear-gradient(135deg, #d8c49a, #b89562);
4079
+ }
4080
+
4081
+ .ribbon-label {
4082
+ font-family: 'Cinzel', serif;
4083
+ font-size: 0.78rem;
4084
+ line-height: 1.2;
4085
+ }
4086
+
4087
+ .ribbon-count {
4088
+ font-size: 0.68rem;
4089
+ color: #6b4f30;
4090
+ white-space: nowrap;
4091
+ }
4092
+
4093
+ /* Day tabs — Book left page */
4094
+ .day-tabs-wrap {
4095
+ display: flex;
4096
+ flex-direction: column;
4097
+ gap: 0.35rem;
4098
+ min-height: 0;
4099
+ }
4100
+
4101
+ .day-tabs {
4102
+ display: flex;
4103
+ flex-direction: column;
4104
+ gap: 0.3rem;
4105
+ overflow-y: auto;
4106
+ max-height: 220px;
4107
+ }
4108
+
4109
+ .day-tab {
4110
+ padding: 0.4rem 0.55rem;
4111
+ border: 1px solid rgba(138, 106, 58, 0.28);
4112
+ border-radius: 8px;
4113
+ background: rgba(255, 249, 232, 0.72);
4114
+ font-family: 'Cinzel', serif;
4115
+ font-size: 0.82rem;
4116
+ color: var(--tome-ink);
4117
+ cursor: pointer;
4118
+ text-align: left;
4119
+ }
4120
+
4121
+ .day-tab.is-active,
4122
+ .day-tab:hover {
4123
+ background: rgba(255, 244, 214, 0.95);
4124
+ border-color: rgba(138, 106, 58, 0.55);
4125
+ }
4126
+
4127
+ .book-day-view .book-entries {
4128
+ column-count: 1 !important;
4129
+ padding: 0;
4130
+ }
4131
+
4132
+ .book-day-banner {
4133
+ font-family: 'Cinzel Decorative', 'Cinzel', serif;
4134
+ font-size: 1.1rem;
4135
+ margin: 0 0 0.65rem;
4136
+ color: #6b4a2c;
4137
+ }
4138
+
4139
+ /* Bonds sidebar */
4140
+ .bonds-sidebar {
4141
+ display: flex;
4142
+ flex-direction: column;
4143
+ gap: 0.45rem;
4144
+ min-height: 0;
4145
+ overflow: hidden auto;
4146
+ }
4147
+
4148
+ .bonds-sidebar-desc,
4149
+ .bonds-sidebar-empty {
4150
+ font-family: 'IM Fell English', serif;
4151
+ font-style: italic;
4152
+ font-size: 0.84rem;
4153
+ color: #5c4228;
4154
+ margin: 0;
4155
+ }
4156
+
4157
+ .bonds-sidebar .rel-legend {
4158
+ display: flex;
4159
+ flex-wrap: wrap;
4160
+ gap: 0.35rem 0.55rem;
4161
+ }
4162
+
4163
+ .bonds-sidebar .rel-list {
4164
+ margin: 0;
4165
+ padding-left: 1rem;
4166
+ font-family: 'IM Fell English', serif;
4167
+ font-size: 0.78rem;
4168
+ line-height: 1.35;
4169
+ color: var(--tome-ink);
4170
+ }
4171
+
4172
+ .tome-explore-stage .diorama-wrap,
4173
+ .tome-explore-stage .rel-graph-stage {
4174
+ flex: 1 1 auto;
4175
+ min-height: 280px;
4176
+ }
4177
+
4178
+ .tome-souls-index .entity-grid {
4179
+ display: flex;
4180
+ flex-direction: column;
4181
+ gap: 0.45rem;
4182
+ max-height: 340px;
4183
+ overflow-y: auto;
4184
+ }
4185
+
4186
+ .tome-souls-index .entity-grid-card {
4187
+ display: grid;
4188
+ grid-template-columns: 56px 1fr;
4189
+ min-height: 72px;
4190
+ }
4191
+
4192
+ .souls-filter-row {
4193
+ gap: 0.35rem !important;
4194
+ margin-bottom: 0.25rem !important;
4195
+ }
4196
+
4197
+ /* Entity hologram — Souls right page */
4198
+ .entity-hologram {
4199
+ position: relative;
4200
+ perspective: 900px;
4201
+ min-height: 320px;
4202
+ flex: 1 1 auto;
4203
+ }
4204
+
4205
+ .entity-hologram-empty .hologram-stage {
4206
+ display: grid;
4207
+ place-items: center;
4208
+ min-height: 280px;
4209
+ border: 1px dashed rgba(138, 106, 58, 0.35);
4210
+ border-radius: 12px;
4211
+ background: rgba(255, 249, 232, 0.45);
4212
+ }
4213
+
4214
+ .hologram-hint {
4215
+ font-family: 'IM Fell English', serif;
4216
+ font-style: italic;
4217
+ color: #5c4228;
4218
+ text-align: center;
4219
+ padding: 1rem;
4220
+ }
4221
+
4222
+ .hologram-beam {
4223
+ position: absolute;
4224
+ left: 50%;
4225
+ bottom: 8%;
4226
+ width: 62%;
4227
+ height: 42%;
4228
+ transform: translateX(-50%);
4229
+ background: radial-gradient(ellipse at center, rgba(201, 161, 59, 0.22), transparent 68%);
4230
+ pointer-events: none;
4231
+ filter: blur(8px);
4232
+ animation: hologramPulse 3.2s ease-in-out infinite;
4233
+ }
4234
+
4235
+ @keyframes hologramPulse {
4236
+ 0%, 100% { opacity: 0.45; transform: translateX(-50%) scale(0.96); }
4237
+ 50% { opacity: 0.85; transform: translateX(-50%) scale(1.04); }
4238
+ }
4239
+
4240
+ .hologram-stage {
4241
+ position: relative;
4242
+ transform: rotateX(8deg) translateZ(12px);
4243
+ transform-style: preserve-3d;
4244
+ border: 1px solid rgba(201, 161, 59, 0.35);
4245
+ border-radius: 14px;
4246
+ background: linear-gradient(180deg, rgba(12, 10, 8, 0.92), rgba(24, 18, 12, 0.88));
4247
+ box-shadow:
4248
+ 0 18px 40px rgba(0, 0, 0, 0.35),
4249
+ 0 0 28px rgba(201, 161, 59, 0.12);
4250
+ overflow: hidden;
4251
+ color: #f4e8c8;
4252
+ }
4253
+
4254
+ .hologram-banner {
4255
+ height: 88px;
4256
+ background-size: cover;
4257
+ background-position: center;
4258
+ position: relative;
4259
+ }
4260
+
4261
+ .hologram-banner-scrim {
4262
+ position: absolute;
4263
+ inset: 0;
4264
+ background: linear-gradient(180deg, transparent, rgba(10, 8, 6, 0.85));
4265
+ }
4266
+
4267
+ .hologram-portrait-wrap {
4268
+ position: absolute;
4269
+ top: 52px;
4270
+ left: 1rem;
4271
+ z-index: 2;
4272
+ filter: drop-shadow(0 8px 16px rgba(0, 0, 0, 0.45));
4273
+ }
4274
+
4275
+ .hologram-body {
4276
+ padding: 3.2rem 1rem 1rem;
4277
+ font-family: 'IM Fell English', serif;
4278
+ }
4279
+
4280
+ .hologram-meta {
4281
+ margin: 0 0 0.25rem;
4282
+ font-size: 0.72rem;
4283
+ letter-spacing: 0.06em;
4284
+ text-transform: uppercase;
4285
+ color: #c9a13b;
4286
+ }
4287
+
4288
+ .hologram-name {
4289
+ margin: 0 0 0.45rem;
4290
+ font-family: 'Cinzel Decorative', 'Cinzel', serif;
4291
+ font-size: 1.25rem;
4292
+ color: #f8e4a8;
4293
+ }
4294
+
4295
+ .hologram-appearance,
4296
+ .hologram-memory {
4297
+ margin: 0 0 0.55rem;
4298
+ font-size: 0.88rem;
4299
+ line-height: 1.45;
4300
+ color: #e8dcc0;
4301
+ }
4302
+
4303
+ .hologram-goals {
4304
+ font-size: 0.82rem;
4305
+ margin-bottom: 0.45rem;
4306
+ color: #d8c8a4;
4307
+ }
4308
+
4309
+ .hologram-greeting {
4310
+ margin: 0.35rem 0 0.55rem;
4311
+ padding-left: 0.75rem;
4312
+ border-left: 2px solid rgba(201, 161, 59, 0.45);
4313
+ font-style: italic;
4314
+ color: #f0e2b8;
4315
+ }
4316
+
4317
+ /* Closed book overlay after last page */
4318
+ .realm-opening.is-reclosed {
4319
+ opacity: 1 !important;
4320
+ visibility: visible !important;
4321
+ pointer-events: auto !important;
4322
+ }
4323
+
4324
+ .realm-opening.is-reclosed .realm-book-stage {
4325
+ display: flex !important;
4326
+ }
4327
+
4328
+ .realm-opening.is-reclosed .realm-book-closed {
4329
+ opacity: 1 !important;
4330
+ pointer-events: auto !important;
4331
+ transform: none !important;
4332
+ }
4333
+
4334
+ .realm-opening.is-reclosed .realm-prologue-card,
4335
+ .realm-opening.is-reclosed .realm-book-open {
4336
+ display: none !important;
4337
+ }
4338
+
4339
+ .realm-cover-card {
4340
+ background:
4341
+ radial-gradient(circle at 22% 12%, rgba(255, 240, 198, 0.1), transparent 30%),
4342
+ radial-gradient(circle at 78% 88%, rgba(0, 0, 0, 0.22), transparent 42%),
4343
+ linear-gradient(155deg, #4a2e1a 0%, #2e1a10 38%, #1a0f08 100%) !important;
4344
+ border-color: rgba(120, 78, 42, 0.65) !important;
4345
+ }
4346
+
4347
+ .tome-printed-page .current-event,
4348
+ .tome-printed-page .realm-pulse,
4349
+ .tome-printed-page .activity-feed,
4350
+ .tome-printed-page .location-panel,
4351
+ .tome-printed-page .book-container,
4352
+ .tome-printed-page .entity-hologram-empty .hologram-stage {
4353
+ background: rgba(255, 249, 232, 0.62) !important;
4354
+ box-shadow: none !important;
4355
+ border: 1px solid rgba(138, 106, 58, 0.22) !important;
4356
+ }