@morscherlab/mint-sdk 1.1.7 → 1.1.9

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 (90) hide show
  1. package/dist/__tests__/components/RetiredStyles.test.d.ts +1 -0
  2. package/dist/__tests__/composables/autoGroup/groupByOrder.test.d.ts +1 -0
  3. package/dist/__tests__/composables/useSampleGroups.tree.test.d.ts +1 -0
  4. package/dist/components/AppTopBar.vue.d.ts +6 -11
  5. package/dist/components/AutoGroupModal.adapter.d.ts +10 -6
  6. package/dist/components/PluginIcon.vue.d.ts +2 -2
  7. package/dist/components/PluginWorkspaceView.props.d.ts +5 -7
  8. package/dist/components/PluginWorkspaceView.vue.d.ts +0 -3
  9. package/dist/components/SampleSelector.groups.d.ts +7 -7
  10. package/dist/components/SampleSelector.selection.d.ts +6 -12
  11. package/dist/components/SampleSelector.tree.d.ts +39 -0
  12. package/dist/components/SampleSelectorGroupNode.vue.d.ts +9 -0
  13. package/dist/components/SmartGroup.types.d.ts +5 -0
  14. package/dist/components/SmartGroupFieldRecipe.groups.d.ts +3 -2
  15. package/dist/components/SmartGroupFieldRecipe.vue.d.ts +2 -0
  16. package/dist/components/index.js +1 -1
  17. package/dist/{components-CDomhGSj.js → components-BbtUpCTv.js} +913 -943
  18. package/dist/components-BbtUpCTv.js.map +1 -0
  19. package/dist/composables/index.d.ts +1 -1
  20. package/dist/composables/index.js +1 -1
  21. package/dist/composables/useAutoGroup.d.ts +3 -0
  22. package/dist/composables/usePlatformContext.d.ts +3 -0
  23. package/dist/composables/useSampleGroups.d.ts +25 -0
  24. package/dist/index.js +2 -2
  25. package/dist/install.js +1 -1
  26. package/dist/styles.css +130 -557
  27. package/dist/types/platform.d.ts +1 -0
  28. package/dist/{useProtocolTemplates-BMt0LlXf.js → useProtocolTemplates-BWfNaynA.js} +199 -24
  29. package/dist/useProtocolTemplates-BWfNaynA.js.map +1 -0
  30. package/dist/utils/pluginIcon.d.ts +1 -1
  31. package/dist/utils/sampleGroupPath.d.ts +6 -0
  32. package/package.json +1 -1
  33. package/src/__tests__/components/AppAvatarMenu.test.ts +11 -0
  34. package/src/__tests__/components/AppTopBar.test.ts +84 -10
  35. package/src/__tests__/components/AppTopBarPageSelector.test.ts +1 -1
  36. package/src/__tests__/components/AutoGroupModal.adapter.test.ts +16 -6
  37. package/src/__tests__/components/PluginIcon.test.ts +19 -0
  38. package/src/__tests__/components/PluginWorkspaceView.test.ts +8 -0
  39. package/src/__tests__/components/RetiredStyles.test.ts +82 -0
  40. package/src/__tests__/components/SampleSelector.drag.test.ts +26 -0
  41. package/src/__tests__/components/SampleSelector.groups.test.ts +19 -6
  42. package/src/__tests__/components/SampleSelector.selection.test.ts +9 -23
  43. package/src/__tests__/components/SmartGroupFieldRecipe.groups.test.ts +5 -0
  44. package/src/__tests__/composables/autoGroup/compose.test.ts +9 -10
  45. package/src/__tests__/composables/autoGroup/groupByOrder.test.ts +249 -0
  46. package/src/__tests__/composables/autoGroup/integration.test.ts +3 -7
  47. package/src/__tests__/composables/useSampleGroups.tree.test.ts +145 -0
  48. package/src/components/AppTopBar.story.vue +9 -7
  49. package/src/components/AppTopBar.vue +79 -72
  50. package/src/components/AutoGroupModal.adapter.ts +17 -12
  51. package/src/components/AutoGroupModal.story.vue +89 -0
  52. package/src/components/AutoGroupModal.vue +16 -8
  53. package/src/components/PluginIcon.story.vue +11 -9
  54. package/src/components/PluginIcon.vue +3 -3
  55. package/src/components/PluginWorkspaceView.props.ts +5 -7
  56. package/src/components/PluginWorkspaceView.vue +0 -4
  57. package/src/components/SampleSelector.drag.ts +5 -5
  58. package/src/components/SampleSelector.groups.ts +15 -15
  59. package/src/components/SampleSelector.selection.ts +7 -46
  60. package/src/components/SampleSelector.story.vue +37 -0
  61. package/src/components/SampleSelector.tree.ts +48 -0
  62. package/src/components/SampleSelector.vue +52 -320
  63. package/src/components/SampleSelectorGroupNode.vue +197 -0
  64. package/src/components/SmartGroup.types.ts +5 -0
  65. package/src/components/SmartGroupFieldRecipe.groups.ts +6 -2
  66. package/src/components/SmartGroupFieldRecipe.vue +76 -4
  67. package/src/components/internal/AppTopBarPageSelectorInternal.vue +2 -2
  68. package/src/composables/autoGroup/compose.ts +31 -10
  69. package/src/composables/index.ts +1 -0
  70. package/src/composables/useAutoGroup.ts +108 -5
  71. package/src/composables/useSampleGroups.ts +118 -33
  72. package/src/styles/components/app-avatar-menu.css +2 -1
  73. package/src/styles/components/app-plugin-switcher.css +0 -13
  74. package/src/styles/components/app-top-bar.css +13 -54
  75. package/src/styles/components/dose-calculator.css +0 -4
  76. package/src/styles/components/experiment-popover.css +2 -104
  77. package/src/styles/components/experiment-selector-modal.css +0 -7
  78. package/src/styles/components/plugin-icon.css +3 -0
  79. package/src/styles/components/protocol-step-editor.css +0 -6
  80. package/src/styles/components/reagent-editor.css +0 -5
  81. package/src/styles/components/sample-hierarchy-tree.css +0 -4
  82. package/src/styles/components/sample-selector.css +0 -96
  83. package/src/styles/components/schedule-calendar.css +0 -6
  84. package/src/styles/components/smart-group.css +22 -0
  85. package/src/styles/components/well-plate.css +1 -27
  86. package/src/types/platform.ts +1 -0
  87. package/src/utils/pluginIcon.ts +5 -2
  88. package/src/utils/sampleGroupPath.ts +19 -0
  89. package/dist/components-CDomhGSj.js.map +0 -1
  90. package/dist/useProtocolTemplates-BMt0LlXf.js.map +0 -1
