@tumbaland/frontend-core 1.15.0 → 1.16.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.
@@ -0,0 +1,49 @@
1
+ import { ComponentType, ReactNode } from 'react';
2
+ import type { SvgIconProps } from '@mui/material/SvgIcon';
3
+ /** One section of a module: a page of it the navigation lists by name. */
4
+ export interface SectionBarItem {
5
+ label: string;
6
+ /** Absolute address, the same one in the shell and standalone (see each app's appBase). */
7
+ route: string;
8
+ icon?: ComponentType<SvgIconProps>;
9
+ }
10
+ export interface SectionBarProps {
11
+ /** The module the sections belong to. Named at the left of the bar. */
12
+ title: string;
13
+ /** Drawn in a tinted tile before the title. */
14
+ icon?: ComponentType<SvgIconProps>;
15
+ /** The module's accent. Tints the tile and the section being shown. */
16
+ accent?: string;
17
+ /** In menu order. A module with one section passes none and gets just a title. */
18
+ items?: SectionBarItem[];
19
+ currentRoute: string;
20
+ /** Phones get one dropdown in place of the row, and no title. */
21
+ compact?: boolean;
22
+ /** Pinned to the right of the bar — where the shell puts the tenant selector. */
23
+ end?: ReactNode;
24
+ /**
25
+ * Drawn at the left in place of the title, for a page that has something
26
+ * better to put there than its own name — the shell's home page puts the
27
+ * wordmark here. Shown on the same terms as the title, so a module with
28
+ * sections still spends the room on them.
29
+ */
30
+ lead?: ReactNode;
31
+ }
32
+ /**
33
+ * The one bar above a module's content: what you are in, and where else you can
34
+ * go inside it.
35
+ *
36
+ * It lives here rather than in the shell because a module renders it in both
37
+ * the modes it runs in. Inside the shell one bar is drawn for whichever module
38
+ * is loaded; standalone, the module's own `AppLayout` draws the same bar from
39
+ * the same section list. The alternative — in-page MUI `Tabs` — is what had
40
+ * Profile and Billing looking nothing like Journal, Album and Money, and put a
41
+ * second row of navigation under the shell's own.
42
+ *
43
+ * Sections are laid out in full on a wide screen, since a row of five names is
44
+ * worth more than a button that hides four of them. Below `sm` there is no room
45
+ * for that, so the same list becomes a dropdown labelled with the section being
46
+ * shown.
47
+ */
48
+ export declare function SectionBar({ title, icon: Icon, accent, items, currentRoute, compact, end, lead }: SectionBarProps): import("react").JSX.Element;
49
+ export default SectionBar;
@@ -0,0 +1,143 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { Link } from 'react-router';
4
+ import Box from '@mui/material/Box';
5
+ import Button from '@mui/material/Button';
6
+ import ListItemIcon from '@mui/material/ListItemIcon';
7
+ import ListItemText from '@mui/material/ListItemText';
8
+ import Menu from '@mui/material/Menu';
9
+ import MenuItem from '@mui/material/MenuItem';
10
+ import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
11
+ import { brand, chrome, ink, surface, TOUCH_TARGET } from '../../theme/tokens';
12
+ /** A section owns its own path and everything nested under it. */
13
+ const isSectionActive = (route, currentRoute) => currentRoute === route || currentRoute.startsWith(`${route}/`);
14
+ /**
15
+ * The one bar above a module's content: what you are in, and where else you can
16
+ * go inside it.
17
+ *
18
+ * It lives here rather than in the shell because a module renders it in both
19
+ * the modes it runs in. Inside the shell one bar is drawn for whichever module
20
+ * is loaded; standalone, the module's own `AppLayout` draws the same bar from
21
+ * the same section list. The alternative — in-page MUI `Tabs` — is what had
22
+ * Profile and Billing looking nothing like Journal, Album and Money, and put a
23
+ * second row of navigation under the shell's own.
24
+ *
25
+ * Sections are laid out in full on a wide screen, since a row of five names is
26
+ * worth more than a button that hides four of them. Below `sm` there is no room
27
+ * for that, so the same list becomes a dropdown labelled with the section being
28
+ * shown.
29
+ */
30
+ export function SectionBar({ title, icon: Icon, accent = brand.pinkInk, items = [], currentRoute, compact = false, end, lead }) {
31
+ const [anchorEl, setAnchorEl] = useState(null);
32
+ const open = Boolean(anchorEl);
33
+ const close = () => setAnchorEl(null);
34
+ const activeItem = items.find(({ route }) => isSectionActive(route, currentRoute));
35
+ const sections = items.length > 0 && (_jsx(Box, { component: "nav", "aria-label": `${title} sections`, sx: compact ? compactNav : sectionRow, children: compact ? (_jsx(Button
36
+ // No aria-label: the accessible name is the section the button
37
+ // names, which is the thing a reader needs read out. The nav around
38
+ // it already says which module's sections these are.
39
+ , { "aria-haspopup": "menu", "aria-expanded": open, onClick: (event) => setAnchorEl(event.currentTarget), startIcon: activeItem?.icon ? _jsx(activeItem.icon, {}) : undefined, endIcon: _jsx(ExpandMoreIcon, {}), sx: { ...pillBase, ...activePill(accent), maxWidth: '100%', fontSize: 15 }, children: _jsx(Box, { component: "span", sx: { overflow: 'hidden', textOverflow: 'ellipsis' }, children: activeItem?.label ?? title }) })) : (items.map(({ label, route, icon: ItemIcon }) => {
40
+ const active = isSectionActive(route, currentRoute);
41
+ return (_jsx(Button, { component: Link, to: route, "aria-current": active ? 'page' : undefined, startIcon: ItemIcon ? _jsx(ItemIcon, {}) : undefined, sx: { ...pillBase, ...(active ? activePill(accent) : idlePill) }, children: label }, route));
42
+ })) }));
43
+ // The module is already named twice over on a page that has sections: the
44
+ // rail entry beside it is lit, and the section pill (or, on a phone, the
45
+ // dropdown) says which of its pages you are on. A third copy in the corner
46
+ // was just furniture. A module with no sections has neither, and a bar
47
+ // holding nothing but the tenant button reads as a mistake, so that one
48
+ // keeps its name.
49
+ const showTitle = items.length === 0;
50
+ return (_jsxs(Box, { component: "header", sx: { ...bar, minHeight: compact ? 56 : 68, px: compact ? 1.25 : 2 }, children: [showTitle &&
51
+ (lead ?? (_jsxs(Box, { sx: { display: 'flex', alignItems: 'center', gap: 1.25, minWidth: 0 }, children: [Icon && (_jsx(Box, { sx: { ...tile, color: accent, backgroundColor: `${accent}14` }, children: _jsx(Icon, { fontSize: "small" }) })), _jsx(Box, { component: "span", sx: {
52
+ fontSize: 17,
53
+ fontWeight: 700,
54
+ color: ink.strong,
55
+ whiteSpace: 'nowrap',
56
+ overflow: 'hidden',
57
+ textOverflow: 'ellipsis'
58
+ }, children: title })] }))), sections, _jsx(Box, { sx: { flex: 1, minWidth: 0 } }), end, items.length > 0 && (_jsx(Menu, { anchorEl: anchorEl, open: open, onClose: close, anchorOrigin: { vertical: 'bottom', horizontal: 'left' }, transformOrigin: { vertical: 'top', horizontal: 'left' }, slotProps: {
59
+ paper: {
60
+ sx: {
61
+ mt: 0.5,
62
+ minWidth: 220,
63
+ borderRadius: 3,
64
+ border: `1px solid ${surface.divider}`,
65
+ boxShadow: '0 18px 40px -24px rgba(88,28,135,0.5)'
66
+ }
67
+ },
68
+ list: { 'aria-label': `${title} sections` }
69
+ }, children: items.map(({ label, route, icon: ItemIcon }) => {
70
+ const active = isSectionActive(route, currentRoute);
71
+ return (_jsxs(MenuItem, { component: Link, to: route, selected: active, "aria-current": active ? 'page' : undefined, onClick: close, sx: {
72
+ mx: 0.5,
73
+ borderRadius: 2,
74
+ minHeight: TOUCH_TARGET,
75
+ color: active ? accent : ink.body,
76
+ '&.Mui-selected': { backgroundColor: `${accent}14`, color: accent },
77
+ '&.Mui-selected:hover': { backgroundColor: `${accent}20` },
78
+ '&:hover': { backgroundColor: surface.sunken }
79
+ }, children: [ItemIcon && (_jsx(ListItemIcon, { sx: { color: 'inherit', minWidth: 36 }, children: _jsx(ItemIcon, { fontSize: "small" }) })), _jsx(ListItemText, { slotProps: { primary: { sx: { fontWeight: active ? 700 : 500 } } }, children: label })] }, route));
80
+ }) }))] }));
81
+ }
82
+ /**
83
+ * Frosted, like the navigation rail beside it, so the two read as one piece of
84
+ * chrome with the page washing through both.
85
+ */
86
+ const bar = {
87
+ flexShrink: 0,
88
+ position: 'sticky',
89
+ top: 0,
90
+ zIndex: 2,
91
+ display: 'flex',
92
+ alignItems: 'center',
93
+ gap: 1,
94
+ py: 1,
95
+ ...chrome,
96
+ borderBottom: `1px solid ${surface.divider}`
97
+ };
98
+ const tile = {
99
+ width: 34,
100
+ height: 34,
101
+ borderRadius: 2.5,
102
+ display: 'grid',
103
+ placeItems: 'center'
104
+ };
105
+ const sectionRow = {
106
+ display: 'flex',
107
+ alignItems: 'center',
108
+ gap: 0.5,
109
+ minWidth: 0,
110
+ overflowX: 'auto',
111
+ // The row scrolls when a module has more sections than fit; a scrollbar
112
+ // across the chrome would read as a seam.
113
+ scrollbarWidth: 'none',
114
+ '&::-webkit-scrollbar': { display: 'none' }
115
+ };
116
+ const compactNav = { minWidth: 0, display: 'flex' };
117
+ const pillBase = {
118
+ flexShrink: 0,
119
+ minWidth: 0,
120
+ height: 36,
121
+ px: 1.75,
122
+ borderRadius: 999,
123
+ textTransform: 'none',
124
+ fontSize: 14.5,
125
+ whiteSpace: 'nowrap',
126
+ transition: 'background-color .2s ease, color .2s ease',
127
+ '& .MuiButton-startIcon svg': { fontSize: 19 },
128
+ '& .MuiButton-startIcon, & .MuiButton-endIcon': { flexShrink: 0 }
129
+ };
130
+ const idlePill = {
131
+ fontWeight: 500,
132
+ color: ink.body,
133
+ '&:hover': { backgroundColor: surface.sunken, color: ink.strong }
134
+ };
135
+ /** The section being shown, and the compact dropdown that stands in for it. */
136
+ const activePill = (accent) => ({
137
+ fontWeight: 700,
138
+ color: accent,
139
+ backgroundColor: `${accent}14`,
140
+ boxShadow: `inset 0 0 0 1px ${accent}29`,
141
+ '&:hover': { backgroundColor: `${accent}20` }
142
+ });
143
+ export default SectionBar;
@@ -0,0 +1,2 @@
1
+ export { SectionBar, default } from './SectionBar';
2
+ export type { SectionBarProps, SectionBarItem } from './SectionBar';
@@ -0,0 +1 @@
1
+ export { SectionBar, default } from './SectionBar';
@@ -17,8 +17,17 @@ export interface TenantSelectorProps {
17
17
  loading?: boolean;
18
18
  /** Error state */
19
19
  error?: string | null;
20
+ /** Drops the "Viewing" caption and tightens the button, for a phone's bar. */
21
+ compact?: boolean;
20
22
  /** Custom styling */
21
23
  sx?: SxProps<Theme>;
22
24
  }
