@ifc-lite/viewer 1.19.1 → 1.21.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 (106) hide show
  1. package/.turbo/turbo-build.log +59 -44
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +488 -0
  4. package/dist/assets/{basketViewActivator-CA2CTcVo.js → basketViewActivator-Bzw51jhm.js} +6 -6
  5. package/dist/assets/decode-worker-t2EGKAxO.js +1708 -0
  6. package/dist/assets/drawing-2d-Bjy8YPrg.js +257 -0
  7. package/dist/assets/exporters-u0sz2Upj.js +259119 -0
  8. package/dist/assets/geometry-controller.worker-NH8pZmrU.js +7 -0
  9. package/dist/assets/geometry.worker-Bp4rW_R1.js +1 -0
  10. package/dist/assets/ids-B7AXEv7h.js +4067 -0
  11. package/dist/assets/ifc-lite-DfZHk36-.js +7 -0
  12. package/dist/assets/ifc-lite_bg-DlKs5-yM.wasm +0 -0
  13. package/dist/assets/ifc-lite_bg-PqmRe3Ph.wasm +0 -0
  14. package/dist/assets/index-CSWgTe1s.css +1 -0
  15. package/dist/assets/{index-D8Epw-e7.js → index-DVNSvEMh.js} +40146 -35823
  16. package/dist/assets/laz-perf-Cvr_Lepg.js +1 -0
  17. package/dist/assets/laz-perf-DnSyzVYH.wasm +0 -0
  18. package/dist/assets/{native-bridge-DKmx1z95.js → native-bridge-BiD01jI9.js} +1 -1
  19. package/dist/assets/parser.worker-Bnbrl6gy.js +182 -0
  20. package/dist/assets/{sandbox-tccwm5Bo.js → sandbox-DPD1ROr0.js} +4 -4
  21. package/dist/assets/{server-client-LoWPK1N2.js → server-client-DP8fMPY9.js} +1 -1
  22. package/dist/assets/{wasm-bridge-BsJGgPMs.js → wasm-bridge-CErti6zX.js} +1 -1
  23. package/dist/assets/workerHelpers-CBbWSJmd.js +36 -0
  24. package/dist/index.html +8 -8
  25. package/index.html +1 -1
  26. package/package.json +10 -10
  27. package/src/components/viewer/BasketPresentationDock.tsx +3 -0
  28. package/src/components/viewer/CesiumOverlay.tsx +165 -120
  29. package/src/components/viewer/DeviationPanel.tsx +172 -0
  30. package/src/components/viewer/HierarchyPanel.tsx +29 -3
  31. package/src/components/viewer/HoverTooltip.tsx +5 -0
  32. package/src/components/viewer/IDSAuditSummary.tsx +389 -0
  33. package/src/components/viewer/IDSPanel.tsx +80 -26
  34. package/src/components/viewer/MainToolbar.tsx +60 -7
  35. package/src/components/viewer/MergeLayersBanner.tsx +108 -0
  36. package/src/components/viewer/MobileToolbar.tsx +326 -0
  37. package/src/components/viewer/PointCloudClasses.tsx +111 -0
  38. package/src/components/viewer/PointCloudLegend.tsx +119 -0
  39. package/src/components/viewer/PointCloudPanel.tsx +52 -1
  40. package/src/components/viewer/PropertiesPanel.tsx +37 -6
  41. package/src/components/viewer/RectSelectionOverlay.tsx +48 -0
  42. package/src/components/viewer/StatusBar.tsx +14 -0
  43. package/src/components/viewer/ViewerLayout.tsx +288 -95
  44. package/src/components/viewer/Viewport.tsx +86 -18
  45. package/src/components/viewer/ViewportContainer.tsx +25 -11
  46. package/src/components/viewer/ViewportOverlays.tsx +41 -26
  47. package/src/components/viewer/mouseHandlerTypes.ts +22 -0
  48. package/src/components/viewer/properties/GeoreferencingPanel.tsx +77 -8
  49. package/src/components/viewer/properties/MaterialCard.tsx +2 -2
  50. package/src/components/viewer/selectionHandlers.ts +41 -0
  51. package/src/components/viewer/tools/SectionPanel.tsx +181 -24
  52. package/src/components/viewer/tools/SectionVisualization.tsx +384 -3
  53. package/src/components/viewer/useAnimationLoop.ts +22 -0
  54. package/src/components/viewer/useMouseControls.ts +296 -3
  55. package/src/components/viewer/usePointCloudSync.ts +8 -1
  56. package/src/components/viewer/useRenderUpdates.ts +21 -1
  57. package/src/components/viewer/useTouchControls.ts +100 -41
  58. package/src/hooks/federationLoadGate.test.ts +90 -0
  59. package/src/hooks/federationLoadGate.ts +127 -0
  60. package/src/hooks/ids/idsDataAccessor.ts +11 -259
  61. package/src/hooks/ingest/pointCloudIngest.ts +127 -16
  62. package/src/hooks/useDrawingGeneration.ts +81 -8
  63. package/src/hooks/useIDS.ts +90 -10
  64. package/src/hooks/useIfcFederation.ts +94 -16
  65. package/src/hooks/useIfcLoader.ts +289 -64
  66. package/src/hooks/useViewerSelectors.ts +10 -0
  67. package/src/lib/geo/cesium-bridge.ts +84 -67
  68. package/src/lib/geo/clamp-anchor.test.ts +80 -0
  69. package/src/lib/geo/clamp-anchor.ts +57 -0
  70. package/src/lib/geo/effective-georef.test.ts +79 -1
  71. package/src/lib/geo/effective-georef.ts +83 -0
  72. package/src/lib/geo/reproject.ts +26 -13
  73. package/src/lib/geo/terrain-elevation.ts +166 -0
  74. package/src/lib/lens/adapter.ts +1 -1
  75. package/src/lib/llm/context-builder.ts +1 -1
  76. package/src/lib/perf/memoryAccounting.test.ts +92 -0
  77. package/src/lib/perf/memoryAccounting.ts +235 -0
  78. package/src/sdk/adapters/mutation-view.ts +1 -1
  79. package/src/store/constants.ts +39 -2
  80. package/src/store/index.ts +6 -1
  81. package/src/store/slices/cesiumSlice.ts +1 -1
  82. package/src/store/slices/idsSlice.ts +24 -0
  83. package/src/store/slices/loadingSlice.ts +12 -0
  84. package/src/store/slices/pointCloudSlice.ts +72 -1
  85. package/src/store/slices/sectionSlice.test.ts +590 -1
  86. package/src/store/slices/sectionSlice.ts +344 -17
  87. package/src/store/slices/uiSlice.merge-layers.test.ts +217 -0
  88. package/src/store/slices/uiSlice.ts +60 -2
  89. package/src/store/types.ts +42 -0
  90. package/src/store.ts +13 -0
  91. package/src/utils/acquireFileBuffer.test.ts +231 -0
  92. package/src/utils/acquireFileBuffer.ts +128 -0
  93. package/src/utils/ifcConfig.ts +24 -0
  94. package/src/utils/nativeSpatialDataStore.ts +20 -2
  95. package/src/utils/spatialHierarchy.test.ts +116 -0
  96. package/src/utils/spatialHierarchy.ts +23 -0
  97. package/tailwind.config.js +5 -0
  98. package/tsconfig.json +1 -0
  99. package/vite.config.ts +6 -0
  100. package/dist/assets/decode-worker-Collf_X_.js +0 -1320
  101. package/dist/assets/drawing-2d-DoxKMqbO.js +0 -257
  102. package/dist/assets/exporters-xbXqEDlO.js +0 -81590
  103. package/dist/assets/geometry.worker-DQEZB2rB.js +0 -1
  104. package/dist/assets/ids-2WdONLlu.js +0 -2033
  105. package/dist/assets/ifc-lite_bg-4yUkDRD8.wasm +0 -0
  106. package/dist/assets/index-BXeEKqJG.css +0 -1
