@morscherlab/mint-sdk 1.1.0-beta.1 → 1.1.0-beta.11

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 (94) hide show
  1. package/README.md +18 -4
  2. package/dist/{EmptyState-D1c4RdC7.js → EmptyState-DgBRoYAy.js} +22 -13
  3. package/dist/EmptyState-DgBRoYAy.js.map +1 -0
  4. package/dist/{ExperimentSelectorModal-B7w6k3Y8.js → ExperimentSelectorModal-BBWXC7KW.js} +3 -3
  5. package/dist/{ExperimentSelectorModal-B7w6k3Y8.js.map → ExperimentSelectorModal-BBWXC7KW.js.map} +1 -1
  6. package/dist/{SettingsModal-DPDwCbXK.js → SettingsModal-Dpg-X1OM.js} +3 -3
  7. package/dist/{SettingsModal-DPDwCbXK.js.map → SettingsModal-Dpg-X1OM.js.map} +1 -1
  8. package/dist/__tests__/components/BaseToggle.test.d.ts +1 -0
  9. package/dist/__tests__/components/SectionCard.test.d.ts +1 -0
  10. package/dist/__tests__/composables/analysisArtifactTarget.test.d.ts +1 -0
  11. package/dist/__tests__/types/GeneratedJsonSchema.test.d.ts +1 -0
  12. package/dist/analysisArtifactTypes-ejqR8MX3.js.map +1 -1
  13. package/dist/components/BaseSelect.vue.d.ts +1 -1
  14. package/dist/components/BaseToggle.vue.d.ts +2 -0
  15. package/dist/components/FitPanel.vue.d.ts +1 -1
  16. package/dist/components/PluginWorkspaceView.props.d.ts +1 -1
  17. package/dist/components/SectionCard.vue.d.ts +47 -0
  18. package/dist/components/index.d.ts +1 -0
  19. package/dist/components/index.js +6 -6
  20. package/dist/{components-UfkPN1bK.js → components-Ci_BTx00.js} +711 -633
  21. package/dist/components-Ci_BTx00.js.map +1 -0
  22. package/dist/composables/analysisArtifactTarget.d.ts +5 -0
  23. package/dist/composables/index.d.ts +3 -1
  24. package/dist/composables/index.js +8 -8
  25. package/dist/composables/platformContextHelpers.d.ts +2 -0
  26. package/dist/composables/useAnalysisArtifacts.d.ts +3 -1
  27. package/dist/composables/useGeneratedAnalysis.d.ts +1 -1
  28. package/dist/composables/usePlatformContext.d.ts +3 -0
  29. package/dist/composables/usePluginClient.d.ts +11 -5
  30. package/dist/{composables-F4JFK9K1.js → composables-C0VEJ73O.js} +172 -30
  31. package/dist/composables-C0VEJ73O.js.map +1 -0
  32. package/dist/index.js +11 -11
  33. package/dist/install.js +5 -5
  34. package/dist/styles.css +312 -11
  35. package/dist/tailwind.preset.d.ts +5 -0
  36. package/dist/tailwind.preset.js +7 -2
  37. package/dist/tailwind.preset.js.map +1 -1
  38. package/dist/templates/index.js +3 -3
  39. package/dist/{templates-Cttbjbyc.js → templates-Cdj93S9C.js} +2 -2
  40. package/dist/{templates-Cttbjbyc.js.map → templates-Cdj93S9C.js.map} +1 -1
  41. package/dist/types/analysisArtifactTypes.d.ts +8 -0
  42. package/dist/types/components.d.ts +2 -0
  43. package/dist/types/generated-ui.d.ts +13 -0
  44. package/dist/types/index.d.ts +2 -2
  45. package/dist/types/platform.d.ts +2 -0
  46. package/dist/{useControlSchema-Dallso-L.js → useControlSchema-B_qgU4rW.js} +48 -19
  47. package/dist/useControlSchema-B_qgU4rW.js.map +1 -0
  48. package/dist/{useExperimentSelector-BMjzNcf2.js → useExperimentSelector-BuQZRgMt.js} +2 -2
  49. package/dist/{useExperimentSelector-BMjzNcf2.js.map → useExperimentSelector-BuQZRgMt.js.map} +1 -1
  50. package/dist/{useFormBuilder-BXaaDI41.js → useFormBuilder-D1G5J5Z5.js} +2 -2
  51. package/dist/{useFormBuilder-BXaaDI41.js.map → useFormBuilder-D1G5J5Z5.js.map} +1 -1
  52. package/dist/{useProtocolTemplates-BP7_4RGQ.js → useProtocolTemplates-DeaOmMaw.js} +4 -4
  53. package/dist/{useProtocolTemplates-BP7_4RGQ.js.map → useProtocolTemplates-DeaOmMaw.js.map} +1 -1
  54. package/package.json +1 -1
  55. package/src/__tests__/components/BaseSelect.test.ts +52 -0
  56. package/src/__tests__/components/BaseToggle.test.ts +40 -0
  57. package/src/__tests__/components/GeneratedPluginView.test.ts +10 -0
  58. package/src/__tests__/components/PluginWorkspaceView.test.ts +68 -14
  59. package/src/__tests__/components/SectionCard.test.ts +181 -0
  60. package/src/__tests__/composables/analysisArtifactTarget.test.ts +77 -0
  61. package/src/__tests__/composables/useAnalysisArtifacts.test.ts +74 -1
  62. package/src/__tests__/composables/useGeneratedAnalysis.test.ts +2 -0
  63. package/src/__tests__/composables/usePlatformContext.test.ts +28 -0
  64. package/src/__tests__/composables/usePluginClient.test.ts +365 -21
  65. package/src/__tests__/composables/usePluginConfig.test.ts +323 -7
  66. package/src/__tests__/types/GeneratedJsonSchema.test.ts +62 -0
  67. package/src/components/BaseSelect.vue +16 -2
  68. package/src/components/BaseToggle.vue +3 -1
  69. package/src/components/GeneratedPluginView.vue +1 -1
  70. package/src/components/PluginWorkspaceView.props.ts +1 -1
  71. package/src/components/PluginWorkspaceView.vue +1 -5
  72. package/src/components/SectionCard.story.vue +163 -0
  73. package/src/components/SectionCard.vue +98 -0
  74. package/src/components/index.ts +1 -0
  75. package/src/composables/analysisArtifactTarget.ts +53 -0
  76. package/src/composables/index.ts +6 -0
  77. package/src/composables/platformContextHelpers.ts +10 -3
  78. package/src/composables/useAnalysisArtifacts.ts +48 -0
  79. package/src/composables/useGeneratedAnalysis.ts +30 -12
  80. package/src/composables/usePluginClient.ts +130 -20
  81. package/src/composables/usePluginConfig.ts +156 -20
  82. package/src/styles/components/section-card.css +161 -0
  83. package/src/styles/index.css +1 -0
  84. package/src/styles/variables.css +25 -0
  85. package/src/tailwind.preset.ts +5 -0
  86. package/src/types/analysisArtifactTypes.ts +9 -0
  87. package/src/types/components.ts +12 -0
  88. package/src/types/generated-ui.ts +13 -0
  89. package/src/types/index.ts +3 -0
  90. package/src/types/platform.ts +2 -0
  91. package/dist/EmptyState-D1c4RdC7.js.map +0 -1
  92. package/dist/components-UfkPN1bK.js.map +0 -1
  93. package/dist/composables-F4JFK9K1.js.map +0 -1
  94. package/dist/useControlSchema-Dallso-L.js.map +0 -1
