diff --git a/global.css b/global.css
index e16d3c2..9298e6e 100644
--- a/global.css
+++ b/global.css
@@ -3,704 +3,15 @@
@import 'tailwindcss/utilities.css';
@import 'nativewind/theme';
-/* Class-driven dark mode (GluestackUIProvider toggles the .dark class on web
- and on the native root View). */
-@custom-variant dark (&:where(.dark, .dark *));
-
-/* ─── Design tokens (light / dark) ───────────────────────────────
- 1. :root — light defaults (all platforms)
- 2. @media dark :root — dark defaults; on native this follows
- Appearance.getColorScheme() (the provider
- sets an override for explicit modes).
- 3. :root.dark / :root.light — web-only class override, beats the media
- query within the same layer. */
-@layer theme {
- :root {
- --color-primary-0: 248 250 252;
- --color-primary-50: 241 245 249;
- --color-primary-100: 226 232 240;
- --color-primary-200: 203 213 225;
- --color-primary-300: 148 163 184;
- --color-primary-400: 100 116 139;
- --color-primary-500: 71 85 105;
- --color-primary-600: 51 65 85;
- --color-primary-700: 45 55 72;
- --color-primary-800: 39 49 66;
- --color-primary-900: 30 41 59;
- --color-primary-950: 15 23 42;
- --color-secondary-0: 254 255 255;
- --color-secondary-50: 241 242 242;
- --color-secondary-100: 231 232 232;
- --color-secondary-200: 219 219 219;
- --color-secondary-300: 175 176 176;
- --color-secondary-400: 114 115 115;
- --color-secondary-500: 94 95 95;
- --color-secondary-600: 81 82 82;
- --color-secondary-700: 63 64 64;
- --color-secondary-800: 39 38 38;
- --color-secondary-900: 24 23 23;
- --color-secondary-950: 11 12 12;
- --color-tertiary-0: 255 250 245;
- --color-tertiary-50: 255 242 229;
- --color-tertiary-100: 255 233 213;
- --color-tertiary-200: 254 209 170;
- --color-tertiary-300: 253 180 116;
- --color-tertiary-400: 251 157 75;
- --color-tertiary-500: 231 129 40;
- --color-tertiary-600: 215 117 31;
- --color-tertiary-700: 180 98 26;
- --color-tertiary-800: 130 73 23;
- --color-tertiary-900: 108 61 19;
- --color-tertiary-950: 84 49 18;
- --color-error-0: 254 233 233;
- --color-error-50: 254 226 226;
- --color-error-100: 254 202 202;
- --color-error-200: 252 165 165;
- --color-error-300: 248 113 113;
- --color-error-400: 239 68 68;
- --color-error-500: 230 53 53;
- --color-error-600: 220 38 38;
- --color-error-700: 185 28 28;
- --color-error-800: 153 27 27;
- --color-error-900: 127 29 29;
- --color-error-950: 83 19 19;
- --color-success-0: 228 255 244;
- --color-success-50: 202 255 232;
- --color-success-100: 162 241 192;
- --color-success-200: 132 211 162;
- --color-success-300: 102 181 132;
- --color-success-400: 72 151 102;
- --color-success-500: 52 131 82;
- --color-success-600: 42 121 72;
- --color-success-700: 32 111 62;
- --color-success-800: 22 101 52;
- --color-success-900: 20 83 45;
- --color-success-950: 27 50 36;
- --color-warning-0: 255 253 251;
- --color-warning-50: 255 249 245;
- --color-warning-100: 255 231 213;
- --color-warning-200: 254 205 170;
- --color-warning-300: 253 173 116;
- --color-warning-400: 251 149 75;
- --color-warning-500: 231 120 40;
- --color-warning-600: 215 108 31;
- --color-warning-700: 180 90 26;
- --color-warning-800: 130 68 23;
- --color-warning-900: 108 56 19;
- --color-warning-950: 84 45 18;
- --color-info-0: 236 248 254;
- --color-info-50: 199 235 252;
- --color-info-100: 162 221 250;
- --color-info-200: 124 207 248;
- --color-info-300: 87 194 246;
- --color-info-400: 50 180 244;
- --color-info-500: 13 166 242;
- --color-info-600: 11 141 205;
- --color-info-700: 9 115 168;
- --color-info-800: 7 90 131;
- --color-info-900: 5 64 93;
- --color-info-950: 3 38 56;
- --color-typography-0: 254 254 255;
- --color-typography-50: 245 245 245;
- --color-typography-100: 229 229 229;
- --color-typography-200: 219 219 220;
- --color-typography-300: 212 212 212;
- --color-typography-400: 163 163 163;
- --color-typography-500: 140 140 140;
- --color-typography-600: 115 115 115;
- --color-typography-700: 82 82 82;
- --color-typography-800: 64 64 64;
- --color-typography-900: 38 38 39;
- --color-typography-950: 23 23 23;
- --color-outline-0: 253 254 254;
- --color-outline-50: 243 243 243;
- --color-outline-100: 230 230 230;
- --color-outline-200: 221 220 219;
- --color-outline-300: 211 211 211;
- --color-outline-400: 165 163 163;
- --color-outline-500: 140 141 141;
- --color-outline-600: 115 116 116;
- --color-outline-700: 83 82 82;
- --color-outline-800: 65 65 65;
- --color-outline-900: 39 38 36;
- --color-outline-950: 26 23 23;
- --color-background-0: 255 255 255;
- --color-background-50: 246 246 246;
- --color-background-100: 242 241 241;
- --color-background-200: 220 219 219;
- --color-background-300: 213 212 212;
- --color-background-400: 162 163 163;
- --color-background-500: 142 142 142;
- --color-background-600: 116 116 116;
- --color-background-700: 83 82 82;
- --color-background-800: 65 64 64;
- --color-background-900: 39 38 37;
- --color-background-950: 24 23 24;
- --color-background-error: 254 241 241;
- --color-background-warning: 255 244 235;
- --color-background-success: 237 252 242;
- --color-background-muted: 247 248 247;
- --color-background-info: 235 248 254;
- --color-indicator-primary: 55 55 55;
- --color-indicator-info: 83 153 236;
- --color-indicator-error: 185 28 28;
- }
-
- @media (prefers-color-scheme: dark) {
- :root {
- --color-primary-0: 15 23 42;
- --color-primary-50: 30 41 59;
- --color-primary-100: 39 49 66;
- --color-primary-200: 45 55 72;
- --color-primary-300: 51 65 85;
- --color-primary-400: 71 85 105;
- --color-primary-500: 100 116 139;
- --color-primary-600: 71 85 105;
- --color-primary-700: 51 65 85;
- --color-primary-800: 45 55 72;
- --color-primary-900: 39 49 66;
- --color-primary-950: 30 41 59;
- --color-secondary-0: 11 12 12;
- --color-secondary-50: 24 23 23;
- --color-secondary-100: 39 38 38;
- --color-secondary-200: 63 64 64;
- --color-secondary-300: 81 82 82;
- --color-secondary-400: 94 95 95;
- --color-secondary-500: 114 115 115;
- --color-secondary-600: 175 176 176;
- --color-secondary-700: 219 219 219;
- --color-secondary-800: 231 232 232;
- --color-secondary-900: 241 242 242;
- --color-secondary-950: 254 255 255;
- --color-tertiary-0: 84 49 18;
- --color-tertiary-50: 108 61 19;
- --color-tertiary-100: 130 73 23;
- --color-tertiary-200: 180 98 26;
- --color-tertiary-300: 215 117 31;
- --color-tertiary-400: 231 129 40;
- --color-tertiary-500: 251 157 75;
- --color-tertiary-600: 253 180 116;
- --color-tertiary-700: 254 209 170;
- --color-tertiary-800: 255 233 213;
- --color-tertiary-900: 255 242 229;
- --color-tertiary-950: 255 250 245;
- --color-error-0: 83 19 19;
- --color-error-50: 127 29 29;
- --color-error-100: 153 27 27;
- --color-error-200: 185 28 28;
- --color-error-300: 220 38 38;
- --color-error-400: 230 53 53;
- --color-error-500: 239 68 68;
- --color-error-600: 248 113 113;
- --color-error-700: 252 165 165;
- --color-error-800: 254 202 202;
- --color-error-900: 254 226 226;
- --color-error-950: 254 233 233;
- --color-success-0: 27 50 36;
- --color-success-50: 20 83 45;
- --color-success-100: 22 101 52;
- --color-success-200: 32 111 62;
- --color-success-300: 42 121 72;
- --color-success-400: 52 131 82;
- --color-success-500: 72 151 102;
- --color-success-600: 102 181 132;
- --color-success-700: 132 211 162;
- --color-success-800: 162 241 192;
- --color-success-900: 202 255 232;
- --color-success-950: 228 255 244;
- --color-warning-0: 84 45 18;
- --color-warning-50: 108 56 19;
- --color-warning-100: 130 68 23;
- --color-warning-200: 180 90 26;
- --color-warning-300: 215 108 31;
- --color-warning-400: 231 120 40;
- --color-warning-500: 251 149 75;
- --color-warning-600: 253 173 116;
- --color-warning-700: 254 205 170;
- --color-warning-800: 255 231 213;
- --color-warning-900: 255 249 245;
- --color-warning-950: 255 253 251;
- --color-info-0: 3 38 56;
- --color-info-50: 5 64 93;
- --color-info-100: 7 90 131;
- --color-info-200: 9 115 168;
- --color-info-300: 11 141 205;
- --color-info-400: 13 166 242;
- --color-info-500: 50 180 244;
- --color-info-600: 87 194 246;
- --color-info-700: 124 207 248;
- --color-info-800: 162 221 250;
- --color-info-900: 199 235 252;
- --color-info-950: 236 248 254;
- --color-typography-0: 23 23 23;
- --color-typography-50: 38 38 39;
- --color-typography-100: 64 64 64;
- --color-typography-200: 82 82 82;
- --color-typography-300: 115 115 115;
- --color-typography-400: 140 140 140;
- --color-typography-500: 163 163 163;
- --color-typography-600: 212 212 212;
- --color-typography-700: 219 219 220;
- --color-typography-800: 229 229 229;
- --color-typography-900: 245 245 245;
- --color-typography-950: 254 254 255;
- --color-outline-0: 26 23 23;
- --color-outline-50: 39 38 36;
- --color-outline-100: 65 65 65;
- --color-outline-200: 83 82 82;
- --color-outline-300: 115 116 116;
- --color-outline-400: 140 141 141;
- --color-outline-500: 165 163 163;
- --color-outline-600: 211 211 211;
- --color-outline-700: 221 220 219;
- --color-outline-800: 230 230 230;
- --color-outline-900: 243 243 243;
- --color-outline-950: 253 254 254;
- --color-background-0: 18 18 18;
- --color-background-50: 39 38 37;
- --color-background-100: 65 64 64;
- --color-background-200: 83 82 82;
- --color-background-300: 116 116 116;
- --color-background-400: 142 142 142;
- --color-background-500: 162 163 163;
- --color-background-600: 213 212 212;
- --color-background-700: 220 219 219;
- --color-background-800: 242 241 241;
- --color-background-900: 246 246 246;
- --color-background-950: 254 254 254;
- --color-background-error: 66 43 43;
- --color-background-warning: 65 47 35;
- --color-background-success: 28 43 33;
- --color-background-muted: 51 51 51;
- --color-background-info: 26 40 46;
- --color-indicator-primary: 247 247 247;
- --color-indicator-info: 161 199 245;
- --color-indicator-error: 232 70 69;
- }
- }
-
- :root.dark {
- --color-primary-0: 15 23 42;
- --color-primary-50: 30 41 59;
- --color-primary-100: 39 49 66;
- --color-primary-200: 45 55 72;
- --color-primary-300: 51 65 85;
- --color-primary-400: 71 85 105;
- --color-primary-500: 100 116 139;
- --color-primary-600: 71 85 105;
- --color-primary-700: 51 65 85;
- --color-primary-800: 45 55 72;
- --color-primary-900: 39 49 66;
- --color-primary-950: 30 41 59;
- --color-secondary-0: 11 12 12;
- --color-secondary-50: 24 23 23;
- --color-secondary-100: 39 38 38;
- --color-secondary-200: 63 64 64;
- --color-secondary-300: 81 82 82;
- --color-secondary-400: 94 95 95;
- --color-secondary-500: 114 115 115;
- --color-secondary-600: 175 176 176;
- --color-secondary-700: 219 219 219;
- --color-secondary-800: 231 232 232;
- --color-secondary-900: 241 242 242;
- --color-secondary-950: 254 255 255;
- --color-tertiary-0: 84 49 18;
- --color-tertiary-50: 108 61 19;
- --color-tertiary-100: 130 73 23;
- --color-tertiary-200: 180 98 26;
- --color-tertiary-300: 215 117 31;
- --color-tertiary-400: 231 129 40;
- --color-tertiary-500: 251 157 75;
- --color-tertiary-600: 253 180 116;
- --color-tertiary-700: 254 209 170;
- --color-tertiary-800: 255 233 213;
- --color-tertiary-900: 255 242 229;
- --color-tertiary-950: 255 250 245;
- --color-error-0: 83 19 19;
- --color-error-50: 127 29 29;
- --color-error-100: 153 27 27;
- --color-error-200: 185 28 28;
- --color-error-300: 220 38 38;
- --color-error-400: 230 53 53;
- --color-error-500: 239 68 68;
- --color-error-600: 248 113 113;
- --color-error-700: 252 165 165;
- --color-error-800: 254 202 202;
- --color-error-900: 254 226 226;
- --color-error-950: 254 233 233;
- --color-success-0: 27 50 36;
- --color-success-50: 20 83 45;
- --color-success-100: 22 101 52;
- --color-success-200: 32 111 62;
- --color-success-300: 42 121 72;
- --color-success-400: 52 131 82;
- --color-success-500: 72 151 102;
- --color-success-600: 102 181 132;
- --color-success-700: 132 211 162;
- --color-success-800: 162 241 192;
- --color-success-900: 202 255 232;
- --color-success-950: 228 255 244;
- --color-warning-0: 84 45 18;
- --color-warning-50: 108 56 19;
- --color-warning-100: 130 68 23;
- --color-warning-200: 180 90 26;
- --color-warning-300: 215 108 31;
- --color-warning-400: 231 120 40;
- --color-warning-500: 251 149 75;
- --color-warning-600: 253 173 116;
- --color-warning-700: 254 205 170;
- --color-warning-800: 255 231 213;
- --color-warning-900: 255 249 245;
- --color-warning-950: 255 253 251;
- --color-info-0: 3 38 56;
- --color-info-50: 5 64 93;
- --color-info-100: 7 90 131;
- --color-info-200: 9 115 168;
- --color-info-300: 11 141 205;
- --color-info-400: 13 166 242;
- --color-info-500: 50 180 244;
- --color-info-600: 87 194 246;
- --color-info-700: 124 207 248;
- --color-info-800: 162 221 250;
- --color-info-900: 199 235 252;
- --color-info-950: 236 248 254;
- --color-typography-0: 23 23 23;
- --color-typography-50: 38 38 39;
- --color-typography-100: 64 64 64;
- --color-typography-200: 82 82 82;
- --color-typography-300: 115 115 115;
- --color-typography-400: 140 140 140;
- --color-typography-500: 163 163 163;
- --color-typography-600: 212 212 212;
- --color-typography-700: 219 219 220;
- --color-typography-800: 229 229 229;
- --color-typography-900: 245 245 245;
- --color-typography-950: 254 254 255;
- --color-outline-0: 26 23 23;
- --color-outline-50: 39 38 36;
- --color-outline-100: 65 65 65;
- --color-outline-200: 83 82 82;
- --color-outline-300: 115 116 116;
- --color-outline-400: 140 141 141;
- --color-outline-500: 165 163 163;
- --color-outline-600: 211 211 211;
- --color-outline-700: 221 220 219;
- --color-outline-800: 230 230 230;
- --color-outline-900: 243 243 243;
- --color-outline-950: 253 254 254;
- --color-background-0: 18 18 18;
- --color-background-50: 39 38 37;
- --color-background-100: 65 64 64;
- --color-background-200: 83 82 82;
- --color-background-300: 116 116 116;
- --color-background-400: 142 142 142;
- --color-background-500: 162 163 163;
- --color-background-600: 213 212 212;
- --color-background-700: 220 219 219;
- --color-background-800: 242 241 241;
- --color-background-900: 246 246 246;
- --color-background-950: 254 254 254;
- --color-background-error: 66 43 43;
- --color-background-warning: 65 47 35;
- --color-background-success: 28 43 33;
- --color-background-muted: 51 51 51;
- --color-background-info: 26 40 46;
- --color-indicator-primary: 247 247 247;
- --color-indicator-info: 161 199 245;
- --color-indicator-error: 232 70 69;
- }
-
- :root.light {
- --color-primary-0: 248 250 252;
- --color-primary-50: 241 245 249;
- --color-primary-100: 226 232 240;
- --color-primary-200: 203 213 225;
- --color-primary-300: 148 163 184;
- --color-primary-400: 100 116 139;
- --color-primary-500: 71 85 105;
- --color-primary-600: 51 65 85;
- --color-primary-700: 45 55 72;
- --color-primary-800: 39 49 66;
- --color-primary-900: 30 41 59;
- --color-primary-950: 15 23 42;
- --color-secondary-0: 254 255 255;
- --color-secondary-50: 241 242 242;
- --color-secondary-100: 231 232 232;
- --color-secondary-200: 219 219 219;
- --color-secondary-300: 175 176 176;
- --color-secondary-400: 114 115 115;
- --color-secondary-500: 94 95 95;
- --color-secondary-600: 81 82 82;
- --color-secondary-700: 63 64 64;
- --color-secondary-800: 39 38 38;
- --color-secondary-900: 24 23 23;
- --color-secondary-950: 11 12 12;
- --color-tertiary-0: 255 250 245;
- --color-tertiary-50: 255 242 229;
- --color-tertiary-100: 255 233 213;
- --color-tertiary-200: 254 209 170;
- --color-tertiary-300: 253 180 116;
- --color-tertiary-400: 251 157 75;
- --color-tertiary-500: 231 129 40;
- --color-tertiary-600: 215 117 31;
- --color-tertiary-700: 180 98 26;
- --color-tertiary-800: 130 73 23;
- --color-tertiary-900: 108 61 19;
- --color-tertiary-950: 84 49 18;
- --color-error-0: 254 233 233;
- --color-error-50: 254 226 226;
- --color-error-100: 254 202 202;
- --color-error-200: 252 165 165;
- --color-error-300: 248 113 113;
- --color-error-400: 239 68 68;
- --color-error-500: 230 53 53;
- --color-error-600: 220 38 38;
- --color-error-700: 185 28 28;
- --color-error-800: 153 27 27;
- --color-error-900: 127 29 29;
- --color-error-950: 83 19 19;
- --color-success-0: 228 255 244;
- --color-success-50: 202 255 232;
- --color-success-100: 162 241 192;
- --color-success-200: 132 211 162;
- --color-success-300: 102 181 132;
- --color-success-400: 72 151 102;
- --color-success-500: 52 131 82;
- --color-success-600: 42 121 72;
- --color-success-700: 32 111 62;
- --color-success-800: 22 101 52;
- --color-success-900: 20 83 45;
- --color-success-950: 27 50 36;
- --color-warning-0: 255 253 251;
- --color-warning-50: 255 249 245;
- --color-warning-100: 255 231 213;
- --color-warning-200: 254 205 170;
- --color-warning-300: 253 173 116;
- --color-warning-400: 251 149 75;
- --color-warning-500: 231 120 40;
- --color-warning-600: 215 108 31;
- --color-warning-700: 180 90 26;
- --color-warning-800: 130 68 23;
- --color-warning-900: 108 56 19;
- --color-warning-950: 84 45 18;
- --color-info-0: 236 248 254;
- --color-info-50: 199 235 252;
- --color-info-100: 162 221 250;
- --color-info-200: 124 207 248;
- --color-info-300: 87 194 246;
- --color-info-400: 50 180 244;
- --color-info-500: 13 166 242;
- --color-info-600: 11 141 205;
- --color-info-700: 9 115 168;
- --color-info-800: 7 90 131;
- --color-info-900: 5 64 93;
- --color-info-950: 3 38 56;
- --color-typography-0: 254 254 255;
- --color-typography-50: 245 245 245;
- --color-typography-100: 229 229 229;
- --color-typography-200: 219 219 220;
- --color-typography-300: 212 212 212;
- --color-typography-400: 163 163 163;
- --color-typography-500: 140 140 140;
- --color-typography-600: 115 115 115;
- --color-typography-700: 82 82 82;
- --color-typography-800: 64 64 64;
- --color-typography-900: 38 38 39;
- --color-typography-950: 23 23 23;
- --color-outline-0: 253 254 254;
- --color-outline-50: 243 243 243;
- --color-outline-100: 230 230 230;
- --color-outline-200: 221 220 219;
- --color-outline-300: 211 211 211;
- --color-outline-400: 165 163 163;
- --color-outline-500: 140 141 141;
- --color-outline-600: 115 116 116;
- --color-outline-700: 83 82 82;
- --color-outline-800: 65 65 65;
- --color-outline-900: 39 38 36;
- --color-outline-950: 26 23 23;
- --color-background-0: 255 255 255;
- --color-background-50: 246 246 246;
- --color-background-100: 242 241 241;
- --color-background-200: 220 219 219;
- --color-background-300: 213 212 212;
- --color-background-400: 162 163 163;
- --color-background-500: 142 142 142;
- --color-background-600: 116 116 116;
- --color-background-700: 83 82 82;
- --color-background-800: 65 64 64;
- --color-background-900: 39 38 37;
- --color-background-950: 24 23 24;
- --color-background-error: 254 241 241;
- --color-background-warning: 255 244 235;
- --color-background-success: 237 252 242;
- --color-background-muted: 247 248 247;
- --color-background-info: 235 248 254;
- --color-indicator-primary: 55 55 55;
- --color-indicator-info: 83 153 236;
- --color-indicator-error: 185 28 28;
- }
-}
-
-/* ─── Tailwind v4 utilities for the legacy gluestack v2 token scales ── */
-@theme inline {
- --color-primary-0: rgb(var(--color-primary-0));
- --color-primary-50: rgb(var(--color-primary-50));
- --color-primary-100: rgb(var(--color-primary-100));
- --color-primary-200: rgb(var(--color-primary-200));
- --color-primary-300: rgb(var(--color-primary-300));
- --color-primary-400: rgb(var(--color-primary-400));
- --color-primary-500: rgb(var(--color-primary-500));
- --color-primary-600: rgb(var(--color-primary-600));
- --color-primary-700: rgb(var(--color-primary-700));
- --color-primary-800: rgb(var(--color-primary-800));
- --color-primary-900: rgb(var(--color-primary-900));
- --color-primary-950: rgb(var(--color-primary-950));
- --color-secondary-0: rgb(var(--color-secondary-0));
- --color-secondary-50: rgb(var(--color-secondary-50));
- --color-secondary-100: rgb(var(--color-secondary-100));
- --color-secondary-200: rgb(var(--color-secondary-200));
- --color-secondary-300: rgb(var(--color-secondary-300));
- --color-secondary-400: rgb(var(--color-secondary-400));
- --color-secondary-500: rgb(var(--color-secondary-500));
- --color-secondary-600: rgb(var(--color-secondary-600));
- --color-secondary-700: rgb(var(--color-secondary-700));
- --color-secondary-800: rgb(var(--color-secondary-800));
- --color-secondary-900: rgb(var(--color-secondary-900));
- --color-secondary-950: rgb(var(--color-secondary-950));
- --color-tertiary-0: rgb(var(--color-tertiary-0));
- --color-tertiary-50: rgb(var(--color-tertiary-50));
- --color-tertiary-100: rgb(var(--color-tertiary-100));
- --color-tertiary-200: rgb(var(--color-tertiary-200));
- --color-tertiary-300: rgb(var(--color-tertiary-300));
- --color-tertiary-400: rgb(var(--color-tertiary-400));
- --color-tertiary-500: rgb(var(--color-tertiary-500));
- --color-tertiary-600: rgb(var(--color-tertiary-600));
- --color-tertiary-700: rgb(var(--color-tertiary-700));
- --color-tertiary-800: rgb(var(--color-tertiary-800));
- --color-tertiary-900: rgb(var(--color-tertiary-900));
- --color-tertiary-950: rgb(var(--color-tertiary-950));
- --color-error-0: rgb(var(--color-error-0));
- --color-error-50: rgb(var(--color-error-50));
- --color-error-100: rgb(var(--color-error-100));
- --color-error-200: rgb(var(--color-error-200));
- --color-error-300: rgb(var(--color-error-300));
- --color-error-400: rgb(var(--color-error-400));
- --color-error-500: rgb(var(--color-error-500));
- --color-error-600: rgb(var(--color-error-600));
- --color-error-700: rgb(var(--color-error-700));
- --color-error-800: rgb(var(--color-error-800));
- --color-error-900: rgb(var(--color-error-900));
- --color-error-950: rgb(var(--color-error-950));
- --color-success-0: rgb(var(--color-success-0));
- --color-success-50: rgb(var(--color-success-50));
- --color-success-100: rgb(var(--color-success-100));
- --color-success-200: rgb(var(--color-success-200));
- --color-success-300: rgb(var(--color-success-300));
- --color-success-400: rgb(var(--color-success-400));
- --color-success-500: rgb(var(--color-success-500));
- --color-success-600: rgb(var(--color-success-600));
- --color-success-700: rgb(var(--color-success-700));
- --color-success-800: rgb(var(--color-success-800));
- --color-success-900: rgb(var(--color-success-900));
- --color-success-950: rgb(var(--color-success-950));
- --color-warning-0: rgb(var(--color-warning-0));
- --color-warning-50: rgb(var(--color-warning-50));
- --color-warning-100: rgb(var(--color-warning-100));
- --color-warning-200: rgb(var(--color-warning-200));
- --color-warning-300: rgb(var(--color-warning-300));
- --color-warning-400: rgb(var(--color-warning-400));
- --color-warning-500: rgb(var(--color-warning-500));
- --color-warning-600: rgb(var(--color-warning-600));
- --color-warning-700: rgb(var(--color-warning-700));
- --color-warning-800: rgb(var(--color-warning-800));
- --color-warning-900: rgb(var(--color-warning-900));
- --color-warning-950: rgb(var(--color-warning-950));
- --color-info-0: rgb(var(--color-info-0));
- --color-info-50: rgb(var(--color-info-50));
- --color-info-100: rgb(var(--color-info-100));
- --color-info-200: rgb(var(--color-info-200));
- --color-info-300: rgb(var(--color-info-300));
- --color-info-400: rgb(var(--color-info-400));
- --color-info-500: rgb(var(--color-info-500));
- --color-info-600: rgb(var(--color-info-600));
- --color-info-700: rgb(var(--color-info-700));
- --color-info-800: rgb(var(--color-info-800));
- --color-info-900: rgb(var(--color-info-900));
- --color-info-950: rgb(var(--color-info-950));
- --color-typography-0: rgb(var(--color-typography-0));
- --color-typography-50: rgb(var(--color-typography-50));
- --color-typography-100: rgb(var(--color-typography-100));
- --color-typography-200: rgb(var(--color-typography-200));
- --color-typography-300: rgb(var(--color-typography-300));
- --color-typography-400: rgb(var(--color-typography-400));
- --color-typography-500: rgb(var(--color-typography-500));
- --color-typography-600: rgb(var(--color-typography-600));
- --color-typography-700: rgb(var(--color-typography-700));
- --color-typography-800: rgb(var(--color-typography-800));
- --color-typography-900: rgb(var(--color-typography-900));
- --color-typography-950: rgb(var(--color-typography-950));
- --color-outline-0: rgb(var(--color-outline-0));
- --color-outline-50: rgb(var(--color-outline-50));
- --color-outline-100: rgb(var(--color-outline-100));
- --color-outline-200: rgb(var(--color-outline-200));
- --color-outline-300: rgb(var(--color-outline-300));
- --color-outline-400: rgb(var(--color-outline-400));
- --color-outline-500: rgb(var(--color-outline-500));
- --color-outline-600: rgb(var(--color-outline-600));
- --color-outline-700: rgb(var(--color-outline-700));
- --color-outline-800: rgb(var(--color-outline-800));
- --color-outline-900: rgb(var(--color-outline-900));
- --color-outline-950: rgb(var(--color-outline-950));
- --color-background-0: rgb(var(--color-background-0));
- --color-background-50: rgb(var(--color-background-50));
- --color-background-100: rgb(var(--color-background-100));
- --color-background-200: rgb(var(--color-background-200));
- --color-background-300: rgb(var(--color-background-300));
- --color-background-400: rgb(var(--color-background-400));
- --color-background-500: rgb(var(--color-background-500));
- --color-background-600: rgb(var(--color-background-600));
- --color-background-700: rgb(var(--color-background-700));
- --color-background-800: rgb(var(--color-background-800));
- --color-background-900: rgb(var(--color-background-900));
- --color-background-950: rgb(var(--color-background-950));
- --color-background-error: rgb(var(--color-background-error));
- --color-background-warning: rgb(var(--color-background-warning));
- --color-background-success: rgb(var(--color-background-success));
- --color-background-muted: rgb(var(--color-background-muted));
- --color-background-info: rgb(var(--color-background-info));
- --color-indicator-primary: rgb(var(--color-indicator-primary));
- --color-indicator-info: rgb(var(--color-indicator-info));
- --color-indicator-error: rgb(var(--color-indicator-error));
-}
-
-/* ─── Non-color theme extensions (ported from tailwind.config.js) ── */
-@theme {
- --font-roboto: Roboto, sans-serif;
- --font-weight-extrablack: 950;
- --text-2xs: 10px;
- --color-typography-white: #ffffff;
- --color-typography-gray: #d4d4d4;
- --color-typography-black: #181718;
- --color-background-light: #fbfbfb;
- --color-background-dark: #181719;
- --shadow-hard-1: -2px 2px 8px 0px rgba(38, 38, 38, 0.2);
- --shadow-hard-2: 0px 3px 10px 0px rgba(38, 38, 38, 0.2);
- --shadow-hard-3: 2px 2px 8px 0px rgba(38, 38, 38, 0.2);
- --shadow-hard-4: 0px -3px 10px 0px rgba(38, 38, 38, 0.2);
- --shadow-hard-5: 0px 2px 10px 0px rgba(38, 38, 38, 0.1);
- --shadow-soft-1: 0px 0px 10px rgba(38, 38, 38, 0.1);
- --shadow-soft-2: 0px 0px 20px rgba(38, 38, 38, 0.2);
- --shadow-soft-3: 0px 0px 30px rgba(38, 38, 38, 0.1);
- --shadow-soft-4: 0px 0px 40px rgba(38, 38, 38, 0.1);
-}
-
-/* Web-only skeleton loading animation */
-@keyframes skeleton-pulse {
- 0%, 100% {
- opacity: 1;
- }
- 50% {
- opacity: 0.75;
- }
-}
+@import './theme-tokens.css';
+
+/* Native dark mode: the colour-scheme media query, which react-native-css drives from Appearance.
+ GluestackUIProvider calls Appearance.setColorScheme() for explicit light/dark choices, so this
+ covers a user-selected theme as well as the system one, and it is the same query the tokens flip on.
+
+ There is deliberately no theme class on native. The provider used to put one on the View wrapping
+ the whole app, and any className on that wrapper stops every native ScrollView beneath it from
+ starting a pan: a drag on plain content did nothing app-wide, and only a Pressable handing the
+ responder off could scroll anything. Keep this selector-free — react-native-css silently drops
+ rules whose selectors it cannot evaluate, which takes dark mode with them. */
+@custom-variant dark (@media (prefers-color-scheme: dark));
diff --git a/global.web.css b/global.web.css
new file mode 100644
index 0000000..cf123fb
--- /dev/null
+++ b/global.web.css
@@ -0,0 +1,12 @@
+@import 'tailwindcss/theme.css' layer(theme);
+@import 'tailwindcss/preflight.css' layer(base);
+@import 'tailwindcss/utilities.css';
+@import 'nativewind/theme';
+
+@import './theme-tokens.css';
+
+/* Web dark mode: the .dark class GluestackUIProvider puts on (see index.web.tsx). It is
+ always present — explicit modes set .dark/.light directly, and system mode sets one from the
+ media query — so the class alone is authoritative. Matching prefers-color-scheme here as well
+ would make an explicit Light choice render dark utilities on a dark-themed OS. */
+@custom-variant dark (&:where(.dark, .dark *));
diff --git a/gluestack-ui.config.json b/gluestack-ui.config.json
index ca842a1..7a9848a 100644
--- a/gluestack-ui.config.json
+++ b/gluestack-ui.config.json
@@ -1,6 +1,5 @@
{
"tailwind": {
- "config": "tailwind.config.js",
"css": "global.css"
},
"app": {
diff --git a/src/app/(app)/chat.tsx b/src/app/(app)/chat.tsx
index 561d581..f487ed6 100644
--- a/src/app/(app)/chat.tsx
+++ b/src/app/(app)/chat.tsx
@@ -35,7 +35,12 @@ function ChannelRow({ channel, onPress }: { channel: ChatChannelResultData; onPr
);
}
- const isIncident = channel.ChannelType === ChatChannelType.Incident || channel.ChannelType === ChatChannelType.IncidentLane || channel.ChannelType === ChatChannelType.IncidentCommand;
+ const isIncident =
+ channel.ChannelType === ChatChannelType.Incident ||
+ channel.ChannelType === ChatChannelType.IncidentLane ||
+ channel.ChannelType === ChatChannelType.IncidentCommand ||
+ channel.ChannelType === ChatChannelType.IncidentLeads ||
+ channel.ChannelType === ChatChannelType.IncidentDispatch;
const Icon = channel.ChannelType === ChatChannelType.Chatbot ? Sparkles : isIncident ? Network : Users;
return (
diff --git a/src/app/(app)/command.tsx b/src/app/(app)/command.tsx
index a11bd89..90c43c4 100644
--- a/src/app/(app)/command.tsx
+++ b/src/app/(app)/command.tsx
@@ -680,11 +680,11 @@ export default function CommandBoard() {
);
diff --git a/src/components/chat/__tests__/chat-utils.test.ts b/src/components/chat/__tests__/chat-utils.test.ts
index f9aff7e..78b60e3 100644
--- a/src/components/chat/__tests__/chat-utils.test.ts
+++ b/src/components/chat/__tests__/chat-utils.test.ts
@@ -3,7 +3,7 @@ import { type TFunction } from 'i18next';
import { ChatChannelType, type ChatChannelResultData } from '@/models/v4/chat';
-import { copyToClipboard, getChannelDisplayName, getImageMimeType, hasLink, linkifySegments } from '../chat-utils';
+import { copyToClipboard, getChannelDisplayName, getImageMimeType, groupChannels, hasLink, linkifySegments } from '../chat-utils';
jest.mock('expo-clipboard', () => ({ setStringAsync: jest.fn() }));
@@ -34,6 +34,31 @@ describe('chat-utils', () => {
});
});
+ describe('groupChannels', () => {
+ it('buckets every incident-scoped channel type into the incidents section', () => {
+ const grouped = groupChannels([
+ buildChannel({ ChatChannelId: 'a', ChannelType: ChatChannelType.Incident }),
+ buildChannel({ ChatChannelId: 'b', ChannelType: ChatChannelType.IncidentLane }),
+ buildChannel({ ChatChannelId: 'c', ChannelType: ChatChannelType.IncidentCommand }),
+ buildChannel({ ChatChannelId: 'd', ChannelType: ChatChannelType.IncidentLeads }),
+ buildChannel({ ChatChannelId: 'e', ChannelType: ChatChannelType.IncidentDispatch }),
+ ]);
+ expect(grouped.incidents.map((c) => c.ChatChannelId).sort()).toEqual(['a', 'b', 'c', 'd', 'e']);
+ expect(grouped.channels).toHaveLength(0);
+ });
+
+ it('buckets the unit dispatch line into the channels section', () => {
+ const grouped = groupChannels([buildChannel({ ChatChannelId: 'ud', ChannelType: ChatChannelType.UnitDispatch })]);
+ expect(grouped.channels.map((c) => c.ChatChannelId)).toEqual(['ud']);
+ expect(grouped.incidents).toHaveLength(0);
+ });
+
+ it('skips archived channels', () => {
+ const grouped = groupChannels([buildChannel({ ChatChannelId: 'x', ChannelType: ChatChannelType.Incident, IsArchived: true })]);
+ expect(grouped.incidents).toHaveLength(0);
+ });
+ });
+
describe('getImageMimeType', () => {
it('prefers the picker asset mimeType when available', () => {
expect(getImageMimeType('file:///photos/photo.jpg', 'image/png')).toBe('image/png');
diff --git a/src/components/chat/chat-utils.ts b/src/components/chat/chat-utils.ts
index 3d0476b..f2a939e 100644
--- a/src/components/chat/chat-utils.ts
+++ b/src/components/chat/chat-utils.ts
@@ -23,6 +23,8 @@ export function groupChannels(channels: ChatChannelResultData[]): GroupedChannel
case ChatChannelType.Incident:
case ChatChannelType.IncidentLane:
case ChatChannelType.IncidentCommand:
+ case ChatChannelType.IncidentLeads:
+ case ChatChannelType.IncidentDispatch:
grouped.incidents.push(channel);
break;
case ChatChannelType.Chatbot:
diff --git a/src/components/ui/gluestack-ui-provider/index.tsx b/src/components/ui/gluestack-ui-provider/index.tsx
index 8d5f01b..c545cae 100644
--- a/src/components/ui/gluestack-ui-provider/index.tsx
+++ b/src/components/ui/gluestack-ui-provider/index.tsx
@@ -1,24 +1,24 @@
'use client';
import { OverlayProvider } from '@gluestack-ui/core/overlay/creator';
import { ToastProvider } from '@gluestack-ui/core/toast/creator';
-import React, { useEffect } from 'react';
-import { Appearance, useColorScheme, View, type ViewProps } from 'react-native';
+import React, { useLayoutEffect } from 'react';
+import { Appearance, View, type ViewProps } from 'react-native';
export type ModeType = 'light' | 'dark' | 'system';
export function GluestackUIProvider({ mode = 'light', ...props }: { mode?: ModeType; children?: React.ReactNode; style?: ViewProps['style'] }) {
- // Tokens (--color-*) flip through the prefers-color-scheme media query,
- // which react-native-css drives from Appearance. The className wrapper
- // drives the class-based `dark:` variant (see @custom-variant in global.css).
- const osScheme = useColorScheme();
- const resolvedScheme: 'light' | 'dark' = mode === 'system' ? (osScheme === 'dark' ? 'dark' : 'light') : mode;
-
- useEffect(() => {
+ // Both the tokens (--color-*) and the `dark:` variant flip through the prefers-color-scheme media
+ // query, which react-native-css drives from Appearance — hence the override below.
+ useLayoutEffect(() => {
Appearance.setColorScheme(mode === 'system' ? 'unspecified' : mode);
}, [mode]);
+ // This View deliberately carries NO className. It wraps the entire app, and react-native-css wraps
+ // any classed component in an element of its own; with a class here every native ScrollView beneath
+ // it stopped responding to a plain drag app-wide — only a Pressable taking the JS responder could
+ // scroll anything. Style it inline if it ever needs styling, and see the dark variant in global.css.
return (
-
+
{props.children}
diff --git a/src/components/ui/input/index.tsx b/src/components/ui/input/index.tsx
index a9fa059..5f2157d 100644
--- a/src/components/ui/input/index.tsx
+++ b/src/components/ui/input/index.tsx
@@ -6,6 +6,8 @@ import { styled } from 'nativewind';
import React from 'react';
import { Pressable, TextInput, View } from 'react-native';
+import { useTextFieldVerticalFix } from '../text-field-metrics';
+
const SCOPE = 'INPUT';
const StyledUIIcon = styled(UIIcon, { className: 'style' });
@@ -60,7 +62,7 @@ const inputSlotStyle = tva({
});
const inputFieldStyle = tva({
- base: 'flex-1 text-typography-900 py-0 px-3 placeholder:text-typography-500 h-full ios:leading-[0px] web:cursor-text web:data-[disabled=true]:cursor-not-allowed',
+ base: 'flex-1 text-typography-900 py-0 px-3 placeholder:text-typography-500 h-full web:cursor-text web:data-[disabled=true]:cursor-not-allowed',
parentVariants: {
variant: {
@@ -136,8 +138,9 @@ const InputSlot = React.forwardRef, IInp
type IInputFieldProps = React.ComponentProps & VariantProps & { className?: string };
-const InputField = React.forwardRef, IInputFieldProps>(function InputField({ className, ...props }, ref) {
+const InputField = React.forwardRef, IInputFieldProps>(function InputField({ className, style, ...props }, ref) {
const { variant: parentVariant, size: parentSize } = useStyleContext(SCOPE);
+ const verticalFix = useTextFieldVerticalFix(parentSize);
return (
, II
},
class: className,
})}
+ style={[verticalFix, style]}
/>
);
});
diff --git a/src/components/ui/select/index.tsx b/src/components/ui/select/index.tsx
index d8dae42..eee3c38 100644
--- a/src/components/ui/select/index.tsx
+++ b/src/components/ui/select/index.tsx
@@ -9,6 +9,8 @@ import { styled } from 'nativewind';
import React from 'react';
import { Pressable, TextInput, View } from 'react-native';
+// Mirrors the Input component: the trigger's fixed height clips `h-full` text on Android the same way.
+import { useTextFieldVerticalFix } from '../text-field-metrics';
import {
Actionsheet,
ActionsheetBackdrop,
@@ -67,7 +69,7 @@ const selectTriggerStyle = tva({
});
const selectInputStyle = tva({
- base: 'py-auto px-3 placeholder:text-typography-500 web:w-full h-full text-typography-900 pointer-events-none web:outline-none ios:leading-[0px]',
+ base: 'py-auto px-3 placeholder:text-typography-500 web:w-full h-full text-typography-900 pointer-events-none web:outline-none',
parentVariants: {
size: {
xl: 'text-xl',
@@ -146,8 +148,9 @@ const SelectTrigger = React.forwardRef & React.ComponentProps & { className?: string };
-const SelectInput = React.forwardRef, ISelectInputProps>(function SelectInput({ className, ...props }, ref) {
+const SelectInput = React.forwardRef, ISelectInputProps>(function SelectInput({ className, style, ...props }, ref) {
const { size: parentSize, variant: parentVariant } = useStyleContext();
+ const verticalFix = useTextFieldVerticalFix(parentSize);
return (
,
})}
ref={ref}
{...props}
+ style={[verticalFix, style]}
/>
);
});
diff --git a/src/components/ui/text-field-metrics.ts b/src/components/ui/text-field-metrics.ts
new file mode 100644
index 0000000..cd66553
--- /dev/null
+++ b/src/components/ui/text-field-metrics.ts
@@ -0,0 +1,36 @@
+import React from 'react';
+import { Platform } from 'react-native';
+
+/**
+ * Android field metrics, applied from JS because the class layer cannot express them correctly.
+ *
+ * Measured on device, each case a real Input/InputField:
+ * - the class `h-full` (height: 100%) resolves taller than the fixed-height parent on Android, and
+ * the parent's overflow-hidden then clips the top of the glyphs;
+ * - an explicit pixel height matching the parent renders correctly;
+ * - overriding only the lineHeight, at either the class or the style layer, does not help;
+ * - lineHeight 0 (what iOS uses) hides Android text completely, and a later `undefined` does not
+ * clear the value the size class sets.
+ *
+ * So Android gets a concrete height plus a lineHeight near the font size, and drops the extra font
+ * padding. iOS keeps the zero lineHeight that upstream applied through `ios:leading-[0px]`; that class
+ * is gone from the base style so the value can be chosen per platform here.
+ */
+const ANDROID_FIELD_METRICS: Record = {
+ sm: { height: 36, lineHeight: 18 },
+ md: { height: 40, lineHeight: 20 },
+ lg: { height: 44, lineHeight: 22 },
+ xl: { height: 48, lineHeight: 25 },
+};
+
+export const useTextFieldVerticalFix = (size: string | undefined) =>
+ React.useMemo(() => {
+ if (Platform.OS === 'ios') {
+ return { lineHeight: 0 } as const;
+ }
+ if (Platform.OS === 'android') {
+ const metrics = ANDROID_FIELD_METRICS[size ?? 'md'] ?? ANDROID_FIELD_METRICS.md;
+ return { height: metrics.height, lineHeight: metrics.lineHeight, includeFontPadding: false, textAlignVertical: 'center' } as const;
+ }
+ return undefined;
+ }, [size]);
diff --git a/src/hooks/use-signalr-lifecycle.ts b/src/hooks/use-signalr-lifecycle.ts
index 7d20b8b..ad37672 100644
--- a/src/hooks/use-signalr-lifecycle.ts
+++ b/src/hooks/use-signalr-lifecycle.ts
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef } from 'react';
import { logger } from '@/lib/logging';
+import { useCommandStore } from '@/stores/command/store';
import { useSignalRStore } from '@/stores/signalr/signalr-store';
import { useAppLifecycle } from './use-app-lifecycle';
@@ -132,6 +133,22 @@ export function useSignalRLifecycle({ isSignedIn, hasInitialized }: UseSignalRLi
});
}
});
+
+ // The hubs do not replay what was pushed while the app was away, so anything another user
+ // changed on a command board during the gap would stay invisible until the next event.
+ // Reconnecting only restores the feed; this backfills what it missed.
+ // connectUpdateHub swallows its own errors, so a fulfilled result alone does not mean the
+ // hub is up — the store's connected flag is the real signal.
+ if (results[0].status === 'fulfilled' && useSignalRStore.getState().isUpdateHubConnected) {
+ try {
+ await useCommandStore.getState().syncFromServer();
+ } catch (syncError) {
+ logger.warn({
+ message: 'Failed to resync command boards after app resume',
+ context: { error: syncError },
+ });
+ }
+ }
} catch (error) {
logger.error({
message: 'Unexpected error during SignalR reconnect on app resume',
diff --git a/src/lib/theme-styles.ts b/src/lib/theme-styles.ts
new file mode 100644
index 0000000..7a80cba
--- /dev/null
+++ b/src/lib/theme-styles.ts
@@ -0,0 +1,9 @@
+/**
+ * Native stylesheet entry. Pairs with theme-styles.web.ts, which loads global.web.css instead.
+ *
+ * The two entries exist because the `dark:` variant cannot be shared: native drives it from the
+ * colour-scheme media query (it has no theme class, and adding one to the app-wide wrapper View
+ * breaks native scrolling), while web drives it from the .dark class on . Tokens are shared
+ * via theme-tokens.css.
+ */
+import '../../global.css';
diff --git a/src/lib/theme-styles.web.ts b/src/lib/theme-styles.web.ts
new file mode 100644
index 0000000..3f1d528
--- /dev/null
+++ b/src/lib/theme-styles.web.ts
@@ -0,0 +1,7 @@
+/**
+ * Web stylesheet entry. Pairs with theme-styles.ts, which loads global.css on native.
+ *
+ * Web keys the `dark:` variant off the .dark class GluestackUIProvider maintains on , so an
+ * explicit Light choice stays light on a dark-themed OS.
+ */
+import '../../global.web.css';
diff --git a/src/models/v4/chat/chatEnums.ts b/src/models/v4/chat/chatEnums.ts
index 11916f0..ce9075b 100644
--- a/src/models/v4/chat/chatEnums.ts
+++ b/src/models/v4/chat/chatEnums.ts
@@ -17,6 +17,8 @@ export enum ChatChannelType {
IncidentLeads = 9,
/** The incident's line to the dispatch desk: everyone on the incident, plus every authorized dispatcher. */
IncidentDispatch = 10,
+ /** A unit's standing line to the dispatch desk: the unit identity plus every authorized dispatcher. Department-wide, not call-scoped. */
+ UnitDispatch = 11,
}
/** Message type (ChatMessageResultData.MessageType). */
diff --git a/src/stores/signalr/__tests__/incident-command-realtime.test.ts b/src/stores/signalr/__tests__/incident-command-realtime.test.ts
new file mode 100644
index 0000000..5dbefd9
--- /dev/null
+++ b/src/stores/signalr/__tests__/incident-command-realtime.test.ts
@@ -0,0 +1,268 @@
+/**
+ * Board-to-board realtime: when any user on the incident (Staging, Ops, Dispatch) changes the board,
+ * Core writes a command log entry, which pushes `incidentCommandUpdated` to the department group.
+ *
+ * The eventing worker sends the call id as a BARE STRING — `SendAsync("incidentCommandUpdated", id)`
+ * with `ItemId = CallId.ToString()` — so the payload must be read as a string, not only as an object.
+ * When it was read as an object only, every real event missed the targeted refresh and fell through to
+ * the debounced full-bundle sync: the board still caught up, but seconds later and after re-fetching
+ * every open incident. These pin the parsing and the routing that follows from it.
+ *
+ * Every factory below is self-contained — a factory that closes over a module-level const runs before
+ * that const is initialised and silently yields undefined.
+ */
+jest.mock('@/services/signalr.service', () => {
+ const mockInstance = {
+ connectToHubWithEventingUrl: jest.fn().mockResolvedValue(undefined),
+ disconnectFromHub: jest.fn().mockResolvedValue(undefined),
+ invoke: jest.fn().mockResolvedValue(undefined),
+ on: jest.fn(),
+ off: jest.fn(),
+ removeAllListeners: jest.fn(),
+ connectToHub: jest.fn().mockResolvedValue(undefined),
+ disconnectAll: jest.fn().mockResolvedValue(undefined),
+ };
+ class MockSignalRService {
+ static readonly HUB_DISCONNECTED_EVENT = '__hubDisconnected';
+ static readonly HUB_RECONNECTED_EVENT = '__hubReconnected';
+ }
+ return { signalRService: mockInstance, SignalRService: MockSignalRService, default: mockInstance };
+});
+
+jest.mock('../../app/core-store', () => {
+ const state = { config: { EventingUrl: 'https://eventing.example.com/' } };
+ const store = () => state;
+ store.getState = () => state;
+ store.subscribe = jest.fn();
+ store.setState = jest.fn();
+ return { useCoreStore: store };
+});
+
+jest.mock('../../security/store', () => {
+ const state = { rights: { DepartmentId: '123' } };
+ const store = { getState: () => state };
+ return { securityStore: store, useSecurityStore: store };
+});
+
+jest.mock('../../command/store', () => {
+ const state = {
+ boards: {} as Record,
+ refreshBoard: jest.fn(() => Promise.resolve()),
+ syncFromServer: jest.fn(() => Promise.resolve()),
+ };
+ return { useCommandStore: { getState: () => state, __state: state } };
+});
+
+jest.mock('@/lib/logging', () => ({
+ logger: { info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn(), trace: jest.fn(), fatal: jest.fn() },
+}));
+
+jest.mock('@/lib/env', () => ({
+ Env: { CHANNEL_HUB_NAME: 'eventingHub', REALTIME_GEO_HUB_NAME: 'geolocationHub' },
+}));
+
+jest.mock('@/lib', () => ({
+ useAuthStore: { getState: jest.fn(() => ({ accessToken: 'mock-token' })) },
+}));
+
+import { signalRService } from '@/services/signalr.service';
+
+import { useSignalRStore } from '../signalr-store';
+
+interface CommandStoreMock {
+ useCommandStore: {
+ __state: {
+ boards: Record;
+ refreshBoard: jest.Mock;
+ syncFromServer: jest.Mock;
+ };
+ };
+}
+
+const commandState = (jest.requireMock('../../command/store') as CommandStoreMock).useCommandStore.__state;
+
+/** Connects the update hub and hands back the registered incidentCommandUpdated listener. */
+const captureIncidentCommandHandler = async (): Promise<(message: unknown) => void> => {
+ await useSignalRStore.getState().connectUpdateHub();
+ const registration = (signalRService.on as jest.Mock).mock.calls.find(([event]) => event === 'incidentCommandUpdated');
+ if (!registration) {
+ throw new Error(`incidentCommandUpdated was never subscribed (store error: ${String(useSignalRStore.getState().error)})`);
+ }
+ return registration[1] as (message: unknown) => void;
+};
+
+describe('incidentCommandUpdated realtime routing', () => {
+ beforeEach(() => {
+ (signalRService.on as jest.Mock).mockClear();
+ // mockClear keeps implementations, so a test that made invoke reject would break the next
+ // connectUpdateHub before it ever subscribes.
+ (signalRService.invoke as jest.Mock).mockReset().mockResolvedValue(undefined);
+ commandState.refreshBoard.mockClear();
+ commandState.syncFromServer.mockClear();
+ commandState.boards = { '1001': { callId: '1001' } };
+ // The store is a module singleton, and connectUpdateHub returns early when it believes it is
+ // already connected — reset the flag so each test really re-subscribes.
+ useSignalRStore.setState({ isUpdateHubConnected: false, error: null });
+ jest.useFakeTimers();
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ it('refreshes the open board when Core sends the call id as a bare string', async () => {
+ const handler = await captureIncidentCommandHandler();
+
+ // The shape Core actually puts on the wire — this must hit the targeted refresh.
+ handler('1001');
+
+ expect(commandState.refreshBoard).toHaveBeenCalledWith('1001');
+ expect(commandState.syncFromServer).not.toHaveBeenCalled();
+ });
+
+ it('accepts a numeric call id', async () => {
+ const handler = await captureIncidentCommandHandler();
+
+ handler(1001);
+
+ expect(commandState.refreshBoard).toHaveBeenCalledWith('1001');
+ });
+
+ it('still accepts an object payload from a richer producer', async () => {
+ const handler = await captureIncidentCommandHandler();
+
+ handler({ CallId: '1001' });
+
+ expect(commandState.refreshBoard).toHaveBeenCalledWith('1001');
+ expect(commandState.syncFromServer).not.toHaveBeenCalled();
+ });
+
+ it('accepts a numeric call id inside an object payload', async () => {
+ const handler = await captureIncidentCommandHandler();
+
+ handler({ CallId: 1001 });
+
+ expect(commandState.refreshBoard).toHaveBeenCalledWith('1001');
+ });
+
+ it.each([
+ ['NaN', { CallId: NaN }],
+ ['Infinity', { CallId: Infinity }],
+ ['a nested object', { CallId: {} }],
+ ['an array', { CallId: ['1001'] }],
+ ['a boolean', { CallId: true }],
+ ])('does not treat %s as a call id', async (_label, payload) => {
+ const handler = await captureIncidentCommandHandler();
+
+ // Stringifying these yields plausible-looking ids ("NaN", "[object Object]", "1001"), which
+ // would refresh the wrong board or none at all; they belong on the full-sync fallback.
+ handler(payload);
+
+ expect(commandState.refreshBoard).not.toHaveBeenCalled();
+
+ jest.advanceTimersByTime(2000);
+ expect(commandState.syncFromServer).toHaveBeenCalledTimes(1);
+ });
+
+ it('falls back to a debounced full sync for an incident this device has not opened', async () => {
+ const handler = await captureIncidentCommandHandler();
+
+ handler('2002');
+
+ expect(commandState.refreshBoard).not.toHaveBeenCalled();
+ expect(commandState.syncFromServer).not.toHaveBeenCalled();
+
+ jest.advanceTimersByTime(2000);
+
+ expect(commandState.syncFromServer).toHaveBeenCalledTimes(1);
+ });
+
+ it('rejoins the department group after an automatic reconnect and backfills the gap', async () => {
+ // A reconnect gets a new connection id, so the server-side group membership is gone. Without
+ // re-announcing, the device stops receiving other users' board changes entirely — silently.
+ await captureIncidentCommandHandler();
+ (signalRService.invoke as jest.Mock).mockClear();
+
+ const reconnect = (signalRService.on as jest.Mock).mock.calls.find(([event]) => event === '__hubReconnected:eventingHub');
+ expect(reconnect).toBeDefined();
+
+ (reconnect?.[1] as () => void)();
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(signalRService.invoke).toHaveBeenCalledWith('eventingHub', 'connect', 123);
+
+ // The hub replays nothing from the outage, so the boards have to be resynced.
+ jest.advanceTimersByTime(2000);
+ expect(commandState.syncFromServer).toHaveBeenCalledTimes(1);
+ });
+
+ it('clears the connected flag and retries when the rejoin fails', async () => {
+ await captureIncidentCommandHandler();
+ useSignalRStore.setState({ isUpdateHubConnected: true });
+
+ const reconnect = (signalRService.on as jest.Mock).mock.calls.find(([event]) => event === '__hubReconnected:eventingHub');
+ (signalRService.invoke as jest.Mock).mockClear();
+ (signalRService.invoke as jest.Mock).mockRejectedValue(new Error('hub refused'));
+
+ (reconnect?.[1] as () => void)();
+ await Promise.resolve();
+ await Promise.resolve();
+
+ // An automatic reconnect never raises the disconnected event, so nothing else would clear this
+ // and connectUpdateHub's already-connected guard would block every later repair.
+ expect(useSignalRStore.getState().isUpdateHubConnected).toBe(false);
+
+ // A failed rejoin means no group membership at all, so it retries rather than going quiet.
+ (signalRService.invoke as jest.Mock).mockResolvedValue(undefined);
+ jest.advanceTimersByTime(5000);
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(signalRService.invoke).toHaveBeenCalledWith('eventingHub', 'connect', 123);
+ expect(useSignalRStore.getState().isUpdateHubConnected).toBe(true);
+ });
+
+ it('stops retrying after the attempt budget and leaves the session rebuildable', async () => {
+ await captureIncidentCommandHandler();
+ useSignalRStore.setState({ isUpdateHubConnected: true });
+
+ const reconnect = (signalRService.on as jest.Mock).mock.calls.find(([event]) => event === '__hubReconnected:eventingHub');
+ (signalRService.invoke as jest.Mock).mockClear();
+ (signalRService.invoke as jest.Mock).mockRejectedValue(new Error('hub refused'));
+
+ (reconnect?.[1] as () => void)();
+ for (let i = 0; i < 4; i += 1) {
+ await Promise.resolve();
+ await Promise.resolve();
+ jest.advanceTimersByTime(5000);
+ }
+ await Promise.resolve();
+ await Promise.resolve();
+
+ // Three attempts, then it stops rather than retrying forever.
+ expect((signalRService.invoke as jest.Mock).mock.calls.length).toBe(3);
+ expect(useSignalRStore.getState().isUpdateHubConnected).toBe(false);
+ });
+
+ it('marks the update hub disconnected so it can be rebuilt later', async () => {
+ await captureIncidentCommandHandler();
+
+ const disconnect = (signalRService.on as jest.Mock).mock.calls.find(([event]) => event === '__hubDisconnected:eventingHub');
+ expect(disconnect).toBeDefined();
+
+ (disconnect?.[1] as () => void)();
+
+ expect(useSignalRStore.getState().isUpdateHubConnected).toBe(false);
+ });
+
+ it('treats an unusable payload as "something changed" rather than a call id', async () => {
+ const handler = await captureIncidentCommandHandler();
+
+ handler(' ');
+ jest.advanceTimersByTime(2000);
+
+ expect(commandState.refreshBoard).not.toHaveBeenCalled();
+ expect(commandState.syncFromServer).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/stores/signalr/signalr-store.ts b/src/stores/signalr/signalr-store.ts
index f7149dd..04a47f2 100644
--- a/src/stores/signalr/signalr-store.ts
+++ b/src/stores/signalr/signalr-store.ts
@@ -34,6 +34,33 @@ const CHAT_HUB_METHODS = [
// Track registered chat handlers for cleanup and the heartbeat timer.
// Hub methods can send several positional arguments, so handlers are variadic.
const chatHubHandlers: Record void) | null> = {};
+
+// Same for the update hub's lifecycle listeners, which have to be torn down on disconnect so they
+// do not accumulate across reconnects.
+const updateHubHandlers: Record void) | null> = {};
+
+// Rejoining the department group after a reconnect, retried on the same terms as the chat arm.
+const UPDATE_REJOIN_RETRY_MS = 5000;
+const UPDATE_REJOIN_MAX_ATTEMPTS = 3;
+let updateRejoinTimer: ReturnType | null = null;
+let updateRejoinAttempts = 0;
+
+function stopUpdateRejoinRetry(): void {
+ if (updateRejoinTimer) {
+ clearTimeout(updateRejoinTimer);
+ updateRejoinTimer = null;
+ }
+}
+
+function unregisterUpdateHubHandlers(): void {
+ Object.keys(updateHubHandlers).forEach((event) => {
+ const handler = updateHubHandlers[event];
+ if (handler) {
+ signalRService.off(event, handler);
+ updateHubHandlers[event] = null;
+ }
+ });
+}
const CHAT_ARM_RETRY_MS = 5000;
const CHAT_ARM_MAX_ATTEMPTS = 3;
// The hub replays a full resync on arm; collapse the duplicate that arrives when the
@@ -161,18 +188,48 @@ function extractAlertId(message: unknown): string | undefined {
return undefined;
}
-/** Minimal shape of the incidentCommandUpdated payload — server identifies the
- * affected incident by call (PascalCase or lower-camel). */
+/** Object form of the incidentCommandUpdated payload — an incident identified by call
+ * (PascalCase or lower-camel). */
interface IncidentCommandSignalRMessage {
- CallId?: string;
- callId?: string;
+ CallId?: string | number;
+ callId?: string | number;
}
+/**
+ * A call id is a non-empty string or a finite number and nothing else. Anything looser gets
+ * stringified into a plausible-looking id — an array of one becomes its element, an object becomes
+ * "[object Object]" — and would be treated as a real incident instead of falling through to the
+ * full-sync fallback.
+ */
+function toCallId(value: unknown): string | undefined {
+ if (typeof value === 'string') {
+ const trimmed = value.trim();
+ return trimmed.length > 0 ? trimmed : undefined;
+ }
+ if (typeof value === 'number' && Number.isFinite(value)) {
+ return String(value);
+ }
+ return undefined;
+}
+
+/**
+ * The affected incident's call id.
+ *
+ * Core sends the call id as a bare string: the eventing worker calls
+ * `SendAsync("incidentCommandUpdated", id)` with the `ItemId` the topic provider set to
+ * `CallId.ToString()`. Object payloads are still accepted so a producer that sends a richer message
+ * keeps working. Reading only objects meant every real event fell through to the debounced full
+ * bundle sync instead of refreshing the one board that changed — the whole board still updated, but
+ * seconds late and after re-fetching every open incident.
+ */
function extractCommandCallId(message: unknown): string | undefined {
+ const scalar = toCallId(message);
+ if (scalar !== undefined) {
+ return scalar;
+ }
if (message !== null && typeof message === 'object') {
const m = message as IncidentCommandSignalRMessage;
- const id = m.CallId ?? m.callId;
- return id !== undefined && id !== null ? String(id) : undefined;
+ return toCallId(m.CallId ?? m.callId);
}
return undefined;
}
@@ -408,6 +465,75 @@ export const useSignalRStore = create((set, get) => ({
});
set({ isUpdateHubConnected: true, error: null });
});
+
+ /**
+ * An automatic reconnect gets a new connection id, so the department group this connection
+ * joined above is gone with the old one — without re-announcing, the device goes quiet and
+ * stops seeing other users' board changes until the app is backgrounded and resumed.
+ *
+ * The hub also does not replay what was sent during the outage, so the boards are resynced
+ * once the group is rejoined; that is what backfills the changes made while offline.
+ */
+ unregisterUpdateHubHandlers();
+
+ const updateReconnected = `${SignalRService.HUB_RECONNECTED_EVENT}:${Env.CHANNEL_HUB_NAME}`;
+
+ /**
+ * Rejoin the department group, retrying a few times before giving up.
+ *
+ * A failed rejoin is silent and total: the socket is up, so nothing looks wrong, but the
+ * connection belongs to no group and no board change will ever arrive. Nothing else clears the
+ * connected flag either — an automatic reconnect never raises the disconnected event, only a
+ * close does — so the flag has to be cleared here or connectUpdateHub()'s already-connected
+ * guard would block every later repair.
+ */
+ const rejoinDepartmentGroup = () => {
+ const departmentId = parseInt(securityStore.getState().rights?.DepartmentId ?? '0');
+ signalRService
+ .invoke(Env.CHANNEL_HUB_NAME, 'connect', departmentId)
+ .then(() => {
+ stopUpdateRejoinRetry();
+ updateRejoinAttempts = 0;
+ set({ isUpdateHubConnected: true, error: null });
+ logger.info({ message: 'Re-announced to update hub after reconnect; resyncing command boards', context: { departmentId } });
+ debouncedFullSync();
+ })
+ .catch((error) => {
+ updateRejoinAttempts += 1;
+ logger.warn({ message: 'Failed to re-announce to update hub after reconnect', context: { error, attempt: updateRejoinAttempts, maxAttempts: UPDATE_REJOIN_MAX_ATTEMPTS } });
+ set({ isUpdateHubConnected: false });
+
+ if (updateRejoinAttempts < UPDATE_REJOIN_MAX_ATTEMPTS) {
+ stopUpdateRejoinRetry();
+ updateRejoinTimer = setTimeout(() => {
+ updateRejoinTimer = null;
+ rejoinDepartmentGroup();
+ }, UPDATE_REJOIN_RETRY_MS);
+ } else {
+ logger.error({ message: 'Giving up re-announcing to update hub; the next connectUpdateHub will rebuild the session', context: { attempts: updateRejoinAttempts } });
+ }
+ });
+ };
+
+ const onUpdateReconnected = () => {
+ stopUpdateRejoinRetry();
+ updateRejoinAttempts = 0;
+ rejoinDepartmentGroup();
+ };
+ updateHubHandlers[updateReconnected] = onUpdateReconnected;
+ signalRService.on(updateReconnected, onUpdateReconnected);
+
+ const updateDisconnected = `${SignalRService.HUB_DISCONNECTED_EVENT}:${Env.CHANNEL_HUB_NAME}`;
+ const onUpdateDisconnected = () => {
+ // A dropped transport supersedes any rejoin still pending against the old connection.
+ stopUpdateRejoinRetry();
+ updateRejoinAttempts = 0;
+ // Clearing the flag is what lets connectUpdateHub rebuild the session later; while it stayed
+ // true the hub could never be re-announced.
+ set({ isUpdateHubConnected: false });
+ };
+ updateHubHandlers[updateDisconnected] = onUpdateDisconnected;
+ signalRService.on(updateDisconnected, onUpdateDisconnected);
} catch (error) {
const err = error instanceof Error ? error : new Error('Unknown error occurred');
logger.warn({
@@ -419,6 +545,9 @@ export const useSignalRStore = create((set, get) => ({
},
disconnectUpdateHub: async () => {
try {
+ stopUpdateRejoinRetry();
+ updateRejoinAttempts = 0;
+ unregisterUpdateHubHandlers();
await signalRService.disconnectFromHub(Env.CHANNEL_HUB_NAME);
set({ isUpdateHubConnected: false, lastUpdateMessage: null });
} catch (error) {
diff --git a/src/translations/ar.json b/src/translations/ar.json
index 94b7f80..36dcf96 100644
--- a/src/translations/ar.json
+++ b/src/translations/ar.json
@@ -478,6 +478,7 @@
"chat_load_failed": "Couldn't load this incident's chat channels. Tap again to retry.",
"close_channels": "إغلاق الكل",
"command_chat": "Command chat",
+ "command_chat_short": "Command",
"command_chat_unavailable": "No command chat channel has been created for this incident yet.",
"command_details": "تفاصيل القيادة",
"crew_placeholder": "اسم الشخص أو الفريق",
@@ -487,6 +488,7 @@
"delete_lane_move_resources": "نقل إلى الموارد",
"delete_lane_release_resources": "إخلاء من الحادث",
"delete_lane_resources_message": "يحتوي هذا المسار على {{count}} موارد معيّنة. انقلها إلى قائمة الموارد أو أخلها من الحادث.",
+ "details_short": "Details",
"dispatch_chat": "Dispatch",
"dispatch_chat_unavailable": "No dispatch channel has been created for this incident yet.",
"drag_move_hint": "اضغط مطولاً على المورد لسحبه، أو اضغط عليه ثم اضغط على مسار.",
@@ -824,6 +826,7 @@
"transfer_command": "نقل القيادة",
"transfer_command_hint": "سلّم قيادة الحادث إلى مستخدم آخر. تبقى اللوحة مشتركة.",
"transfer_error": "فشل نقل القيادة",
+ "transfer_short": "Transfer",
"transfer_success": "تم نقل القيادة",
"transmission_log": "سجل الإرسال",
"transmission_seconds": "{{count}} ث",
diff --git a/src/translations/de.json b/src/translations/de.json
index a756b3e..33ba8cd 100644
--- a/src/translations/de.json
+++ b/src/translations/de.json
@@ -478,6 +478,7 @@
"chat_load_failed": "Couldn't load this incident's chat channels. Tap again to retry.",
"close_channels": "Alle schließen",
"command_chat": "Command chat",
+ "command_chat_short": "Command",
"command_chat_unavailable": "No command chat channel has been created for this incident yet.",
"command_details": "Führungsdetails",
"crew_placeholder": "Name der Person oder des Trupps",
@@ -487,6 +488,7 @@
"delete_lane_move_resources": "Zu Ressourcen verschieben",
"delete_lane_release_resources": "Vom Einsatz lösen",
"delete_lane_resources_message": "Dieser Spur sind {{count}} Ressourcen zugewiesen. Zurück in die Ressourcenliste verschieben oder vom Einsatz lösen.",
+ "details_short": "Details",
"dispatch_chat": "Dispatch",
"dispatch_chat_unavailable": "No dispatch channel has been created for this incident yet.",
"drag_move_hint": "Halten Sie eine Ressource gedrückt, um sie zu ziehen, oder tippen Sie sie und anschließend eine Spur an.",
@@ -824,6 +826,7 @@
"transfer_command": "Führung übergeben",
"transfer_command_hint": "Übergib die Einsatzleitung an einen anderen Benutzer. Das Board bleibt geteilt.",
"transfer_error": "Führungsübergabe fehlgeschlagen",
+ "transfer_short": "Transfer",
"transfer_success": "Führung übergeben",
"transmission_log": "Sendeprotokoll",
"transmission_seconds": "{{count}}s",
diff --git a/src/translations/en.json b/src/translations/en.json
index a221a39..8c77dbb 100644
--- a/src/translations/en.json
+++ b/src/translations/en.json
@@ -478,6 +478,7 @@
"chat_load_failed": "Couldn't load this incident's chat channels. Tap again to retry.",
"close_channels": "Close All",
"command_chat": "Command chat",
+ "command_chat_short": "Command",
"command_chat_unavailable": "No command chat channel has been created for this incident yet.",
"command_details": "Command Details",
"crew_placeholder": "Person or crew name",
@@ -487,6 +488,7 @@
"delete_lane_move_resources": "Move to Resources",
"delete_lane_release_resources": "Release from Incident",
"delete_lane_resources_message": "This lane has {{count}} assigned resources. Move them back to the resources list or release them from the incident.",
+ "details_short": "Details",
"dispatch_chat": "Dispatch",
"dispatch_chat_unavailable": "No dispatch channel has been created for this incident yet.",
"drag_move_hint": "Press and hold a resource to drag it, or tap it and then tap a lane.",
@@ -824,6 +826,7 @@
"transfer_command": "Transfer Command",
"transfer_command_hint": "Hand incident command to another user. The board stays shared.",
"transfer_error": "Failed to transfer command",
+ "transfer_short": "Transfer",
"transfer_success": "Command transferred",
"transmission_log": "Transmission Log",
"transmission_seconds": "{{count}}s",
diff --git a/src/translations/es.json b/src/translations/es.json
index c497912..f798480 100644
--- a/src/translations/es.json
+++ b/src/translations/es.json
@@ -478,6 +478,7 @@
"chat_load_failed": "Couldn't load this incident's chat channels. Tap again to retry.",
"close_channels": "Cerrar todo",
"command_chat": "Command chat",
+ "command_chat_short": "Command",
"command_chat_unavailable": "No command chat channel has been created for this incident yet.",
"command_details": "Detalles del comando",
"crew_placeholder": "Nombre de la persona o del equipo",
@@ -487,6 +488,7 @@
"delete_lane_move_resources": "Mover a recursos",
"delete_lane_release_resources": "Dar de baja del incidente",
"delete_lane_resources_message": "Este carril tiene {{count}} recursos asignados. Muévalos a la lista de recursos o délos de baja del incidente.",
+ "details_short": "Details",
"dispatch_chat": "Dispatch",
"dispatch_chat_unavailable": "No dispatch channel has been created for this incident yet.",
"drag_move_hint": "Mantén pulsado un recurso para arrastrarlo, o tócalo y luego toca un carril.",
@@ -824,6 +826,7 @@
"transfer_command": "Transferir el mando",
"transfer_command_hint": "Entrega el mando del incidente a otro usuario. El tablero sigue compartido.",
"transfer_error": "No se pudo transferir el mando",
+ "transfer_short": "Transfer",
"transfer_success": "Mando transferido",
"transmission_log": "Registro de transmisiones",
"transmission_seconds": "{{count}}s",
diff --git a/src/translations/fr.json b/src/translations/fr.json
index 68e0af7..15cf9a5 100644
--- a/src/translations/fr.json
+++ b/src/translations/fr.json
@@ -478,6 +478,7 @@
"chat_load_failed": "Couldn't load this incident's chat channels. Tap again to retry.",
"close_channels": "Tout fermer",
"command_chat": "Command chat",
+ "command_chat_short": "Command",
"command_chat_unavailable": "No command chat channel has been created for this incident yet.",
"command_details": "Détails du commandement",
"crew_placeholder": "Nom de la personne ou de l'équipe",
@@ -487,6 +488,7 @@
"delete_lane_move_resources": "Déplacer vers les ressources",
"delete_lane_release_resources": "Libérer de l'incident",
"delete_lane_resources_message": "Cette voie compte {{count}} ressources affectées. Déplacez-les vers la liste des ressources ou libérez-les de l'incident.",
+ "details_short": "Details",
"dispatch_chat": "Dispatch",
"dispatch_chat_unavailable": "No dispatch channel has been created for this incident yet.",
"drag_move_hint": "Appuyez longuement sur une ressource pour la faire glisser, ou touchez-la puis touchez une voie.",
@@ -824,6 +826,7 @@
"transfer_command": "Transférer le commandement",
"transfer_command_hint": "Confiez le commandement de l’incident à un autre utilisateur. Le tableau reste partagé.",
"transfer_error": "Échec du transfert du commandement",
+ "transfer_short": "Transfer",
"transfer_success": "Commandement transféré",
"transmission_log": "Journal des transmissions",
"transmission_seconds": "{{count}}s",
diff --git a/src/translations/it.json b/src/translations/it.json
index e03ddac..ee0b631 100644
--- a/src/translations/it.json
+++ b/src/translations/it.json
@@ -478,6 +478,7 @@
"chat_load_failed": "Couldn't load this incident's chat channels. Tap again to retry.",
"close_channels": "Chiudi tutto",
"command_chat": "Command chat",
+ "command_chat_short": "Command",
"command_chat_unavailable": "No command chat channel has been created for this incident yet.",
"command_details": "Dettagli del comando",
"crew_placeholder": "Nome della persona o della squadra",
@@ -487,6 +488,7 @@
"delete_lane_move_resources": "Sposta in Risorse",
"delete_lane_release_resources": "Rilascia dall'incidente",
"delete_lane_resources_message": "Questa corsia ha {{count}} risorse assegnate. Spostale nell'elenco delle risorse o rilasciale dall'incidente.",
+ "details_short": "Details",
"dispatch_chat": "Dispatch",
"dispatch_chat_unavailable": "No dispatch channel has been created for this incident yet.",
"drag_move_hint": "Tieni premuta una risorsa per trascinarla, oppure toccala e poi tocca una corsia.",
@@ -824,6 +826,7 @@
"transfer_command": "Trasferisci comando",
"transfer_command_hint": "Passa il comando dell’incidente a un altro utente. La board resta condivisa.",
"transfer_error": "Trasferimento del comando non riuscito",
+ "transfer_short": "Transfer",
"transfer_success": "Comando trasferito",
"transmission_log": "Registro trasmissioni",
"transmission_seconds": "{{count}}s",
diff --git a/src/translations/pl.json b/src/translations/pl.json
index 5313952..18c4c51 100644
--- a/src/translations/pl.json
+++ b/src/translations/pl.json
@@ -478,6 +478,7 @@
"chat_load_failed": "Couldn't load this incident's chat channels. Tap again to retry.",
"close_channels": "Zamknij wszystkie",
"command_chat": "Command chat",
+ "command_chat_short": "Command",
"command_chat_unavailable": "No command chat channel has been created for this incident yet.",
"command_details": "Szczegóły dowodzenia",
"crew_placeholder": "Nazwa osoby lub zastępu",
@@ -487,6 +488,7 @@
"delete_lane_move_resources": "Przenieś do zasobów",
"delete_lane_release_resources": "Zwolnij z incydentu",
"delete_lane_resources_message": "Ta sekcja ma {{count}} przydzielonych zasobów. Przenieś je z powrotem do listy zasobów lub zwolnij z incydentu.",
+ "details_short": "Details",
"dispatch_chat": "Dispatch",
"dispatch_chat_unavailable": "No dispatch channel has been created for this incident yet.",
"drag_move_hint": "Naciśnij i przytrzymaj zasób, aby go przeciągnąć, albo dotknij go, a następnie dotknij pasa.",
@@ -824,6 +826,7 @@
"transfer_command": "Przekaż dowodzenie",
"transfer_command_hint": "Przekaż dowodzenie zdarzeniem innemu użytkownikowi. Tablica pozostaje wspólna.",
"transfer_error": "Nie udało się przekazać dowodzenia",
+ "transfer_short": "Transfer",
"transfer_success": "Dowodzenie przekazane",
"transmission_log": "Dziennik transmisji",
"transmission_seconds": "{{count}}s",
diff --git a/src/translations/sv.json b/src/translations/sv.json
index 5558012..36f995a 100644
--- a/src/translations/sv.json
+++ b/src/translations/sv.json
@@ -478,6 +478,7 @@
"chat_load_failed": "Couldn't load this incident's chat channels. Tap again to retry.",
"close_channels": "Stäng alla",
"command_chat": "Command chat",
+ "command_chat_short": "Command",
"command_chat_unavailable": "No command chat channel has been created for this incident yet.",
"command_details": "Ledningsdetaljer",
"crew_placeholder": "Namn på person eller styrka",
@@ -487,6 +488,7 @@
"delete_lane_move_resources": "Flytta till resurser",
"delete_lane_release_resources": "Frigör från insatsen",
"delete_lane_resources_message": "Detta körfält har {{count}} tilldelade resurser. Flytta dem tillbaka till resurslistan eller frigör dem från insatsen.",
+ "details_short": "Details",
"dispatch_chat": "Dispatch",
"dispatch_chat_unavailable": "No dispatch channel has been created for this incident yet.",
"drag_move_hint": "Tryck och håll på en resurs för att dra den, eller tryck på den och sedan på en bana.",
@@ -824,6 +826,7 @@
"transfer_command": "Överför befäl",
"transfer_command_hint": "Lämna över insatsbefälet till en annan användare. Tavlan förblir delad.",
"transfer_error": "Kunde inte överföra befälet",
+ "transfer_short": "Transfer",
"transfer_success": "Befälet överfört",
"transmission_log": "Sändningslogg",
"transmission_seconds": "{{count}}s",
diff --git a/src/translations/uk.json b/src/translations/uk.json
index 0093b02..670c0d8 100644
--- a/src/translations/uk.json
+++ b/src/translations/uk.json
@@ -478,6 +478,7 @@
"chat_load_failed": "Couldn't load this incident's chat channels. Tap again to retry.",
"close_channels": "Закрити всі",
"command_chat": "Command chat",
+ "command_chat_short": "Command",
"command_chat_unavailable": "No command chat channel has been created for this incident yet.",
"command_details": "Деталі управління",
"crew_placeholder": "Ім'я особи або назва ланки",
@@ -487,6 +488,7 @@
"delete_lane_move_resources": "Перемістити до ресурсів",
"delete_lane_release_resources": "Звільнити з інциденту",
"delete_lane_resources_message": "Ця смуга має {{count}} призначених ресурсів. Поверніть їх до списку ресурсів або звільніть з інциденту.",
+ "details_short": "Details",
"dispatch_chat": "Dispatch",
"dispatch_chat_unavailable": "No dispatch channel has been created for this incident yet.",
"drag_move_hint": "Натисніть і утримуйте ресурс, щоб перетягнути його, або торкніться ресурсу, а потім смуги.",
@@ -824,6 +826,7 @@
"transfer_command": "Передати командування",
"transfer_command_hint": "Передайте командування інцидентом іншому користувачу. Дошка залишається спільною.",
"transfer_error": "Не вдалося передати командування",
+ "transfer_short": "Transfer",
"transfer_success": "Командування передано",
"transmission_log": "Журнал передач",
"transmission_seconds": "{{count}}с",
diff --git a/theme-tokens.css b/theme-tokens.css
new file mode 100644
index 0000000..8bbcad9
--- /dev/null
+++ b/theme-tokens.css
@@ -0,0 +1,703 @@
+/* Design tokens and keyframes shared by both platform entry points.
+
+ Imported by global.css (native) and global.web.css (web). Those two exist only because the dark
+ variant has to differ per platform — see the comments in each. Everything else lives here so the
+ token set never drifts between them. */
+
+/* ─── Design tokens (light / dark) ───────────────────────────────
+ 1. :root — light defaults (all platforms)
+ 2. @media dark :root — dark defaults; on native this follows
+ Appearance.getColorScheme() (the provider
+ sets an override for explicit modes).
+ 3. :root.dark / :root.light — web-only class override, beats the media
+ query within the same layer. */
+@layer theme {
+ :root {
+ --color-primary-0: 248 250 252;
+ --color-primary-50: 241 245 249;
+ --color-primary-100: 226 232 240;
+ --color-primary-200: 203 213 225;
+ --color-primary-300: 148 163 184;
+ --color-primary-400: 100 116 139;
+ --color-primary-500: 71 85 105;
+ --color-primary-600: 51 65 85;
+ --color-primary-700: 45 55 72;
+ --color-primary-800: 39 49 66;
+ --color-primary-900: 30 41 59;
+ --color-primary-950: 15 23 42;
+ --color-secondary-0: 254 255 255;
+ --color-secondary-50: 241 242 242;
+ --color-secondary-100: 231 232 232;
+ --color-secondary-200: 219 219 219;
+ --color-secondary-300: 175 176 176;
+ --color-secondary-400: 114 115 115;
+ --color-secondary-500: 94 95 95;
+ --color-secondary-600: 81 82 82;
+ --color-secondary-700: 63 64 64;
+ --color-secondary-800: 39 38 38;
+ --color-secondary-900: 24 23 23;
+ --color-secondary-950: 11 12 12;
+ --color-tertiary-0: 255 250 245;
+ --color-tertiary-50: 255 242 229;
+ --color-tertiary-100: 255 233 213;
+ --color-tertiary-200: 254 209 170;
+ --color-tertiary-300: 253 180 116;
+ --color-tertiary-400: 251 157 75;
+ --color-tertiary-500: 231 129 40;
+ --color-tertiary-600: 215 117 31;
+ --color-tertiary-700: 180 98 26;
+ --color-tertiary-800: 130 73 23;
+ --color-tertiary-900: 108 61 19;
+ --color-tertiary-950: 84 49 18;
+ --color-error-0: 254 233 233;
+ --color-error-50: 254 226 226;
+ --color-error-100: 254 202 202;
+ --color-error-200: 252 165 165;
+ --color-error-300: 248 113 113;
+ --color-error-400: 239 68 68;
+ --color-error-500: 230 53 53;
+ --color-error-600: 220 38 38;
+ --color-error-700: 185 28 28;
+ --color-error-800: 153 27 27;
+ --color-error-900: 127 29 29;
+ --color-error-950: 83 19 19;
+ --color-success-0: 228 255 244;
+ --color-success-50: 202 255 232;
+ --color-success-100: 162 241 192;
+ --color-success-200: 132 211 162;
+ --color-success-300: 102 181 132;
+ --color-success-400: 72 151 102;
+ --color-success-500: 52 131 82;
+ --color-success-600: 42 121 72;
+ --color-success-700: 32 111 62;
+ --color-success-800: 22 101 52;
+ --color-success-900: 20 83 45;
+ --color-success-950: 27 50 36;
+ --color-warning-0: 255 253 251;
+ --color-warning-50: 255 249 245;
+ --color-warning-100: 255 231 213;
+ --color-warning-200: 254 205 170;
+ --color-warning-300: 253 173 116;
+ --color-warning-400: 251 149 75;
+ --color-warning-500: 231 120 40;
+ --color-warning-600: 215 108 31;
+ --color-warning-700: 180 90 26;
+ --color-warning-800: 130 68 23;
+ --color-warning-900: 108 56 19;
+ --color-warning-950: 84 45 18;
+ --color-info-0: 236 248 254;
+ --color-info-50: 199 235 252;
+ --color-info-100: 162 221 250;
+ --color-info-200: 124 207 248;
+ --color-info-300: 87 194 246;
+ --color-info-400: 50 180 244;
+ --color-info-500: 13 166 242;
+ --color-info-600: 11 141 205;
+ --color-info-700: 9 115 168;
+ --color-info-800: 7 90 131;
+ --color-info-900: 5 64 93;
+ --color-info-950: 3 38 56;
+ --color-typography-0: 254 254 255;
+ --color-typography-50: 245 245 245;
+ --color-typography-100: 229 229 229;
+ --color-typography-200: 219 219 220;
+ --color-typography-300: 212 212 212;
+ --color-typography-400: 163 163 163;
+ --color-typography-500: 140 140 140;
+ --color-typography-600: 115 115 115;
+ --color-typography-700: 82 82 82;
+ --color-typography-800: 64 64 64;
+ --color-typography-900: 38 38 39;
+ --color-typography-950: 23 23 23;
+ --color-outline-0: 253 254 254;
+ --color-outline-50: 243 243 243;
+ --color-outline-100: 230 230 230;
+ --color-outline-200: 221 220 219;
+ --color-outline-300: 211 211 211;
+ --color-outline-400: 165 163 163;
+ --color-outline-500: 140 141 141;
+ --color-outline-600: 115 116 116;
+ --color-outline-700: 83 82 82;
+ --color-outline-800: 65 65 65;
+ --color-outline-900: 39 38 36;
+ --color-outline-950: 26 23 23;
+ --color-background-0: 255 255 255;
+ --color-background-50: 246 246 246;
+ --color-background-100: 242 241 241;
+ --color-background-200: 220 219 219;
+ --color-background-300: 213 212 212;
+ --color-background-400: 162 163 163;
+ --color-background-500: 142 142 142;
+ --color-background-600: 116 116 116;
+ --color-background-700: 83 82 82;
+ --color-background-800: 65 64 64;
+ --color-background-900: 39 38 37;
+ --color-background-950: 24 23 24;
+ --color-background-error: 254 241 241;
+ --color-background-warning: 255 244 235;
+ --color-background-success: 237 252 242;
+ --color-background-muted: 247 248 247;
+ --color-background-info: 235 248 254;
+ --color-indicator-primary: 55 55 55;
+ --color-indicator-info: 83 153 236;
+ --color-indicator-error: 185 28 28;
+ }
+
+ @media (prefers-color-scheme: dark) {
+ :root {
+ --color-primary-0: 15 23 42;
+ --color-primary-50: 30 41 59;
+ --color-primary-100: 39 49 66;
+ --color-primary-200: 45 55 72;
+ --color-primary-300: 51 65 85;
+ --color-primary-400: 71 85 105;
+ --color-primary-500: 100 116 139;
+ --color-primary-600: 71 85 105;
+ --color-primary-700: 51 65 85;
+ --color-primary-800: 45 55 72;
+ --color-primary-900: 39 49 66;
+ --color-primary-950: 30 41 59;
+ --color-secondary-0: 11 12 12;
+ --color-secondary-50: 24 23 23;
+ --color-secondary-100: 39 38 38;
+ --color-secondary-200: 63 64 64;
+ --color-secondary-300: 81 82 82;
+ --color-secondary-400: 94 95 95;
+ --color-secondary-500: 114 115 115;
+ --color-secondary-600: 175 176 176;
+ --color-secondary-700: 219 219 219;
+ --color-secondary-800: 231 232 232;
+ --color-secondary-900: 241 242 242;
+ --color-secondary-950: 254 255 255;
+ --color-tertiary-0: 84 49 18;
+ --color-tertiary-50: 108 61 19;
+ --color-tertiary-100: 130 73 23;
+ --color-tertiary-200: 180 98 26;
+ --color-tertiary-300: 215 117 31;
+ --color-tertiary-400: 231 129 40;
+ --color-tertiary-500: 251 157 75;
+ --color-tertiary-600: 253 180 116;
+ --color-tertiary-700: 254 209 170;
+ --color-tertiary-800: 255 233 213;
+ --color-tertiary-900: 255 242 229;
+ --color-tertiary-950: 255 250 245;
+ --color-error-0: 83 19 19;
+ --color-error-50: 127 29 29;
+ --color-error-100: 153 27 27;
+ --color-error-200: 185 28 28;
+ --color-error-300: 220 38 38;
+ --color-error-400: 230 53 53;
+ --color-error-500: 239 68 68;
+ --color-error-600: 248 113 113;
+ --color-error-700: 252 165 165;
+ --color-error-800: 254 202 202;
+ --color-error-900: 254 226 226;
+ --color-error-950: 254 233 233;
+ --color-success-0: 27 50 36;
+ --color-success-50: 20 83 45;
+ --color-success-100: 22 101 52;
+ --color-success-200: 32 111 62;
+ --color-success-300: 42 121 72;
+ --color-success-400: 52 131 82;
+ --color-success-500: 72 151 102;
+ --color-success-600: 102 181 132;
+ --color-success-700: 132 211 162;
+ --color-success-800: 162 241 192;
+ --color-success-900: 202 255 232;
+ --color-success-950: 228 255 244;
+ --color-warning-0: 84 45 18;
+ --color-warning-50: 108 56 19;
+ --color-warning-100: 130 68 23;
+ --color-warning-200: 180 90 26;
+ --color-warning-300: 215 108 31;
+ --color-warning-400: 231 120 40;
+ --color-warning-500: 251 149 75;
+ --color-warning-600: 253 173 116;
+ --color-warning-700: 254 205 170;
+ --color-warning-800: 255 231 213;
+ --color-warning-900: 255 249 245;
+ --color-warning-950: 255 253 251;
+ --color-info-0: 3 38 56;
+ --color-info-50: 5 64 93;
+ --color-info-100: 7 90 131;
+ --color-info-200: 9 115 168;
+ --color-info-300: 11 141 205;
+ --color-info-400: 13 166 242;
+ --color-info-500: 50 180 244;
+ --color-info-600: 87 194 246;
+ --color-info-700: 124 207 248;
+ --color-info-800: 162 221 250;
+ --color-info-900: 199 235 252;
+ --color-info-950: 236 248 254;
+ --color-typography-0: 23 23 23;
+ --color-typography-50: 38 38 39;
+ --color-typography-100: 64 64 64;
+ --color-typography-200: 82 82 82;
+ --color-typography-300: 115 115 115;
+ --color-typography-400: 140 140 140;
+ --color-typography-500: 163 163 163;
+ --color-typography-600: 212 212 212;
+ --color-typography-700: 219 219 220;
+ --color-typography-800: 229 229 229;
+ --color-typography-900: 245 245 245;
+ --color-typography-950: 254 254 255;
+ --color-outline-0: 26 23 23;
+ --color-outline-50: 39 38 36;
+ --color-outline-100: 65 65 65;
+ --color-outline-200: 83 82 82;
+ --color-outline-300: 115 116 116;
+ --color-outline-400: 140 141 141;
+ --color-outline-500: 165 163 163;
+ --color-outline-600: 211 211 211;
+ --color-outline-700: 221 220 219;
+ --color-outline-800: 230 230 230;
+ --color-outline-900: 243 243 243;
+ --color-outline-950: 253 254 254;
+ --color-background-0: 18 18 18;
+ --color-background-50: 39 38 37;
+ --color-background-100: 65 64 64;
+ --color-background-200: 83 82 82;
+ --color-background-300: 116 116 116;
+ --color-background-400: 142 142 142;
+ --color-background-500: 162 163 163;
+ --color-background-600: 213 212 212;
+ --color-background-700: 220 219 219;
+ --color-background-800: 242 241 241;
+ --color-background-900: 246 246 246;
+ --color-background-950: 254 254 254;
+ --color-background-error: 66 43 43;
+ --color-background-warning: 65 47 35;
+ --color-background-success: 28 43 33;
+ --color-background-muted: 51 51 51;
+ --color-background-info: 26 40 46;
+ --color-indicator-primary: 247 247 247;
+ --color-indicator-info: 161 199 245;
+ --color-indicator-error: 232 70 69;
+ }
+ }
+
+ :root.dark {
+ --color-primary-0: 15 23 42;
+ --color-primary-50: 30 41 59;
+ --color-primary-100: 39 49 66;
+ --color-primary-200: 45 55 72;
+ --color-primary-300: 51 65 85;
+ --color-primary-400: 71 85 105;
+ --color-primary-500: 100 116 139;
+ --color-primary-600: 71 85 105;
+ --color-primary-700: 51 65 85;
+ --color-primary-800: 45 55 72;
+ --color-primary-900: 39 49 66;
+ --color-primary-950: 30 41 59;
+ --color-secondary-0: 11 12 12;
+ --color-secondary-50: 24 23 23;
+ --color-secondary-100: 39 38 38;
+ --color-secondary-200: 63 64 64;
+ --color-secondary-300: 81 82 82;
+ --color-secondary-400: 94 95 95;
+ --color-secondary-500: 114 115 115;
+ --color-secondary-600: 175 176 176;
+ --color-secondary-700: 219 219 219;
+ --color-secondary-800: 231 232 232;
+ --color-secondary-900: 241 242 242;
+ --color-secondary-950: 254 255 255;
+ --color-tertiary-0: 84 49 18;
+ --color-tertiary-50: 108 61 19;
+ --color-tertiary-100: 130 73 23;
+ --color-tertiary-200: 180 98 26;
+ --color-tertiary-300: 215 117 31;
+ --color-tertiary-400: 231 129 40;
+ --color-tertiary-500: 251 157 75;
+ --color-tertiary-600: 253 180 116;
+ --color-tertiary-700: 254 209 170;
+ --color-tertiary-800: 255 233 213;
+ --color-tertiary-900: 255 242 229;
+ --color-tertiary-950: 255 250 245;
+ --color-error-0: 83 19 19;
+ --color-error-50: 127 29 29;
+ --color-error-100: 153 27 27;
+ --color-error-200: 185 28 28;
+ --color-error-300: 220 38 38;
+ --color-error-400: 230 53 53;
+ --color-error-500: 239 68 68;
+ --color-error-600: 248 113 113;
+ --color-error-700: 252 165 165;
+ --color-error-800: 254 202 202;
+ --color-error-900: 254 226 226;
+ --color-error-950: 254 233 233;
+ --color-success-0: 27 50 36;
+ --color-success-50: 20 83 45;
+ --color-success-100: 22 101 52;
+ --color-success-200: 32 111 62;
+ --color-success-300: 42 121 72;
+ --color-success-400: 52 131 82;
+ --color-success-500: 72 151 102;
+ --color-success-600: 102 181 132;
+ --color-success-700: 132 211 162;
+ --color-success-800: 162 241 192;
+ --color-success-900: 202 255 232;
+ --color-success-950: 228 255 244;
+ --color-warning-0: 84 45 18;
+ --color-warning-50: 108 56 19;
+ --color-warning-100: 130 68 23;
+ --color-warning-200: 180 90 26;
+ --color-warning-300: 215 108 31;
+ --color-warning-400: 231 120 40;
+ --color-warning-500: 251 149 75;
+ --color-warning-600: 253 173 116;
+ --color-warning-700: 254 205 170;
+ --color-warning-800: 255 231 213;
+ --color-warning-900: 255 249 245;
+ --color-warning-950: 255 253 251;
+ --color-info-0: 3 38 56;
+ --color-info-50: 5 64 93;
+ --color-info-100: 7 90 131;
+ --color-info-200: 9 115 168;
+ --color-info-300: 11 141 205;
+ --color-info-400: 13 166 242;
+ --color-info-500: 50 180 244;
+ --color-info-600: 87 194 246;
+ --color-info-700: 124 207 248;
+ --color-info-800: 162 221 250;
+ --color-info-900: 199 235 252;
+ --color-info-950: 236 248 254;
+ --color-typography-0: 23 23 23;
+ --color-typography-50: 38 38 39;
+ --color-typography-100: 64 64 64;
+ --color-typography-200: 82 82 82;
+ --color-typography-300: 115 115 115;
+ --color-typography-400: 140 140 140;
+ --color-typography-500: 163 163 163;
+ --color-typography-600: 212 212 212;
+ --color-typography-700: 219 219 220;
+ --color-typography-800: 229 229 229;
+ --color-typography-900: 245 245 245;
+ --color-typography-950: 254 254 255;
+ --color-outline-0: 26 23 23;
+ --color-outline-50: 39 38 36;
+ --color-outline-100: 65 65 65;
+ --color-outline-200: 83 82 82;
+ --color-outline-300: 115 116 116;
+ --color-outline-400: 140 141 141;
+ --color-outline-500: 165 163 163;
+ --color-outline-600: 211 211 211;
+ --color-outline-700: 221 220 219;
+ --color-outline-800: 230 230 230;
+ --color-outline-900: 243 243 243;
+ --color-outline-950: 253 254 254;
+ --color-background-0: 18 18 18;
+ --color-background-50: 39 38 37;
+ --color-background-100: 65 64 64;
+ --color-background-200: 83 82 82;
+ --color-background-300: 116 116 116;
+ --color-background-400: 142 142 142;
+ --color-background-500: 162 163 163;
+ --color-background-600: 213 212 212;
+ --color-background-700: 220 219 219;
+ --color-background-800: 242 241 241;
+ --color-background-900: 246 246 246;
+ --color-background-950: 254 254 254;
+ --color-background-error: 66 43 43;
+ --color-background-warning: 65 47 35;
+ --color-background-success: 28 43 33;
+ --color-background-muted: 51 51 51;
+ --color-background-info: 26 40 46;
+ --color-indicator-primary: 247 247 247;
+ --color-indicator-info: 161 199 245;
+ --color-indicator-error: 232 70 69;
+ }
+
+ :root.light {
+ --color-primary-0: 248 250 252;
+ --color-primary-50: 241 245 249;
+ --color-primary-100: 226 232 240;
+ --color-primary-200: 203 213 225;
+ --color-primary-300: 148 163 184;
+ --color-primary-400: 100 116 139;
+ --color-primary-500: 71 85 105;
+ --color-primary-600: 51 65 85;
+ --color-primary-700: 45 55 72;
+ --color-primary-800: 39 49 66;
+ --color-primary-900: 30 41 59;
+ --color-primary-950: 15 23 42;
+ --color-secondary-0: 254 255 255;
+ --color-secondary-50: 241 242 242;
+ --color-secondary-100: 231 232 232;
+ --color-secondary-200: 219 219 219;
+ --color-secondary-300: 175 176 176;
+ --color-secondary-400: 114 115 115;
+ --color-secondary-500: 94 95 95;
+ --color-secondary-600: 81 82 82;
+ --color-secondary-700: 63 64 64;
+ --color-secondary-800: 39 38 38;
+ --color-secondary-900: 24 23 23;
+ --color-secondary-950: 11 12 12;
+ --color-tertiary-0: 255 250 245;
+ --color-tertiary-50: 255 242 229;
+ --color-tertiary-100: 255 233 213;
+ --color-tertiary-200: 254 209 170;
+ --color-tertiary-300: 253 180 116;
+ --color-tertiary-400: 251 157 75;
+ --color-tertiary-500: 231 129 40;
+ --color-tertiary-600: 215 117 31;
+ --color-tertiary-700: 180 98 26;
+ --color-tertiary-800: 130 73 23;
+ --color-tertiary-900: 108 61 19;
+ --color-tertiary-950: 84 49 18;
+ --color-error-0: 254 233 233;
+ --color-error-50: 254 226 226;
+ --color-error-100: 254 202 202;
+ --color-error-200: 252 165 165;
+ --color-error-300: 248 113 113;
+ --color-error-400: 239 68 68;
+ --color-error-500: 230 53 53;
+ --color-error-600: 220 38 38;
+ --color-error-700: 185 28 28;
+ --color-error-800: 153 27 27;
+ --color-error-900: 127 29 29;
+ --color-error-950: 83 19 19;
+ --color-success-0: 228 255 244;
+ --color-success-50: 202 255 232;
+ --color-success-100: 162 241 192;
+ --color-success-200: 132 211 162;
+ --color-success-300: 102 181 132;
+ --color-success-400: 72 151 102;
+ --color-success-500: 52 131 82;
+ --color-success-600: 42 121 72;
+ --color-success-700: 32 111 62;
+ --color-success-800: 22 101 52;
+ --color-success-900: 20 83 45;
+ --color-success-950: 27 50 36;
+ --color-warning-0: 255 253 251;
+ --color-warning-50: 255 249 245;
+ --color-warning-100: 255 231 213;
+ --color-warning-200: 254 205 170;
+ --color-warning-300: 253 173 116;
+ --color-warning-400: 251 149 75;
+ --color-warning-500: 231 120 40;
+ --color-warning-600: 215 108 31;
+ --color-warning-700: 180 90 26;
+ --color-warning-800: 130 68 23;
+ --color-warning-900: 108 56 19;
+ --color-warning-950: 84 45 18;
+ --color-info-0: 236 248 254;
+ --color-info-50: 199 235 252;
+ --color-info-100: 162 221 250;
+ --color-info-200: 124 207 248;
+ --color-info-300: 87 194 246;
+ --color-info-400: 50 180 244;
+ --color-info-500: 13 166 242;
+ --color-info-600: 11 141 205;
+ --color-info-700: 9 115 168;
+ --color-info-800: 7 90 131;
+ --color-info-900: 5 64 93;
+ --color-info-950: 3 38 56;
+ --color-typography-0: 254 254 255;
+ --color-typography-50: 245 245 245;
+ --color-typography-100: 229 229 229;
+ --color-typography-200: 219 219 220;
+ --color-typography-300: 212 212 212;
+ --color-typography-400: 163 163 163;
+ --color-typography-500: 140 140 140;
+ --color-typography-600: 115 115 115;
+ --color-typography-700: 82 82 82;
+ --color-typography-800: 64 64 64;
+ --color-typography-900: 38 38 39;
+ --color-typography-950: 23 23 23;
+ --color-outline-0: 253 254 254;
+ --color-outline-50: 243 243 243;
+ --color-outline-100: 230 230 230;
+ --color-outline-200: 221 220 219;
+ --color-outline-300: 211 211 211;
+ --color-outline-400: 165 163 163;
+ --color-outline-500: 140 141 141;
+ --color-outline-600: 115 116 116;
+ --color-outline-700: 83 82 82;
+ --color-outline-800: 65 65 65;
+ --color-outline-900: 39 38 36;
+ --color-outline-950: 26 23 23;
+ --color-background-0: 255 255 255;
+ --color-background-50: 246 246 246;
+ --color-background-100: 242 241 241;
+ --color-background-200: 220 219 219;
+ --color-background-300: 213 212 212;
+ --color-background-400: 162 163 163;
+ --color-background-500: 142 142 142;
+ --color-background-600: 116 116 116;
+ --color-background-700: 83 82 82;
+ --color-background-800: 65 64 64;
+ --color-background-900: 39 38 37;
+ --color-background-950: 24 23 24;
+ --color-background-error: 254 241 241;
+ --color-background-warning: 255 244 235;
+ --color-background-success: 237 252 242;
+ --color-background-muted: 247 248 247;
+ --color-background-info: 235 248 254;
+ --color-indicator-primary: 55 55 55;
+ --color-indicator-info: 83 153 236;
+ --color-indicator-error: 185 28 28;
+ }
+}
+
+/* ─── Tailwind v4 utilities for the legacy gluestack v2 token scales ── */
+@theme inline {
+ --color-primary-0: rgb(var(--color-primary-0));
+ --color-primary-50: rgb(var(--color-primary-50));
+ --color-primary-100: rgb(var(--color-primary-100));
+ --color-primary-200: rgb(var(--color-primary-200));
+ --color-primary-300: rgb(var(--color-primary-300));
+ --color-primary-400: rgb(var(--color-primary-400));
+ --color-primary-500: rgb(var(--color-primary-500));
+ --color-primary-600: rgb(var(--color-primary-600));
+ --color-primary-700: rgb(var(--color-primary-700));
+ --color-primary-800: rgb(var(--color-primary-800));
+ --color-primary-900: rgb(var(--color-primary-900));
+ --color-primary-950: rgb(var(--color-primary-950));
+ --color-secondary-0: rgb(var(--color-secondary-0));
+ --color-secondary-50: rgb(var(--color-secondary-50));
+ --color-secondary-100: rgb(var(--color-secondary-100));
+ --color-secondary-200: rgb(var(--color-secondary-200));
+ --color-secondary-300: rgb(var(--color-secondary-300));
+ --color-secondary-400: rgb(var(--color-secondary-400));
+ --color-secondary-500: rgb(var(--color-secondary-500));
+ --color-secondary-600: rgb(var(--color-secondary-600));
+ --color-secondary-700: rgb(var(--color-secondary-700));
+ --color-secondary-800: rgb(var(--color-secondary-800));
+ --color-secondary-900: rgb(var(--color-secondary-900));
+ --color-secondary-950: rgb(var(--color-secondary-950));
+ --color-tertiary-0: rgb(var(--color-tertiary-0));
+ --color-tertiary-50: rgb(var(--color-tertiary-50));
+ --color-tertiary-100: rgb(var(--color-tertiary-100));
+ --color-tertiary-200: rgb(var(--color-tertiary-200));
+ --color-tertiary-300: rgb(var(--color-tertiary-300));
+ --color-tertiary-400: rgb(var(--color-tertiary-400));
+ --color-tertiary-500: rgb(var(--color-tertiary-500));
+ --color-tertiary-600: rgb(var(--color-tertiary-600));
+ --color-tertiary-700: rgb(var(--color-tertiary-700));
+ --color-tertiary-800: rgb(var(--color-tertiary-800));
+ --color-tertiary-900: rgb(var(--color-tertiary-900));
+ --color-tertiary-950: rgb(var(--color-tertiary-950));
+ --color-error-0: rgb(var(--color-error-0));
+ --color-error-50: rgb(var(--color-error-50));
+ --color-error-100: rgb(var(--color-error-100));
+ --color-error-200: rgb(var(--color-error-200));
+ --color-error-300: rgb(var(--color-error-300));
+ --color-error-400: rgb(var(--color-error-400));
+ --color-error-500: rgb(var(--color-error-500));
+ --color-error-600: rgb(var(--color-error-600));
+ --color-error-700: rgb(var(--color-error-700));
+ --color-error-800: rgb(var(--color-error-800));
+ --color-error-900: rgb(var(--color-error-900));
+ --color-error-950: rgb(var(--color-error-950));
+ --color-success-0: rgb(var(--color-success-0));
+ --color-success-50: rgb(var(--color-success-50));
+ --color-success-100: rgb(var(--color-success-100));
+ --color-success-200: rgb(var(--color-success-200));
+ --color-success-300: rgb(var(--color-success-300));
+ --color-success-400: rgb(var(--color-success-400));
+ --color-success-500: rgb(var(--color-success-500));
+ --color-success-600: rgb(var(--color-success-600));
+ --color-success-700: rgb(var(--color-success-700));
+ --color-success-800: rgb(var(--color-success-800));
+ --color-success-900: rgb(var(--color-success-900));
+ --color-success-950: rgb(var(--color-success-950));
+ --color-warning-0: rgb(var(--color-warning-0));
+ --color-warning-50: rgb(var(--color-warning-50));
+ --color-warning-100: rgb(var(--color-warning-100));
+ --color-warning-200: rgb(var(--color-warning-200));
+ --color-warning-300: rgb(var(--color-warning-300));
+ --color-warning-400: rgb(var(--color-warning-400));
+ --color-warning-500: rgb(var(--color-warning-500));
+ --color-warning-600: rgb(var(--color-warning-600));
+ --color-warning-700: rgb(var(--color-warning-700));
+ --color-warning-800: rgb(var(--color-warning-800));
+ --color-warning-900: rgb(var(--color-warning-900));
+ --color-warning-950: rgb(var(--color-warning-950));
+ --color-info-0: rgb(var(--color-info-0));
+ --color-info-50: rgb(var(--color-info-50));
+ --color-info-100: rgb(var(--color-info-100));
+ --color-info-200: rgb(var(--color-info-200));
+ --color-info-300: rgb(var(--color-info-300));
+ --color-info-400: rgb(var(--color-info-400));
+ --color-info-500: rgb(var(--color-info-500));
+ --color-info-600: rgb(var(--color-info-600));
+ --color-info-700: rgb(var(--color-info-700));
+ --color-info-800: rgb(var(--color-info-800));
+ --color-info-900: rgb(var(--color-info-900));
+ --color-info-950: rgb(var(--color-info-950));
+ --color-typography-0: rgb(var(--color-typography-0));
+ --color-typography-50: rgb(var(--color-typography-50));
+ --color-typography-100: rgb(var(--color-typography-100));
+ --color-typography-200: rgb(var(--color-typography-200));
+ --color-typography-300: rgb(var(--color-typography-300));
+ --color-typography-400: rgb(var(--color-typography-400));
+ --color-typography-500: rgb(var(--color-typography-500));
+ --color-typography-600: rgb(var(--color-typography-600));
+ --color-typography-700: rgb(var(--color-typography-700));
+ --color-typography-800: rgb(var(--color-typography-800));
+ --color-typography-900: rgb(var(--color-typography-900));
+ --color-typography-950: rgb(var(--color-typography-950));
+ --color-outline-0: rgb(var(--color-outline-0));
+ --color-outline-50: rgb(var(--color-outline-50));
+ --color-outline-100: rgb(var(--color-outline-100));
+ --color-outline-200: rgb(var(--color-outline-200));
+ --color-outline-300: rgb(var(--color-outline-300));
+ --color-outline-400: rgb(var(--color-outline-400));
+ --color-outline-500: rgb(var(--color-outline-500));
+ --color-outline-600: rgb(var(--color-outline-600));
+ --color-outline-700: rgb(var(--color-outline-700));
+ --color-outline-800: rgb(var(--color-outline-800));
+ --color-outline-900: rgb(var(--color-outline-900));
+ --color-outline-950: rgb(var(--color-outline-950));
+ --color-background-0: rgb(var(--color-background-0));
+ --color-background-50: rgb(var(--color-background-50));
+ --color-background-100: rgb(var(--color-background-100));
+ --color-background-200: rgb(var(--color-background-200));
+ --color-background-300: rgb(var(--color-background-300));
+ --color-background-400: rgb(var(--color-background-400));
+ --color-background-500: rgb(var(--color-background-500));
+ --color-background-600: rgb(var(--color-background-600));
+ --color-background-700: rgb(var(--color-background-700));
+ --color-background-800: rgb(var(--color-background-800));
+ --color-background-900: rgb(var(--color-background-900));
+ --color-background-950: rgb(var(--color-background-950));
+ --color-background-error: rgb(var(--color-background-error));
+ --color-background-warning: rgb(var(--color-background-warning));
+ --color-background-success: rgb(var(--color-background-success));
+ --color-background-muted: rgb(var(--color-background-muted));
+ --color-background-info: rgb(var(--color-background-info));
+ --color-indicator-primary: rgb(var(--color-indicator-primary));
+ --color-indicator-info: rgb(var(--color-indicator-info));
+ --color-indicator-error: rgb(var(--color-indicator-error));
+}
+
+/* ─── Non-color theme extensions (ported from tailwind.config.js) ── */
+@theme {
+ --font-roboto: Roboto, sans-serif;
+ --font-weight-extrablack: 950;
+ --text-2xs: 10px;
+ --color-typography-white: #ffffff;
+ --color-typography-gray: #d4d4d4;
+ --color-typography-black: #181718;
+ --color-background-light: #fbfbfb;
+ --color-background-dark: #181719;
+ --shadow-hard-1: -2px 2px 8px 0px rgba(38, 38, 38, 0.2);
+ --shadow-hard-2: 0px 3px 10px 0px rgba(38, 38, 38, 0.2);
+ --shadow-hard-3: 2px 2px 8px 0px rgba(38, 38, 38, 0.2);
+ --shadow-hard-4: 0px -3px 10px 0px rgba(38, 38, 38, 0.2);
+ --shadow-hard-5: 0px 2px 10px 0px rgba(38, 38, 38, 0.1);
+ --shadow-soft-1: 0px 0px 10px rgba(38, 38, 38, 0.1);
+ --shadow-soft-2: 0px 0px 20px rgba(38, 38, 38, 0.2);
+ --shadow-soft-3: 0px 0px 30px rgba(38, 38, 38, 0.1);
+ --shadow-soft-4: 0px 0px 40px rgba(38, 38, 38, 0.1);
+}
+
+/* Web-only skeleton loading animation */
+@keyframes skeleton-pulse {
+ 0%, 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.75;
+ }
+}
diff --git a/tsconfig.json b/tsconfig.json
index b3b8955..9de46fe 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -15,8 +15,7 @@
"paths": {
"@/*": ["./src/*"],
"@env": ["./src/lib/env.js"],
- "@assets/*": ["./assets/*"],
- "tailwind.config": ["./tailwind.config.js"]
+ "@assets/*": ["./assets/*"]
},
"esModuleInterop": true,
"checkJs": true,