@almadar/ui 5.127.0 → 5.129.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.
@@ -21548,7 +21548,7 @@ var init_DashboardLayout = __esm({
21548
21548
  ]
21549
21549
  }
21550
21550
  ),
21551
- user && /* @__PURE__ */ jsxs(Box, { className: "relative", children: [
21551
+ user?.name && /* @__PURE__ */ jsxs(Box, { className: "relative", children: [
21552
21552
  /* @__PURE__ */ jsxs(
21553
21553
  Button,
21554
21554
  {
@@ -26804,12 +26804,12 @@ var init_MapView = __esm({
26804
26804
  shadowSize: [41, 41]
26805
26805
  });
26806
26806
  L.Marker.prototype.options.icon = defaultIcon;
26807
- const { useEffect: useEffect59, useRef: useRef59, useCallback: useCallback87, useState: useState89 } = React82__default;
26807
+ const { useEffect: useEffect59, useRef: useRef60, useCallback: useCallback87, useState: useState89 } = React82__default;
26808
26808
  const { Typography: Typography2 } = await Promise.resolve().then(() => (init_Typography(), Typography_exports));
26809
26809
  const { useEventBus: useEventBus2 } = await Promise.resolve().then(() => (init_useEventBus(), useEventBus_exports));
26810
26810
  function MapUpdater({ centerLat, centerLng, zoom }) {
26811
26811
  const map = useMap();
26812
- const prevRef = useRef59({ centerLat, centerLng, zoom });
26812
+ const prevRef = useRef60({ centerLat, centerLng, zoom });
26813
26813
  useEffect59(() => {
26814
26814
  const prev = prevRef.current;
26815
26815
  if (prev.centerLat !== centerLat || prev.centerLng !== centerLng || prev.zoom !== zoom) {
@@ -35861,6 +35861,290 @@ var init_GraphCanvas = __esm({
35861
35861
  GraphCanvas.displayName = "GraphCanvas";
35862
35862
  }
35863
35863
  });
35864
+ var ImportSourcePicker;
35865
+ var init_ImportSourcePicker = __esm({
35866
+ "components/core/molecules/import/ImportSourcePicker.tsx"() {
35867
+ "use client";
35868
+ init_Box();
35869
+ init_Icon();
35870
+ init_Typography();
35871
+ init_cn();
35872
+ ImportSourcePicker = ({
35873
+ sources,
35874
+ onSelect,
35875
+ onFilesSelected,
35876
+ title,
35877
+ moreSources,
35878
+ className
35879
+ }) => {
35880
+ const fileInputRef = useRef(null);
35881
+ const handlePick = (source) => {
35882
+ if (source.disabled) return;
35883
+ if (source.kind === "file") {
35884
+ const input = fileInputRef.current;
35885
+ if (!input) return;
35886
+ input.accept = source.accept ?? "";
35887
+ input.multiple = source.multiple ?? false;
35888
+ input.click();
35889
+ return;
35890
+ }
35891
+ onSelect?.(source.id);
35892
+ };
35893
+ const handleFiles = (event) => {
35894
+ const files = event.target.files ? Array.from(event.target.files) : [];
35895
+ event.target.value = "";
35896
+ if (files.length > 0) onFilesSelected?.(files);
35897
+ };
35898
+ return /* @__PURE__ */ jsxs(Box, { className: cn("flex flex-col gap-2", className), children: [
35899
+ title ? /* @__PURE__ */ jsx(Typography, { variant: "h4", children: title }) : null,
35900
+ sources.map((source) => /* @__PURE__ */ jsxs(
35901
+ Box,
35902
+ {
35903
+ role: "button",
35904
+ tabIndex: source.disabled ? -1 : 0,
35905
+ "aria-disabled": source.disabled,
35906
+ onClick: () => handlePick(source),
35907
+ onKeyDown: (event) => {
35908
+ if (event.key === "Enter" || event.key === " ") {
35909
+ event.preventDefault();
35910
+ handlePick(source);
35911
+ }
35912
+ },
35913
+ className: cn(
35914
+ "flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3 text-left",
35915
+ source.disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer hover:bg-accent hover:text-accent-foreground"
35916
+ ),
35917
+ children: [
35918
+ source.icon ? /* @__PURE__ */ jsx(Icon, { icon: source.icon, size: "md" }) : null,
35919
+ /* @__PURE__ */ jsxs(Box, { className: "flex flex-col", children: [
35920
+ /* @__PURE__ */ jsx(Typography, { variant: "label", children: source.label }),
35921
+ source.description ? /* @__PURE__ */ jsx(Typography, { variant: "caption", className: "text-muted-foreground", children: source.description }) : null
35922
+ ] })
35923
+ ]
35924
+ },
35925
+ source.id
35926
+ )),
35927
+ moreSources,
35928
+ /* @__PURE__ */ jsx(
35929
+ "input",
35930
+ {
35931
+ ref: fileInputRef,
35932
+ type: "file",
35933
+ className: "hidden",
35934
+ onChange: handleFiles,
35935
+ "data-testid": "import-source-file-input"
35936
+ }
35937
+ )
35938
+ ] });
35939
+ };
35940
+ }
35941
+ });
35942
+ function formatFieldValue(value) {
35943
+ if (value === null) return "\u2014";
35944
+ if (value instanceof Date) return value.toISOString();
35945
+ if (Array.isArray(value)) return value.map(formatFieldValue).join(", ");
35946
+ if (typeof value === "object") return JSON.stringify(value);
35947
+ return String(value);
35948
+ }
35949
+ function unitTitle(unit) {
35950
+ for (const key of TITLE_KEYS) {
35951
+ const value = unit.fields[key];
35952
+ if (typeof value === "string" && value.length > 0) return value;
35953
+ }
35954
+ return unit.ref;
35955
+ }
35956
+ function fieldSummary(unit) {
35957
+ return Object.entries(unit.fields).filter(([key]) => !TITLE_KEYS.includes(key)).map(([key, value]) => `${key}: ${formatFieldValue(value)}`).join(" \xB7 ");
35958
+ }
35959
+ var TITLE_KEYS, ImportPreviewTree;
35960
+ var init_ImportPreviewTree = __esm({
35961
+ "components/core/molecules/import/ImportPreviewTree.tsx"() {
35962
+ "use client";
35963
+ init_Box();
35964
+ init_Badge();
35965
+ init_Button();
35966
+ init_Icon();
35967
+ init_Typography();
35968
+ init_cn();
35969
+ TITLE_KEYS = ["title", "name", "label"];
35970
+ ImportPreviewTree = ({
35971
+ units,
35972
+ skipped = [],
35973
+ entityDisplay,
35974
+ onConfirm,
35975
+ onCancel,
35976
+ confirmLabel = "Confirm import",
35977
+ cancelLabel = "Cancel",
35978
+ indent = 16,
35979
+ className
35980
+ }) => {
35981
+ const groups = /* @__PURE__ */ new Map();
35982
+ for (const unit of units) {
35983
+ const group = groups.get(unit.targetEntity);
35984
+ if (group) group.push(unit);
35985
+ else groups.set(unit.targetEntity, [unit]);
35986
+ }
35987
+ const renderUnit = (unit, childrenByParent, depth) => {
35988
+ const summary = fieldSummary(unit);
35989
+ const children = childrenByParent.get(unit.ref) ?? [];
35990
+ return /* @__PURE__ */ jsxs(React82__default.Fragment, { children: [
35991
+ /* @__PURE__ */ jsxs(
35992
+ Box,
35993
+ {
35994
+ className: "flex flex-col py-1",
35995
+ style: { paddingLeft: depth * indent },
35996
+ "data-testid": `import-preview-unit-${unit.ref}`,
35997
+ children: [
35998
+ /* @__PURE__ */ jsxs(Box, { className: "flex items-center gap-2", children: [
35999
+ /* @__PURE__ */ jsx(Icon, { icon: "file-text", size: "xs", className: "text-muted-foreground" }),
36000
+ /* @__PURE__ */ jsx(Typography, { variant: "body2", children: unitTitle(unit) })
36001
+ ] }),
36002
+ summary ? /* @__PURE__ */ jsx(Typography, { variant: "caption", className: "text-muted-foreground", children: summary }) : null
36003
+ ]
36004
+ }
36005
+ ),
36006
+ children.map((child) => renderUnit(child, childrenByParent, depth + 1))
36007
+ ] }, unit.ref);
36008
+ };
36009
+ return /* @__PURE__ */ jsxs(Box, { className: cn("flex flex-col gap-4", className), children: [
36010
+ units.length === 0 ? /* @__PURE__ */ jsx(Typography, { variant: "body2", className: "text-muted-foreground", children: "No units staged." }) : null,
36011
+ Array.from(groups.entries()).map(([entity, groupUnits]) => {
36012
+ const refs = new Set(groupUnits.map((unit) => unit.ref));
36013
+ const childrenByParent = /* @__PURE__ */ new Map();
36014
+ const roots = [];
36015
+ for (const unit of groupUnits) {
36016
+ if (unit.parentRef && refs.has(unit.parentRef)) {
36017
+ const siblings = childrenByParent.get(unit.parentRef);
36018
+ if (siblings) siblings.push(unit);
36019
+ else childrenByParent.set(unit.parentRef, [unit]);
36020
+ } else {
36021
+ roots.push(unit);
36022
+ }
36023
+ }
36024
+ const label = entityDisplay[entity]?.plural ?? entity;
36025
+ return /* @__PURE__ */ jsxs(Box, { border: true, className: "rounded-md border-border bg-card p-3", children: [
36026
+ /* @__PURE__ */ jsxs(Box, { className: "flex items-center gap-2 pb-2", children: [
36027
+ /* @__PURE__ */ jsx(Typography, { variant: "label", children: label }),
36028
+ /* @__PURE__ */ jsx(Badge, { amount: groupUnits.length })
36029
+ ] }),
36030
+ roots.map((unit) => renderUnit(unit, childrenByParent, 0))
36031
+ ] }, entity);
36032
+ }),
36033
+ skipped.length > 0 ? /* @__PURE__ */ jsxs(Box, { border: true, className: "rounded-md border-border bg-card p-3", children: [
36034
+ /* @__PURE__ */ jsxs(Box, { className: "flex items-center gap-2 pb-2", children: [
36035
+ /* @__PURE__ */ jsx(Typography, { variant: "label", children: "Skipped" }),
36036
+ /* @__PURE__ */ jsx(Badge, { amount: skipped.length })
36037
+ ] }),
36038
+ skipped.map((element) => /* @__PURE__ */ jsxs(Box, { className: "flex items-baseline gap-2 py-1", children: [
36039
+ /* @__PURE__ */ jsx(Typography, { variant: "body2", children: element.ref }),
36040
+ /* @__PURE__ */ jsx(Typography, { variant: "caption", className: "text-muted-foreground", children: element.reason })
36041
+ ] }, element.ref))
36042
+ ] }) : null,
36043
+ onConfirm || onCancel ? /* @__PURE__ */ jsxs(Box, { className: "flex items-center justify-end gap-2", children: [
36044
+ onCancel ? /* @__PURE__ */ jsx(Button, { variant: "default", label: cancelLabel, onClick: onCancel }) : null,
36045
+ onConfirm ? /* @__PURE__ */ jsx(Button, { variant: "primary", label: confirmLabel, onClick: onConfirm }) : null
36046
+ ] }) : null
36047
+ ] });
36048
+ };
36049
+ }
36050
+ });
36051
+ var PIPELINE, DEFAULT_LABELS, ImportProgress;
36052
+ var init_ImportProgress = __esm({
36053
+ "components/core/molecules/import/ImportProgress.tsx"() {
36054
+ "use client";
36055
+ init_Box();
36056
+ init_Badge();
36057
+ init_Icon();
36058
+ init_Typography();
36059
+ init_cn();
36060
+ PIPELINE = [
36061
+ "fetching",
36062
+ "mapping",
36063
+ "reviewing",
36064
+ "committing"
36065
+ ];
36066
+ DEFAULT_LABELS = {
36067
+ fetching: "Fetching",
36068
+ mapping: "Mapping",
36069
+ reviewing: "Reviewing",
36070
+ committing: "Committing",
36071
+ done: "Done",
36072
+ failed: "Failed"
36073
+ };
36074
+ ImportProgress = ({
36075
+ step,
36076
+ counts,
36077
+ labels,
36078
+ className
36079
+ }) => {
36080
+ const label = (key) => labels?.[key] ?? DEFAULT_LABELS[key];
36081
+ const currentIndex = step === "done" || step === "failed" ? PIPELINE.length : PIPELINE.indexOf(step);
36082
+ return /* @__PURE__ */ jsxs(Box, { className: cn("flex flex-col gap-3", className), children: [
36083
+ /* @__PURE__ */ jsxs(Box, { className: "flex items-center gap-2", children: [
36084
+ PIPELINE.map((key, index) => {
36085
+ const isComplete = index < currentIndex;
36086
+ const isActive = index === currentIndex;
36087
+ return /* @__PURE__ */ jsxs(React82__default.Fragment, { children: [
36088
+ index > 0 ? /* @__PURE__ */ jsx(Box, { className: "h-px w-4 bg-border" }) : null,
36089
+ /* @__PURE__ */ jsxs(Box, { className: "flex items-center gap-1", "data-testid": `import-progress-step-${key}`, children: [
36090
+ /* @__PURE__ */ jsx(
36091
+ Icon,
36092
+ {
36093
+ icon: isComplete ? "check" : isActive ? "loader" : "circle",
36094
+ size: "sm",
36095
+ className: cn(
36096
+ isComplete ? "text-success" : isActive ? "text-primary" : "text-muted-foreground"
36097
+ )
36098
+ }
36099
+ ),
36100
+ /* @__PURE__ */ jsx(
36101
+ Typography,
36102
+ {
36103
+ variant: "caption",
36104
+ className: cn(isActive ? "text-foreground" : "text-muted-foreground"),
36105
+ children: label(key)
36106
+ }
36107
+ )
36108
+ ] })
36109
+ ] }, key);
36110
+ }),
36111
+ step === "done" || step === "failed" ? /* @__PURE__ */ jsxs(Fragment, { children: [
36112
+ /* @__PURE__ */ jsx(Box, { className: "h-px w-4 bg-border" }),
36113
+ /* @__PURE__ */ jsxs(Box, { className: "flex items-center gap-1", "data-testid": `import-progress-step-${step}`, children: [
36114
+ /* @__PURE__ */ jsx(
36115
+ Icon,
36116
+ {
36117
+ icon: step === "done" ? "check" : "x",
36118
+ size: "sm",
36119
+ className: step === "done" ? "text-success" : "text-error"
36120
+ }
36121
+ ),
36122
+ /* @__PURE__ */ jsx(
36123
+ Typography,
36124
+ {
36125
+ variant: "caption",
36126
+ className: step === "done" ? "text-success" : "text-error",
36127
+ children: label(step)
36128
+ }
36129
+ )
36130
+ ] })
36131
+ ] }) : null
36132
+ ] }),
36133
+ counts ? /* @__PURE__ */ jsxs(Box, { className: "flex items-center gap-2", children: [
36134
+ counts.staged !== void 0 ? /* @__PURE__ */ jsx(Badge, { label: `Staged ${counts.staged}` }) : null,
36135
+ counts.committed !== void 0 ? /* @__PURE__ */ jsx(Badge, { variant: "success", label: `Committed ${counts.committed}` }) : null,
36136
+ counts.failed !== void 0 ? /* @__PURE__ */ jsx(Badge, { variant: "danger", label: `Failed ${counts.failed}` }) : null
36137
+ ] }) : null
36138
+ ] });
36139
+ };
36140
+ }
36141
+ });
36142
+
36143
+ // components/core/molecules/import/index.ts
36144
+ var init_import = __esm({
36145
+ "components/core/molecules/import/index.ts"() {
36146
+ }
36147
+ });
35864
36148
  var ReflectionBlock;
35865
36149
  var init_ReflectionBlock = __esm({
35866
36150
  "components/core/molecules/ReflectionBlock.tsx"() {
@@ -35935,6 +36219,7 @@ var init_molecules2 = __esm({
35935
36219
  init_EmptyState();
35936
36220
  init_Pagination();
35937
36221
  init_molecules();
36222
+ init_import();
35938
36223
  }
35939
36224
  });
35940
36225
 
@@ -36415,7 +36700,7 @@ function getBadgeVariant(fieldName, value) {
36415
36700
  function formatFieldLabel(fieldName) {
36416
36701
  return fieldName.replace(/([A-Z])/g, " $1").replace(/^./, (str) => str.toUpperCase());
36417
36702
  }
36418
- function formatFieldValue(value, fieldName) {
36703
+ function formatFieldValue2(value, fieldName) {
36419
36704
  if (typeof value === "number") {
36420
36705
  if (fieldName.toLowerCase().includes("progress") || fieldName.toLowerCase().includes("percent")) {
36421
36706
  return `${value}%`;
@@ -36510,7 +36795,7 @@ function renderRichFieldValue(value, fieldName, fieldType) {
36510
36795
  return str;
36511
36796
  }
36512
36797
  default:
36513
- return formatFieldValue(value, fieldName);
36798
+ return formatFieldValue2(value, fieldName);
36514
36799
  }
36515
36800
  }
36516
36801
  function normalizeFieldDefs(fields) {
@@ -36621,7 +36906,7 @@ var init_DetailPanel = __esm({
36621
36906
  const value = getNestedValue(normalizedData, field);
36622
36907
  return {
36623
36908
  label: labelFor(field),
36624
- value: formatFieldValue(value, field),
36909
+ value: formatFieldValue2(value, field),
36625
36910
  icon: getFieldIcon(field)
36626
36911
  };
36627
36912
  }
@@ -42652,6 +42937,9 @@ var init_component_registry_generated = __esm({
42652
42937
  init_HeroOrganism();
42653
42938
  init_HeroSection();
42654
42939
  init_Icon();
42940
+ init_ImportPreviewTree();
42941
+ init_ImportProgress();
42942
+ init_ImportSourcePicker();
42655
42943
  init_InfiniteScrollSentinel();
42656
42944
  init_Input();
42657
42945
  init_InputGroup();
@@ -42915,6 +43203,9 @@ var init_component_registry_generated = __esm({
42915
43203
  "HeroOrganism": HeroOrganism,
42916
43204
  "HeroSection": HeroSection,
42917
43205
  "Icon": Icon,
43206
+ "ImportPreviewTree": ImportPreviewTree,
43207
+ "ImportProgress": ImportProgress,
43208
+ "ImportSourcePicker": ImportSourcePicker,
42918
43209
  "InfiniteScrollSentinel": InfiniteScrollSentinel,
42919
43210
  "Input": Input,
42920
43211
  "InputGroup": InputGroup,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@almadar/ui",
3
- "version": "5.127.0",
3
+ "version": "5.129.0",
4
4
  "description": "React UI components, hooks, and providers for Almadar",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -110,6 +110,7 @@
110
110
  "index.css",
111
111
  "themes",
112
112
  "locales",
113
+ "scripts",
113
114
  "tailwind-preset.cjs"
114
115
  ],
115
116
  "publishConfig": {
@@ -117,12 +118,12 @@
117
118
  "access": "public"
118
119
  },
119
120
  "dependencies": {
120
- "@almadar/core": "^10.35.0",
121
- "@almadar/evaluator": "^2.36.0",
121
+ "@almadar/core": "^10.38.0",
122
+ "@almadar/evaluator": "^2.37.0",
122
123
  "@almadar/logger": "^1.10.0",
123
- "@almadar/runtime": "^6.38.0",
124
- "@almadar/std": "^16.145.0",
125
- "@almadar/syntax": "^1.12.0",
124
+ "@almadar/runtime": "^6.41.0",
125
+ "@almadar/std": "^16.147.0",
126
+ "@almadar/syntax": "^1.13.0",
126
127
  "@dnd-kit/core": "^6.3.1",
127
128
  "@dnd-kit/sortable": "^10.0.0",
128
129
  "@dnd-kit/utilities": "^3.2.2",
@@ -0,0 +1,237 @@
1
+ #!/usr/bin/env npx tsx
2
+ /**
3
+ * Tailwind Safelist Audit
4
+ *
5
+ * Scans all @almadar/ui source files for Tailwind classes that need
6
+ * to be in the safelist (because consuming apps can't extract them
7
+ * from compiled dist/ files). Compares against the current safelist
8
+ * in tailwind-preset.cjs and reports missing entries.
9
+ *
10
+ * Classes that need safelisting:
11
+ * 1. Arbitrary value classes: bg-[var(--color-X)], min-h-[200px], etc.
12
+ * 2. Opacity modifiers on semantic colors: bg-primary/10, text-foreground/60
13
+ * 3. Responsive prefixes on dynamic values: sm:grid-cols-2
14
+ * 4. State prefixes on CSS vars: hover:bg-[var(--color-X)]
15
+ *
16
+ * Standard utility classes (flex, p-4, rounded-lg) are fine because
17
+ * the shell template's tailwind.config.js includes the dist/ path in
18
+ * content scanning. But CSS-variable-based arbitrary classes use bracket
19
+ * notation that Tailwind can't extract from minified JS.
20
+ *
21
+ * Usage:
22
+ * npx tsx scripts/audit-tailwind-safelist.ts
23
+ * npx tsx scripts/audit-tailwind-safelist.ts --fix (appends missing to preset)
24
+ *
25
+ * @packageDocumentation
26
+ */
27
+
28
+ import { readFileSync, writeFileSync, readdirSync, statSync } from 'fs';
29
+ import { join, relative } from 'path';
30
+
31
+ const ROOT = join(import.meta.dirname, '..');
32
+ const PRESET_PATH = join(ROOT, 'tailwind-preset.cjs');
33
+ const SCAN_DIRS = ['components', 'runtime', 'renderer', 'context', 'providers', 'hooks'];
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Regex patterns for classes that need safelisting
37
+ // ---------------------------------------------------------------------------
38
+
39
+ // Matches arbitrary value classes: bg-[var(...)], min-h-[200px], w-[300px], etc.
40
+ // Requires a hyphen before the bracket to avoid matching JS array access like arr[0]
41
+ const ARBITRARY_RE = /(?:^|\s|["'`])([a-z][a-z0-9]*(?::[a-z][a-z0-9-]*)+-\[[^\]]+\]|[a-z][a-z0-9]*-[a-z0-9-]*-\[[^\]]+\]|[a-z][a-z0-9]*-\[[^\]]+\])/g;
42
+
43
+ // Matches opacity modifiers on semantic colors: bg-primary/10, text-foreground/60
44
+ const OPACITY_RE = /(?:^|\s|["'`])((?:bg|text|border|ring|from|to|via|hover:bg|hover:text|hover:border|dark:bg|dark:hover:bg|group-hover:bg|group-hover:text|peer-focus:ring|focus:ring)-(?:primary|secondary|muted|accent|foreground|card|surface|background|border|error|success|warning|info|ring|input|muted-foreground|card-foreground|primary-foreground|secondary-foreground|error-foreground|success-foreground|warning-foreground|info-foreground)\/\d+)/g;
45
+
46
+ // Matches classes with [var(--...)] or [length:var(--...)]
47
+ const CSS_VAR_RE = /(?:^|\s|["'`])([a-z][a-z0-9-]*(?::[a-z][a-z0-9-]*)*-\[(?:length:|color:)?var\(--[a-z0-9-]+\)[^\]]*\])/g;
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // File scanner
51
+ // ---------------------------------------------------------------------------
52
+
53
+ function getAllFiles(dir: string, ext = '.tsx'): string[] {
54
+ const results: string[] = [];
55
+ try {
56
+ for (const entry of readdirSync(dir)) {
57
+ const full = join(dir, entry);
58
+ const stat = statSync(full);
59
+ if (stat.isDirectory() && entry !== 'node_modules' && entry !== 'dist' && entry !== '.storybook') {
60
+ results.push(...getAllFiles(full, ext));
61
+ } else if (entry.endsWith(ext) || entry.endsWith('.ts')) {
62
+ results.push(full);
63
+ }
64
+ }
65
+ } catch {
66
+ // directory doesn't exist
67
+ }
68
+ return results;
69
+ }
70
+
71
+ function extractSafelistCandidates(content: string): Set<string> {
72
+ const candidates = new Set<string>();
73
+
74
+ // Find arbitrary value classes
75
+ for (const match of content.matchAll(ARBITRARY_RE)) {
76
+ const cls = match[1];
77
+ if (cls && !cls.startsWith('//') && !cls.startsWith('*')) {
78
+ candidates.add(cls);
79
+ }
80
+ }
81
+
82
+ // Find opacity modifier classes
83
+ for (const match of content.matchAll(OPACITY_RE)) {
84
+ if (match[1]) candidates.add(match[1]);
85
+ }
86
+
87
+ // Find CSS variable classes
88
+ for (const match of content.matchAll(CSS_VAR_RE)) {
89
+ if (match[1]) candidates.add(match[1]);
90
+ }
91
+
92
+ return candidates;
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Safelist parser
97
+ // ---------------------------------------------------------------------------
98
+
99
+ function getCurrentSafelist(): Set<string> {
100
+ const content = readFileSync(PRESET_PATH, 'utf-8');
101
+ const safelist = new Set<string>();
102
+
103
+ // Find the safelist array - match from 'safelist: [' to the matching ']'
104
+ const startIdx = content.indexOf('safelist:');
105
+ if (startIdx === -1) return safelist;
106
+
107
+ const bracketStart = content.indexOf('[', startIdx);
108
+ if (bracketStart === -1) return safelist;
109
+
110
+ // Find the matching closing bracket (handle nested brackets)
111
+ let depth = 0;
112
+ let bracketEnd = -1;
113
+ for (let i = bracketStart; i < content.length; i++) {
114
+ if (content[i] === '[') depth++;
115
+ if (content[i] === ']') {
116
+ depth--;
117
+ if (depth === 0) { bracketEnd = i; break; }
118
+ }
119
+ }
120
+ if (bracketEnd === -1) return safelist;
121
+
122
+ const safelistContent = content.slice(bracketStart + 1, bracketEnd);
123
+ const entries = safelistContent.matchAll(/['"]([^'"]+)['"]/g);
124
+ for (const m of entries) {
125
+ if (m[1]) safelist.add(m[1]);
126
+ }
127
+
128
+ return safelist;
129
+ }
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // Main
133
+ // ---------------------------------------------------------------------------
134
+
135
+ const fix = process.argv.includes('--fix');
136
+
137
+ console.log('Tailwind Safelist Audit');
138
+ console.log('='.repeat(60));
139
+ console.log(`Scanning: ${SCAN_DIRS.join(', ')}`);
140
+ console.log(`Preset: ${relative(ROOT, PRESET_PATH)}`);
141
+ console.log();
142
+
143
+ // Scan all source files
144
+ const allCandidates = new Map<string, Set<string>>(); // class -> set of files
145
+ let fileCount = 0;
146
+
147
+ for (const dir of SCAN_DIRS) {
148
+ const files = getAllFiles(join(ROOT, dir));
149
+ for (const file of files) {
150
+ if (file.includes('.stories.') || file.includes('.test.') || file.includes('.spec.')) continue;
151
+ fileCount++;
152
+ const content = readFileSync(file, 'utf-8');
153
+ const candidates = extractSafelistCandidates(content);
154
+ for (const cls of candidates) {
155
+ if (!allCandidates.has(cls)) allCandidates.set(cls, new Set());
156
+ allCandidates.get(cls)!.add(relative(ROOT, file));
157
+ }
158
+ }
159
+ }
160
+
161
+ console.log(`Scanned ${fileCount} files, found ${allCandidates.size} safelist candidates`);
162
+ console.log();
163
+
164
+ // Compare against current safelist
165
+ const currentSafelist = getCurrentSafelist();
166
+ const missing: Array<{ cls: string; files: string[] }> = [];
167
+ const alreadySafe: string[] = [];
168
+
169
+ for (const [cls, files] of allCandidates) {
170
+ if (currentSafelist.has(cls)) {
171
+ alreadySafe.push(cls);
172
+ } else {
173
+ missing.push({ cls, files: Array.from(files) });
174
+ }
175
+ }
176
+
177
+ // Also exclude standard utility classes that Tailwind extracts from dist/ scanning.
178
+ // Only arbitrary values ([...]), opacity modifiers (/N), and CSS var classes need safelisting.
179
+ const needsSafelist = (cls: string) =>
180
+ cls.includes('[') || cls.includes('/') || cls.includes('var(');
181
+
182
+ const filtered = missing.filter(m => needsSafelist(m.cls));
183
+ const skipped = missing.filter(m => !needsSafelist(m.cls));
184
+ missing.length = 0;
185
+ missing.push(...filtered);
186
+
187
+ // Report
188
+ console.log(`Current safelist: ${currentSafelist.size} entries`);
189
+ console.log(`Already safelisted: ${alreadySafe.length}`);
190
+ console.log(`Standard utilities (no safelist needed): ${skipped.length}`);
191
+ console.log(`Missing from safelist: ${missing.length}`);
192
+ console.log();
193
+
194
+ if (missing.length === 0) {
195
+ console.log('All safelist candidates are covered. No changes needed.');
196
+ process.exit(0);
197
+ }
198
+
199
+ // Sort missing by class name
200
+ missing.sort((a, b) => a.cls.localeCompare(b.cls));
201
+
202
+ console.log('Missing classes:');
203
+ for (const { cls, files } of missing) {
204
+ console.log(` ${cls}`);
205
+ for (const f of files.slice(0, 3)) {
206
+ console.log(` <- ${f}`);
207
+ }
208
+ if (files.length > 3) {
209
+ console.log(` ... and ${files.length - 3} more files`);
210
+ }
211
+ }
212
+
213
+ if (fix) {
214
+ console.log();
215
+ console.log('Appending missing classes to safelist...');
216
+
217
+ const presetContent = readFileSync(PRESET_PATH, 'utf-8');
218
+
219
+ // Find the end of the safelist array and insert before the closing ]
220
+ const safelistEndIdx = presetContent.indexOf('],', presetContent.indexOf('safelist:'));
221
+ if (safelistEndIdx === -1) {
222
+ console.error('Could not find safelist closing bracket in preset file');
223
+ process.exit(1);
224
+ }
225
+
226
+ const newEntries = missing.map(m => ` '${m.cls}',`).join('\n');
227
+ const comment = ` // Auto-added by audit-tailwind-safelist.ts (${new Date().toISOString().split('T')[0]})`;
228
+ const updated = presetContent.slice(0, safelistEndIdx) +
229
+ '\n' + comment + '\n' + newEntries + '\n' +
230
+ presetContent.slice(safelistEndIdx);
231
+
232
+ writeFileSync(PRESET_PATH, updated, 'utf-8');
233
+ console.log(`Added ${missing.length} classes to safelist.`);
234
+ } else {
235
+ console.log();
236
+ console.log('Run with --fix to add missing classes to the preset.');
237
+ }