@checkstack/ui 1.12.0 → 1.13.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 (43) hide show
  1. package/CHANGELOG.md +145 -0
  2. package/package.json +20 -15
  3. package/src/components/Accordion.tsx +17 -9
  4. package/src/components/ActionCard.tsx +4 -4
  5. package/src/components/BrandIcon.tsx +57 -0
  6. package/src/components/CodeEditor/CodeEditor.tsx +71 -7
  7. package/src/components/CodeEditor/TypefoxEditor.tsx +266 -53
  8. package/src/components/CodeEditor/editorTheme.test.ts +41 -0
  9. package/src/components/CodeEditor/editorTheme.ts +26 -0
  10. package/src/components/CodeEditor/index.ts +3 -1
  11. package/src/components/CodeEditor/monacoGuard.ts +76 -0
  12. package/src/components/CodeEditor/monacoTsService.ts +5 -37
  13. package/src/components/CodeEditor/scriptContext.test.ts +15 -7
  14. package/src/components/CodeEditor/scriptContext.ts +12 -18
  15. package/src/components/CodeEditor/types.ts +20 -0
  16. package/src/components/CodeEditor/validateScripts.ts +53 -13
  17. package/src/components/CodeEditor/vscodeServicesSignal.ts +72 -0
  18. package/src/components/ConfirmationModal.tsx +7 -1
  19. package/src/components/DynamicForm/DynamicForm.tsx +101 -53
  20. package/src/components/DynamicForm/DynamicOptionsField.tsx +19 -14
  21. package/src/components/DynamicForm/FormField.tsx +84 -24
  22. package/src/components/DynamicForm/MultiTypeEditorField.tsx +11 -0
  23. package/src/components/DynamicForm/index.ts +14 -0
  24. package/src/components/DynamicForm/types.ts +63 -1
  25. package/src/components/DynamicForm/utils.test.ts +38 -0
  26. package/src/components/DynamicForm/utils.ts +22 -0
  27. package/src/components/DynamicForm/validation.logic.test.ts +255 -0
  28. package/src/components/DynamicForm/validation.logic.ts +210 -0
  29. package/src/components/DynamicIcon.tsx +39 -17
  30. package/src/components/Markdown.tsx +68 -2
  31. package/src/components/Spinner.tsx +56 -0
  32. package/src/components/StatusBadge.tsx +78 -0
  33. package/src/components/StrategyConfigCard.tsx +3 -3
  34. package/src/components/Tabs.tsx +7 -1
  35. package/src/components/UserMenu.logic.test.ts +37 -0
  36. package/src/components/UserMenu.logic.ts +30 -0
  37. package/src/components/UserMenu.tsx +40 -12
  38. package/src/components/iconRegistry.tsx +27 -0
  39. package/src/index.ts +3 -0
  40. package/stories/Introduction.mdx +1 -1
  41. package/stories/Markdown.stories.tsx +56 -0
  42. package/stories/Spinner.stories.tsx +90 -0
  43. package/tsconfig.json +3 -0
@@ -26,8 +26,14 @@ import {
26
26
  typescriptDefaults,
27
27
  javascriptDefaults,
28
28
  ensureStandaloneWorkerFactory,
29
- markVscodeServicesReady,
30
29
  } from "./monacoTsService";
30
+ import {
31
+ areVscodeServicesReady,
32
+ claimColdInit,
33
+ markVscodeServicesReady,
34
+ onVscodeServicesReady,
35
+ releaseColdInit,
36
+ } from "./vscodeServicesSignal";
31
37
  // Named import also triggers the side-effect registration of the REAL VS Code
32
38
  // JSON language service (proper highlighting + completion + folding), replacing
33
39
  // the hand-rolled `json-template` Monarch grammar. We turn its built-in
@@ -41,8 +47,24 @@ import { jsonDefaults } from "@codingame/monaco-vscode-standalone-json-language-
41
47
  import getLanguagesServiceOverride from "@codingame/monaco-vscode-languages-service-override";
42
48
 
43
49
 
