@micha.bigler/ui-core-micha 2.33.1 → 2.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/WORK_ORDERS.md +4 -2
  2. package/dist/auth/ReauthModal.js +3 -1
  3. package/dist/components/AccessCodeManager.js +16 -14
  4. package/dist/components/AccessCodeSingleUseToggle.js +2 -1
  5. package/dist/components/AllowedEmailDomainsManager.js +3 -1
  6. package/dist/components/AuthFactorRequirementCard.js +2 -1
  7. package/dist/components/BulkInviteCsvTab.js +7 -7
  8. package/dist/components/LoginForm.js +2 -1
  9. package/dist/components/MFAComponent.js +11 -5
  10. package/dist/components/MfaLoginComponent.js +4 -1
  11. package/dist/components/PasskeysComponent.js +4 -2
  12. package/dist/components/QrSignupManager.js +2 -1
  13. package/dist/components/QrSignupValidityManager.js +4 -1
  14. package/dist/components/RegistrationMethodsManager.js +3 -1
  15. package/dist/components/SecurityComponent.js +3 -1
  16. package/dist/components/SupportRecoveryRequestsTab.js +4 -1
  17. package/dist/components/UserInviteComponent.js +8 -8
  18. package/dist/components/UserListComponent.js +8 -4
  19. package/dist/components/charts/ChartFrame.js +5 -2
  20. package/dist/components/charts/TimeSeriesChart.js +7 -4
  21. package/dist/index.js +1 -1
  22. package/dist/layout/PageLayout.js +3 -2
  23. package/dist/messaging/AttachmentList.js +6 -1
  24. package/dist/messaging/Composer.js +2 -1
  25. package/dist/messaging/DirectMessageLauncher.js +2 -1
  26. package/dist/messaging/MessageBubble.js +29 -8
  27. package/dist/messaging/PollCard.js +6 -4
  28. package/dist/messaging/Thread.js +2 -1
  29. package/dist/notifications/NotificationSettings.js +17 -3
  30. package/dist/notifications/PopupSurface.js +2 -1
  31. package/dist/onboarding/steps/BrowserPushStep.js +2 -1
  32. package/dist/pages/AccountPage.js +2 -1
  33. package/dist/pages/LoginPage.js +2 -1
  34. package/dist/pages/SignUpPage.js +2 -1
  35. package/dist/pages/SignupConfirmPage.js +2 -1
  36. package/dist/theme/index.js +2 -1
  37. package/dist/theme/kitSxRegistry.js +187 -0
  38. package/dist/theme/themeCompleteness.js +218 -0
  39. package/dist/theme/tokens.js +1 -0
  40. package/package.json +1 -1
  41. package/src/auth/ReauthModal.jsx +5 -2
  42. package/src/components/AccessCodeManager.jsx +22 -20
  43. package/src/components/AccessCodeSingleUseToggle.jsx +4 -2
  44. package/src/components/AllowedEmailDomainsManager.jsx +6 -3
  45. package/src/components/AuthFactorRequirementCard.jsx +4 -2
  46. package/src/components/BulkInviteCsvTab.jsx +11 -10
  47. package/src/components/LoginForm.jsx +5 -3
  48. package/src/components/MFAComponent.jsx +14 -7
  49. package/src/components/MfaLoginComponent.jsx +9 -5
  50. package/src/components/PasskeysComponent.jsx +7 -4
  51. package/src/components/QrSignupManager.jsx +6 -4
  52. package/src/components/QrSignupValidityManager.jsx +8 -4
  53. package/src/components/RegistrationMethodsManager.jsx +7 -4
  54. package/src/components/SecurityComponent.jsx +10 -7
  55. package/src/components/SupportRecoveryRequestsTab.jsx +8 -4
  56. package/src/components/UserInviteComponent.jsx +10 -10
  57. package/src/components/UserListComponent.jsx +12 -7
  58. package/src/components/charts/ChartFrame.jsx +6 -2
  59. package/src/components/charts/TimeSeriesChart.jsx +8 -4
  60. package/src/index.js +3 -0
  61. package/src/layout/PageLayout.jsx +4 -2
  62. package/src/messaging/AttachmentList.jsx +9 -3
  63. package/src/messaging/Composer.jsx +3 -1
  64. package/src/messaging/DirectMessageLauncher.jsx +3 -1
  65. package/src/messaging/MessageBubble.jsx +31 -9
  66. package/src/messaging/PollCard.jsx +8 -6
  67. package/src/messaging/Thread.jsx +3 -1
  68. package/src/notifications/NotificationSettings.jsx +24 -9
  69. package/src/notifications/PopupSurface.jsx +3 -1
  70. package/src/onboarding/steps/BrowserPushStep.jsx +3 -1
  71. package/src/pages/AccountPage.jsx +11 -9
  72. package/src/pages/LoginPage.jsx +6 -4
  73. package/src/pages/SignUpPage.jsx +4 -2
  74. package/src/pages/SignupConfirmPage.jsx +4 -2
  75. package/src/theme/index.js +3 -0
  76. package/src/theme/kitSxRegistry.js +246 -0
  77. package/src/theme/themeCompleteness.js +228 -0
  78. package/src/theme/tokens.js +1 -0
  79. package/tests/createAppTheme.test.js +1 -0
  80. package/tests/themeCompleteness.test.js +214 -0
  81. package/work-orders/THEME-3.md +210 -0
  82. package/work-orders/THEME-4.md +271 -0
