@morscherlab/mint-sdk 1.0.39 → 1.0.42

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 (78) hide show
  1. package/dist/{ExperimentPopover-DEzCbTqo.js → ExperimentPopover-8A4Rhffp.js} +1 -1
  2. package/dist/{ExperimentPopover-mzmSfAUp.js → ExperimentPopover-BbPkIFsI.js} +8 -2
  3. package/dist/ExperimentPopover-BbPkIFsI.js.map +1 -0
  4. package/dist/{ExperimentSelectorModal-Bn0Hmg07.js → ExperimentSelectorModal-B2qek_YG.js} +91 -46
  5. package/dist/ExperimentSelectorModal-B2qek_YG.js.map +1 -0
  6. package/dist/{ExperimentSelectorModal-BAIlIybO.js → ExperimentSelectorModal-BwPbQN1g.js} +1 -1
  7. package/dist/__tests__/components/AutoGroupModal.preview.test.d.ts +1 -0
  8. package/dist/__tests__/composables/autoGroup/classKey.test.d.ts +1 -0
  9. package/dist/__tests__/composables/autoGroup/groupTree.test.d.ts +1 -0
  10. package/dist/__tests__/composables/autoGroup/tokenLength.test.d.ts +1 -0
  11. package/dist/components/AppTopBar.navigation.d.ts +0 -1
  12. package/dist/components/index.js +3 -3
  13. package/dist/{components-Cyi0IfRl.js → components-BGVwavdd.js} +5632 -5629
  14. package/dist/components-BGVwavdd.js.map +1 -0
  15. package/dist/composables/autoGroup/classKey.d.ts +1 -0
  16. package/dist/composables/autoGroup/index.d.ts +2 -1
  17. package/dist/composables/autoGroup/replicatePreGroup.d.ts +10 -12
  18. package/dist/composables/autoGroup/tokenLength.d.ts +17 -0
  19. package/dist/composables/index.d.ts +1 -1
  20. package/dist/composables/index.js +3 -3
  21. package/dist/composables/useAutoGroup.d.ts +2 -0
  22. package/dist/composables/usePluginClient.d.ts +82 -5
  23. package/dist/{composables-CFSn4NN3.js → composables-C_hPF0Gn.js} +256 -9
  24. package/dist/{composables-CFSn4NN3.js.map → composables-C_hPF0Gn.js.map} +1 -1
  25. package/dist/index.js +6 -6
  26. package/dist/install.js +3 -3
  27. package/dist/styles.css +602 -555
  28. package/dist/types/auto-group.d.ts +19 -0
  29. package/dist/{useProtocolTemplates-CXP2ZosM.js → useProtocolTemplates-BbvlHoPD.js} +218 -90
  30. package/dist/useProtocolTemplates-BbvlHoPD.js.map +1 -0
  31. package/package.json +1 -1
  32. package/src/__tests__/components/AppTopBar.navigation.test.ts +3 -5
  33. package/src/__tests__/components/AppTopBar.test.ts +2 -5
  34. package/src/__tests__/components/AppTopBarPageSelector.test.ts +22 -0
  35. package/src/__tests__/components/AutoGroupModal.preview.test.ts +46 -0
  36. package/src/__tests__/components/PluginWorkspaceView.test.ts +18 -0
  37. package/src/__tests__/composables/autoGroup/classKey.test.ts +25 -0
  38. package/src/__tests__/composables/autoGroup/fingerprint.test.ts +72 -0
  39. package/src/__tests__/composables/autoGroup/groupTree.test.ts +99 -0
  40. package/src/__tests__/composables/autoGroup/tokenLength.test.ts +85 -0
  41. package/src/__tests__/composables/useAutoGroup.test.ts +111 -19
  42. package/src/__tests__/composables/usePluginClient.test.ts +129 -3
  43. package/src/components/AppTopBar.navigation.ts +0 -2
  44. package/src/components/AppTopBar.story.vue +5 -5
  45. package/src/components/AppTopBar.vue +0 -1
  46. package/src/components/AutoGroupModal.vue +23 -19
  47. package/src/components/BaseModal.story.vue +7 -15
  48. package/src/components/ExperimentDataViewer.vue +1 -0
  49. package/src/components/ExperimentPopover.vue +6 -4
  50. package/src/components/ExperimentSelectorModal.vue +30 -3
  51. package/src/components/IconButton.story.vue +5 -0
  52. package/src/components/PluginWorkspaceView.vue +5 -1
  53. package/src/components/SampleSelector.vue +3 -2
  54. package/src/components/SampleSelectorSampleRow.vue +4 -2
  55. package/src/components/internal/AppTopBarPageSelectorInternal.vue +0 -1
  56. package/src/composables/autoGroup/classKey.ts +5 -2
  57. package/src/composables/autoGroup/columns.ts +2 -2
  58. package/src/composables/autoGroup/compose.ts +56 -0
  59. package/src/composables/autoGroup/fingerprint.ts +15 -1
  60. package/src/composables/autoGroup/index.ts +2 -0
  61. package/src/composables/autoGroup/replicatePreGroup.ts +34 -0
  62. package/src/composables/autoGroup/template.ts +2 -2
  63. package/src/composables/autoGroup/tokenLength.ts +53 -0
  64. package/src/composables/autoGroup/vocab.json +1 -2
  65. package/src/composables/index.ts +6 -0
  66. package/src/composables/useAutoGroup.ts +34 -13
  67. package/src/composables/usePluginClient.ts +453 -8
  68. package/src/styles/components/app-page-selector.css +3 -5
  69. package/src/styles/components/auto-group-modal.css +7 -11
  70. package/src/styles/components/button.css +14 -4
  71. package/src/styles/components/modal.css +3 -0
  72. package/src/styles/components/sample-selector.css +17 -0
  73. package/src/styles/variables.css +8 -0
  74. package/src/types/auto-group.ts +19 -0
  75. package/dist/ExperimentPopover-mzmSfAUp.js.map +0 -1
  76. package/dist/ExperimentSelectorModal-Bn0Hmg07.js.map +0 -1
  77. package/dist/components-Cyi0IfRl.js.map +0 -1
  78. package/dist/useProtocolTemplates-CXP2ZosM.js.map +0 -1
