From 51d59686b3d0bd785619db88155f6398e0b9882e Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Wed, 12 Aug 2026 20:24:13 -0700 Subject: [PATCH 1/2] RC-T40 Chat Fixes --- global.css | 730 +----------------- global.web.css | 12 + src/app/(app)/chat.tsx | 7 +- src/app/_layout.tsx | 4 +- .../chat/__tests__/chat-utils.test.ts | 27 +- src/components/chat/chat-utils.ts | 2 + .../ui/gluestack-ui-provider/index.tsx | 16 +- src/components/ui/input/index.tsx | 42 +- src/components/ui/select/index.tsx | 33 +- src/hooks/use-signalr-lifecycle.ts | 10 + src/lib/theme-styles.ts | 9 + src/lib/theme-styles.web.ts | 7 + src/models/v4/chat/chatEnums.ts | 2 + .../__tests__/incident-command-store.test.ts | 78 ++ src/stores/calls/incident-command-store.ts | 35 +- src/stores/signalr/signalr-store.ts | 32 + theme-tokens.css | 720 +++++++++++++++++ 17 files changed, 1028 insertions(+), 738 deletions(-) create mode 100644 global.web.css create mode 100644 src/lib/theme-styles.ts create mode 100644 src/lib/theme-styles.web.ts create mode 100644 theme-tokens.css diff --git a/global.css b/global.css index 0b211685..9298e6e9 100644 --- a/global.css +++ b/global.css @@ -3,721 +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 pulse animation for user location marker on map */ -@keyframes pulse-ring { - 0%, 100% { - transform: scale(1); - opacity: 0.3; - } - 50% { - transform: scale(1.2); - opacity: 0.15; - } -} - -/* Web-only: applied to the location marker outer ring (replaces Animated.loop) */ -.pulse-ring { - animation: pulse-ring 2s ease-in-out infinite; -} - -/* 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 00000000..cf123fb6 --- /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/src/app/(app)/chat.tsx b/src/app/(app)/chat.tsx index bdfae254..a0292c94 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/_layout.tsx b/src/app/_layout.tsx index 77787029..1a7a0549 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -1,5 +1,5 @@ -// Import global CSS file -import '../../global.css'; +// Import global CSS (platform-specific entry: global.css on native, global.web.css on web) +import '../lib/theme-styles'; import '../lib/i18n'; // Side-effect import: registers the full app-data wipe as the session-cleanup // handler for every logout path (manual, forced 401, refresh rejection). diff --git a/src/components/chat/__tests__/chat-utils.test.ts b/src/components/chat/__tests__/chat-utils.test.ts index f9aff7e2..78b60e37 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 3d0476b0..f2a939e7 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 8d5f01ba..30eab037 100644 --- a/src/components/ui/gluestack-ui-provider/index.tsx +++ b/src/components/ui/gluestack-ui-provider/index.tsx @@ -2,23 +2,23 @@ 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 { 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; - + // 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. useEffect(() => { 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 a9fa0596..9ecd718d 100644 --- a/src/components/ui/input/index.tsx +++ b/src/components/ui/input/index.tsx @@ -4,10 +4,44 @@ import { createInput } from '@gluestack-ui/core/input/creator'; import { tva, useStyleContext, type VariantProps, withStyleContext } from '@gluestack-ui/utils/nativewind-utils'; import { styled } from 'nativewind'; import React from 'react'; -import { Pressable, TextInput, View } from 'react-native'; +import { Platform, Pressable, TextInput, View } from 'react-native'; const SCOPE = 'INPUT'; +/** + * 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 }, +}; + +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]); + const StyledUIIcon = styled(UIIcon, { className: 'style' }); const UIInput = createInput({ @@ -60,7 +94,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 +170,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 d8dae422..a909c88b 100644 --- a/src/components/ui/select/index.tsx +++ b/src/components/ui/select/index.tsx @@ -7,7 +7,7 @@ import { tva } from '@gluestack-ui/utils/nativewind-utils'; import { useStyleContext, withStyleContext } from '@gluestack-ui/utils/nativewind-utils'; import { styled } from 'nativewind'; import React from 'react'; -import { Pressable, TextInput, View } from 'react-native'; +import { Platform, Pressable, TextInput, View } from 'react-native'; import { Actionsheet, @@ -66,8 +66,33 @@ const selectTriggerStyle = tva({ }, }); +/** + * Mirrors the Input component: on Android the class `h-full` resolves taller than the fixed-height + * trigger and the trigger's overflow-hidden clips the top of the value, so the field gets a concrete + * height and a lineHeight near the font size instead. iOS keeps the zero lineHeight that + * `ios:leading-[0px]` used to supply. + */ +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 }, +}; + +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]); + 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 +171,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/hooks/use-signalr-lifecycle.ts b/src/hooks/use-signalr-lifecycle.ts index efb82175..a7dbf268 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 { useIncidentCommandStore } from '@/stores/calls/incident-command-store'; import { useSignalRStore } from '@/stores/signalr/signalr-store'; import { useAppLifecycle } from './use-app-lifecycle'; @@ -134,6 +135,15 @@ export function useSignalRLifecycle({ isSignedIn, hasInitialized }: UseSignalRLi }); } }); + + // The hubs replay nothing pushed while the app was away, so incident command changes made + // during the gap would stay invisible. Reconnecting restores the feed; this backfills it. + if (results[0]?.status === 'fulfilled') { + const openCallId = useIncidentCommandStore.getState().callId; + if (openCallId) { + useIncidentCommandStore.getState().handleIncidentCommandUpdated(openCallId); + } + } } 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 00000000..7a80cba2 --- /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 00000000..3f1d5289 --- /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 11916f0c..ce9075bd 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/calls/__tests__/incident-command-store.test.ts b/src/stores/calls/__tests__/incident-command-store.test.ts index 35e24edc..05b9b7b1 100644 --- a/src/stores/calls/__tests__/incident-command-store.test.ts +++ b/src/stores/calls/__tests__/incident-command-store.test.ts @@ -326,4 +326,82 @@ describe('useIncidentCommandStore', () => { unmount(); }); }); + + describe('handleIncidentCommandUpdated (realtime)', () => { + /** + * The IC moving resources pushes incidentCommandUpdated for the call; the crew's panel has to + * follow along without blanking, so this refetches in place rather than via fetchIncidentView. + */ + it('refreshes the view in place for the call being viewed', async () => { + const mockView = createMockView(); + mockGetResourceIncidentView.mockResolvedValue({ Data: mockView, Status: 'Ok' } as any); + + const { result, unmount } = renderHook(() => useIncidentCommandStore()); + + await act(async () => { + await result.current.fetchIncidentView('call123'); + }); + + const updated = { ...mockView, ImportantInformation: 'Structure now unstable' }; + mockGetResourceIncidentView.mockResolvedValue({ Data: updated, Status: 'Ok' } as any); + + act(() => { + result.current.handleIncidentCommandUpdated('call123'); + }); + + await waitFor(() => { + expect(result.current.view).toEqual(updated); + }); + // The view must never blank while refreshing. + expect(result.current.isLoading).toBe(false); + + unmount(); + }); + + it('ignores an update for a different call', async () => { + const mockView = createMockView(); + mockGetResourceIncidentView.mockResolvedValue({ Data: mockView, Status: 'Ok' } as any); + + const { result, unmount } = renderHook(() => useIncidentCommandStore()); + + await act(async () => { + await result.current.fetchIncidentView('call123'); + }); + mockGetResourceIncidentView.mockClear(); + + act(() => { + result.current.handleIncidentCommandUpdated('other-call'); + }); + + expect(mockGetResourceIncidentView).not.toHaveBeenCalled(); + + unmount(); + }); + + it('keeps the current view when the refresh fails', async () => { + const mockView = createMockView(); + mockGetResourceIncidentView.mockResolvedValue({ Data: mockView, Status: 'Ok' } as any); + + const { result, unmount } = renderHook(() => useIncidentCommandStore()); + + await act(async () => { + await result.current.fetchIncidentView('call123'); + }); + + mockGetResourceIncidentView.mockRejectedValue(new Error('network down')); + + act(() => { + result.current.handleIncidentCommandUpdated('call123'); + }); + + await waitFor(() => { + expect(mockGetResourceIncidentView).toHaveBeenCalledTimes(2); + }); + // A failed background refresh must not wipe what the crew is reading. + expect(result.current.view).toEqual(mockView); + + unmount(); + }); + }); + }); diff --git a/src/stores/calls/incident-command-store.ts b/src/stores/calls/incident-command-store.ts index 7a3a8ad3..101b53a6 100644 --- a/src/stores/calls/incident-command-store.ts +++ b/src/stores/calls/incident-command-store.ts @@ -1,6 +1,7 @@ import { create } from 'zustand'; import { getResourceIncidentView } from '@/api/calls/incidentCommand'; +import { logger } from '@/lib/logging'; import { type ResourceIncidentView } from '@/models/v4/incidentCommand/resourceIncidentView'; import { INCIDENT_VIEW_STATUS_NOT_FOUND } from '@/models/v4/incidentCommand/resourceIncidentViewResult'; import { useCoreStore } from '@/stores/app/core-store'; @@ -9,7 +10,15 @@ interface IncidentCommandState { view: ResourceIncidentView | null; isLoading: boolean; error: string | null; + /** The call whose incident view is loaded, so realtime updates can be matched to it. */ + callId: string | null; fetchIncidentView: (callId: string) => Promise; + /** + * Realtime refresh for the call being viewed. Unlike fetchIncidentView this keeps the current view + * on screen while refetching — the IC moving resources should update the panel in place rather + * than blanking it and flashing a spinner on every change. + */ + handleIncidentCommandUpdated: (callId: string) => void; reset: () => void; } @@ -17,22 +26,44 @@ interface IncidentCommandState { // fetch bump it, so an in-flight request that has been superseded is discarded. let requestSeq = 0; -export const useIncidentCommandStore = create((set) => ({ +export const useIncidentCommandStore = create((set, get) => ({ view: null, isLoading: false, error: null, + callId: null, reset: () => { requestSeq++; set({ view: null, isLoading: false, error: null, + callId: null, }); }, + handleIncidentCommandUpdated: (callId: string) => { + const state = get(); + if (!state.callId || state.callId !== String(callId)) { + return; + } + + const seq = ++requestSeq; + void (async () => { + try { + const activeUnitId = useCoreStore.getState().activeUnitId; + const result = await getResourceIncidentView(callId, activeUnitId ?? undefined); + if (seq !== requestSeq) return; + set({ view: result && result.Data && result.Status !== INCIDENT_VIEW_STATUS_NOT_FOUND ? result.Data : null, error: null }); + } catch (error) { + if (seq !== requestSeq) return; + // A failed background refresh must not wipe the view the crew is reading. + logger.warn({ message: 'IncidentCommand: realtime refresh failed', context: { callId, error } }); + } + })(); + }, fetchIncidentView: async (callId: string) => { const seq = ++requestSeq; // Clear the previous call's view so it can never render for the new call. - set({ view: null, isLoading: true, error: null }); + set({ view: null, isLoading: true, error: null, callId: String(callId) }); try { const activeUnitId = useCoreStore.getState().activeUnitId; const result = await getResourceIncidentView(callId, activeUnitId ?? undefined); diff --git a/src/stores/signalr/signalr-store.ts b/src/stores/signalr/signalr-store.ts index ee82227b..4956d63c 100644 --- a/src/stores/signalr/signalr-store.ts +++ b/src/stores/signalr/signalr-store.ts @@ -6,6 +6,7 @@ import { logger } from '@/lib/logging'; import { SignalRService, signalRService } from '@/services/signalr.service'; import { useCoreStore } from '../app/core-store'; +import { useIncidentCommandStore } from '../calls/incident-command-store'; import { useChatStore } from '../chat/store'; import { FeatureFlagKeys, featureFlagsStore } from '../feature-flags/store'; import { securityStore } from '../security/store'; @@ -160,6 +161,26 @@ function extractAlertId(message: unknown): string | undefined { return undefined; } +/** + * The affected incident's call id. Core sends it as a bare string — the eventing worker forwards the + * topic's ItemId, which is CallId.ToString() — with object payloads tolerated for safety. + */ +function extractCommandCallId(message: unknown): string | undefined { + if (typeof message === 'string') { + const trimmed = message.trim(); + return trimmed.length > 0 ? trimmed : undefined; + } + if (typeof message === 'number' && Number.isFinite(message)) { + return String(message); + } + if (message !== null && typeof message === 'object') { + const m = message as { CallId?: string | number; callId?: string | number }; + const id = m.CallId ?? m.callId; + return id !== undefined && id !== null && String(id).trim().length > 0 ? String(id).trim() : undefined; + } + return undefined; +} + /** Update-hub events that carry a per-event timestamp for targeted refetches. */ export const UPDATE_HUB_EVENTS = [ 'personnelStatusUpdated', @@ -171,6 +192,7 @@ export const UPDATE_HUB_EVENTS = [ 'weatherAlertReceived', 'weatherAlertUpdated', 'weatherAlertExpired', + 'incidentCommandUpdated', ] as const; export type UpdateHubEvent = (typeof UPDATE_HUB_EVENTS)[number]; @@ -367,6 +389,16 @@ export const useSignalRStore = create((set, get) => ({ } }); + signalRService.on('incidentCommandUpdated', (message) => { + recordEvent('incidentCommandUpdated')(message); + const callId = extractCommandCallId(message); + if (callId) { + useIncidentCommandStore.getState().handleIncidentCommandUpdated(callId); + } else { + logger.warn({ message: 'incidentCommandUpdated: could not extract callId from message', context: { message } }); + } + }); + signalRService.on('onConnected', () => { logger.info({ message: 'Connected to update SignalR hub', diff --git a/theme-tokens.css b/theme-tokens.css new file mode 100644 index 00000000..3b21a520 --- /dev/null +++ b/theme-tokens.css @@ -0,0 +1,720 @@ +/* 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 pulse animation for user location marker on map */ +@keyframes pulse-ring { + 0%, 100% { + transform: scale(1); + opacity: 0.3; + } + 50% { + transform: scale(1.2); + opacity: 0.15; + } +} + +/* Web-only: applied to the location marker outer ring (replaces Animated.loop) */ +.pulse-ring { + animation: pulse-ring 2s ease-in-out infinite; +} + +/* Web-only skeleton loading animation */ +@keyframes skeleton-pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.75; + } +} From 81be78128663b38a31e98cc6a949820f6b6bf08e Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Wed, 12 Aug 2026 21:21:48 -0700 Subject: [PATCH 2/2] RC-T39 PR#267 fixes --- gluestack-ui.config.json | 1 - .../ui/gluestack-ui-provider/index.tsx | 4 +- src/components/ui/input/index.tsx | 38 ++----------------- src/components/ui/select/index.tsx | 29 ++------------ src/components/ui/text-field-metrics.ts | 36 ++++++++++++++++++ src/hooks/use-signalr-lifecycle.ts | 4 +- tsconfig.json | 3 +- 7 files changed, 48 insertions(+), 67 deletions(-) create mode 100644 src/components/ui/text-field-metrics.ts diff --git a/gluestack-ui.config.json b/gluestack-ui.config.json index ca842a19..7a9848a6 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/components/ui/gluestack-ui-provider/index.tsx b/src/components/ui/gluestack-ui-provider/index.tsx index 30eab037..c545cae4 100644 --- a/src/components/ui/gluestack-ui-provider/index.tsx +++ b/src/components/ui/gluestack-ui-provider/index.tsx @@ -1,7 +1,7 @@ 'use client'; import { OverlayProvider } from '@gluestack-ui/core/overlay/creator'; import { ToastProvider } from '@gluestack-ui/core/toast/creator'; -import React, { useEffect } from 'react'; +import React, { useLayoutEffect } from 'react'; import { Appearance, View, type ViewProps } from 'react-native'; export type ModeType = 'light' | 'dark' | 'system'; @@ -9,7 +9,7 @@ export type ModeType = 'light' | 'dark' | 'system'; export function GluestackUIProvider({ mode = 'light', ...props }: { mode?: ModeType; children?: React.ReactNode; style?: ViewProps['style'] }) { // 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. - useEffect(() => { + useLayoutEffect(() => { Appearance.setColorScheme(mode === 'system' ? 'unspecified' : mode); }, [mode]); diff --git a/src/components/ui/input/index.tsx b/src/components/ui/input/index.tsx index 9ecd718d..5f2157d9 100644 --- a/src/components/ui/input/index.tsx +++ b/src/components/ui/input/index.tsx @@ -4,43 +4,11 @@ import { createInput } from '@gluestack-ui/core/input/creator'; import { tva, useStyleContext, type VariantProps, withStyleContext } from '@gluestack-ui/utils/nativewind-utils'; import { styled } from 'nativewind'; import React from 'react'; -import { Platform, Pressable, TextInput, View } from 'react-native'; +import { Pressable, TextInput, View } from 'react-native'; -const SCOPE = 'INPUT'; +import { useTextFieldVerticalFix } from '../text-field-metrics'; -/** - * 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 }, -}; - -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]); +const SCOPE = 'INPUT'; const StyledUIIcon = styled(UIIcon, { className: 'style' }); diff --git a/src/components/ui/select/index.tsx b/src/components/ui/select/index.tsx index a909c88b..eee3c38a 100644 --- a/src/components/ui/select/index.tsx +++ b/src/components/ui/select/index.tsx @@ -7,8 +7,10 @@ import { tva } from '@gluestack-ui/utils/nativewind-utils'; import { useStyleContext, withStyleContext } from '@gluestack-ui/utils/nativewind-utils'; import { styled } from 'nativewind'; import React from 'react'; -import { Platform, Pressable, TextInput, View } from 'react-native'; +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, @@ -66,31 +68,6 @@ const selectTriggerStyle = tva({ }, }); -/** - * Mirrors the Input component: on Android the class `h-full` resolves taller than the fixed-height - * trigger and the trigger's overflow-hidden clips the top of the value, so the field gets a concrete - * height and a lineHeight near the font size instead. iOS keeps the zero lineHeight that - * `ios:leading-[0px]` used to supply. - */ -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 }, -}; - -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]); - 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', parentVariants: { diff --git a/src/components/ui/text-field-metrics.ts b/src/components/ui/text-field-metrics.ts new file mode 100644 index 00000000..cd665530 --- /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 a7dbf268..b156422e 100644 --- a/src/hooks/use-signalr-lifecycle.ts +++ b/src/hooks/use-signalr-lifecycle.ts @@ -138,7 +138,9 @@ export function useSignalRLifecycle({ isSignedIn, hasInitialized }: UseSignalRLi // The hubs replay nothing pushed while the app was away, so incident command changes made // during the gap would stay invisible. Reconnecting restores the feed; this backfills it. - if (results[0]?.status === 'fulfilled') { + // 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) { const openCallId = useIncidentCommandStore.getState().callId; if (openCallId) { useIncidentCommandStore.getState().handleIncidentCommandUpdated(openCallId); diff --git a/tsconfig.json b/tsconfig.json index afe8606f..7ef79236 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,