astro-tractstack 2.0.0-rc.63 → 2.0.0-rc.64

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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astro-tractstack",
3
- "version": "2.0.0-rc.63",
3
+ "version": "2.0.0-rc.64",
4
4
  "description": "Astro integration for TractStack - redeeming the web from boring experiences",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -55,7 +55,7 @@
55
55
  "@ark-ui/react": "^5.21.0",
56
56
  "@astrojs/react": "^3.6.3",
57
57
  "@heroicons/react": "^2.1.1",
58
- "@internationalized/date": "3.8.2",
58
+ "@internationalized/date": "^3.9.0",
59
59
  "@mhsdesign/jit-browser-tailwindcss": "^0.4.2",
60
60
  "@nanostores/persistent": "^1.1.0",
61
61
  "@nanostores/react": "^1.0.0",
@@ -1,8 +1,10 @@
1
1
  const VERBOSE = false;
2
2
 
3
- // This function now contains all essential one-time and recurring HTMX setup.
4
3
  function configureHtmx() {
5
- if (!window.htmx) return;
4
+ if (!window.htmx || window.HTMX_LISTENER_ATTACHED) {
5
+ return;
6
+ }
7
+ window.HTMX_LISTENER_ATTACHED = true;
6
8
 
7
9
  if (!window.HTMX_CONFIGURED) {
8
10
  window.htmx.config.selfRequestsOnly = false;
@@ -11,13 +13,12 @@ function configureHtmx() {
11
13
 
12
14
  window.htmx.on(document.body, 'htmx:configRequest', function (evt) {
13
15
  const config = window.TRACTSTACK_CONFIG;
14
- if (!config || !config.sessionId) return; // Check for config and session ID
16
+ if (!config || !config.sessionId) return;
15
17
 
16
18
  if (evt.detail.path && evt.detail.path.startsWith('/api/v1/')) {
17
19
  evt.detail.path = config.backendUrl + evt.detail.path;
18
20
  }
19
21
 
20
- // MODIFIED: Use session ID from the global config object
21
22
  const sessionId = config.sessionId;
22
23
  evt.detail.headers['X-Tenant-ID'] = config.tenantId;
23
24
  evt.detail.headers['X-StoryFragment-ID'] = config.storyfragmentId;
@@ -25,6 +26,27 @@ function configureHtmx() {
25
26
  evt.detail.headers['X-TractStack-Session-ID'] = sessionId;
26
27
  }
27
28
  });
29
+
30
+ window.htmx.on(document.body, 'htmx:beforeRequest', async function (evt) {
31
+ const params = evt.detail.requestConfig.parameters;
32
+ if (params && params.beliefVerb === 'IDENTIFY_AS') {
33
+ evt.preventDefault();
34
+
35
+ const originalPayload = params;
36
+ const unsetPayload = {
37
+ unsetBeliefIds: originalPayload.beliefId,
38
+ paneId: originalPayload.paneId || '',
39
+ };
40
+
41
+ try {
42
+ await sendBeliefUpdate(unsetPayload);
43
+ await sendBeliefUpdate(originalPayload);
44
+ } catch (error) {
45
+ if (VERBOSE)
46
+ console.error('🔴 BELIEF: Two-step identifyAs update failed', error);
47
+ }
48
+ }
49
+ });
28
50
  }
29
51
 
30
52
  const pageBeliefs = {};
@@ -32,7 +54,6 @@ let activeStoryfragmentId = null;
32
54
 
33
55
  function waitForSessionReady() {
34
56
  return new Promise((resolve) => {
35
- // This event is fired by sse.ts after the handshake is complete.
36
57
  if (window.TRACTSTACK_CONFIG?.session?.isReady) {
37
58
  resolve();
38
59
  } else {
@@ -54,7 +75,7 @@ function initializeBeliefs() {
54
75
  '🔧 BELIEF: First-time initialization of belief handlers and HTMX config.'
55
76
  );
56
77
 
57
- configureHtmx(); // Run config on initial load.
78
+ configureHtmx();
58
79
 
59
80
  document.addEventListener('change', function (event) {
60
81
  const target = event.target;
@@ -96,7 +117,6 @@ async function handleBeliefChange(element) {
96
117
 
97
118
  trackBeliefState(beliefId, beliefValue);
98
119
 
99
- // Pass the current page's beliefs to the backend
100
120
  await sendBeliefUpdate({
101
121
  beliefId,
102
122
  beliefType,
@@ -110,7 +130,7 @@ async function sendBeliefUpdate(data) {
110
130
 
111
131
  try {
112
132
  const config = window.TRACTSTACK_CONFIG;
113
- if (!config || !config.sessionId) return; // Check for config and session ID
133
+ if (!config || !config.sessionId) return;
114
134
 
115
135
  if (VERBOSE)
116
136
  console.log('🚨 FRONTEND DEBUG: Sending belief update with headers:', {
@@ -170,7 +190,7 @@ function setActiveStoryFragment() {
170
190
  console.log(
171
191
  `📖 BELIEF: Active story fragment set to ${activeStoryfragmentId}`
172
192
  );
173
- // Ensure a state object exists for the newly active page
193
+
174
194
  if (!pageBeliefs[activeStoryfragmentId]) {
175
195
  pageBeliefs[activeStoryfragmentId] = {};
176
196
  }
@@ -184,7 +204,6 @@ document.addEventListener('astro:page-load', () => {
184
204
  setActiveStoryFragment();
185
205
  });
186
206
 
187
- // Also set the active story on the very first page load.
188
207
  document.addEventListener('DOMContentLoaded', setActiveStoryFragment);
189
208
 
190
209
  if (VERBOSE)
@@ -64,7 +64,6 @@ const MenuComponent = (props: MenuProps) => {
64
64
  const { payload, slug, isContext, brandConfig } = props;
65
65
  const thisPayload = payload.optionsPayload;
66
66
 
67
- // Helper function to process menu links - MODIFIED to build the correct hx-vals payload
68
67
  function processMenuLink(e: MenuLink): ProcessedMenuLinkDatum {
69
68
  const item = { ...e } as ProcessedMenuLinkDatum;
70
69
  const actionLisp = item.actionLisp?.trim();
@@ -83,35 +82,36 @@ const MenuComponent = (props: MenuProps) => {
83
82
  return item;
84
83
  }
85
84
 
86
- if (
87
- actionLisp.startsWith('(declare') ||
88
- actionLisp.startsWith('(identifyAs')
89
- ) {
90
- const tokens = lispLexer(actionLisp);
91
- const commandExpression = (
92
- tokens?.[0] as LispToken[]
93
- )?.[0] as LispToken[];
94
- const command = commandExpression?.[0] as string;
95
- const parameters = commandExpression?.[1] as (string | number)[];
96
- const beliefId = parameters?.[0];
97
- const value = parameters?.[1];
85
+ const [lispTokens] = lispLexer(actionLisp);
86
+
87
+ if (lispTokens && lispTokens.length > 0) {
88
+ // Deconstruct the nested structure: e.g., ['declare', ['HotLead', 'BELIEVES_YES']]
89
+ const tokens = lispTokens[0] as LispToken[];
90
+
91
+ if (
92
+ (tokens[0] === 'declare' || tokens[0] === 'identifyAs') &&
93
+ Array.isArray(tokens[1]) &&
94
+ tokens[1].length >= 2
95
+ ) {
96
+ const command = tokens[0] as string;
97
+ const params = tokens[1] as (string | number)[];
98
+ const beliefId = params[0] as string;
99
+ const value = params[1] as string;
98
100
 
99
- if (command && beliefId !== undefined && value !== undefined) {
100
101
  let hxValsMap: { [key: string]: string } = {};
101
102
 
102
- // CORRECTED: Build the hx-vals payload to match server expectations.
103
103
  if (command === 'declare') {
104
104
  hxValsMap = {
105
- beliefId: String(beliefId),
106
- beliefType: 'Belief', // This was the missing required field.
107
- beliefValue: String(value), // Key changed from beliefVerb to beliefValue.
105
+ beliefId: beliefId,
106
+ beliefType: 'Belief',
107
+ beliefValue: value,
108
108
  };
109
109
  } else if (command === 'identifyAs') {
110
110
  hxValsMap = {
111
- beliefId: String(beliefId),
112
- beliefType: 'Belief', // This was the missing required field.
113
- beliefVerb: 'IDENTIFY_AS', // This is specific to identifyAs.
114
- beliefObject: String(value),
111
+ beliefId: beliefId,
112
+ beliefType: 'Belief',
113
+ beliefVerb: 'IDENTIFY_AS',
114
+ beliefObject: value,
115
115
  };
116
116
  }
117
117
 
@@ -129,12 +129,10 @@ const MenuComponent = (props: MenuProps) => {
129
129
  );
130
130
  }
131
131
 
132
- // Fallback for unknown commands or parsing failures
133
132
  item.renderAs = 'span';
134
133
  return item;
135
134
  }
136
135
 
137
- // Process featured and additional links using the modified helper
138
136
  const featuredLinks = thisPayload
139
137
  .filter((e: MenuLink) => e.featured)
140
138
  .map(processMenuLink);
@@ -142,7 +140,6 @@ const MenuComponent = (props: MenuProps) => {
142
140
  .filter((e: MenuLink) => !e.featured)
143
141
  .map(processMenuLink);
144
142
 
145
- // Helper component to render either a link or a button, avoiding repetition.
146
143
  const InteractiveMenuItem = ({ item }: { item: ProcessedMenuLinkDatum }) => {
147
144
  if (item.renderAs === 'button') {
148
145
  return (
@@ -173,7 +170,6 @@ const MenuComponent = (props: MenuProps) => {
173
170
  );
174
171
  }
175
172
 
176
- // Fallback for 'span'
177
173
  return (
178
174
  <span
179
175
  className="text-mydarkgrey block text-2xl font-bold leading-6 opacity-50"
@@ -60,7 +60,6 @@ const PaneMagicPathPanel = ({ nodeId, setMode }: PaneMagicPathPanelProps) => {
60
60
  if (!idsResponse.ok) throw new Error('Failed to fetch belief IDs');
61
61
 
62
62
  const idsResult = await idsResponse.json();
63
- // CORRECTED: The key from the backend is "beliefIds", not "beliefs"
64
63
  if (!idsResult.beliefIds || idsResult.beliefIds.length === 0) {
65
64
  setAvailableBeliefs([]);
66
65
  setIsLoading(false);
@@ -74,7 +73,6 @@ const PaneMagicPathPanel = ({ nodeId, setMode }: PaneMagicPathPanelProps) => {
74
73
  'Content-Type': 'application/json',
75
74
  'X-Tenant-ID': tenantId,
76
75
  },
77
- // CORRECTED: Pass the array from the correct key "beliefIds"
78
76
  body: JSON.stringify({ beliefIds: idsResult.beliefIds }),
79
77
  });
80
78
 
@@ -50,6 +50,13 @@ interface InteractiveDisclosureWidgetProps {
50
50
 
51
51
  const generateId = (): string => Math.random().toString(36).substring(2, 9);
52
52
 
53
+ const quoteIfNecessary = (command: string, value: string): string => {
54
+ if (command === 'identifyAs' && value.includes(' ')) {
55
+ return `"${value}"`;
56
+ }
57
+ return value;
58
+ };
59
+
53
60
  const IconSelector = ({
54
61
  value,
55
62
  onChange,
@@ -136,7 +143,9 @@ const DisclosureItemEditor = ({
136
143
  }) => {
137
144
  return (
138
145
  <div
139
- className={`space-y-4 rounded-lg border bg-white p-4 shadow-sm transition-opacity ${item.isDisabled ? 'border-gray-100 opacity-40' : 'border-gray-200'}`}
146
+ className={`space-y-4 rounded-lg border bg-white p-4 shadow-sm transition-opacity ${
147
+ item.isDisabled ? 'border-gray-100 opacity-40' : 'border-gray-200'
148
+ }`}
140
149
  >
141
150
  <div className="flex items-center justify-between">
142
151
  <div className="flex items-center gap-2">
@@ -168,7 +177,9 @@ const DisclosureItemEditor = ({
168
177
  <button
169
178
  type="button"
170
179
  onClick={onToggle}
171
- className={`rounded p-1 hover:bg-gray-100 ${item.isDisabled ? 'text-blue-600' : 'text-red-600'}`}
180
+ className={`rounded p-1 hover:bg-gray-100 ${
181
+ item.isDisabled ? 'text-blue-600' : 'text-red-600'
182
+ }`}
172
183
  >
173
184
  {item.isDisabled ? (
174
185
  <ArrowUturnLeftIcon className="h-4 w-4" />
@@ -274,7 +285,6 @@ export default function InteractiveDisclosureWidget({
274
285
 
275
286
  const actionCommand =
276
287
  currentBelief.scale === 'custom' ? 'identifyAs' : 'declare';
277
-
278
288
  const finalDisclosures: DisclosureItem[] = loadedDisclosures.map(
279
289
  (loadedItem) => {
280
290
  const isFromScale = scaleKeys.some(
@@ -285,13 +295,12 @@ export default function InteractiveDisclosureWidget({
285
295
  id: generateId(),
286
296
  isCustom: !isFromScale,
287
297
  actionLisp: isFromScale
288
- ? `(${actionCommand} ${beliefTag} ${loadedItem.beliefValue})`
298
+ ? `(${actionCommand} ${beliefTag} ${quoteIfNecessary(actionCommand, loadedItem.beliefValue)})`
289
299
  : loadedItem.actionLisp,
290
300
  isDisabled: false,
291
301
  };
292
302
  }
293
303
  );
294
-
295
304
  scaleKeys.forEach(({ slug, name }) => {
296
305
  if (!finalDisclosures.some((d) => d.beliefValue === slug)) {
297
306
  finalDisclosures.push({
@@ -300,13 +309,12 @@ export default function InteractiveDisclosureWidget({
300
309
  title: name,
301
310
  description: '',
302
311
  icon: 'app',
303
- actionLisp: `(${actionCommand} ${beliefTag} ${slug})`,
312
+ actionLisp: `(${actionCommand} ${beliefTag} ${quoteIfNecessary(actionCommand, slug)})`,
304
313
  isCustom: false,
305
314
  isDisabled: true,
306
315
  });
307
316
  }
308
317
  });
309
-
310
318
  setDisclosures(finalDisclosures);
311
319
  } catch (e) {
312
320
  console.error('Error parsing disclosure payload:', e);
@@ -345,7 +353,6 @@ export default function InteractiveDisclosureWidget({
345
353
  const disclosuresToStore: StoredDisclosureItem[] = disclosures
346
354
  .filter((d) => !d.isDisabled)
347
355
  .map(({ id, isCustom, isDisabled, ...rest }) => rest);
348
-
349
356
  const payload = { styles: widgetStyles, disclosures: disclosuresToStore };
350
357
  onUpdate([selectedBeliefTag, JSON.stringify(payload)]);
351
358
  };
@@ -369,7 +376,7 @@ export default function InteractiveDisclosureWidget({
369
376
  title: name,
370
377
  description: '',
371
378
  icon: 'app',
372
- actionLisp: `(${actionCommand} ${tag} ${slug})`,
379
+ actionLisp: `(${actionCommand} ${tag} ${quoteIfNecessary(actionCommand, slug)})`,
373
380
  isCustom: false,
374
381
  isDisabled: false,
375
382
  }));
@@ -407,8 +414,10 @@ export default function InteractiveDisclosureWidget({
407
414
  setDisclosures(
408
415
  disclosures.map((d) => (d.id === id ? { ...d, ...updates } : d))
409
416
  );
417
+
410
418
  const updateWidgetStyles = (updates: Partial<WidgetStyles>) =>
411
419
  setWidgetStyles((prev) => ({ ...prev, ...updates }));
420
+
412
421
  const toggleDisclosure = (id: string) =>
413
422
  setDisclosures(
414
423
  disclosures.map((d) =>
@@ -80,10 +80,27 @@ export default function ActionBuilderField({
80
80
 
81
81
  const handleParamChange = (newParams: string) => {
82
82
  setParams(newParams);
83
- if (newParams && newParams.trim() !== '' && newParams.trim() !== '()') {
84
- onChange(`(${command} ${newParams})`);
85
- } else {
83
+ const trimmedParams = newParams.trim();
84
+
85
+ if (!trimmedParams || trimmedParams === '()') {
86
86
  onChange('');
87
+ return;
88
+ }
89
+
90
+ if (command === 'identifyAs') {
91
+ const firstSpaceIndex = trimmedParams.indexOf(' ');
92
+ if (firstSpaceIndex === -1) {
93
+ // Handle case with only beliefId and no value
94
+ onChange(`(${command} ${trimmedParams})`);
95
+ } else {
96
+ const beliefId = trimmedParams.substring(0, firstSpaceIndex);
97
+ const value = trimmedParams.substring(firstSpaceIndex + 1);
98
+ const finalValue = value.includes(' ') ? `"${value}"` : value;
99
+ onChange(`(${command} ${beliefId} ${finalValue})`);
100
+ }
101
+ } else {
102
+ // Original behavior for all other commands
103
+ onChange(`(${command} ${trimmedParams})`);
87
104
  }
88
105
  };
89
106
 
@@ -37,16 +37,14 @@ export default function BeliefForm({
37
37
  onClose,
38
38
  }: BeliefFormProps) {
39
39
  const [customValue, setCustomValue] = useState('');
40
+ const [customValueError, setCustomValueError] = useState<string | null>(null);
40
41
 
41
- // Subscribe to orphan analysis store
42
42
  const orphanState = useStore(orphanAnalysisStore);
43
43
 
44
- // Load orphan analysis on component mount
45
44
  useEffect(() => {
46
45
  loadOrphanAnalysis();
47
46
  }, []);
48
47
 
49
- // Get usage information for this belief
50
48
  const getBeliefUsage = (): string[] => {
51
49
  if (!belief?.id || !orphanState.data || !orphanState.data.beliefs) {
52
50
  return [];
@@ -54,7 +52,6 @@ export default function BeliefForm({
54
52
  return orphanState.data.beliefs[belief.id] || [];
55
53
  };
56
54
 
57
- // Check if belief is in use
58
55
  const isBeliefInUse = (): boolean => {
59
56
  if (isCreate || !belief?.id) return false;
60
57
  return getBeliefUsage().length > 0;
@@ -63,7 +60,6 @@ export default function BeliefForm({
63
60
  const beliefInUse = isBeliefInUse();
64
61
  const usageCount = getBeliefUsage().length;
65
62
 
66
- // Initialize form state
67
63
  const initialState: BeliefNodeState = belief
68
64
  ? convertToLocalState(belief)
69
65
  : {
@@ -85,7 +81,6 @@ export default function BeliefForm({
85
81
  data
86
82
  );
87
83
 
88
- // Call success callback after save (original pattern)
89
84
  setTimeout(() => {
90
85
  onClose?.(true);
91
86
  }, 1000);
@@ -102,23 +97,30 @@ export default function BeliefForm({
102
97
  },
103
98
  });
104
99
 
105
- const handleAddCustomValue = () => {
106
- if (!customValue.trim()) return;
100
+ const handleCustomValueChange = (value: string) => {
101
+ setCustomValue(value);
102
+ const valueRegex = /^[a-zA-Z]([a-zA-Z0-9?!]| (?=[a-zA-Z0-9?!]))*$/;
103
+ if (value && !valueRegex.test(value)) {
104
+ setCustomValueError(
105
+ 'Must start with a letter. No double or trailing spaces.'
106
+ );
107
+ } else {
108
+ setCustomValueError(null);
109
+ }
110
+ };
107
111
 
112
+ const handleAddCustomValue = () => {
113
+ if (!customValue.trim() || customValueError) return;
108
114
  const newState = addCustomValue(formState.state, customValue);
109
115
  formState.updateField('customValues', newState.customValues);
110
116
  setCustomValue('');
111
117
  };
112
118
 
113
119
  const handleRemoveCustomValue = (index: number) => {
114
- // Check if this is a newly added value (not saved yet)
115
120
  const currentValue = formState.state.customValues[index];
116
121
  const originalValues = formState.originalState.customValues || [];
117
122
  const isNewValue = !originalValues.includes(currentValue);
118
123
 
119
- // Allow removal if:
120
- // 1. Belief is not in use, OR
121
- // 2. This is a new value that hasn't been saved yet
122
124
  if (!beliefInUse || isNewValue) {
123
125
  const newState = removeCustomValue(formState.state, index);
124
126
  formState.updateField('customValues', newState.customValues);
@@ -188,7 +190,6 @@ export default function BeliefForm({
188
190
 
189
191
  return (
190
192
  <div className="space-y-8">
191
- {/* Header */}
192
193
  <div className="border-b border-gray-200 pb-4">
193
194
  <h2 className="text-2xl font-bold text-gray-900">
194
195
  {isCreate ? 'Create Belief' : 'Edit Belief'}
@@ -200,10 +201,8 @@ export default function BeliefForm({
200
201
  </p>
201
202
  </div>
202
203
 
203
- {/* Usage Warning */}
204
204
  {renderUsageWarning()}
205
205
 
206
- {/* Info Box */}
207
206
  <div className="rounded-md bg-blue-50 p-4">
208
207
  <div className="text-sm text-blue-700">
209
208
  <p className="font-bold">What are Beliefs?</p>
@@ -225,7 +224,6 @@ export default function BeliefForm({
225
224
  </div>
226
225
  </div>
227
226
 
228
- {/* Basic Fields */}
229
227
  <div className="grid grid-cols-1 gap-6 md:grid-cols-2">
230
228
  <StringInput
231
229
  value={formState.state.title}
@@ -254,7 +252,6 @@ export default function BeliefForm({
254
252
  </div>
255
253
  </div>
256
254
 
257
- {/* Scale Selection */}
258
255
  <div className="space-y-4">
259
256
  <div className="relative">
260
257
  <EnumSelect
@@ -276,7 +273,6 @@ export default function BeliefForm({
276
273
  {renderScalePreview()}
277
274
  </div>
278
275
 
279
- {/* Custom Values Section */}
280
276
  {formState.state.scale === 'custom' && (
281
277
  <div className="space-y-4">
282
278
  <div>
@@ -286,31 +282,41 @@ export default function BeliefForm({
286
282
  </p>
287
283
  </div>
288
284
 
289
- {/* Add Custom Value */}
290
285
  <div className="flex gap-2">
291
286
  <div className="flex-1">
292
- <div className="flex-1">
293
- <input
294
- type="text"
295
- value={customValue}
296
- onChange={(e) => setCustomValue(e.target.value)}
297
- onKeyDown={handleKeyDown}
298
- placeholder="Enter custom value"
299
- className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-cyan-600 sm:text-sm sm:leading-6"
300
- />
301
- </div>
287
+ <input
288
+ type="text"
289
+ value={customValue}
290
+ onChange={(e) => handleCustomValueChange(e.target.value)}
291
+ onKeyDown={handleKeyDown}
292
+ placeholder="Enter custom value"
293
+ className={`block w-full rounded-md border-0 px-3 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset placeholder:text-gray-400 focus:ring-2 focus:ring-inset sm:text-sm sm:leading-6 ${
294
+ customValueError
295
+ ? 'ring-red-500 focus:ring-red-600'
296
+ : 'ring-gray-300 focus:ring-cyan-600'
297
+ }`}
298
+ />
302
299
  </div>
303
300
  <button
304
301
  type="button"
305
302
  onClick={handleAddCustomValue}
306
- disabled={!customValue.trim()}
303
+ disabled={!customValue.trim() || !!customValueError}
307
304
  className="inline-flex items-center rounded-md bg-cyan-600 px-3 py-2 text-sm font-bold text-white shadow-sm hover:bg-cyan-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cyan-600 disabled:cursor-not-allowed disabled:opacity-50"
308
305
  >
309
306
  <PlusIcon className="h-4 w-4" />
310
307
  </button>
311
308
  </div>
312
309
 
313
- {/* Custom Values List */}
310
+ {customValueError && (
311
+ <p className="mt-1 text-sm text-red-600">{customValueError}</p>
312
+ )}
313
+
314
+ {formState.errors.customValues && (
315
+ <p className="text-sm text-red-600">
316
+ {formState.errors.customValues}
317
+ </p>
318
+ )}
319
+
314
320
  {formState.state.customValues.length > 0 && (
315
321
  <div className="space-y-2">
316
322
  {formState.state.customValues.map((value, index) => {
@@ -355,7 +361,6 @@ export default function BeliefForm({
355
361
  </div>
356
362
  )}
357
363
 
358
- {/* Save/Cancel Bar */}
359
364
  <UnsavedChangesBar
360
365
  formState={formState}
361
366
  message="You have unsaved belief changes"
@@ -363,7 +368,6 @@ export default function BeliefForm({
363
368
  cancelLabel="Discard Changes"
364
369
  />
365
370
 
366
- {/* Cancel Navigation Button */}
367
371
  <div className="flex justify-start">
368
372
  <button
369
373
  type="button"
@@ -4,9 +4,6 @@ import type {
4
4
  FieldErrors,
5
5
  } from '@/types/tractstack';
6
6
 
7
- /**
8
- * Convert backend BeliefNode to frontend BeliefNodeState
9
- */
10
7
  export function convertToLocalState(beliefNode: BeliefNode): BeliefNodeState {
11
8
  return {
12
9
  id: beliefNode.id,
@@ -17,9 +14,6 @@ export function convertToLocalState(beliefNode: BeliefNode): BeliefNodeState {
17
14
  };
18
15
  }
19
16
 
20
- /**
21
- * Convert frontend BeliefNodeState to backend BeliefNode format
22
- */
23
17
  export function convertToBackendFormat(state: BeliefNodeState): BeliefNode {
24
18
  return {
25
19
  id: state.id,
@@ -31,47 +25,45 @@ export function convertToBackendFormat(state: BeliefNodeState): BeliefNode {
31
25
  };
32
26
  }
33
27
 
34
- /**
35
- * Validate belief node state
36
- */
37
28
  export function validateBeliefNode(state: BeliefNodeState): FieldErrors {
38
29
  const errors: FieldErrors = {};
39
30
 
40
- // Validate title
41
31
  if (!state.title?.trim()) {
42
32
  errors.title = 'Title is required';
43
33
  }
44
34
 
45
- // Validate slug
46
35
  if (!state.slug?.trim()) {
47
36
  errors.slug = 'Slug is required';
37
+ } else {
38
+ const slugRegex = /^[a-zA-Z]+$/;
39
+ if (!slugRegex.test(state.slug)) {
40
+ errors.slug = 'Slug must contain only letters (a-z, A-Z)';
41
+ }
48
42
  }
49
43
 
50
- // Validate scale
51
44
  if (!state.scale?.trim()) {
52
45
  errors.scale = 'Scale is required';
53
46
  }
54
47
 
55
- // Validate custom values if scale is custom
56
48
  if (state.scale === 'custom') {
57
49
  if (!state.customValues || state.customValues.length === 0) {
58
50
  errors.customValues =
59
51
  'At least one custom value is required for custom scale';
60
52
  } else {
61
- state.customValues.forEach((value, index) => {
62
- if (!value?.trim()) {
63
- errors[`customValues.${index}`] = 'Custom value cannot be empty';
53
+ const valueRegex = /^[a-zA-Z]([a-zA-Z0-9?!]| (?=[a-zA-Z0-9?!]))*$/;
54
+ for (const value of state.customValues) {
55
+ if (value.trim() && !valueRegex.test(value)) {
56
+ errors.customValues =
57
+ 'Values must start with a letter, have no double or trailing spaces, and use valid characters.';
58
+ break;
64
59
  }
65
- });
60
+ }
66
61
  }
67
62
  }
68
63
 
69
64
  return errors;
70
65
  }
71
66
 
72
- /**
73
- * State interceptor for form state management
74
- */
75
67
  export function beliefStateIntercept(
76
68
  state: BeliefNodeState,
77
69
  field: keyof BeliefNodeState,
@@ -88,7 +80,6 @@ export function beliefStateIntercept(
88
80
  break;
89
81
  case 'scale':
90
82
  newState.scale = value || '';
91
- // Clear custom values when scale changes away from custom
92
83
  if (value !== 'custom') {
93
84
  newState.customValues = [];
94
85
  }
@@ -103,9 +94,6 @@ export function beliefStateIntercept(
103
94
  return newState;
104
95
  }
105
96
 
106
- /**
107
- * Add a new custom value to the state
108
- */
109
97
  export function addCustomValue(
110
98
  state: BeliefNodeState,
111
99
  value: string
@@ -118,9 +106,6 @@ export function addCustomValue(
118
106
  };
119
107
  }
120
108
 
121
- /**
122
- * Remove a custom value from the state
123
- */
124
109
  export function removeCustomValue(
125
110
  state: BeliefNodeState,
126
111
  index: number
@@ -131,9 +116,6 @@ export function removeCustomValue(
131
116
  };
132
117
  }
133
118
 
134
- /**
135
- * Update a specific custom value in the state
136
- */
137
119
  export function updateCustomValue(
138
120
  state: BeliefNodeState,
139
121
  index: number,
@@ -148,9 +130,6 @@ export function updateCustomValue(
148
130
  };
149
131
  }
150
132
 
151
- /**
152
- * Scale options for the belief form
153
- */
154
133
  export const SCALE_OPTIONS = [
155
134
  { value: 'likert', label: 'Likert Scale (1-5)' },
156
135
  { value: 'agreement', label: 'Agreement (Agree/Disagree)' },
@@ -160,9 +139,6 @@ export const SCALE_OPTIONS = [
160
139
  { value: 'custom', label: 'Custom Values' },
161
140
  ];
162
141
 
163
- /**
164
- * Get scale preview data for displaying scale options
165
- */
166
142
  export function getScalePreview(scale: string) {
167
143
  const scalePreviewData: {
168
144
  [key: string]: Array<{ id: number; name: string; color: string }>;