agharsallah commited on
Commit
d2180f0
Β·
1 Parent(s): f5ed461

feat: Enhance color handling and styling across fishbowl components

Browse files
src/ui/fishbowl/adapter.py CHANGED
@@ -64,6 +64,25 @@ def agent_hue(manifest) -> int:
64
  return int(digest[:4], 16) % 360
65
 
66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  def agent_archetype(manifest) -> str:
68
  """The manifest's ``archetype``, or a fallback derived from its role."""
69
  return getattr(manifest, "archetype", None) or f"the {manifest.role}"
 
64
  return int(digest[:4], 16) % 360
65
 
66
 
67
+ # ── agent colour (one phosphor per hue; every cast member is mapped the same way) ─
68
+ # Lives here, with the other design-vocabulary mappings (hue/tier/mood colour), so every
69
+ # surface β€” avatar, MindCard, feed line, split row β€” derives the *same* colour from the
70
+ # *same* hue. That single source is what lets the eye map a name to a face to a transcript
71
+ # line at a glance. Lightness/chroma are shared across the cast; only the hue varies.
72
+ _AC_LIGHTNESS = 0.82 # high enough to stay legible as name text on the dark tank
73
+ _AC_CHROMA = 0.17 # vivid enough that adjacent hues read as distinct identities
74
+
75
+
76
+ def agent_color(hue: int, lightness: float = _AC_LIGHTNESS, chroma: float = _AC_CHROMA) -> str:
77
+ """The agent's phosphor colour β€” all cast share L/C, only the hue varies."""
78
+ return f"oklch({lightness} {chroma} {hue})"
79
+
80
+
81
+ def agent_color_dim(hue: int) -> str:
82
+ """The dimmed companion colour (used for sealed / inactive surfaces)."""
83
+ return f"oklch(0.5 0.1 {hue})"
84
+
85
+
86
  def agent_archetype(manifest) -> str:
87
  """The manifest's ``archetype``, or a fallback derived from its role."""
88
  return getattr(manifest, "archetype", None) or f"the {manifest.role}"
src/ui/fishbowl/assets/styles.css CHANGED
@@ -651,7 +651,10 @@ footer { display: none !important; }
651
  .fishbowl .stage { position: relative; display: grid; grid-template-columns: repeat(3, 1fr); grid-template-rows: repeat(3, 1fr); padding: 22px; gap: 10px; overflow: auto; min-height: 0; place-items: center; }
652
  .fishbowl .core { position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); display: flex; flex-direction: column; align-items: center; gap: 6px; text-align: center; pointer-events: none; z-index: 1; }
653
  .fishbowl .core-glyph { font-size: 64px; color: var(--cyan); text-shadow: var(--glow-hot); line-height: 1; opacity: 0.85; animation: coreFloat 6s ease-in-out infinite; }
654
- @keyframes coreFloat { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-6px); } }
 
 
 
655
  .fishbowl .core-title { font-size: 16px; letter-spacing: 0.1em; color: var(--ink); }
656
  .fishbowl .core-round { font-size: 9px; color: var(--ink-dim); }
657
  /* Audience-only secret peek (Twenty Sprouts) β€” a dashed "director's view" chip the cast
@@ -685,7 +688,7 @@ footer { display: none !important; }
685
  /* head: avatar Β· identity Β· the live mood pill (the one datum that changes turn to turn) */
686
  .fishbowl .mind-head { display: grid; grid-template-columns: auto 1fr auto; gap: 11px; align-items: center; padding-bottom: 10px; border-bottom: 1px solid var(--line-soft); }
687
  .fishbowl .mind-id { min-width: 0; }
688
- .fishbowl .mind-name { font-size: 14.5px; font-weight: 700; letter-spacing: 0.04em; color: var(--ink); line-height: 1.15; display: flex; align-items: center; gap: 7px; }
689
  .fishbowl .mic { color: var(--coral); font-size: 9px; animation: livePulse 0.9s ease-in-out infinite; }