25
+ /**
26
+ * Whose data the page is showing: the person's own, or one of their groups.
27
+ *
28
+ * Drawn as the same rounded pill the section bar beside it uses, in the brand
29
+ * violet rather than MUI's default blue, so the one control that changes what
30
+ * every module shows reads as part of the chrome instead of a stray form field.
31
+ */
23
32
  declare const TenantSelector: React.FC<TenantSelectorProps>;
24
33
  export default TenantSelector;
@@ -2,7 +2,15 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useState } from 'react';
3
3
  import { Box, Button, Menu, MenuItem, ListItemIcon, ListItemText, Divider, Typography, Chip } from '@mui/material';
4
4
  import { PersonOutlined, Groups, ExpandMore, Check } from '@mui/icons-material';
5
- const TenantSelector = ({ selectedTenant, tenants = [], onTenantChange, loading = false, error = null, sx = {} }) => {
5
+ import { brand, ink, surface, TOUCH_TARGET } from '../../theme/tokens';
6
+ /**
7
+ * Whose data the page is showing: the person's own, or one of their groups.
8
+ *
9
+ * Drawn as the same rounded pill the section bar beside it uses, in the brand
10
+ * violet rather than MUI's default blue, so the one control that changes what
11
+ * every module shows reads as part of the chrome instead of a stray form field.
12
+ */
13
+ const TenantSelector = ({ selectedTenant, tenants = [], onTenantChange, loading = false, error = null, compact = false, sx = {} }) => {
6
14
  const [anchorEl, setAnchorEl] = useState(null);
7
15
  const isOpen = Boolean(anchorEl);
8
16
  const handleClick = (event) => {
@@ -17,7 +25,7 @@ const TenantSelector = ({ selectedTenant, tenants = [], onTenantChange, loading
17
25
  };
18
26
  const getDisplayName = (tenant) => {
19
27
  if (!tenant)
20
- return 'Select Context';
28
+ return 'Choose context';
21
29
  return tenant.type === 'personal' ? 'Personal' : tenant.name;
22
30
  };
23
31
  const getIcon = (type) => {
@@ -26,52 +34,55 @@ const TenantSelector = ({ selectedTenant, tenants = [], onTenantChange, loading
26
34
  if (error) {
27
35
  return _jsx(Chip, { label: "Error loading contexts", color: "error", size: "small", sx: { ...sx } });
28
36
  }
29
- return (_jsxs(Box, { sx: { ...sx }, children: [_jsx(Button, { onClick: handleClick, disabled: loading || tenants.length === 0, endIcon: _jsx(ExpandMore, {}), startIcon: selectedTenant ? getIcon(selectedTenant.type) : _jsx(PersonOutlined, { fontSize: "small" }), sx: {
37
+ return (_jsxs(Box, { sx: { ...sx }, children: [_jsx(Button, { onClick: handleClick, disabled: loading || tenants.length === 0, "aria-haspopup": "menu", "aria-expanded": isOpen, endIcon: _jsx(ExpandMore, {}), startIcon: selectedTenant ? getIcon(selectedTenant.type) : _jsx(PersonOutlined, { fontSize: "small" }), sx: {
30
38
  textTransform: 'none',
31
- fontWeight: 500,
32
- borderRadius: 2,
33
- px: 2,
34
- py: 0.5,
35
- minWidth: 140,
39
+ fontWeight: 600,
40
+ borderRadius: 999,
41
+ px: compact ? 1.25 : 1.75,
42
+ minHeight: compact ? 38 : TOUCH_TARGET,
43
+ minWidth: 0,
36
44
  justifyContent: 'flex-start',
37
- color: 'text.primary',
38
- border: '1px solid',
39
- borderColor: 'divider',
45
+ color: ink.strong,
46
+ backgroundColor: surface.paper,
47
+ border: `1px solid ${surface.divider}`,
48
+ transition: 'border-color .2s ease, box-shadow .2s ease',
49
+ '& .MuiButton-startIcon': { color: brand.violetInk, flexShrink: 0 },
50
+ '& .MuiButton-endIcon': { color: ink.muted, flexShrink: 0 },
40
51
  '&:hover': {
41
- borderColor: 'primary.main',
42
- backgroundColor: 'action.hover'
43
- }
44
- }, children: _jsxs(Box, { sx: { flexGrow: 1, textAlign: 'left', overflow: 'hidden' }, children: [_jsx(Typography, { variant: "caption", sx: {
45
- color: 'text.secondary',
46
- display: 'block',
47
- fontSize: 10
48
- }, children: "Context" }), _jsx(Typography, { variant: "body2", sx: {
52
+ backgroundColor: surface.paper,
53
+ borderColor: `${brand.violetInk}66`,
54
+ boxShadow: `0 8px 20px -14px ${brand.violetInk}`
55
+ },
56
+ '&.Mui-disabled': { color: ink.muted, border: `1px solid ${surface.divider}` }
57
+ }, children: _jsxs(Box, { sx: { flexGrow: 1, textAlign: 'left', overflow: 'hidden' }, children: [!compact && (_jsx(Typography, { variant: "caption", sx: { color: ink.muted, display: 'block', fontSize: 10, letterSpacing: 0.4, lineHeight: 1.2 }, children: "Viewing" })), _jsx(Typography, { variant: "body2", sx: {
49
58
  display: 'block',
59
+ fontWeight: 600,
60
+ lineHeight: 1.3,
50
61
  whiteSpace: 'nowrap',
51
62
  overflow: 'hidden',
52
- textOverflow: 'ellipsis',
53
- maxWidth: 100
54
- }, children: loading ? 'Loading...' : getDisplayName(selectedTenant) })] }) }), _jsxs(Menu, { anchorEl: anchorEl, open: isOpen, onClose: handleClose, anchorOrigin: {
55
- vertical: 'bottom',
56
- horizontal: 'left'
57
- }, transformOrigin: {
58
- vertical: 'top',
59
- horizontal: 'left'
60
- }, children: [_jsx(MenuItem, { disabled: true, children: _jsx(Typography, { variant: "caption", sx: {
61
- color: 'text.secondary',
62
- fontWeight: 600
63
- }, children: "SELECT CONTEXT" }) }), _jsx(Divider, {}), tenants.map((tenant) => {
63
+ textOverflow: 'ellipsis'
64
+ }, children: loading ? 'Loading...' : getDisplayName(selectedTenant) })] }) }), _jsxs(Menu, { anchorEl: anchorEl, open: isOpen, onClose: handleClose, anchorOrigin: { vertical: 'bottom', horizontal: 'right' }, transformOrigin: { vertical: 'top', horizontal: 'right' }, slotProps: {
65
+ paper: {
66
+ sx: {
67
+ mt: 0.5,
68
+ minWidth: 248,
69
+ borderRadius: 3,
70
+ border: `1px solid ${surface.divider}`,
71
+ boxShadow: '0 18px 40px -24px rgba(88,28,135,0.5)'
72
+ }
73
+ }
74
+ }, children: [_jsx(MenuItem, { disabled: true, sx: { '&.Mui-disabled': { opacity: 1 } }, children: _jsx(Typography, { variant: "caption", sx: { color: ink.muted, fontWeight: 700, letterSpacing: 0.4 }, children: "Switch to" }) }), _jsx(Divider, {}), tenants.map((tenant) => {
64
75
  const isSelected = selectedTenant?.id === tenant.id;
65
76
  return (_jsxs(MenuItem, { onClick: () => handleTenantSelect(tenant), selected: isSelected, sx: {
66
- py: 1.5,
67
- '&.Mui-selected': {
68
- backgroundColor: 'primary.50'
69
- }
70
- }, children: [_jsx(ListItemIcon, { children: getIcon(tenant.type) }), _jsx(ListItemText, { primary: _jsxs(Box, { sx: { display: 'flex', alignItems: 'center', justifyContent: 'space-between' }, children: [_jsx(Typography, { variant: "body2", sx: { fontWeight: 500 }, children: tenant.type === 'personal' ? 'Personal' : tenant.name }), isSelected && _jsx(Check, { fontSize: "small", color: "primary" })] }), secondary: _jsx(Typography, { variant: "caption", sx: {
71
- color: 'text.secondary'
72
- }, children: tenant.type === 'personal' ? 'Your personal data' : 'Group workspace' }) })] }, tenant.id));
73
- }), tenants.length === 0 && !loading && (_jsx(MenuItem, { disabled: true, children: _jsx(ListItemText, { primary: _jsx(Typography, { variant: "body2", sx: {
74
- color: 'text.secondary'
75
- }, children: "No contexts available" }) }) }))] })] }));
77
+ mx: 0.5,
78
+ py: 1.25,
79
+ borderRadius: 2,
80
+ minHeight: TOUCH_TARGET,
81
+ color: isSelected ? brand.violetInk : ink.body,
82
+ '&.Mui-selected': { backgroundColor: `${brand.violetInk}14`, color: brand.violetInk },
83
+ '&.Mui-selected:hover': { backgroundColor: `${brand.violetInk}20` },
84
+ '&:hover': { backgroundColor: surface.sunken }
85
+ }, children: [_jsx(ListItemIcon, { sx: { color: 'inherit', minWidth: 36 }, children: getIcon(tenant.type) }), _jsx(ListItemText, { primary: _jsxs(Box, { sx: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }, children: [_jsx(Typography, { variant: "body2", noWrap: true, sx: { fontWeight: isSelected ? 700 : 500 }, children: tenant.type === 'personal' ? 'Personal' : tenant.name }), isSelected && _jsx(Check, { fontSize: "small", sx: { color: 'inherit' } })] }), secondary: _jsx(Typography, { variant: "caption", sx: { color: ink.muted }, children: tenant.type === 'personal' ? 'Your personal data' : 'Group workspace' }) })] }, tenant.id));
86
+ }), tenants.length === 0 && !loading && (_jsx(MenuItem, { disabled: true, children: _jsx(ListItemText, { primary: _jsx(Typography, { variant: "body2", sx: { color: ink.muted }, children: "No contexts available" }) }) }))] })] }));
76
87
  };
