@visns-studio/visns-components 6.5.4 → 6.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -91,7 +91,7 @@
91
91
  "react-dom": "^17.0.0 || ^18.0.0"
92
92
  },
93
93
  "name": "@visns-studio/visns-components",
94
- "version": "6.5.4",
94
+ "version": "6.6.0",
95
95
  "description": "Various packages to assist in the development of our Custom Applications.",
96
96
  "main": "src/index.js",
97
97
  "files": [
@@ -43,6 +43,7 @@ import NativeDateFilterEditor from './columns/NativeDateFilterEditor';
43
43
  import { toast } from 'react-toastify';
44
44
  import fetchUtil from '../utils/fetchUtil';
45
45
  import { confirmDialog } from './utils/ConfirmDialog';
46
+ import { useDensity } from './utils/useDensity';
46
47
  import { DEFAULT_INTELLIGENT_SORTING_CONFIG } from '../utils/relationshipSortingUtils';
47
48
  import {
48
49
  extractColumnsMetadata,
@@ -923,31 +924,19 @@ const DataGrid = forwardRef(
923
924
  };
924
925
  }, []);
925
926
 
926
- // Track body.tablet-mode so we can fix rowHeight in tablet views.
927
- // With rowHeight={null} Inovua measures each row's content, which is
927
+ // The px geometry this grid lays out with — row heights, the width one
928
+ // action icon needs, the floor under a column — comes from the density
929
+ // table rather than from constants here, so app density (data-density
930
+ // on <html>) and per-view tablet mode (body.tablet-mode) are decided in
931
+ // one place. `grid` is a stable reference out of DENSITY_GRID, so it is
932
+ // safe in the dependency arrays below.
933
+ //
934
+ // Why a fixed rowHeight matters in the roomier modes: with
935
+ // rowHeight={null} Inovua measures each row's content, which is
928
936
  // unreliable after auto-refresh on an idle tab (background-throttled
929
- // rAF/timers). A fixed height in tablet-mode bypasses measurement
930
- // entirely and prevents action buttons from being clipped.
931
- const [isTabletMode, setIsTabletMode] = useState(() =>
932
- typeof document !== 'undefined' &&
933
- document.body.classList.contains('tablet-mode')
934
- );
935
- useEffect(() => {
936
- if (typeof document === 'undefined') {
937
- return undefined;
938
- }
939
- const sync = () =>
940
- setIsTabletMode(
941
- document.body.classList.contains('tablet-mode')
942
- );
943
- sync();
944
- const observer = new MutationObserver(sync);
945
- observer.observe(document.body, {
946
- attributes: true,
947
- attributeFilter: ['class'],
948
- });
949
- return () => observer.disconnect();
950
- }, []);
937
+ // rAF/timers). A fixed height bypasses measurement entirely and
938
+ // prevents action buttons from being clipped.
939
+ const { grid } = useDensity();
951
940
 
952
941
  // Effect to handle autoRefresh setting