690
  .fishbowl .mind-arch { margin-top: 3px; font-size: 10.5px; color: var(--ink-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
691
 
@@ -728,17 +731,21 @@ footer { display: none !important; }
728
  /* keep the narrator rail feed compact β€” minimal height, scroll stays as-is */
729
  .fishbowl .rail .feed { flex: none; max-height: 220px; }
730
  .fishbowl .feed.dense { gap: 9px; }
731
- .fishbowl .fe { animation: feIn 0.3s ease; }
 
 
 
 
732
  @keyframes feIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
733
  .fishbowl .fe.narr { border-left: 2px solid var(--violet); padding-left: 12px; }
734
  .fishbowl .narr-voice { font-family: var(--font-display); font-size: 8px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--violet); display: block; margin-bottom: 4px; }
735
  .fishbowl .fe.narr p { margin: 0; font-size: 13px; line-height: 1.6; color: var(--ink); opacity: 0.92; font-style: italic; }
736
- .fishbowl .fe.say { background: rgba(3,12,18,0.6); border-left: 2px solid var(--ac); border-radius: 0 var(--r) var(--r) 0; padding: 8px 11px; }
737
  .fishbowl .say-line { font-size: 12.5px; line-height: 1.45; }
738
- .fishbowl .say-line b { color: var(--ac); margin-right: 7px; letter-spacing: 0.04em; }
739
  .fishbowl .say-line span { color: var(--ink); }
740
- .fishbowl .say-think { margin-top: 5px; font-size: 11px; color: var(--ac); opacity: 0.85; }
741
- .fishbowl .say-think i { color: color-mix(in oklab, var(--ac) 75%, var(--ink)); }
742
  .fishbowl .fe.poke, .fishbowl .fe.verdict-fe { border: 1px solid var(--amber); border-radius: var(--r); padding: 9px 11px; background: rgba(255,207,107,0.07); }
743
  .fishbowl .poke-tag { display: inline-flex; align-items: center; gap: 6px; font-family: var(--font-display); font-size: 8.5px; letter-spacing: 0.14em; text-transform: uppercase; color: var(--amber); border: 1px solid rgba(255,207,107,0.4); border-radius: 999px; padding: 2px 8px; margin-bottom: 6px; }
744
  .fishbowl .fe.poke p, .fishbowl .fe.verdict-fe p { margin: 0; font-size: 12px; line-height: 1.5; color: var(--ink); }
@@ -757,7 +764,7 @@ footer { display: none !important; }
757
  .fishbowl .roster { border-right: 1px solid var(--line); padding: 16px 14px; display: flex; flex-direction: column; gap: 10px; background: rgba(6,20,27,0.4); overflow: auto; }
758
  .fishbowl .roster-chip { display: flex; align-items: center; gap: 11px; padding: 9px 11px; border: 1px solid var(--line); border-radius: var(--r); transition: all 0.2s ease; }
759
  .fishbowl .roster-chip.on { border-color: var(--ac); box-shadow: 0 0 14px color-mix(in oklab, var(--ac) 35%, transparent); background: color-mix(in oklab, var(--ac) 6%, transparent); }
760
- .fishbowl .rc-name { font-size: 12.5px; letter-spacing: 0.05em; color: var(--ink); }
761
  .fishbowl .rc-mood { font-family: var(--font-display); font-size: 8px; letter-spacing: 0.1em; text-transform: uppercase; }
762
  .fishbowl .feedview-main { min-height: 0; display: flex; }
763
  .fishbowl .feedview-main .feed { max-width: 720px; margin: 0 auto; width: 100%; }
@@ -770,7 +777,7 @@ footer { display: none !important; }
770
  .fishbowl .split-row { display: grid; grid-template-columns: 230px 1fr 1fr; gap: 16px; align-items: stretch; padding: 14px; border: 1px solid var(--line); border-radius: var(--r-lg); margin-bottom: 12px; background: var(--panel); transition: all 0.2s ease; }
771
  .fishbowl .split-row.on { border-color: var(--ac); box-shadow: 0 0 18px color-mix(in oklab, var(--ac) 30%, transparent); }
772
  .fishbowl .split-id { display: flex; gap: 12px; align-items: center; }
773
- .fishbowl .split-name { font-size: 14px; letter-spacing: 0.06em; color: var(--ink); }
774
  .fishbowl .split-arch { font-size: 10.5px; color: var(--ink-dim); }
775
  .fishbowl .split-model { display: flex; align-items: center; gap: 5px; font-size: 9.5px; color: var(--ink-mid); margin-top: 3px; }
776
  .fishbowl .split-said, .fishbowl .split-think { border-radius: var(--r); padding: 12px 14px; display: flex; align-items: center; }
@@ -845,7 +852,7 @@ footer { display: none !important; }
845
  .fishbowl .wf-sub { display: flex; align-items: center; justify-content: center; gap: 9px; flex-wrap: wrap; margin-top: -2px; }
846
  .fishbowl .wf-arch { font-style: italic; font-size: 12px; color: var(--ink-dim); letter-spacing: 0.02em; }
847
  .fishbowl .wf-roster { display: flex; align-items: center; justify-content: center; gap: 7px; flex-wrap: wrap; }
848
- .fishbowl .wf-chip { font-family: var(--font-display); font-size: 9px; letter-spacing: 0.12em; text-transform: uppercase; color: hsl(var(--win-hue) 70% 78%); border: 1px solid hsl(var(--win-hue) 70% 60% / 0.4); background: hsl(var(--win-hue) 70% 50% / 0.1); border-radius: 999px; padding: 3px 10px; white-space: nowrap; }
849
  .fishbowl .wf-cheer { font-size: 12.5px; color: var(--ink); opacity: 0.92; margin-top: 4px; }
850
  .fishbowl .wf-loser { font-family: var(--font-display); font-size: 8.5px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--ink-faint); }
