@morscherlab/mint-sdk 1.1.8 → 1.1.10

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 (91) 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 +22 -7
  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-HXV-ZkBg.js} +939 -948
  18. package/dist/components-HXV-ZkBg.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-D3IZKLqr.js} +198 -24
  29. package/dist/useProtocolTemplates-D3IZKLqr.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 +47 -7
  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/SampleSelector.test.ts +35 -0
  44. package/src/__tests__/components/SmartGroupFieldRecipe.groups.test.ts +5 -0
  45. package/src/__tests__/composables/autoGroup/compose.test.ts +9 -10
  46. package/src/__tests__/composables/autoGroup/groupByOrder.test.ts +278 -0
  47. package/src/__tests__/composables/autoGroup/integration.test.ts +3 -7
  48. package/src/__tests__/composables/useSampleGroups.tree.test.ts +145 -0
  49. package/src/components/AppTopBar.story.vue +9 -7
  50. package/src/components/AppTopBar.vue +79 -72
  51. package/src/components/AutoGroupModal.adapter.ts +69 -13
  52. package/src/components/AutoGroupModal.story.vue +89 -0
  53. package/src/components/AutoGroupModal.vue +18 -8
  54. package/src/components/PluginIcon.story.vue +11 -9
  55. package/src/components/PluginIcon.vue +3 -3
  56. package/src/components/PluginWorkspaceView.props.ts +5 -7
  57. package/src/components/PluginWorkspaceView.vue +0 -4
  58. package/src/components/SampleSelector.drag.ts +5 -5
  59. package/src/components/SampleSelector.groups.ts +15 -15
  60. package/src/components/SampleSelector.selection.ts +7 -46
  61. package/src/components/SampleSelector.story.vue +37 -0
  62. package/src/components/SampleSelector.tree.ts +48 -0
  63. package/src/components/SampleSelector.vue +52 -320
  64. package/src/components/SampleSelectorGroupNode.vue +197 -0
  65. package/src/components/SmartGroup.types.ts +5 -0
  66. package/src/components/SmartGroupFieldRecipe.groups.ts +6 -2
  67. package/src/components/SmartGroupFieldRecipe.vue +76 -4
  68. package/src/components/internal/AppTopBarPageSelectorInternal.vue +2 -2
  69. package/src/composables/autoGroup/compose.ts +31 -10
  70. package/src/composables/index.ts +1 -0
  71. package/src/composables/useAutoGroup.ts +112 -5
  72. package/src/composables/useSampleGroups.ts +118 -33
  73. package/src/styles/components/app-avatar-menu.css +2 -1
  74. package/src/styles/components/app-plugin-switcher.css +0 -13
  75. package/src/styles/components/app-top-bar.css +13 -54
  76. package/src/styles/components/dose-calculator.css +0 -4
  77. package/src/styles/components/experiment-popover.css +2 -104
  78. package/src/styles/components/experiment-selector-modal.css +0 -7
  79. package/src/styles/components/plugin-icon.css +3 -0
  80. package/src/styles/components/protocol-step-editor.css +0 -6
  81. package/src/styles/components/reagent-editor.css +0 -5
  82. package/src/styles/components/sample-hierarchy-tree.css +0 -4
  83. package/src/styles/components/sample-selector.css +0 -96
  84. package/src/styles/components/schedule-calendar.css +0 -6
  85. package/src/styles/components/smart-group.css +22 -0
  86. package/src/styles/components/well-plate.css +1 -27
  87. package/src/types/platform.ts +1 -0
  88. package/src/utils/pluginIcon.ts +5 -2
  89. package/src/utils/sampleGroupPath.ts +19 -0
  90. package/dist/components-CDomhGSj.js.map +0 -1
  91. 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
  />
@@ -4,7 +4,7 @@
4
4
  * `AutoGroupResult`. Framework-free so the mapping is unit-testable in isolation
5
5
  * from `AutoGroupModal.vue`.
6
6
  */
7
- import type { AutoGroupResult, ColumnInfo, ColumnRole } from '../types/auto-group'
7
+ import type { AutoGroupResult, ClassSchema, ColumnInfo, ColumnRole } from '../types/auto-group'
8
8
  import type { SampleGroup } from '../types'
9
9
  import type { FieldRecipeGroup, QcRoute, SmartGroupField } from './SmartGroupFieldRecipe.groups'
10
10
  import type { ManualSample } from './SmartGroupManual.cohorts'