@@ -45,6 +45,7 @@ export type PluginHttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete'
45
45
 
46
46
  export interface PluginEndpointContract {
47
47
  name: string
48
+ operationId?: string
48
49
  method: PluginHttpMethod
49
50
  path: string
50
51
  requiresAuth?: boolean
@@ -75,8 +76,16 @@ export interface PluginNavItemContract {
75
76
  requiresFeature?: string
76
77
  }
77
78
 
79
+ export interface PluginContractSettings {
80
+ modelName: string | null
81
+ /** Pydantic JSON Schema used to generate typed settings values. */
82
+ valueSchema: Record<string, unknown> | null
83
+ /** SDK SettingsModal schema used to render the settings form. */
84
+ formSchema: SettingsModalSchema | null
85
+ }
86
+
78
87
  export interface PluginContract {
79
- schemaVersion: number
88
+ schemaVersion: 2
80
89
  plugin: {
81
90
  name?: string
82
91
  packageName?: string
@@ -92,11 +101,9 @@ export interface PluginContract {
92
101
  analysisResultReaders?: string[]
93
102
  capabilities?: Record<string, unknown>
94
103
  }
95
- settings?: {
96
- modelName?: string | null
97
- schema?: SettingsModalSchema | null
98
- }
104
+ settings: PluginContractSettings
99
105
  endpoints: PluginEndpointContract[]
106
+ schemas?: Record<string, Record<string, unknown>>
100
107
  jobs?: GeneratedJobDefinition[]
101
108
  hash: string
102
109
  }
@@ -546,8 +553,14 @@ function normalizePluginSettingsOptions(options: UsePluginSettingsOptions | stri
546
553
  return typeof options === 'string' ? { pluginName: options } : options
547
554
  }
548
555
 
556
+ const PLATFORM_OWNED_SETTINGS_KEY = 'allowed_experiment_types'
557
+
549
558
  function cloneRecord(value: Record<string, unknown>): Record<string, unknown> {
550
- return { ...value }
559
+ return JSON.parse(JSON.stringify(value)) as Record<string, unknown>
560
+ }
561
+
562
+ function hasOwnSetting(settings: Record<string, unknown>, key: string): boolean {
563
+ return Object.prototype.hasOwnProperty.call(settings, key)
551
564
  }
552
565
 
553
566
  function responseSettingsPayload(response: unknown): Record<string, unknown> {
@@ -565,6 +578,43 @@ function responseSettingsPayload(response: unknown): Record<string, unknown> {
565
578
  return {}
566
579
  }
567
580
 
581
+ function responseSettingsRevision(response: unknown): string | null {
582
+ if (!response || typeof response !== 'object') return null
583
+ const revision = (response as { revision?: unknown }).revision
584
+ return typeof revision === 'string' && revision ? revision : null
585
+ }
586
+
587
+ function topLevelSettingsPatch(
588
+ current: Record<string, unknown>,
589
+ keys: Iterable<string>,
590
+ ): Record<string, unknown> {
591
+ return Object.fromEntries(
592
+ [...keys].map(key => [
593
+ key,
594
+ hasOwnSetting(current, key) ? current[key] : null,
595
+ ]),
596
+ )
597
+ }
598
+
599
+ function changedTopLevelSettingKeys(
600
+ current: Record<string, unknown>,
601
+ saved: Record<string, unknown>,
602
+ ): Set<string> {
603
+ return new Set(
604
+ [...new Set([...Object.keys(current), ...Object.keys(saved)])].filter(
605
+ key => JSON.stringify(current[key]) !== JSON.stringify(saved[key]),
606
+ ),
607
+ )
608
+ }
609
+
610
+ function platformOwnedSettingsPatch(settings: Record<string, unknown>): Record<string, unknown> {
611
+ return {
612
+ [PLATFORM_OWNED_SETTINGS_KEY]: hasOwnSetting(settings, PLATFORM_OWNED_SETTINGS_KEY)
613
+ ? settings[PLATFORM_OWNED_SETTINGS_KEY]
614
+ : null,
615
+ }
616
+ }
617
+
568
618
  /** Load, edit, and persist plugin settings from platform config or the plugin-local settings router. */
569
619
  export function usePluginSettings<TSettings = Record<string, unknown>>(
570
620
  options: UsePluginSettingsOptions | string = {},
@@ -575,8 +625,9 @@ export function usePluginSettings<TSettings = Record<string, unknown>>(
575
625
  const platformApi = useApi({ baseUrl: resolvedOptions.platformApiBaseUrl ?? injectedContext?.platformApiUrl })
576
626
  const pluginApi = useApi({ baseUrl: resolvedOptions.apiBaseUrl ?? injectedContext?.plugin?.api_prefix })
577
627
 
578
- const values = shallowRef<PluginSettingsValues<TSettings>>({} as PluginSettingsValues<TSettings>)
628
+ const values = ref({}) as Ref<PluginSettingsValues<TSettings>>
579
629
  const savedValues = shallowRef<Record<string, unknown>>({})
630
+ const revision = ref<string | null>(null)
580
631
  const request = useRequestSyncState('Plugin settings request failed.')
581
632
  const isLoading = ref(false)
582
633
  const isSaving = ref(false)
@@ -584,8 +635,17 @@ export function usePluginSettings<TSettings = Record<string, unknown>>(
584
635
  const resolvedPluginName = computed(() =>
585
636
  resolvedOptions.pluginName ?? plugin.value?.name ?? injectedContext?.plugin?.name ?? '',
586
637
  )
638
+ const usesPluginSettingsApi = computed(() => {
639
+ const currentPlugin = injectedContext?.plugin ?? plugin.value
640
+ return currentPlugin?.settings_api === 'plugin'
641
+ && resolvedPluginName.value === currentPlugin.name
642
+ })
587
643
  const usesPlatformConfig = computed(() =>
588
- Boolean((isIntegrated.value || injectedContext?.isIntegrated) && resolvedPluginName.value),
644
+ Boolean(
645
+ (isIntegrated.value || injectedContext?.isIntegrated)
646
+ && resolvedPluginName.value
647
+ && !usesPluginSettingsApi.value,
648
+ ),
589
649
  )
590
650
  const settings = computed(() => values.value)
591
651
  const isDirty = computed(() => JSON.stringify(values.value) !== JSON.stringify(savedValues.value))
@@ -600,10 +660,11 @@ export function usePluginSettings<TSettings = Record<string, unknown>>(
600
660
  values.value = cloneRecord(nextValues) as PluginSettingsValues<TSettings>
601
661
  }
602
662
 
603
- function applyLoaded(nextValues: Record<string, unknown>): void {
663
+ function applyLoaded(nextValues: Record<string, unknown>, nextRevision: string | null): void {
604
664
  const cloned = cloneRecord(nextValues)
605
665
  values.value = cloned as PluginSettingsValues<TSettings>
606
666
  savedValues.value = cloneRecord(cloned)
667
+ revision.value = nextRevision
607
668
  }
608
669
 
609
670
  async function load(): Promise<void> {
@@ -620,7 +681,10 @@ export function usePluginSettings<TSettings = Record<string, unknown>>(
620
681
  () => pluginApi.get<{ settings: Record<string, unknown>; mode?: string }>('/settings'),
621
682
  { success: 'load', errorMessage: 'Failed to load plugin settings' },
622
683
  )
623
- applyLoaded(responseSettingsPayload(response))
684
+ applyLoaded(
685
+ responseSettingsPayload(response),
686
+ responseSettingsRevision(response),
687
+ )
624
688
  } catch {
625
689
  // Error state is handled by request.run().
626
690
  } finally {
@@ -636,19 +700,65 @@ export function usePluginSettings<TSettings = Record<string, unknown>>(
636
700
  isSaving.value = true
637
701
  try {
638
702
  const payload = cloneRecord(values.value)
639
- const response = usesPlatformConfig.value
640
- ? await request.run(
641
- () => platformApi.patch<{ plugin_name: string; config: Record<string, unknown> }>(
642
- `/plugins/${encodeURIComponent(resolvedPluginName.value)}/config`,
643
- { config: payload },
644
- ),
645
- { success: 'save', errorMessage: 'Failed to save plugin settings' },
703
+ let response: unknown
704
+ if (usesPlatformConfig.value) {
705
+ const dirtyKeys = changedTopLevelSettingKeys(payload, savedValues.value)
706
+ const platformOwnedChanged = dirtyKeys.has(PLATFORM_OWNED_SETTINGS_KEY)
707
+ const typedKeys = [...dirtyKeys].filter(
708
+ key => key !== PLATFORM_OWNED_SETTINGS_KEY,
646
709
  )
647
- : await request.run(
648
- () => pluginApi.put<{ settings: Record<string, unknown>; mode?: string }>('/settings', payload),
710
+ let nextRevision = revision.value
711
+
712
+ if (typedKeys.length) {
713
+ response = await request.run(
714
+ () => platformApi.patch<{
715
+ plugin_name: string
716
+ config: Record<string, unknown>
717
+ revision?: string
718
+ }>(
719
+ `/plugins/${encodeURIComponent(resolvedPluginName.value)}/config`,
720
+ { config: topLevelSettingsPatch(payload, typedKeys) },
721
+ ),
722
+ { success: 'save', errorMessage: 'Failed to save plugin settings' },
723
+ )
724
+ nextRevision = responseSettingsRevision(response) ?? nextRevision
725
+ }
726
+
727
+ if (platformOwnedChanged) {
728
+ response = await request.run(
729
+ () => platformApi.patch<{
730
+ plugin_name: string
731
+ config: Record<string, unknown>
732
+ revision?: string
733
+ }>(
734
+ `/plugins/${encodeURIComponent(resolvedPluginName.value)}/config`,
735
+ { config: platformOwnedSettingsPatch(payload) },
736
+ ),
737
+ { success: 'save', errorMessage: 'Failed to save plugin settings' },
738
+ )
739
+ nextRevision = responseSettingsRevision(response) ?? nextRevision
740
+ }
741
+
742
+ if (!response) return true
743
+ applyLoaded(responseSettingsPayload(response), nextRevision)
744
+ return true
745
+ } else {
746
+ const ifMatch = revision.value
747
+ ? { headers: { 'If-Match': `"${revision.value}"` } }
748
+ : undefined
749
+ response = await request.run(
750
+ () => pluginApi.put<{
751
+ settings: Record<string, unknown>
752
+ mode?: string
753
+ revision?: string
754
+ }>('/settings', payload, ifMatch),
649
755
  { success: 'save', errorMessage: 'Failed to save plugin settings' },
650
756
  )
651
- applyLoaded(responseSettingsPayload(response))
757
+ }
758
+ applyLoaded(
759
+ responseSettingsPayload(response),
760
+ responseSettingsRevision(response) ?? revision.value,
761
+ )
652
762
  return true
653
763
  } catch {
654
764
  return false
@@ -1,8 +1,57 @@
1
1
  import { ref, computed, onMounted, type Ref, type ComputedRef } from 'vue'
2
2
  import { useApi } from './useApi'
3
3
  import { usePlatformContext } from './usePlatformContext'
4
+ import { getInjectedPlatformContext } from './platformContextHelpers'
4
5
  import { useRequestSyncState } from './useRequestSyncState'
5
6
 
7
+ const PLATFORM_OWNED_SETTINGS_KEY = 'allowed_experiment_types'
8
+
9
+ function cloneConfig(config: Record<string, unknown>): Record<string, unknown> {
10
+ return JSON.parse(JSON.stringify(config)) as Record<string, unknown>
11
+ }
12
+
13
+ function hasOwnConfig(config: Record<string, unknown>, key: string): boolean {
14
+ return Object.prototype.hasOwnProperty.call(config, key)
15
+ }
16
+
17
+ function changedTopLevelKeys(
18
+ current: Record<string, unknown>,
19
+ saved: Record<string, unknown>,
20
+ ): Set<string> {
21
+ return new Set(
22
+ [...new Set([...Object.keys(current), ...Object.keys(saved)])].filter(
23
+ key => JSON.stringify(current[key]) !== JSON.stringify(saved[key]),
24
+ ),
25
+ )
26
+ }
27
+
28
+ function topLevelConfigPatch(
29
+ current: Record<string, unknown>,
30
+ keys: Iterable<string>,
31
+ ): Record<string, unknown> {
32
+ return Object.fromEntries(
33
+ [...keys].map(key => [
34
+ key,
35
+ hasOwnConfig(current, key) ? current[key] : null,
36
+ ]),
37
+ )
38
+ }
39
+
40
+ function platformOwnedConfigPatch(config: Record<string, unknown>): Record<string, unknown> {
41
+ return {
42
+ [PLATFORM_OWNED_SETTINGS_KEY]: hasOwnConfig(config, PLATFORM_OWNED_SETTINGS_KEY)
43
+ ? config[PLATFORM_OWNED_SETTINGS_KEY]
44
+ : null,
45
+ }
46
+ }
47
+
48
+ function responseConfigPayload(response: {
49
+ config?: Record<string, unknown>
50
+ settings?: Record<string, unknown>
51
+ }): Record<string, unknown> {
52
+ return response.config ?? response.settings ?? {}
53
+ }
54
+
6
55
  export interface UsePluginConfigReturn {
7
56
  /** Editable plugin configuration values. */
8
57
  config: Ref<Record<string, unknown>>
@@ -28,13 +77,25 @@ export interface UsePluginConfigReturn {
28
77
 
29
78
  /** Loads, saves, and tracks dirty state for a plugin's persistent configuration via the platform API. */
30
79
  export function usePluginConfig(pluginName?: string): UsePluginConfigReturn {
31
- const api = useApi()
80
+ const injectedContext = getInjectedPlatformContext()
32
81
  const { plugin } = usePlatformContext()
82
+ const platformApi = useApi()
83
+ const pluginApi = useApi({
84
+ baseUrl: injectedContext?.plugin?.api_prefix ?? plugin.value?.api_prefix,
85
+ })
33
86
 
34
- const resolvedName = computed(() => pluginName ?? plugin.value?.name ?? '')
87
+ const resolvedName = computed(
88
+ () => pluginName ?? plugin.value?.name ?? injectedContext?.plugin?.name ?? '',
89
+ )
90
+ const usesPluginSettingsApi = computed(() => {
91
+ const currentPlugin = injectedContext?.plugin ?? plugin.value
92
+ return currentPlugin?.settings_api === 'plugin'
93
+ && resolvedName.value === currentPlugin.name
94
+ })
35
95
 
36
96
  const config = ref<Record<string, unknown>>({})
37
97
  const savedConfig = ref<Record<string, unknown>>({})
98
+ const revision = ref<string | null>(null)
38
99
  const request = useRequestSyncState('Plugin config request failed.')
39
100
  const isLoading = ref(false)
40
101
  const isSaving = ref(false)
@@ -52,14 +113,29 @@ export function usePluginConfig(pluginName?: string): UsePluginConfigReturn {
52
113
 
53
114
  isLoading.value = true
54
115
  try {
55
- const response = await request.run(
56
- () => api.get<{ plugin_name: string; config: Record<string, unknown> }>(
57
- `/plugins/${encodeURIComponent(name)}/config`,
58
- ),
59
- { success: 'load', errorMessage: 'Failed to load plugin config' },
60
- )
61
- config.value = { ...response.config }
62
- savedConfig.value = { ...response.config }
116
+ const response = usesPluginSettingsApi.value
117
+ ? await request.run(
118
+ () => pluginApi.get<{
119
+ settings: Record<string, unknown>
120
+ mode?: string
121
+ revision?: string
122
+ }>('/settings'),
123
+ { success: 'load', errorMessage: 'Failed to load plugin config' },
124
+ )
125
+ : await request.run(
126
+ () => platformApi.get<{
127
+ plugin_name: string
128
+ config: Record<string, unknown>
129
+ revision?: string
130
+ }>(
131
+ `/plugins/${encodeURIComponent(name)}/config`,
132
+ ),
133
+ { success: 'load', errorMessage: 'Failed to load plugin config' },
134
+ )
135
+ const responseConfig = responseConfigPayload(response)
136
+ config.value = cloneConfig(responseConfig)
137
+ savedConfig.value = cloneConfig(responseConfig)
138
+ revision.value = response.revision ?? null
63
139
  } catch {
64
140
  // Error state is handled by request.run().
65
141
  } finally {
@@ -73,15 +149,75 @@ export function usePluginConfig(pluginName?: string): UsePluginConfigReturn {
73
149
 
74
150
  isSaving.value = true
75
151
  try {
76
- const response = await request.run(
77
- () => api.patch<{ plugin_name: string; config: Record<string, unknown> }>(
78
- `/plugins/${encodeURIComponent(name)}/config`,
79
- { config: config.value },
80
- ),
81
- { success: 'save', errorMessage: 'Failed to save plugin config' },
82
- )
83
- config.value = { ...response.config }
84
- savedConfig.value = { ...response.config }
152
+ const payload = cloneConfig(config.value)
153
+ let response: {
154
+ plugin_name?: string
155
+ config?: Record<string, unknown>
156
+ settings?: Record<string, unknown>
157
+ mode?: string
158
+ revision?: string
159
+ } | undefined
160
+ let nextRevision = revision.value
161
+
162
+ if (usesPluginSettingsApi.value) {
163
+ response = await request.run(
164
+ () => pluginApi.put<{
165
+ settings: Record<string, unknown>
166
+ mode?: string
167
+ revision?: string
168
+ }>(
169
+ '/settings',
170
+ payload,
171
+ revision.value
172
+ ? { headers: { 'If-Match': `"${revision.value}"` } }
173
+ : undefined,
174
+ ),
175
+ { success: 'save', errorMessage: 'Failed to save plugin config' },
176
+ )
177
+ nextRevision = response.revision ?? nextRevision
178
+ } else {
179
+ const dirtyKeys = changedTopLevelKeys(payload, savedConfig.value)
180
+ const platformOwnedChanged = dirtyKeys.has(PLATFORM_OWNED_SETTINGS_KEY)
181
+ const typedKeys = [...dirtyKeys].filter(
182
+ key => key !== PLATFORM_OWNED_SETTINGS_KEY,
183
+ )
184
+
185
+ if (typedKeys.length) {
186
+ response = await request.run(
187
+ () => platformApi.patch<{
188
+ plugin_name: string
189
+ config: Record<string, unknown>
190
+ revision?: string
191
+ }>(
192
+ `/plugins/${encodeURIComponent(name)}/config`,
193
+ { config: topLevelConfigPatch(payload, typedKeys) },
194
+ ),
195
+ { success: 'save', errorMessage: 'Failed to save plugin config' },
196
+ )
197
+ nextRevision = response.revision ?? nextRevision
198
+ }
199
+
200
+ if (platformOwnedChanged) {
201
+ response = await request.run(
202
+ () => platformApi.patch<{
203
+ plugin_name: string
204
+ config: Record<string, unknown>
205
+ revision?: string
206
+ }>(
207
+ `/plugins/${encodeURIComponent(name)}/config`,
208
+ { config: platformOwnedConfigPatch(payload) },
209
+ ),
210
+ { success: 'save', errorMessage: 'Failed to save plugin config' },
211
+ )
212
+ nextRevision = response.revision ?? nextRevision
213
+ }
214
+ }
215
+
216
+ if (!response) return true
217
+ const responseConfig = responseConfigPayload(response)
218
+ config.value = cloneConfig(responseConfig)
219
+ savedConfig.value = cloneConfig(responseConfig)
220
+ revision.value = response.revision ?? nextRevision
85
221
  return true
86
222
  } catch {
87
223
  return false
@@ -91,7 +227,7 @@ export function usePluginConfig(pluginName?: string): UsePluginConfigReturn {
91
227
  }
92
228
 
93
229
  function reset(): void {
94
- config.value = { ...savedConfig.value }
230
+ config.value = cloneConfig(savedConfig.value)
95
231
  request.clearError()
96
232
  }
97
233
 
@@ -0,0 +1,161 @@
1
+ /* SectionCard — the MINT 1.1 one-card system.
2
+ *
3
+ * One card per page region: 12px radius, one shadow token (--card-shadow),
4
+ * a single header strip, and hairline-separated regions inside — never a
5
+ * card nested in a card.
6
+ */
7
+
8
+ .mint-section-card {
9
+ display: flex;
10
+ flex-direction: column;
11
+ border-radius: 12px;
12
+ border: 1px solid var(--border-color);
13
+ background: var(--bg-secondary);
14
+ box-shadow: var(--card-shadow);
15
+ overflow: hidden;
16
+ transition: background-color var(--mint-transition), border-color var(--mint-transition);
17
+ }
18
+
19
+ /* Fill the height the parent offers: the body takes the slack and scrolls,
20
+ * so the header and footer stay pinned to the card's edges. */
21
+ .mint-section-card--fill {
22
+ flex: 1;
23
+ min-height: 0;
24
+ }
25
+
26
+ .mint-section-card--fill .mint-section-card__body {
27
+ flex: 1;
28
+ min-height: 0;
29
+ overflow: auto;
30
+ }
31
+
32
+ .mint-section-card__header {
33
+ display: flex;
34
+ align-items: center;
35
+ gap: 0.625rem;
36
+ padding: 0.6875rem 1rem;
37
+ border-bottom: 1px solid var(--border-color);
38
+ flex-shrink: 0;
39
+ }
40
+
41
+ .mint-section-card__header--divider-light {
42
+ border-bottom-color: var(--border-light);
43
+ }
44
+
45
+ .mint-section-card__icon-chip {
46
+ display: inline-flex;
47
+ align-items: center;
48
+ justify-content: center;
49
+ flex-shrink: 0;
50
+ width: 1.375rem;
51
+ height: 1.375rem;
52
+ border-radius: 6px;
53
+ }
54
+
55
+ .mint-section-card__icon {
56
+ width: 0.8125rem;
57
+ height: 0.8125rem;
58
+ }
59
+
60
+ .mint-section-card__icon-chip--primary {
61
+ background: var(--color-primary-soft);
62
+ color: var(--color-primary);
63
+ }
64
+
65
+ .mint-section-card__icon-chip--experiment {
66
+ background: var(--color-experiment-soft);
67
+ color: var(--color-experiment);
68
+ }
69
+
70
+ .mint-section-card__icon-chip--project {
71
+ background: var(--color-project-soft);
72
+ color: var(--color-project);
73
+ }
74
+
75
+ .mint-section-card__icon-chip--success {
76
+ background: var(--mint-success-bg);
77
+ color: var(--mint-success);
78
+ }
79
+
80
+ .mint-section-card__icon-chip--error {
81
+ background: var(--mint-error-bg);
82
+ color: var(--mint-error);
83
+ }
84
+
85
+ .mint-section-card__icon-chip--warning {
86
+ background: var(--mint-warning-bg);
87
+ color: var(--mint-warning);
88
+ }
89
+
90
+ .mint-section-card__icon-chip--info {
91
+ background: var(--mint-info-bg);
92
+ color: var(--mint-info);
93
+ }
94
+
95
+ .mint-section-card__title {
96
+ margin: 0;
97
+ font-size: 0.8125rem;
98
+ font-weight: 600;
99
+ color: var(--text-primary);
100
+ }
101
+
102
+ .mint-section-card__title--uppercase {
103
+ font-size: 0.75rem;
104
+ letter-spacing: 0.08em;
105
+ text-transform: uppercase;
106
+ color: var(--text-secondary);
107
+ }
108
+
109
+ .mint-section-card__count {
110
+ font-family: 'Fira Code', monospace;
111
+ font-size: 0.6875rem;
112
+ color: var(--text-muted);
113
+ }
114
+
115
+ .mint-section-card__spacer {
116
+ flex: 1;
117
+ }
118
+
119
+ .mint-section-card__toolbar {
120
+ flex-shrink: 0;
121
+ }
122
+
123
+ .mint-section-card__footer {
124
+ display: flex;
125
+ align-items: center;
126
+ justify-content: space-between;
127
+ padding: 0.5rem 1rem;
128
+ border-top: 1px solid var(--border-color);
129
+ background: var(--bg-primary);
130
+ flex-shrink: 0;
131
+ }
132
+
133
+ /* Hairline row helpers — regions inside a card separate with 1px lines,
134
+ * rendered by a 1px gap over a hairline-colored track. */
135
+ .mint-section-card__rows {
136
+ display: flex;
137
+ flex-direction: column;
138
+ gap: 1px;
139
+ background: var(--border-light);
140
+ }
141
+
142
+ .mint-section-card__row {
143
+ display: flex;
144
+ align-items: center;
145
+ gap: 0.625rem;
146
+ padding: 0.5625rem 1rem;
147
+ background: var(--bg-secondary);
148
+ transition: background-color var(--mint-transition);
149
+ }
150
+
151
+ a.mint-section-card__row,
152
+ button.mint-section-card__row {
153
+ cursor: pointer;
154
+ color: inherit;
155
+ text-decoration: none;
156
+ }
157
+
158
+ a.mint-section-card__row:hover,
159
+ button.mint-section-card__row:hover {
160
+ background: var(--bg-primary);
161
+ }
@@ -28,6 +28,7 @@
28
28
  @import './components/plate-map-editor.css';
29
29
  @import './components/radio-group.css';
30
30
  @import './components/sample-legend.css';
31
+ @import './components/section-card.css';
31
32
  @import './components/select.css';
32
33
  @import './components/skeleton.css';
33
34
  @import './components/slider.css';
@@ -36,6 +36,18 @@
36
36
  --color-cta-hover: #EA580C;
37
37
  --color-purple: #8B5CF6;
38
38
 
39
+ /* Entity accent colors — experiments (cyan) and projects (sky), from the
40
+ MINT 1.1 card system. -soft backs icon chips and pills, -border outlines
41
+ pills that sit on a card surface. */
42
+ --color-experiment: #06B6D4;
43
+ --color-experiment-hover: #0891B2;
44
+ --color-experiment-soft: rgba(6, 182, 212, 0.12);
45
+ --color-experiment-border: rgba(6, 182, 212, 0.3);
46
+ --color-project: #0EA5E9;
47
+ --color-project-hover: #0284C7;
48
+ --color-project-soft: rgba(14, 165, 233, 0.12);
49
+ --color-project-border: rgba(14, 165, 233, 0.32);
50
+
39
51
  /* MINT brand mark — used for browser theme-color, marketing chrome, app icon background */
40
52
  --mint-brand: #7BD0B5;
41
53
  --mint-brand-hover: #5FB89A;
@@ -51,6 +63,8 @@
51
63
  --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
52
64
  --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
53
65
  --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
66
+ /* The MINT 1.1 card system uses exactly one shadow for every card. */
67
+ --card-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.04), 0 1px 3px 0 rgb(0 0 0 / 0.06);
54
68
 
55
69
  /* Border radius */
56
70
  --radius-sm: 0.25rem;
@@ -148,6 +162,17 @@ html.dark {
148
162
  --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.3), 0 1px 2px -1px rgb(0 0 0 / 0.2);
149
163
  --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.3), 0 2px 4px -2px rgb(0 0 0 / 0.2);
150
164
  --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.3), 0 4px 6px -4px rgb(0 0 0 / 0.2);
165
+ --card-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.24), 0 1px 3px 0 rgb(0 0 0 / 0.32);
166
+
167
+ /* Entity accent colors - Dark mode */
168
+ --color-experiment: #22D3EE;
169
+ --color-experiment-hover: #67E8F9;
170
+ --color-experiment-soft: rgba(34, 211, 238, 0.16);
171
+ --color-experiment-border: rgba(34, 211, 238, 0.36);
172
+ --color-project: #38BDF8;
173
+ --color-project-hover: #7DD3FC;
174
+ --color-project-soft: rgba(56, 189, 248, 0.16);
175
+ --color-project-border: rgba(56, 189, 248, 0.38);
151
176
 
152
177
  /* Semantic colors - Dark mode */
153
178
  --mint-success-bg: rgba(16, 185, 129, 0.15);
@@ -18,6 +18,10 @@ const mintPreset = {
18
18
  'warning-hover': 'var(--mint-warning-hover)',
19
19
  brand: 'var(--mint-brand)',
20
20
  'brand-hover': 'var(--mint-brand-hover)',
21
+ experiment: 'var(--color-experiment)',
22
+ 'experiment-hover': 'var(--color-experiment-hover)',
23
+ project: 'var(--color-project)',
24
+ 'project-hover': 'var(--color-project-hover)',
21
25
  },
22
26
  bg: {
23
27
  primary: 'var(--mint-bg-primary)',
@@ -50,6 +54,7 @@ const mintPreset = {
50
54
  mint: '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)',
51
55
  'mint-md': '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
52
56
  'mint-lg': '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)',
57
+ 'mint-card': 'var(--card-shadow)',
53
58
  },
54
59
  transitionDuration: {
55
60
  mint: '150ms',