851
 
@@ -998,15 +1005,13 @@ footer { display: none !important; }
998
  .fishbowl.fb-constellation .core-round { color: var(--ink-faint); }
999
 
1000
  /* ---- MindCard depth + life: entrance, hover lift, richer speaking glow ---- */
1001
- @keyframes mindIn { from { opacity: 0; transform: translateY(12px) scale(0.97); } to { opacity: 1; transform: none; } }
 
 
 
 
1002
  @media (prefers-reduced-motion: no-preference) {
1003
- .fishbowl.fb-constellation .ring-slot { animation: mindIn 0.5s cubic-bezier(0.2,0.7,0.2,1) both; }
1004
- .fishbowl.fb-constellation .ring-slot:nth-child(1) { animation-delay: 0.04s; }
1005
- .fishbowl.fb-constellation .ring-slot:nth-child(2) { animation-delay: 0.10s; }
1006
- .fishbowl.fb-constellation .ring-slot:nth-child(3) { animation-delay: 0.16s; }
1007
- .fishbowl.fb-constellation .ring-slot:nth-child(4) { animation-delay: 0.22s; }
1008
- .fishbowl.fb-constellation .ring-slot:nth-child(5) { animation-delay: 0.28s; }
1009
- .fishbowl.fb-constellation .ring-slot:nth-child(6) { animation-delay: 0.34s; }
1010
  }
1011
  .fishbowl .mind-front {
1012
  transition: border-color 0.22s ease, box-shadow 0.22s ease, transform 0.22s ease;
 
651
  .fishbowl .stage { position: relative; display: grid; grid-template-columns: repeat(3, 1fr); grid-template-rows: repeat(3, 1fr); padding: 22px; gap: 10px; overflow: auto; min-height: 0; place-items: center; }
652
  .fishbowl .core { position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); display: flex; flex-direction: column; align-items: center; gap: 6px; text-align: center; pointer-events: none; z-index: 1; }
653
  .fishbowl .core-glyph { font-size: 64px; color: var(--cyan); text-shadow: var(--glow-hot); line-height: 1; opacity: 0.85; animation: coreFloat 6s ease-in-out infinite; }
