@morscherlab/mint-sdk 1.0.44 → 1.0.46

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 (53) hide show
  1. package/dist/__tests__/components/SmartGroupFieldRecipe.groups.test.d.ts +1 -0
  2. package/dist/__tests__/components/SmartGroupManual.cohorts.test.d.ts +1 -0
  3. package/dist/components/AutoGroupModal.adapter.d.ts +42 -0
  4. package/dist/components/AutoGroupModal.vue.d.ts +1 -105
  5. package/dist/components/DataFrame.vue.d.ts +15 -0
  6. package/dist/components/SmartGroup.types.d.ts +71 -0
  7. package/dist/components/SmartGroupFieldRecipe.groups.d.ts +53 -0
  8. package/dist/components/SmartGroupFieldRecipe.vue.d.ts +37 -0
  9. package/dist/components/SmartGroupManual.cohorts.d.ts +52 -0
  10. package/dist/components/SmartGroupManual.vue.d.ts +17 -0
  11. package/dist/components/SmartGroupModal.vue.d.ts +27 -0
  12. package/dist/components/index.d.ts +3 -0
  13. package/dist/components/index.js +2 -2
  14. package/dist/{components-BGVwavdd.js → components-C7UFkNIp.js} +3036 -2751
  15. package/dist/components-C7UFkNIp.js.map +1 -0
  16. package/dist/composables/index.js +2 -2
  17. package/dist/{composables-C_hPF0Gn.js → composables-CpBhNKHf.js} +7 -147
  18. package/dist/composables-CpBhNKHf.js.map +1 -0
  19. package/dist/index.js +4 -4
  20. package/dist/install.js +1 -1
  21. package/dist/styles.css +12824 -12195
  22. package/dist/{useProtocolTemplates-BbvlHoPD.js → useProtocolTemplates-C8-YlHj1.js} +206 -66
  23. package/dist/useProtocolTemplates-C8-YlHj1.js.map +1 -0
  24. package/package.json +1 -1
  25. package/src/__tests__/components/AutoGroupModal.adapter.test.ts +164 -0
  26. package/src/__tests__/components/DataFrame.test.ts +100 -0
  27. package/src/__tests__/components/SampleSelector.test.ts +176 -16
  28. package/src/__tests__/components/SmartGroupFieldRecipe.groups.test.ts +96 -0
  29. package/src/__tests__/components/SmartGroupManual.cohorts.test.ts +97 -0
  30. package/src/components/AutoGroupModal.adapter.ts +147 -0
  31. package/src/components/AutoGroupModal.vue +176 -1321
  32. package/src/components/DataFrame.vue +97 -5
  33. package/src/components/SampleSelector.vue +8 -23
  34. package/src/components/SmartGroup.types.ts +93 -0
  35. package/src/components/SmartGroupFieldRecipe.groups.ts +105 -0
  36. package/src/components/SmartGroupFieldRecipe.story.vue +58 -0
  37. package/src/components/SmartGroupFieldRecipe.vue +427 -0
  38. package/src/components/SmartGroupManual.cohorts.ts +112 -0
  39. package/src/components/SmartGroupManual.story.vue +55 -0
  40. package/src/components/SmartGroupManual.vue +398 -0
  41. package/src/components/SmartGroupModal.story.vue +61 -0
  42. package/src/components/SmartGroupModal.vue +61 -0
  43. package/src/components/index.ts +3 -0
  44. package/src/styles/components/dataframe.css +79 -0
  45. package/src/styles/components/sample-selector.css +1 -5
  46. package/src/styles/components/smart-group.css +708 -0
  47. package/src/styles/index.css +1 -1
  48. package/dist/components-BGVwavdd.js.map +0 -1
  49. package/dist/composables-C_hPF0Gn.js.map +0 -1
  50. package/dist/useProtocolTemplates-BbvlHoPD.js.map +0 -1
  51. package/src/__tests__/components/AutoGroupModal.preview.test.ts +0 -46
  52. package/src/styles/components/auto-group-modal.css +0 -1336
  53. /package/dist/__tests__/components/{AutoGroupModal.preview.test.d.ts → AutoGroupModal.adapter.test.d.ts} +0 -0
