git-watchtower 2.0.3 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-watchtower",
3
- "version": "2.0.3",
3
+ "version": "2.1.0",
4
4
  "description": "Terminal-based Git branch monitor with activity sparklines and optional dev server with live reload",
5
5
  "main": "bin/git-watchtower.js",
6
6
  "bin": {
@@ -909,6 +909,299 @@ function getDashboardCss() {
909
909
  .pref-btn:hover { background: var(--bg-surface-hover); color: var(--text-dim); border-color: var(--text-muted); }
910
910
  .pref-btn.active { background: var(--accent-dim); color: #fff; border-color: var(--accent-dim); }
911
911
 
912
+ /* ── Session Stats Card (sidebar) ─────────────────────────────── */
913
+ .session-stats-card {
914
+ padding: 10px 16px 12px;
915
+ border-bottom: 1px solid var(--border-subtle);
916
+ font-size: 11px;
917
+ color: var(--text-dim);
918
+ display: grid;
919
+ grid-template-columns: auto 1fr;
920
+ gap: 4px 10px;
921
+ background: var(--bg);
922
+ }
923
+ .session-stats-card .stat-k {
924
+ color: var(--text-muted);
925
+ text-transform: uppercase;
926
+ letter-spacing: 0.6px;
927
+ font-size: 10px;
928
+ font-weight: 600;
929
+ align-self: center;
930
+ }
931
+ .session-stats-card .stat-v {
932
+ color: var(--text);
933
+ font-family: var(--font-mono);
934
+ font-size: 12px;
935
+ text-align: right;
936
+ }
937
+ .session-stats-card .stat-v .sep { color: var(--text-muted); }
938
+ .session-stats-card .stat-v .added { color: var(--green); }
939
+ .session-stats-card .stat-v .deleted { color: var(--red); }
940
+ .session-stats-card .stat-v .accent { color: var(--accent); }
941
+
942
+ /* ── Casino Mode ──────────────────────────────────────────────── */
943
+ .casino-layer {
944
+ position: fixed;
945
+ inset: 0;
946
+ pointer-events: none;
947
+ z-index: 90;
948
+ opacity: 0;
949
+ transition: opacity 0.3s;
950
+ }
951
+ body.casino-active .casino-layer { opacity: 1; }
952
+
953
+ /* Marquee: neon border that cycles hues around the viewport. Pure CSS
954
+ so we don't burn a JS timer for something a single keyframe can do. */
955
+ .casino-marquee {
956
+ position: absolute;
957
+ inset: 0;
958
+ border: 4px solid transparent;
959
+ border-radius: 0;
960
+ box-shadow:
961
+ inset 0 0 24px rgba(255, 64, 180, 0.45),
962
+ inset 0 0 60px rgba(255, 220, 64, 0.2);
963
+ background:
964
+ linear-gradient(var(--bg), var(--bg)) padding-box,
965
+ conic-gradient(
966
+ from 0deg,
967
+ #ff2d7a, #ffd400, #30ff9c, #29d4ff, #b070ff, #ff2d7a
968
+ ) border-box;
969
+ animation: casino-marquee-spin 6s linear infinite;
970
+ }
971
+ @keyframes casino-marquee-spin {
972
+ to { filter: hue-rotate(360deg); }
973
+ }
974
+ /* Running chase lights along the top and bottom edges */
975
+ .casino-marquee::before,
976
+ .casino-marquee::after {
977
+ content: '';
978
+ position: absolute;
979
+ left: 0;
980
+ right: 0;
981
+ height: 6px;
982
+ background-image: repeating-linear-gradient(
983
+ 90deg,
984
+ #ffd400 0 10px,
985
+ transparent 10px 20px,
986
+ #ff2d7a 20px 30px,
987
+ transparent 30px 40px,
988
+ #29d4ff 40px 50px,
989
+ transparent 50px 60px
990
+ );
991
+ background-size: 60px 6px;
992
+ opacity: 0.85;
993
+ }
994
+ .casino-marquee::before { top: 0; animation: casino-chase-right 1.2s linear infinite; }
995
+ .casino-marquee::after { bottom: 0; animation: casino-chase-left 1.2s linear infinite; }
996
+ @keyframes casino-chase-right { to { background-position: 60px 0; } }
997
+ @keyframes casino-chase-left { to { background-position: -60px 0; } }
998
+
999
+ /* Flashing "MAX ADDICTION" header badge */
1000
+ .casino-badge {
1001
+ position: absolute;
1002
+ top: 10px;
1003
+ right: 140px;
1004
+ padding: 3px 10px;
1005
+ border-radius: 10px;
1006
+ font-size: 10px;
1007
+ font-weight: 800;
1008
+ letter-spacing: 0.8px;
1009
+ text-transform: uppercase;
1010
+ color: #fff200;
1011
+ background: linear-gradient(90deg, #b10096, #ff2d7a);
1012
+ border: 1px solid rgba(255, 255, 255, 0.25);
1013
+ box-shadow: 0 0 14px rgba(255, 45, 122, 0.6);
1014
+ animation: casino-badge-flash 0.6s steps(2, end) infinite;
1015
+ }
1016
+ @keyframes casino-badge-flash {
1017
+ 0%, 100% {
1018
+ background: linear-gradient(90deg, #b10096, #ff2d7a);
1019
+ color: #fff200;
1020
+ }
1021
+ 50% {
1022
+ background: linear-gradient(90deg, #ffd400, #ff9a00);
1023
+ color: #b10096;
1024
+ }
1025
+ }
1026
+
1027
+ /* Slot reels — sits just under the header */
1028
+ .casino-reels {
1029
+ position: absolute;
1030
+ top: 57px;
1031
+ left: 50%;
1032
+ transform: translateX(-50%) translateY(-20px);
1033
+ display: flex;
1034
+ gap: 6px;
1035
+ padding: 8px 14px;
1036
+ background: linear-gradient(180deg, #1a1022, #120818);
1037
+ border: 1px solid rgba(255, 255, 255, 0.15);
1038
+ border-radius: 10px;
1039
+ box-shadow:
1040
+ 0 10px 24px rgba(0, 0, 0, 0.45),
1041
+ 0 0 18px rgba(255, 45, 122, 0.35);
1042
+ opacity: 0;
1043
+ visibility: hidden;
1044
+ transition: opacity 0.2s, transform 0.25s;
1045
+ }
1046
+ .casino-reels.active {
1047
+ opacity: 1;
1048
+ visibility: visible;
1049
+ transform: translateX(-50%) translateY(0);
1050
+ }
1051
+ .casino-reel {
1052
+ width: 40px;
1053
+ height: 40px;
1054
+ display: flex;
1055
+ align-items: center;
1056
+ justify-content: center;
1057
+ font-size: 22px;
1058
+ line-height: 1;
1059
+ background: #fff;
1060
+ border-radius: 6px;
1061
+ box-shadow:
1062
+ inset 0 -6px 10px rgba(0, 0, 0, 0.1),
1063
+ inset 0 2px 4px rgba(0, 0, 0, 0.1);
1064
+ }
1065
+ .casino-reels.spinning .casino-reel {
1066
+ animation: casino-reel-blur 0.1s linear infinite;
1067
+ }
1068
+ .casino-reels.spinning .casino-reel[data-reel="1"] { animation-delay: 0.03s; }
1069
+ .casino-reels.spinning .casino-reel[data-reel="2"] { animation-delay: 0.06s; }
1070
+ .casino-reels.spinning .casino-reel[data-reel="3"] { animation-delay: 0.09s; }
1071
+ .casino-reels.spinning .casino-reel[data-reel="4"] { animation-delay: 0.12s; }
1072
+ @keyframes casino-reel-blur {
1073
+ 0% { transform: translateY(-2px); filter: blur(0.6px); }
1074
+ 50% { transform: translateY(2px); filter: blur(0.6px); }
1075
+ 100% { transform: translateY(-2px); filter: blur(0.6px); }
1076
+ }
1077
+ .casino-reels.win .casino-reel {
1078
+ animation: casino-reel-winflash 0.24s steps(2, end) infinite;
1079
+ }
1080
+ @keyframes casino-reel-winflash {
1081
+ 0%, 100% { background: #fff; }
1082
+ 50% { background: #ffd400; }
1083
+ }
1084
+ .casino-reel-label {
1085
+ position: absolute;
1086
+ top: calc(100% + 6px);
1087
+ left: 50%;
1088
+ transform: translateX(-50%);
1089
+ font-size: 11px;
1090
+ font-weight: 800;
1091
+ letter-spacing: 1.2px;
1092
+ text-transform: uppercase;
1093
+ color: var(--text-dim);
1094
+ white-space: nowrap;
1095
+ text-shadow: 0 0 10px currentColor;
1096
+ opacity: 0;
1097
+ transition: opacity 0.2s;
1098
+ }
1099
+ .casino-reels.result .casino-reel-label { opacity: 1; }
1100
+
1101
+ /* Centered win / loss overlay flashing banner */
1102
+ .casino-overlay {
1103
+ position: absolute;
1104
+ top: 45%;
1105
+ left: 50%;
1106
+ transform: translate(-50%, -50%) scale(0.85);
1107
+ padding: 18px 48px;
1108
+ font-size: 32px;
1109
+ font-weight: 900;
1110
+ letter-spacing: 2px;
1111
+ text-transform: uppercase;
1112
+ color: #fff200;
1113
+ background: linear-gradient(135deg, #b10096, #ff2d7a);
1114
+ border: 3px solid rgba(255, 255, 255, 0.35);
1115
+ border-radius: 14px;
1116
+ box-shadow:
1117
+ 0 0 40px rgba(255, 45, 122, 0.7),
1118
+ 0 0 80px rgba(255, 220, 64, 0.4);
1119
+ text-shadow: 0 2px 0 rgba(0,0,0,0.3);
1120
+ opacity: 0;
1121
+ visibility: hidden;
1122
+ pointer-events: none;
1123
+ transition: opacity 0.15s, transform 0.2s;
1124
+ }
1125
+ .casino-overlay.active {
1126
+ opacity: 1;
1127
+ visibility: visible;
1128
+ transform: translate(-50%, -50%) scale(1);
1129
+ animation: casino-overlay-flash 0.2s steps(2, end) infinite;
1130
+ }
1131
+ @keyframes casino-overlay-flash {
1132
+ 0%, 100% { filter: brightness(1); }
1133
+ 50% { filter: brightness(1.35) saturate(1.3); }
1134
+ }
1135
+ .casino-overlay.level-small { background: linear-gradient(135deg, #116b2a, #3fb950); }
1136
+ .casino-overlay.level-medium { background: linear-gradient(135deg, #7a5600, #ffd400); color: #2a1200; }
1137
+ .casino-overlay.level-large { background: linear-gradient(135deg, #ba6a00, #ff9a00); color: #2a1200; }
1138
+ .casino-overlay.level-huge { background: linear-gradient(135deg, #7a00ba, #bc8cff); }
1139
+ .casino-overlay.level-jackpot {
1140
+ background: linear-gradient(135deg, #007a82, #29d4ff);
1141
+ color: #fff;
1142
+ animation-duration: 0.12s;
1143
+ }
1144
+ .casino-overlay.level-mega {
1145
+ background: linear-gradient(135deg, #b10000, #ff2d2d);
1146
+ animation-duration: 0.08s;
1147
+ font-size: 40px;
1148
+ }
1149
+ .casino-overlay.loss {
1150
+ background: linear-gradient(135deg, #2a0000, #b10000);
1151
+ color: #fff;
1152
+ font-size: 26px;
1153
+ }
1154
+
1155
+ /* Floating stats panel. Slides in from the right when casino is on. */
1156
+ .casino-stats-panel {
1157
+ position: absolute;
1158
+ right: 16px;
1159
+ bottom: 56px;
1160
+ width: 340px;
1161
+ padding: 12px 14px;
1162
+ background: linear-gradient(160deg, #1a0a24, #0f0616);
1163
+ border: 2px solid #ff2d7a;
1164
+ border-radius: 10px;
1165
+ box-shadow:
1166
+ 0 0 20px rgba(255, 45, 122, 0.45),
1167
+ 0 8px 28px rgba(0, 0, 0, 0.6);
1168
+ color: var(--text);
1169
+ font-size: 12px;
1170
+ transform: translateX(calc(100% + 32px));
1171
+ transition: transform 0.35s cubic-bezier(0.2, 0.9, 0.3, 1.1);
1172
+ pointer-events: auto;
1173
+ }
1174
+ body.casino-active .casino-stats-panel { transform: translateX(0); }
1175
+ .casino-stats-panel .cstats-title {
1176
+ font-weight: 800;
1177
+ letter-spacing: 1px;
1178
+ text-transform: uppercase;
1179
+ font-size: 11px;
1180
+ color: #ffd400;
1181
+ margin-bottom: 8px;
1182
+ text-align: center;
1183
+ text-shadow: 0 0 8px rgba(255, 220, 64, 0.6);
1184
+ }
1185
+ .casino-stats-panel .cstats-row {
1186
+ display: flex;
1187
+ justify-content: space-between;
1188
+ align-items: center;
1189
+ padding: 3px 0;
1190
+ border-bottom: 1px dashed rgba(255, 255, 255, 0.08);
1191
+ font-family: var(--font-mono);
1192
+ }
1193
+ .casino-stats-panel .cstats-row:last-child { border-bottom: none; }
1194
+ .casino-stats-panel .cstats-k {
1195
+ color: var(--text-dim);
1196
+ font-family: var(--font);
1197
+ font-size: 11px;
1198
+ }
1199
+ .casino-stats-panel .cstats-v { font-weight: 700; }
1200
+ .casino-stats-panel .cstats-v.pos { color: #3fb950; }
1201
+ .casino-stats-panel .cstats-v.neg { color: #f85149; }
1202
+ .casino-stats-panel .cstats-v.gold { color: #ffd400; }
1203
+ .casino-stats-panel .cstats-v.neon { color: #29d4ff; }
1204
+
912
1205
  @media (max-width: 900px) {
913
1206
  }
914
1207
  `;
@@ -39,6 +39,7 @@ function getDashboardHtml() {
39
39
 
40
40
  <div class="side-panel" id="side-panel">
41
41
  <div class="panel-header">Activity Log <button class="sidebar-toggle" id="sidebar-toggle" title="Toggle sidebar">&#x25b6;</button></div>
42
+ <div class="session-stats-card" id="session-stats-card"></div>
42
43
  <div class="activity-log" id="activity-log"></div>
43
44
  </div>
44
45
 
@@ -59,6 +60,26 @@ function getDashboardHtml() {
59
60
  </div>
60
61
  </div>
61
62
 
63
+ <!-- Casino Mode overlay layer. Everything inside is hidden by default and
64
+ only becomes visible when body has the .casino-active class (driven by
65
+ state.casinoModeEnabled). Pointer-events are off so it never blocks
66
+ clicks on the real dashboard underneath. -->
67
+ <div class="casino-layer" id="casino-layer">
68
+ <div class="casino-marquee" id="casino-marquee"></div>
69
+ <div class="casino-reels" id="casino-reels">
70
+ <div class="casino-reel" data-reel="0">&#x1f3b0;</div>
71
+ <div class="casino-reel" data-reel="1">&#x1f3b0;</div>
72
+ <div class="casino-reel" data-reel="2">&#x1f3b0;</div>
73
+ <div class="casino-reel" data-reel="3">&#x1f3b0;</div>
74
+ <div class="casino-reel" data-reel="4">&#x1f3b0;</div>
75
+ <div class="casino-reel-label" id="casino-reel-label"></div>
76
+ </div>
77
+ <div class="casino-overlay" id="casino-win-overlay"></div>
78
+ <div class="casino-overlay loss" id="casino-loss-overlay"></div>
79
+ <div class="casino-badge" id="casino-badge">&#x1f3b0; MAX ADDICTION &#x1f3b0;</div>
80
+ <div class="casino-stats-panel" id="casino-stats-panel"></div>
81
+ </div>
82
+
62
83
  <div class="flash" id="flash"></div>
63
84
  <div class="confirm-overlay" id="confirm-overlay">
64
85
  <div class="confirm-box" id="confirm-box"></div>
@@ -40,6 +40,8 @@ function getDashboardJs() {
40
40
  // from the server-pushed 'state' above.
41
41
  const ui = {
42
42
  prevBranches: null,
43
+ prevPollingStatus: 'idle',
44
+ prevCasinoEnabled: false,
43
45
  selectedIndex: 0,
44
46
  searchMode: false,
45
47
  searchQuery: '',
@@ -60,6 +62,35 @@ function getDashboardJs() {
60
62
  remoteTabPollTimer: null,
61
63
  };
62
64
 
65
+ // Casino mode client state. Kept separate so it can be fully torn down
66
+ // on disable without touching unrelated UI state.
67
+ const casino = {
68
+ reelSpinTimer: null,
69
+ reelFrame: 0,
70
+ reelResultClearTimer: null,
71
+ winFlashTimer: null,
72
+ winFlashFrames: 0,
73
+ lossFlashTimer: null,
74
+ lossFlashFrames: 0,
75
+ lastTotalPollsWithUpdates: null,
76
+ };
77
+ const CASINO_SYMBOLS = ['\u{1f352}','\u{1f34b}','\u{1f34a}','\u{1f347}','\u{1f514}','\u{1f48e}','7⃣','\u{1f3b0}'];
78
+ const CASINO_WIN_LEVELS = [
79
+ { key: 'small', min: 1, max: 49, label: '✨ WIN', color: '#3fb950' },
80
+ { key: 'medium', min: 50, max: 199, label: '\u{1f389} NICE WIN!', color: '#ffd400' },
81
+ { key: 'large', min: 200, max: 499, label: '\u{1f525} BIG WIN!', color: '#ff9a00' },
82
+ { key: 'huge', min: 500, max: 999, label: '\u{1f4a5} HUGE WIN!', color: '#bc8cff' },
83
+ { key: 'jackpot', min: 1000, max: 4999, label: '\u{1f4b0} JACKPOT! \u{1f4b0}', color: '#29d4ff' },
84
+ { key: 'mega', min: 5000, max: Infinity, label: '\u{1f3b0} MEGA JACKPOT!!! \u{1f3b0}', color: '#ff2d2d' },
85
+ ];
86
+ function getCasinoWinLevel(totalLines) {
87
+ for (let i = 0; i < CASINO_WIN_LEVELS.length; i++) {
88
+ const lvl = CASINO_WIN_LEVELS[i];
89
+ if (totalLines >= lvl.min && totalLines <= lvl.max) return lvl;
90
+ }
91
+ return null;
92
+ }
93
+
63
94
  // ── Persistent Preferences (localStorage) ─────────────────────
64
95
  const PREFS_KEY = 'git-watchtower-prefs';
65
96
  function loadPrefs() {
@@ -218,7 +249,12 @@ function getDashboardJs() {
218
249
  diffBranchesForNotifications(state.branches, newState.branches || []);
219
250
  }
220
251
  ui.prevBranches = state ? state.branches : null;
252
+ // Casino effects key off the edge between two SSE frames — grab
253
+ // the transition BEFORE the new state replaces the old.
254
+ const prevBranchesForCasino = state ? state.branches : null;
221
255
  state = newState;
256
+ onStateTransition(newState, prevBranchesForCasino);
257
+ ui.prevPollingStatus = newState.pollingStatus;
222
258
  } else {
223
259
  if (state) {
224
260
  state.projects = newState.projects;
@@ -476,6 +512,200 @@ ${pureFnBlock}
476
512
  });
477
513
  };
478
514
 
515
+ // ── Casino Mode ────────────────────────────────────────────────
516
+ // The terminal drives reel/win animations from server-side timers; in
517
+ // the browser we drive them off state transitions instead. That lets us
518
+ // avoid pushing a frame-by-frame stream over SSE and keeps effects local
519
+ // to each connected client.
520
+
521
+ function casinoCleanup() {
522
+ if (casino.reelSpinTimer) { clearInterval(casino.reelSpinTimer); casino.reelSpinTimer = null; }
523
+ if (casino.reelResultClearTimer) { clearTimeout(casino.reelResultClearTimer); casino.reelResultClearTimer = null; }
524
+ if (casino.winFlashTimer) { clearInterval(casino.winFlashTimer); casino.winFlashTimer = null; }
525
+ if (casino.lossFlashTimer) { clearInterval(casino.lossFlashTimer); casino.lossFlashTimer = null; }
526
+ casino.winFlashFrames = 0;
527
+ casino.lossFlashFrames = 0;
528
+ const reels = document.getElementById('casino-reels');
529
+ if (reels) reels.className = 'casino-reels';
530
+ const win = document.getElementById('casino-win-overlay');
531
+ if (win) win.className = 'casino-overlay';
532
+ const loss = document.getElementById('casino-loss-overlay');
533
+ if (loss) loss.className = 'casino-overlay loss';
534
+ }
535
+
536
+ function casinoSetReelSymbol(idx, emoji) {
537
+ const el = document.querySelector('.casino-reel[data-reel="' + idx + '"]');
538
+ if (el) el.textContent = emoji;
539
+ }
540
+
541
+ function casinoStartSpinning() {
542
+ if (casino.reelSpinTimer) return;
543
+ if (casino.reelResultClearTimer) {
544
+ clearTimeout(casino.reelResultClearTimer);
545
+ casino.reelResultClearTimer = null;
546
+ }
547
+ const reels = document.getElementById('casino-reels');
548
+ if (!reels) return;
549
+ reels.className = 'casino-reels active spinning';
550
+ const label = document.getElementById('casino-reel-label');
551
+ if (label) label.textContent = '';
552
+ casino.reelSpinTimer = setInterval(() => {
553
+ casino.reelFrame++;
554
+ for (let i = 0; i < 5; i++) {
555
+ const idx = (casino.reelFrame + i * 3) % CASINO_SYMBOLS.length;
556
+ casinoSetReelSymbol(i, CASINO_SYMBOLS[idx]);
557
+ }
558
+ }, 100);
559
+ }
560
+
561
+ function casinoStopSpinning(hadUpdates, totalLines) {
562
+ if (casino.reelSpinTimer) { clearInterval(casino.reelSpinTimer); casino.reelSpinTimer = null; }
563
+ const reels = document.getElementById('casino-reels');
564
+ const label = document.getElementById('casino-reel-label');
565
+ if (!reels || !label) return;
566
+ const winLevel = hadUpdates ? getCasinoWinLevel(totalLines || 1) : null;
567
+
568
+ if (hadUpdates && winLevel) {
569
+ const isJackpot = winLevel.key === 'jackpot' || winLevel.key === 'mega';
570
+ const sym = isJackpot ? '7⃣' : CASINO_SYMBOLS[Math.floor(Math.random() * (CASINO_SYMBOLS.length - 1))];
571
+ for (let i = 0; i < 5; i++) casinoSetReelSymbol(i, sym);
572
+ reels.className = 'casino-reels active result win';
573
+ label.textContent = winLevel.label.replace(/^[^A-Z0-9]+/, '').trim() || 'WIN';
574
+ label.style.color = winLevel.color;
575
+ // Fire the centered banner.
576
+ casinoTriggerWin(winLevel);
577
+ // Let the flashing linger a beat, then settle.
578
+ casino.reelResultClearTimer = setTimeout(() => {
579
+ casino.reelResultClearTimer = null;
580
+ reels.className = 'casino-reels';
581
+ }, isJackpot ? 4000 : 2400);
582
+ } else {
583
+ // No updates — show a random losing line and auto-fade.
584
+ for (let i = 0; i < 5; i++) {
585
+ casinoSetReelSymbol(i, CASINO_SYMBOLS[(casino.reelFrame + i * 3) % CASINO_SYMBOLS.length]);
586
+ }
587
+ reels.className = 'casino-reels active result';
588
+ label.textContent = '\u{1f634} NOTHING';
589
+ label.style.color = '#8b949e';
590
+ casino.reelResultClearTimer = setTimeout(() => {
591
+ casino.reelResultClearTimer = null;
592
+ reels.className = 'casino-reels';
593
+ }, 2000);
594
+ }
595
+ }
596
+
597
+ function casinoTriggerWin(winLevel) {
598
+ const overlay = document.getElementById('casino-win-overlay');
599
+ if (!overlay) return;
600
+ overlay.textContent = winLevel.label;
601
+ overlay.className = 'casino-overlay active level-' + winLevel.key;
602
+ if (casino.winFlashTimer) clearInterval(casino.winFlashTimer);
603
+ casino.winFlashFrames = 0;
604
+ const maxFrames = (winLevel.key === 'jackpot' || winLevel.key === 'mega') ? 30 : 16;
605
+ casino.winFlashTimer = setInterval(() => {
606
+ casino.winFlashFrames++;
607
+ if (casino.winFlashFrames >= maxFrames) {
608
+ clearInterval(casino.winFlashTimer);
609
+ casino.winFlashTimer = null;
610
+ overlay.className = 'casino-overlay';
611
+ }
612
+ }, 120);
613
+ }
614
+
615
+ function casinoTriggerLoss(message) {
616
+ const overlay = document.getElementById('casino-loss-overlay');
617
+ if (!overlay) return;
618
+ overlay.textContent = '\u{1f480} ' + (message || 'BUST!') + ' \u{1f480}';
619
+ overlay.className = 'casino-overlay loss active';
620
+ if (casino.lossFlashTimer) clearInterval(casino.lossFlashTimer);
621
+ casino.lossFlashFrames = 0;
622
+ casino.lossFlashTimer = setInterval(() => {
623
+ casino.lossFlashFrames++;
624
+ if (casino.lossFlashFrames >= 12) {
625
+ clearInterval(casino.lossFlashTimer);
626
+ casino.lossFlashTimer = null;
627
+ overlay.className = 'casino-overlay loss';
628
+ }
629
+ }, 130);
630
+ }
631
+
632
+ // Sum up the line churn from branches that just transitioned to
633
+ // justUpdated. Mirrors how the terminal decides a poll was a "win".
634
+ function casinoMeasureUpdate(prevBranches, newBranches, newAheadBehind) {
635
+ if (!newBranches) return { hadUpdates: false, totalLines: 0 };
636
+ const prevMap = {};
637
+ if (prevBranches) {
638
+ for (let i = 0; i < prevBranches.length; i++) prevMap[prevBranches[i].name] = prevBranches[i];
639
+ }
640
+ let hadUpdates = false;
641
+ let totalLines = 0;
642
+ for (let i = 0; i < newBranches.length; i++) {
643
+ const nb = newBranches[i];
644
+ const ob = prevMap[nb.name];
645
+ const transitioned = nb.justUpdated && (!ob || !ob.justUpdated);
646
+ if (transitioned) {
647
+ hadUpdates = true;
648
+ const ab = newAheadBehind ? newAheadBehind[nb.name] : null;
649
+ if (ab) totalLines += (ab.linesAdded || 0) + (ab.linesDeleted || 0);
650
+ }
651
+ }
652
+ return { hadUpdates, totalLines };
653
+ }
654
+
655
+ function renderCasinoStats() {
656
+ const panel = document.getElementById('casino-stats-panel');
657
+ if (!panel) return;
658
+ const cs = state && state.casinoStats;
659
+ if (!cs) { panel.innerHTML = ''; return; }
660
+ const netClass = cs.netWinnings >= 0 ? 'pos' : 'neg';
661
+ const netSign = cs.netWinnings >= 0 ? '+' : '';
662
+ let html = '<div class="cstats-title">\u{1f3b0} CASINO WINNINGS \u{1f3b0}</div>';
663
+ html += '<div class="cstats-row"><span class="cstats-k">\u{1f4dd} Line Changes</span><span class="cstats-v"><span class="pos">+' + (cs.totalLinesAdded || 0) + '</span> / <span class="neg">-' + (cs.totalLinesDeleted || 0) + '</span> = <span class="gold">$' + (cs.totalLines || 0) + '</span></span></div>';
664
+ html += '<div class="cstats-row"><span class="cstats-k">\u{1f4b8} Poll Cost</span><span class="cstats-v neg">$' + (cs.totalPolls || 0) + '</span></div>';
665
+ html += '<div class="cstats-row"><span class="cstats-k">\u{1f4b0} Net Earnings</span><span class="cstats-v ' + netClass + '">' + netSign + '$' + (cs.netWinnings || 0) + '</span></div>';
666
+ html += '<div class="cstats-row"><span class="cstats-k">\u{1f3b0} House Edge</span><span class="cstats-v neon">' + (cs.houseEdge || 0) + '%</span></div>';
667
+ html += '<div class="cstats-row"><span class="cstats-k">\u{1f60e} Vibes</span><span class="cstats-v">' + escHtml(cs.vibesQuality || '') + '</span></div>';
668
+ html += '<div class="cstats-row"><span class="cstats-k">\u{1f3b2} Luck</span><span class="cstats-v gold">' + (cs.luckMeter || 0) + '%</span></div>';
669
+ html += '<div class="cstats-row"><span class="cstats-k">\u{1f9e0} Dopamine Hits</span><span class="cstats-v pos">' + (cs.dopamineHits || 0) + '</span></div>';
670
+ if (cs.consecutivePolls > 1) {
671
+ html += '<div class="cstats-row"><span class="cstats-k">\u{1f525} Streak</span><span class="cstats-v gold">' + cs.consecutivePolls + 'x</span></div>';
672
+ }
673
+ html += '<div class="cstats-row"><span class="cstats-k">⏱ Session</span><span class="cstats-v">' + escHtml(cs.sessionDuration || '') + '</span></div>';
674
+ panel.innerHTML = html;
675
+ }
676
+
677
+ // Apply/tear down casino mode based on state.casinoModeEnabled.
678
+ function reconcileCasinoMode() {
679
+ if (!state) return;
680
+ const enabled = !!state.casinoModeEnabled;
681
+ document.body.classList.toggle('casino-active', enabled);
682
+ if (!enabled) {
683
+ if (ui.prevCasinoEnabled) {
684
+ casinoCleanup();
685
+ ui.prevCasinoEnabled = false;
686
+ }
687
+ return;
688
+ }
689
+ ui.prevCasinoEnabled = true;
690
+ renderCasinoStats();
691
+ }
692
+
693
+ // Drive reel spin/stop from the polling status transition, and fire win
694
+ // effects when new updates land. Called from the SSE state handler so
695
+ // we see each edge exactly once.
696
+ function onStateTransition(newState, prevBranches) {
697
+ if (!newState.casinoModeEnabled) return;
698
+ const prevStatus = ui.prevPollingStatus;
699
+ const newStatus = newState.pollingStatus;
700
+ if (newStatus === 'fetching' && prevStatus !== 'fetching') {
701
+ casinoStartSpinning();
702
+ } else if (prevStatus === 'fetching' && newStatus !== 'fetching') {
703
+ const { hadUpdates, totalLines } = casinoMeasureUpdate(prevBranches, newState.branches, newState.aheadBehindCache);
704
+ casinoStopSpinning(hadUpdates, totalLines);
705
+ }
706
+ if (newState.hasMergeConflict) casinoTriggerLoss('MERGE CONFLICT!');
707
+ }
708
+
479
709
  // ── Render ─────────────────────────────────────────────────────
480
710
  function render() {
481
711
  if (!state) return;
@@ -510,7 +740,9 @@ ${pureFnBlock}
510
740
  renderBranches();
511
741
  renderActivityLog();
512
742
  renderSessionStats();
743
+ renderSessionStatsCard();
513
744
  renderPrefsBar();
745
+ reconcileCasinoMode();
514
746
 
515
747
  // Auto-show update notification (once per session)
516
748
  if (state.updateAvailable && !ui.updateNotificationShown && !anyModalOpen()) {
@@ -1014,6 +1246,33 @@ ${pureFnBlock}
1014
1246
  bar.innerHTML = html;
1015
1247
  }
1016
1248
 
1249
+ // ── Session Stats Card (sidebar) ───────────────────────────────
1250
+ // Lives at the top of the activity log. Real, grounded numbers the
1251
+ // dashboard always shows — not dependent on casino mode.
1252
+ function renderSessionStatsCard() {
1253
+ const card = document.getElementById('session-stats-card');
1254
+ if (!card) return;
1255
+ const s = state && state.sessionStats;
1256
+ if (!s) { card.innerHTML = ''; return; }
1257
+ const branches = (state && state.branches) || [];
1258
+ let activeCount = 0;
1259
+ let staleCount = 0;
1260
+ for (let i = 0; i < branches.length; i++) {
1261
+ const b = branches[i];
1262
+ if (b.justUpdated || b.name === state.currentBranch) activeCount++;
1263
+ else staleCount++;
1264
+ }
1265
+ let html = '';
1266
+ html += '<span class="stat-k">Session</span><span class="stat-v">' + escHtml(s.sessionDuration || '0m') + '</span>';
1267
+ html += '<span class="stat-k">Lines</span><span class="stat-v"><span class="added">+' + fmtCompact(s.linesAdded || 0) + '</span> <span class="sep">/</span> <span class="deleted">-' + fmtCompact(s.linesDeleted || 0) + '</span></span>';
1268
+ html += '<span class="stat-k">Polls</span><span class="stat-v">' + (s.totalPolls || 0) + ' <span class="sep">·</span> <span class="accent">' + (s.hitRate || 0) + '%</span> hit</span>';
1269
+ if (s.lastUpdate) {
1270
+ html += '<span class="stat-k">Last hit</span><span class="stat-v">' + escHtml(s.lastUpdate) + '</span>';
1271
+ }
1272
+ html += '<span class="stat-k">Branches</span><span class="stat-v">' + activeCount + ' <span class="sep">active</span> <span class="sep">·</span> ' + staleCount + ' <span class="sep">stale</span></span>';
1273
+ card.innerHTML = html;
1274
+ }
1275
+
1017
1276
  // ── Error Toast with Stash Hint ────────────────────────────────
1018
1277
  function showErrorToastWithHint(message, hint) {
1019
1278
  const container = document.getElementById('toast-container');
package/src/server/web.js CHANGED
@@ -14,6 +14,7 @@ const http = require('http');
14
14
  const { getWebDashboardHtml } = require('./web-ui');
15
15
  const { version: PACKAGE_VERSION } = require('../../package.json');
16
16
  const sessionStats = require('../stats/session');
17
+ const casino = require('../casino');
17
18
 
18
19
  /**
19
20
  * Default web dashboard port
@@ -122,6 +123,13 @@ class WebDashboardServer {
122
123
 
123
124
  // UI
124
125
  soundEnabled: s.soundEnabled,
126
+ casinoModeEnabled: s.casinoModeEnabled,
127
+ // Casino stats track server-side regardless of which surface toggled
128
+ // the mode on, so the web dashboard can render the same winnings box
129
+ // the terminal does. Null when disabled — keeps payload small and
130
+ // avoids ticking Math.random()/Date.now() into every SSE push when
131
+ // nobody's asked for the effect.
132
+ casinoStats: s.casinoModeEnabled ? casino.getStats() : null,
125
133
  projectName: s.projectName,
126
134
 
127
135
  // Activity