654
+ /* Gentle amplitude: the stage HTML is replaced every tick, restarting this float mid-cycle,
655
+ so a large drift would snap the central glyph each time. A small rise keeps the drift
656
+ alive in static/scrub views without a visible jump during live autoplay. */
657
+ @keyframes coreFloat { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-3px); } }
658
  .fishbowl .core-title { font-size: 16px; letter-spacing: 0.1em; color: var(--ink); }
659
  .fishbowl .core-round { font-size: 9px; color: var(--ink-dim); }
660
  /* Audience-only secret peek (Twenty Sprouts) β€” a dashed "director's view" chip the cast
 
688
  /* head: avatar Β· identity Β· the live mood pill (the one datum that changes turn to turn) */
689
  .fishbowl .mind-head { display: grid; grid-template-columns: auto 1fr auto; gap: 11px; align-items: center; padding-bottom: 10px; border-bottom: 1px solid var(--line-soft); }
690
  .fishbowl .mind-id { min-width: 0; }
691
+ .fishbowl .mind-name { font-size: 14.5px; font-weight: 700; letter-spacing: 0.04em; color: var(--ac, var(--ink)); text-shadow: 0 0 9px color-mix(in oklab, var(--ac, var(--ink)) 35%, transparent); line-height: 1.15; display: flex; align-items: center; gap: 7px; }
692
  .fishbowl .mic { color: var(--coral); font-size: 9px; animation: livePulse 0.9s ease-in-out infinite; }
