@morscherlab/mint-sdk 1.3.0 → 1.3.2

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 (70) hide show
  1. package/dist/__tests__/components/AutoGroupModal.anatomy.test.d.ts +1 -0
  2. package/dist/__tests__/components/AutoGroupModal.wiring.test.d.ts +1 -0
  3. package/dist/__tests__/composables/autoGroup/fieldEdits.test.d.ts +1 -0
  4. package/dist/__tests__/composables/autoGroup/golden.test.d.ts +1 -0
  5. package/dist/components/AutoGroupModal.adapter.d.ts +27 -4
  6. package/dist/components/PlateEditor.vue.d.ts +2 -0
  7. package/dist/components/SmartGroup.types.d.ts +22 -0
  8. package/dist/components/SmartGroupFieldRecipe.groups.d.ts +10 -2
  9. package/dist/components/SmartGroupFieldRecipe.vue.d.ts +3 -0
  10. package/dist/components/index.js +1 -1
  11. package/dist/{components-vHfxV2PB.js → components-CEePoD3W.js} +502 -264
  12. package/dist/components-CEePoD3W.js.map +1 -0
  13. package/dist/composables/autoGroup/classify.d.ts +7 -1
  14. package/dist/composables/autoGroup/columns.d.ts +16 -1
  15. package/dist/composables/autoGroup/index.d.ts +5 -7
  16. package/dist/composables/autoGroup/tokenize.d.ts +36 -1
  17. package/dist/composables/index.js +2 -2
  18. package/dist/composables/useAutoGroup.d.ts +13 -2
  19. package/dist/{composables-D6uqp0UF.js → composables-BbHozlA7.js} +2 -2
  20. package/dist/{composables-D6uqp0UF.js.map → composables-BbHozlA7.js.map} +1 -1
  21. package/dist/index.js +3 -3
  22. package/dist/install.js +1 -1
  23. package/dist/permissions.js +2 -2
  24. package/dist/permissions.js.map +1 -1
  25. package/dist/styles.css +71 -82
  26. package/dist/types/auto-group.d.ts +1 -1
  27. package/dist/{validation-Dmp9Q5W0.js → validation-CukfLjy-.js} +323 -187
  28. package/dist/validation-CukfLjy-.js.map +1 -0
  29. package/package.json +2 -2
  30. package/src/__tests__/components/AutoGroupModal.anatomy.test.ts +86 -0
  31. package/src/__tests__/components/AutoGroupModal.wiring.test.ts +53 -0
  32. package/src/__tests__/components/PlateEditor.test.ts +10 -0
  33. package/src/__tests__/components/SampleSelector.test.ts +14 -16
  34. package/src/__tests__/components/SmartGroupFieldRecipe.groups.test.ts +23 -0
  35. package/src/__tests__/components/SmartGroupFieldRecipe.test.ts +176 -0
  36. package/src/__tests__/composables/autoGroup/columns.test.ts +100 -11
  37. package/src/__tests__/composables/autoGroup/fieldEdits.test.ts +74 -0
  38. package/src/__tests__/composables/autoGroup/fingerprint.test.ts +9 -0
  39. package/src/__tests__/composables/autoGroup/golden.test.ts +187 -0
  40. package/src/__tests__/composables/autoGroup/groupByOrder.test.ts +7 -7
  41. package/src/__tests__/composables/autoGroup/tokenLength.test.ts +7 -0
  42. package/src/__tests__/composables/autoGroup/tokenize.test.ts +78 -1
  43. package/src/__tests__/composables/useAutoGroup.test.ts +2 -1
  44. package/src/__tests__/fixtures/auto-group/share-exp021.txt +103 -0
  45. package/src/__tests__/fixtures/auto-group/share-exp031.txt +39 -0
  46. package/src/__tests__/fixtures/auto-group/share-exp114.txt +123 -0
  47. package/src/__tests__/fixtures/auto-group/share-exp214.txt +63 -0
  48. package/src/__tests__/utils/permissions.test.ts +15 -0
  49. package/src/components/AutoGroupModal.adapter.ts +80 -9
  50. package/src/components/AutoGroupModal.vue +41 -8
  51. package/src/components/PlateEditor.vue +3 -0
  52. package/src/components/SmartGroup.types.ts +23 -0
  53. package/src/components/SmartGroupFieldRecipe.groups.ts +14 -2
  54. package/src/components/SmartGroupFieldRecipe.vue +289 -169
  55. package/src/composables/autoGroup/classify.ts +26 -7
  56. package/src/composables/autoGroup/columns.ts +144 -36
  57. package/src/composables/autoGroup/compose.ts +8 -10
  58. package/src/composables/autoGroup/fingerprint.ts +1 -1
  59. package/src/composables/autoGroup/index.ts +5 -12
  60. package/src/composables/autoGroup/tokenLength.ts +8 -0
  61. package/src/composables/autoGroup/tokenize.ts +148 -1
  62. package/src/composables/autoGroup/vocab.json +5 -11
  63. package/src/composables/useAutoGroup.ts +92 -79
  64. package/src/permissions.ts +3 -2
  65. package/src/styles/components/smart-group.css +71 -82
  66. package/src/types/auto-group.ts +1 -0
  67. package/dist/components-vHfxV2PB.js.map +0 -1
  68. package/dist/composables/autoGroup/replicatePreGroup.d.ts +0 -36
  69. package/dist/validation-Dmp9Q5W0.js.map +0 -1
  70. package/src/composables/autoGroup/replicatePreGroup.ts +0 -124