44
- import { type CSSProperties, useEffect, useId, useRef, useState } from "react";
45
- import * as monaco from "@codingame/monaco-vscode-editor-api";
50
+ import {
51
+ type CSSProperties,
52
+ useEffect,
53
+ useId,
54
+ useLayoutEffect,
55
+ useRef,
56
+ useState,
57
+ } from "react";
58
+ // Types come from the package directly (type-only, so it pulls no runtime code
59
+ // and the lint rule allows it). RUNTIME monaco access goes through the guarded
60
+ // accessor (`monacoRuntime`, see monacoGuard.ts): in dev it throws if a
61
+ // `monaco.editor.*` / `monaco.languages.*` function runs before the services
62
+ // are initialized, preventing the "Services are already initialized" regression
63
+ // class. `no-restricted-imports` forbids importing the raw editor-api value.
64
+ import type * as monaco from "@codingame/monaco-vscode-editor-api";
65
+ import { monaco as monacoRuntime } from "./monacoGuard";
66
+ import { useTheme } from "../ThemeProvider";
67
+ import { MONACO_THEME_MAP, VARIABLE_TOKEN_COLOR } from "./editorTheme";
46
68
  import { MonacoEditorReactComp } from "@typefox/monaco-editor-react";
47
69
  import { extractBracketKeyGroups } from "./bracketKeyGroups";
48
70
  import { validateJsonTemplate } from "./validateJsonTemplate";
@@ -129,6 +151,44 @@ const registerAcquiredFiles = (
129
151
  }
130
152
  };
131
153
 
154
+ // ─── @checkstack/sdk editor-type injection ──────────────────────────────────
155
+ //
156
+ // The running release's SDK editor bundle (ambient `.d.ts` for the script
157
+ // helpers + typed client) is mounted ONCE into the shared TS/JS services,
158
+ // keyed by release version. A deployment upgrade changes the key, so the libs
159
+ // reset and the editor never serves stale SDK types (plan §6.2).
160
+ const sdkMountedPaths = new Set<string>();
161
+ let currentSdkResetKey: string | undefined;
162
+
163
+ /**
164
+ * Mount the SDK bundle files, resetting on a release-version change. addExtraLib
165
+ * overwrites by path, so a version bump re-mounts the same virtual paths with
166
+ * fresh content; the mounted-path set just dedupes within a version so two
167
+ * editors don't double-register.
168
+ */
169
+ const mountSdkTypes = ({
170
+ files,
171
+ resetKey,
172
+ }: {
173
+ files: ReadonlyArray<{ path: string; content: string }>;
174
+ resetKey: string | undefined;
175
+ }): void => {
176
+ if (resetKey !== currentSdkResetKey) {
177
+ currentSdkResetKey = resetKey;
178
+ sdkMountedPaths.clear();
179
+ }
180
+ for (const file of files) {
181
+ const uri = `file:///${file.path}`;
182
+ // On a fresh version we re-mount (overwrite) even if the path was seen
183
+ // under a prior key; within a version, skip an already-mounted path.
184
+ if (sdkMountedPaths.has(uri)) continue;
185
+ sdkMountedPaths.add(uri);
186
+ for (const defaults of [typescriptDefaults, javascriptDefaults]) {
187
+ defaults.addExtraLib(file.content, uri);
188
+ }
189
+ }
190
+ };
191
+
132
192
  /**
133
193
  * Acquire types for every NEW bare specifier in `source`, against the given
134
194
  * resolver. Pure planning (`parseBareImportSpecifiers` / `planAcquisitions`)
@@ -212,20 +272,31 @@ const TEMPLATE_VALIDATORS: Partial<
212
272
  // Variable-like tokens (`{{ template }}` expressions, shell `$env` refs) are
213
273
  // highlighted via inline decorations rather than per-language grammars: this
214
274
  // works for any language (yaml / xml / markdown have no template grammar; shell
215
- // doesn't color `$VAR` inside strings) and keeps the color consistent. #9cdcfe
216
- // is the vs-dark `variable` token color, matching json-template's grammar. The
217
- // CSS class is injected once.
275
+ // doesn't color `$VAR` inside strings) and keeps the color consistent.
276
+ // The style element is injected once and updated whenever the resolved theme
277
+ // changes so the decoration tracks the editor theme.
218
278
  const VARIABLE_TOKEN_CLASS = "checkstack-editor-variable";
219
- let variableTokenStyleInjected = false;
220
- const ensureVariableTokenStyle = (): void => {
221
- if (variableTokenStyleInjected) {
279
+ const VARIABLE_TOKEN_STYLE_ID = "checkstack-editor-variable-style";
280
+
281
+ const ensureVariableTokenStyle = ({
282
+ resolvedTheme,
283
+ }: {
284
+ resolvedTheme: "light" | "dark";
285
+ }): void => {
286
+ const color = VARIABLE_TOKEN_COLOR[resolvedTheme];
287
+ const existing = document.querySelector<HTMLStyleElement>(
288
+ `#${VARIABLE_TOKEN_STYLE_ID}`,
289
+ );
290
+ if (existing !== null) {
291
+ // Update in place so a theme toggle refreshes the color.
292
+ existing.textContent = `.${VARIABLE_TOKEN_CLASS}{color:${color} !important;}`;
222
293
  return;
223
294
  }
224
- variableTokenStyleInjected = true;
225
295
  const style = document.createElement("style");
296
+ style.id = VARIABLE_TOKEN_STYLE_ID;
226
297
  // `!important` so the decoration color always wins over the underlying token
227
298
  // color (.mtkN), making `{{ }}` look identical inside and outside strings.
228
- style.textContent = `.${VARIABLE_TOKEN_CLASS}{color:#9cdcfe !important;}`;
299
+ style.textContent = `.${VARIABLE_TOKEN_CLASS}{color:${color} !important;}`;
229
300
  document.head.append(style);
230
301
  };
231
302
 
@@ -241,7 +312,6 @@ const installRegexDecorations = ({
241
312
  pattern: RegExp;
242
313
  className: string;
243
314
  }): monaco.IDisposable => {
244
- ensureVariableTokenStyle();
245
315
  const compute = (): monaco.editor.IModelDeltaDecoration[] => {
246
316
  const text = model.getValue();
247
317
  const decorations: monaco.editor.IModelDeltaDecoration[] = [];
@@ -251,7 +321,7 @@ const installRegexDecorations = ({
251
321
  const start = model.getPositionAt(match.index);
252
322
  const end = model.getPositionAt(match.index + match[0].length);
253
323
  decorations.push({
254
- range: new monaco.Range(
324
+ range: new monacoRuntime.Range(
255
325
  start.lineNumber,
256
326
  start.column,
257
327
  end.lineNumber,
@@ -282,7 +352,7 @@ const installRegexDecorations = ({
282
352
  const findModelById = (
283
353
  modelId: string,
284
354
  ): monaco.editor.ITextModel | undefined =>
285
- monaco.editor
355
+ monacoRuntime.editor
286
356
  .getModels()
287
357
  .find((candidate) => candidate.uri.toString().includes(modelId));
288
358
 
@@ -346,6 +416,20 @@ export type TypefoxEditorProps = {
346
416
  * resets so types refresh against the new install.
347
417
  */