693
  .fishbowl .mind-arch { margin-top: 3px; font-size: 10.5px; color: var(--ink-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
694
 
 
731
  /* keep the narrator rail feed compact β€” minimal height, scroll stays as-is */
732
  .fishbowl .rail .feed { flex: none; max-height: 220px; }
733
  .fishbowl .feed.dense { gap: 9px; }
734
+ /* Only the newest line slides in. The Show re-renders the whole feed HTML every tick
735
+ (Gradio replaces innerHTML wholesale), so animating every .fe re-played the entrance on
736
+ the entire transcript each time β€” reading as a full-screen reload. Scoping the entrance
737
+ to :last-child keeps the "new message" cue while the rest of the log stays still. */
738
+ .fishbowl .fe:last-child { animation: feIn 0.3s ease; }
739
  @keyframes feIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
740
  .fishbowl .fe.narr { border-left: 2px solid var(--violet); padding-left: 12px; }
741
  .fishbowl .narr-voice { font-family: var(--font-display); font-size: 8px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--violet); display: block; margin-bottom: 4px; }
742
  .fishbowl .fe.narr p { margin: 0; font-size: 13px; line-height: 1.6; color: var(--ink); opacity: 0.92; font-style: italic; }
743
+ .fishbowl .fe.say { background: rgba(3,12,18,0.6); border-left: 2px solid var(--ac, var(--cyan)); border-radius: 0 var(--r) var(--r) 0; padding: 8px 11px; }
744
  .fishbowl .say-line { font-size: 12.5px; line-height: 1.45; }
745
+ .fishbowl .say-line b { color: var(--ac, var(--cyan)); text-shadow: 0 0 8px color-mix(in oklab, var(--ac, var(--cyan)) 40%, transparent); margin-right: 7px; font-weight: 700; letter-spacing: 0.04em; }
746
  .fishbowl .say-line span { color: var(--ink); }
747
+ .fishbowl .say-think { margin-top: 5px; font-size: 11px; color: var(--ac, var(--cyan)); opacity: 0.85; }
748
+ .fishbowl .say-think i { color: color-mix(in oklab, var(--ac, var(--cyan)) 75%, var(--ink)); }
749
  .fishbowl .fe.poke, .fishbowl .fe.verdict-fe { border: 1px solid var(--amber); border-radius: var(--r); padding: 9px 11px; background: rgba(255,207,107,0.07); }
750
  .fishbowl .poke-tag { display: inline-flex; align-items: center; gap: 6px; font-family: var(--font-display); font-size: 8.5px; letter-spacing: 0.14em; text-transform: uppercase; color: var(--amber); border: 1px solid rgba(255,207,107,0.4); border-radius: 999px; padding: 2px 8px; margin-bottom: 6px; }
751
  .fishbowl .fe.poke p, .fishbowl .fe.verdict-fe p { margin: 0; font-size: 12px; line-height: 1.5; color: var(--ink); }
 
764
  .fishbowl .roster { border-right: 1px solid var(--line); padding: 16px 14px; display: flex; flex-direction: column; gap: 10px; background: rgba(6,20,27,0.4); overflow: auto; }
765
  .fishbowl .roster-chip { display: flex; align-items: center; gap: 11px; padding: 9px 11px; border: 1px solid var(--line); border-radius: var(--r); transition: all 0.2s ease; }
766
  .fishbowl .roster-chip.on { border-color: var(--ac); box-shadow: 0 0 14px color-mix(in oklab, var(--ac) 35%, transparent); background: color-mix(in oklab, var(--ac) 6%, transparent); }
767
+ .fishbowl .rc-name { font-size: 12.5px; letter-spacing: 0.05em; color: var(--ac, var(--ink)); }
768
  .fishbowl .rc-mood { font-family: var(--font-display); font-size: 8px; letter-spacing: 0.1em; text-transform: uppercase; }
769
  .fishbowl .feedview-main { min-height: 0; display: flex; }
770
  .fishbowl .feedview-main .feed { max-width: 720px; margin: 0 auto; width: 100%; }
 
777
  .fishbowl .split-row { display: grid; grid-template-columns: 230px 1fr 1fr; gap: 16px; align-items: stretch; padding: 14px; border: 1px solid var(--line); border-radius: var(--r-lg); margin-bottom: 12px; background: var(--panel); transition: all 0.2s ease; }
778
  .fishbowl .split-row.on { border-color: var(--ac); box-shadow: 0 0 18px color-mix(in oklab, var(--ac) 30%, transparent); }
779
  .fishbowl .split-id { display: flex; gap: 12px; align-items: center; }
780
+ .fishbowl .split-name { font-size: 14px; letter-spacing: 0.06em; font-weight: 700; color: var(--ac, var(--ink)); text-shadow: 0 0 8px color-mix(in oklab, var(--ac, var(--ink)) 32%, transparent); }
781
  .fishbowl .split-arch { font-size: 10.5px; color: var(--ink-dim); }
782
  .fishbowl .split-model { display: flex; align-items: center; gap: 5px; font-size: 9.5px; color: var(--ink-mid); margin-top: 3px; }
783
  .fishbowl .split-said, .fishbowl .split-think { border-radius: var(--r); padding: 12px 14px; display: flex; align-items: center; }
 
852
  .fishbowl .wf-sub { display: flex; align-items: center; justify-content: center; gap: 9px; flex-wrap: wrap; margin-top: -2px; }
853
  .fishbowl .wf-arch { font-style: italic; font-size: 12px; color: var(--ink-dim); letter-spacing: 0.02em; }
854
  .fishbowl .wf-roster { display: flex; align-items: center; justify-content: center; gap: 7px; flex-wrap: wrap; }
855
+ .fishbowl .wf-chip { font-family: var(--font-display); font-size: 9px; letter-spacing: 0.12em; text-transform: uppercase; color: hsl(var(--ch, var(--win-hue)) 80% 80%); border: 1px solid hsl(var(--ch, var(--win-hue)) 75% 62% / 0.45); background: hsl(var(--ch, var(--win-hue)) 75% 50% / 0.12); border-radius: 999px; padding: 3px 10px; white-space: nowrap; }
856
  .fishbowl .wf-cheer { font-size: 12.5px; color: var(--ink); opacity: 0.92; margin-top: 4px; }
857
  .fishbowl .wf-loser { font-family: var(--font-display); font-size: 8.5px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--ink-faint); }
858
 
 
1005
  .fishbowl.fb-constellation .core-round { color: var(--ink-faint); }