@@ -16,21 +16,21 @@ import type {
16
16
  ValueOps,
17
17
  } from '../types/auto-group'
18
18
  import {
19
+ GROUPING_FIELD_ROLES,
19
20
  buildClassSchema,
20
21
  classKey,
21
22
  composeGroups,
22
23
  composeTemplate,
23
24
  detectClass,
24
- expandGroupsWithReplicates,
25
- expandTreeWithReplicates,
26
25
  findMerges,
27
- pickPrimaryDelimiter,
28
- preGroupReplicates,
26
+ isExperimentalKind,
29
27
  restoreFingerprint,
30
28
  serializeFingerprint,
31
29
  splitByTokenLength,
32
- splitMulti,
33
- type ReplicatePreGrouping,
30
+ splitColumns,
31
+ tokenizeBatch,
32
+ type Delimiter,
33
+ type ExperimentHeader,
34
34
  type TemplateOptions,
35
35
  } from './autoGroup'
36
36
  import { unwrapExperimentDesignData } from './experimentDesignData'
@@ -82,12 +82,10 @@ export function useAutoGroup() {
82
82
  })
83
83
 
84
84
  const tokenized = ref<string[][]>([])
85
- const delimiter = ref<'_' | '-' | '.'>('_')
86
- // Replicate pre-grouping: strip injection-number / T<n> / B<n> / Rep<n> markers,
87
- // collapse samples with the same base name into one "row" for tokenisation,
88
- // then expand the resulting groups back to the original samples in `result`.
89
- // null when the paste path hasn't run (CSV path keeps the old behaviour for now).
90
- const preGrouping = ref<ReplicatePreGrouping | null>(null)
85
+ const delimiter = ref<Delimiter>('_')
86
+ // Experiment code / date / operator that all pasted names share. Empty for
87
+ // CSV and experiment input.
88
+ const experiment = ref<ExperimentHeader>({})
91
89
 
