@visns-studio/visns-components 6.5.5 → 6.6.1

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.5",
94
+ "version": "6.6.1",
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,
@@ -870,6 +871,25 @@ const DataGrid = forwardRef(
870
871
  return initialLimit;
871
872
  });
872
873
  const [search, setSearch] = useState('');
874
+
875
+ // This component instance survives client-side route changes — React Router
876
+ // reconciles the new page's grid into the same DataGrid element, and only the
877
+ // inner ReactDataGrid remounts (its key includes the ajax url). Without this
878
+ // reset the previous page's filters and search ride into the new page's very
879
+ // first request, including whereHas relations the new page's model may not
880
+ // have at all, which the backend answers with a 500. It has to happen during
881
+ // render rather than in an effect: an effect would fire only after the
882
+ // remounted inner grid has already mounted and issued that first fetch, so
883
+ // adjusting the state here is what keeps the stale values from ever reaching
884
+ // it. The equality guards keep this from looping.
885
+ const prevAjaxUrlRef = useRef(ajaxSetting?.url);
886
+ if (prevAjaxUrlRef.current !== ajaxSetting?.url) {
887
+ prevAjaxUrlRef.current = ajaxSetting?.url;
888
+ if (filterValue.length > 0) setFilterValue([]);
889
+ if (search !== '') setSearch('');
890
+ if (dSearch !== '') setDSearch('');
891
+ }
892
+
873
893
  const [selected, setSelected] = useState({});
874
894
  const [inputDebounceTimers] = useState({});
875
895
  const [localInputValues, setLocalInputValues] = useState({});
@@ -923,31 +943,19 @@ const DataGrid = forwardRef(
923
943
  };
924
944
  }, []);
925
945
 
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
946
+ // The px geometry this grid lays out with — row heights, the width one
947
+ // action icon needs, the floor under a column — comes from the density
948
+ // table rather than from constants here, so app density (data-density
949
+ // on <html>) and per-view tablet mode (body.tablet-mode) are decided in
950
+ // one place. `grid` is a stable reference out of DENSITY_GRID, so it is
951
+ // safe in the dependency arrays below.
952
+ //
953
+ // Why a fixed rowHeight matters in the roomier modes: with
954
+ // rowHeight={null} Inovua measures each row's content, which is
928
955
  // 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
- }, []);
956
+ // rAF/timers). A fixed height bypasses measurement entirely and
957
+ // prevents action buttons from being clipped.
958
+ const { grid } = useDensity();
951
959
 
952
960
  // Effect to handle autoRefresh setting