1006
 
1007
  /* ---- MindCard depth + life: entrance, hover lift, richer speaking glow ---- */
1008
+ /* The stage HTML is rebuilt every tick (wholesale innerHTML swap), so a per-card entrance
1009
+ replayed the full staggered cascade on the whole cast each time β€” the dominant "screen
1010
+ reload" flicker. Instead, only the card that just acted gives a small settle, drawing the
1011
+ eye to the active speaker while the rest of the cast holds perfectly still. */
1012
+ @keyframes mindSpeakIn { from { opacity: 0.6; transform: translateY(4px); } to { opacity: 1; transform: none; } }
1013
  @media (prefers-reduced-motion: no-preference) {
1014
+ .fishbowl.fb-constellation .mind.speaking { animation: mindSpeakIn 0.3s cubic-bezier(0.2,0.7,0.2,1) both; }
 
 
 
 
 
 
1015
  }
1016
  .fishbowl .mind-front {
1017
  transition: border-color 0.22s ease, box-shadow 0.22s ease, transform 0.22s ease;
src/ui/fishbowl/lab.py CHANGED
@@ -414,9 +414,7 @@ def build_lab() -> dict[str, gr.components.Component]:
414
  )
415
 
416
  if workers and not choices:
417
- gr.Markdown(
418
- f"_No {backend_label} models in the catalogue β€” the cast runs the deterministic stub._"
419
- )
420
 
421
  # The Judge. Static handles (the app shell reads them on Summon and the picker
422
  # offers the catalogue), wrapped in a Group whose visibility tracks the effective
 
414
  )
415
 
416
  if workers and not choices:
417
+ gr.Markdown(f"_No {backend_label} models in the catalogue β€” the cast runs the deterministic stub._")
 
 
418
 
419
  # The Judge. Static handles (the app shell reads them on Summon and the picker
420
  # offers the catalogue), wrapped in a Group whose visibility tracks the effective
src/ui/fishbowl/render/avatar.py CHANGED
@@ -9,17 +9,12 @@ Unit 1's CSS animates the emitted ``av-*`` classes.
9
 
10
  from __future__ import annotations
11
 
12
- __all__ = ["render_avatar", "agent_color", "agent_color_dim"]
13
-
14
-
15
- def agent_color(hue: int, lightness: float = 0.82, chroma: float = 0.14) -> str:
16
- """The agent's phosphor colour β€” all cast share L/C, only the hue varies."""
17
- return f"oklch({lightness} {chroma} {hue})"
18
 
19
-
20
- def agent_color_dim(hue: int) -> str:
21
- """The dimmed companion colour (used for sealed / inactive surfaces)."""
22
- return f"oklch(0.5 0.09 {hue})"
23
 
24
 
25
  # mouth path per mood (panic is drawn as an open 'o' instead of a path)
 
9
 
10
  from __future__ import annotations
11
 
12
+ # Colour helpers live in the design-vocabulary layer (``adapter``) so the avatar, MindCard,
13
+ # feed line, and split row all derive one phosphor from one hue. Re-exported here for the
14
+ # renderers that have always reached for them via this module.
15
+ from src.ui.fishbowl.adapter import agent_color, agent_color_dim
 
 
16
 
17
+ __all__ = ["render_avatar", "agent_color", "agent_color_dim"]
 
 
 
18
 
19
 
20
  # mouth path per mood (panic is drawn as an open 'o' instead of a path)
src/ui/fishbowl/render/feed.py CHANGED
@@ -22,6 +22,8 @@ from __future__ import annotations
22
 
23
  import html
24
 
 
 
25
  _BOLT = "⚑" # poke lines carry a lightning bolt
26
  _SCALES = "βš–" # the verdict line carries the scales of judgement
27
 
@@ -49,7 +51,12 @@ def _say_line(item: dict, *, mind_reader: bool) -> str:
49
  if mind_reader and thought:
50
  thought_html = html.escape(thought)