@@ -11,6 +11,9 @@ import {
11
11
  import { useTranslation } from 'react-i18next';
12
12
  import { exportChartPng, exportChartSvg } from './exportChart';
13
13
 
14
+ export const CHART_FRAME_ROOT_SX = { p: 2 };
15
+ export const CHART_FRAME_ALERT_SX = { mt: 2 };
16
+
14
17
  /**
15
18
  * Chart-type-agnostic chart scaffolding. Children may be an X-Charts primitive or a bespoke SVG.
16
19
  */
@@ -44,6 +47,7 @@ export function ChartFrame({
44
47
  const hasMeta = meta !== undefined && meta !== null && meta !== false && meta !== '';
45
48
  const hasCustomAriaLabel = Boolean(ariaLabel);
46
49
  const chartLabel = ariaLabel || title;
50
+ const callerSx = Array.isArray(sx) ? sx : [sx];
47
51
 
48
52
  const runExport = async (type) => {
49
53
  setExportError(false);
@@ -69,7 +73,7 @@ export function ChartFrame({
69
73
  }
70
74
 
71
75
  return (
72
- <Paper variant={variant} sx={{ p: 2, ...sx }}>
76
+ <Paper variant={variant} sx={[CHART_FRAME_ROOT_SX, ...callerSx]}>
73
77
  <Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2, mb: 2 }}>
74
78
  <Box>
75
79
  <Typography id={titleId} variant={titleVariant}>{title}</Typography>
@@ -101,7 +105,7 @@ export function ChartFrame({
101
105
  {content}
102
106
  </Box>
103
107
 
104
- {exportError && <Alert severity="error" sx={{ mt: 2 }}>{t('ChartFrame.EXPORT_ERROR')}</Alert>}
108
+ {exportError && <Alert severity="error" sx={CHART_FRAME_ALERT_SX}>{t('ChartFrame.EXPORT_ERROR')}</Alert>}
105
109
 
106
110
  {/* `meta` opts the card into the single bordered foot row (meta left, exports right).
107
111
  Without it the markup below is byte-for-byte the pre-`meta` structure -- a bare Stack,
@@ -32,6 +32,13 @@ import { useNeutralChartPalette } from './palette';
32
32
  // MUI measurement bug is actually resolved.
33
33
  const CHART_HEIGHT = 320;
34
34
 
35
+ // Dynamic sx factories are outside the registry's documented top-level-object
36
+ // lower bound; the series colour is data-dependent at render time.
37
+ const seriesToggleSx = (colour) => ({
38
+ color: colour,
39
+ '&.Mui-checked': { color: colour },
40
+ });
41
+
35
42
  // CHART-5: how many x-axis tick labels to show, evenly spread across
36
43
  // data.xLabels, regardless of container width or label length. Replaces
37
44
  // MUI's own collision-based tick filtering (which, given enough densely
@@ -240,10 +247,7 @@ export function TimeSeriesChart({
240
247
  onChange={() => toggleSeries(series.key)}
241
248
  icon={<SeriesToggleDot color={palette[index % palette.length]} active={false} />}
242
249
  checkedIcon={<SeriesToggleDot color={palette[index % palette.length]} active />}
243
- sx={{
244
- color: palette[index % palette.length],
245
- '&.Mui-checked': { color: palette[index % palette.length] },
246
- }}
250
+ sx={seriesToggleSx(palette[index % palette.length])}
247
251
  />
248
252
  )}
249
253
  label={series.label}
package/src/index.js CHANGED
@@ -4,7 +4,10 @@
4
4
  export {
5
5
  createAppTheme,
6
6
  assertThemeComplete,
7
+ assertKitSxDisjoint,
7
8
  calculateContrastRatio,
9
+ KIT_COMPONENT_SX_REGISTRY,
10
+ reportKitSxBypasses,
8
11
  reportThemeAdoption,
9
12
  THEME_COMPLETENESS_SURFACES,
10
13
  } from './theme';
@@ -2,9 +2,11 @@
2
2
  import React from 'react';
3
3
  import { Container, Box, Typography } from '@mui/material';
4
4
 
5
+ export const PAGE_LAYOUT_CONTAINER_SX = { mt: 4 };
6
+
5
7
  // Layout for content pages with larger width (e.g. Profile, Welcome, Input)
6
8
  export const WidePage = ({ title, children, maxWidth = 'lg' }) => (
7
- <Container maxWidth={maxWidth} sx={{ mt: 4 }}>
9
+ <Container maxWidth={maxWidth} sx={PAGE_LAYOUT_CONTAINER_SX}>
8
10
  {title && (
9
11
  <Typography variant="h4" gutterBottom>
10
12
  {title}
@@ -16,7 +18,7 @@ export const WidePage = ({ title, children, maxWidth = 'lg' }) => (
16
18
 
17
19
  // Layout for forms or narrow pages (e.g. Login, Reset, Invite)
18
20
  export const NarrowPage = ({ title, subtitle, children }) => (
19
- <Container maxWidth="md" sx={{ mt: 4 }}>
21
+ <Container maxWidth="md" sx={PAGE_LAYOUT_CONTAINER_SX}>
20
22
  <Box sx={{ maxWidth: 480, mx: 'auto' }}>
21
23
  {title && (
22
24
  <Typography variant="h4" gutterBottom>
@@ -7,6 +7,12 @@ import { useTranslation } from 'react-i18next';
7
7
 
8
8
  import { useMessaging } from './MessagingProvider';
9
9
 
10
+ export const ATTACHMENT_LIST_LIGHTBOX_ACTION_SX = {
11
+ bgcolor: 'background.paper',
12
+ boxShadow: 1,
13
+ };
14
+ export const ATTACHMENT_LIST_ALERT_SX = { m: 2 };
15
+
10
16
  function isImage(attachment) { return attachment.content_type?.startsWith('image/'); }
11
17
  // `filename` is dcm's real, sanitized upload name (MSG-12) -- `id` is the
12
18
  // last-resort fallback for interop with a host still on a pre-MSG-12 pin.
@@ -117,15 +123,15 @@ export function AttachmentList({ attachments = [] }) {
117
123
  <Dialog open={Boolean(lightbox)} onClose={() => setLightbox(null)} maxWidth="lg" aria-label={lightbox ? nameOf(lightbox.attachment) : undefined}>
118
124
  {lightbox && <Box sx={{ position: 'relative', minWidth: 240, minHeight: lightbox.status === 'ready' ? undefined : 160, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
119
125
  <Stack direction="row" spacing={0.5} sx={{ position: 'absolute', top: 4, right: 4 }}>
120
- {lightbox.status === 'ready' && <IconButton aria-label={t('MessagingAttachments.DOWNLOAD_ACTION')} onClick={() => download(lightbox.attachment)} sx={{ bgcolor: 'background.paper', boxShadow: 1 }}>
126
+ {lightbox.status === 'ready' && <IconButton aria-label={t('MessagingAttachments.DOWNLOAD_ACTION')} onClick={() => download(lightbox.attachment)} sx={ATTACHMENT_LIST_LIGHTBOX_ACTION_SX}>
121
127
  <DownloadIcon />
122
128
  </IconButton>}
123
- <IconButton aria-label={t('MessagingAttachments.CLOSE_PREVIEW')} onClick={() => setLightbox(null)} sx={{ bgcolor: 'background.paper', boxShadow: 1 }}>
129
+ <IconButton aria-label={t('MessagingAttachments.CLOSE_PREVIEW')} onClick={() => setLightbox(null)} sx={ATTACHMENT_LIST_LIGHTBOX_ACTION_SX}>
124
130
  <CloseIcon />
125
131
  </IconButton>
126
132
  </Stack>
127
133
  {lightbox.status === 'loading' && <CircularProgress aria-label={t('MessagingAttachments.LOADING_PREVIEW')} sx={{ m: 4 }} />}
128
- {lightbox.status === 'error' && <Alert severity="error" role="alert" sx={{ m: 2 }}>{t('MessagingAttachments.PREVIEW_ERROR')}</Alert>}
134
+ {lightbox.status === 'error' && <Alert severity="error" role="alert" sx={ATTACHMENT_LIST_ALERT_SX}>{t('MessagingAttachments.PREVIEW_ERROR')}</Alert>}
129
135
  {lightbox.status === 'ready' && <Box component="img" src={lightbox.url} alt={nameOf(lightbox.attachment)} sx={{ display: 'block', maxWidth: '90vw', maxHeight: '90vh' }} />}
130
136
  </Box>}
131
137
  </Dialog>
@@ -10,6 +10,8 @@ import { useTranslation } from 'react-i18next';
10
10
  import { compressImageForUpload } from './composerImageCompression';
11
11
  import { extractApiErrorMessage, useMessaging } from './MessagingProvider';
12
12
 
13
+ export const COMPOSER_EMOJI_BUTTON_SX = { fontSize: '1.15rem' };
14
+
13
15
  // A wider, Composer-only set (MSG-6i) -- deliberately separate from
14
16
  // ReactionBar's own QUICK_EMOJIS (5, curated for fast reactions). Mobile
15
17
  // users already have a full native emoji keyboard for free-form text; this
@@ -167,7 +169,7 @@ export function Composer({ conversationId, conversation, replyTarget = null, onR
167
169
  */}
168
170
  <Box role="group" aria-label={t('MessagingComposer.EMOJI_PICKER')} sx={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 0.25, p: 0.5, maxWidth: 280 }}>
169
171
  {COMPOSER_EMOJIS.map((emoji) => (
170
- <IconButton key={emoji} type="button" size="small" onClick={() => insertEmoji(emoji)} sx={{ fontSize: '1.15rem' }}>
172
+ <IconButton key={emoji} type="button" size="small" onClick={() => insertEmoji(emoji)} sx={COMPOSER_EMOJI_BUTTON_SX}>
171
173
  {emoji}
172
174
  </IconButton>
173
175
  ))}
@@ -18,6 +18,8 @@ import { useTranslation } from 'react-i18next';
18
18
 
19
19
  import { extractApiErrorMessage, useMessaging } from './MessagingProvider';
20
20
 
21
+ export const DIRECT_MESSAGE_LAUNCHER_ALERT_SX = { mb: 1 };
22
+
21
23
  /**
22
24
  * Starts direct conversations from host-provided people. Candidate discovery is
23
25
  * intentionally outside messaging because memberships are host-domain data.
@@ -70,7 +72,7 @@ export function DirectMessageLauncher({ candidates = [], scope, onOpen }) {
70
72
  <Dialog open={open} onClose={closePicker} fullScreen={fullScreen} aria-labelledby={titleId}>
71
73
  <DialogTitle id={titleId}>{t('MessagingDirect.TITLE')}</DialogTitle>
72
74
  <DialogContent>
73
- {error && <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert>}
75
+ {error && <Alert severity="error" sx={DIRECT_MESSAGE_LAUNCHER_ALERT_SX}>{error}</Alert>}
74
76
  {starting && <Typography role="status" sx={{ mb: 1 }}><CircularProgress size={16} sx={{ mr: 1 }} />{t('MessagingDirect.STARTING')}</Typography>}
75
77
  {candidates.length === 0 ? (
76
78
  <Typography>{t('MessagingDirect.EMPTY')}</Typography>
@@ -8,6 +8,29 @@ import { extractApiErrorMessage, useOptionalMessaging } from './MessagingProvide
8
8
  import { ReactionBar } from './ReactionBar';
9
9
  import { PollCard } from './PollCard';
10
10
 
11
+ export const MESSAGE_BUBBLE_ROOT_SX = {
12
+ position: 'relative',
13
+ p: 1.25,
14
+ minWidth: 0,
15
+ overflowWrap: 'anywhere',
16
+ '& .message-actions': {
17
+ opacity: 0,
18
+ pointerEvents: 'none',
19
+ transition: 'opacity 120ms ease-in-out',
20
+ },
21
+ '&:hover .message-actions, &:focus-within .message-actions, &[data-actions-visible="true"] .message-actions': {
22
+ opacity: 1,
23
+ pointerEvents: 'auto',
24
+ },
25
+ };
26
+ export const MESSAGE_BUBBLE_ACTION_SX = {
27
+ position: 'absolute',
28
+ top: -12,
29
+ right: -12,
30
+ bgcolor: 'background.paper',
31
+ boxShadow: 1,
32
+ };
33
+
11
34
  function senderName(message) {
12
35
  return message.sender?.display_name || message.sender?.username || message.sender_name || message.sender?.name;
13
36
  }
@@ -103,16 +126,15 @@ export function MessageBubble({ message, replyTo, conversation, onReply, onJumpT
103
126
  data-actions-visible={actionsVisible || undefined}
104
127
  onClick={revealActions}
105
128
  onContextMenu={canOpenMenu ? openMenu : undefined}
106
- sx={{
107
- position: 'relative', p: 1.25, minWidth: 0,
108
- bgcolor: isOwn ? (theme) => alpha(theme.palette.primary.light, 0.24) : 'background.paper',
109
- borderColor: isOwn ? (theme) => alpha(theme.palette.primary.main, 0.35) : 'divider',
110
- overflowWrap: 'anywhere',
111
- '& .message-actions': { opacity: 0, pointerEvents: 'none', transition: 'opacity 120ms ease-in-out' },
112
- '&:hover .message-actions, &:focus-within .message-actions, &[data-actions-visible="true"] .message-actions': { opacity: 1, pointerEvents: 'auto' },
113
- }}
129
+ sx={[
130
+ MESSAGE_BUBBLE_ROOT_SX,
131
+ {
132
+ bgcolor: isOwn ? (theme) => alpha(theme.palette.primary.light, 0.24) : 'background.paper',
133
+ borderColor: isOwn ? (theme) => alpha(theme.palette.primary.main, 0.35) : 'divider',
134
+ },
135
+ ]}
114
136
  >
115
- {canOpenMenu && <IconButton className="message-actions" size="small" aria-label={t('MessagingActions.MENU')} onClick={openMenu} sx={{ position: 'absolute', top: -12, right: -12, bgcolor: 'background.paper', boxShadow: 1 }}><MoreVertIcon fontSize="small" /></IconButton>}
137
+ {canOpenMenu && <IconButton className="message-actions" size="small" aria-label={t('MessagingActions.MENU')} onClick={openMenu} sx={MESSAGE_BUBBLE_ACTION_SX}><MoreVertIcon fontSize="small" /></IconButton>}
116
138
  <Stack spacing={0.75}>
117
139
  {replyTo && <ButtonBase type="button" onClick={() => onJumpToMessage?.(replyTo.id)} sx={{ display: 'block', width: '100%', px: 0.75, py: 0.5, textAlign: 'left', borderLeft: 3, borderColor: 'primary.main', bgcolor: 'action.hover', borderRadius: 0.5 }}>
118
140
  <Stack spacing={0} alignItems="flex-start">
@@ -5,6 +5,10 @@ import { useTranslation } from 'react-i18next';
5
5
 
6
6
  import { extractApiErrorMessage, useMessaging } from './MessagingProvider';
7
7
 
8
+ export const POLL_CARD_ROOT_SX = { p: 1.5, minWidth: 260 };
9
+ export const POLL_CARD_FORM_CONTROL_SX = { width: '100%', m: 0, alignItems: 'flex-start' };
10
+ export const POLL_CARD_CHECKBOX_SX = { pt: 0.25 };
11
+
8
12
  /**
9
13
  * Independently mountable live poll view. Server authorization remains authoritative.
10
14
  *
@@ -124,19 +128,17 @@ export function PollCard({ message, canClose: canCloseProp }) {
124
128
  </Stack>;
125
129
  return { accessibleLabel, richLabel };
126
130
  };
127
- const formControlSx = { width: '100%', m: 0, alignItems: 'flex-start' };
128
-
129
131
  // MSG-6j: a short question/option set could otherwise pull the whole `MessageBubble`
130
132
  // (`width: fit-content`) in tighter than comfortable to read. 260px stays under the
131
133
  // bubble's own `maxWidth: min(75%, 680px)` ceiling even at a 375px viewport (75% = 281px)
132
134
  // -- verified at that width, not just desktop.
133
- return <Paper variant="outlined" sx={{ p: 1.5, minWidth: 260 }} aria-label={t('MessagingPoll.LABEL')}><Stack spacing={1.5}>
135
+ return <Paper variant="outlined" sx={POLL_CARD_ROOT_SX} aria-label={t('MessagingPoll.LABEL')}><Stack spacing={1.5}>
134
136
  <Typography variant="subtitle2">{poll.question}</Typography>
135
137
  {poll.allow_multiple
136
138
  ? (poll.options || []).map((option) => {
137
139
  const { accessibleLabel, richLabel } = optionLabel(option);
138
- return <FormControlLabel key={option.id} disabled={closed} sx={formControlSx} label={richLabel}
139
- control={<Checkbox checked={selected.includes(option.id)} inputProps={{ 'aria-label': accessibleLabel }} onChange={() => tap(option.id)} sx={{ pt: 0.25 }} />} />;
140
+ return <FormControlLabel key={option.id} disabled={closed} sx={POLL_CARD_FORM_CONTROL_SX} label={richLabel}
141
+ control={<Checkbox checked={selected.includes(option.id)} inputProps={{ 'aria-label': accessibleLabel }} onChange={() => tap(option.id)} sx={POLL_CARD_CHECKBOX_SX} />} />;
140
142
  })
141
143
  // No RadioGroup-level onChange: it would only ever hand back
142
144
  // `event.target.value`, a DOM string -- coercing a non-string option id
@@ -148,7 +150,7 @@ export function PollCard({ message, canClose: canCloseProp }) {
148
150
  : <RadioGroup value={selected[0] ?? ''} name={`poll-${poll.id}-options`}>
149
151
  {(poll.options || []).map((option) => {
150
152
  const { accessibleLabel, richLabel } = optionLabel(option);
151
- return <FormControlLabel key={option.id} value={option.id} disabled={closed} sx={formControlSx} label={richLabel}
153
+ return <FormControlLabel key={option.id} value={option.id} disabled={closed} sx={POLL_CARD_FORM_CONTROL_SX} label={richLabel}
152
154
  control={<Radio inputProps={{ 'aria-label': accessibleLabel }} onChange={() => tap(option.id)} sx={{ pt: 0.25 }} />} />;
153
155
  })}
154
156
  </RadioGroup>}
@@ -8,6 +8,8 @@ import { MESSAGING_ENVELOPE, useMessaging } from './MessagingProvider';
8
8
  import { ReadTicks } from './ReadTicks';
9
9
  import { useRealtime } from '../notifications/realtime';
10
10
 
11
+ export const THREAD_DAY_SEPARATOR_SX = { flex: 1 };
12
+
11
13
  function chronological(messages) { return [...messages].sort((left, right) => new Date(left.created_at || 0) - new Date(right.created_at || 0)); }
12
14
  function replyToId(message) { return message.reply_to_id ?? message.reply_to; }
13
15
  // last_reply_at/reply_count are viewer-independent (always on serialize_message);
@@ -174,7 +176,7 @@ export function Thread({ conversationId, onReplyTargetChange, canModerateMessage
174
176
  no vertical overflow). */}
175
177
  <Stack spacing={1} sx={{ pr: 2 }}>{datedRoots.map(({ message, showDateSeparator }) => {
176
178
  const replies = chronological(Object.values(cache.messages).filter((item) => String(replyToId(item)) === String(message.id)));
177
- return <Stack key={message.id} spacing={0.75}>{showDateSeparator && <Stack data-testid="day-separator" direction="row" alignItems="center" spacing={1}><Divider sx={{ flex: 1 }} /><Typography variant="caption" color="text.secondary">{new Date(message.created_at).toLocaleDateString(i18n?.language)}</Typography><Divider sx={{ flex: 1 }} /></Stack>}<MessageBubble message={message} conversation={conversation} onReply={reply} onJumpToMessage={jumpToMessage} onAnnouncementLink={onAnnouncementLink} canModerateMessages={canModerateMessages}>{canShowReadTicks(message, user) && <ReadTicks messageId={message.id} conversation={conversation} />}</MessageBubble>
179
+ return <Stack key={message.id} spacing={0.75}>{showDateSeparator && <Stack data-testid="day-separator" direction="row" alignItems="center" spacing={1}><Divider sx={THREAD_DAY_SEPARATOR_SX} /><Typography variant="caption" color="text.secondary">{new Date(message.created_at).toLocaleDateString(i18n?.language)}</Typography><Divider sx={THREAD_DAY_SEPARATOR_SX} /></Stack>}<MessageBubble message={message} conversation={conversation} onReply={reply} onJumpToMessage={jumpToMessage} onAnnouncementLink={onAnnouncementLink} canModerateMessages={canModerateMessages}>{canShowReadTicks(message, user) && <ReadTicks messageId={message.id} conversation={conversation} />}</MessageBubble>
178
180
  {(message.reply_count || replies.length) > 0 && (() => {
179
181
  const unread = hasUnreadReplies(message, cache.receipts, user?.id);
180
182
  const label = openThreads[message.id] ? t('MessagingThread.HIDE_REPLIES') : t('MessagingThread.SHOW_REPLIES', { count: message.reply_count || replies.length });
@@ -18,6 +18,21 @@ import {
18
18
  urlBase64ToUint8Array,
19
19
  } from './api';
20
20
 
21
+ export const NOTIFICATION_SETTINGS_ALERT_SX = { mb: 2 };
22
+ export const NOTIFICATION_SETTINGS_HINT_ALERT_SX = { mb: 1.5 };
23
+ export const NOTIFICATION_SETTINGS_REACH_ALERT_SX = { mb: 1 };
24
+ export const NOTIFICATION_SETTINGS_FORM_CONTROL_SX = { alignItems: 'flex-start', ml: 0 };
25
+ export const NOTIFICATION_SETTINGS_PUSH_CONTROL_SX = {
26
+ alignItems: 'flex-start',
27
+ ml: 0,
28
+ mt: 1.5,
29
+ };
30
+ export const NOTIFICATION_SETTINGS_CATEGORY_CONTROL_SX = {
31
+ alignItems: 'center',
32
+ ml: 0,
33
+ display: 'flex',
34
+ };
35
+
21
36
  function reachLabelKey(notificationType) {
22
37
  const noActiveChannel = notificationType.active && notificationType.has_active_channel === false;
23
38
  if (notificationType.active && notificationType.passive) {
@@ -210,23 +225,23 @@ export function NotificationSettings() {
210
225
  <Typography variant="body2" color="text.secondary" gutterBottom>
211
226
  {t('NotificationSettings.SUBTITLE')}
212
227
  </Typography>
213
- {error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError('')}>{error}</Alert>}
214
- {conflict && <Alert severity="warning" sx={{ mb: 2 }} onClose={() => setConflict('')}>{conflict}</Alert>}
228
+ {error && <Alert severity="error" sx={NOTIFICATION_SETTINGS_ALERT_SX} onClose={() => setError('')}>{error}</Alert>}
229
+ {conflict && <Alert severity="warning" sx={NOTIFICATION_SETTINGS_ALERT_SX} onClose={() => setConflict('')}>{conflict}</Alert>}
215
230
 
216
231
  <Box sx={{ py: 2 }}>
217
232
  <FormControlLabel
218
233
  control={<Switch checked={Boolean(preferences?.email_opt_in)} onChange={handleEmailToggle} disabled={savingEmail} />}
219
234
  label={<Box><Typography variant="body1">{t('NotificationSettings.EMAIL_LABEL')}</Typography><Typography variant="caption" color="text.secondary">{t('NotificationSettings.EMAIL_HINT')}</Typography></Box>}
220
235
  labelPlacement="end"
221
- sx={{ alignItems: 'flex-start', ml: 0 }}
236
+ sx={NOTIFICATION_SETTINGS_FORM_CONTROL_SX}
222
237
  />
223
238
  </Box>
224
239
 
225
240
  <Divider />
226
241
 
227
242
  <Box sx={{ py: 2 }}>
228
- {iosNeedsInstall && <Alert severity="info" sx={{ mb: 1.5 }}>{t('NotificationSettings.IOS_HINT')}</Alert>}
229
- {!pushSupported && !iosNeedsInstall && <Alert severity="warning" sx={{ mb: 1.5 }}>{t('NotificationSettings.PUSH_NOT_SUPPORTED')}</Alert>}
243
+ {iosNeedsInstall && <Alert severity="info" sx={NOTIFICATION_SETTINGS_HINT_ALERT_SX}>{t('NotificationSettings.IOS_HINT')}</Alert>}
244
+ {!pushSupported && !iosNeedsInstall && <Alert severity="warning" sx={NOTIFICATION_SETTINGS_HINT_ALERT_SX}>{t('NotificationSettings.PUSH_NOT_SUPPORTED')}</Alert>}
230
245
  <FormControlLabel
231
246
  control={(
232
247
  <Switch
@@ -237,7 +252,7 @@ export function NotificationSettings() {
237
252
  )}
238
253
  label={<Box><Typography variant="body1">{t('NotificationSettings.PUSH_LABEL')}</Typography><Typography variant="caption" color="text.secondary">{t('NotificationSettings.PUSH_HINT')}</Typography></Box>}
239
254
  labelPlacement="end"
240
- sx={{ alignItems: 'flex-start', ml: 0 }}
255
+ sx={NOTIFICATION_SETTINGS_FORM_CONTROL_SX}
241
256
  />
242
257
  <FormControlLabel
243
258
  control={(
@@ -249,7 +264,7 @@ export function NotificationSettings() {
249
264
  )}
250
265
  label={<Box><Typography variant="body1">{t('NotificationSettings.PUSH_PREVIEW_LABEL')}</Typography><Typography variant="caption" color="text.secondary">{t('NotificationSettings.PUSH_PREVIEW_HINT')}</Typography></Box>}
251
266
  labelPlacement="end"
252
- sx={{ alignItems: 'flex-start', ml: 0, mt: 1.5 }}
267
+ sx={NOTIFICATION_SETTINGS_PUSH_CONTROL_SX}
253
268
  />
254
269
  </Box>
255
270
 
@@ -266,7 +281,7 @@ export function NotificationSettings() {
266
281
  // reach description is static settings copy, not a status message.
267
282
  if (labelKey === 'NotificationSettings.REACH_NO_ACTIVE_CHANNEL') {
268
283
  return (
269
- <Alert key={notificationType.key} severity="warning" sx={{ mb: 1 }}>
284
+ <Alert key={notificationType.key} severity="warning" sx={NOTIFICATION_SETTINGS_REACH_ALERT_SX}>
270
285
  <strong>{notificationType.label}</strong> — {reachText}
271
286
  </Alert>
272
287
  );
@@ -302,7 +317,7 @@ export function NotificationSettings() {
302
317
  )}
303
318
  label={row.label}
304
319
  labelPlacement="end"
305
- sx={{ alignItems: 'center', ml: 0, display: 'flex' }}
320
+ sx={NOTIFICATION_SETTINGS_CATEGORY_CONTROL_SX}
306
321
  />
307
322
  ))}
308
323
  </Box>
@@ -8,6 +8,8 @@ import { WizardDialogShell } from '../onboarding/WizardDialogShell';
8
8
  import { useNotifications } from './NotificationsProvider';
9
9
  import { DEFAULT_ENVELOPE, useRealtime } from './realtime';
10
10
 
11
+ export const POPUP_SURFACE_DISMISS_BUTTON_SX = { alignSelf: 'flex-end' };
12
+
11
13
  function PopupStepComponent({ onDismiss, ctx }) {
12
14
  const { t } = useTranslation();
13
15
  const content = ctx?.content || {};
@@ -17,7 +19,7 @@ function PopupStepComponent({ onDismiss, ctx }) {
17
19
  {content.body_key && (
18
20
  <Typography variant="body1">{t(content.body_key, content.params || {})}</Typography>
19
21
  )}
20
- <Button onClick={onDismiss} sx={{ alignSelf: 'flex-end' }}>
22
+ <Button onClick={onDismiss} sx={POPUP_SURFACE_DISMISS_BUTTON_SX}>
21
23
  {t('PopupSurface.CLOSE')}
22
24
  </Button>
23
25
  </Stack>
@@ -16,6 +16,8 @@ import {
16
16
  urlBase64ToUint8Array,
17
17
  } from '../../notifications/api';
18
18
 
19
+ export const BROWSER_PUSH_FORM_CONTROL_LABEL_SX = { alignItems: 'flex-start', ml: 0 };
20
+
19
21
  export function BrowserPushStep({ onComplete, onDismiss, ctx }) {
20
22
  const { t } = useTranslation();
21
23
  const [loading, setLoading] = useState(false);
@@ -92,7 +94,7 @@ export function BrowserPushStep({ onComplete, onDismiss, ctx }) {
92
94
  control={<Switch checked={emailOptedIn} onChange={handleEmailToggle} disabled={savingEmail} />}
93
95
  label={<Box><Typography variant="body1">{t('Onboarding.EMAIL_NOTIFICATIONS_LABEL')}</Typography><Typography variant="caption" color="text.secondary">{t('Onboarding.EMAIL_NOTIFICATIONS_HINT')}</Typography></Box>}
94
96
  labelPlacement="end"
95
- sx={{ alignItems: 'flex-start', ml: 0 }}
97
+ sx={BROWSER_PUSH_FORM_CONTROL_LABEL_SX}
96
98
  />
97
99
  {pushSupported && (
98
100
  <Box>
@@ -16,6 +16,8 @@ import { useTranslation } from 'react-i18next';
16
16
  // Internal context
17
17
  import { AuthContext } from '../auth/AuthContext';
18
18
 
19
+ export const ACCOUNT_PAGE_SECTION_PAPER_SX = { p: 2.5 };
20
+
19
21
  // Falls die Komponenten noch lokal sind:
20
22
  import { WidePage } from '../layout/PageLayout';
21
23
  import { ProfileComponent } from '../components/ProfileComponent';
@@ -250,13 +252,13 @@ export function AccountPage({
250
252
  <Box sx={{ mt: 2 }}>
251
253
  <Stack spacing={2.5}>
252
254
  {canViewAuthPolicy && (
253
- <Paper variant="outlined" sx={{ p: 2.5, borderRadius: 2 }}>
255
+ <Paper variant="outlined" sx={ACCOUNT_PAGE_SECTION_PAPER_SX}>
254
256
  <AuthFactorRequirementCard canEdit={canWriteAuthPolicy} policy={authPolicy} />
255
257
  </Paper>
256
258
  )}
257
259
 
258
260
  {canViewAuthPolicy && (
259
- <Paper variant="outlined" sx={{ p: 2.5, borderRadius: 2 }}>
261
+ <Paper variant="outlined" sx={ACCOUNT_PAGE_SECTION_PAPER_SX}>
260
262
  <RegistrationMethodsManager
261
263
  policy={authPolicy}
262
264
  error={authPolicyError}
@@ -267,13 +269,13 @@ export function AccountPage({
267
269
  )}
268
270
 
269
271
  {canSendInvites && Boolean(authPolicy?.allow_admin_invite) && (
270
- <Paper variant="outlined" sx={{ p: 2.5, borderRadius: 2 }}>
272
+ <Paper variant="outlined" sx={ACCOUNT_PAGE_SECTION_PAPER_SX}>
271
273
  <UserInviteComponent />
272
274
  </Paper>
273
275
  )}
274
276
 
275
277
  {canManageAccessCodes && Boolean(authPolicy?.allow_self_signup_access_code) && (
276
- <Paper variant="outlined" sx={{ p: 2.5, borderRadius: 2 }}>
278
+ <Paper variant="outlined" sx={ACCOUNT_PAGE_SECTION_PAPER_SX}>
277
279
  <Typography variant="h6" gutterBottom>
278
280
  {t('Auth.ACCESS_CODE_MANAGER_TITLE', 'Access Codes')}
279
281
  </Typography>
@@ -285,7 +287,7 @@ export function AccountPage({
285
287
  )}
286
288
 
287
289
  {canManageAccessCodes && Boolean(authPolicy?.allow_self_signup_access_code) && (
288
- <Paper variant="outlined" sx={{ p: 2.5, borderRadius: 2 }}>
290
+ <Paper variant="outlined" sx={ACCOUNT_PAGE_SECTION_PAPER_SX}>
289
291
  <AccessCodeSingleUseToggle
290
292
  canEdit={canWriteAuthPolicy}
291
293
  policy={authPolicy}
@@ -295,13 +297,13 @@ export function AccountPage({
295
297
  )}
296
298
 
297
299
  {canSendInvites && showBulkInviteCsvTab && Boolean(authPolicy?.allow_admin_invite) && (
298
- <Paper variant="outlined" sx={{ p: 2.5, borderRadius: 2 }}>
300
+ <Paper variant="outlined" sx={ACCOUNT_PAGE_SECTION_PAPER_SX}>
299
301
  <BulkInviteCsvTab {...bulkInviteCsvProps} />
300
302
  </Paper>
301
303
  )}
302
304
 
303
305
  {canViewAuthPolicy && Boolean(authPolicy?.allow_self_signup_email_domain) && (
304
- <Paper variant="outlined" sx={{ p: 2.5, borderRadius: 2 }}>
306
+ <Paper variant="outlined" sx={ACCOUNT_PAGE_SECTION_PAPER_SX}>
305
307
  <AllowedEmailDomainsManager
306
308
  enabled={Boolean(authPolicy?.allow_self_signup_email_domain)}
307
309
  domains={authPolicy?.allowed_email_domains || []}
@@ -312,7 +314,7 @@ export function AccountPage({
312
314
  )}
313
315
 
314
316
  {canViewAuthPolicy && Boolean(authPolicy?.allow_self_signup_qr) && (
315
- <Paper variant="outlined" sx={{ p: 2.5, borderRadius: 2 }}>
317
+ <Paper variant="outlined" sx={ACCOUNT_PAGE_SECTION_PAPER_SX}>
316
318
  <QrSignupValidityManager
317
319
  enabled={Boolean(authPolicy?.allow_self_signup_qr)}
318
320
  expiryDays={authPolicy?.signup_qr_expiry_days}
@@ -323,7 +325,7 @@ export function AccountPage({
323
325
  )}
324
326
 
325
327
  {canManageSignupQr && Boolean(authPolicy?.allow_self_signup_qr) && (
326
- <Paper variant="outlined" sx={{ p: 2.5, borderRadius: 2 }}>
328
+ <Paper variant="outlined" sx={ACCOUNT_PAGE_SECTION_PAPER_SX}>
327
329
  <QrSignupManager
328
330
  enabled={Boolean(authPolicy?.allow_self_signup_qr)}
329
331
  expiryDays={authPolicy?.signup_qr_expiry_days}
@@ -20,6 +20,8 @@ import { loginWithPasskey, startSocialLogin } from '../utils/authService';
20
20
  import { LoginForm } from '../components/LoginForm';
21
21
  import { MfaLoginComponent } from '../components/MfaLoginComponent';
22
22
 
23
+ export const LOGIN_PAGE_ALERT_SX = { mb: 2 };
24
+
23
25
  export function LoginPage() {
24
26
  const navigate = useNavigate();
25
27
  const location = useLocation();
@@ -284,25 +286,25 @@ export function LoginPage() {
284
286
  </Helmet>
285
287
 
286
288
  {errorKey && (
287
- <Alert severity="error" sx={{ mb: 2 }}>
289
+ <Alert severity="error" sx={LOGIN_PAGE_ALERT_SX}>
288
290
  {t(errorKey)}
289
291
  </Alert>
290
292
  )}
291
293
 
292
294
  {recoveryFetching && !errorKey && (
293
- <Alert severity="info" sx={{ mb: 2 }}>
295
+ <Alert severity="info" sx={LOGIN_PAGE_ALERT_SX}>
294
296
  {t('Auth.RECOVERY_LINK_VALIDATING', 'Validating recovery link…')}
295
297
  </Alert>
296
298
  )}
297
299
 
298
300
  {recoveryToken && !errorKey && (
299
- <Alert severity="info" sx={{ mb: 2 }}>
301
+ <Alert severity="info" sx={LOGIN_PAGE_ALERT_SX}>
300
302
  {t('Auth.RECOVERY_LOGIN_WARNING', 'Recovery link validated. Please enter your password.')}
301
303
  </Alert>
302
304
  )}
303
305
 
304
306
  {twoFactorRequired && !recoveryToken && !recoveryFetching && (
305
- <Alert severity="info" sx={{ mb: 2 }}>
307
+ <Alert severity="info" sx={LOGIN_PAGE_ALERT_SX}>
306
308
  {t('Auth.TWO_FACTOR_REQUIRED_HINT', 'This app requires two authentication factors for full access.')}
307
309
  </Alert>
308
310
  )}
@@ -21,6 +21,8 @@ import { AuthContext } from '../auth/AuthContext';
21
21
  import { submitRegistrationRequest } from '../auth/authApi';
22
22
  import { TurnstileWidget } from '../components/TurnstileWidget';
23
23
 
24
+ export const SIGN_UP_PAGE_ALERT_SX = { mb: 2 };
25
+
24
26
  const TURNSTILE_SIGNUP_MODES = new Set([
25
27
  'self_signup_open',
26
28
  'self_signup_email_domain',
@@ -162,13 +164,13 @@ export function SignUpPage() {
162
164
  </Helmet>
163
165
 
164
166
  {successKey && (
165
- <Alert severity="success" sx={{ mb: 2 }}>
167
+ <Alert severity="success" sx={SIGN_UP_PAGE_ALERT_SX}>
166
168
  {t(successKey, { email })}
167
169
  </Alert>
168
170
  )}
169
171
 
170
172
  {errorKey && (
171
- <Alert severity="error" sx={{ mb: 2 }}>
173
+ <Alert severity="error" sx={SIGN_UP_PAGE_ALERT_SX}>
172
174
  {t(errorKey, t('Auth.INVITE_FAILED', 'Could not complete signup.'))}
173
175
  </Alert>
174
176
  )}
@@ -20,6 +20,8 @@ import { useTranslation } from 'react-i18next';
20
20
  import { NarrowPage } from '../layout/PageLayout';
21
21
  import { confirmRegistration } from '../auth/authApi';
22
22
 
23
+ export const SIGNUP_CONFIRM_PAGE_ALERT_SX = { mb: 2 };
24
+
23
25
  export function SignupConfirmPage() {
24
26
  const { t } = useTranslation();
25
27
  const location = useLocation();
@@ -85,7 +87,7 @@ export function SignupConfirmPage() {
85
87
 
86
88
  {tokenMissing ? (
87
89
  <>
88
- <Alert severity="error" sx={{ mb: 2 }}>
90
+ <Alert severity="error" sx={SIGNUP_CONFIRM_PAGE_ALERT_SX}>
89
91
  {t('Auth.PENDING_TOKEN_INVALID', 'This confirmation link is invalid or expired.')}
90
92
  </Alert>
91
93
  <Stack direction="row" spacing={1} sx={{ mt: 1 }}>
@@ -100,7 +102,7 @@ export function SignupConfirmPage() {
100
102
  ) : (
101
103
  <>
102
104
  {errorKey && (
103
- <Alert severity="error" sx={{ mb: 2 }}>
105
+ <Alert severity="error" sx={SIGNUP_CONFIRM_PAGE_ALERT_SX}>
104
106
  {t(errorKey, t('Auth.PENDING_TOKEN_INVALID', 'Could not confirm registration.'))}
105
107
  </Alert>
106
108
  )}
@@ -1,10 +1,13 @@
1
1
  export { createAppTheme } from './createAppTheme';
2
2
  export {
3
3
  assertThemeComplete,
4
+ assertKitSxDisjoint,
4
5
  calculateContrastRatio,
6
+ reportKitSxBypasses,
5
7
  reportThemeAdoption,
6
8
  THEME_COMPLETENESS_SURFACES,
7
9
  } from './themeCompleteness';
10
+ export { KIT_COMPONENT_SX_REGISTRY } from './kitSxRegistry';
8
11
  export {
9
12
  formatShortTime,
10
13
  formatShortDate,