@@ -1,18 +1,20 @@
1
- import { computed, shallowRef, watch, type ComputedRef, type Ref } from 'vue'
1
+ import { computed, onMounted, onUnmounted, ref, shallowRef, watch, type ComputedRef, type Ref } from 'vue'
2
2
  import { useApi } from './useApi'
3
3
  import { usePlatformContext } from './usePlatformContext'
4
4
  import { useRequestSyncState } from './useRequestSyncState'
5
+ import { useAuthStore } from '../stores/auth'
5
6
  import {
6
7
  currentExperimentFromContext,
7
8
  getInjectedPlatformContext,
8
9
  resolveCurrentExperimentId,
9
10
  } from './platformContextHelpers'
10
11
  import {
12
+ buildPluginEndpointUrl,
11
13
  hasKeys,
12
14
  pluginEndpointRequestParts,
13
15
  requestBodyFromPayload,
14
16
  } from './pluginEndpointBuilder'
15
- import type { ExperimentSummary, PageSelectorItem } from '../types'
17
+ import type { ExperimentSummary, PageSelectorItem, SettingsModalSchema, TopBarSettingsConfig } from '../types'
16
18
 
17
19
  export {
18
20
  buildPluginEndpointUrl,
@@ -66,6 +68,10 @@ export interface PluginContract {
66
68
  analysisResultReaders?: string[]
67
69
  capabilities?: Record<string, unknown>
68
70
  }
71
+ settings?: {
72
+ modelName?: string | null
73
+ schema?: SettingsModalSchema | null
74
+ }
69
75
  endpoints: PluginEndpointContract[]
70
76
  hash: string
71
77
  }
@@ -114,6 +120,84 @@ export interface DownloadBlobOptions {
114
120
  revokeObjectUrlDelayMs?: number
115
121
  }
116
122
 
