git-watchtower 2.2.3 → 2.3.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.2.3",
3
+ "version": "2.3.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": {
@@ -63,9 +63,11 @@ function getDashboardCss() {
63
63
  border-bottom: 1px solid rgba(255,255,255,0.08);
64
64
  user-select: none;
65
65
  box-shadow: 0 1px 8px rgba(0,0,0,0.3);
66
+ /* position:relative so .casino-reels-header (absolute) anchors here. */
66
67
  position: relative;
67
68
  z-index: 10;
68
69
  }
70
+ .header-icon { display: inline-block; }
69
71
  .header-left {
70
72
  display: flex;
71
73
  align-items: center;
@@ -113,7 +115,10 @@ function getDashboardCss() {
113
115
  .layout {
114
116
  display: grid;
115
117
  grid-template-columns: 1fr 320px;
116
- grid-template-rows: 1fr auto;
118
+ /* Row 1: branch panel + sidebar.
119
+ Row 2: dashboard-stats (full-width).
120
+ Row 3: keyboard-shortcut footer (full-width). */
121
+ grid-template-rows: 1fr auto auto;
117
122
  height: calc(100vh - 49px);
118
123
  min-height: 0;
119
124
  gap: 0;
@@ -759,24 +764,6 @@ function getDashboardCss() {
759
764
  .info-label { color: var(--text-muted); font-weight: 500; }
760
765
  .info-value { color: var(--text); font-family: var(--font-mono); }
761
766
 
762
- /* ── Session Stats (footer) ──────────────────────────────────── */
763
- .stats-bar {
764
- display: flex;
765
- gap: 16px;
766
- align-items: center;
767
- font-size: 11px;
768
- color: var(--text-muted);
769
- font-family: var(--font-mono);
770
- flex-wrap: wrap;
771
- }
772
- .stats-bar .stat-item {
773
- display: flex;
774
- align-items: center;
775
- gap: 4px;
776
- }
777
- .stats-bar .stat-value { color: var(--text-dim); font-weight: 600; }
778
- .stats-bar .stat-label { font-family: var(--font); }
779
-
780
767
  /* ── Cleanup Modal ───────────────────────────────────────────── */
781
768
  .cleanup-branch-list {
782
769
  display: flex;
@@ -909,172 +896,238 @@ function getDashboardCss() {
909
896
  .pref-btn:hover { background: var(--bg-surface-hover); color: var(--text-dim); border-color: var(--text-muted); }
910
897
  .pref-btn.active { background: var(--accent-dim); color: #fff; border-color: var(--accent-dim); }
911
898
 
912
- /* ── Session Stats Card (sidebar) ─────────────────────────────── */
913
- .session-stats-card {
914
- padding: 10px 16px 12px;
915
- border-bottom: 1px solid var(--border-subtle);
899
+ /* ── Dashboard Stats Bar (always-on, above the keyboard footer) ──
900
+ This is the canonical place for live session stats. The same
901
+ element re-skins to "casino winnings" when state.casinoModeEnabled
902
+ flips on, so users get the same row in both modes. */
903
+ .dashboard-stats {
904
+ grid-column: 1 / -1;
905
+ padding: 8px 20px;
906
+ background: var(--bg-surface);
907
+ border-top: 1px solid var(--border);
908
+ display: flex;
909
+ flex-wrap: wrap;
910
+ gap: 8px 24px;
911
+ align-items: center;
912
+ justify-content: space-between;
916
913
  font-size: 11px;
917
914
  color: var(--text-dim);
918
- display: grid;
919
- grid-template-columns: auto 1fr;
920
- gap: 4px 10px;
915
+ transition: background 0.25s, border-color 0.25s, box-shadow 0.25s;
916
+ }
917
+ .dashboard-stats .stats-group {
918
+ display: flex;
919
+ flex-wrap: wrap;
920
+ align-items: center;
921
+ gap: 14px;
922
+ }
923
+ /* Title pill anchors the bar — makes it obvious these are global
924
+ session metrics, not row-specific. */
925
+ .dashboard-stats .stats-title {
926
+ font-size: 10px;
927
+ font-weight: 800;
928
+ letter-spacing: 1.2px;
929
+ text-transform: uppercase;
930
+ color: var(--text-muted);
931
+ padding: 3px 10px;
932
+ border-radius: 999px;
921
933
  background: var(--bg);
934
+ border: 1px solid var(--border);
935
+ display: inline-flex;
936
+ align-items: center;
937
+ gap: 6px;
938
+ }
939
+ .dashboard-stats .stat {
940
+ display: inline-flex;
941
+ align-items: baseline;
942
+ gap: 6px;
943
+ white-space: nowrap;
922
944
  }
923
- .session-stats-card .stat-k {
945
+ .dashboard-stats .stat-k {
924
946
  color: var(--text-muted);
925
947
  text-transform: uppercase;
926
948
  letter-spacing: 0.6px;
927
949
  font-size: 10px;
928
950
  font-weight: 600;
929
- align-self: center;
930
951
  }
931
- .session-stats-card .stat-v {
952
+ .dashboard-stats .stat-v {
932
953
  color: var(--text);
933
954
  font-family: var(--font-mono);
934
955
  font-size: 12px;
935
- text-align: right;
956
+ font-weight: 600;
936
957
  }
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); }
958
+ .dashboard-stats .stat-v .added { color: var(--green); }
959
+ .dashboard-stats .stat-v .deleted { color: var(--red); }
960
+ .dashboard-stats .stat-v .sep { color: var(--text-muted); }
961
+ .dashboard-stats .stat-v .accent { color: var(--accent); }
962
+ /* Casino skin: same row, neon-pulsed, brighter title. */
963
+ .dashboard-stats.casino-mode {
964
+ background: linear-gradient(90deg, #1a0a24 0%, #2a0a36 50%, #1a0a24 100%);
965
+ border-top: 2px solid #ff2d7a;
966
+ box-shadow: inset 0 0 24px rgba(255, 45, 122, 0.25);
967
+ }
968
+ .dashboard-stats.casino-mode .stats-title {
969
+ color: #ffd400;
970
+ background: rgba(177, 0, 150, 0.4);
971
+ border-color: #ff2d7a;
972
+ text-shadow: 0 0 6px rgba(255, 220, 64, 0.6);
973
+ }
974
+ .dashboard-stats.casino-mode .stat-k { color: #ffd400; }
975
+ .dashboard-stats.casino-mode .stat-v { color: var(--text); }
976
+ .dashboard-stats.casino-mode .stat-v .pos { color: #3fb950; }
977
+ .dashboard-stats.casino-mode .stat-v .neg { color: #f85149; }
978
+ .dashboard-stats.casino-mode .stat-v .gold { color: #ffd400; }
979
+ .dashboard-stats.casino-mode .stat-v .neon { color: #29d4ff; }
980
+
981
+ /* ── Casino Mode ────────────────────────────────────────────────
982
+ Edge strips, header reskin, header reels, win/loss overlays. The
983
+ stats live in .dashboard-stats above; nothing floats over the
984
+ dashboard content anymore. */
941
985
 
942
- /* ── Casino Mode ──────────────────────────────────────────────── */
943
986
  .casino-layer {
944
987
  position: fixed;
945
988
  inset: 0;
946
989
  pointer-events: none;
947
990
  z-index: 90;
948
991
  opacity: 0;
949
- transition: opacity 0.3s;
992
+ visibility: hidden;
993
+ transition: opacity 0.25s;
994
+ }
995
+ body.casino-active .casino-layer {
996
+ opacity: 1;
997
+ visibility: visible;
950
998
  }
951
- body.casino-active .casino-layer { opacity: 1; }
952
999
 
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 {
1000
+ /* Marquee: four solid neon strips at the viewport edges. Each strip
1001
+ pulses its own hue so the whole frame chases colours together. */
1002
+ .casino-edge {
956
1003
  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 {
1004
+ background: #ff2d7a;
1005
+ box-shadow: 0 0 18px rgba(255, 45, 122, 0.7);
1006
+ animation: casino-edge-pulse 0.9s ease-in-out infinite;
1007
+ overflow: hidden;
1008
+ }
1009
+ .casino-edge.top { top: 0; left: 0; right: 0; height: 8px; }
1010
+ .casino-edge.bottom { bottom: 0; left: 0; right: 0; height: 8px; animation-delay: 0.45s; }
1011
+ .casino-edge.left { top: 0; bottom: 0; left: 0; width: 8px; animation-delay: 0.225s; }
1012
+ .casino-edge.right { top: 0; bottom: 0; right: 0; width: 8px; animation-delay: 0.675s; }
1013
+ @keyframes casino-edge-pulse {
1014
+ 0% { background: #ff2d7a; box-shadow: 0 0 18px rgba(255, 45, 122, 0.7); }
1015
+ 25% { background: #ffd400; box-shadow: 0 0 18px rgba(255, 220, 64, 0.7); }
1016
+ 50% { background: #29d4ff; box-shadow: 0 0 18px rgba(41, 212, 255, 0.7); }
1017
+ 75% { background: #b070ff; box-shadow: 0 0 18px rgba(176, 112, 255, 0.7); }
1018
+ 100% { background: #ff2d7a; box-shadow: 0 0 18px rgba(255, 45, 122, 0.7); }
1019
+ }
1020
+
1021
+ /* Chase-light stripes horizontal pattern on top/bottom, vertical
1022
+ pattern on left/right, all flowing in opposite directions so the
1023
+ marquee reads as a closed loop. */
1024
+ .casino-edge::after {
977
1025
  content: '';
978
1026
  position: absolute;
979
- left: 0;
980
- right: 0;
981
- height: 6px;
1027
+ inset: 0;
1028
+ }
1029
+ .casino-edge.top::after,
1030
+ .casino-edge.bottom::after {
982
1031
  background-image: repeating-linear-gradient(
983
1032
  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
1033
+ rgba(255, 255, 255, 0.85) 0 8px,
1034
+ transparent 8px 24px
1035
+ );
1036
+ background-size: 24px 100%;
1037
+ animation: casino-chase-x 0.9s linear infinite;
1038
+ }
1039
+ .casino-edge.bottom::after { animation-direction: reverse; }
1040
+ .casino-edge.left::after,
1041
+ .casino-edge.right::after {
1042
+ background-image: repeating-linear-gradient(
1043
+ 0deg,
1044
+ rgba(255, 255, 255, 0.85) 0 8px,
1045
+ transparent 8px 24px
990
1046
  );
991
- background-size: 60px 6px;
992
- opacity: 0.85;
1047
+ background-size: 100% 24px;
1048
+ animation: casino-chase-y 0.9s linear infinite;
993
1049
  }
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; } }
1050
+ .casino-edge.right::after { animation-direction: reverse; }
1051
+ @keyframes casino-chase-x { to { background-position: 24px 0; } }
1052
+ @keyframes casino-chase-y { to { background-position: 0 24px; } }
998
1053
 
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
- }
1054
+ /* Header reskin: rainbow text + animated icon + slot reels in-place. */
1055
+ body.casino-active .header-text {
1056
+ background: linear-gradient(
1057
+ 90deg,
1058
+ #ff2d7a, #ffd400, #30ff9c, #29d4ff, #b070ff, #ff2d7a
1059
+ );
1060
+ background-size: 200% 100%;
1061
+ -webkit-background-clip: text;
1062
+ background-clip: text;
1063
+ -webkit-text-fill-color: transparent;
1064
+ color: transparent;
1065
+ animation: casino-rainbow-slide 3s linear infinite;
1066
+ /* Drop shadow with a coloured tint compensates for transparent fill. */
1067
+ filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.4));
1068
+ }
1069
+ @keyframes casino-rainbow-slide {
1070
+ to { background-position: 200% 0; }
1071
+ }
1072
+ body.casino-active .header-icon {
1073
+ display: inline-block;
1074
+ animation: casino-icon-spin 2.4s linear infinite;
1075
+ }
1076
+ @keyframes casino-icon-spin {
1077
+ 0%, 100% { filter: hue-rotate(0deg) drop-shadow(0 0 4px rgba(255, 220, 64, 0.6)); }
1078
+ 50% { filter: hue-rotate(180deg) drop-shadow(0 0 6px rgba(255, 45, 122, 0.8)); }
1025
1079
  }
1026
1080
 
1027
- /* Slot reels — sits just under the header */
1028
- .casino-reels {
1081
+ /* Slot reels — sit centred inside the header banner, hidden until
1082
+ casino mode is on. Sized small enough to fit in the existing
1083
+ header height without disturbing surrounding controls. */
1084
+ .casino-reels-header {
1029
1085
  position: absolute;
1030
- top: 57px;
1086
+ top: 50%;
1031
1087
  left: 50%;
1032
- transform: translateX(-50%) translateY(-20px);
1088
+ transform: translate(-50%, -50%);
1033
1089
  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);
1090
+ gap: 4px;
1091
+ padding: 4px 8px;
1092
+ background: rgba(20, 8, 30, 0.9);
1093
+ border: 1px solid #ff2d7a;
1094
+ border-radius: 8px;
1095
+ box-shadow: 0 0 14px rgba(255, 45, 122, 0.55);
1042
1096
  opacity: 0;
1043
1097
  visibility: hidden;
1044
- transition: opacity 0.2s, transform 0.25s;
1098
+ pointer-events: none;
1099
+ transition: opacity 0.25s;
1100
+ z-index: 1;
1045
1101
  }
1046
- .casino-reels.active {
1102
+ body.casino-active .casino-reels-header {
1047
1103
  opacity: 1;
1048
1104
  visibility: visible;
1049
- transform: translateX(-50%) translateY(0);
1050
1105
  }
1051
1106
  .casino-reel {
1052
- width: 40px;
1053
- height: 40px;
1107
+ width: 26px;
1108
+ height: 26px;
1054
1109
  display: flex;
1055
1110
  align-items: center;
1056
1111
  justify-content: center;
1057
- font-size: 22px;
1112
+ font-size: 16px;
1058
1113
  line-height: 1;
1059
1114
  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);
1115
+ border-radius: 4px;
1116
+ box-shadow: inset 0 -3px 6px rgba(0, 0, 0, 0.12), inset 0 1px 2px rgba(0, 0, 0, 0.1);
1064
1117
  }
1065
- .casino-reels.spinning .casino-reel {
1118
+ .casino-reels-header.spinning .casino-reel {
1066
1119
  animation: casino-reel-blur 0.1s linear infinite;
1067
1120
  }
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; }
1121
+ .casino-reels-header.spinning .casino-reel[data-reel="1"] { animation-delay: 0.03s; }
1122
+ .casino-reels-header.spinning .casino-reel[data-reel="2"] { animation-delay: 0.06s; }
1123
+ .casino-reels-header.spinning .casino-reel[data-reel="3"] { animation-delay: 0.09s; }
1124
+ .casino-reels-header.spinning .casino-reel[data-reel="4"] { animation-delay: 0.12s; }
1072
1125
  @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); }
1126
+ 0% { transform: translateY(-1.5px); filter: blur(0.6px); }
1127
+ 50% { transform: translateY(1.5px); filter: blur(0.6px); }
1128
+ 100% { transform: translateY(-1.5px); filter: blur(0.6px); }
1076
1129
  }
1077
- .casino-reels.win .casino-reel {
1130
+ .casino-reels-header.win .casino-reel {
1078
1131
  animation: casino-reel-winflash 0.24s steps(2, end) infinite;
1079
1132
  }
1080
1133
  @keyframes casino-reel-winflash {
@@ -1083,22 +1136,22 @@ function getDashboardCss() {
1083
1136
  }
1084
1137
  .casino-reel-label {
1085
1138
  position: absolute;
1086
- top: calc(100% + 6px);
1139
+ top: calc(100% + 4px);
1087
1140
  left: 50%;
1088
1141
  transform: translateX(-50%);
1089
- font-size: 11px;
1142
+ font-size: 10px;
1090
1143
  font-weight: 800;
1091
1144
  letter-spacing: 1.2px;
1092
1145
  text-transform: uppercase;
1093
- color: var(--text-dim);
1146
+ color: #ffd400;
1094
1147
  white-space: nowrap;
1095
- text-shadow: 0 0 10px currentColor;
1148
+ text-shadow: 0 0 8px currentColor;
1096
1149
  opacity: 0;
1097
1150
  transition: opacity 0.2s;
1098
1151
  }
1099
- .casino-reels.result .casino-reel-label { opacity: 1; }
1152
+ .casino-reels-header.result .casino-reel-label { opacity: 1; }
1100
1153
 
1101
- /* Centered win / loss overlay flashing banner */
1154
+ /* Centred win / loss overlay banner — solid, opaque, hard to miss. */
1102
1155
  .casino-overlay {
1103
1156
  position: absolute;
1104
1157
  top: 45%;
@@ -1110,17 +1163,18 @@ function getDashboardCss() {
1110
1163
  letter-spacing: 2px;
1111
1164
  text-transform: uppercase;
1112
1165
  color: #fff200;
1113
- background: linear-gradient(135deg, #b10096, #ff2d7a);
1114
- border: 3px solid rgba(255, 255, 255, 0.35);
1166
+ background: #b10096;
1167
+ border: 3px solid #ffd400;
1115
1168
  border-radius: 14px;
1116
1169
  box-shadow:
1117
1170
  0 0 40px rgba(255, 45, 122, 0.7),
1118
1171
  0 0 80px rgba(255, 220, 64, 0.4);
1119
- text-shadow: 0 2px 0 rgba(0,0,0,0.3);
1172
+ text-shadow: 0 2px 0 rgba(0, 0, 0, 0.3);
1120
1173
  opacity: 0;
1121
1174
  visibility: hidden;
1122
1175
  pointer-events: none;
1123
1176
  transition: opacity 0.15s, transform 0.2s;
1177
+ z-index: 3;
1124
1178
  }
1125
1179
  .casino-overlay.active {
1126
1180
  opacity: 1;
@@ -1132,76 +1186,30 @@ function getDashboardCss() {
1132
1186
  0%, 100% { filter: brightness(1); }
1133
1187
  50% { filter: brightness(1.35) saturate(1.3); }
1134
1188
  }
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); }
1189
+ .casino-overlay.level-small { background: #238636; border-color: #3fb950; }
1190
+ .casino-overlay.level-medium { background: #ffd400; color: #2a1200; border-color: #ff9a00; }
1191
+ .casino-overlay.level-large { background: #ff9a00; color: #2a1200; border-color: #ffd400; }
1192
+ .casino-overlay.level-huge { background: #7a00ba; color: #fff; border-color: #bc8cff; }
1139
1193
  .casino-overlay.level-jackpot {
1140
- background: linear-gradient(135deg, #007a82, #29d4ff);
1141
- color: #fff;
1194
+ background: #29d4ff;
1195
+ color: #04293a;
1196
+ border-color: #fff;
1142
1197
  animation-duration: 0.12s;
1143
1198
  }
1144
1199
  .casino-overlay.level-mega {
1145
- background: linear-gradient(135deg, #b10000, #ff2d2d);
1200
+ background: #b10000;
1201
+ color: #fff200;
1202
+ border-color: #ffd400;
1146
1203
  animation-duration: 0.08s;
1147
1204
  font-size: 40px;
1148
1205
  }
1149
1206
  .casino-overlay.loss {
1150
- background: linear-gradient(135deg, #2a0000, #b10000);
1207
+ background: #b10000;
1151
1208
  color: #fff;
1152
1209
  font-size: 26px;
1210
+ border-color: #ff2d2d;
1153
1211
  }
1154
1212
 
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
-
1205
1213
  @media (max-width: 900px) {
1206
1214
  }
1207
1215
  `;
@@ -12,10 +12,23 @@ function getDashboardHtml() {
12
12
  return `
13
13
  <div class="header">
14
14
  <div class="header-left">
15
- <span class="header-title">&#x1f3f0; Git Watchtower</span>
15
+ <span class="header-title">
16
+ <span class="header-icon" id="header-icon">&#x1f3f0;</span>
17
+ <span class="header-text">Git Watchtower</span>
18
+ </span>
16
19
  <span class="header-version" id="version"></span>
17
20
  <span class="header-project" id="project-name">-</span>
18
21
  </div>
22
+ <!-- Slot reels live in the header so they fit inside the blue banner
23
+ without dropping over the branch list. Hidden until casino-active. -->
24
+ <div class="casino-reels-header" id="casino-reels">
25
+ <div class="casino-reel" data-reel="0">&#x1f352;</div>
26
+ <div class="casino-reel" data-reel="1">&#x1f34b;</div>
27
+ <div class="casino-reel" data-reel="2">&#x1f34a;</div>
28
+ <div class="casino-reel" data-reel="3">&#x1f347;</div>
29
+ <div class="casino-reel" data-reel="4">&#x1f3b0;</div>
30
+ <div class="casino-reel-label" id="casino-reel-label"></div>
31
+ </div>
19
32
  <div class="header-right">
20
33
  <button class="notif-btn" id="notif-btn" title="Enable desktop notifications">notifications</button>
21
34
  <span class="badge" id="status-badge">connecting</span>
@@ -39,10 +52,14 @@ function getDashboardHtml() {
39
52
 
40
53
  <div class="side-panel" id="side-panel">
41
54
  <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>
43
55
  <div class="activity-log" id="activity-log"></div>
44
56
  </div>
45
57
 
58
+ <!-- Permanent stats row, sits above the keyboard-shortcut footer.
59
+ Hosts grounded session stats by default; the same element re-skins
60
+ to casino-style winnings when state.casinoModeEnabled flips on. -->
61
+ <div class="dashboard-stats" id="dashboard-stats"></div>
62
+
46
63
  <div class="footer" id="footer">
47
64
  <span><kbd>j</kbd><kbd>k</kbd> navigate</span>
48
65
  <span><kbd>Enter</kbd> switch</span>
@@ -55,29 +72,22 @@ function getDashboardHtml() {
55
72
  <span><kbd>S</kbd> stash</span>
56
73
  <span><kbd>d</kbd> cleanup</span>
57
74
  <span><kbd>h</kbd> history</span>
75
+ <span><kbd>c</kbd> casino</span>
58
76
  <span><kbd>Esc</kbd> close</span>
59
- <span class="stats-bar" id="stats-bar"></span>
60
77
  </div>
61
78
  </div>
62
79
 
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. -->
80
+ <!-- Casino Mode overlay layer viewport-edge effects only. The reels
81
+ and stats live inline in the dashboard now (header / bottom bar);
82
+ this layer is just the marquee strips and the centred win/loss
83
+ banners. Pointer-events stay off so it never blocks clicks. -->
67
84
  <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>
85
+ <div class="casino-edge top"></div>
86
+ <div class="casino-edge bottom"></div>
87
+ <div class="casino-edge left"></div>
88
+ <div class="casino-edge right"></div>
77
89
  <div class="casino-overlay" id="casino-win-overlay"></div>
78
90
  <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
91
  </div>
82
92
 
83
93
  <div class="flash" id="flash"></div>
@@ -525,8 +525,10 @@ ${pureFnBlock}
525
525
  if (casino.lossFlashTimer) { clearInterval(casino.lossFlashTimer); casino.lossFlashTimer = null; }
526
526
  casino.winFlashFrames = 0;
527
527
  casino.lossFlashFrames = 0;
528
+ // Reels stay in DOM but drop the dynamic state classes — the casino
529
+ // layer's opacity transition handles hiding them visually.
528
530
  const reels = document.getElementById('casino-reels');
529
- if (reels) reels.className = 'casino-reels';
531
+ if (reels) reels.className = 'casino-reels-header';
530
532
  const win = document.getElementById('casino-win-overlay');
531
533
  if (win) win.className = 'casino-overlay';
532
534
  const loss = document.getElementById('casino-loss-overlay');
@@ -546,7 +548,7 @@ ${pureFnBlock}
546
548
  }
547
549
  const reels = document.getElementById('casino-reels');
548
550
  if (!reels) return;
549
- reels.className = 'casino-reels active spinning';
551
+ reels.className = 'casino-reels-header spinning';
550
552
  const label = document.getElementById('casino-reel-label');
551
553
  if (label) label.textContent = '';
552
554
  casino.reelSpinTimer = setInterval(() => {
@@ -569,7 +571,7 @@ ${pureFnBlock}
569
571
  const isJackpot = winLevel.key === 'jackpot' || winLevel.key === 'mega';
570
572
  const sym = isJackpot ? '7⃣' : CASINO_SYMBOLS[Math.floor(Math.random() * (CASINO_SYMBOLS.length - 1))];
571
573
  for (let i = 0; i < 5; i++) casinoSetReelSymbol(i, sym);
572
- reels.className = 'casino-reels active result win';
574
+ reels.className = 'casino-reels-header result win';
573
575
  label.textContent = winLevel.label.replace(/^[^A-Z0-9]+/, '').trim() || 'WIN';
574
576
  label.style.color = winLevel.color;
575
577
  // Fire the centered banner.
@@ -577,19 +579,19 @@ ${pureFnBlock}
577
579
  // Let the flashing linger a beat, then settle.
578
580
  casino.reelResultClearTimer = setTimeout(() => {
579
581
  casino.reelResultClearTimer = null;
580
- reels.className = 'casino-reels';
582
+ reels.className = 'casino-reels-header';
581
583
  }, isJackpot ? 4000 : 2400);
582
584
  } else {
583
585
  // No updates — show a random losing line and auto-fade.
584
586
  for (let i = 0; i < 5; i++) {
585
587
  casinoSetReelSymbol(i, CASINO_SYMBOLS[(casino.reelFrame + i * 3) % CASINO_SYMBOLS.length]);
586
588
  }
587
- reels.className = 'casino-reels active result';
589
+ reels.className = 'casino-reels-header result';
588
590
  label.textContent = '\u{1f634} NOTHING';
589
591
  label.style.color = '#8b949e';
590
592
  casino.reelResultClearTimer = setTimeout(() => {
591
593
  casino.reelResultClearTimer = null;
592
- reels.className = 'casino-reels';
594
+ reels.className = 'casino-reels-header';
593
595
  }, 2000);
594
596
  }
595
597
  }
@@ -652,42 +654,19 @@ ${pureFnBlock}
652
654
  return { hadUpdates, totalLines };
653
655
  }
654
656
 
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
657
  // Apply/tear down casino mode based on state.casinoModeEnabled.
658
+ // Stats rendering is handled by renderDashboardStats — this function
659
+ // only owns the body class flip, header icon swap, and timer cleanup.
678
660
  function reconcileCasinoMode() {
679
661
  if (!state) return;
680
662
  const enabled = !!state.casinoModeEnabled;
681
663
  document.body.classList.toggle('casino-active', enabled);
682
- if (!enabled) {
683
- if (ui.prevCasinoEnabled) {
684
- casinoCleanup();
685
- ui.prevCasinoEnabled = false;
686
- }
687
- return;
664
+ const icon = document.getElementById('header-icon');
665
+ if (icon) icon.innerHTML = enabled ? '\u{1f3b0}' : '\u{1f3f0}';
666
+ if (!enabled && ui.prevCasinoEnabled) {
667
+ casinoCleanup();
688
668
  }
689
- ui.prevCasinoEnabled = true;
690
- renderCasinoStats();
669
+ ui.prevCasinoEnabled = enabled;
691
670
  }
692
671
 
693
672
  // Drive reel spin/stop from the polling status transition, and fire win
@@ -710,6 +689,10 @@ ${pureFnBlock}
710
689
  function render() {
711
690
  if (!state) return;
712
691
 
692
+ // Apply casino mode FIRST so a later renderer throwing (the SSE
693
+ // handler wraps render() in try/catch) can't swallow casino effects.
694
+ reconcileCasinoMode();
695
+
713
696
  // Header — hide project name pill when tabs are showing it
714
697
  const projectEl = document.getElementById('project-name');
715
698
  const hasTabs = state.projects && state.projects.length > 1;
@@ -739,10 +722,8 @@ ${pureFnBlock}
739
722
 
740
723
  renderBranches();
741
724
  renderActivityLog();
742
- renderSessionStats();
743
- renderSessionStatsCard();
725
+ renderDashboardStats();
744
726
  renderPrefsBar();
745
- reconcileCasinoMode();
746
727
 
747
728
  // Auto-show update notification (once per session)
748
729
  if (state.updateAvailable && !ui.updateNotificationShown && !anyModalOpen()) {
@@ -890,7 +871,7 @@ ${pureFnBlock}
890
871
  let html = '';
891
872
  for (let i = 0; i < log.length; i++) {
892
873
  const entry = log[i];
893
- const t = '';
874
+ let t = '';
894
875
  if (entry.timestamp) {
895
876
  const d = new Date(entry.timestamp);
896
877
  t = isNaN(d.getTime()) ? '' : d.toLocaleTimeString();
@@ -1217,60 +1198,71 @@ ${pureFnBlock}
1217
1198
 
1218
1199
  function hideUpdate() { updateModal.hide(); }
1219
1200
 
1220
- // ── Session Stats ──────────────────────────────────────────────
1221
- function renderSessionStats() {
1222
- if (!state || !state.sessionStats) return;
1223
- const s = state.sessionStats;
1224
- const bar = document.getElementById('stats-bar');
1225
- const activeBranches = 0;
1226
- const staleBranches = 0;
1227
- if (state.branches) {
1228
- for (let i = 0; i < state.branches.length; i++) {
1229
- const b = state.branches[i];
1230
- // Consider stale if no updates and not current
1231
- if (b.justUpdated || b.name === state.currentBranch) {
1232
- activeBranches++;
1233
- } else {
1234
- staleBranches++;
1235
- }
1236
- }
1237
- }
1238
- let html = '';
1239
- html += '<span class="stat-item"><span class="stat-label">Session:</span> <span class="stat-value">' + escHtml(s.sessionDuration || '0m') + '</span></span>';
1240
- html += '<span class="stat-item"><span class="stat-label">Lines:</span> <span class="stat-value">+' + (s.linesAdded || 0) + '/-' + (s.linesDeleted || 0) + '</span></span>';
1241
- html += '<span class="stat-item"><span class="stat-label">Polls:</span> <span class="stat-value">' + (s.totalPolls || 0) + '</span> <span class="stat-label">(' + (s.hitRate || 0) + '% hit)</span></span>';
1242
- if (s.lastUpdate) {
1243
- html += '<span class="stat-item"><span class="stat-label">Last update:</span> <span class="stat-value">' + escHtml(s.lastUpdate) + '</span></span>';
1201
+ // ── Dashboard Stats Bar ────────────────────────────────────────
1202
+ // Permanent row above the keyboard footer. Default: grounded session
1203
+ // metrics. When casino mode is on the same row re-skins to "casino
1204
+ // winnings" same DOM, different content + .casino-mode class.
1205
+ function renderDashboardStats() {
1206
+ const bar = document.getElementById('dashboard-stats');
1207
+ if (!bar) return;
1208
+ if (state && state.casinoModeEnabled && state.casinoStats) {
1209
+ bar.className = 'dashboard-stats casino-mode';
1210
+ bar.innerHTML = renderCasinoStatsRow(state.casinoStats);
1211
+ } else {
1212
+ bar.className = 'dashboard-stats';
1213
+ bar.innerHTML = renderSessionStatsRow();
1244
1214
  }
1245
- html += '<span class="stat-item"><span class="stat-label">Active:</span> <span class="stat-value">' + activeBranches + '</span> <span class="stat-label">Stale:</span> <span class="stat-value">' + staleBranches + '</span></span>';
1246
- bar.innerHTML = html;
1247
1215
  }
1248
1216
 
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;
1217
+ function renderSessionStatsRow() {
1255
1218
  const s = state && state.sessionStats;
1256
- if (!s) { card.innerHTML = ''; return; }
1219
+ if (!s) return '';
1257
1220
  const branches = (state && state.branches) || [];
1258
- let activeCount = 0;
1259
- let staleCount = 0;
1221
+ let active = 0;
1222
+ let stale = 0;
1260
1223
  for (let i = 0; i < branches.length; i++) {
1261
1224
  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;
1225
+ if (b.justUpdated || b.name === state.currentBranch) active++;
1226
+ else stale++;
1227
+ }
1228
+ const stat = (k, v) => '<span class="stat"><span class="stat-k">' + k + '</span><span class="stat-v">' + v + '</span></span>';
1229
+ // Left = identity / what session this is.
1230
+ let left = '<div class="stats-group">';
1231
+ left += '<span class="stats-title">\u{1f4ca} Session Stats</span>';
1232
+ left += stat('Duration', escHtml(s.sessionDuration || '0m'));
1233
+ left += stat('Branches', active + ' <span class="sep">active</span> <span class="sep">·</span> ' + stale + ' <span class="sep">stale</span>');
1234
+ left += '</div>';
1235
+ // Right = live activity readouts.
1236
+ let right = '<div class="stats-group">';
1237
+ right += stat('Lines', '<span class="added">+' + fmtCompact(s.linesAdded || 0) + '</span> <span class="sep">/</span> <span class="deleted">-' + fmtCompact(s.linesDeleted || 0) + '</span>');
1238
+ right += stat('Polls', (s.totalPolls || 0) + ' <span class="sep">·</span> <span class="accent">' + (s.hitRate || 0) + '%</span> hit');
1239
+ if (s.lastUpdate) right += stat('Last hit', escHtml(s.lastUpdate));
1240
+ right += '</div>';
1241
+ return left + right;
1242
+ }
1243
+
1244
+ function renderCasinoStatsRow(cs) {
1245
+ const netClass = cs.netWinnings >= 0 ? 'pos' : 'neg';
1246
+ const netSign = cs.netWinnings >= 0 ? '+' : '';
1247
+ const stat = (k, v) => '<span class="stat"><span class="stat-k">' + k + '</span><span class="stat-v">' + v + '</span></span>';
1248
+ const dollar = '$'; // avoid bare $ in generated JS — see js.dom.test.js
1249
+ // Left = identity + the underlying churn that drove the winnings.
1250
+ let left = '<div class="stats-group">';
1251
+ left += '<span class="stats-title">\u{1f3b0} Casino Stats</span>';
1252
+ left += stat('Session', escHtml(cs.sessionDuration || ''));
1253
+ left += stat('\u{1f4dd} Lines', '<span class="pos">+' + (cs.totalLinesAdded || 0) + '</span> <span class="sep">/</span> <span class="neg">-' + (cs.totalLinesDeleted || 0) + '</span> <span class="sep">=</span> <span class="gold">' + dollar + (cs.totalLines || 0) + '</span>');
1254
+ left += '</div>';
1255
+ // Right = the gambling readouts (fast-changing, attention-grabbing).
1256
+ let right = '<div class="stats-group">';
1257
+ right += stat('\u{1f4b8} Cost', '<span class="neg">' + dollar + (cs.totalPolls || 0) + '</span>');
1258
+ right += stat('\u{1f4b0} Net', '<span class="' + netClass + '">' + netSign + dollar + (cs.netWinnings || 0) + '</span>');
1259
+ right += stat('\u{1f3b0} Edge', '<span class="neon">' + (cs.houseEdge || 0) + '%</span>');
1260
+ right += stat('\u{1f3b2} Luck', '<span class="gold">' + (cs.luckMeter || 0) + '%</span>');
1261
+ right += stat('\u{1f60e} Vibes', escHtml(cs.vibesQuality || ''));
1262
+ right += stat('\u{1f9e0} Hits', '<span class="pos">' + (cs.dopamineHits || 0) + '</span>');
1263
+ if (cs.consecutivePolls > 1) right += stat('\u{1f525} Streak', '<span class="gold">' + cs.consecutivePolls + 'x</span>');
1264
+ right += '</div>';
1265
+ return left + right;
1274
1266
  }
1275
1267
 
1276
1268
  // ── Error Toast with Stash Hint ────────────────────────────────