953
942
  useEffect(() => {
@@ -3613,14 +3602,11 @@ const DataGrid = forwardRef(
3613
3602
  .filter((entry) => !isRowActionConfig(entry))
3614
3603
  );
3615
3604
 
3616
- // Width to fit N action icons: tablet-mode icons have a larger
3617
- // tap target/margin (~44px) than the default (~34px), plus cell
3605
+ // Width to fit N action icons: roomier densities give icons a
3606
+ // larger tap target/margin than the default, plus cell
3618
3607
  // padding. Used by both action columns and the trailing column.
3619
3608
  const actionColumnWidthFor = (count) =>
3620
- Math.max(
3621
- 100,
3622
- count * (isTabletMode ? 44 : 34) + 16
3623
- );
3609
+ Math.max(100, count * grid.actionColUnit + 16);
3624
3610
 
3625
3611
  const renderColumn = (column) => {
3626
3612
  let childValue;
@@ -3652,7 +3638,7 @@ const DataGrid = forwardRef(
3652
3638
  // clamped columns exceed the container (narrow
3653
3639
  // screens) — columns then render a few px wide
3654
3640
  // with no horizontal scrollbar ever appearing.
3655
- minWidth: column.minWidth ?? 80,
3641
+ minWidth: column.minWidth ?? grid.columnMinWidth,
3656
3642
  maxWidth: column.maxWidth ?? undefined,
3657
3643
  textAlign: column.textAlign ?? undefined,
3658
3644
  textVerticalAlign:
@@ -3822,9 +3808,7 @@ const DataGrid = forwardRef(
3822
3808
  // what the icons themselves need — tap targets
3823
3809
  // stay intact in tablet mode.
3824
3810
  const iconsNeed =
3825
- actionCount *
3826
- (isTabletMode ? 44 : 34) +
3827
- 16;
3811
+ actionCount * grid.actionColUnit + 16;
3828
3812
  const width =
3829
3813
  column.maxWidth != null
3830
3814
  ? Math.max(
@@ -4757,7 +4741,7 @@ const DataGrid = forwardRef(
4757
4741
  .catch((error) => {
4758
4742
  console.error('Error in fetching dropdown data: ', error);
4759
4743
  });
4760
- }, [columns, filterDataSource, memoizedSettings, isTabletMode]);
4744
+ }, [columns, filterDataSource, memoizedSettings, grid]);
4761
4745
 
4762
4746
  // Force re-render when filterDataSource updates to ensure dropdown filters get their data
4763
4747
  const [renderKey, setRenderKey] = useState(0);
@@ -4886,13 +4870,16 @@ const DataGrid = forwardRef(
4886
4870
  } else if (ajaxSetting?.take >= 35) {
4887
4871
  setLimit(ajaxSetting.take);
4888
4872
  } else {
4873
+ // Divide by the row height actually in force. This was
4874
+ // hardcoded to 32 even in tablet mode, so tablet pages asked
4875
+ // for far more rows than could ever fit on screen.
4889
4876
  const calculateRows = (height) =>
4890
- Math.floor((height - 100) / 32);
4877
+ Math.floor((height - 100) / grid.rowUnit);
4891
4878
 
4892
4879
  const rowCount = calculateRows(minHeight);
4893
4880
  setLimit(rowCount);
4894
4881
  }
4895
- }, [dataCount, gridHeight, window.innerHeight, userProfile?.rows_per_page, userProfile?.settings?.rows_per_page]);
4882
+ }, [dataCount, gridHeight, window.innerHeight, grid, userProfile?.rows_per_page, userProfile?.settings?.rows_per_page]);
4896
4883
 