92
90
  function parseInput() {
93
91
  if ((inputMode.value === 'csv' || inputMode.value === 'experiment') && csvData.value) {
@@ -98,9 +96,7 @@ export function useAutoGroup() {
98
96
  }
99
97
 
100
98
  function parseInputFromCsv() {
101
- // CSV input already exposes structured columns, so replicate pre-grouping
102
- // isn't needed (and would interfere with header-aware role inference).
103
- preGrouping.value = null
99
+ experiment.value = {}
104
100
  const csv = csvData.value!
105
101
  const nonSampleCols = csv.columns.filter(c => c !== csv.sampleColumn && c !== 'sample_type')
106
102
  if (nonSampleCols.length === 0) {
@@ -146,47 +142,54 @@ export function useAutoGroup() {
146
142
  if (lines.length === 0) {
147
143
  classes.value = []
148
144
  schemas.value = {}
149
- preGrouping.value = null
145
+ experiment.value = {}
150
146
  return
151
147
  }
152
- // Replicate pre-pass: strip _T<n> / _B<n> / _Rep<n> markers, then collapse
153
- // samples whose stripped names match into one base entry. The trailing
154
- // injection / run-order number is deliberately NOT stripped here — it
155
- // survives tokenisation and is surfaced as a 'run-order' column below. The
156
- // tokenize → classify → split-by-length → schema pipeline runs on the
157
- // *base* names; the `result` computed expands each resulting group back to
158
- // the original samples via `expandGroupsWithReplicates`.
159
- const pre = preGroupReplicates(lines)
160
- preGrouping.value = pre
161
-
162
- const d = pickPrimaryDelimiter(pre.baseNames)
163
- delimiter.value = d
164
- tokenized.value = pre.baseNames.map(l => splitMulti(l, d))
165
- // sampleTypeHints align to original samples, so pick the first sample's
166
- // hint per base group as the representative hint.
167
- const hints = pre.membersByBase.map(members => sampleTypeHints.value[members[0]])
168
- // Classify by sample type, THEN split each class by token length so every
169
- // member of a class shares the same field count. This keeps the per-class
170
- // token matrix rectangular, so column index N means the same field for
171
- // every sample and the trailing injection number lands in its own column.
148
+ // One token row for each sample. Replicate tokens, sample numbers and the
149
+ // injection number stay in the row and get a column role below
150
+ // ('replicate', 'id', 'run-order'), which keeps them out of the default
151
+ // group key. Nothing is removed from a name before the user sees it.
152
+ const batch = tokenizeBatch(lines)
153
+ delimiter.value = batch.delimiter
154
+ experiment.value = batch.experiment
155
+ const tokens = batch.rows
156
+ // Classify by sample type, THEN split each experimental class by token
157
+ // length so every member of a class shares the same field count. This
158
+ // keeps the per-class token matrix rectangular, so column index N means
159
+ // the same field for every sample.
172
160
  const detected = splitByTokenLength(
173
- detectClass(tokenized.value, { sampleTypeHints: hints }),
174
- tokenized.value,
161
+ detectClass(tokens, { sampleTypeHints: sampleTypeHints.value, sharedPrefix: batch.sharedPrefix }),
162
+ tokens,
175
163
  )
176
- classes.value = detected
177
164
  const newSchemas: Record<string, ClassSchema> = {}
178
165
  for (const cls of detected) {
179
- const memberTokens = cls.members.map(i => tokenized.value[i])
180
- newSchemas[classKey(cls)] = {
181
- ...buildClassSchema(
182
- memberTokens,
183
- cls.kind,
184
- cls.subKind,
185
- cls.classTagPositions,
186
- ),
187
- tokenLength: cls.tokenLength,
166
+ let memberTokens = cls.members.map(i => tokens[i])
167
+ let tailPositions: number[] = []
168
+ const experimental = isExperimentalKind(cls.kind)
169
+ if (experimental) {
170
+ // Tokens that hold two fields (`2-1`, `C1`) become two columns.
171
+ const split = splitColumns(memberTokens)
172
+ cls.members.forEach((member, row) => { tokens[member] = split.rows[row] })
173
+ cls.classTagPositions = split.origin.flatMap((source, index) =>
174
+ cls.classTagPositions.includes(source) ? [index] : [],
175
+ )
176
+ memberTokens = split.rows
177
+ tailPositions = split.tail
188
178
  }
179
+ const schema = buildClassSchema(
180
+ memberTokens,
181
+ cls.kind,
182
+ cls.subKind,
183
+ cls.classTagPositions,
184
+ tailPositions,
185
+ )
186
+ // A QC-kind class is one group named by the class label, also when the
187
+ // user routes it into the groups.
188
+ if (!experimental) schema.groupBy = []
189
+ newSchemas[classKey(cls)] = { ...schema, tokenLength: cls.tokenLength }
189
190
  }
191
+ tokenized.value = tokens
192
+ classes.value = detected
190
193
  schemas.value = newSchemas
191
194
  if (detected.length > 0) {
192
195
  activeClassKey.value = classKey(detected[0])
@@ -206,41 +209,20 @@ export function useAutoGroup() {
206
209
  return findMerges(schema, memberTokens)
207
210
  })
208
211
 
209
- const result = computed<AutoGroupResult>(() => {
210
- const pre = preGrouping.value
211
- const composed = composeGroups({
212
+ function composeWith(withSchemas: Record<string, ClassSchema>): AutoGroupResult {
213
+ return composeGroups({
212
214
  tokenizedSamples: tokenized.value,
213
- // After the pre-pass, sampleNames passed to compose are the *base* names
214
- // (one per replicate group); fall back to the original samples when
215
- // pre-grouping hasn't run (e.g. the CSV path or empty input).
216
- sampleNames: pre ? pre.baseNames : samples.value,
217
- schemas: schemas.value,
215
+ sampleNames: samples.value,
216
+ schemas: withSchemas,
218
217
  classes: classes.value,
219
218
  prefixClassLabels: !(csvData.value && (inputMode.value === 'csv' || inputMode.value === 'experiment')),
220
219
  missingGroupName: csvData.value && (inputMode.value === 'csv' || inputMode.value === 'experiment')
221
220
  ? STRUCTURED_MISSING_GROUP
222
221
  : undefined,
223
222
  })
224
- if (!pre) return composed
225
- // Expand each group's `samples` from base names back to the original
226
- // sample names so the Preview panel shows real filenames, not stripped
227
- // bases, and so `metadata` reflects each input row exactly once.
228
- return {
229
- ...composed,
230
- groups: expandGroupsWithReplicates(composed.groups, pre),
231
- experimentalGroups: composed.experimentalGroups
232
- ? expandGroupsWithReplicates(composed.experimentalGroups, pre)
233
- : undefined,
234
- qcGroups: composed.qcGroups
235
- ? expandGroupsWithReplicates(composed.qcGroups, pre)
236
- : undefined,
237
- // The tree is built over base names too; expand its leaves to the original
238
- // samples so it agrees with the flat experimental-groups panel.
239
- groupTree: composed.groupTree
240
- ? expandTreeWithReplicates(composed.groupTree, pre)
241
- : composed.groupTree,
242
- }
243
- })
223
+ }
224
+
225
+ const result = computed<AutoGroupResult>(() => composeWith(schemas.value))
244
226
 
245
227
  const fingerprint = computed<SchemaFingerprint>(() =>
246
228
  serializeFingerprint(result.value.schemas ?? []),
@@ -316,9 +298,11 @@ export function useAutoGroup() {
316
298
  // the same field at a different position.
317
299
  function schemasExposing(fieldKey: string): { key: string; index: number }[] {
318
300
  return Object.entries(schemas.value).flatMap(([key, schema]) => {
301
+ // An overlay class is one group; its columns are not grouping fields.
302
+ if (!isExperimentalKind(schema.classKind)) return []
319
303
  const col = schema.columns.find(c =>
320
304
  (c.displayName ?? c.name) === fieldKey
321
- && (c.role === 'factor' || c.role === 'replicate' || schema.groupBy.includes(c.index)),
305
+ && (GROUPING_FIELD_ROLES.has(c.role) || schema.groupBy.includes(c.index)),
322
306
  )
323
307
  return col ? [{ key, index: col.index }] : []
324
308
  })
@@ -345,6 +329,34 @@ export function useAutoGroup() {
345
329
  }
346
330
  }
347
331
 
332
+ /**
333
+ * Rename a grouping field in every class exposing it. A blank name, or a
334
+ * name that a column of any experimental class has, changes nothing: the
335
+ * name is the field's identity across classes, and the cards merge by it.
336
+ */
337
+ function renameGroupByField(fieldKey: string, name: string) {
338
+ const next = name.trim()
339
+ const targets = schemasExposing(fieldKey)
340
+ if (!next || next === fieldKey || targets.length === 0) return
341
+ const taken = Object.values(schemas.value).some(schema =>
342
+ isExperimentalKind(schema.classKind) && schema.columns.some(c => (c.displayName ?? c.name) === next),
343
+ )
344
+ if (taken) return
345
+ for (const { key, index } of targets) setColumnName(key, index, next)
346
+ }
347
+
348
+ /** Number of experimental groups that remain when the field is switched off in every class. */
349
+ function groupCountWithout(fieldKey: string): number {
350
+ const targets = new Map(schemasExposing(fieldKey).map(({ key, index }) => [key, index]))
351
+ const without = Object.fromEntries(
352
+ Object.entries(schemas.value).map(([key, schema]) => [
353
+ key,
354
+ targets.has(key) ? { ...schema, groupBy: schema.groupBy.filter(i => i !== targets.get(key)) } : schema,
355
+ ]),
356
+ )
357
+ return composeWith(without).experimentalGroups?.length ?? 0
358
+ }
359
+
348
360
  /**
349
361
  * Move a grouping field in the active schema, then project that visible field
350
362
  * order across every class. Class-only fields keep their relative order after
@@ -575,6 +587,7 @@ export function useAutoGroup() {
575
587
  schemas.value = {}
576
588
  activeClassKey.value = ''
577
589
  tokenized.value = []
590
+ experiment.value = {}
578
591
  outliers.value = []
579
592
  sampleTypeHints.value = []
580
593
  }
@@ -583,7 +596,7 @@ export function useAutoGroup() {
583
596
  // State
584
597
  inputMode, rawText, csvData, sampleTypeHints,
585
598
  classes, schemas, activeClassKey,
586
- delimiter, dominantFieldCount, minFieldCount,
599
+ delimiter, experiment, dominantFieldCount, minFieldCount,
587
600
  // Computed
588
601
  samples, tokenized, activeSchema, suggestions,
589
602
  groups, qcGroups, metadata, excludedSamples, result, fingerprint,
@@ -592,7 +605,7 @@ export function useAutoGroup() {
592
605
  canDownloadTemplate,
593
606
  // Mutators
594
607
  setClassDisposition, setClassDispositions, toggleGroupBy, moveGroupBy,
595
- toggleGroupByField, moveGroupByField,
608
+ toggleGroupByField, moveGroupByField, renameGroupByField, groupCountWithout,
596
609
  setRole, setColumnName, setValueOps, setBinning,
597
610
  mergeColumns, loadFingerprint,
598
611
  // Actions
@@ -95,9 +95,10 @@ export function canAccessPlugin(
95
95
  user: PermissionUser | null | undefined,
96
96
  pluginName: string | null | undefined,
97
97
  ): boolean {
98
- if (!pluginName) return true
98
+ if (!pluginName || isAdminUser(user)) return true
99
99
  const access = getRoleInfo(user)?.plugin_access
100
- if (!access || access === 'all') return true
100
+ // As on the platform: a value that is not null, 'all' or a list allows no plugin.
101
+ if (access == null || access === 'all') return true
101
102
  return Array.isArray(access) && access.includes(pluginName)
102
103
  }
103
104
 
@@ -160,22 +160,6 @@
160
160
  }
161
161
  .sg-field__toggle { grid-row: 1 / 3; grid-column: 2; align-self: center; }
162
162
 
163
- /* toggle switch */
164
- .sg-switch {
165
- position: relative; width: 1.65rem; height: 0.95rem;
166
- border-radius: 9999px; background: var(--bg-tertiary);
167
- border: none; cursor: pointer; flex-shrink: 0;
168
- transition: background 0.15s ease;
169
- }
170
- .sg-switch.on { background: var(--color-primary); }
171
- .sg-switch::after {
172
- content: ''; position: absolute; top: 0.125rem; left: 0.125rem;
173
- width: 0.7rem; height: 0.7rem; border-radius: 9999px;
174
- background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / 0.25);
175
- transition: transform 0.15s ease;
176
- }
177
- .sg-switch.on::after { transform: translateX(0.7rem); }
178
-
179
163
  /* ==========================================================================
180
164
  Shared — live group preview (right rail)
181
165
  ========================================================================== */
@@ -229,18 +213,8 @@
229
213
  font-size: 0.6875rem; font-weight: 600;
230
214
  color: var(--text-secondary);
231
215
  }
232
- .sg-qc__route {
233
- margin-left: auto;
234
- font: inherit; font-size: 0.75rem; font-weight: 500;
235
- color: var(--text-secondary);
236
- padding: 0.1875rem 1.5rem 0.1875rem 0.5rem;
237
- background: var(--bg-secondary);
238
- background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%2394a3b8' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
239
- background-position: right 0.25rem center; background-repeat: no-repeat;
240
- background-size: 1.1em 1.1em;
241
- border: 1px solid var(--border-color); border-radius: var(--radius-sm);
242
- appearance: none; cursor: pointer;
243
- }
216
+ .fr-qc__route { margin-left: auto; }
217
+ .fr-qc__chips { display: flex; flex-wrap: wrap; gap: 0.25rem; }
244
218
  .sg-qc__chip {
245
219
  display: inline-flex; align-items: center; gap: 0.375rem;
246
220
  padding: 0.25rem 0.5rem; border-radius: var(--radius);
@@ -287,47 +261,85 @@
287
261
  /* ==========================================================================
288
262
  Field Recipe refinements (layered on the shared chrome above)
289
263
  ========================================================================== */
264
+ /* Section label above the anatomy and each field list. */
265
+ .fr-label { font-size: 0.75rem; font-weight: 600; color: var(--text-secondary); }
266
+
267
+ /* One-line field row: [grip] [layer] name [rename] values [hint] [toggle]. */
290
268
  .fr .sg-field {
291
- grid-template-columns: 1fr auto;
292
- grid-template-rows: auto auto;
293
- row-gap: 0.25rem;
294
- padding: 0.5rem 0.75rem;
269
+ display: flex; align-items: center; gap: 0.5rem;
270
+ padding: 0.375rem 0.625rem;
295
271
  }
296
- .fr-field__head { display: flex; align-items: center; gap: 0.5rem; }
297
- .fr .sg-field__role { margin-left: 0; }
298
- .fr .sg-field__vals { grid-column: 1 / 2; grid-row: 2; }
299
- .fr-switch { grid-column: 2; grid-row: 1 / 3; align-self: center; }
300
-
301
- /* Layer-order controls: which hierarchy layer this field forms, plus reorder. */
302
- .sg-field__order {
303
- grid-column: 1 / 3;
304
- display: flex; align-items: center; gap: 0.25rem;
272
+ .fr .sg-field__name { white-space: nowrap; }
273
+ .fr .sg-field__role { margin-left: 0; white-space: nowrap; }
274
+ .fr .sg-field__vals { flex: 1; min-width: 0; }
275
+ .fr-row__grip,
276
+ .fr-row__rename {
277
+ display: inline-flex; align-items: center; justify-content: center;
278
+ width: 1.25rem; height: 1.25rem; padding: 0; flex-shrink: 0;
279
+ border: none; border-radius: var(--radius-sm); background: none;
280
+ color: var(--text-muted); cursor: pointer;
281
+ transition: background 0.15s ease, color 0.15s ease;
305
282
  }
306
- .sg-field__layer {
307
- font-size: 0.6875rem; font-weight: 600;
308
- color: var(--color-primary);
309
- padding: 0.0625rem 0.375rem; border-radius: 9999px;
310
- background: var(--color-primary-soft);
311
- margin-right: auto;
283
+ .fr-row__grip { cursor: grab; }
284
+ .fr-row__grip:hover,
285
+ .fr-row__rename:hover { color: var(--text-primary); background: var(--bg-hover); }
286
+ .fr-row__grip:focus-visible,
287
+ .fr-row__rename:focus-visible { outline: none; box-shadow: var(--focus-ring-soft); }
288
+ /* Which hierarchy layer this field forms (1 = the sample selector's major group). */
289
+ .fr-row__layer {
290
+ display: inline-grid; place-items: center; flex-shrink: 0;
291
+ width: 1.25rem; height: 1.25rem; border-radius: 9999px;
292
+ border: 1px solid var(--color-primary);
293
+ background: var(--bg-secondary); color: var(--_ink-primary);
294
+ font-size: 0.6875rem; font-weight: 700; font-variant-numeric: tabular-nums;
295
+ }
296
+ .fr-row__name-input {
297
+ width: 9rem; padding: 0.0625rem 0.375rem;
298
+ font: inherit; font-size: 0.875rem; font-weight: 600; color: var(--text-primary);
299
+ border: 1px solid var(--color-primary); border-radius: var(--radius-sm);
300
+ background: var(--bg-secondary);
312
301
  }
313
- .sg-field__order-btn {
314
- display: inline-flex; align-items: center; justify-content: center;
315
- width: 1.25rem; height: 1.25rem; padding: 0;
316
- border: 1px solid var(--border-color); border-radius: 0.375rem;
317
- background: var(--bg-primary); color: var(--text-secondary);
318
- cursor: pointer; transition: background 0.15s ease, color 0.15s ease;
302
+ .fr-row__name-input:focus-visible { outline: none; box-shadow: var(--focus-ring-soft); }
303
+ /* Groups that remain when this field is switched off. */
304
+ .fr-row__hint {
305
+ font-size: 0.75rem; font-weight: 500; color: var(--text-secondary-strong);
306
+ white-space: nowrap; font-variant-numeric: tabular-nums;
319
307
  }
320
- .sg-field__order-btn:hover:not(:disabled) { background: var(--bg-tertiary); color: var(--text-primary); }
321
- .sg-field__order-btn:disabled { opacity: 0.4; cursor: default; }
308
+ /* The message is dark text (AA on the tinted row); the input border carries the error colour. */
309
+ .fr-row__error { font-size: 0.75rem; font-weight: 600; color: var(--text-primary); white-space: nowrap; }
310
+ .fr-row__name-input[aria-invalid='true'] { border-color: var(--_ink-error); box-shadow: var(--focus-ring-error); }
311
+ .fr-row--dragging { opacity: 0.5; }
312
+ .fr-row--over { box-shadow: inset 0 2px 0 var(--color-primary); }
322
313
 
323
- /* Replicate-on: the field card itself warns instead of silently shattering. */
314
+ /* Replicate-on: the field row itself warns instead of silently shattering. */
324
315
  .fr-field--warn.sg-field--on {
325
316
  border-color: var(--mint-warning-border);
326
317
  background: var(--mint-warning-bg);
327
318
  }
328
- .fr-field--warn.sg-field--on .fr-switch.on { background: var(--mint-warning); }
329
- .fr-field--warn.sg-field--on .sg-field__role--rep {
330
- color: #fff; background: var(--mint-warning);
319
+ .fr-field--warn.sg-field--on .fr-row__layer { border-color: var(--mint-warning); color: var(--text-primary); }
320
+
321
+ /* Name anatomy: the tokens of one example sample and the role of each. */
322
+ .fr-anatomy { display: flex; flex-direction: column; gap: 0.375rem; }
323
+ .fr-anatomy__tokens { display: flex; flex-wrap: wrap; gap: 0.375rem 0.5rem; }
324
+ .fr-token { display: inline-flex; flex-direction: column; align-items: center; gap: 0.125rem; }
325
+ .fr-token__text {
326
+ font-family: var(--font-mono); font-size: 0.75rem; font-weight: 500;
327
+ padding: 0.125rem 0.375rem; border-radius: var(--radius-sm);
328
+ border: 1px solid var(--border-color); background: var(--bg-tertiary);
329
+ color: var(--text-secondary-strong);
330
+ }
331
+ .fr-token__caption { font-size: 0.6875rem; color: var(--text-secondary-strong); white-space: nowrap; }
332
+ .fr-token--on .fr-token__text {
333
+ border-color: var(--color-primary); background: var(--color-primary-soft);
334
+ color: var(--text-primary);
335
+ }
336
+ .fr-token--on .fr-token__caption { color: var(--_ink-primary); font-weight: 700; }
337
+ .fr-token--off .fr-token__text { border-style: dashed; background: transparent; }
338
+
339
+ /* No samples: say what to do. */
340
+ .fr-blank {
341
+ display: flex; flex-direction: column; align-items: flex-start; gap: 0.5rem;
342
+ padding: 1rem 0;
331
343
  }
332
344
 
333
345
  /* Neutral (no-selection) summary variant. */
@@ -652,29 +664,6 @@ html.dark .sg-summary code { background: rgba(255, 255, 255, 0.1); }
652
664
  background: var(--bg-tertiary); color: var(--text-primary); border-color: var(--text-muted);
653
665
  }
654
666
  .fr-upload:focus-visible { outline: none; box-shadow: var(--focus-ring-soft); }
655
- .fr-csv { position: relative; }
656
- .fr-csv-menu {
657
- position: absolute; top: calc(100% + 0.25rem); right: 0; z-index: 10;
658
- min-width: 11rem; padding: 0.25rem;
659
- display: flex; flex-direction: column; gap: 0.0625rem;
660
- background: var(--bg-secondary);
661
- border: 1px solid var(--border-color); border-radius: var(--radius-md);
662
- box-shadow: var(--shadow-lg);
663
- }
664
- .fr-csv-item {
665
- display: flex; align-items: center; gap: 0.5rem;
666
- font: inherit; font-size: 0.8125rem; font-weight: 500; text-align: left;
667
- color: var(--text-primary); cursor: pointer;
668
- padding: 0.4375rem 0.5rem; border: none; border-radius: var(--radius);
669
- background: none; transition: background 0.12s ease, color 0.12s ease;
670
- }
671
- .fr-csv-item:hover { background: var(--bg-hover); }
672
- .fr-csv-item:disabled {
673
- /* disabled cue: muted colour + struck label + not-allowed, not opacity alone */
674
- color: var(--text-muted); cursor: not-allowed; opacity: 0.7;
675
- text-decoration: line-through; text-decoration-color: var(--text-muted);
676
- }
677
- .fr-csv-item:disabled:hover { background: none; }
678
667
  /* anchor the drop overlay to the main panel */
679
668
  .sg-body__main { position: relative; }
680
669
  .fr-dropzone {
@@ -61,6 +61,7 @@ export type ColumnRole =
61
61
  | 'factor'
62
62
  | 'replicate'
63
63
  | 'run-order'
64
+ | 'id'
64
65
  | 'numeric'
65
66
  | 'class-tag'
66
67
  | 'ignore'