348
418
  acquireResetKey?: string;
419
+ /**
420
+ * The running release's `@checkstack/sdk` editor bundle, as virtual `.d.ts`
421
+ * files to mount (TS/JS editors). Makes `import { defineHealthCheck } from
422
+ * "@checkstack/sdk/healthcheck"` resolve with real, version-matched types.
423
+ * Each file mounts at `file:///<path>` via `addExtraLib`. Fetched live by the
424
+ * consumer (so this component stays network-agnostic + DOM-test-free).
425
+ */
426
+ sdkTypes?: ReadonlyArray<{ path: string; content: string }>;
427
+ /**
428
+ * Release-version reset key for `sdkTypes`. When it changes, the previously
429
+ * mounted SDK libs are reset so the editor never serves stale SDK types after
430
+ * a deployment upgrade.
431
+ */
432
+ sdkTypesResetKey?: string;
349
433
  /**
350
434
  * Importable installed package NAMES (TS/JS editors). When provided, the
351
435
  * editor suggests these as completions while the cursor is inside an import
@@ -355,11 +439,20 @@ export type TypefoxEditorProps = {
355
439
  * Injected by the consumer so this component stays plugin-agnostic.
356
440
  */
357
441
  importablePackages?: string[];
442
+ /**
443
+ * When `true`, this editor never CLAIMS the one-time global cold init - it
444
+ * always waits for another (visible) editor to bring the monaco-vscode
445
+ * services up, then mounts. Set this for OFFSCREEN/hidden editors (the
446
+ * automation `ScriptServicesBooter`): a hidden editor's init may never
447
+ * complete, so it must not be the sole initializer. Defaults to `false`.
448
+ */
449
+ deferInit?: boolean;
358
450
  };
359
451
 