51
  line += f'<div class="say-think">↳ <i class="thought">{thought_html}</i></div>'
52
- return f'<div class="fe say">{line}</div>'
 
 
 
 
 
53
 
54
 
55
  def _poke_line(item: dict) -> str:
 
22
 
23
  import html
24
 
25
+ from src.ui.fishbowl.adapter import agent_color, agent_color_dim
26
+
27
  _BOLT = "⚑" # poke lines carry a lightning bolt
28
  _SCALES = "βš–" # the verdict line carries the scales of judgement
29
 
 
51
  if mind_reader and thought:
52
  thought_html = html.escape(thought)
53
  line += f'<div class="say-think">↳ <i class="thought">{thought_html}</i></div>'
54
+ # The line wears the speaker's own phosphor (name, accent border, thought tint) so the
55
+ # transcript is colour-coded to the cast β€” the same hue as their MindCard and avatar.
56
+ # A hue-less line (e.g. an un-cast actor) falls back to the CSS default accent.
57
+ hue = item.get("hue")
58
+ style = f' style="--ac:{agent_color(int(hue))};--acd:{agent_color_dim(int(hue))}"' if hue is not None else ""
59
+ return f'<div class="fe say"{style}>{line}</div>'
60
 
61
 
62
  def _poke_line(item: dict) -> str:
src/ui/fishbowl/render/mindcard.py CHANGED
@@ -12,8 +12,8 @@ from __future__ import annotations
12
 
13
  import html
14
 
15
- from src.ui.fishbowl.adapter import TIER_COLOR, mood_color
16
- from src.ui.fishbowl.render.avatar import agent_color, agent_color_dim, render_avatar
17
 
18
  __all__ = ["render_mindcard"]
19
 
 
12
 
13
  import html
14
 
15
+ from src.ui.fishbowl.adapter import TIER_COLOR, agent_color, agent_color_dim, mood_color
16
+ from src.ui.fishbowl.render.avatar import render_avatar
17
 
18
  __all__ = ["render_mindcard"]
19
 
src/ui/fishbowl/render/stage.py CHANGED
@@ -18,6 +18,8 @@ from __future__ import annotations
18
 
19
  import html
20
 
 
 
21
  # Fixed, evocative core glyph for the stage centre. The prototype uses the scenario's
22
  # own glyph; the view-model carries no glyph field, so we fall back to this (and honour
23
  # a ``glyph`` key if a future view-model provides one).
@@ -117,8 +119,14 @@ def render_split(vm: dict) -> str:
117
  leak = " leak" if member.get("mood") == "panic" else ""
118
  said_cell = _split_cell(member.get("said"), placeholder="β€” hasn't spoken β€”", think=False)
119
  think_cell = _split_cell(member.get("thought"), placeholder="β€” quiet β€”", think=True)
 
 
 
 
 
 