@@ -0,0 +1,172 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * BIM ↔ scan deviation heatmap controls.
7
+ *
8
+ * Renders a "Compute Deviation" button when the scene has at least
9
+ * one mesh and one point cloud. Once compute completes, exposes a
10
+ * range slider + diverging-ramp legend; the splat shader's
11
+ * deviation colour mode then visualises signed distance to the
12
+ * nearest mesh surface.
13
+ *
14
+ * Lives inside the `PointCloudPanel`; rendered conditionally on
15
+ * `pointCloudAssetCount > 0`.
16
+ */
17
+
18
+ import { useCallback, useState } from 'react';
19
+ import { useViewerStore } from '@/store';
20
+ import { getGlobalRenderer } from '@/hooks/useBCF';
21
+ import { cn } from '@/lib/utils';
22
+
23
+ export interface DeviationPanelProps {
24
+ /** Total number of triangles currently in the scene — gates the
25
+ * compute button on the existence of a BIM model. */
26
+ triangleCount: number;
27
+ }
28
+
29
+ export function DeviationPanel({ triangleCount }: DeviationPanelProps) {
30
+ const halfRange = useViewerStore((s) => s.pointCloudDeviationHalfRange);
31
+ const setHalfRange = useViewerStore((s) => s.setPointCloudDeviationHalfRange);
32
+ const computed = useViewerStore((s) => s.pointCloudDeviationComputed);
33
+ const setComputed = useViewerStore((s) => s.setPointCloudDeviationComputed);
34
+ const colorMode = useViewerStore((s) => s.pointCloudColorMode);
35
+ const setColorMode = useViewerStore((s) => s.setPointCloudColorMode);
36
+
37
+ const [running, setRunning] = useState(false);
38
+ const [stats, setStats] = useState<{
39
+ triangles: number;
40
+ points: number;
41
+ durationMs: number;
42
+ } | null>(null);
43
+ const [error, setError] = useState<string | null>(null);
44
+
45
+ const handleCompute = useCallback(async () => {
46
+ const renderer = getGlobalRenderer();
47
+ if (!renderer) {
48
+ setError('Renderer not initialised yet.');
49
+ return;
50
+ }
51
+ setError(null);
52
+ setRunning(true);
53
+ const t0 = performance.now();
54
+ try {
55
+ const result = await renderer.computeDeviations({ maxRange: 1.0 });
56
+ const dt = performance.now() - t0;
57
+ if (result.pointsProcessed === 0) {
58
+ setError('No points processed — load a point cloud first.');
59
+ setRunning(false);
60
+ return;
61
+ }
62
+ if (result.bvhTriangles === 0) {
63
+ setError('No mesh geometry in the scene — load an IFC first.');
64
+ setRunning(false);
65
+ return;
66
+ }
67
+ setStats({
68
+ triangles: result.bvhTriangles,
69
+ points: result.pointsProcessed,
70
+ durationMs: dt,
71
+ });
72
+ setComputed(true);
73
+ // Default-pick a sensible half-range from the BVH's bbox if the
74
+ // user hasn't touched the slider yet (initial 5 cm is fine for
75
+ // small models but useless for a city-block scan).
76
+ if (halfRange === 0.05 && result.suggestedHalfRange !== 0.05) {
77
+ setHalfRange(result.suggestedHalfRange);
78
+ }
79
+ // Auto-switch the colour mode to deviation so the user sees
80
+ // the result immediately.
81
+ setColorMode('deviation');
82
+ } catch (err) {
83
+ setError(err instanceof Error ? err.message : String(err));
84
+ } finally {
85
+ setRunning(false);
86
+ }
87
+ }, [halfRange, setHalfRange, setColorMode, setComputed]);
88
+
89
+ // Hide the panel entirely when there's no BIM to compare against.
90
+ // Point-cloud-only sessions (just a LAS / IFCx scan) have nothing
91
+ // to deviate from so the button would always fail.
92
+ if (triangleCount === 0) return null;
93
+
94
+ return (
95
+ <div className="flex flex-col gap-1 mt-1 pt-1 border-t border-border/40">
96
+ <span className="text-[9px] uppercase text-muted-foreground tracking-wider">
97
+ Deviation (BIM ↔ scan)
98
+ </span>
99
+ <button
100
+ type="button"
101
+ onClick={handleCompute}
102
+ disabled={running}
103
+ className={cn(
104
+ 'text-xs px-2 py-1 rounded transition-colors',
105
+ running
106
+ ? 'bg-muted text-muted-foreground'
107
+ : 'bg-teal-600 text-white hover:bg-teal-500 disabled:opacity-50',
108
+ )}
109
+ title={`Build BVH from ${triangleCount.toLocaleString()} triangles, then signed-distance every loaded point against the nearest surface`}
110
+ >
111
+ {running ? 'Computing…' : computed ? 'Recompute' : 'Compute deviation'}
112
+ </button>
113
+ {error && (
114
+ <span className="text-[10px] text-destructive">{error}</span>
115
+ )}
116
+ {stats && (
117
+ <div className="text-[10px] text-muted-foreground">
118
+ {stats.points.toLocaleString()} pts vs.{' '}
119
+ {stats.triangles.toLocaleString()} tris in{' '}
120
+ {Math.round(stats.durationMs)} ms
121
+ </div>
122
+ )}
123
+
124
+ {computed && (
125
+ <>
126
+ {/* Range slider: half-width in mm. Range from 1 mm to 1 m
127
+ (logarithmic feel via the millimetre conversion). */}
128
+ <label className="flex items-center gap-2 mt-1">
129
+ <span className="text-[10px] text-muted-foreground w-12 shrink-0">
130
+ ±{(halfRange * 1000).toFixed(halfRange < 0.01 ? 1 : 0)}mm
131
+ </span>
132
+ <input
133
+ type="range"
134
+ min={1}
135
+ max={1000}
136
+ step={1}
137
+ value={Math.round(halfRange * 1000)}
138
+ onChange={(e) => setHalfRange(Number(e.target.value) / 1000)}
139
+ className="flex-1 h-1 accent-teal-600 cursor-pointer"
140
+ title="Deviation half-range in millimetres — values past ±this map to the ramp endpoints"
141
+ aria-label="Deviation range half-width"
142
+ />
143
+ </label>
144
+
145
+ {/* Legend: blue → white → red gradient with labelled endpoints. */}
146
+ <div
147
+ className="h-2 rounded-sm border border-foreground/10 mt-0.5"
148
+ style={{
149
+ background: 'linear-gradient(to right, rgb(26,77,217), rgb(242,242,242), rgb(217,51,26))',
150
+ }}
151
+ aria-label="Deviation ramp from negative (blue) to positive (red)"
152
+ />
153
+ <div className="flex justify-between text-[9px] text-muted-foreground">
154
+ <span>−{(halfRange * 1000).toFixed(0)}mm (inside)</span>
155
+ <span>0</span>
156
+ <span>+{(halfRange * 1000).toFixed(0)}mm (outside)</span>
157
+ </div>
158
+
159
+ {colorMode !== 'deviation' && (
160
+ <button
161
+ type="button"
162
+ onClick={() => setColorMode('deviation')}
163
+ className="text-[10px] text-teal-600 hover:text-teal-500 underline text-left mt-0.5"
164
+ >
165
+ Switch colour mode to Deviation
166
+ </button>
167
+ )}
168
+ </>
169
+ )}
170
+ </div>
171
+ );
172
+ }
@@ -126,13 +126,39 @@ export function HierarchyPanel() {
126
126
  groupingMode,
127
127
  setGroupingMode,
128
128
  unifiedStoreys,
129
- filteredNodes,
130
- storeysNodes,
131
- modelsNodes,
129
+ filteredNodes: rawFilteredNodes,
130
+ storeysNodes: rawStoreysNodes,
131
+ modelsNodes: rawModelsNodes,
132
132
  toggleExpand,
133
133
  getNodeElements,
134
134
  } = useHierarchyTree({ models, ifcDataStore, isMultiModel, geometryResult });