360
452
  /**
361
453
  * Isolated editor used to validate the Typefox/monaco-vscode stack in the
362
- * browser. Dark theme, no minimap, automatic layout, word-based suggestions
454
+ * browser. Theme follows `useTheme().resolvedTheme` (`vs` in light mode,
455
+ * `vs-dark` in dark mode), no minimap, automatic layout, word-based suggestions
363
456
  * disabled. For `typescript`/`javascript` it injects the `context` types +
364
457
  * bracket completions; for markup/text languages it offers `{{ }}` template
365
458
  * completions.
@@ -381,6 +474,41 @@ const languageStatusServiceOverride: monaco.editor.IEditorOverrideServices =
381
474
  : {};
382
475
  })();
383
476
 
477
+ // The monaco-vscode global API config. Hoisted to module scope (it has no
478
+ // per-editor state) so it can drive a single, app-lifetime global init below.
479
+ const vscodeApiConfig: MonacoVscodeApiConfig = {
480
+ // 'classic' is the standalone axis (no extension host); 'extended' is the
481
+ // extension-host axis we deliberately avoid in this migration.
482
+ $type: "classic",
483
+ // Register the missing ILanguageStatusService (see the override above) so
484
+ // focusing a JSON editor doesn't throw "addStatus is not supported".
485
+ serviceOverrides: { ...languageStatusServiceOverride },
486
+ // Plain editor, no workbench views.
487
+ viewsConfig: { $type: "EditorService" },
488
+ monacoWorkerFactory: ensureStandaloneWorkerFactory,
489
+ };
490
+
491
+ // ─── How the global monaco-vscode init is serialized ────────────────────────
492
+ //
493
+ // `@codingame/monaco-vscode-api`'s global `initialize()` is ONE-SHOT and can
494
+ // never be torn down or re-run. `@typefox/monaco-editor-react` performs that
495
+ // init itself when a `MonacoEditorReactComp` is given a `vscodeApiConfig`, and
496
+ // that is StrictMode-safe FOR A SINGLE EDITOR (upstream tests cover exactly
497
+ // that). The breakage is two editors on one page (e.g. an open script-action
498
+ // editor PLUS the hidden `ScriptServicesBooter`): both wrappers race the init
499
+ // guard (only set inside an async `start()`), both call `initialize()`, the
500
+ // second throws "Services are already initialized", and the global state is
501
+ // corrupted so NO editor starts. StrictMode (dev only) makes the race
502
+ // deterministic via mount -> unmount -> remount; production works because
503
+ // StrictMode is a no-op there.
504
+ //
505
+ // Fix (per the maintainers' single-editor-init guidance): exactly ONE editor
506
+ // claims the cold init and mounts WITH `vscodeApiConfig` (the proven path);
507
+ // every other editor waits for `areVscodeServicesReady()` and only then mounts
508
+ // (it still passes `vscodeApiConfig`, but @typefox no-ops since the services
509
+ // are already initialized). The hidden booter sets `deferInit` so it never
510
+ // claims - the claimer must be a real, visible editor whose init can complete.
511
+
384
512
  // Always-available runtime built-in import specifiers (Node + Bun), derived at
385
513
  // build time from the bundled stdlib types. These are importable in the script
386
514
  // sandbox regardless of the installed-package allowlist (the sandbox is a Bun
@@ -389,6 +517,13 @@ const languageStatusServiceOverride: monaco.editor.IEditorOverrideServices =
389
517
  // needs no lazy acquisition. The JSON is a plain `string[]`.
390
518
  const BUILTIN_MODULE_SPECIFIERS: readonly string[] = builtinModulesJson;
391
519
 
520
+ // Passed as the wrapper's `onError` so a wrapper failure is surfaced here
521
+ // instead of becoming an uncaught promise rejection (and so @typefox doesn't
522
+ // reset its internal run-queue lock + re-throw).
523
+ const handleEditorError = (error: Error): void => {
524
+ console.error("[CodeEditor] monaco editor error:", error);
525
+ };
526
+
392
527
  export const TypefoxEditor = ({
393
528
  id,
394
529
  value,
@@ -404,8 +539,16 @@ export const TypefoxEditor = ({
404
539
  placeholder,
405
540
  acquireTypes,
406
541
  acquireResetKey,
542
+ sdkTypes,
543
+ sdkTypesResetKey,
407
544
  importablePackages,
545
+ deferInit = false,
408
546
  }: TypefoxEditorProps) => {
547
+ // Follow the app's resolved theme so the editor uses `vs` (light) or
548
+ // `vs-dark` (dark) and updates live when the user toggles the theme.
549
+ const { resolvedTheme } = useTheme();
550
+ const monacoTheme = MONACO_THEME_MAP[resolvedTheme];
551
+
409
552
  // `MonacoEditorReactComp` captures `onTextChanged` once at editor-start, so
410
553
  // the handler it calls would otherwise close over a stale `onChange` (bound
411
554
  // to the value/sibling-config at mount time). Routing through a ref that we
@@ -430,8 +573,62 @@ export const TypefoxEditor = ({
430
573
  // completion providers below register against a ready languages registry.
431
574
  const [apiReady, setApiReady] = useState(false);
432
575
 
576
+ // Cold-init serialization (see the block comment above). Tracks whether the
577
+ // global services are up yet, and whether THIS editor is the designated
578
+ // initializer (the one that mounts first, with `vscodeApiConfig`).
579
+ const [servicesReady, setServicesReady] = useState(() =>
580
+ areVscodeServicesReady(),
581
+ );
582
+ const [isInitializer, setIsInitializer] = useState(false);
433
583
  useEffect(() => {
434
- if (!isTsLike || typeDefinitions === undefined) {
584
+ if (servicesReady) return;
585
+ return onVscodeServicesReady(() => setServicesReady(true));
586
+ }, [servicesReady]);
587
+ // Decide the initializer role in a layout effect (NOT during render) so it is
588
+ // StrictMode-safe: StrictMode runs setup -> cleanup -> setup, and the cleanup
589
+ // releases the claim, so exactly one editor ends up the stable holder. A
590
+ // `deferInit` editor (the hidden booter) never claims - the initializer must
591
+ // be a real, visible editor whose @typefox init can actually complete.
592
+ useLayoutEffect(() => {
593
+ if (areVscodeServicesReady() || deferInit) return;
594
+ const claimed = claimColdInit();
595
+ setIsInitializer(claimed);
596
+ return () => {
597
+ if (claimed) releaseColdInit();
598
+ };
599
+ }, [deferInit]);
600
+
601
+ // Mount the wrapper when the services are ready (we attach to them) OR when we
602
+ // are the initializer (we bring them up, passing `vscodeApiConfig`). Until
603
+ // then, a sized, non-animated placeholder so the layout doesn't jump.
604
+ const canMountWrapper = servicesReady || isInitializer;
605
+
606
+ // Keep the Monaco global theme and the variable-token decoration color in
607
+ // sync whenever the resolved app theme changes. Monaco's theme is a global
608
+ // imperative setting (not per-editor), so re-deriving `editorAppConfig`
609
+ // alone is not enough after the editor has started - this effect is what
610
+ // makes live toggling work.
611
+ //
612
+ // GATED on `apiReady`: `monaco.editor.setTheme()` resolves a service via
613
+ // `StandaloneServices.get()`, which AUTO-INITIALIZES the monaco-vscode
614
+ // services if they aren't up yet (CodinGame standaloneServices.js:963). If
615
+ // this ran before the wrapper's own init (e.g. on the deferred booter, which
616
+ // mounts but never gets `apiReady`), it would trip CodinGame's
617
+ // `servicesInitialized` flag and make the wrapper's later `initialize()` throw
618
+ // "Services are already initialized". The initial theme is already applied via
619
+ // `editorAppConfig.editorOptions.theme`; this effect only handles live
620
+ // toggling, which is always after the editor (and thus the API) has started.
621
+ useEffect(() => {
622
+ if (!apiReady) return;
623
+ monacoRuntime.editor.setTheme(monacoTheme);
624
+ ensureVariableTokenStyle({ resolvedTheme });
625
+ }, [apiReady, resolvedTheme, monacoTheme]);
626
+
627
+ useEffect(() => {
628
+ // GATED on `apiReady` for the same reason as the theme effect above:
629
+ // touching `typescriptDefaults` before the wrapper init can auto-initialize
630
+ // the services and collide with the wrapper's `initialize()`.
631
+ if (!apiReady || !isTsLike || typeDefinitions === undefined) {
435
632
  return;
436
633
  }
437
634
  // Inject this editor's ambient `context` types. addExtraLib keys by path
@@ -447,7 +644,18 @@ export const TypefoxEditor = ({
447
644
  return () => {
448
645
  lib.dispose();
449
646
  };
450
- }, [isTsLike, typeDefinitions, modelId]);
647
+ }, [apiReady, isTsLike, typeDefinitions, modelId]);
648
+
649
+ // Mount the @checkstack/sdk editor bundle (script helpers + typed client) so
650
+ // `import ... from "@checkstack/sdk/healthcheck"` resolves with real types.
651
+ // Shared/module-scoped + reset-on-version (mountSdkTypes); the fetch lives in
652
+ // the consumer so this component is network-agnostic + DOM-test-free.
653
+ useEffect(() => {
654
+ if (!apiReady || !isTsLike || sdkTypes === undefined) {
655
+ return;
656
+ }
657
+ mountSdkTypes({ files: sdkTypes, resetKey: sdkTypesResetKey });
658
+ }, [apiReady, isTsLike, sdkTypes, sdkTypesResetKey]);
451
659
 
452
660
  // Lazy Automatic Type Acquisition (ATA). For TS/JS editors with an injected
453
661
  // `acquireTypes` resolver, parse the buffer's bare import/require specifiers
@@ -555,7 +763,7 @@ export const TypefoxEditor = ({
555
763
  return {
556
764
  suggestions: entries.map((entry) => ({
557
765
  label: entry.name,
558
- kind: monaco.languages.CompletionItemKind.Module,
766
+ kind: monacoRuntime.languages.CompletionItemKind.Module,
559
767
  detail: entry.detail,
560
768
  insertText: entry.name,
561
769
  filterText: entry.name,
@@ -567,7 +775,7 @@ export const TypefoxEditor = ({
567
775
  };
568
776
 
569
777
  const disposables = (["typescript", "javascript"] as const).map((lang) =>
570
- monaco.languages.registerCompletionItemProvider(lang, {
778
+ monacoRuntime.languages.registerCompletionItemProvider(lang, {
571
779
  // Opening quotes start a specifier; `:` advances into a `node:`/`bun:`
572
780
  // builtin; `/` advances into a scoped name or subpath.
573
781
  triggerCharacters: ['"', "'", "/", ":"],
@@ -628,7 +836,7 @@ export const TypefoxEditor = ({
628
836
  return {
629
837
  suggestions: keys.map((key) => ({
630
838
  label: `["${key}"]`,
631
- kind: monaco.languages.CompletionItemKind.Property,
839
+ kind: monacoRuntime.languages.CompletionItemKind.Property,
632
840
  detail: objectExpression,
633
841
  insertText: `["${key}"]`,
634
842
  filterText: key,
@@ -657,7 +865,7 @@ export const TypefoxEditor = ({
657
865
  };
658
866
 
659
867
  const disposables = (["typescript", "javascript"] as const).map((lang) =>
660
- monaco.languages.registerCompletionItemProvider(lang, {
868
+ monacoRuntime.languages.registerCompletionItemProvider(lang, {
661
869
  triggerCharacters: ["."],
662
870
  provideCompletionItems,
663
871
  }),
@@ -708,7 +916,7 @@ export const TypefoxEditor = ({
708
916
  )
709
917
  .map((prop, index) => ({
710
918
  label: `{{${prop.path}}}`,
711
- kind: monaco.languages.CompletionItemKind.Variable,
919
+ kind: monacoRuntime.languages.CompletionItemKind.Variable,
712
920
  detail: prop.type,
713
921
  documentation: prop.description,
714
922
  insertText: `{{${prop.path}}}`,
@@ -727,7 +935,7 @@ export const TypefoxEditor = ({
727
935
  return { suggestions, incomplete: false };
728
936
  };
729
937
 
730
- const provider = monaco.languages.registerCompletionItemProvider(
938
+ const provider = monacoRuntime.languages.registerCompletionItemProvider(
731
939
  languageId,
732
940
  { triggerCharacters: ["{"], provideCompletionItems },
733
941
  );
@@ -829,7 +1037,7 @@ export const TypefoxEditor = ({
829
1037
  )
830
1038
  .map((v, index) => ({
831
1039
  label: `$${v.name}`,
832
- kind: monaco.languages.CompletionItemKind.Variable,
1040
+ kind: monacoRuntime.languages.CompletionItemKind.Variable,
833
1041
  detail: v.example ? `e.g. ${v.example}` : "shell env var",
834
1042
  // Full name in the (wrapping) docs panel so long CHECKSTACK_* names
835
1043
  // stay legible even when the suggest-list label truncates.
@@ -850,7 +1058,7 @@ export const TypefoxEditor = ({
850
1058
  return { suggestions, incomplete: false };
851
1059
  };
852
1060
 
853
- const provider = monaco.languages.registerCompletionItemProvider("shell", {
1061
+ const provider = monacoRuntime.languages.registerCompletionItemProvider("shell", {
854
1062
  triggerCharacters: ["$", "{"],
855
1063
  provideCompletionItems,
856
1064
  });
@@ -866,7 +1074,7 @@ export const TypefoxEditor = ({
866
1074
  if (!apiReady) {
867
1075
  return;
868
1076
  }
869
- const model = monaco.editor
1077
+ const model = monacoRuntime.editor
870
1078
  .getModels()
871
1079
  .find((candidate) => candidate.uri.toString().includes(modelId));
872
1080
  if (!model) {
@@ -876,14 +1084,14 @@ export const TypefoxEditor = ({
876
1084
  severity: EditorMarker["severity"],
877
1085
  ): monaco.MarkerSeverity => {
878
1086
  if (severity === "warning") {
879
- return monaco.MarkerSeverity.Warning;
1087
+ return monacoRuntime.MarkerSeverity.Warning;
880
1088
  }
881
1089
  if (severity === "info") {
882
- return monaco.MarkerSeverity.Info;
1090
+ return monacoRuntime.MarkerSeverity.Info;
883
1091
  }
884
- return monaco.MarkerSeverity.Error;
1092
+ return monacoRuntime.MarkerSeverity.Error;
885
1093
  };
886
- monaco.editor.setModelMarkers(
1094
+ monacoRuntime.editor.setModelMarkers(
887
1095
  model,
888
1096
  "external-validation",
889
1097
  (markers ?? []).map((marker) => ({
@@ -896,7 +1104,7 @@ export const TypefoxEditor = ({
896
1104
  })),
897
1105
  );
898
1106
  return () => {
899
- monaco.editor.setModelMarkers(model, "external-validation", []);
1107
+ monacoRuntime.editor.setModelMarkers(model, "external-validation", []);
900
1108
  };
901
1109
  }, [apiReady, markers, modelId]);
902
1110
 
@@ -918,7 +1126,7 @@ export const TypefoxEditor = ({
918
1126
  const owner = "template-validation";
919
1127
  const runValidation = (): void => {
920
1128
  const diagnostics = validate(model.getValue());
921
- monaco.editor.setModelMarkers(
1129
+ monacoRuntime.editor.setModelMarkers(
922
1130
  model,
923
1131
  owner,
924
1132
  diagnostics.map((diagnostic) => {
@@ -932,7 +1140,7 @@ export const TypefoxEditor = ({
932
1140
  endLineNumber: end.lineNumber,
933
1141
  endColumn: end.column,
934
1142
  message: diagnostic.message,
935
- severity: monaco.MarkerSeverity.Error,
1143
+ severity: monacoRuntime.MarkerSeverity.Error,
936
1144
  };
937
1145
  }),
938
1146
  );
@@ -943,25 +1151,10 @@ export const TypefoxEditor = ({
943
1151
  });
944
1152
  return () => {
945
1153
  subscription.dispose();
946
- monaco.editor.setModelMarkers(model, owner, []);
1154
+ monacoRuntime.editor.setModelMarkers(model, owner, []);
947
1155
  };
948
1156
  }, [apiReady, language, modelId]);
949
1157
 
950
- const vscodeApiConfig: MonacoVscodeApiConfig = {
951
- // 'classic' is the standalone axis (no extension host); 'extended' is the
952
- // extension-host axis we deliberately avoid in this migration.
953
- $type: "classic",
954
- // Register the missing ILanguageStatusService (see the override comment
955
- // above) so focusing a JSON editor doesn't throw "addStatus is not
956
- // supported".
957
- serviceOverrides: { ...languageStatusServiceOverride },
958
- viewsConfig: {
959
- // Plain editor, no workbench views.
960
- $type: "EditorService",
961
- },
962
- monacoWorkerFactory: ensureStandaloneWorkerFactory,
963
- };
964
-
965
1158
  const editorAppConfig: EditorAppConfig = {
966
1159
  // Unique per instance (modelId includes a useId suffix) so multiple editors
967
1160
  // sharing the same `id` prop don't collide in the wrapper's app registry.
@@ -974,10 +1167,12 @@ export const TypefoxEditor = ({
974
1167
  },
975
1168
  },
976
1169
  editorOptions: {
977
- // 'vs-dark' is the builtin classic dark theme. (The VS Code
978
- // 'Default Dark Modern' theme would require the extension-host
979
- // theme-defaults extension, which the standalone setup omits.)
980
- theme: "vs-dark",
1170
+ // Derived from useTheme().resolvedTheme: "vs" for light, "vs-dark" for
1171
+ // dark. These are the two built-in classic themes available in the
1172
+ // standalone setup (the VS Code 'Default Dark Modern' theme requires the
1173
+ // extension-host theme-defaults extension, which the standalone setup
1174
+ // omits).
1175
+ theme: monacoTheme,
981
1176
  minimap: { enabled: false },
982
1177
  automaticLayout: true,
983
1178
  // Force completions to come from the TS language service rather than
@@ -1001,12 +1196,30 @@ export const TypefoxEditor = ({
1001
1196
  ? { minHeight: `${minHeight}px`, height: "100%" }
1002
1197
  : { minHeight: `${minHeight}px`, height: `${minHeight}px` };
1003
1198
 
1199
+ // Non-initializer editors wait for the services to be up before mounting (a
1200
+ // sized, non-animated placeholder until then, so the layout doesn't jump).
1201
+ // The initializer mounts immediately and brings the services up.
1202
+ if (!canMountWrapper) {
1203
+ return (
1204
+ <div
1205
+ style={containerStyle}
1206
+ className="w-full rounded-md bg-muted"
1207
+ aria-busy="true"
1208
+ aria-label={placeholder ?? "Loading editor"}
1209
+ />
1210
+ );
1211
+ }
1212
+
1004
1213
  return (
1005
1214
  <MonacoEditorReactComp
1006
1215
  style={containerStyle}
1007
1216
  vscodeApiConfig={vscodeApiConfig}
1008
1217
  editorAppConfig={editorAppConfig}
1009
1218
  onTextChanged={handleTextChanged}
1219
+ // Route wrapper errors to our handler rather than letting @typefox reset
1220
+ // its run-queue lock and re-throw as an uncaught rejection (recommended by
1221
+ // the monaco-languageclient maintainers).
1222
+ onError={handleEditorError}
1010
1223
  onEditorStartDone={() => {
1011
1224
  // Per-editor ready signal for the completion providers. We use this
1012
1225
  // (not onVscodeApiInitDone) because the vscode API initialises globally
@@ -0,0 +1,41 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { MONACO_THEME_MAP, VARIABLE_TOKEN_COLOR } from "./editorTheme";
3
+
4
+ describe("MONACO_THEME_MAP", () => {
5
+ it("maps light to vs", () => {
6
+ expect(MONACO_THEME_MAP["light"]).toBe("vs");
7
+ });
8
+
9
+ it("maps dark to vs-dark", () => {
10
+ expect(MONACO_THEME_MAP["dark"]).toBe("vs-dark");
11
+ });
12
+
13
+ it("covers both resolved theme values", () => {
14
+ const keys = Object.keys(MONACO_THEME_MAP);
15
+ expect(keys).toContain("light");
16
+ expect(keys).toContain("dark");
17
+ expect(keys).toHaveLength(2);
18
+ });
19
+ });
20
+
21
+ describe("VARIABLE_TOKEN_COLOR", () => {
22
+ it("provides distinct colors for light and dark", () => {
23
+ expect(VARIABLE_TOKEN_COLOR["light"]).not.toBe(VARIABLE_TOKEN_COLOR["dark"]);
24
+ });
25
+
26
+ it("light color is a valid hex color", () => {
27
+ expect(VARIABLE_TOKEN_COLOR["light"]).toMatch(/^#[0-9a-fA-F]{6}$/);
28
+ });
29
+
30
+ it("dark color is a valid hex color", () => {
31
+ expect(VARIABLE_TOKEN_COLOR["dark"]).toMatch(/^#[0-9a-fA-F]{6}$/);
32
+ });
33
+
34
+ it("dark color matches the vs-dark variable token color", () => {
35
+ expect(VARIABLE_TOKEN_COLOR["dark"]).toBe("#9cdcfe");
36
+ });
37
+
38
+ it("light color matches the vs light variable token color", () => {
39
+ expect(VARIABLE_TOKEN_COLOR["light"]).toBe("#0070c1");
40
+ });
41
+ });
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Pure theme-mapping constants for the Monaco editor. Extracted here (and not
3
+ * inlined in TypefoxEditor.tsx) so the logic can be unit-tested without
4
+ * importing browser-only Monaco modules.
5
+ */
6
+
7
+ /**
8
+ * Maps the app's resolved theme to the matching Monaco built-in theme
9
+ * identifier. Only the two built-in standalone themes are used: the VS Code
10
+ * 'Default Dark Modern' theme would require the extension-host theme-defaults
11
+ * extension, which the standalone setup omits.
12
+ */
13
+ export const MONACO_THEME_MAP: Record<"light" | "dark", string> = {
14
+ light: "vs",
15
+ dark: "vs-dark",
16
+ };
17
+
18
+ /**
19
+ * Color used for the `{{ }}` / `$VAR` decoration in each Monaco theme.
20
+ * - vs-dark uses #9cdcfe (the VS Code dark "variable" token).
21
+ * - vs (light) uses #0070c1 (the VS Code light "variable.other" token).
22
+ */
23
+ export const VARIABLE_TOKEN_COLOR: Record<"light" | "dark", string> = {
24
+ light: "#0070c1",
25
+ dark: "#9cdcfe",
26
+ };
@@ -38,7 +38,9 @@ export {
38
38
  // Subscribe to / query the monaco-vscode "services ready" transition so a
39
39
  // consumer (the automation editor's script validator + its hidden services
40
40
  // booter) can react the moment the first editor initializes the services.
41
+ // Sourced from the Monaco-free signal module so this barrel re-export does NOT
42
+ // drag the `@codingame/*` stack onto pages that never mount an editor.
41
43
  export {
42
44
  onVscodeServicesReady,
43
45
  areVscodeServicesReady,
44
- } from "./monacoTsService";
46
+ } from "./vscodeServicesSignal";