120
  rows.append(
121
- f'<div class="split-row{speaking}">'
122
  '<div class="split-id">'
123
  f'<div class="disp split-name">{name}</div>'
124
  f'<div class="split-arch">{archetype}</div>'
 
18
 
19
  import html
20
 
21
+ from src.ui.fishbowl.adapter import agent_color, agent_color_dim
22
+
23
  # Fixed, evocative core glyph for the stage centre. The prototype uses the scenario's
24
  # own glyph; the view-model carries no glyph field, so we fall back to this (and honour
25
  # a ``glyph`` key if a future view-model provides one).
 
119
  leak = " leak" if member.get("mood") == "panic" else ""
120
  said_cell = _split_cell(member.get("said"), placeholder="β€” hasn't spoken β€”", think=False)
121
  think_cell = _split_cell(member.get("thought"), placeholder="β€” quiet β€”", think=True)
122
+ # Each row carries its mind's own phosphor so the omniscient table is colour-keyed
123
+ # to the same hue the cast wears on stage and in the transcript.
124
+ hue = member.get("hue")
125
+ row_style = (
126
+ f' style="--ac:{agent_color(int(hue))};--acd:{agent_color_dim(int(hue))}"' if hue is not None else ""
127
+ )
128
  rows.append(
129
+ f'<div class="split-row{speaking}"{row_style}>'
130
  '<div class="split-id">'
131
  f'<div class="disp split-name">{name}</div>'
132
  f'<div class="split-arch">{archetype}</div>'
src/ui/fishbowl/render/winner.py CHANGED
@@ -55,8 +55,11 @@ def _confetti() -> str:
55
  return f'<div class="wf-confetti" aria-hidden="true">{"".join(bits)}</div>'
56
 
57
 
58
- def _chip(text: str) -> str:
59
- return f'<span class="wf-chip">{html.escape(text)}</span>'
 
 
 
60
 
61
 
62
  def render_winner(vm: dict) -> str:
@@ -90,7 +93,7 @@ def render_winner(vm: dict) -> str:
90
  members = [by_id[m] for m in (teams.get(winner) or []) if m in by_id]
91
  if members:
92
  hue = int(members[0].get("hue", hue))
93
- chips = "".join(_chip(str(m.get("name", ""))) for m in members)
94
  roster = f'<div class="wf-roster">{chips}</div>'
95
  else: # an individual mind took it
96
  card = by_id.get(winner)
 
55
  return f'<div class="wf-confetti" aria-hidden="true">{"".join(bits)}</div>'
56
 
57
 
58
+ def _chip(text: str, hue: int | None = None) -> str:
59
+ # A roster chip wears its own member's hue so a teammate's name matches the colour
60
+ # they wore on stage; hue-less chips (e.g. the model chip) keep the winner's tint.
61
+ style = f' style="--ch:{int(hue)}"' if hue is not None else ""
62
+ return f'<span class="wf-chip"{style}>{html.escape(text)}</span>'
63
 
64
 
65
  def render_winner(vm: dict) -> str:
 
93
  members = [by_id[m] for m in (teams.get(winner) or []) if m in by_id]
94
  if members:
95
  hue = int(members[0].get("hue", hue))
96
+ chips = "".join(_chip(str(m.get("name", "")), m.get("hue")) for m in members)
97
  roster = f'<div class="wf-roster">{chips}</div>'
98
  else: # an individual mind took it
99
  card = by_id.get(winner)
src/ui/fishbowl/view_model.py CHANGED
@@ -116,11 +116,17 @@ def view_model_at(
116
  }
117
  )
118
 
 
 
 
 
119
  feed = []
120
  for e in prefix:
121
  item = event_to_feed_item(e, names)
122
  if item is not None:
123
  item["turn"] = e.turn
 
 
124
  feed.append(item)
125
 
126
  # The arena contract for this run (ADR-0029), stamped on run.started β€” lets the
 
116
  }
117
  )
118
 
119
+ # The hue a name maps to, so a feed/transcript line can wear the same phosphor as the
120
+ # speaker's MindCard and avatar β€” the colour is what lets the eye tie line to face.
121
+ hue_by_name = {m.name: agent_hue(m) for m in cast}
122
+
123
  feed = []
124
  for e in prefix:
125
  item = event_to_feed_item(e, names)
126
  if item is not None:
127
  item["turn"] = e.turn
128
+ if item.get("agent") in hue_by_name:
129
+ item["hue"] = hue_by_name[item["agent"]]
130
  feed.append(item)
131
 
132
  # The arena contract for this run (ADR-0029), stamped on run.started β€” lets the
tests/test_fishbowl_mindcard.py CHANGED
@@ -40,7 +40,7 @@ def test_avatar_is_svg_with_hue_colour():
40
  out = render_avatar(120, "smug")
41
  assert out.startswith("<div")
42
  assert "<svg" in out
43
- assert "oklch(0.82 0.14 120)" in out
44
  assert "av-smug" in out
45
 
46
 
 
40
  out = render_avatar(120, "smug")
41
  assert out.startswith("<div")
42
  assert "<svg" in out
43
+ assert "oklch(0.82 0.17 120)" in out
44
  assert "av-smug" in out
45
 
46