135
135
 
136
+ // Issue #540: when the user has the merge-layers load setting on,
137
+ // hide `IfcBuildingElementPart` rows from the tree — the Rust layer
138
+ // suppresses their meshes, so leaving the rows visible would lead
139
+ // to dead-clicks. Filter at the consumer (this panel) rather than
140
+ // in `spatialHierarchy.ts` per the agent coordination plan.
141
+ const mergeLayersHidesParts = useViewerStore((s) => s.mergeLayers);
142
+ const PART_TYPE_KEY = 'ifcbuildingelementpart';
143
+ const stripPartNodes = useCallback(
144
+ (nodes: TreeNode[]): TreeNode[] => {
145
+ if (!mergeLayersHidesParts) return nodes;
146
+ return nodes.filter((node) => {
147
+ // Only element rows carry an `ifcType` we can compare. Class
148
+ // grouping ("IfcBuildingElementPart (N)") and ifc-type nodes
149
+ // also expose an `ifcType`; we strip those too because they
150
+ // would expand to empty groups after merge.
151
+ const t = node.ifcType?.toLowerCase();
152
+ if (!t) return true;
153
+ return t !== PART_TYPE_KEY;
154
+ });
155
+ },
156
+ [mergeLayersHidesParts],
157
+ );
158
+ const filteredNodes = useMemo(() => stripPartNodes(rawFilteredNodes), [stripPartNodes, rawFilteredNodes]);
159
+ const storeysNodes = useMemo(() => stripPartNodes(rawStoreysNodes), [stripPartNodes, rawStoreysNodes]);
160
+ const modelsNodes = useMemo(() => stripPartNodes(rawModelsNodes), [stripPartNodes, rawModelsNodes]);
161
+
136
162
  // Refs for both scroll areas