77
88
  export default TenantSelector;
@@ -12,4 +12,4 @@ export interface UseTenantResult {
12
12
  * Custom hook for managing tenant selection
13
13
  * Handles both personal account and group contexts
14
14
  */
15
- export declare const useTenant: (groupApiUrl: string, userEmail?: string) => UseTenantResult;
15
+ export declare const useTenant: (groupApiUrl: string, userId?: string) => UseTenantResult;
@@ -7,6 +7,47 @@ export { TenantProvider, useTenantListener, useTenantContext };
7
7
  // Owned here, but declared in frontend-core so `logout()` can clear it — see
8
8
  // the note on session-scoped storage there.
9
9
  const STORAGE_KEY = TENANT_STORAGE_KEY;
10
+ /**
11
+ * Which context each account last worked in, keyed by user id.
12
+ *
13
+ * Deliberately *not* in SESSION_SCOPED_KEYS: `logout()` clears the active
14
+ * selection above, which is right — `useTenantFilter` reads that key
15
+ * synchronously with no idea who is signed in, so leaving a group id there
16
+ * across a sign-out is what sent the next account's requests out with a
17
+ * groupId it is not a member of. This is the other half: a preference, per
18
+ * account, that survives a sign-out so signing back in returns you to the
19
+ * group you were working in rather than dropping you on Personal every time.
20
+ *
21
+ * Keyed by email rather than stamped with one, so a second account signing in
22
+ * on the same browser reads its own entry or none, never someone else's.
23
+ */
24
+ const LAST_TENANT_KEY = 'lastTenantByUser';
25
+ const readLastTenants = () => {
26
+ try {
27
+ const raw = localStorage.getItem(LAST_TENANT_KEY);
28
+ return raw ? JSON.parse(raw) : {};
29
+ }
30
+ catch (err) {
31
+ logger.warn('Failed to read the last tenant per account', { error: err });
32
+ return {};
33
+ }
34
+ };
35
+ /** The context this account was last in, or null for an account with no history. */
36
+ const loadLastTenant = (userId) => {
37
+ if (!userId)
38
+ return null;
39
+ return readLastTenants()[userId] ?? null;
40
+ };
41
+ const rememberLastTenant = (tenant, userId) => {
42
+ if (!userId)
43
+ return;
44
+ try {
45
+ localStorage.setItem(LAST_TENANT_KEY, JSON.stringify({ ...readLastTenants(), [userId]: tenant }));
46
+ }
47
+ catch (err) {
48
+ logger.warn('Failed to remember the last tenant', { error: err });
49
+ }
50
+ };
10
51
  /** The stamp is storage bookkeeping; consumers see a plain TenantOption. */
11
52
  const toOption = ({ id, name, type }) => ({ id, name, type });
12
53
  // Global cache to prevent multiple simultaneous requests across all hook instances
@@ -19,12 +60,12 @@ const CACHE_DURATION = 30000; // 30 seconds
19
60
  // user as well as URL: the cache outlives a sign-out, and handing the incoming
20
61
  // account the previous one's group list is how a stale selection gets
21
62
  // "validated" and survives.
22
- const fetchGroupsGlobally = async (groupApiUrl, userEmail) => {
63
+ const fetchGroupsGlobally = async (groupApiUrl, userId) => {
23
64
  requestCounter++;
24
65
  logger.debug('fetchGroupsGlobally called', { requestNumber: requestCounter, groupApiUrl });
25
66
  // Check if we have a valid cached request for the same API URL and user
26
67
  const now = Date.now();
27
- const cacheKey = `${userEmail ?? ''}|${groupApiUrl}`;
68
+ const cacheKey = `${userId ?? ''}|${groupApiUrl}`;
28
69
  if (globalGroupsFetchCache &&
29
70
  globalGroupsCacheKey === cacheKey &&
30
71
  now - globalGroupsCacheTimestamp < CACHE_DURATION) {
@@ -98,7 +139,7 @@ const fetchGroupsGlobally = async (groupApiUrl, userEmail) => {
98
139
  * Custom hook for managing tenant selection
99
140
  * Handles both personal account and group contexts
100
141
  */
101
- export const useTenant = (groupApiUrl, userEmail) => {
142
+ export const useTenant = (groupApiUrl, userId) => {
102
143
  const [selectedTenant, setSelectedTenantState] = useState(null);
103
144
  const [availableTenants, setAvailableTenants] = useState([]);
104
145
  const [loading, setLoading] = useState(true);
@@ -120,20 +161,22 @@ export const useTenant = (groupApiUrl, userEmail) => {
120
161
  // Save tenant selection to localStorage, stamped with the current account
121
162
  const saveTenant = useCallback((tenant) => {
122
163
  try {
123
- const stored = { ...tenant, userEmail };
164
+ const stored = { ...tenant, userId };
124
165
  localStorage.setItem(STORAGE_KEY, JSON.stringify(stored));
125
166
  }
126
167
  catch (err) {
127
168
  logger.warn('Failed to save tenant selection', { error: err });
128
169
  }
129
- }, [userEmail]);
170
+ // Outlives the sign-out that clears the line above.
171
+ rememberLastTenant(tenant, userId);
172
+ }, [userId]);
130
173
  // Fetch available groups from API using user authentication
131
174
  const fetchGroups = useCallback(async () => {
132
- return fetchGroupsGlobally(groupApiUrl, userEmail);
133
- }, [groupApiUrl, userEmail]);
175
+ return fetchGroupsGlobally(groupApiUrl, userId);
176
+ }, [groupApiUrl, userId]);
134
177
  // Refresh tenants list
135
178
  const refreshTenants = useCallback(async () => {
136
- if (!userEmail) {
179
+ if (!userId) {
137
180
  setLoading(false);
138
181
  return;
139
182
  }
@@ -155,22 +198,35 @@ export const useTenant = (groupApiUrl, userEmail) => {
155
198
  // A selection made by a different account is not ours to keep, even if
156
199
  // this account happens to belong to that group too — an unstamped one
157
200
  // predates this check and is validated by membership alone.
158
- const belongsToThisUser = !savedTenant?.userEmail || savedTenant.userEmail === userEmail;
201
+ const belongsToThisUser = !savedTenant?.userId || savedTenant.userId === userId;
159
202
  const stillAMember = allTenants.some((t) => t.id === savedTenant?.id);
160
203
  if (savedTenant && belongsToThisUser && stillAMember) {
161
204
  setSelectedTenantState(toOption(savedTenant));
162
205
  }
163
206
  else {
164
- // Fall back to personal: the saved tenant is gone, or was someone else's
165
- setSelectedTenantState(personalTenant);
166
- saveTenant(personalTenant);
207
+ // No active selection this account can use. Before falling back to
208
+ // Personal, see where this account was last working: a sign-out clears
209
+ // the active key, and without this every sign-in landed on Personal
210
+ // however long you had been in a group.
211
+ const lastUsed = loadLastTenant(userId);
212
+ const restorable = lastUsed && lastUsed.type === 'group' && allTenants.some((tenant) => tenant.id === lastUsed.id)
213
+ ? lastUsed
214
+ : null;
215
+ const next = restorable ?? personalTenant;
216
+ setSelectedTenantState(next);
217
+ saveTenant(next);
167
218
  // Sibling components read the raw storage value synchronously on mount
168
219
  // (useTenantFilter), so by now they have already fired their requests
169
- // against the discarded groupId and are showing 403s. Reload for the
170
- // same reason switching tenant by hand reloads it is the only way
171
- // this app re-reads the context. Storage now says personal, so the
220
+ // against whatever storage said a moment ago the discarded groupId,
221
+ // or nothing at all where we have just restored one. Reload for the
222
+ // same reason switching tenant by hand reloads: it is the only way
223
+ // this app re-reads the context. Storage now agrees with state, so the
172
224
  // next pass takes the branch above and this does not loop.
173
- if (savedTenant?.type === 'group') {
225
+ if (restorable) {
226
+ logger.debug('Restored the context this account last used', { name: restorable.name });
227
+ window.location.reload();
228
+ }
229
+ else if (savedTenant?.type === 'group') {
174
230
  logger.debug('Discarded a tenant selection this account cannot use; reloading');
175
231
  window.location.reload();
176
232
  }
@@ -197,7 +253,7 @@ export const useTenant = (groupApiUrl, userEmail) => {
197
253
  finally {
198
254
  setLoading(false);
199
255
  }
200
- }, [userEmail, fetchGroups, loadSavedTenant, saveTenant]);
256
+ }, [userId, fetchGroups, loadSavedTenant, saveTenant]);
201
257
  // Set selected tenant with page reload
202
258
  const setSelectedTenant = useCallback((tenant) => {
203
259
  logger.debug('Tenant changing', { to: tenant.type === 'personal' ? 'Personal' : tenant.name });
package/dist/index.d.ts CHANGED
@@ -21,12 +21,14 @@ export { LoginButton } from './components/LoginButton';
21
21
  export { NotificationsMenu } from './components/NotificationsMenu/NotificationsMenu';
22
22
  export { AuthHeader } from './components/AuthHeader';
23
23
  export { AppLayout } from './components/AppLayout';
24
+ export { SectionBar } from './components/SectionBar';
24
25
  export { UsageMeter, formatBytes, useEntitlements } from './components/UsageMeter';
25
26
  export { TenantSelector, TenantProvider, useTenant, useTenantListener, useTenantContext, useTenantFilter } from './components/tenant';
26
27
  export type { LoaderProps } from './components/Loader';
27
28
  export type { ErrorPageProps } from './components/ErrorPage';
28
29
  export type { AppErrorBoundaryProps } from './components/AppErrorBoundary';
29
30
  export type { AppLayoutProps } from './components/AppLayout';
31
+ export type { SectionBarProps, SectionBarItem } from './components/SectionBar';
30
32
  export type { UnauthorizedPageProps } from './components/UnauthorizedPage';
31
33
  export type { ProtectedRouteProps, ProtectedRouteAuthService } from './components/ProtectedRoute';
32
34
  export type { LoginButtonProps } from './components/LoginButton';
@@ -37,4 +39,4 @@ export type { UsageMeterProps, UseEntitlementsResult } from './components/UsageM
37
39
  export { createConfigProvider } from './config/createConfigProvider';
38
40
  export type { CreateConfigProviderOptions } from './config/createConfigProvider';
39
41
  export { toOrigin } from './routing';
40
- export { brand, ink, surface, gradient, moduleAccent, wordmark, glass, glassInteractive, brandButton, section, gutter, gutterSx, TOUCH_TARGET, PHONE, createTumbalandTheme, tumbalandTheme, TUMBALAND_COLORS } from './theme';
42
+ export { brand, ink, surface, gradient, moduleAccent, wordmark, glass, glassInteractive, chrome, brandButton, section, gutter, gutterSx, TOUCH_TARGET, PHONE, createTumbalandTheme, tumbalandTheme, TUMBALAND_COLORS } from './theme';
package/dist/index.js CHANGED
@@ -29,6 +29,7 @@ export { LoginButton } from './components/LoginButton';
29
29
  export { NotificationsMenu } from './components/NotificationsMenu/NotificationsMenu';
30
30
  export { AuthHeader } from './components/AuthHeader';
31
31
  export { AppLayout } from './components/AppLayout';
32
+ export { SectionBar } from './components/SectionBar';
32
33
  // Entitlements: the usage bar and the hook that feeds it
33
34
  export { UsageMeter, formatBytes, useEntitlements } from './components/UsageMeter';
34
35
  // Tenant selection: provider, selector, and hooks shared across domain frontends
@@ -39,4 +40,4 @@ export { createConfigProvider } from './config/createConfigProvider';
39
40
  export { toOrigin } from './routing';
40
41
  // Theme: the palette, the MUI theme built from it, and the raw tokens for the
41
42
  // places that have to name a colour outside a `sx` prop.
42
- export { brand, ink, surface, gradient, moduleAccent, wordmark, glass, glassInteractive, brandButton, section, gutter, gutterSx, TOUCH_TARGET, PHONE, createTumbalandTheme, tumbalandTheme, TUMBALAND_COLORS } from './theme';
43
+ export { brand, ink, surface, gradient, moduleAccent, wordmark, glass, glassInteractive, chrome, brandButton, section, gutter, gutterSx, TOUCH_TARGET, PHONE, createTumbalandTheme, tumbalandTheme, TUMBALAND_COLORS } from './theme';
@@ -1,2 +1,2 @@
1
- export { brand, ink, surface, gradient, moduleAccent, wordmark, glass, glassInteractive, brandButton, section, gutter, TOUCH_TARGET, PHONE } from './tokens';
1
+ export { brand, ink, surface, gradient, moduleAccent, wordmark, glass, glassInteractive, chrome, brandButton, section, gutter, TOUCH_TARGET, PHONE } from './tokens';
2
2
  export { createTumbalandTheme, tumbalandTheme, TUMBALAND_COLORS, gutterSx } from './createTumbalandTheme';
@@ -1,2 +1,2 @@
1
- export { brand, ink, surface, gradient, moduleAccent, wordmark, glass, glassInteractive, brandButton, section, gutter, TOUCH_TARGET, PHONE } from './tokens';
1
+ export { brand, ink, surface, gradient, moduleAccent, wordmark, glass, glassInteractive, chrome, brandButton, section, gutter, TOUCH_TARGET, PHONE } from './tokens';
2
2
  export { createTumbalandTheme, tumbalandTheme, TUMBALAND_COLORS, gutterSx } from './createTumbalandTheme';
@@ -79,7 +79,7 @@ export declare const moduleAccent: {
79
79
  readonly journal: "#e11d48";
80
80
  readonly album: "#2563eb";
81
81
  readonly money: "#15803d";
82
- readonly profile: "#0f172a";
82
+ readonly profile: "#d61f92";
83
83
  readonly groups: "#7c3aed";
84
84
  readonly billing: "#4f46e5";
85
85
  };
@@ -114,6 +114,23 @@ export declare const glass: {
114
114
  readonly border: "1px solid rgba(255,255,255,0.7)";
115
115
  readonly boxShadow: "0 10px 30px -22px rgba(31,38,135,0.5)";
116
116
  };
117
+ /**
118
+ * The frosted chrome around the content: the navigation rail, the tab bar under
119
+ * it on phones, and the header bar above it.
120
+ *
121
+ * Named once because the three meet at a corner. They each had their own
122
+ * rgba white and their own blur, so the seam between the rail and the bar it
123
+ * touches was visible as a faint step in the tint.
124
+ *
125
+ * Not `glass`: that is the card, which is rounded and has a light border of its
126
+ * own. A full-height rail with a 28px radius and a white outline is a card the
127
+ * page happens to be next to.
128
+ */
129
+ export declare const chrome: {
130
+ readonly background: "rgba(255,255,255,0.78)";
131
+ readonly backdropFilter: "blur(14px) saturate(150%)";
132
+ readonly WebkitBackdropFilter: "blur(14px) saturate(150%)";
133
+ };
117
134
  /** A card that responds to being pointed at. `accent` tints the lifted shadow. */
118
135
  export declare const glassInteractive: (accent?: string) => {
119
136
  readonly transition: "transform .2s ease, box-shadow .2s ease";
@@ -82,7 +82,10 @@ export const moduleAccent = {
82
82
  journal: '#e11d48',
83
83
  album: '#2563eb',
84
84
  money: '#15803d',
85
- profile: ink.strong,
85
+ // The identity pink, in its readable form. This was `ink.strong`, which is
86
+ // a text colour: as the tint behind Profile's current section it drew a grey
87
+ // pill in the one place the product's own colour belongs.
88
+ profile: brand.pinkInk,
86
89
  groups: brand.violetInk,
87
90
  billing: '#4f46e5'
88
91
  };
@@ -117,6 +120,23 @@ export const glass = {
117
120
  border: '1px solid rgba(255,255,255,0.7)',
118
121
  boxShadow: '0 10px 30px -22px rgba(31,38,135,0.5)'
119
122
  };
123
+ /**
124
+ * The frosted chrome around the content: the navigation rail, the tab bar under
125
+ * it on phones, and the header bar above it.
126
+ *
127
+ * Named once because the three meet at a corner. They each had their own
128
+ * rgba white and their own blur, so the seam between the rail and the bar it
129
+ * touches was visible as a faint step in the tint.
130
+ *
131
+ * Not `glass`: that is the card, which is rounded and has a light border of its
132
+ * own. A full-height rail with a 28px radius and a white outline is a card the
133
+ * page happens to be next to.
134
+ */
135
+ export const chrome = {
136
+ background: 'rgba(255,255,255,0.78)',
137
+ backdropFilter: 'blur(14px) saturate(150%)',
138
+ WebkitBackdropFilter: 'blur(14px) saturate(150%)'
139
+ };
120
140
  /** A card that responds to being pointed at. `accent` tints the lifted shadow. */
121
141
  export const glassInteractive = (accent = brand.purple) => ({
122
142
  ...glass,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tumbaland/frontend-core",
3
- "version": "1.15.0",
3
+ "version": "1.16.0",
4
4
  "description": "Shared frontend logic, components and theme for Tumbaland frontends",
5
5
  "author": "Tumbaland",
6
6
  "license": "MIT",