4897
4884
  useEffect(() => {
4898
4885
  const debouncedHandleResize = debounce(() => {
@@ -5578,7 +5565,7 @@ const DataGrid = forwardRef(
5578
5565
  }
5579
5566
  headerProps={headerProps}
5580
5567
  idProperty="id"
5581
- minRowHeight={isTabletMode ? 52 : 32}
5568
+ minRowHeight={grid.minRowHeight}
5582
5569
  onFilterValueChange={(fv) => {
5583
5570
  handleFilterChange(fv, filterValue);
5584
5571
  }}
@@ -5590,7 +5577,12 @@ const DataGrid = forwardRef(
5590
5577
  renderRowContextMenu={renderRowContextMenu}
5591
5578
  renderColumnContextMenu={renderColumnContextMenu}
5592
5579
  rowClassName={rowClassName}
5593
- rowHeight={isTabletMode ? 52 : null}
5580
+ // null lets the grid measure each row; a number
5581
+ // fixes the height, which the roomier densities
5582
+ // need — measurement is unreliable in a
5583
+ // background-throttled tab and clips action
5584
+ // buttons. See the density table in useDensity.
5585
+ rowHeight={grid.rowHeight}
5594
5586
  rowStyle={getRowStyle}
5595
5587
  selected={selected}
5596
5588
  showZebraRows={true}
@@ -5616,15 +5608,36 @@ const DataGrid = forwardRef(
5616
5608
  ? 'InovuaReactDataGrid--grouped datagrid-with-grouping datagrid-fixed-group-headers'
5617
5609
  : 'datagrid-without-grouping'
5618
5610
  }
5619
- checkboxColumnProps={{
5620
- width: 60,
5621
- minWidth: 60,
5622
- maxWidth: 60,
5611
+ // The real prop is `checkboxColumn` (bool | column
5612
+ // object) — {...tableSetting} spreads the view's
5613
+ // `checkboxColumn: true` and this object, placed
5614
+ // after the spread, replaces it. It previously sat
5615
+ // on a prop named `checkboxColumnProps`, which no
5616
+ // version of the datagrid has ever read: the grid
5617
+ // fell back to its internal 40px default column and
5618
+ // the custom select-all header below never
5619
+ // rendered.
5620
+ checkboxColumn={
5621
+ tableSetting?.checkboxColumn
5622
+ ? {
5623
+ // The ?? 60 is a backstop, not a second
5624
+ // source of truth: if the density table ever
5625
+ // fails to supply a width (a stale-HMR module
5626
+ // mix did exactly that once), the grid would
5627
+ // otherwise fall back to its internal 40px
5628
+ // default and clip the checkbox.
5629
+ width: grid.checkboxColWidth ?? 60,
5630
+ minWidth: grid.checkboxColWidth ?? 60,
5631
+ maxWidth: grid.checkboxColWidth ?? 60,
5623
5632
  resizable: false,
5624
5633
  sortable: false,
5625
5634
  headerAlign: 'center',
5626
5635
  textAlign: 'center',
5627
- renderHeader: () => {
5636
+ // `header`, not `renderHeader`: the column
5637
+ // merge is {...default, ...this}, and the
5638
+ // default supplies its select-all through
5639
+ // `header` — this key must shadow that one.
5640
+ header: () => {
5628
5641
  // Custom header checkbox that works with grouped data
5629
5642
  const allRowsSelected =
5630
5643
  dataRef.current &&
@@ -5649,7 +5662,12 @@ const DataGrid = forwardRef(
5649
5662
  return (
5650
5663
  <input
5651
5664
  type="checkbox"
5652
- checked={allRowsSelected}
5665
+ // Boolean() — the && chain yields
5666
+ // undefined before data arrives,
5667
+ // which React reads as
5668
+ // "uncontrolled" and then warns
5669
+ // when it flips to controlled.
5670
+ checked={Boolean(allRowsSelected)}
5653
5671
  ref={(checkbox) => {
5654
5672
  if (checkbox) {
5655
5673
  checkbox.indeterminate =
@@ -5695,8 +5713,11 @@ const DataGrid = forwardRef(
5695
5713
  }
5696
5714
  }}
5697
5715
  style={{
5698
- width: '20px',
5699
- height: '20px',
5716
+ // rem so it rides the density
5717
+ // root: 20px default, 22.5px
5718
+ // in large.
5719
+ width: '1.25rem',
5720
+ height: '1.25rem',
5700
5721
  cursor: 'pointer',
5701
5722
  accentColor:
5702
5723
  'var(--primary-color, #3b82f6)',
@@ -5704,7 +5725,9 @@ const DataGrid = forwardRef(
5704
5725
  />
5705
5726
  );
5706
5727
  },
5707
- }}
5728
+ }
5729
+ : undefined
5730
+ }
5708
5731
  checkboxOnlyRowSelect={true}
5709
5732
  />
5710
5733
  ) : null}
@@ -146,12 +146,12 @@ const Login = ({
146
146
  if (result.previous && result.previous !== '') {
147
147
  window.location.href = result.previous;
148
148
  } else {
149
+ // GenericAuth owns the transition from here:
150
+ // its curtain drops over this screen and its
151
+ // profile check performs the one navigation
152
+ // to '/'. Navigating here as well produced a
153
+ // double route change and a visible flicker.
149
154
  setSystemAuth(true);
150
- // Move now, with the profile the server just
151
- // returned — waiting for the background
152
- // re-check to navigate leaves the login form
153
- // on screen for a beat after success.
154
- navigate('/', { replace: true });
155
155
  }
156
156
  }
157
157
  } else {
@@ -1,4 +1,4 @@
1
- import React, { useState, useEffect } from 'react';
1
+ import React, { useState, useEffect, useInsertionEffect } from 'react';
2
2
  import {
3
3
  useNavigate,
4
4
  useLocation,
@@ -26,7 +26,7 @@ import ClientOTPVerify from '../auth/ClientOTPVerify';
26
26
  // blank white page between the sign-in redirect and the first authenticated
27
27
  // paint. Self-contained: SMIL animation, no stylesheet dependency, and the
28
28
  // consuming app's design tokens with neutral fallbacks.
29
- const AuthLoading = () => (
29
+ const AuthLoading = ({ label }) => (
30
30
  <div
31
31
  role="status"
32
32
  aria-label="Loading"
@@ -34,9 +34,13 @@ const AuthLoading = () => (
34
34
  position: 'fixed',
35
35
  inset: 0,
36
36
  display: 'flex',
37
+ flexDirection: 'column',
38
+ gap: '1rem',
37
39
  alignItems: 'center',
38
40
  justifyContent: 'center',
39
41
  background: 'var(--bg-color, #f4f4f5)',
42
+ // Above every app surface — this is a curtain, not a page.
43
+ zIndex: 2000,
40
44
  }}
41
45
  >
42
46
  <svg width="42" height="42" viewBox="0 0 42 42" fill="none">
@@ -63,13 +67,27 @@ const AuthLoading = () => (
63
67
  />
64
68
  </path>
65
69
  </svg>
70
+ {label ? (
71
+ <p
72
+ style={{
73
+ margin: 0,
74
+ fontFamily: 'var(--font-family, inherit)',
75
+ fontSize: 'var(--font-size-md, 0.9375rem)',
76
+ color: 'var(--paragraph-color, #52525b)',
77
+ }}
78
+ >
79
+ {label}
80
+ </p>
81
+ ) : null}
66
82
  </div>
67
83
  );
68
84
 
69
85
  const ProtectedRoute = ({ user, loading, redirectPath = '/login' }) => {
70
86
  const location = useLocation();
71
87
 
72
- return loading ? <AuthLoading /> : !user || !user.id ? (
88
+ // The full-screen curtain in GenericAuth covers the loading state; this
89
+ // just refrains from redirecting until the check settles.
90
+ return loading ? null : !user || !user.id ? (
73
91
  <Navigate
74
92
  to={redirectPath}
75
93
  replace
@@ -110,6 +128,7 @@ const GenericAuth = ({
110
128
  enforce2FA = false, // Default to not enforcing 2FA
111
129
  clientPortalConfig = null, // Configuration for client portal with component and URLs
112
130
  config = {}, // Configuration object for auth features
131
+ density = 'default', // 'default' | 'large' — app-wide display density
113
132
  }) => {
114
133
  const navigate = useNavigate();
115
134
  const location = useLocation();
@@ -120,15 +139,37 @@ const GenericAuth = ({
120
139
  const [isAuthenticated, setIsAuthenticated] = useState(false);
121
140
  const [isClientAuthenticated, setIsClientAuthenticated] = useState(false);
122
141
 
142
+ // Mirror the `density` prop onto <html> as data-density, which is what
143
+ // styles/density.css keys off. 'default' writes nothing, so an app that
144
+ // never passes the prop leaves no attribute and matches no rule.
145
+ // useInsertionEffect fires before child useLayoutEffects, so the root
146
+ // font-size is in place before anything downstream (the DataGrid most of
147
+ // all) measures itself against it.
148
+ useInsertionEffect(() => {
149
+ if (
150
+ typeof document === 'undefined' ||
151
+ !density ||
152
+ density === 'default'
153
+ ) {
154
+ return undefined;
155
+ }
156
+ document.documentElement.setAttribute('data-density', density);
157
+ return () => {
158
+ document.documentElement.removeAttribute('data-density');
159
+ };
160
+ }, [density]);
161
+
123
162
  const updateAuthStatus = async () => {
124
- // Guards must not act while the FIRST profile check is in flight:
125
- // evaluated against empty state they bounced dashboard → /login →
126
- // dashboard after sign-in. But only block when there is no usable
127
- // profile — raising loading over an already-authenticated tree swaps
128
- // the whole page for a spinner on every background re-check, which
129
- // is its own flicker. With a profile in hand the re-check is
130
- // invisible.
131
- if (!userProfile.id) {
163
+ // The loading curtain covers two moments and stays down for neither
164
+ // more nor less: the first profile check of a page load (no usable
165
+ // profile yet), and the sign-in transition (still on the login
166
+ // screen) — the user asked for "everything behind a please-wait
167
+ // screen, then the dashboard", not a settled page interrupted by a
168
+ // background re-check.
169
+ if (
170
+ !userProfile.id ||
171
+ window.location.pathname.split('/')[1] === 'login'
172
+ ) {
132
173
  setIsLoading(true);
133
174
  }
134
175
 
@@ -232,6 +273,10 @@ const GenericAuth = ({
232
273
 
233
274
  return (
234
275
  <>
276
+ {/* One curtain over everything while auth settles — the login
277
+ screen included, so sign-in reads: submit → please wait →
278
+ dashboard, with no intermediate paint. */}
279
+ {isLoading ? <AuthLoading label="Please wait…" /> : null}
235
280
  <Routes>
236
281
  <Route
237
282
  path="login"
@@ -366,6 +411,7 @@ const GenericAuth = ({
366
411
  themeType={themeType}
367
412
  userProfile={userProfile}
368
413
  enforce2FA={enforce2FA}
414
+ density={density}
369
415
  />
370
416
  }
371
417
  />
@@ -16,6 +16,7 @@ import TableFilter from '../TableFilter';
16
16
  import GenericGrid from './GenericGrid';
17
17
  import GenericReportForm from './GenericReportForm';
18
18
  import StandardModal from './StandardModal';
19
+ import { useDensity, useAvailableHeight } from '../utils/useDensity';
19
20
 
20
21
  import styles from '../styles/GenericIndex.module.scss';
21
22
 
@@ -302,6 +303,23 @@ function GenericIndex({
302
303
  const [customActionConfig, setCustomActionConfig] = useState(null);
303
304
  const { windowHeight, windowWidth } = useWindowDimensions();
304
305
 
306
+ // Grid height. The long-standing behaviour is a flat 80% of the viewport,
307
+ // which leaves a dead band below the table; at large density the screen is
308
+ // the point, so measure what is actually left below the grid instead.
309
+ // `useAvailableHeight` returns null until it can measure (and always when
310
+ // disabled), so the 80% rule stays the fallback and remains exactly what
311
+ // every non-density app gets.
312
+ const { isLarge } = useDensity();
313
+ const gridWrapperRef = useRef(null);
314
+ const measuredGridHeight = useAvailableHeight(gridWrapperRef, {
315
+ enabled: isLarge,
316
+ recomputeKey: windowHeight,
317
+ });
318
+ const gridHeight =
319
+ isLarge && measuredGridHeight != null
320
+ ? measuredGridHeight
321
+ : windowHeight * 0.8;
322
+
305
323
  // Toggle a body-level "tablet-mode" class when this view opts in via
306
324
  // setting.tabletMode. Lets consumer projects style the whole UI
307
325
  // (including app-level chrome) for tablet use without touching this lib.
@@ -663,12 +681,12 @@ function GenericIndex({
663
681
  style: config.style || userProfile?.settings?.style || {},
664
682
  };
665
683
 
666
- return (
684
+ const table = (
667
685
  <Table
668
686
  {...configWithStyle}
669
687
  ref={gridRef}
670
688
  hoverColor={configWithStyle.style?.hoverColor}
671
- gridHeight={windowHeight * 0.8}
689
+ gridHeight={gridHeight}
672
690
  setConfig={setConfig}
673
691
  setTableData={setTableData}
674
692
  setTotal={setTotal}
@@ -677,6 +695,19 @@ function GenericIndex({
677
695
  userProfile={userProfile}
678
696
  />
679
697
  );
698
+
699
+ // The measuring wrapper only exists in large density — every other
700
+ // app renders the exact tree it always has.
701
+ return isLarge ? (
702
+ <div
703
+ ref={gridWrapperRef}
704
+ style={{ flex: '1 1 auto', minHeight: 0 }}
705
+ >
706
+ {table}
707
+ </div>
708
+ ) : (
709
+ table
710
+ );
680
711
  };
681
712
 
682
713
  if (subnav) {
@@ -693,7 +724,8 @@ function GenericIndex({
693
724
  }, [
694
725
  config,
695
726
  subnav,
696
- windowHeight,
727
+ gridHeight,
728
+ isLarge,
697
729
  userProfile,
698
730
  setConfig,
699
731
  setTotal,
@@ -1,3 +1,7 @@
1
+ // Density first: its `:root[data-density='large']` block is inert until the
2
+ // app opts in via GenericAuth's `density` prop, but the root font-size it
3
+ // sets is what every rem in the stylesheets below resolves against.
4
+ import '../styles/density.css';
1
5
  import '../styles/global.css';
2
6
 
3
7
  import React, { useState, useEffect, useCallback } from 'react';
@@ -27,6 +31,10 @@ const GenericMain = ({
27
31
  themeType,
28
32
  userProfile,
29
33
  enforce2FA = false, // Default to not enforcing 2FA
34
+ // Accepted so density is part of the main-layout interface too; the
35
+ // attribute that actually drives the CSS is written by GenericAuth, and
36
+ // nothing in here reads the value yet.
37
+ density = 'default',
30
38
  }) => {
31
39
  const navigate = useNavigate();
32
40
  const location = useLocation();
@@ -690,7 +690,7 @@
690
690
  }
691
691
 
692
692
  .searchInput {
693
- height: 38px;
693
+ height: var(--grid-toolbar-height, 38px);
694
694
  border-radius: 0;
695
695
  border: none;
696
696
  padding: 0 10px 0 50px;
@@ -722,7 +722,7 @@
722
722
  display: inline-flex;
723
723
  align-items: center;
724
724
  padding: 0 15px;
725
- height: 38px;
725
+ height: var(--grid-toolbar-height, 38px);
726
726
  background-color: #f9fafb;
727
727
  border: 1px solid #d1d5db;
728
728
  margin-bottom: 0;
@@ -734,7 +734,7 @@
734
734
  display: inline-flex;
735
735
  align-items: center;
736
736
  padding: 0 15px;
737
- height: 38px;
737
+ height: var(--grid-toolbar-height, 38px);
738
738
  background-color: #f9fafb;
739
739
  border: 1px solid #d1d5db;
740
740
  border-radius: var(--br, 4px);
@@ -771,11 +771,11 @@
771
771
  .actionButtons {
772
772
  display: flex;
773
773
  align-items: center;
774
- height: 38px;
774
+ height: var(--grid-toolbar-height, 38px);
775
775
  gap: 0;
776
776
 
777
777
  > .btn {
778
- height: 38px;
778
+ height: var(--grid-toolbar-height, 38px);
779
779
  border-radius: 0;
780
780
  display: flex;
781
781
  align-items: center;
@@ -799,11 +799,11 @@
799
799
  .actionButtonsStandalone {
800
800
  display: flex;
801
801
  align-items: center;
802
- height: 38px;
802
+ height: var(--grid-toolbar-height, 38px);
803
803
  gap: 0;
804
804
 
805
805
  > .btn {
806
- height: 38px;
806
+ height: var(--grid-toolbar-height, 38px);
807
807
  display: flex;
808
808
  align-items: center;
809
809
  justify-content: center;
@@ -884,7 +884,7 @@
884
884
  justify-content: flex-start;
885
885
  margin-bottom: 0;
886
886
  width: 100%;
887
- height: 38px;
887
+ height: var(--grid-toolbar-height, 38px);
888
888
  position: relative;
889
889
  border: 1px solid #d1d5db;
890
890
  border-bottom: none;
@@ -906,7 +906,7 @@
906
906
  }
907
907
 
908
908
  input {
909
- height: 38px;
909
+ height: var(--grid-toolbar-height, 38px);
910
910
  border-radius: 0;
911
911
  border: none;
912
912
  padding: 0 10px 0 50px;
@@ -930,14 +930,14 @@
930
930
 
931
931
  .filterAction--alt {
932
932
  position: relative;
933
- height: 38px;
933
+ height: var(--grid-toolbar-height, 38px);
934
934
  z-index: 5;
935
935
  display: flex;
936
936
  gap: 0;
937
937
  /* margin-left is now controlled in the component */
938
938
 
939
939
  > .btn {
940
- height: 38px;
940
+ height: var(--grid-toolbar-height, 38px);
941
941
  border-radius: 0;
942
942
  display: flex;
943
943
  align-items: center;
@@ -960,7 +960,7 @@ input[type='file']:hover {
960
960
  border-radius: var(--br, 6px);
961
961
  font-size: 0.8rem;
962
962
  transition: all 0.2s ease;
963
- min-height: 36px;
963
+ min-height: var(--field-pocket-size, 36px);
964
964
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
965
965
  position: relative;
966
966
  overflow: hidden;
@@ -997,8 +997,8 @@ input[type='file']:hover {
997
997
  padding: 0.35em;
998
998
  border-radius: 4px;
999
999
  transition: all 0.2s ease;
1000
- min-width: 28px;
1001
- height: 28px;
1000
+ min-width: var(--field-pocket-size-sm, 28px);
1001
+ height: var(--field-pocket-size-sm, 28px);
1002
1002
  flex-shrink: 0;
1003
1003
  }
1004
1004
 
@@ -1151,7 +1151,7 @@ input[type='file']:hover {
1151
1151
  transition: all 0.2s ease;
1152
1152
  margin-bottom: 0.25em;
1153
1153
  border: 1px solid transparent;
1154
- min-height: 36px;
1154
+ min-height: var(--field-pocket-size, 36px);
1155
1155
  }
1156
1156
 
1157
1157
  .file-list-item-image {
@@ -1370,7 +1370,7 @@ input[type='file']:hover {
1370
1370
  z-index: 999; /* Match react-select z-index */
1371
1371
  max-height: 200px;
1372
1372
  overflow-y: auto;
1373
- font-size: 14px; /* Match react-select font size */
1373
+ font-size: var(--field-suggestion-font-size, 14px); /* Match react-select font size */
1374
1374
  }
1375
1375
 
1376
1376
  .emailSuggestion {
@@ -260,7 +260,7 @@
260
260
  close under its heading and the whole block is separated from the
261
261
  content below rather than floating between the two. */
262
262
  margin: calc(-1 * var(--spacing-sm, 0.5rem)) 0 var(--spacing-lg, 1.5rem);
263
- max-width: 70ch;
263
+ max-width: var(--detail-prose-width, 70ch);
264
264
  font-size: var(--font-size-sm, 0.8125rem);
265
265
  line-height: 1.55;
266
266
  color: var(--muted-color, #6e7276);
@@ -765,6 +765,9 @@
765
765
  display: flex;
766
766
  align-items: center;
767
767
  min-width: 0;
768
+ /* Flex drops whitespace-only text nodes — same fix as the index
769
+ header: one word-space back between title fragments. */
770
+ column-gap: 0.3em;
768
771
  }
769
772
 
770
773
  /* The parent crumb. Same size and baseline as the entity it leads to, so
@@ -877,7 +880,7 @@
877
880
  background: #f9f9f9; /* Subtle background to match the theme */
878
881
  border: 1px solid #ddd; /* Light border for structure */
879
882
  border-radius: 6px; /* Slight rounding for a modern feel */
880
- max-width: 600px; /* Constrain width for a balanced layout */
883
+ max-width: var(--detail-panel-max-width, 600px); /* Constrain width for a balanced layout */
881
884
  box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.1); /* Gentle shadow for depth */
882
885
  font-style: italic; /* Optional: Add a subtle font style */
883
886
  }
@@ -1078,7 +1081,7 @@
1078
1081
 
1079
1082
  .modalWide {
1080
1083
  width: 90vw;
1081
- max-width: 1400px;
1084
+ max-width: var(--detail-max-width, 1400px);
1082
1085
  min-width: 600px;
1083
1086
  }
1084
1087