137
163
  const storeysRef = useRef<HTMLDivElement>(null);
138
164
  const modelsRef = useRef<HTMLDivElement>(null);
@@ -77,6 +77,11 @@ export function HoverTooltip() {
77
77
  <div className="text-xs text-muted-foreground">
78
78
  #{hoverState.entityId}
79
79
  </div>
80
+ {hoverState.worldXYZ && (
81
+ <div className="text-[10px] font-mono text-muted-foreground/80 mt-0.5">
82
+ {hoverState.worldXYZ.x.toFixed(2)}, {hoverState.worldXYZ.y.toFixed(2)}, {hoverState.worldXYZ.z.toFixed(2)}
83
+ </div>
84
+ )}
80
85
  </div>
81
86
  );
82
87
  }
@@ -0,0 +1,389 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * IDSAuditSummary — surfaces the auditor's verdict on a loaded IDS
7
+ * document.
8
+ *
9
+ * Visual language: refined-technical instrument. Restraint over
10
+ * decoration. The hierarchy is carried by:
11
+ * - **Severity rails** — 2px tinted left border on each issue row.
12
+ * - **Codes as machine output** — monospace uppercase chips with
13
+ * severity-tinted backgrounds; treat them like log lines.
14
+ * - **Counts strip** — compact `▪ 3 errors ▪ 2 warnings ▪ 0 info`
15
+ * bar with colored dots, similar to a developer-tool status line.
16
+ * - **Empty state** — single line with a check icon, no flair.
17
+ *
18
+ * Interactions:
19
+ * - Click counts strip to toggle the issue list.
20
+ * - Click an individual row to expose its `path` and `detail` payload.
21
+ * - Filter tabs (All / Errors / Warnings) when any issues exist.
22
+ */
23
+
24
+ import React, { useMemo, useState } from 'react';
25
+ import {
26
+ AlertCircle,
27
+ AlertTriangle,
28
+ CheckCircle2,
29
+ ChevronDown,
30
+ ChevronRight,
31
+ Info,
32
+ Loader2,
33
+ } from 'lucide-react';
34
+ import type { IDSAuditIssue, IDSAuditReport, IDSAuditSeverity } from '@ifc-lite/ids';
35
+ import { cn } from '@/lib/utils';
36
+
37
+ interface IDSAuditSummaryProps {
38
+ report: IDSAuditReport | null;
39
+ /** True while the auditor is running. */
40
+ auditing?: boolean;
41
+ /** Optional className passed to the outer container. */
42
+ className?: string;
43
+ }
44
+
45
+ type SeverityFilter = 'all' | IDSAuditSeverity;
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Severity tokens
49
+ // ---------------------------------------------------------------------------
50
+
51
+ const SEVERITY_ORDER: Record<IDSAuditSeverity, number> = {
52
+ error: 0,
53
+ warning: 1,
54
+ info: 2,
55
+ };
56
+
57
+ const SEVERITY_TOKENS: Record<
58
+ IDSAuditSeverity,
59
+ {
60
+ label: string;
61
+ pluralLabel: string;
62
+ dot: string;
63
+ rail: string;
64
+ chipBg: string;
65
+ chipFg: string;
66
+ chipBorder: string;
67
+ icon: React.ReactNode;
68
+ iconClass: string;
69
+ }
70
+ > = {
71
+ error: {
72
+ label: 'error',
73
+ pluralLabel: 'errors',
74
+ dot: 'bg-red-500',
75
+ rail: 'border-l-red-500',
76
+ chipBg: 'bg-red-500/10',
77
+ chipFg: 'text-red-600 dark:text-red-400',
78
+ chipBorder: 'border-red-500/30',
79
+ icon: <AlertCircle className="h-4 w-4" aria-hidden="true" />,
80
+ iconClass: 'text-red-500',
81
+ },
82
+ warning: {
83
+ label: 'warning',
84
+ pluralLabel: 'warnings',
85
+ dot: 'bg-amber-500',
86
+ rail: 'border-l-amber-500',
87
+ chipBg: 'bg-amber-500/10',
88
+ chipFg: 'text-amber-700 dark:text-amber-400',
89
+ chipBorder: 'border-amber-500/30',
90
+ icon: <AlertTriangle className="h-4 w-4" aria-hidden="true" />,
91
+ iconClass: 'text-amber-500',
92
+ },
93
+ info: {
94
+ label: 'note',
95
+ pluralLabel: 'notes',
96
+ dot: 'bg-sky-400',
97
+ rail: 'border-l-sky-400',
98
+ chipBg: 'bg-sky-400/10',
99
+ chipFg: 'text-sky-600 dark:text-sky-400',
100
+ chipBorder: 'border-sky-400/30',
101
+ icon: <Info className="h-4 w-4" aria-hidden="true" />,
102
+ iconClass: 'text-sky-500',
103
+ },
104
+ };
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // Component
108
+ // ---------------------------------------------------------------------------
109
+
110
+ export function IDSAuditSummary({
111
+ report,
112
+ auditing = false,
113
+ className,
114
+ }: IDSAuditSummaryProps): JSX.Element | null {
115
+ const [expanded, setExpanded] = useState(false);
116
+ const [filter, setFilter] = useState<SeverityFilter>('all');
117
+
118
+ // Stable per-severity counts.
119
+ const counts = useMemo(() => {
120
+ const base: Record<IDSAuditSeverity, number> = {
121
+ error: 0,
122
+ warning: 0,
123
+ info: 0,
124
+ };
125
+ if (!report) return base;
126
+ for (const issue of report.issues) {
127
+ base[issue.severity] += 1;
128
+ }
129
+ return base;
130
+ }, [report]);
131
+
132
+ // Sort issues by severity (errors first), preserving document order
133
+ // within each bucket. Rendering errors-first gives the user the most
134
+ // important information at the top of the expanded list.
135
+ const sortedIssues = useMemo(() => {
136
+ if (!report) return [];
137
+ return [...report.issues].sort(
138
+ (a, b) =>
139
+ SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]
140
+ );
141
+ }, [report]);
142
+
143
+ const visibleIssues = useMemo(() => {
144
+ if (filter === 'all') return sortedIssues;
145
+ return sortedIssues.filter((i) => i.severity === filter);
146
+ }, [sortedIssues, filter]);
147
+
148
+ // Auditing in flight — quietly mark the spot.
149
+ if (auditing && !report) {
150
+ return (
151
+ <div
152
+ className={cn(
153
+ 'flex items-center gap-2 rounded-md border border-border/60 bg-muted/30 px-3 py-2 text-xs text-muted-foreground',
154
+ 'animate-fade-in-up',
155
+ className
156
+ )}
157
+ role="status"
158
+ aria-live="polite"
159
+ >
160
+ <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
161
+ <span>Auditing IDS document…</span>
162
+ </div>
163
+ );
164
+ }
165
+
166
+ if (!report) return null;
167
+
168
+ const totalIssues = report.issues.length;
169
+ const isClean = report.status === 'valid' || totalIssues === 0;
170
+
171
+ // Clean state — single line, no flair.
172
+ if (isClean) {
173
+ return (
174
+ <div
175
+ className={cn(
176
+ 'flex items-center gap-2 rounded-md border border-emerald-500/30 bg-emerald-500/5 px-3 py-2 text-xs text-emerald-700 dark:text-emerald-400',
177
+ 'animate-fade-in-up',
178
+ className
179
+ )}
180
+ >
181
+ <CheckCircle2 className="h-4 w-4" aria-hidden="true" />
182
+ <span>Document is valid — no audit issues</span>
183
+ </div>
184
+ );
185
+ }
186
+
187
+ // Has issues — counts strip + collapsible list.
188
+ return (
189
+ <section
190
+ className={cn(
191
+ 'overflow-hidden rounded-md border border-border/70 bg-card animate-fade-in-up',
192
+ className
193
+ )}
194
+ aria-label="IDS document audit summary"
195
+ >
196
+ <button
197
+ type="button"
198
+ onClick={() => setExpanded((v) => !v)}
199
+ className={cn(
200
+ 'flex w-full items-center justify-between gap-3 px-3 py-2 text-left transition-colors',
201
+ 'hover:bg-muted/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/60'
202
+ )}
203
+ aria-expanded={expanded}
204
+ >
205
+ <span className="flex items-center gap-3 text-xs">
206
+ {(['error', 'warning', 'info'] as IDSAuditSeverity[]).map((sev) => {
207
+ const n = counts[sev];
208
+ if (n === 0) return null;
209
+ const t = SEVERITY_TOKENS[sev];
210
+ return (
211
+ <span key={sev} className="inline-flex items-center gap-1.5">
212
+ <span
213
+ className={cn('h-1.5 w-1.5 rounded-full', t.dot)}
214
+ aria-hidden="true"
215
+ />
216
+ <span className={cn('font-mono tabular-nums', t.chipFg)}>
217
+ {n}
218
+ </span>
219
+ <span className="text-muted-foreground">
220
+ {n === 1 ? t.label : t.pluralLabel}
221
+ </span>
222
+ </span>
223
+ );
224
+ })}
225
+ </span>
226
+ <span className="flex items-center gap-1 text-xs text-muted-foreground">
227
+ <span>{expanded ? 'Hide' : 'Details'}</span>
228
+ {expanded ? (
229
+ <ChevronDown className="h-3.5 w-3.5" aria-hidden="true" />
230
+ ) : (
231
+ <ChevronRight className="h-3.5 w-3.5" aria-hidden="true" />
232
+ )}
233
+ </span>
234
+ </button>
235
+
236
+ {expanded && (
237
+ <div className="border-t border-border/60">
238
+ {/* Filter tabs */}
239
+ <div className="flex items-center gap-1 border-b border-border/60 bg-muted/20 px-2 py-1.5">
240
+ {(
241
+ [
242
+ { key: 'all', label: `All (${totalIssues})` },
243
+ counts.error > 0 && {
244
+ key: 'error',
245
+ label: `Errors (${counts.error})`,
246
+ },
247
+ counts.warning > 0 && {
248
+ key: 'warning',
249
+ label: `Warnings (${counts.warning})`,
250
+ },
251
+ counts.info > 0 && {
252
+ key: 'info',
253
+ label: `Notes (${counts.info})`,
254
+ },
255
+ ].filter(Boolean) as Array<{
256
+ key: SeverityFilter;
257
+ label: string;
258
+ }>
259
+ ).map((tab) => (
260
+ <button
261
+ key={tab.key}
262
+ type="button"
263
+ onClick={() => setFilter(tab.key)}
264
+ className={cn(
265
+ 'rounded px-2 py-0.5 text-[11px] transition-colors',
266
+ filter === tab.key
267
+ ? 'bg-foreground text-background'
268
+ : 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
269
+ )}
270
+ >
271
+ {tab.label}
272
+ </button>
273
+ ))}
274
+ </div>
275
+
276
+ {/* Issue list */}
277
+ <ul className="max-h-72 overflow-y-auto py-1">
278
+ {visibleIssues.map((issue, i) => (
279
+ <IssueRow key={`${issue.code}-${issue.path}-${i}`} issue={issue} index={i} />
280
+ ))}
281
+ {visibleIssues.length === 0 && (
282
+ <li className="px-3 py-3 text-xs text-muted-foreground">
283
+ No issues match the selected filter.
284
+ </li>
285
+ )}
286
+ </ul>
287
+ </div>
288
+ )}
289
+ </section>
290
+ );
291
+ }
292
+
293
+ // ---------------------------------------------------------------------------
294
+ // Issue row
295
+ // ---------------------------------------------------------------------------
296
+
297
+ interface IssueRowProps {
298
+ issue: IDSAuditIssue;
299
+ index: number;
300
+ }
301
+
302
+ function IssueRow({ issue, index }: IssueRowProps): JSX.Element {
303
+ const [open, setOpen] = useState(false);
304
+ const t = SEVERITY_TOKENS[issue.severity];
305
+ const hasDetail =
306
+ !!issue.path ||
307
+ (issue.detail !== undefined && Object.keys(issue.detail).length > 0);
308
+
309
+ return (
310
+ <li
311
+ className={cn(
312
+ 'group border-l-2 px-3 py-1.5 text-xs transition-colors hover:bg-muted/30',
313
+ t.rail,
314
+ // Stagger reveal — capped so long lists don't take seconds.
315
+ 'animate-fade-in-up'
316
+ )}
317
+ style={{ animationDelay: `${Math.min(index, 12) * 24}ms` }}
318
+ >
319
+ <button
320
+ type="button"
321
+ onClick={() => hasDetail && setOpen((v) => !v)}
322
+ className={cn(
323
+ 'flex w-full items-start gap-2 text-left',
324
+ hasDetail && 'cursor-pointer',
325
+ !hasDetail && 'cursor-default'
326
+ )}
327
+ aria-expanded={hasDetail ? open : undefined}
328
+ >
329
+ <span className={cn('mt-0.5 shrink-0', t.iconClass)}>{t.icon}</span>
330
+ <span className="min-w-0 flex-1 space-y-1">
331
+ <span className="flex flex-wrap items-baseline gap-2">
332
+ <code
333
+ className={cn(
334
+ 'shrink-0 rounded border px-1.5 py-0 font-mono text-[10px] uppercase tracking-tight leading-relaxed',
335
+ t.chipBg,
336
+ t.chipFg,
337
+ t.chipBorder
338
+ )}
339
+ >
340
+ {issue.code}
341
+ </code>
342
+ <span className="text-foreground">{issue.message}</span>
343
+ </span>
344
+ {hasDetail && open && (
345
+ <div className="ml-1 mt-1.5 space-y-1 border-l border-border/60 pl-2">
346
+ {issue.path && (
347
+ <div className="flex gap-2 font-mono text-[11px]">
348
+ <span className="text-muted-foreground/70">path</span>
349
+ <span className="break-all text-muted-foreground">
350
+ {issue.path}
351
+ </span>
352
+ </div>
353
+ )}
354
+ {issue.facetType && (
355
+ <div className="flex gap-2 font-mono text-[11px]">
356
+ <span className="text-muted-foreground/70">facet</span>
357
+ <span className="text-muted-foreground">
358
+ {issue.facetType}
359
+ </span>
360
+ </div>
361
+ )}
362
+ {issue.detail && Object.keys(issue.detail).length > 0 && (
363
+ <div className="flex flex-col gap-0.5 font-mono text-[11px]">
364
+ {Object.entries(issue.detail).map(([k, v]) => (
365
+ <div key={k} className="flex gap-2">
366
+ <span className="text-muted-foreground/70">{k}</span>
367
+ <span className="break-all text-muted-foreground">
368
+ {String(v)}
369
+ </span>
370
+ </div>
371
+ ))}
372
+ </div>
373
+ )}
374
+ </div>
375
+ )}
376
+ </span>
377
+ {hasDetail && (
378
+ <ChevronDown
379
+ className={cn(
380
+ 'mt-1 h-3 w-3 shrink-0 text-muted-foreground/60 transition-transform',
381
+ open && 'rotate-180'
382
+ )}
383
+ aria-hidden="true"
384
+ />
385
+ )}
386
+ </button>
387
+ </li>
388
+ );
389
+ }