123
+ export type PluginSettingsValues<TSettings> = Partial<TSettings> & Record<string, unknown>
124
+
125
+ export interface UsePluginSettingsOptions {
126
+ /** Plugin name for platform persistence. Defaults to the integrated platform plugin name. */
127
+ pluginName?: string
128
+ /** Plugin API base URL for standalone/local settings routes. Generated clients pass pluginApiPrefix. */
129
+ apiBaseUrl?: string
130
+ /** Platform API base URL for /plugins/{name}/config. Defaults to the injected platform API URL. */
131
+ platformApiBaseUrl?: string
132
+ /** SettingsModal schema generated from the backend settings_model. */
133
+ schema?: SettingsModalSchema | null
134
+ /** SettingsModal title when using settingsConfig. */
135
+ title?: string
136
+ /** Whether SettingsModal should include SDK appearance controls. */
137
+ showAppearance?: boolean
138
+ /** Load persisted values on component mount. */
139
+ loadOnMount?: boolean
140
+ }
141
+
142
+ export interface UsePluginSettingsReturn<TSettings = Record<string, unknown>> {
143
+ /** Current typed settings values. */
144
+ settings: ComputedRef<PluginSettingsValues<TSettings>>
145
+ /** Editable settings values for SettingsModal. */
146
+ values: Ref<PluginSettingsValues<TSettings>>
147
+ /** Alias for values, kept for callers that think in platform config terms. */
148
+ config: Ref<PluginSettingsValues<TSettings>>
149
+ /** Ready-to-pass AppTopBar/PluginWorkspaceView settingsConfig. */
150
+ settingsConfig: ComputedRef<TopBarSettingsConfig>
151
+ isLoading: Ref<boolean>
152
+ isSaving: Ref<boolean>
153
+ error: Ref<string | null>
154
+ lastLoadedAt: Ref<Date | null>
155
+ lastSavedAt: Ref<Date | null>
156
+ isDirty: ComputedRef<boolean>
157
+ setValues: (values: Record<string, unknown>) => void
158
+ load: () => Promise<void>
159
+ save: (values?: Record<string, unknown>) => Promise<boolean>
160
+ reset: () => void
161
+ }
162
+
163
+ export interface PluginEventStreamMessage<TData = string> {
164
+ event: string
165
+ data: TData
166
+ id?: string
167
+ retry?: number
168
+ raw: string
169
+ }
170
+
171
+ export interface PluginEventStreamOptions<TData = string> {
172
+ /** Override the generated or platform-injected API base URL. */
173
+ baseUrl?: string
174
+ /** Start the stream immediately. Defaults to true. */
175
+ immediate?: boolean
176
+ /** Reconnect after errors or server closes. Defaults to true. */
177
+ reconnect?: boolean
178
+ /** Delay before reconnecting after a stream failure or close. Defaults to 2000ms. */
179
+ reconnectDelayMs?: number
180
+ /** Parse `data:` as JSON before calling onMessage. */
181
+ parseJson?: boolean
182
+ /** Fetch credentials mode. Defaults to same-origin. */
183
+ credentials?: RequestCredentials
184
+ /** Extra request headers. Authorization is injected unless already supplied. */
185
+ headers?: Record<string, string>
186
+ onOpen?: () => void
187
+ onMessage?: (message: PluginEventStreamMessage<TData>) => void
188
+ onError?: (error: unknown) => void
189
+ }
190
+
191
+ export interface UsePluginEventStreamReturn<TData = string> {
192
+ isConnected: Ref<boolean>
193
+ isConnecting: Ref<boolean>
194
+ error: Ref<string | null>
195
+ lastMessage: Ref<PluginEventStreamMessage<TData> | null>
196
+ lastEventId: Ref<string | null>
197
+ start: () => void
198
+ stop: () => void
199
+ }
200
+
117
201
  export type PluginFormDataValue =
118
202
  | string
119
203
  | number
@@ -240,7 +324,6 @@ export function getPluginPageSelectorItems(contract: PluginContract): PageSelect
240
324
  label: item.label,
241
325
  to: normalizePluginNavPath(item.path),
242
326
  icon: item.icon || pluginIcon || undefined,
243
- hint: item.description || contract.plugin.name,
244
327
  }))
245
328
  }
246
329
 
@@ -337,11 +420,373 @@ export function usePluginClient<TClient>(client: TClient): TClient {
337
420
  return client
338
421
  }
339
422
 