@@ -52,7 +52,7 @@ interface Props {
52
52
  /** Active id for the preferred centered pill navigation. */
53
53
  currentPillId?: string
54
54
 
55
- /** Account dropdown entries. Takes precedence over the classic profile button. */
55
+ /** Account dropdown entries. */
56
56
  accountMenu?: AccountMenuItem[]
57
57
  /** Show the notifications icon button. */
58
58
  showNotifications?: boolean
@@ -69,17 +69,15 @@ interface Props {
69
69
  showStandaloneLabel?: boolean
70
70
  /** Custom standalone badge label. */
71
71
  standaloneLabel?: string
72
- /** Show the classic admin shortcut. */
72
+ /** Show the admin shortcut. */
73
73
  showAdmin?: boolean
74
- /** Route used by the classic admin shortcut. */
74
+ /** Route used by the admin shortcut. */
75
75
  adminPath?: string
76
- /** Show the classic profile button when accountMenu is not provided. */
77
- showProfile?: boolean
78
- /** Classic profile display name. */
76
+ /** Account display name. */
79
77
  userName?: string
80
- /** Explicit classic profile initial. */
78
+ /** Explicit account avatar initial. */
81
79
  userInitial?: string
82
- /** Classic profile email, used by avatar/account layouts. */
80
+ /** Account email shown in the avatar menu. */
83
81
  userEmail?: string
84
82
  }
85
83
 
@@ -93,7 +91,6 @@ const props = withDefaults(defineProps<Props>(), {
93
91
  standaloneLabel: 'Standalone',
94
92
  showAdmin: false,
95
93
  adminPath: '/admin',
96
- showProfile: false,
97
94
  showNotifications: false,
98
95
  hasNotificationDot: false,
99
96
  })
@@ -103,7 +100,6 @@ const ExperimentPopover = defineAsyncComponent(() => import('./ExperimentPopover
103
100
  const ExperimentSelectorModal = defineAsyncComponent(() => import('./ExperimentSelectorModal.vue'))
104
101
 
105
102
  const emit = defineEmits<{
106
- 'profile-click': []
107
103
  'admin-click': []
108
104
  'page-selector-select': [page: PageSelectorItem]
109
105
  'pill-select': [item: PillNavItem]
@@ -120,6 +116,7 @@ const settingsOpen = ref(false)
120
116
  const { isIntegrated, plugin } = usePlatformContext()
121
117
  const isStandalone = computed(() => !isIntegrated.value)
122
118
  const appExperiment = inject(APP_EXPERIMENT_KEY, null)
119
+ const canBindExperiment = computed(() => plugin.value?.plugin_type !== 'static')
123
120
 
124
121
  const pluginIcon = computed(() => {
125
122
  const currentPlugin = plugin.value
@@ -128,12 +125,6 @@ const pluginIcon = computed(() => {
128
125
  : null
129
126
  })
130
127
 
131
- const profileInitial = computed(() => {
132
- if (props.userInitial) return props.userInitial
133
- if (props.userName) return props.userName.charAt(0).toUpperCase()
134
- return 'U'
135
- })
136
-
137
128
  const hasPlatformPageSelector = computed(() =>
138
129
  props.pageSelector === undefined && (plugin.value?.nav_items?.length ?? 0) > 1,
139
130
  )
@@ -201,69 +192,99 @@ const effectiveCurrentPageSelectorId = computed(() =>
201
192
  :href="homePath"
202
193
  class="mint-topbar-home-link"
203
194
  >
204
- <slot name="icon">
195
+ <span v-if="isIntegrated || $slots.icon || $slots.logo || pluginIcon || showLogo" class="mint-topbar__identity">
205
196
  <PluginIcon
206
- v-if="pluginIcon"
197
+ v-if="isIntegrated"
207
198
  class="mint-topbar__plugin-icon"
208
- :icon="pluginIcon.icon"
209
- :tone="pluginIcon.color"
210
- size="md"
199
+ :icon="plugin?.icon"
200
+ :tone="plugin?.color"
201
+ size="topbar"
211
202
  variant="solid"
212
203
  />
213
- <slot v-else name="logo">
214
- <div v-if="showLogo" class="mint-topbar__logo">
215
- <div class="mint-topbar__logo-icon">
216
- <span class="mint-topbar__logo-text">M</span>
204
+ <slot v-else name="icon">
205
+ <PluginIcon
206
+ v-if="pluginIcon"
207
+ class="mint-topbar__plugin-icon"
208
+ :icon="pluginIcon.icon"
209
+ :tone="pluginIcon.color"
210
+ size="topbar"
211
+ variant="solid"
212
+ />
213
+ <slot v-else name="logo">
214
+ <div v-if="showLogo" class="mint-topbar__logo">
215
+ <div class="mint-topbar__logo-icon">
216
+ <span class="mint-topbar__logo-text">M</span>
217
+ </div>
217
218
  </div>
218
- </div>
219
+ </slot>
219
220
  </slot>
220
- </slot>
221
+ </span>
221
222
  </a>
222
223
  <router-link
223
224
  v-else-if="homePath"
224
225
  :to="homePath"
225
226
  class="mint-topbar-home-link"
226
227
  >
227
- <slot name="icon">
228
+ <span v-if="isIntegrated || $slots.icon || $slots.logo || pluginIcon || showLogo" class="mint-topbar__identity">
228
229
  <PluginIcon
229
- v-if="pluginIcon"
230
+ v-if="isIntegrated"
230
231
  class="mint-topbar__plugin-icon"
231
- :icon="pluginIcon.icon"
232
- :tone="pluginIcon.color"
233
- size="md"
232
+ :icon="plugin?.icon"
233
+ :tone="plugin?.color"
234
+ size="topbar"
234
235
  variant="solid"
235
236
  />
236
- <slot v-else name="logo">
237
- <div v-if="showLogo" class="mint-topbar__logo">
238
- <div class="mint-topbar__logo-icon">
239
- <span class="mint-topbar__logo-text">M</span>
237
+ <slot v-else name="icon">
238
+ <PluginIcon
239
+ v-if="pluginIcon"
240
+ class="mint-topbar__plugin-icon"
241
+ :icon="pluginIcon.icon"
242
+ :tone="pluginIcon.color"
243
+ size="topbar"
244
+ variant="solid"
245
+ />
246
+ <slot v-else name="logo">
247
+ <div v-if="showLogo" class="mint-topbar__logo">
248
+ <div class="mint-topbar__logo-icon">
249
+ <span class="mint-topbar__logo-text">M</span>
250
+ </div>
240
251
  </div>
241
- </div>
252
+ </slot>
242
253
  </slot>
243
- </slot>
254
+ </span>
244
255
  </router-link>
245
256
  <template v-else>
246
- <slot name="icon">
257
+ <span v-if="isIntegrated || $slots.icon || $slots.logo || pluginIcon || showLogo" class="mint-topbar__identity">
247
258
  <PluginIcon
248
- v-if="pluginIcon"
259
+ v-if="isIntegrated"
249
260
  class="mint-topbar__plugin-icon"
250
- :icon="pluginIcon.icon"
251
- :tone="pluginIcon.color"
252
- size="md"
261
+ :icon="plugin?.icon"
262
+ :tone="plugin?.color"
263
+ size="topbar"
253
264
  variant="solid"
254
265
  />
255
- <slot v-else name="logo">
256
- <div v-if="showLogo" class="mint-topbar__logo">
257
- <div class="mint-topbar__logo-icon">
258
- <span class="mint-topbar__logo-text">M</span>
266
+ <slot v-else name="icon">
267
+ <PluginIcon
268
+ v-if="pluginIcon"
269
+ class="mint-topbar__plugin-icon"
270
+ :icon="pluginIcon.icon"
271
+ :tone="pluginIcon.color"
272
+ size="topbar"
273
+ variant="solid"
274
+ />
275
+ <slot v-else name="logo">
276
+ <div v-if="showLogo" class="mint-topbar__logo">
277
+ <div class="mint-topbar__logo-icon">
278
+ <span class="mint-topbar__logo-text">M</span>
279
+ </div>
259
280
  </div>
260
- </div>
281
+ </slot>
261
282
  </slot>
262
- </slot>
283
+ </span>
263
284
  </template>
264
285
  </div>
265
286
 
266
- <!-- Left: page selector or plugin switcher (new) -->
287
+ <!-- Left: page selector or plugin switcher -->
267
288
  <AppPluginSwitcher
268
289
  v-if="hasPluginSwitcher && pluginSwitcher"
269
290
  :current="pluginSwitcher.current"
@@ -298,7 +319,7 @@ const effectiveCurrentPageSelectorId = computed(() =>
298
319
  <!-- Nav slot (inline, after brand/selector) -->
299
320
  <slot name="nav" />
300
321
 
301
- <!-- Center: pill nav (new) -->
322
+ <!-- Center: pill nav -->
302
323
  <div v-if="hasPillNav || $slots.center" class="mint-topbar__center">
303
324
  <slot name="center">
304
325
  <AppTopBarPillNavInternal
@@ -318,7 +339,7 @@ const effectiveCurrentPageSelectorId = computed(() =>
318
339
  </span>
319
340
 
320
341
  <ExperimentPopover
321
- v-if="appExperiment && !isStandalone"
342
+ v-if="appExperiment && !isStandalone && canBindExperiment"
322
343
  v-bind="appExperiment.popover.value"
323
344
  @select="appExperiment.openModal()"
324
345
  @save="appExperiment.handleSave()"
@@ -331,7 +352,7 @@ const effectiveCurrentPageSelectorId = computed(() =>
331
352
 
332
353
  <slot name="actions" />
333
354
 
334
- <!-- Notifications (new) -->
355
+ <!-- Notifications -->
335
356
  <button
336
357
  v-if="showNotifications"
337
358
  type="button"
@@ -346,10 +367,10 @@ const effectiveCurrentPageSelectorId = computed(() =>
346
367
  <span v-if="hasNotificationDot" class="mint-topbar__icon-btn-dot" aria-hidden="true" />
347
368
  </button>
348
369
 
349
- <!-- Classic: theme toggle -->
370
+ <!-- Theme toggle -->
350
371
  <ThemeToggle v-if="showThemeToggle" size="sm" />
351
372
 
352
- <!-- Classic: settings gear -->
373
+ <!-- Settings -->
353
374
  <button
354
375
  v-if="showSettings"
355
376
  type="button"
@@ -362,7 +383,7 @@ const effectiveCurrentPageSelectorId = computed(() =>
362
383
  </svg>
363
384
  </button>
364
385
 
365
- <!-- Classic: admin link -->
386
+ <!-- Admin link -->
366
387
  <router-link
367
388
  v-if="showAdmin"
368
389
  :to="adminPath"
@@ -375,7 +396,7 @@ const effectiveCurrentPageSelectorId = computed(() =>
375
396
  </svg>
376
397
  </router-link>
377
398
 
378
- <!-- New: avatar menu (takes precedence over classic profile button) -->
399
+ <!-- Account menu -->
379
400
  <AppAvatarMenu
380
401
  v-if="hasAccountMenu || $slots['account-menu-items']"
381
402
  :user-name="userName"
@@ -392,20 +413,6 @@ const effectiveCurrentPageSelectorId = computed(() =>
392
413
  <slot name="account-menu-item-icon" v-bind="slotProps" />
393
414
  </template>
394
415
  </AppAvatarMenu>
395
-
396
- <!-- Classic: profile button (only when avatar menu not provided) -->
397
- <button
398
- v-else-if="showProfile"
399
- type="button"
400
- class="mint-topbar__profile-btn"
401
- aria-label="Edit profile"
402
- @click="emit('profile-click')"
403
- >
404
- <div class="mint-topbar__profile-avatar">
405
- {{ profileInitial }}
406
- </div>
407
- <span v-if="userName" class="mint-topbar__profile-name">{{ userName }}</span>
408
- </button>
409
416
  </div>
410
417
  </div>
411
418
  </header>
@@ -436,7 +443,7 @@ const effectiveCurrentPageSelectorId = computed(() =>
436
443
  </SettingsModal>
437
444
 
438
445
  <ExperimentSelectorModal
439
- v-if="appExperiment && !isStandalone && appExperiment.selectorModal.value.modelValue"
446
+ v-if="appExperiment && !isStandalone && canBindExperiment && appExperiment.selectorModal.value.modelValue"
440
447
  v-bind="appExperiment.selectorModal.value"
441
448
  @update:model-value="$event ? appExperiment.openModal() : appExperiment.closeModal()"
442
449
  />
@@ -28,23 +28,24 @@ export function roleLabel(role?: ColumnRole): string {
28
28
  /**
29
29
  * Columns worth offering as grouping toggles: factors and replicates, plus any
30
30
  * column already enabled in `groupBy` (so a manual group-by choice still shows).
31
- * Preserves the engine's column order.
31
+ * Preserves the engine's column order. `groupBy` is the schema's ORDERED layer
32
+ * list — entry N is hierarchy layer N of the composed group names.
32
33
  */
33
- export function candidateColumns(columns: ColumnInfo[], enabled: ReadonlySet<number>): ColumnInfo[] {
34
+ export function candidateColumns(columns: ColumnInfo[], groupBy: readonly number[]): ColumnInfo[] {
35
+ const enabled = new Set(groupBy)
34
36
  return columns.filter(c => c.role === 'factor' || c.role === 'replicate' || enabled.has(c.index))
35
37
  }
36
38
 
37
- /** The candidate column indices in display order, so card index → engine column. */
38
- export function candidateColumnIndices(columns: ColumnInfo[], enabled: ReadonlySet<number>): number[] {
39
- return candidateColumns(columns, enabled).map(c => c.index)
40
- }
41
-
42
- /** Map engine columns to Field Recipe cards + their on/off state (same order). */
39
+ /**
40
+ * Map engine columns to Field Recipe cards + their on/off state (same order).
41
+ * `layers` aligns with the cards: the column's 1-based position in the
42
+ * `groupBy` layer order, or `null` when the column is not grouped by.
43
+ */
43
44
  export function engineFieldsToCards(
44
45
  columns: ColumnInfo[],
45
- enabled: ReadonlySet<number>,
46
- ): { fields: SmartGroupField[]; enabled: boolean[] } {
47
- const candidates = candidateColumns(columns, enabled)
46
+ groupBy: readonly number[],
47
+ ): { fields: SmartGroupField[]; enabled: boolean[]; layers: (number | null)[] } {
48
+ const candidates = candidateColumns(columns, groupBy)
48
49
  return {
49
50
  fields: candidates.map(c => ({
50
51
  key: c.displayName ?? c.name,
@@ -52,7 +53,11 @@ export function engineFieldsToCards(
52
53
  vals: c.uniqueValues.join(', '),
53
54
  rep: c.role === 'replicate',
54
55
  })),
55
- enabled: candidates.map(c => enabled.has(c.index)),
56
+ enabled: candidates.map(c => groupBy.includes(c.index)),
57
+ layers: candidates.map(c => {
58
+ const pos = groupBy.indexOf(c.index)
59
+ return pos === -1 ? null : pos + 1
60
+ }),
56
61
  }
57
62
  }
58
63
 
@@ -85,6 +85,70 @@ const mockDesignData = {
85
85
  extraction_results: [],
86
86
  }
87
87
 
88
+ function wellId(index: number): string {
89
+ const row = String.fromCharCode(65 + Math.floor(index / 12))
90
+ return `${row}${(index % 12) + 1}`
91
+ }
92
+
93
+ const complexBiologicalConditions = ['Liver', 'Kidney'].flatMap(tissue =>
94
+ ['WT', 'KO'].flatMap(genotype =>
95
+ [
96
+ { treatment: 'Vehicle', dose: '0 uM' },
97
+ { treatment: 'Drug A', dose: '10 uM' },
98
+ { treatment: 'Drug A', dose: '50 uM' },
99
+ ].flatMap(arm =>
100
+ ['6 h', '24 h'].flatMap(timepoint =>
101
+ [1, 2].map(replicate => ({ tissue, genotype, ...arm, timepoint, replicate })),
102
+ ),
103
+ ),
104
+ ),
105
+ )
106
+
107
+ const complexBiologicalSamples = complexBiologicalConditions.map((condition, index) => {
108
+ const subject = `M${String(index + 1).padStart(3, '0')}`
109
+ const conditionIndex = Math.floor(index / 2)
110
+ const batch = `Batch ${String((conditionIndex % 3) + 1).padStart(2, '0')}`
111
+ const polarity = conditionIndex % 2 === 0 ? 'POS' : 'NEG'
112
+ const sampleName = [
113
+ condition.tissue,
114
+ condition.genotype,
115
+ condition.treatment.replaceAll(' ', ''),
116
+ condition.dose.replaceAll(' ', ''),
117
+ condition.timepoint.replaceAll(' ', ''),
118
+ subject,
119
+ `R${condition.replicate}`,
120
+ polarity,
121
+ ].join('_')
122
+
123
+ return {
124
+ plate_id: 'Plate 1',
125
+ well_id: wellId(index),
126
+ sample_name: sampleName,
127
+ sample_type: 'sample',
128
+ conditions: {
129
+ Tissue: condition.tissue,
130
+ Genotype: condition.genotype,
131
+ Treatment: condition.treatment,
132
+ Dose: condition.dose,
133
+ Timepoint: condition.timepoint,
134
+ Sex: conditionIndex % 2 === 0 ? 'Female' : 'Male',
135
+ Cohort: index < 24 ? 'Discovery' : 'Validation',
136
+ Batch: batch,
137
+ Polarity: polarity,
138
+ },
139
+ }
140
+ })
141
+
142
+ const complexDesignData = {
143
+ schema_version: '2.0',
144
+ study_name: 'Drug A multi-factor tissue response',
145
+ organism: 'Mus musculus',
146
+ platform: 'LC-MS',
147
+ samples: complexBiologicalSamples,
148
+ plates: [{ name: 'Plate 1', layout_type: '96-well' }],
149
+ extraction_results: [],
150
+ }
151
+
88
152
  // Hyphen-delimited samples
89
153
  const hyphenSamples = [
90
154
  'WT-Vehicle-1', 'WT-Vehicle-2', 'WT-Vehicle-3',
@@ -310,6 +374,31 @@ function initState(samples: string[] = proteomicsSamples) {
310
374
  </template>
311
375
  </Variant>
312
376
 
377
+ <Variant title="Complex Experiment Metadata" :init-state="() => ({ isOpen: true, lastResult: null as AutoGroupResult | null })">
378
+ <template #default="{ state }">
379
+ <div style="padding: 2rem;">
380
+ <button
381
+ type="button"
382
+ style="padding: 0.5rem 1rem; background: var(--bg-card, #fff); border: 1px solid var(--border-color, #e5e7eb); border-radius: 0.375rem; cursor: pointer; font-size: 0.875rem;"
383
+ @click="state.isOpen = true"
384
+ >
385
+ Re-open Modal
386
+ </button>
387
+ <div v-if="state.lastResult" style="margin-top: 1rem; font-size: 0.8125rem; color: var(--text-secondary, #64748b);">
388
+ <strong>Last result:</strong>
389
+ {{ state.lastResult.groups.length }} groups,
390
+ {{ state.lastResult.metadata.length }} samples,
391
+ {{ state.lastResult.excludedSamples.length }} excluded
392
+ </div>
393
+ <AutoGroupModal
394
+ v-model="state.isOpen"
395
+ :design-data="complexDesignData"
396
+ @apply="(r: AutoGroupResult) => { state.lastResult = r }"
397
+ />
398
+ </div>
399
+ </template>
400
+ </Variant>
401
+
313
402
  <Variant title="Empty (Manual Paste)">
314
403
  <div style="padding: 2rem;">
315
404
  <button
@@ -17,7 +17,6 @@ import { useAutoGroup, parseCSV } from '../composables/useAutoGroup'
17
17
  import { useApi } from '../composables/useApi'
18
18
  import { readFileAsText } from '../composables/useFileImport'
19
19
  import {
20
- candidateColumnIndices,
21
20
  engineFieldsToCards,
22
21
  engineGroupsToRail,
23
22
  engineToQcRoute,
@@ -83,10 +82,9 @@ function setQcRouting(value: QcRouting) {
83
82
  }
84
83
 
85
84
  // ── Auto mode (Field Recipe) ─────────────────────────────────────────
86
- const fieldCards = computed(() => engineFieldsToCards(autoGroup.fields.value, autoGroup.enabledFields.value))
87
- const cardColumnIndices = computed(() =>
88
- candidateColumnIndices(autoGroup.fields.value, autoGroup.enabledFields.value),
89
- )
85
+ // The schema's ordered groupBy IS the hierarchy layer order (layer 1 first).
86
+ const groupByOrder = computed(() => autoGroup.activeSchema.value?.groupBy ?? [])
87
+ const fieldCards = computed(() => engineFieldsToCards(autoGroup.fields.value, groupByOrder.value))
90
88
  const railGroups = computed(() =>
91
89
  engineGroupsToRail(autoGroup.result.value.experimentalGroups ?? autoGroup.groups.value),
92
90
  )
@@ -103,10 +101,18 @@ const qcChip = computed(() =>
103
101
  const fieldRecipeRoute = computed<QcRoute>(() => engineToQcRoute(qcRouting.value))
104
102
  const headerCount = computed(() => autoGroup.samples.value.length)
105
103
 
104
+ // The cards describe the batch, not one class, so edits fan out to every class
105
+ // exposing the field — otherwise a multi-class batch (e.g. a Liver class and a
106
+ // Kidney class) ends up with only half its groups reordered.
106
107
  function onToggleField(cardIndex: number) {
107
- const columnIndex = cardColumnIndices.value[cardIndex]
108
- if (columnIndex === undefined) return
109
- autoGroup.toggleGroupBy(autoGroup.activeClassKey.value, columnIndex)
108
+ const field = fieldCards.value.fields[cardIndex]
109
+ if (!field) return
110
+ autoGroup.toggleGroupByField(field.key)
111
+ }
112
+ function onMoveField(cardIndex: number, direction: 'up' | 'down') {
113
+ const field = fieldCards.value.fields[cardIndex]
114
+ if (!field) return
115
+ autoGroup.moveGroupByField(field.key, direction)
110
116
  }
111
117
  function onRouteChange(route: QcRoute) {
112
118
  setQcRouting(qcRouteToEngine(route))
@@ -247,6 +253,7 @@ watch(
247
253
  v-if="activeWorkflow === 'auto'"
248
254
  :fields="fieldCards.fields"
249
255
  :enabled="fieldCards.enabled"
256
+ :layers="fieldCards.layers"
250
257
  :groups="railGroups"
251
258
  :qc-chip="qcChip"
252
259
  :route="fieldRecipeRoute"
@@ -256,6 +263,7 @@ watch(
256
263
  :loading="experimentLoading"
257
264
  experiment-label=""
258
265
  @toggle="onToggleField"
266
+ @move="onMoveField"
259
267
  @update:route="onRouteChange"
260
268
  @csv-file="onCsvFile"
261
269
  @download-template="onDownloadTemplate"
@@ -26,12 +26,12 @@ const SAMPLE_STRUCTURED = JSON.stringify({
26
26
  })
27
27
  // Tiny 1x1 transparent PNG.
28
28
  const SAMPLE_PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII='
29
- const SAMPLE_HTTPS = 'https://upload.wikimedia.org/wikipedia/commons/thumb/4/4f/Vue.js_Logo_2.svg/64px-Vue.js_Logo_2.svg.png'
29
+ const SAMPLE_HTTPS_SVG = 'https://vite.dev/logo.svg'
30
30
 
31
31
  function initState() {
32
32
  return reactive({
33
33
  icon: SAMPLE_PATH,
34
- size: 'md' as 'sm' | 'md' | 'lg',
34
+ size: 'md' as 'sm' | 'md' | 'topbar' | 'lg',
35
35
  variant: 'solid' as 'solid' | 'tinted',
36
36
  tone: '',
37
37
  })
@@ -48,7 +48,7 @@ function initState() {
48
48
  </template>
49
49
  <template #controls="{ state }">
50
50
  <HstText v-model="state.icon" title="icon" />
51
- <HstSelect v-model="state.size" title="size" :options="['sm', 'md', 'lg']" />
51
+ <HstSelect v-model="state.size" title="size" :options="['sm', 'md', 'topbar', 'lg']" />
52
52
  <HstSelect v-model="state.variant" title="variant" :options="['solid', 'tinted']" />
53
53
  <HstText v-model="state.tone" title="tone (hex)" />
54
54
  </template>
@@ -58,6 +58,7 @@ function initState() {
58
58
  <div style="padding: 2rem; display: flex; gap: 1rem; align-items: center;">
59
59
  <PluginIcon :icon="SAMPLE_PATH" size="sm" />
60
60
  <PluginIcon :icon="SAMPLE_PATH" size="md" />
61
+ <PluginIcon :icon="SAMPLE_PATH" size="topbar" />
61
62
  <PluginIcon :icon="SAMPLE_PATH" size="lg" />
62
63
  </div>
63
64
  </Variant>
@@ -71,14 +72,14 @@ function initState() {
71
72
 
72
73
  <Variant title="Format showcase">
73
74
  <div style="padding: 2rem; display: flex; gap: 1rem; align-items: center;">
74
- <PluginIcon :icon="SAMPLE_STRUCTURED" size="lg" variant="tinted" />
75
- <PluginIcon :icon="SAMPLE_PATH" size="lg" variant="tinted" />
76
- <PluginIcon :icon="SAMPLE_PNG" size="lg" variant="tinted" />
77
- <PluginIcon :icon="SAMPLE_HTTPS" size="lg" variant="tinted" />
78
- <PluginIcon icon="" size="lg" variant="tinted" />
75
+ <PluginIcon :icon="SAMPLE_STRUCTURED" size="lg" variant="solid" />
76
+ <PluginIcon :icon="SAMPLE_PATH" size="lg" variant="solid" />
77
+ <PluginIcon :icon="SAMPLE_PNG" size="lg" variant="solid" />
78
+ <PluginIcon :icon="SAMPLE_HTTPS_SVG" size="lg" variant="solid" />
79
+ <PluginIcon icon="" size="lg" variant="solid" />
79
80
  </div>
80
81
  <p style="padding: 0 2rem; color: var(--text-muted); font-size: 0.875rem;">
81
- Structured vector · SVG path · data URL (PNG) · https URL · empty (fallback)
82
+ Structured gradient · SVG path · data URL (PNG) · HTTPS SVG · empty (fallback)
82
83
  </p>
83
84
  </Variant>
84
85
 
@@ -86,6 +87,7 @@ function initState() {
86
87
  <div style="padding: 2rem; display: flex; gap: 1rem; align-items: center;">
87
88
  <PluginIcon :icon="SAMPLE_STRUCTURED" size="sm" />
88
89
  <PluginIcon :icon="SAMPLE_STRUCTURED" size="md" />
90
+ <PluginIcon :icon="SAMPLE_STRUCTURED" size="topbar" />
89
91
  <PluginIcon :icon="SAMPLE_STRUCTURED" size="lg" />
90
92
  </div>
91
93
  </Variant>
@@ -3,10 +3,10 @@
3
3
  * - Structured MINT icon JSON ({"type":"mint-icon",...}) → controlled multi-color SVG
4
4
  * - SVG path data (e.g. "M13 10V3...") → inline <svg><path>
5
5
  * - Raster data URL (data:image/png|jpeg|jpg|gif|webp;...) → <img>
6
- * - https:// URL → <img> with no-referrer + lazy loading
6
+ * - https:// URL or root-relative static image/SVG asset → <img> with no-referrer + lazy loading
7
7
  * - Anything else → MINT canonical plugin placeholder path
8
8
  *
9
- * http:// URLs and data:image/svg+xml URLs are deliberately rejected
9
+ * http://, protocol-relative, and data:image/svg+xml URLs are deliberately rejected
10
10
  * (mixed-content + XSS, see spec 2026-05-04-plugin-icon-component-design.md). */
11
11
  import { computed } from 'vue'
12
12
  import { detectPluginIcon, type DetectedPluginIcon, type StructuredPluginIcon } from '../utils/pluginIcon'
@@ -15,7 +15,7 @@ defineOptions({ name: 'PluginIcon' })
15
15
 
16
16
  interface Props {
17
17
  icon?: string
18
- size?: 'sm' | 'md' | 'lg'
18
+ size?: 'sm' | 'md' | 'topbar' | 'lg'
19
19
  variant?: 'solid' | 'tinted'
20
20
  tone?: string
21
21
  }
@@ -59,17 +59,15 @@ export interface PluginWorkspaceViewProps {
59
59
  showNotifications?: boolean
60
60
  /** Draw a notification dot on the notifications icon. */
61
61
  hasNotificationDot?: boolean
62
- /** Show the classic admin shortcut. */
62
+ /** Show the admin shortcut. */
63
63
  showAdmin?: boolean
64
- /** Route used by the classic admin shortcut. */
64
+ /** Route used by the admin shortcut. */
65
65
  adminPath?: string
66
- /** Show the classic profile button when accountMenu is not provided. */
67
- showProfile?: boolean
68
- /** Classic profile display name. */
66
+ /** Account display name. */
69
67
  userName?: string
70
- /** Explicit classic profile initial. */
68
+ /** Explicit account avatar initial. */
71
69
  userInitial?: string
72
- /** Classic profile email. */
70
+ /** Account email shown in the avatar menu. */
73
71
  userEmail?: string
74
72
  /** Enable the built-in AppTopBar experiment popover and selector flow. The current experiment comes from the shared experiment store, not from a prop. Keep this comment on one line — the docs extractor drops multi-line JSDoc. */
75
73
  experimentShell?: boolean
@@ -56,7 +56,6 @@ const props = withDefaults(defineProps<PluginWorkspaceViewProps>(), {
56
56
  hasNotificationDot: false,
57
57
  showAdmin: false,
58
58
  adminPath: '/admin',
59
- showProfile: false,
60
59
  userName: undefined,
61
60
  userInitial: undefined,
62
61
  userEmail: undefined,
@@ -115,7 +114,6 @@ const emit = defineEmits<{
115
114
  'sign-out': []
116
115
  'notifications-click': []
117
116
  'settings-values-change': [data: Record<string, unknown>]
118
- 'profile-click': []
119
117
  'admin-click': []
120
118
  'experiment-select': [experiment: ExperimentSummary]
121
119
  'experiment-detach': []
@@ -292,7 +290,6 @@ function handleFormCancel(sectionId: string) {
292
290
  :has-notification-dot="hasNotificationDot"
293
291
  :show-admin="showAdmin"
294
292
  :admin-path="adminPath"
295
- :show-profile="showProfile"
296
293
  :user-name="userName"
297
294
  :user-initial="userInitial"
298
295
  :user-email="userEmail"
@@ -305,7 +302,6 @@ function handleFormCancel(sectionId: string) {
305
302
  @sign-out="emit('sign-out')"
306
303
  @notifications-click="emit('notifications-click')"
307
304
  @settings-values-change="emit('settings-values-change', $event)"
308
- @profile-click="emit('profile-click')"
309
305
  @admin-click="emit('admin-click')"
310
306
  >
311
307
  <template