@@ -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,62 @@ 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
+ }),
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Build the batch-wide field recipe from every class schema. The active schema
66
+ * stays first for a stable card order; fields and enabled hierarchy layers that
67
+ * only exist in another token-length/class split are appended instead of being
68
+ * hidden from the controlled UI.
69
+ */
70
+ export function engineSchemasToCards(
71
+ schemas: Record<string, ClassSchema>,
72
+ activeClassKey: string,
73
+ ): { fields: SmartGroupField[]; enabled: boolean[]; layers: (number | null)[] } {
74
+ const entries = Object.entries(schemas)
75
+ const active = entries.find(([key]) => key === activeClassKey)
76
+ const ordered = active
77
+ ? [active, ...entries.filter(([key]) => key !== activeClassKey)]
78
+ : entries
79
+ const candidates = new Map<string, ColumnInfo>()
80
+ const enabledOrder: string[] = []
81
+ const enabled = new Set<string>()
82
+
83
+ for (const [, schema] of ordered) {
84
+ for (const index of schema.groupBy) {
85
+ const column = schema.columns.find(candidate => candidate.index === index)
86
+ if (!column) continue
87
+ const identity = column.displayName ?? column.name
88
+ if (!enabled.has(identity)) {
89
+ enabled.add(identity)
90
+ enabledOrder.push(identity)
91
+ }
92
+ }
93
+ for (const column of candidateColumns(schema.columns, schema.groupBy)) {
94
+ const identity = column.displayName ?? column.name
95
+ if (!candidates.has(identity)) candidates.set(identity, column)
96
+ }
97
+ }
98
+
99
+ const cards = [...candidates.entries()]
100
+ return {
101
+ fields: cards.map(([key, column]) => ({
102
+ key,
103
+ role: roleLabel(column.role),
104
+ vals: column.uniqueValues.join(', '),
105
+ rep: column.role === 'replicate',
106
+ })),
107
+ enabled: cards.map(([key]) => enabled.has(key)),
108
+ layers: cards.map(([key]) => {
109
+ const position = enabledOrder.indexOf(key)
110
+ return position === -1 ? null : position + 1
111
+ }),
56
112
  }
57
113
  }
58
114
 
@@ -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,9 +17,8 @@ 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
- engineFieldsToCards,
22
20
  engineGroupsToRail,
21
+ engineSchemasToCards,
23
22
  engineToQcRoute,
24
23
  manualSamplesToResult,
25
24
  qcRouteToEngine,
@@ -83,9 +82,10 @@ 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),
85
+ // The recipe represents the whole batch, including fields that only exist in
86
+ // another class or token-length schema split.
87
+ const fieldCards = computed(() =>
88
+ engineSchemasToCards(autoGroup.schemas.value, autoGroup.activeClassKey.value),
89
89
  )
90
90
  const railGroups = computed(() =>
91
91
  engineGroupsToRail(autoGroup.result.value.experimentalGroups ?? autoGroup.groups.value),
@@ -103,10 +103,18 @@ const qcChip = computed(() =>
103
103
  const fieldRecipeRoute = computed<QcRoute>(() => engineToQcRoute(qcRouting.value))
104
104
  const headerCount = computed(() => autoGroup.samples.value.length)
105
105
 
106
+ // The cards describe the batch, not one class, so edits fan out to every class
107
+ // exposing the field — otherwise a multi-class batch (e.g. a Liver class and a
108
+ // Kidney class) ends up with only half its groups reordered.
106
109
  function onToggleField(cardIndex: number) {
107
- const columnIndex = cardColumnIndices.value[cardIndex]
108
- if (columnIndex === undefined) return
109
- autoGroup.toggleGroupBy(autoGroup.activeClassKey.value, columnIndex)
110
+ const field = fieldCards.value.fields[cardIndex]
111
+ if (!field) return
112
+ autoGroup.toggleGroupByField(field.key)
113
+ }
114
+ function onMoveField(cardIndex: number, direction: 'up' | 'down') {
115
+ const field = fieldCards.value.fields[cardIndex]
116
+ if (!field) return
117
+ autoGroup.moveGroupByField(field.key, direction)
110
118
  }
111
119
  function onRouteChange(route: QcRoute) {
112
120
  setQcRouting(qcRouteToEngine(route))
@@ -247,6 +255,7 @@ watch(
247
255
  v-if="activeWorkflow === 'auto'"
248
256
  :fields="fieldCards.fields"
249
257
  :enabled="fieldCards.enabled"
258
+ :layers="fieldCards.layers"
250
259
  :groups="railGroups"
251
260
  :qc-chip="qcChip"
252
261
  :route="fieldRecipeRoute"
@@ -256,6 +265,7 @@ watch(
256
265
  :loading="experimentLoading"
257
266
  experiment-label=""
258
267
  @toggle="onToggleField"
268
+ @move="onMoveField"
259
269
  @update:route="onRouteChange"
260
270
  @csv-file="onCsvFile"
261
271
  @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