953
961
  useEffect(() => {
@@ -3577,6 +3585,7 @@ const DataGrid = forwardRef(
3577
3585
  };
3578
3586
 
3579
3587
  useEffect(() => {
3588
+ const requestUrl = ajaxSetting?.url;
3580
3589
  const dropdownFetches = columns
3581
3590
  .filter((column) =>
3582
3591
  ['dropdown', 'multiDropdown'].includes(column.type)
@@ -3593,6 +3602,12 @@ const DataGrid = forwardRef(
3593
3602
 
3594
3603
  Promise.all(dropdownFetches)
3595
3604
  .then((results) => {
3605
+ // The page changed while these dropdown fetches were in
3606
+ // flight — these columns/filters belong to the previous page.
3607
+ if (requestUrl !== prevAjaxUrlRef.current) {
3608
+ return;
3609
+ }
3610
+
3596
3611
  // Merge all resolved objects into a single object
3597
3612
  const dropdownData = results.reduce((acc, result) => {
3598
3613
  return { ...acc, ...result };
@@ -3613,14 +3628,11 @@ const DataGrid = forwardRef(
3613
3628
  .filter((entry) => !isRowActionConfig(entry))
3614
3629
  );
3615
3630
 
3616
- // Width to fit N action icons: tablet-mode icons have a larger
3617
- // tap target/margin (~44px) than the default (~34px), plus cell
3631
+ // Width to fit N action icons: roomier densities give icons a
3632
+ // larger tap target/margin than the default, plus cell
3618
3633
  // padding. Used by both action columns and the trailing column.
3619
3634
  const actionColumnWidthFor = (count) =>
3620
- Math.max(
3621
- 100,
3622
- count * (isTabletMode ? 44 : 34) + 16
3623
- );
3635
+ Math.max(100, count * grid.actionColUnit + 16);
3624
3636
 
3625
3637
  const renderColumn = (column) => {
3626
3638
  let childValue;
@@ -3652,7 +3664,7 @@ const DataGrid = forwardRef(
3652
3664
  // clamped columns exceed the container (narrow
3653
3665
  // screens) — columns then render a few px wide
3654
3666
  // with no horizontal scrollbar ever appearing.
3655
- minWidth: column.minWidth ?? 80,
3667
+ minWidth: column.minWidth ?? grid.columnMinWidth,
3656
3668
  maxWidth: column.maxWidth ?? undefined,
3657
3669
  textAlign: column.textAlign ?? undefined,
3658
3670
  textVerticalAlign:
@@ -3822,9 +3834,7 @@ const DataGrid = forwardRef(
3822
3834
  // what the icons themselves need — tap targets
3823
3835
  // stay intact in tablet mode.
3824
3836
  const iconsNeed =
3825
- actionCount *
3826
- (isTabletMode ? 44 : 34) +
3827
- 16;
3837
+ actionCount * grid.actionColUnit + 16;
3828
3838
  const width =
3829
3839
  column.maxWidth != null
3830
3840
  ? Math.max(
@@ -4757,7 +4767,7 @@ const DataGrid = forwardRef(
4757
4767
  .catch((error) => {
4758
4768
  console.error('Error in fetching dropdown data: ', error);
4759
4769
  });
4760
- }, [columns, filterDataSource, memoizedSettings, isTabletMode]);
4770
+ }, [columns, filterDataSource, memoizedSettings, grid]);
4761
4771
 
4762
4772
  // Force re-render when filterDataSource updates to ensure dropdown filters get their data
4763
4773
  const [renderKey, setRenderKey] = useState(0);
@@ -4886,13 +4896,16 @@ const DataGrid = forwardRef(
4886
4896
  } else if (ajaxSetting?.take >= 35) {
4887
4897
  setLimit(ajaxSetting.take);
4888
4898
  } else {
4899
+ // Divide by the row height actually in force. This was
4900
+ // hardcoded to 32 even in tablet mode, so tablet pages asked
4901
+ // for far more rows than could ever fit on screen.
4889
4902
  const calculateRows = (height) =>
4890
- Math.floor((height - 100) / 32);
4903
+ Math.floor((height - 100) / grid.rowUnit);
4891
4904
 
4892
4905
  const rowCount = calculateRows(minHeight);
4893
4906
  setLimit(rowCount);
4894
4907
  }
4895
- }, [dataCount, gridHeight, window.innerHeight, userProfile?.rows_per_page, userProfile?.settings?.rows_per_page]);
4908
+ }, [dataCount, gridHeight, window.innerHeight, grid, userProfile?.rows_per_page, userProfile?.settings?.rows_per_page]);
4896
4909
 
4897
4910
  useEffect(() => {
4898
4911
  const debouncedHandleResize = debounce(() => {
@@ -5578,7 +5591,7 @@ const DataGrid = forwardRef(
5578
5591
  }
5579
5592
  headerProps={headerProps}
5580
5593
  idProperty="id"
5581
- minRowHeight={isTabletMode ? 52 : 32}
5594
+ minRowHeight={grid.minRowHeight}
5582
5595
  onFilterValueChange={(fv) => {
5583
5596
  handleFilterChange(fv, filterValue);
5584
5597
  }}
@@ -5590,7 +5603,12 @@ const DataGrid = forwardRef(
5590
5603
  renderRowContextMenu={renderRowContextMenu}
5591
5604
  renderColumnContextMenu={renderColumnContextMenu}
5592
5605
  rowClassName={rowClassName}
5593
- rowHeight={isTabletMode ? 52 : null}
5606
+ // null lets the grid measure each row; a number
5607
+ // fixes the height, which the roomier densities
5608
+ // need — measurement is unreliable in a
5609
+ // background-throttled tab and clips action
5610
+ // buttons. See the density table in useDensity.
5611
+ rowHeight={grid.rowHeight}
5594
5612
  rowStyle={getRowStyle}
5595
5613
  selected={selected}
5596
5614
  showZebraRows={true}
@@ -5616,15 +5634,36 @@ const DataGrid = forwardRef(
5616
5634
  ? 'InovuaReactDataGrid--grouped datagrid-with-grouping datagrid-fixed-group-headers'
5617
5635
  : 'datagrid-without-grouping'
5618
5636
  }
5619
- checkboxColumnProps={{
5620
- width: 60,
5621
- minWidth: 60,
5622
- maxWidth: 60,
5637
+ // The real prop is `checkboxColumn` (bool | column
5638
+ // object) — {...tableSetting} spreads the view's
5639
+ // `checkboxColumn: true` and this object, placed
5640
+ // after the spread, replaces it. It previously sat
5641
+ // on a prop named `checkboxColumnProps`, which no
5642
+ // version of the datagrid has ever read: the grid
5643
+ // fell back to its internal 40px default column and
5644
+ // the custom select-all header below never
5645
+ // rendered.
5646
+ checkboxColumn={
5647
+ tableSetting?.checkboxColumn
5648
+ ? {
5649
+ // The ?? 60 is a backstop, not a second
5650
+ // source of truth: if the density table ever
5651
+ // fails to supply a width (a stale-HMR module
5652
+ // mix did exactly that once), the grid would
5653
+ // otherwise fall back to its internal 40px
5654
+ // default and clip the checkbox.
5655
+ width: grid.checkboxColWidth ?? 60,
5656
+ minWidth: grid.checkboxColWidth ?? 60,
5657
+ maxWidth: grid.checkboxColWidth ?? 60,
5623
5658
  resizable: false,
5624
5659
  sortable: false,
5625
5660
  headerAlign: 'center',
5626
5661
  textAlign: 'center',
5627
- renderHeader: () => {
5662
+ // `header`, not `renderHeader`: the column
5663
+ // merge is {...default, ...this}, and the
5664
+ // default supplies its select-all through
5665
+ // `header` — this key must shadow that one.
5666
+ header: () => {
5628
5667
  // Custom header checkbox that works with grouped data
5629
5668
  const allRowsSelected =
5630
5669
  dataRef.current &&
@@ -5649,7 +5688,12 @@ const DataGrid = forwardRef(
5649
5688
  return (
5650
5689
  <input
5651
5690
  type="checkbox"
5652
- checked={allRowsSelected}
5691
+ // Boolean() — the && chain yields
5692
+ // undefined before data arrives,
5693
+ // which React reads as
5694
+ // "uncontrolled" and then warns
5695
+ // when it flips to controlled.
5696
+ checked={Boolean(allRowsSelected)}
5653
5697
  ref={(checkbox) => {
5654
5698
  if (checkbox) {
5655
5699
  checkbox.indeterminate =
@@ -5695,8 +5739,11 @@ const DataGrid = forwardRef(
5695
5739
  }
5696
5740
  }}
5697
5741
  style={{
5698
- width: '20px',
5699
- height: '20px',
5742
+ // rem so it rides the density
5743
+ // root: 20px default, 22.5px
5744
+ // in large.
5745
+ width: '1.25rem',
5746
+ height: '1.25rem',
5700
5747
  cursor: 'pointer',
5701
5748
  accentColor:
5702
5749
  'var(--primary-color, #3b82f6)',
@@ -5704,7 +5751,9 @@ const DataGrid = forwardRef(
5704
5751
  />
5705
5752
  );
5706
5753
  },
5707
- }}
5754
+ }
5755
+ : undefined
5756
+ }
5708
5757
  checkboxOnlyRowSelect={true}
5709
5758
  />
5710
5759
  ) : null}
@@ -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,
@@ -128,6 +128,7 @@ const GenericAuth = ({
128
128
  enforce2FA = false, // Default to not enforcing 2FA
129
129
  clientPortalConfig = null, // Configuration for client portal with component and URLs
130
130
  config = {}, // Configuration object for auth features
131
+ density = 'default', // 'default' | 'large' — app-wide display density
131
132
  }) => {
132
133
  const navigate = useNavigate();
133
134
  const location = useLocation();
@@ -138,6 +139,26 @@ const GenericAuth = ({
138
139
  const [isAuthenticated, setIsAuthenticated] = useState(false);
139
140
  const [isClientAuthenticated, setIsClientAuthenticated] = useState(false);
140
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
+
141
162
  const updateAuthStatus = async () => {
142
163
  // The loading curtain covers two moments and stays down for neither
143
164
  // more nor less: the first profile check of a page load (no usable
@@ -390,6 +411,7 @@ const GenericAuth = ({
390
411
  themeType={themeType}
391
412
  userProfile={userProfile}
392
413
  enforce2FA={enforce2FA}
414
+ density={density}
393
415
  />
394
416
  }
395
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
 
@@ -501,7 +501,9 @@
501
501
  background-color: transparent;
502
502
  color: var(--paragraph-color, #212529);
503
503
  border: 1px solid rgba(var(--paragraph-color-rgb, 33, 37, 41), 0.2);
504
- height: 30px;
504
+ /* Fixed height, so the density hook has to reach it directly — this is
505
+ the one filter-bar control button-base doesn't size. */
506
+ height: var(--control-height-sm, 30px);
505
507
 
506
508
  svg {
507
509
  margin-right: 4px;
@@ -818,6 +820,11 @@
818
820
  display: flex;
819
821
  align-items: center;
820
822
  min-width: 0;
823
+ /* Flex drops whitespace-only text nodes, so a title written as
824
+ `Forklift <span>(warning)</span>` rendered with the two parts
825
+ jammed together. One word-space back between fragments; a title
826
+ that is a single text node has one flex item and no gap. */
827
+ column-gap: 0.3em;
821
828
  }
822
829
 
823
830
  /* A breadcrumb crumb: still a link, but it should not compete with the
@@ -1177,7 +1184,7 @@
1177
1184
 
1178
1185
  .modalWide {
1179
1186
  width: 90vw;
1180
- max-width: 1400px;
1187
+ max-width: var(--detail-max-width, 1400px);
1181
1188
  min-width: 600px;
1182
1189
  }
1183
1190