@@ -0,0 +1,398 @@
1
+ <script lang="ts">
2
+ /**
3
+ * SmartGroupManual — the "manual" mode of the Smart Group modal.
4
+ *
5
+ * Selection-first grouping for when sample names can't be parsed automatically:
6
+ * a wide searchable/filterable sample list on the left, a persistent Cohorts
7
+ * rail with a progress meter and group → subgroup → sub-subgroup tree on the
8
+ * right, and a contextual assignment bar that slides up only once samples are
9
+ * selected. Cohort derivation lives in the tested `./SmartGroupManual.cohorts`
10
+ * module.
11
+ *
12
+ * The reference-fixture defaults live in this module-scope `<script>` block
13
+ * because `defineProps` default factories are hoisted and cannot reference
14
+ * bindings declared inside `<script setup>`.
15
+ */
16
+ import type { ManualPaletteEntry, SmartGroupSeed } from './SmartGroup.types'
17
+
18
+ const DEFAULT_NAMES = [
19
+ 'Pt001_TumorA_d7_rep1',
20
+ 'Pt001_TumorA_d7_rep1_reseq2024',
21
+ 'Pt001_TumorA_d14_rep1',
22
+ 'Pt002_TumorB_d7_rep1',
23
+ 'Pt002_tumorb_d7_rep2',
24
+ 'Pt002_TumorB_d14_rep1',
25
+ 'Pt003_NoID_d7_rep1',
26
+ 'Pt003_TumorC_d14_rep1_redo',
27
+ 'Ctrl_pooled_old_naming',
28
+ 'control_2_(redo)',
29
+ 'Pt004_TumorA_d7_2024batch2',
30
+ 'Pt004_TumorA_d14',
31
+ 'QC_pool_01',
32
+ 'blank_run3',
33
+ ]
34
+ const DEFAULT_SEED: Record<string, SmartGroupSeed> = {
35
+ Pt001_TumorA_d7_rep1: { group: 'Responder', sub: 'Day 7', sub2: 'Batch 1', color: 'var(--grp-2)' },
36
+ Pt001_TumorA_d14_rep1: { group: 'Responder', sub: 'Day 14', color: 'var(--grp-2)' },
37
+ Pt002_TumorB_d7_rep1: { group: 'Non-responder', sub: 'Day 7', sub2: 'Batch 2', color: 'var(--grp-4)' },
38
+ }
39
+ const DEFAULT_PALETTE: ManualPaletteEntry[] = [
40
+ { c: 'var(--grp-1)', k: 'indigo' },
41
+ { c: 'var(--grp-2)', k: 'teal' },
42
+ { c: 'var(--grp-3)', k: 'amber' },
43
+ { c: 'var(--grp-4)', k: 'rose' },
44
+ { c: 'var(--grp-5)', k: 'violet' },
45
+ ]
46
+ </script>
47
+
48
+ <script setup lang="ts">
49
+ import { computed, h, ref, type FunctionalComponent } from 'vue'
50
+ import {
51
+ assignSamples,
52
+ buildCohortTree,
53
+ filterSamples,
54
+ type ManualFilter,
55
+ type ManualSample,
56
+ } from './SmartGroupManual.cohorts'
57
+ import BaseButton from './BaseButton.vue'
58
+ import type { ManualDonePayload, SmartGroupManualProps } from './SmartGroup.types'
59
+
60
+ const props = withDefaults(defineProps<SmartGroupManualProps>(), {
61
+ sampleNames: () => DEFAULT_NAMES,
62
+ seed: () => DEFAULT_SEED,
63
+ palette: () => DEFAULT_PALETTE,
64
+ })
65
+
66
+ const emit = defineEmits<{
67
+ done: [payload: ManualDonePayload]
68
+ cancel: []
69
+ auto: []
70
+ close: []
71
+ }>()
72
+
73
+ const Icon: FunctionalComponent<{ d: string; size?: number }> = iconProps =>
74
+ h(
75
+ 'svg',
76
+ {
77
+ width: iconProps.size ?? 16,
78
+ height: iconProps.size ?? 16,
79
+ viewBox: '0 0 24 24',
80
+ fill: 'none',
81
+ stroke: 'currentColor',
82
+ 'stroke-width': 2,
83
+ 'stroke-linecap': 'round',
84
+ 'stroke-linejoin': 'round',
85
+ },
86
+ [h('path', { d: iconProps.d })],
87
+ )
88
+ const ICON = {
89
+ search: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z',
90
+ close: 'M6 18L18 6M6 6l12 12',
91
+ chev: 'M6 9l6 6 6-6',
92
+ layers: 'M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5',
93
+ arrow: 'M5 12h14M13 6l6 6-6 6',
94
+ check: 'M20 6L9 17l-5-5',
95
+ }
96
+
97
+ // Unique datalist ids so multiple instances on a page don't collide.
98
+ let mg2Uid = 0
99
+ const uid = ++mg2Uid
100
+ const groupListId = `sg-mg2-groups-${uid}`
101
+ const subListId = `sg-mg2-subs-${uid}`
102
+ const sub2ListId = `sg-mg2-subs2-${uid}`
103
+
104
+ function buildInitial(): ManualSample[] {
105
+ return props.sampleNames.map(name => {
106
+ const seeded = props.seed[name]
107
+ return {
108
+ name,
109
+ group: seeded?.group ?? null,
110
+ sub: seeded?.sub ?? null,
111
+ sub2: seeded?.sub2 ?? null,
112
+ color: seeded?.color ?? null,
113
+ }
114
+ })
115
+ }
116
+
117
+ const samples = ref<ManualSample[]>(buildInitial())
118
+ const query = ref('')
119
+ const filter = ref<ManualFilter>('all')
120
+ const sel = ref<Set<number>>(new Set())
121
+ const grp = ref('')
122
+ const sub = ref('')
123
+ const sub2 = ref('')
124
+ const color = ref(props.palette[0]?.c ?? 'var(--grp-1)')
125
+
126
+ const assignedCount = computed(() => samples.value.filter(sample => sample.group).length)
127
+ const unassignedCount = computed(() => samples.value.length - assignedCount.value)
128
+ const matches = computed(() => filterSamples(samples.value, query.value, filter.value))
129
+ const matchIdx = computed(() => matches.value.map(match => match.index))
130
+ const allMatchSelected = computed(
131
+ () => matchIdx.value.length > 0 && matchIdx.value.every(index => sel.value.has(index)),
132
+ )
133
+ const tree = computed(() => buildCohortTree(samples.value))
134
+
135
+ const existingGroups = computed(() => {
136
+ const map = new Map<string, string | null>()
137
+ samples.value.forEach(sample => {
138
+ if (sample.group && !map.has(sample.group)) map.set(sample.group, sample.color)
139
+ })
140
+ return map
141
+ })
142
+ const groupKeys = computed(() => [...existingGroups.value.keys()])
143
+ const existingSubs = computed(() => [...new Set(samples.value.map(sample => sample.sub).filter(Boolean))] as string[])
144
+ const existingSub2 = computed(() => [...new Set(samples.value.map(sample => sample.sub2).filter(Boolean))] as string[])
145
+
146
+ const canAssign = computed(() => sel.value.size > 0 && grp.value.trim().length > 0)
147
+ const pct = computed(() =>
148
+ samples.value.length ? Math.round((assignedCount.value / samples.value.length) * 100) : 0,
149
+ )
150
+ const chips = computed<{ k: ManualFilter; label: string; n: number }[]>(() => [
151
+ { k: 'all', label: 'All', n: samples.value.length },
152
+ { k: 'unassigned', label: 'Unassigned', n: unassignedCount.value },
153
+ { k: 'assigned', label: 'Assigned', n: assignedCount.value },
154
+ ])
155
+
156
+ function subsWithout(node: { subs2: Map<string, number> }) {
157
+ return [...node.subs2.entries()].filter(([key]) => key !== '—')
158
+ }
159
+
160
+ function toggle(index: number) {
161
+ const next = new Set(sel.value)
162
+ next.has(index) ? next.delete(index) : next.add(index)
163
+ sel.value = next
164
+ }
165
+ function toggleAll() {
166
+ const next = new Set(sel.value)
167
+ if (allMatchSelected.value) matchIdx.value.forEach(index => next.delete(index))
168
+ else matchIdx.value.forEach(index => next.add(index))
169
+ sel.value = next
170
+ }
171
+ function onGroupInput(value: string) {
172
+ grp.value = value
173
+ const seeded = existingGroups.value.get(value)
174
+ if (seeded) {
175
+ // Existing cohort → adopt its colour.
176
+ color.value = seeded
177
+ return
178
+ }
179
+ // New cohort name → pick the first palette swatch not already in use, so
180
+ // editing a matched name into a new one doesn't bleed the previous colour.
181
+ const used = new Set(existingGroups.value.values())
182
+ color.value = props.palette.find(p => !used.has(p.c))?.c ?? props.palette[0]?.c ?? 'var(--grp-1)'
183
+ }
184
+ function assign() {
185
+ if (sel.value.size === 0 || !grp.value.trim()) return
186
+ samples.value = assignSamples(samples.value, sel.value, {
187
+ grp: grp.value,
188
+ sub: sub.value,
189
+ sub2: sub2.value,
190
+ color: color.value,
191
+ })
192
+ sel.value = new Set()
193
+ grp.value = ''
194
+ sub.value = ''
195
+ sub2.value = ''
196
+ }
197
+ function clearSel() {
198
+ sel.value = new Set()
199
+ }
200
+ function onDone() {
201
+ emit('done', { samples: samples.value })
202
+ }
203
+ </script>
204
+
205
+ <template>
206
+ <div class="sg-modal mg2">
207
+ <!-- header -->
208
+ <div class="sg-head">
209
+ <Icon :d="ICON.layers" :size="18" />
210
+ <span class="sg-head__title">Group samples</span>
211
+ <span class="sg-head__sub">· when names can’t be parsed automatically</span>
212
+ <div class="sg-head__spacer" />
213
+ <div class="sg-mode" role="tablist" aria-label="Grouping mode">
214
+ <button type="button" class="sg-mode__btn" role="tab" :aria-selected="false" @click="emit('auto')">Auto</button>
215
+ <button type="button" class="sg-mode__btn sg-mode__btn--on" role="tab" :aria-selected="true">Manual</button>
216
+ </div>
217
+ <button class="sg-head__close" aria-label="Close" @click="emit('close')">
218
+ <Icon :d="ICON.close" :size="16" />
219
+ </button>
220
+ </div>
221
+
222
+ <div class="mg2-body">
223
+ <!-- LEFT — find & select -->
224
+ <div class="mg2-find">
225
+ <div class="mg2-search">
226
+ <Icon :d="ICON.search" :size="15" />
227
+ <input v-model="query" placeholder="Search names — e.g. TumorA, d7, Pt002…" />
228
+ <button v-if="query" class="mg2-search__clr" aria-label="Clear" @click="query = ''">
229
+ <Icon :d="ICON.close" :size="13" />
230
+ </button>
231
+ </div>
232
+
233
+ <div class="mg2-chips">
234
+ <button
235
+ v-for="chip in chips"
236
+ :key="chip.k"
237
+ class="mg2-chip"
238
+ :class="{ 'mg2-chip--on': filter === chip.k }"
239
+ @click="filter = chip.k"
240
+ >
241
+ {{ chip.label }}<span class="mg2-chip__n">{{ chip.n }}</span>
242
+ </button>
243
+ </div>
244
+
245
+ <div class="mg2-selbar">
246
+ <label class="mg2-check">
247
+ <input type="checkbox" :checked="allMatchSelected" @change="toggleAll" />
248
+ <span>Select all<template v-if="filter !== 'all'"> {{ filter }}</template></span>
249
+ </label>
250
+ <span class="mg2-selbar__n">{{ matchIdx.length }} shown</span>
251
+ </div>
252
+
253
+ <div class="mg2-list">
254
+ <div v-if="matches.length === 0" class="mg2-list__empty">No samples match “{{ query }}”.</div>
255
+ <div
256
+ v-for="match in matches"
257
+ :key="match.index"
258
+ class="mg2-row"
259
+ :class="{ 'mg2-row--on': sel.has(match.index) }"
260
+ @click="toggle(match.index)"
261
+ >
262
+ <input type="checkbox" class="mg2-cb" :checked="sel.has(match.index)" readonly />
263
+ <span class="mg2-row__name">{{ match.sample.name }}</span>
264
+ <span v-if="match.sample.group" class="mg2-row__tag">
265
+ <span class="sg-dot" :style="{ background: match.sample.color ?? undefined }" />{{ match.sample.group
266
+ }}<template v-if="match.sample.sub"> · {{ match.sample.sub }}</template>
267
+ </span>
268
+ <span v-else class="mg2-row__tag mg2-row__tag--none">unassigned</span>
269
+ </div>
270
+ </div>
271
+ </div>
272
+
273
+ <!-- RIGHT — cohorts (persistent output) -->
274
+ <div class="mg2-cohorts">
275
+ <div class="mg2-cohorts__head">
276
+ <span class="mg2-cohorts__t">Cohorts</span>
277
+ <span class="mg2-cohorts__n">{{ tree.size }} groups</span>
278
+ </div>
279
+
280
+ <div class="mg2-progress">
281
+ <div class="mg2-progress__top">
282
+ <span><b>{{ assignedCount }}</b> of {{ samples.length }} grouped</span>
283
+ <span class="mg2-progress__pct">{{ pct }}%</span>
284
+ </div>
285
+ <div class="mg2-progress__track"><div class="mg2-progress__fill" :style="{ width: pct + '%' }" /></div>
286
+ </div>
287
+
288
+ <div class="mg2-tree">
289
+ <div v-if="tree.size === 0" class="mg2-tree__empty">
290
+ No cohorts yet. Select samples and assign them to start building.
291
+ </div>
292
+ <div v-for="[name, group] in [...tree.entries()]" :key="name" class="mg2-grp">
293
+ <div class="mg2-grp__row">
294
+ <span class="sg-dot" :style="{ background: group.color ?? undefined }" />
295
+ <span class="mg2-grp__name">{{ name }}</span>
296
+ <span class="mg2-grp__n">{{ group.total }}</span>
297
+ </div>
298
+ <template v-for="[subName, subNode] in [...group.subs.entries()]" :key="subName">
299
+ <div class="mg2-sub">
300
+ <span class="mg2-sub__name">{{ subName }}</span>
301
+ <span class="mg2-sub__n">{{ subNode.total }}</span>
302
+ </div>
303
+ <div v-for="[s2, count] in subsWithout(subNode)" :key="s2" class="mg2-sub2">
304
+ <span class="mg2-sub2__name">{{ s2 }}</span>
305
+ <span class="mg2-sub2__n">{{ count }}</span>
306
+ </div>
307
+ </template>
308
+ </div>
309
+ </div>
310
+
311
+ <button
312
+ v-if="unassignedCount > 0"
313
+ class="mg2-unassigned"
314
+ :class="{ 'mg2-unassigned--on': filter === 'unassigned' }"
315
+ @click="filter = filter === 'unassigned' ? 'all' : 'unassigned'"
316
+ >
317
+ <span class="sg-dot" :style="{ background: 'var(--grp-qc)', opacity: 0.5 }" />
318
+ <b>{{ unassignedCount }}</b> still unassigned
319
+ <span class="mg2-unassigned__act">{{ filter === 'unassigned' ? 'showing' : 'show' }}</span>
320
+ </button>
321
+ </div>
322
+ </div>
323
+
324
+ <!-- contextual assignment bar -->
325
+ <div class="mg2-bar" :class="{ 'mg2-bar--open': sel.size > 0 }">
326
+ <div class="mg2-bar__inner">
327
+ <span class="mg2-bar__count"><b>{{ sel.size }}</b>&nbsp;selected</span>
328
+ <Icon :d="ICON.arrow" :size="16" />
329
+ <div class="mg2-field mg2-field--grow">
330
+ <span class="mg2-field__l">Group</span>
331
+ <div class="mg2-combo">
332
+ <span class="sg-dot" :style="{ background: color }" />
333
+ <input
334
+ :list="groupListId"
335
+ :value="grp"
336
+ placeholder="Type or pick — e.g. Responder"
337
+ @input="onGroupInput(($event.target as HTMLInputElement).value)"
338
+ />
339
+ <span class="mg2-combo__chev"><Icon :d="ICON.chev" :size="14" /></span>
340
+ <datalist :id="groupListId">
341
+ <option v-for="key in groupKeys" :key="key" :value="key" />
342
+ </datalist>
343
+ </div>
344
+ </div>
345
+ <div class="mg2-field mg2-field--grow">
346
+ <span class="mg2-field__l">Subgroup</span>
347
+ <div class="mg2-combo">
348
+ <input :list="subListId" v-model="sub" placeholder="optional — e.g. Day 7" />
349
+ <datalist :id="subListId">
350
+ <option v-for="value in existingSubs" :key="value" :value="value" />
351
+ </datalist>
352
+ </div>
353
+ </div>
354
+ <div class="mg2-field mg2-field--grow">
355
+ <span class="mg2-field__l">Sub-subgroup</span>
356
+ <div class="mg2-combo">
357
+ <input :list="sub2ListId" v-model="sub2" placeholder="optional — e.g. Batch 2" />
358
+ <datalist :id="sub2ListId">
359
+ <option v-for="value in existingSub2" :key="value" :value="value" />
360
+ </datalist>
361
+ </div>
362
+ </div>
363
+ <div class="mg2-field">
364
+ <span class="mg2-field__l">Color</span>
365
+ <div class="mg2-swatches">
366
+ <button
367
+ v-for="swatch in palette"
368
+ :key="swatch.k"
369
+ class="mg2-swatch"
370
+ :class="{ 'mg2-swatch--on': color === swatch.c }"
371
+ :style="{ background: swatch.c }"
372
+ :aria-label="swatch.k"
373
+ @click="color = swatch.c"
374
+ />
375
+ </div>
376
+ </div>
377
+ <div class="mg2-bar__spacer" />
378
+ <button class="mg2-bar__clear" @click="clearSel">Clear</button>
379
+ <button class="sg-btn sg-btn--primary" :disabled="!canAssign" @click="assign">
380
+ <Icon :d="ICON.check" :size="15" />
381
+ {{ canAssign ? `Assign ${sel.size} to “${grp.trim()}”` : 'Assign' }}
382
+ </button>
383
+ </div>
384
+ </div>
385
+
386
+ <!-- footer -->
387
+ <div class="sg-foot">
388
+ <span class="sg-foot__hint">{{ assignedCount }} of {{ samples.length }} samples grouped</span>
389
+ <div class="sg-foot__spacer" />
390
+ <BaseButton variant="ghost" size="sm" @click="emit('cancel')">Cancel</BaseButton>
391
+ <BaseButton variant="primary" size="sm" @click="onDone">Done</BaseButton>
392
+ </div>
393
+ </div>
394
+ </template>
395
+
396
+ <style>
397
+ @import '../styles/components/smart-group.css';
398
+ </style>
@@ -0,0 +1,61 @@
1
+ <script setup lang="ts">
2
+ import SmartGroupModal from './SmartGroupModal.vue'
3
+ import type { FieldRecipeApplyPayload, ManualDonePayload, SmartGroupMode } from './SmartGroup.types'
4
+
5
+ function initState() {
6
+ return { mode: 'auto' as SmartGroupMode, log: '' }
7
+ }
8
+ </script>
9
+
10
+ <template>
11
+ <Story title="Lab/SmartGroupModal">
12
+ <Variant title="Full View" :init-state="initState">
13
+ <template #default="{ state }">
14
+ <div class="sg-story">
15
+ <div class="sg-story__stage">
16
+ <SmartGroupModal
17
+ v-model:mode="state.mode"
18
+ @apply="(r: FieldRecipeApplyPayload) => (state.log = `apply · ${r.groups.length} groups · ${r.route} · ${r.sampleTotal} samples`)"
19
+ @done="(r: ManualDonePayload) => (state.log = `done · ${r.samples.filter(s => s.group).length} grouped`)"
20
+ @cancel="state.log = 'cancelled'"
21
+ @close="state.log = 'closed'"
22
+ />
23
+ </div>
24
+ <p v-if="state.log" class="sg-story__log">{{ state.log }}</p>
25
+ </div>
26
+ </template>
27
+ <template #controls="{ state }">
28
+ <HstSelect
29
+ :model-value="state.mode"
30
+ title="Mode"
31
+ :options="[
32
+ { value: 'auto', label: 'Auto — Field Recipe' },
33
+ { value: 'manual', label: 'Manual — Cohorts' },
34
+ ]"
35
+ @update:model-value="(v: string) => (state.mode = v as SmartGroupMode)"
36
+ />
37
+ </template>
38
+ </Variant>
39
+ </Story>
40
+ </template>
41
+
42
+ <style scoped>
43
+ .sg-story {
44
+ padding: 2rem;
45
+ display: flex;
46
+ flex-direction: column;
47
+ gap: 1rem;
48
+ align-items: flex-start;
49
+ }
50
+ .sg-story__stage {
51
+ width: 980px;
52
+ height: 640px;
53
+ max-width: 100%;
54
+ }
55
+ .sg-story__log {
56
+ margin: 0;
57
+ font-family: 'Fira Code', monospace;
58
+ font-size: 0.8125rem;
59
+ color: var(--text-secondary, #64748b);
60
+ }
61
+ </style>
@@ -0,0 +1,61 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * SmartGroupModal — the Smart Group "Full View": a shell that toggles between
4
+ * the auto Field Recipe (`SmartGroupFieldRecipe`) and manual cohort builder
5
+ * (`SmartGroupManual`). The active mode is a writable `v-model:mode`; the
6
+ * children request a switch by emitting `manual` / `auto`. Apply/done/cancel/
7
+ * close events bubble up unchanged.
8
+ *
9
+ * Each mode is fully configurable through the optional `auto` / `manual` prop
10
+ * bags; with no props the modal renders the design's reference fixtures.
11
+ */
12
+ import SmartGroupFieldRecipe from './SmartGroupFieldRecipe.vue'
13
+ import SmartGroupManual from './SmartGroupManual.vue'
14
+ import type {
15
+ FieldRecipeApplyPayload,
16
+ ManualDonePayload,
17
+ SmartGroupFieldRecipeProps,
18
+ SmartGroupManualProps,
19
+ } from './SmartGroup.types'
20
+
21
+ withDefaults(
22
+ defineProps<{
23
+ /** Props forwarded to the auto-mode Field Recipe. */
24
+ auto?: Partial<SmartGroupFieldRecipeProps>
25
+ /** Props forwarded to the manual cohort builder. */
26
+ manual?: Partial<SmartGroupManualProps>
27
+ }>(),
28
+ {
29
+ auto: () => ({}),
30
+ manual: () => ({}),
31
+ },
32
+ )
33
+
34
+ const mode = defineModel<'auto' | 'manual'>('mode', { default: 'auto' })
35
+
36
+ const emit = defineEmits<{
37
+ apply: [payload: FieldRecipeApplyPayload]
38
+ done: [payload: ManualDonePayload]
39
+ cancel: []
40
+ close: []
41
+ }>()
42
+ </script>
43
+
44
+ <template>
45
+ <SmartGroupFieldRecipe
46
+ v-if="mode === 'auto'"
47
+ v-bind="auto"
48
+ @manual="mode = 'manual'"
49
+ @apply="emit('apply', $event)"
50
+ @cancel="emit('cancel')"
51
+ @close="emit('close')"
52
+ />
53
+ <SmartGroupManual
54
+ v-else
55
+ v-bind="manual"
56
+ @auto="mode = 'auto'"
57
+ @done="emit('done', $event)"
58
+ @cancel="emit('cancel')"
59
+ @close="emit('close')"
60
+ />
61
+ </template>
@@ -77,6 +77,9 @@ export { default as ExperimentTimeline } from './ExperimentTimeline.vue'
77
77
  export { default as SampleSelector } from './SampleSelector.vue'
78
78
  export { default as AutoGroupModal } from './AutoGroupModal.vue'
79
79
  export { default as GroupAssigner } from './GroupAssigner.vue'
80
+ export { default as SmartGroupModal } from './SmartGroupModal.vue'
81
+ export { default as SmartGroupFieldRecipe } from './SmartGroupFieldRecipe.vue'
82
+ export { default as SmartGroupManual } from './SmartGroupManual.vue'
80
83
 
81
84
  // Lab/Experiment components
82
85
  export { default as MoleculeInput } from './MoleculeInput.vue'
@@ -473,3 +473,82 @@ html.dark .mint-dataframe__loading {
473
473
  width: 0.875rem;
474
474
  height: 0.875rem;
475
475
  }
476
+
477
+ /* Resizable columns */
478
+ .mint-dataframe__th--resizable {
479
+ position: relative;
480
+ }
481
+
482
+ .mint-dataframe__resize-handle {
483
+ position: absolute;
484
+ top: 0;
485
+ right: 0;
486
+ width: 7px;
487
+ height: 100%;
488
+ cursor: col-resize;
489
+ user-select: none;
490
+ touch-action: none;
491
+ z-index: 1;
492
+ }
493
+
494
+ .mint-dataframe__resize-handle::after {
495
+ content: '';
496
+ position: absolute;
497
+ top: 20%;
498
+ right: 3px;
499
+ width: 1px;
500
+ height: 60%;
501
+ background-color: var(--border-color);
502
+ transition: background-color 0.15s ease;
503
+ }
504
+
505
+ .mint-dataframe__resize-handle:hover::after {
506
+ width: 2px;
507
+ background-color: var(--color-primary);
508
+ }
509
+
510
+ /* Row delete action column */
511
+ .mint-dataframe__th--actions,
512
+ .mint-dataframe__td--actions {
513
+ width: 2.75rem;
514
+ text-align: center !important;
515
+ white-space: nowrap;
516
+ }
517
+
518
+ .mint-dataframe__delete-btn {
519
+ display: inline-flex;
520
+ align-items: center;
521
+ justify-content: center;
522
+ width: 1.5rem;
523
+ height: 1.5rem;
524
+ border: 0;
525
+ border-radius: var(--radius-md);
526
+ background: transparent;
527
+ color: var(--text-muted);
528
+ cursor: pointer;
529
+ opacity: 0;
530
+ transition:
531
+ background-color 0.15s ease,
532
+ color 0.15s ease,
533
+ opacity 0.15s ease;
534
+ }
535
+
536
+ .mint-dataframe__row:hover .mint-dataframe__delete-btn,
537
+ .mint-dataframe__delete-btn:focus-visible {
538
+ opacity: 1;
539
+ }
540
+
541
+ .mint-dataframe__delete-btn:hover {
542
+ background-color: color-mix(in srgb, var(--color-error, #ef4444) 14%, transparent);
543
+ color: var(--color-error, #ef4444);
544
+ }
545
+
546
+ .mint-dataframe__delete-btn:focus-visible {
547
+ outline: none;
548
+ box-shadow: var(--focus-ring);
549
+ }
550
+
551
+ .mint-dataframe__delete-btn svg {
552
+ width: 0.875rem;
553
+ height: 0.875rem;
554
+ }
@@ -73,16 +73,12 @@
73
73
 
74
74
  .mint-sample-selector__actions-row {
75
75
  display: grid;
76
- grid-template-columns: repeat(2, minmax(0, 1fr)) auto;
76
+ grid-template-columns: minmax(0, 1fr) auto;
77
77
  align-items: center;
78
78
  gap: 0.375rem;
79
79
  min-width: 0;
80
80
  }
81
81
 
82
- .mint-sample-selector__actions-row--single-primary {
83
- grid-template-columns: minmax(0, 1fr) auto;
84
- }
85
-
86
82
  /* BaseButton overrides for action buttons */
87
83
  .mint-sample-selector__action-btn {
88
84
  width: 100%;