340
- /** Read plugin settings exposed through the platform context. */
341
- export function usePluginSettings<TSettings = Record<string, unknown>>() {
342
- const { plugin } = usePlatformContext()
343
- const settings = computed(() => plugin.value?.settings as TSettings | undefined)
344
- return { settings }
423
+ function normalizePluginSettingsOptions(options: UsePluginSettingsOptions | string = {}): UsePluginSettingsOptions {
424
+ return typeof options === 'string' ? { pluginName: options } : options
425
+ }
426
+
427
+ function cloneRecord(value: Record<string, unknown>): Record<string, unknown> {
428
+ return { ...value }
429
+ }
430
+
431
+ function responseSettingsPayload(response: unknown): Record<string, unknown> {
432
+ if (!response || typeof response !== 'object') return {}
433
+ const data = response as {
434
+ config?: unknown
435
+ settings?: unknown
436
+ }
437
+ if (data.config && typeof data.config === 'object') {
438
+ return cloneRecord(data.config as Record<string, unknown>)
439
+ }
440
+ if (data.settings && typeof data.settings === 'object') {
441
+ return cloneRecord(data.settings as Record<string, unknown>)
442
+ }
443
+ return {}
444
+ }
445
+
446
+ /** Load, edit, and persist plugin settings from platform config or the plugin-local settings router. */
447
+ export function usePluginSettings<TSettings = Record<string, unknown>>(
448
+ options: UsePluginSettingsOptions | string = {},
449
+ ): UsePluginSettingsReturn<TSettings> {
450
+ const resolvedOptions = normalizePluginSettingsOptions(options)
451
+ const injectedContext = getInjectedPlatformContext()
452
+ const { plugin, isIntegrated } = usePlatformContext()
453
+ const platformApi = useApi({ baseUrl: resolvedOptions.platformApiBaseUrl ?? injectedContext?.platformApiUrl })
454
+ const pluginApi = useApi({ baseUrl: resolvedOptions.apiBaseUrl ?? injectedContext?.plugin?.api_prefix })
455
+
456
+ const values = shallowRef<PluginSettingsValues<TSettings>>({} as PluginSettingsValues<TSettings>)
457
+ const savedValues = shallowRef<Record<string, unknown>>({})
458
+ const request = useRequestSyncState('Plugin settings request failed.')
459
+ const isLoading = ref(false)
460
+ const isSaving = ref(false)
461
+
462
+ const resolvedPluginName = computed(() =>
463
+ resolvedOptions.pluginName ?? plugin.value?.name ?? injectedContext?.plugin?.name ?? '',
464
+ )
465
+ const usesPlatformConfig = computed(() =>
466
+ Boolean((isIntegrated.value || injectedContext?.isIntegrated) && resolvedPluginName.value),
467
+ )
468
+ const settings = computed(() => values.value)
469
+ const isDirty = computed(() => JSON.stringify(values.value) !== JSON.stringify(savedValues.value))
470
+ const settingsConfig = computed<TopBarSettingsConfig>(() => ({
471
+ title: resolvedOptions.title,
472
+ schema: resolvedOptions.schema ?? undefined,
473
+ showAppearance: resolvedOptions.showAppearance,
474
+ values: values.value,
475
+ }))
476
+
477
+ function setValues(nextValues: Record<string, unknown>): void {
478
+ values.value = cloneRecord(nextValues) as PluginSettingsValues<TSettings>
479
+ }
480
+
481
+ function applyLoaded(nextValues: Record<string, unknown>): void {
482
+ const cloned = cloneRecord(nextValues)
483
+ values.value = cloned as PluginSettingsValues<TSettings>
484
+ savedValues.value = cloneRecord(cloned)
485
+ }
486
+
487
+ async function load(): Promise<void> {
488
+ isLoading.value = true
489
+ try {
490
+ const response = usesPlatformConfig.value
491
+ ? await request.run(
492
+ () => platformApi.get<{ plugin_name: string; config: Record<string, unknown> }>(
493
+ `/plugins/${encodeURIComponent(resolvedPluginName.value)}/config`,
494
+ ),
495
+ { success: 'load', errorMessage: 'Failed to load plugin settings' },
496
+ )
497
+ : await request.run(
498
+ () => pluginApi.get<{ settings: Record<string, unknown>; mode?: string }>('/settings'),
499
+ { success: 'load', errorMessage: 'Failed to load plugin settings' },
500
+ )
501
+ applyLoaded(responseSettingsPayload(response))
502
+ } catch {
503
+ // Error state is handled by request.run().
504
+ } finally {
505
+ isLoading.value = false
506
+ }
507
+ }
508
+
509
+ async function save(nextValues?: Record<string, unknown>): Promise<boolean> {
510
+ if (nextValues) {
511
+ setValues(nextValues)
512
+ }
513
+
514
+ isSaving.value = true
515
+ try {
516
+ const payload = cloneRecord(values.value)
517
+ const response = usesPlatformConfig.value
518
+ ? await request.run(
519
+ () => platformApi.patch<{ plugin_name: string; config: Record<string, unknown> }>(
520
+ `/plugins/${encodeURIComponent(resolvedPluginName.value)}/config`,
521
+ { config: payload },
522
+ ),
523
+ { success: 'save', errorMessage: 'Failed to save plugin settings' },
524
+ )
525
+ : await request.run(
526
+ () => pluginApi.put<{ settings: Record<string, unknown>; mode?: string }>('/settings', payload),
527
+ { success: 'save', errorMessage: 'Failed to save plugin settings' },
528
+ )
529
+ applyLoaded(responseSettingsPayload(response))
530
+ return true
531
+ } catch {
532
+ return false
533
+ } finally {
534
+ isSaving.value = false
535
+ }
536
+ }
537
+
538
+ function reset(): void {
539
+ values.value = cloneRecord(savedValues.value) as PluginSettingsValues<TSettings>
540
+ request.clearError()
541
+ }
542
+
543
+ onMounted(() => {
544
+ if (resolvedOptions.loadOnMount !== false) {
545
+ void load()
546
+ }
547
+ })
548
+
549
+ return {
550
+ settings,
551
+ values,
552
+ config: values,
553
+ settingsConfig,
554
+ isLoading,
555
+ isSaving,
556
+ error: request.error,
557
+ lastLoadedAt: request.lastLoadedAt,
558
+ lastSavedAt: request.lastSavedAt,
559
+ isDirty,
560
+ setValues,
561
+ load,
562
+ save,
563
+ reset,
564
+ }
565
+ }
566
+
567
+ function streamHeaders(options: PluginEventStreamOptions<unknown>): Headers {
568
+ const headers = new Headers(options.headers)
569
+ const authStore = useAuthStore()
570
+ if (!authStore.isInitialized) {
571
+ authStore.initialize()
572
+ }
573
+ if (authStore.token && !headers.has('Authorization')) {
574
+ headers.set('Authorization', `Bearer ${authStore.token}`)
575
+ }
576
+ headers.set('Accept', 'text/event-stream')
577
+ return headers
578
+ }
579
+
580
+ function parseSseEvent<TData>(
581
+ block: string,
582
+ parseJson: boolean,
583
+ ): PluginEventStreamMessage<TData> | null {
584
+ let event = 'message'
585
+ let id: string | undefined
586
+ let retry: number | undefined
587
+ const dataLines: string[] = []
588
+
589
+ for (const line of block.split(/\r?\n/)) {
590
+ if (!line || line.startsWith(':')) continue
591
+ const separator = line.indexOf(':')
592
+ const field = separator === -1 ? line : line.slice(0, separator)
593
+ const value = separator === -1
594
+ ? ''
595
+ : line.slice(separator + 1).replace(/^ /, '')
596
+
597
+ if (field === 'event') event = value || 'message'
598
+ if (field === 'data') dataLines.push(value)
599
+ if (field === 'id') id = value
600
+ if (field === 'retry') {
601
+ const parsed = Number(value)
602
+ if (Number.isFinite(parsed)) retry = parsed
603
+ }
604
+ }
605
+
606
+ if (!dataLines.length && !id && retry === undefined) return null
607
+ const data = dataLines.join('\n')
608
+ return {
609
+ event,
610
+ data: (parseJson && data ? JSON.parse(data) : data) as TData,
611
+ id,
612
+ retry,
613
+ raw: block,
614
+ }
615
+ }
616
+
617
+ function looksLikeEventStreamOptions(value: unknown): value is PluginEventStreamOptions {
618
+ if (!value || typeof value !== 'object') return false
619
+ const candidate = value as Record<string, unknown>
620
+ return [
621
+ 'baseUrl',
622
+ 'immediate',
623
+ 'reconnect',
624
+ 'reconnectDelayMs',
625
+ 'parseJson',
626
+ 'credentials',
627
+ 'headers',
628
+ 'onOpen',
629
+ 'onMessage',
630
+ 'onError',
631
+ ].some(key => key in candidate)
632
+ }
633
+
634
+ /** Consume a generated plugin SSE endpoint with auth headers, reconnect, and teardown. */
635
+ export function usePluginEventStream<TData = string>(
636
+ contract: PluginContract,
637
+ endpoint: PluginEndpointDefinition,
638
+ options?: PluginEventStreamOptions<TData>,
639
+ ): UsePluginEventStreamReturn<TData>
640
+ export function usePluginEventStream<TData = string>(
641
+ contract: PluginContract,
642
+ endpoint: PluginEndpointDefinition,
643
+ payload?: unknown,
644
+ options?: PluginEventStreamOptions<TData>,
645
+ ): UsePluginEventStreamReturn<TData>
646
+ export function usePluginEventStream<TData = string>(
647
+ contract: PluginContract,
648
+ endpoint: PluginEndpointDefinition,
649
+ payloadOrOptions?: unknown,
650
+ maybeOptions?: PluginEventStreamOptions<TData>,
651
+ ): UsePluginEventStreamReturn<TData> {
652
+ const payload = maybeOptions === undefined && looksLikeEventStreamOptions(payloadOrOptions)
653
+ ? undefined
654
+ : payloadOrOptions
655
+ const options = (
656
+ maybeOptions === undefined && looksLikeEventStreamOptions(payloadOrOptions)
657
+ ? payloadOrOptions
658
+ : maybeOptions ?? {}
659
+ ) as PluginEventStreamOptions<TData>
660
+ const isConnected = ref(false)
661
+ const isConnecting = ref(false)
662
+ const error = ref<string | null>(null)
663
+ const lastMessage = shallowRef<PluginEventStreamMessage<TData> | null>(null)
664
+ const lastEventId = ref<string | null>(null)
665
+ let controller: AbortController | null = null
666
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null
667
+ let stopped = true
668
+
669
+ function clearReconnectTimer(): void {
670
+ if (reconnectTimer !== null) {
671
+ clearTimeout(reconnectTimer)
672
+ reconnectTimer = null
673
+ }
674
+ }
675
+
676
+ function scheduleReconnect(): void {
677
+ if (stopped || options.reconnect === false) return
678
+ clearReconnectTimer()
679
+ reconnectTimer = setTimeout(() => {
680
+ void connect()
681
+ }, options.reconnectDelayMs ?? 2000)
682
+ }
683
+
684
+ async function connect(): Promise<void> {
685
+ if (endpoint.method !== 'get') {
686
+ throw new Error(`[MINT SDK] Plugin event streams must use GET; got ${endpoint.method}`)
687
+ }
688
+ if (typeof fetch !== 'function') {
689
+ throw new Error('[MINT SDK] fetch is required for plugin event streams.')
690
+ }
691
+
692
+ controller?.abort()
693
+ controller = new AbortController()
694
+ isConnecting.value = true
695
+ error.value = null
696
+
697
+ try {
698
+ const url = buildPluginEndpointUrl(contract, endpoint, payload, {
699
+ baseUrl: options.baseUrl,
700
+ })
701
+ const response = await fetch(url, {
702
+ method: 'GET',
703
+ headers: streamHeaders(options as PluginEventStreamOptions<unknown>),
704
+ credentials: options.credentials ?? 'same-origin',
705
+ signal: controller.signal,
706
+ })
707
+
708
+ if (!response.ok) {
709
+ throw new Error(`Plugin event stream failed with HTTP ${response.status}`)
710
+ }
711
+ if (!response.body) {
712
+ throw new Error('Plugin event stream response did not include a readable body.')
713
+ }
714
+
715
+ options.onOpen?.()
716
+ isConnected.value = true
717
+ const reader = response.body.getReader()
718
+ const decoder = new TextDecoder()
719
+ let buffer = ''
720
+
721
+ for (;;) {
722
+ const { value, done } = await reader.read()
723
+ if (done) break
724
+ buffer += decoder.decode(value, { stream: true })
725
+
726
+ let boundary = buffer.search(/\r?\n\r?\n/)
727
+ while (boundary !== -1) {
728
+ const block = buffer.slice(0, boundary)
729
+ buffer = buffer.slice(buffer[boundary] === '\r' ? boundary + 4 : boundary + 2)
730
+ const message = parseSseEvent<TData>(block, options.parseJson === true)
731
+ if (message) {
732
+ lastMessage.value = message
733
+ if (message.id !== undefined) {
734
+ lastEventId.value = message.id
735
+ }
736
+ if (message.retry !== undefined) {
737
+ options.reconnectDelayMs = message.retry
738
+ }
739
+ options.onMessage?.(message)
740
+ }
741
+ boundary = buffer.search(/\r?\n\r?\n/)
742
+ }
743
+ }
744
+ } catch (err) {
745
+ if (!stopped && !(err instanceof DOMException && err.name === 'AbortError')) {
746
+ const message = err instanceof Error ? err.message : 'Plugin event stream failed'
747
+ error.value = message
748
+ options.onError?.(err)
749
+ }
750
+ } finally {
751
+ isConnecting.value = false
752
+ isConnected.value = false
753
+ scheduleReconnect()
754
+ }
755
+ }
756
+
757
+ function start(): void {
758
+ stopped = false
759
+ clearReconnectTimer()
760
+ void connect()
761
+ }
762
+
763
+ function stop(): void {
764
+ stopped = true
765
+ clearReconnectTimer()
766
+ controller?.abort()
767
+ controller = null
768
+ isConnecting.value = false
769
+ isConnected.value = false
770
+ }
771
+
772
+ if (options.immediate !== false && typeof window === 'undefined') {
773
+ onMounted(start)
774
+ }
775
+ onUnmounted(stop)
776
+
777
+ if (options.immediate !== false && typeof window !== 'undefined') {
778
+ start()
779
+ }
780
+
781
+ return {
782
+ isConnected,
783
+ isConnecting,
784
+ error,
785
+ lastMessage,
786
+ lastEventId,
787
+ start,
788
+ stop,
789
+ }
345
790
  }
346
791
 
347
792
  /** Read and optionally load the current platform experiment for integrated plugin views. */
@@ -205,10 +205,8 @@
205
205
 
206
206
  .mint-page-selector__item-label {
207
207
  flex: 1;
208
- }
209
-
210
- .mint-page-selector__item-hint {
211
- font-size: 0.65625rem;
212
- color: var(--text-muted);
208
+ min-width: 0;
209
+ overflow: hidden;
210
+ text-overflow: ellipsis;
213
211
  white-space: nowrap;
214
212
  }
@@ -866,7 +866,7 @@
866
866
  font-size: 0.8125rem;
867
867
  color: var(--mint-error);
868
868
  background: var(--mint-error-bg);
869
- border: 1px solid rgba(239, 68, 68, 0.3);
869
+ border: 1px solid var(--mint-error-border);
870
870
  border-radius: var(--radius);
871
871
  }
872
872
 
@@ -889,7 +889,7 @@
889
889
 
890
890
  .mint-auto-group__experiment-error {
891
891
  padding: 1.5rem;
892
- border: 1px solid rgba(239, 68, 68, 0.3);
892
+ border: 1px solid var(--mint-error-border);
893
893
  border-radius: var(--radius-md);
894
894
  background: var(--mint-error-bg);
895
895
  font-size: 0.8125rem;
@@ -925,7 +925,7 @@
925
925
  .mint-auto-group__link-btn:focus-visible {
926
926
  outline: 2px solid var(--color-primary);
927
927
  outline-offset: 1px;
928
- border-radius: 2px;
928
+ border-radius: var(--radius-sm);
929
929
  }
930
930
 
931
931
  .mint-auto-group__experiment-loaded {
@@ -1258,14 +1258,10 @@
1258
1258
  .mint-auto-group__preview-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
1259
1259
  .mint-auto-group__preview-panel { background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px; }
1260
1260
  .mint-auto-group__preview-panel h4 { margin: 0 0 10px; font-size: 12px; color: var(--text-muted); text-transform: uppercase; letter-spacing: .06em; }
1261
- .mint-auto-group__preview-group { border: 1px solid var(--border-color); border-radius: var(--radius); overflow: hidden; }
1262
- .mint-auto-group__preview-group[open] { background: var(--bg-secondary); }
1263
- .mint-auto-group__preview-group summary { display: flex; align-items: center; gap: 10px; cursor: pointer; font-size: 13px; padding: 0.5rem 0.875rem; }
1264
- .mint-auto-group__preview-group[open] summary { border-bottom: 1px solid var(--border); }
1265
- .mint-auto-group__preview-dot { width: 10px; height: 10px; border-radius: 9999px; flex-shrink: 0; }
1266
- .mint-auto-group__preview-count { margin-left: auto; font-family: 'Fira Code', monospace; font-size: 12px; color: var(--text-secondary); background: var(--bg-primary); border: 1px solid var(--border); padding: 2px 8px; border-radius: 9999px; }
1267
- .mint-auto-group__preview-samples { display: flex; flex-wrap: wrap; gap: 6px; padding: 0.5rem 0.875rem 0.625rem; }
1268
- .mint-auto-group__preview-sample { font-family: 'Fira Code', monospace; font-size: 11.5px; color: var(--text-primary); background: var(--bg-primary); border: 1px solid var(--border); padding: 3px 8px; border-radius: 9999px; }
1261
+ /* Nested experimental groups render as a collapsible SampleHierarchyTree; cap
1262
+ its height so a large batch scrolls inside the panel instead of the modal. */
1263
+ .mint-auto-group__preview-tree { max-height: 320px; overflow-y: auto; }
1264
+ .mint-auto-group__preview-empty { margin: 0; font-size: 13px; color: var(--text-muted); }
1269
1265
  .mint-auto-group__qc-chips { display: flex; flex-wrap: wrap; gap: 6px; }
1270
1266
  .mint-auto-group__qc-chip { background: var(--mint-warning-bg); color: #92400E; border: 1px solid var(--mint-warning-border); padding: 4px 10px; border-radius: 9999px; font-size: 12px; }
1271
1267
  .mint-auto-group__fingerprint { margin-top: 14px; }
@@ -78,10 +78,11 @@
78
78
 
79
79
  .mint-button--secondary {
80
80
  background-color: var(--bg-secondary);
81
- color: var(--text-primary);
81
+ color: var(--text-secondary-strong);
82
82
  border: 1px solid var(--border-color);
83
- /* Intentionally tighter + fainter than the colored variants
84
- secondary shouldn't "float" the way filled buttons do. */
83
+ /* Secondary is lower-emphasis through surface, border, and shadow, but keeps
84
+ medium label weight so it still reads as an actionable button. */
85
+ font-weight: 500;
85
86
  box-shadow: 0 1px 1px rgba(15, 23, 42, 0.03);
86
87
  }
87
88
 
@@ -125,7 +126,9 @@
125
126
 
126
127
  .mint-button--ghost {
127
128
  background-color: transparent;
128
- color: var(--text-primary);
129
+ color: var(--text-secondary-strong);
130
+ /* Lowest-emphasis variant: regular (400) weight, below secondary. */
131
+ font-weight: 400;
129
132
  }
130
133
 
131
134
  .mint-button--ghost:hover:not(.mint-button--disabled) {
@@ -136,9 +139,16 @@
136
139
  .mint-button--sm {
137
140
  padding: 0.375rem 0.75rem;
138
141
  font-size: 0.875rem;
142
+ /* Small buttons live in dense/secondary contexts — regular (400) weight
143
+ keeps the compact label from reading too heavy. Applies to all variants. */
144
+ font-weight: 400;
139
145
  min-height: var(--form-height-sm);
140
146
  }
141
147
 
148
+ .mint-button--secondary.mint-button--sm {
149
+ font-weight: 500;
150
+ }
151
+
142
152
  .mint-button--md {
143
153
  padding: 0.5rem 1rem;
144
154
  font-size: 0.875rem;
@@ -165,6 +165,9 @@
165
165
 
166
166
  .mint-modal__close {
167
167
  flex-shrink: 0;
168
+ /* Always pin the close affordance to the right edge of the header, even when
169
+ there is no title/subtitle (no flex-1 header-text to push it over). */
170
+ margin-left: auto;
168
171
  width: 1.75rem;
169
172
  height: 1.75rem;
170
173
  padding: 0;
@@ -239,6 +239,11 @@
239
239
  background-color: var(--bg-hover);
240
240
  }
241
241
 
242
+ .mint-sample-selector__expand-btn:focus-visible {
243
+ outline: none;
244
+ box-shadow: var(--focus-ring);
245
+ }
246
+
242
247
  .mint-sample-selector__expand-icon {
243
248
  width: 0.875rem;
244
249
  height: 0.875rem;
@@ -363,6 +368,11 @@
363
368
  box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08), 0 0 0 3px rgba(0, 0, 0, 0.06);
364
369
  }
365
370
 
371
+ .mint-sample-selector__color-dot--clickable:focus-visible {
372
+ outline: none;
373
+ box-shadow: var(--focus-ring);
374
+ }
375
+
366
376
  /* Count Badge */
367
377
  .mint-sample-selector__count-badge {
368
378
  font-size: 0.625rem;
@@ -396,6 +406,13 @@
396
406
  color: var(--mint-error);
397
407
  }
398
408
 
409
+ /* Reveal the hover-hidden delete button when reached by keyboard, and ring it. */
410
+ .mint-sample-selector__delete-btn:focus-visible {
411
+ outline: none;
412
+ opacity: 1;
413
+ box-shadow: var(--focus-ring);
414
+ }
415
+
399
416
  .mint-sample-selector__delete-btn--hidden {
400
417
  opacity: 0;
401
418
  }
@@ -22,6 +22,11 @@
22
22
  --text-primary: #1E293B;
23
23
  --text-secondary: #64748B;
24
24
  --text-muted: #94A3B8;
25
+ /* De-emphasized label for low-emphasis interactive controls (secondary/ghost
26
+ buttons). A true dark gray — softer than --text-primary's near-black — that
27
+ still clears WCAG AA (4.5:1) on the secondary hover surface (--bg-tertiary),
28
+ where --text-secondary would drop to ~4.35:1. */
29
+ --text-secondary-strong: #475569;
25
30
 
26
31
  /* Brand colors */
27
32
  --color-primary: #6366F1;
@@ -129,6 +134,9 @@ html.dark {
129
134
  --text-primary: #F8FAFC;
130
135
  --text-secondary: #94A3B8;
131
136
  --text-muted: #64748B;
137
+ /* Dark-mode analog: soft light-gray instead of near-white, AA-safe (7.2:1)
138
+ on the dark secondary hover surface (--bg-tertiary #334155). */
139
+ --text-secondary-strong: #CBD5E1;
132
140
 
133
141
  /* Scrollbar */
134
142
  --scrollbar-track: #1E293B;