@morscherlab/mint-sdk 1.5.0-beta.2 → 1.5.0-beta.3

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 (118) hide show
  1. package/dist/{AlertBox-CIhqsWbN.js → AlertBox-CexONPhA.js} +2 -2
  2. package/dist/{AlertBox-CIhqsWbN.js.map → AlertBox-CexONPhA.js.map} +1 -1
  3. package/dist/{AppSidebar-BDrd7Kwn.js → AppSidebar-FxlF5_dI.js} +55 -40
  4. package/dist/AppSidebar-FxlF5_dI.js.map +1 -0
  5. package/dist/{ConfirmDialog-CjWoxAD1.js → ConfirmDialog-D9QXFpsy.js} +2 -2
  6. package/dist/ConfirmDialog-D9QXFpsy.js.map +1 -0
  7. package/dist/{ExperimentPopover-BM92LlrQ.js → ExperimentPopover-Bbd6RaoH.js} +3 -3
  8. package/dist/{ExperimentPopover-BM92LlrQ.js.map → ExperimentPopover-Bbd6RaoH.js.map} +1 -1
  9. package/dist/{ExperimentSelectorModal-LsB1ABmh.js → ExperimentSelectorModal-CUkKmgfr.js} +35 -6
  10. package/dist/ExperimentSelectorModal-CUkKmgfr.js.map +1 -0
  11. package/dist/{SegmentedControl-Dz41vAFM.js → SegmentedControl-C-sckoJZ.js} +2 -2
  12. package/dist/{SegmentedControl-Dz41vAFM.js.map → SegmentedControl-C-sckoJZ.js.map} +1 -1
  13. package/dist/{SettingsModal-C-MSR-bk.js → SettingsModal-BIC0dAYc.js} +6 -6
  14. package/dist/{SettingsModal-C-MSR-bk.js.map → SettingsModal-BIC0dAYc.js.map} +1 -1
  15. package/dist/components/FormBuilder.vue.d.ts +1 -1
  16. package/dist/components/PluginIcon.vue.d.ts +2 -2
  17. package/dist/components/SectionCard.vue.d.ts +13 -1
  18. package/dist/components/SettingsModal.vue.d.ts +1 -1
  19. package/dist/components/internal/FormSchemaInternal.vue.d.ts +9 -9
  20. package/dist/components/internal/FormSectionRenderer.vue.d.ts +4 -4
  21. package/dist/composables/useExperimentSelector.d.ts +2 -0
  22. package/dist/composables/useOnboarding.d.ts +1 -1
  23. package/dist/{controlSchemaModel-BbXbMgc6.js → controlSchemaModel-C_XqpkgE.js} +2 -2
  24. package/dist/{controlSchemaModel-BbXbMgc6.js.map → controlSchemaModel-C_XqpkgE.js.map} +1 -1
  25. package/dist/{data-LzdkEZJ-.js → data-CfY7o8QC.js} +9 -9
  26. package/dist/{data-LzdkEZJ-.js.map → data-CfY7o8QC.js.map} +1 -1
  27. package/dist/data.js +7 -7
  28. package/dist/index.d.ts +0 -1
  29. package/dist/index.js +14 -19
  30. package/dist/index.js.map +1 -1
  31. package/dist/{install-UTqrAgHe.js → install-QF3Sh82O.js} +15 -39
  32. package/dist/install-QF3Sh82O.js.map +1 -0
  33. package/dist/install.js +1 -1
  34. package/dist/{lab-CbdDLAVr.js → lab-BcX5JBSY.js} +34 -112
  35. package/dist/lab-BcX5JBSY.js.map +1 -0
  36. package/dist/lab.js +4 -4
  37. package/dist/{records-CWRztE5M.js → records-BAymyHAn.js} +10 -110
  38. package/dist/records-BAymyHAn.js.map +1 -0
  39. package/dist/{runtime-DrL6lKAb.js → runtime-DngeJtdq.js} +82 -45
  40. package/dist/runtime-DngeJtdq.js.map +1 -0
  41. package/dist/runtime.js +6 -6
  42. package/dist/{shell-Ch7cJnjo.js → shell-CWZi97nW.js} +4 -4
  43. package/dist/{shell-Ch7cJnjo.js.map → shell-CWZi97nW.js.map} +1 -1
  44. package/dist/shell.js +4 -4
  45. package/dist/styles.css +181 -125
  46. package/dist/styles.d.ts +1 -0
  47. package/dist/types/index.d.ts +1 -1
  48. package/dist/types/instrument.d.ts +5 -17
  49. package/dist/{useExperimentSamples-Cz2v3V_q.js → useExperimentSamples-DEDAKa2E.js} +3 -3
  50. package/dist/{useExperimentSamples-Cz2v3V_q.js.map → useExperimentSamples-DEDAKa2E.js.map} +1 -1
  51. package/dist/{useListReorder-QqJc1dpX.js → useListReorder-W1bzK_bo.js} +15 -3
  52. package/dist/useListReorder-W1bzK_bo.js.map +1 -0
  53. package/dist/{useRackEditor-BHGM1Ngz.js → useRackEditor-Bmtuo0aj.js} +155 -26
  54. package/dist/useRackEditor-Bmtuo0aj.js.map +1 -0
  55. package/dist/{useRealtime-CNQbrS01.js → useRealtime-jOetF2mM.js} +3 -3
  56. package/dist/{useRealtime-CNQbrS01.js.map → useRealtime-jOetF2mM.js.map} +1 -1
  57. package/dist/{useRequest-Bfsh7gIm.js → useRequest-D9-3BS-3.js} +2 -2
  58. package/dist/{useRequest-Bfsh7gIm.js.map → useRequest-D9-3BS-3.js.map} +1 -1
  59. package/package.json +7 -4
  60. package/src/__tests__/components/AppTopBarOnboarding.test.ts +10 -0
  61. package/src/__tests__/components/ConfirmDialog.test.ts +2 -2
  62. package/src/__tests__/components/ExperimentSelectorModal.test.ts +16 -0
  63. package/src/__tests__/components/SectionCard.test.ts +33 -0
  64. package/src/__tests__/components/deprecationTags.test.ts +4 -18
  65. package/src/__tests__/composables/useJobsEndpoint.test.ts +50 -0
  66. package/src/__tests__/composables/useOnboarding.test.ts +2 -2
  67. package/src/__tests__/docs/frontendDocsCatalog.test.ts +3 -3
  68. package/src/components/AppSidebar.vue +2 -2
  69. package/src/components/CollapsibleCard.vue +1 -1
  70. package/src/components/ConfirmDialog.vue +1 -1
  71. package/src/components/ExperimentSelectorModal.vue +35 -0
  72. package/src/components/FileUploader.vue +3 -35
  73. package/src/components/GeneratedPluginView.vue +35 -81
  74. package/src/components/GeneratedResultRenderer.vue +2 -1
  75. package/src/components/InstrumentStatusPanels.vue +3 -3
  76. package/src/components/PlotlyChart.vue +1 -1
  77. package/src/components/PluginIcon.story.vue +2 -2
  78. package/src/components/PluginIcon.vue +2 -2
  79. package/src/components/SectionCard.vue +42 -6
  80. package/src/components/internal/AppTopBarOnboardingInternal.vue +30 -14
  81. package/src/composables/index.ts +1 -2
  82. package/src/composables/useExperimentSelector.ts +4 -0
  83. package/src/composables/useJobsEndpoint.ts +15 -4
  84. package/src/composables/useOnboarding.ts +3 -3
  85. package/src/generated-ui/main.ts +3 -1
  86. package/src/index.ts +0 -3
  87. package/src/styles/components/alert-box.css +1 -1
  88. package/src/styles/components/app-sidebar.css +5 -5
  89. package/src/styles/components/button.css +3 -3
  90. package/src/styles/components/checkbox.css +1 -1
  91. package/src/styles/components/collapsible-card.css +1 -1
  92. package/src/styles/components/confirm-dialog.css +2 -2
  93. package/src/styles/components/file-uploader.css +3 -3
  94. package/src/styles/components/icon-button.css +3 -3
  95. package/src/styles/components/loading-spinner.css +1 -1
  96. package/src/styles/components/onboarding.css +39 -0
  97. package/src/styles/components/pill.css +1 -1
  98. package/src/styles/components/plugin-icon.css +2 -2
  99. package/src/styles/components/presence-avatars.css +4 -4
  100. package/src/styles/components/progress-bar.css +1 -1
  101. package/src/styles/components/section-card.css +65 -6
  102. package/src/styles/components/sequence-input.css +1 -1
  103. package/src/styles/components/slider.css +1 -1
  104. package/src/styles/components/toggle.css +1 -1
  105. package/src/styles/variables.css +10 -4
  106. package/src/types/index.ts +0 -1
  107. package/src/types/instrument.ts +5 -18
  108. package/dist/AppSidebar-BDrd7Kwn.js.map +0 -1
  109. package/dist/ConfirmDialog-CjWoxAD1.js.map +0 -1
  110. package/dist/ExperimentSelectorModal-LsB1ABmh.js.map +0 -1
  111. package/dist/deprecatedRootExports.d.ts +0 -191
  112. package/dist/install-UTqrAgHe.js.map +0 -1
  113. package/dist/lab-CbdDLAVr.js.map +0 -1
  114. package/dist/records-CWRztE5M.js.map +0 -1
  115. package/dist/runtime-DrL6lKAb.js.map +0 -1
  116. package/dist/useListReorder-QqJc1dpX.js.map +0 -1
  117. package/dist/useRackEditor-BHGM1Ngz.js.map +0 -1
  118. package/src/deprecatedRootExports.ts +0 -226
@@ -1595,6 +1595,56 @@ describe('useJobsEndpoint', () => {
1595
1595
  }
1596
1596
  })
1597
1597
 
1598
+ it.each([
1599
+ { name: 'resolves when the server still has the job', answers: [() => response(completedJob('submitted-job'))], outcome: 'completed' },
1600
+ { name: 'rejects when the server no longer has the job', answers: [() => response({ detail: 'Job not found' }, 404)], outcome: 'Job submitted-job is no longer available' },
1601
+ { name: 'checks again on the next snapshot after a retryable error', answers: [() => response({ detail: 'Busy' }, 503), () => response(completedJob('submitted-job'))], outcome: 'completed' },
1602
+ ])('should check a waited job that a stale stream snapshot omits and $name', async ({ answers, outcome }) => {
1603
+ const encoder = new TextEncoder()
1604
+ let pushFrame!: (frame: string) => void
1605
+ const stream = new ReadableStream<Uint8Array>({
1606
+ start(controller) { pushFrame = frame => controller.enqueue(encoder.encode(frame)) },
1607
+ })
1608
+ let lookups = 0
1609
+ const transport = vi.fn<GeneratedAnalysisTransport>(async (input, init) => {
1610
+ const url = String(input)
1611
+ if (url.endsWith('/jobs/sessions') && init?.method === 'GET') return response({ sessions: [] })
1612
+ if (url.endsWith('/jobs/sessions')) {
1613
+ return response({ id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [] })
1614
+ }
1615
+ if (url.endsWith('/jobs/events')) return new Response(stream, { headers: { 'Content-Type': 'text/event-stream' } })
1616
+ if (url.endsWith('/jobs') && init?.method === 'POST') return response(activeJob('submitted-job'))
1617
+ if (url.endsWith('/jobs')) return response({ jobs: [] })
1618
+ if (url.endsWith('/jobs/submitted-job')) {
1619
+ lookups += 1
1620
+ return lookups === 1 ? response(activeJob('submitted-job', 'running')) : answers[lookups - 2]!()
1621
+ }
1622
+ throw new Error(`Unexpected request: ${url}`)
1623
+ })
1624
+ const scope = effectScope()
1625
+ const generated = scope.run(() => useJobsEndpoint({
1626
+ baseUrl: '/api/demo',
1627
+ transport,
1628
+ storage: memoryStorage(),
1629
+ createId: () => 'tab-1',
1630
+ streamReconnectDelay: 60_000,
1631
+ }))!
1632
+
1633
+ try {
1634
+ const waiting = generated.wait(await generated.submit('run', {}))
1635
+ .then(job => job.status, (cause: Error) => cause.message)
1636
+ // Each snapshot was built before the submit reached the server.
1637
+ for (const [index] of answers.entries()) {
1638
+ await vi.waitFor(() => expect(lookups).toBe(index + 1))
1639
+ pushFrame(`event: snapshot\ndata: ${JSON.stringify({ jobs: [] })}\n\n`)
1640
+ }
1641
+
1642
+ expect(await waiting).toBe(outcome)
1643
+ } finally {
1644
+ scope.stop()
1645
+ }
1646
+ })
1647
+
1598
1648
  it('rejects pending waiters when their job is deleted or the runtime is disposed', async () => {
1599
1649
  const queued = activeJob('delete-wait-job')
1600
1650
  const disposeJobRequest = deferred<Response>()
@@ -98,7 +98,7 @@ describe('provideOnboarding', () => {
98
98
  expect(state.active.value).toMatchObject({ step: { id: 'b' }, index: 0, count: 1 })
99
99
  })
100
100
 
101
- it('should mark every step seen on dismiss and not start the tour again', async () => {
101
+ it('should mark every step of every tour seen on dismiss and not start a tour again', async () => {
102
102
  const state = setup()
103
103
  await flush()
104
104
  state.dismiss()
@@ -107,7 +107,7 @@ describe('provideOnboarding', () => {
107
107
  const again = setup()
108
108
  await flush()
109
109
 
110
- expect(stored()).toEqual(expect.arrayContaining(['step:intro/a', 'step:intro/b']))
110
+ expect(stored()).toEqual(expect.arrayContaining(['step:intro/a', 'step:intro/b', 'step:list/new']))
111
111
  expect(again.active.value).toBeNull()
112
112
  })
113
113
 
@@ -470,9 +470,9 @@ describe('frontend docs catalog coverage', () => {
470
470
  'BaseButton',
471
471
  'PluginWorkspaceView',
472
472
  'DataFrame',
473
- 'useForm',
474
- 'useReagentSeries',
475
- 'ConcentrationValue',
473
+ 'useExperiment',
474
+ 'useRequest',
475
+ 'UseExperimentReturn',
476
476
  'FormSectionSchema',
477
477
  ]))
478
478
  })
@@ -208,11 +208,11 @@ function railIconStyle(section: SidebarToolSection): Record<string, string> {
208
208
  }
209
209
 
210
210
  function badgeToneClass(section: SidebarToolSection): string {
211
- return `mint-sidebar__rail-badge--${section.badgeTone ?? 'cta'}`
211
+ return `mint-sidebar__rail-badge--${section.badgeTone ?? 'primary'}`
212
212
  }
213
213
 
214
214
  function tagToneClass(section: SidebarToolSection): string {
215
- return `mint-sidebar__tag--${section.badgeTone ?? 'cta'}`
215
+ return `mint-sidebar__tag--${section.badgeTone ?? 'primary'}`
216
216
  }
217
217
 
218
218
  function actionClasses(action: SidebarToolSectionAction): string[] {
@@ -23,7 +23,7 @@ interface Props {
23
23
 
24
24
  const props = withDefaults(defineProps<Props>(), {
25
25
  badge: undefined,
26
- badgeTone: 'cta',
26
+ badgeTone: 'primary',
27
27
  defaultOpen: false,
28
28
  disabled: false,
29
29
  dense: false,
@@ -41,7 +41,7 @@ function handleConfirm() {
41
41
 
42
42
  const confirmVariant = computed<ButtonVariant>(() => {
43
43
  if (props.tone === 'error') return 'danger'
44
- if (props.tone === 'warning' || props.tone === 'cta') return 'cta'
44
+ if (props.tone === 'warning' || props.tone === 'cta') return 'primary'
45
45
  if (props.tone === 'success') return 'success'
46
46
  return 'primary'
47
47
  })
@@ -53,6 +53,28 @@ function close() {
53
53
 
54
54
  const experimentStore = useExperimentStore()
55
55
 
56
+ // The filter choices (not the search) survive a close or a page reload, so a
57
+ // user who works on their own ongoing experiments does not pick them again.
58
+ // One key for every plugin: all plugin pages share the platform origin. The
59
+ // experiment type is left out, since each plugin allows its own types.
60
+ const FILTERS_STORAGE_KEY = 'mint.experimentSelector.filters'
61
+ const REMEMBERED_FILTERS = ['status', 'project', 'datePreset', 'mine'] as const
62
+
63
+ function readRememberedFilters(): Partial<ExperimentFilters> {
64
+ try {
65
+ const saved: unknown = JSON.parse(globalThis.localStorage?.getItem(FILTERS_STORAGE_KEY) ?? '{}')
66
+ if (!saved || typeof saved !== 'object') return {}
67
+ const restored: Record<string, unknown> = {}
68
+ for (const key of REMEMBERED_FILTERS) {
69
+ const value = (saved as Record<string, unknown>)[key]
70
+ if (typeof value === 'string' || value === true) restored[key] = value
71
+ }
72
+ return restored as Partial<ExperimentFilters>
73
+ } catch {
74
+ return {}
75
+ }
76
+ }
77
+
56
78
  const {
57
79
  experiments,
58
80
  filters,
@@ -68,8 +90,21 @@ const {
68
90
  experimentType: props.experimentType,
69
91
  allowedExperimentTypes: props.allowedExperimentTypes,
70
92
  withStatusCounts: true,
93
+ initialFilters: readRememberedFilters(),
71
94
  })
72
95
 
96
+ watch(
97
+ () => REMEMBERED_FILTERS.map(key => filters[key]),
98
+ () => {
99
+ const saved = Object.fromEntries(REMEMBERED_FILTERS.filter(key => filters[key]).map(key => [key, filters[key]]))
100
+ try {
101
+ globalThis.localStorage?.setItem(FILTERS_STORAGE_KEY, JSON.stringify(saved))
102
+ } catch {
103
+ // Storage blocked (private mode): the filters last only while the page is open.
104
+ }
105
+ },
106
+ )
107
+
73
108
  // Re-mounting the list on open clears its keyboard highlight.
74
109
  const openCount = ref(0)
75
110
 
@@ -63,35 +63,8 @@ const maxSizeLabel = computed(() => {
63
63
  return `${props.maxSize} bytes`
64
64
  })
65
65
 
66
- const fileTypeMap: Record<string, { color: string; label: string }> = {
67
- csv: { color: '#10B981', label: 'CSV' },
68
- tsv: { color: '#10B981', label: 'TSV' },
69
- xlsx: { color: '#10B981', label: 'XLSX' },
70
- xls: { color: '#10B981', label: 'XLS' },
71
- pdf: { color: '#EF4444', label: 'PDF' },
72
- png: { color: '#8B5CF6', label: 'PNG' },
73
- jpg: { color: '#8B5CF6', label: 'JPG' },
74
- jpeg: { color: '#8B5CF6', label: 'JPEG' },
75
- gif: { color: '#8B5CF6', label: 'GIF' },
76
- svg: { color: '#8B5CF6', label: 'SVG' },
77
- webp: { color: '#8B5CF6', label: 'WEBP' },
78
- json: { color: '#F59E0B', label: 'JSON' },
79
- xml: { color: '#F59E0B', label: 'XML' },
80
- txt: { color: '#64748B', label: 'TXT' },
81
- zip: { color: '#6366F1', label: 'ZIP' },
82
- gz: { color: '#6366F1', label: 'GZ' },
83
- py: { color: '#3B82F6', label: 'PY' },
84
- js: { color: '#EAB308', label: 'JS' },
85
- ts: { color: '#3B82F6', label: 'TS' },
86
- fasta: { color: '#EC4899', label: 'FASTA' },
87
- fastq: { color: '#EC4899', label: 'FASTQ' },
88
- bam: { color: '#EC4899', label: 'BAM' },
89
- vcf: { color: '#EC4899', label: 'VCF' },
90
- }
91
-
92
- function getFileType(filename: string): { color: string; label: string } {
93
- const ext = filename.split('.').pop()?.toLowerCase() || ''
94
- return fileTypeMap[ext] || { color: '#94A3B8', label: ext.toUpperCase() || 'FILE' }
66
+ function fileTypeLabel(filename: string): string {
67
+ return filename.split('.').pop()?.toUpperCase() || 'FILE'
95
68
  }
96
69
 
97
70
  function validateFiles(files: FileList | File[]): File[] {
@@ -420,12 +393,7 @@ function formatFileSize(bytes: number): string {
420
393
  class="mint-file-uploader__file"
421
394
  >
422
395
  <div class="mint-file-uploader__file-info">
423
- <span
424
- class="mint-file-uploader__file-badge"
425
- :style="{ '--badge-color': getFileType(file.name).color }"
426
- >
427
- {{ getFileType(file.name).label }}
428
- </span>
396
+ <span class="mint-file-uploader__file-badge">{{ fileTypeLabel(file.name) }}</span>
429
397
  <div class="mint-file-uploader__file-meta">
430
398
  <span class="mint-file-uploader__file-name">{{ file.name }}</span>
431
399
  <span class="mint-file-uploader__file-size">{{ formatFileSize(file.size) }}</span>
@@ -22,10 +22,12 @@ import {
22
22
  type GeneratedAnalysisTransport,
23
23
  } from '../composables/useJobsEndpoint'
24
24
  import AlertBox from './AlertBox.vue'
25
+ import AppContainer from './AppContainer.vue'
25
26
  import EmptyState from './EmptyState.vue'
26
27
  import GeneratedResultRenderer from './GeneratedResultRenderer.vue'
27
28
  import LoadingSpinner from './LoadingSpinner.vue'
28
29
  import PluginWorkspaceView from './PluginWorkspaceView.vue'
30
+ import SectionCard from './SectionCard.vue'
29
31
  import { GENERATED_JOBS_API_KEY } from './internal/generatedJobsApi'
30
32
  import { definePluginWorkspaceView } from '../composables/componentSpecs'
31
33
  import { providePluginIdentity } from '../composables/pluginIdentity'
@@ -48,6 +50,7 @@ const generated = useJobsEndpoint({
48
50
  provide(GENERATED_JOBS_API_KEY, { baseUrl: props.baseUrl, transport: props.transport })
49
51
  const activeDefinitionId = ref('')
50
52
  const selectedJobId = ref('')
53
+ const sidebarCollapsed = ref(false)
51
54
  const valuesByDefinition = reactive<Record<string, Record<string, unknown>>>({})
52
55
  const definitions = computed(() => generated.manifest.value?.jobs ?? [])
53
56
  const activeDefinition = computed(() => (
@@ -77,12 +80,22 @@ const workspace = computed(() => definePluginWorkspaceView({
77
80
  subtitle: generated.manifest.value?.ui.subtitle || generated.manifest.value?.ui.description,
78
81
  // The platform shell renders the toasts around this view.
79
82
  toasts: false,
83
+ floating: true,
80
84
  topBar: {
85
+ variant: 'card',
81
86
  pills: pillNav.value,
87
+ themeToggle: true,
82
88
  settings: false,
83
89
  standaloneLabel: props.showStandaloneLabel ? 'Standalone' : false,
84
90
  },
85
- sidebar: { title: 'Parameters', panels: panels.value, forms: forms.value },
91
+ sidebar: {
92
+ title: 'Parameters',
93
+ width: '300px',
94
+ collapsible: true,
95
+ collapsedWidth: '3rem',
96
+ panels: panels.value,
97
+ forms: forms.value,
98
+ },
86
99
  jobs: generated,
87
100
  }))
88
101
 
@@ -160,6 +173,7 @@ onMounted(() => {
160
173
  <template>
161
174
  <PluginWorkspaceView
162
175
  v-model:view="activeDefinitionId"
176
+ v-model:sidebar-collapsed="sidebarCollapsed"
163
177
  :spec="workspace"
164
178
  :values="currentValues"
165
179
  :form-loading="generated.isSubmitting.value"
@@ -168,7 +182,7 @@ onMounted(() => {
168
182
  @form-submit="submitJob"
169
183
  @job-select="selectJob"
170
184
  >
171
- <main class="mint-generated-workspace">
185
+ <AppContainer direction="column" gap="var(--mint-gutter)" class="mint-generated-workspace">
172
186
  <div v-if="generated.isLoading.value" class="mint-generated-workspace__loading">
173
187
  <LoadingSpinner size="lg" />
174
188
  <span>Loading analysis workspace</span>
@@ -177,18 +191,15 @@ onMounted(() => {
177
191
  <template v-else>
178
192
  <AlertBox v-if="generated.error.value" tone="error">{{ generated.error.value }}</AlertBox>
179
193
 
180
- <header v-if="activeDefinition" class="mint-generated-workspace__header">
181
- <div>
182
- <p class="mint-generated-workspace__eyebrow">Analysis result</p>
183
- <h1>{{ activeDefinition.title }}</h1>
184
- <p v-if="activeDefinition.description">{{ activeDefinition.description }}</p>
185
- </div>
186
- <div class="mint-generated-workspace__resources" aria-label="Job resource requirements">
187
- <span>{{ resourceSummary(activeDefinition) }}</span>
188
- </div>
189
- </header>
190
-
191
- <section class="mint-generated-workspace__result">
194
+ <SectionCard :title="activeDefinition?.title ?? 'Result'">
195
+ <template v-if="activeDefinition" #actions>
196
+ <span class="mint-generated-workspace__resources" aria-label="Job resource requirements">
197
+ {{ resourceSummary(activeDefinition) }}
198
+ </span>
199
+ </template>
200
+ <p v-if="activeDefinition?.description" class="mint-generated-workspace__description">
201
+ {{ activeDefinition.description }}
202
+ </p>
192
203
  <GeneratedResultRenderer
193
204
  v-if="currentResult"
194
205
  :result="currentResult"
@@ -199,93 +210,36 @@ onMounted(() => {
199
210
  description="Set the parameters and run the analysis. Jobs continue independently if you switch tabs."
200
211
  size="md"
201
212
  />
202
- </section>
213
+ </SectionCard>
203
214
  </template>
204
- </main>
215
+ </AppContainer>
205
216
  </PluginWorkspaceView>
206
217
  </template>
207
218
 
208
219
  <style scoped>
209
220
  .mint-generated-workspace {
210
- position: relative;
211
221
  min-width: 0;
212
- min-height: calc(100vh - 5rem);
213
- padding: clamp(1rem, 2.5vw, 2.5rem);
214
- background: var(--bg-primary);
222
+ min-height: 0;
223
+ overflow-y: auto;
215
224
  }
216
225
 
217
226
  .mint-generated-workspace__loading {
218
227
  display: flex;
219
- min-height: 24rem;
228
+ flex: 1;
220
229
  align-items: center;
221
230
  justify-content: center;
222
231
  gap: 0.75rem;
223
232
  color: var(--text-secondary);
224
233
  }
225
234
 
226
- .mint-generated-workspace__header {
227
- display: flex;
228
- align-items: flex-start;
229
- justify-content: space-between;
230
- gap: 1.5rem;
231
- max-width: 76rem;
232
- margin: 0 auto 1.25rem;
233
- }
234
-
235
- .mint-generated-workspace__header h1 {
236
- margin: 0;
237
- color: var(--text-primary);
238
- font-size: clamp(1.35rem, 2vw, 1.8rem);
239
- line-height: 1.2;
240
- }
241
-
242
- .mint-generated-workspace__header p:not(.mint-generated-workspace__eyebrow) {
243
- margin: 0.4rem 0 0;
244
- color: var(--text-secondary);
245
- }
246
-
247
- .mint-generated-workspace__eyebrow {
248
- margin: 0 0 0.35rem;
249
- color: var(--text-secondary);
250
- font-size: 0.72rem;
251
- font-weight: 700;
252
- }
253
-
254
235
  .mint-generated-workspace__resources {
255
- display: flex;
256
- flex-wrap: wrap;
257
- justify-content: flex-end;
258
- gap: 0.4rem;
259
- }
260
-
261
- .mint-generated-workspace__resources span {
262
- padding: 0.3rem 0.55rem;
263
- border: 1px solid var(--border-color);
264
- border-radius: 999px;
265
- background: var(--bg-secondary);
266
- color: var(--text-secondary);
236
+ color: var(--text-muted);
237
+ font-family: var(--font-mono);
267
238
  font-size: 0.75rem;
268
- font-weight: 600;
269
239
  }
270
240
 
271
- .mint-generated-workspace__result {
272
- max-width: 76rem;
273
- min-height: 24rem;
274
- margin: 0 auto;
275
- padding: clamp(1rem, 2vw, 1.75rem);
276
- border: 1px solid var(--border-color);
277
- border-radius: 1rem;
278
- background: var(--bg-secondary);
279
- box-shadow: var(--shadow-sm);
280
- }
281
-
282
- @media (max-width: 640px) {
283
- .mint-generated-workspace__header {
284
- flex-direction: column;
285
- }
286
-
287
- .mint-generated-workspace__resources {
288
- justify-content: flex-start;
289
- }
241
+ .mint-generated-workspace__description {
242
+ margin: 0 0 var(--mint-gutter);
243
+ color: var(--text-secondary);
290
244
  }
291
245
  </style>
@@ -133,6 +133,7 @@ function humanize(value: string): string {
133
133
  padding: 0.75rem 1rem;
134
134
  border-radius: var(--radius-md);
135
135
  background: var(--bg-tertiary);
136
+ font-family: var(--font-mono);
136
137
  font-size: 0.8125rem;
137
138
  line-height: 1.55;
138
139
  white-space: pre-wrap;
@@ -152,7 +153,7 @@ function humanize(value: string): string {
152
153
  gap: 1rem;
153
154
  padding: 1rem;
154
155
  border: 1px solid var(--border-color);
155
- border-radius: 0.75rem;
156
+ border-radius: var(--radius-lg);
156
157
  color: var(--color-primary);
157
158
  text-decoration: none;
158
159
  }
@@ -105,11 +105,11 @@ function formatValue(value: string | number | boolean | null): string {
105
105
  </template>
106
106
 
107
107
  <style scoped>
108
- .mint-status-panels__tabs { padding: 0 1rem; }
109
- .mint-status-panels__body { padding: 0.75rem 1rem; }
108
+ .mint-status-panels__tabs { padding: 0 var(--mint-section-card-inset); }
109
+ .mint-status-panels__body { padding: 0.75rem var(--mint-section-card-inset); }
110
110
  .mint-status-panels__bar { display: flex; flex-direction: column; gap: 0.5rem; }
111
111
  .mint-status-panels__series { align-self: flex-end; }
112
- .mint-status-panels__kv { margin: 0; padding: 0.25rem 1rem 0.5rem; }
112
+ .mint-status-panels__kv { margin: 0; padding: 0.25rem var(--mint-section-card-inset) 0.5rem; }
113
113
  .mint-status-panels__kv-row {
114
114
  display: flex;
115
115
  align-items: baseline;
@@ -360,7 +360,7 @@ onBeforeUnmount(() => {
360
360
  margin: 0;
361
361
  padding: 1rem;
362
362
  border: 1px solid var(--mint-error-border);
363
- border-radius: 0.75rem;
363
+ border-radius: var(--radius-lg);
364
364
  background: var(--mint-error-bg);
365
365
  color: var(--mint-error-hover);
366
366
  }
@@ -52,8 +52,8 @@ function initState() {
52
52
  <HstText v-model="state.icon" title="icon" />
53
53
  <HstSelect v-model="state.size" title="size" :options="['sm', 'md', 'topbar', 'lg']" />
54
54
  <HstSelect v-model="state.variant" title="variant" :options="['solid', 'tinted']" />
55
- <HstSelect v-model="state.tone" title="tone (deprecated, no effect; removed in 1.5)" :options="['', 'neutral', 'primary', 'cta', 'success', 'warning', 'error', 'info']" />
56
- <HstText v-model="state.color" title="color (deprecated, no effect; removed in 1.5)" />
55
+ <HstSelect v-model="state.tone" title="tone (deprecated, no effect; removed in 2.0)" :options="['', 'neutral', 'primary', 'cta', 'success', 'warning', 'error', 'info']" />
56
+ <HstText v-model="state.color" title="color (deprecated, no effect; removed in 2.0)" />
57
57
  </template>
58
58
  </Variant>
59
59
 
@@ -18,9 +18,9 @@ interface Props {
18
18
  icon?: string
19
19
  size?: 'sm' | 'md' | 'topbar' | 'lg'
20
20
  variant?: 'solid' | 'tinted'
21
- /** @deprecated v2 design: the tile is always the neutral accent-soft tile, so the tone has no visible effect. Removed in 1.5. */
21
+ /** @deprecated v2 design: the tile is always the neutral accent-soft tile, so the tone has no visible effect. Removed in 2.0. */
22
22
  tone?: Tone
23
- /** @deprecated v2 design: the tile is always the neutral accent-soft tile, so the color has no visible effect. Removed in 1.5. */
23
+ /** @deprecated v2 design: the tile is always the neutral accent-soft tile, so the color has no visible effect. Removed in 2.0. */
24
24
  color?: string
25
25
  }
26
26
 
@@ -2,7 +2,7 @@
2
2
  /** One-card page region from the MINT 1.1 card system: 12px radius, the
3
3
  * single --card-shadow token, a header strip (icon chip, title, mono count,
4
4
  * actions), and hairline-separated content — no card-in-card. */
5
- import { computed } from 'vue'
5
+ import { computed, useId } from 'vue'
6
6
  import type { SectionCardAccent, SectionCardDivider } from '../types'
7
7
 
8
8
  interface Props {
@@ -23,6 +23,8 @@ interface Props {
23
23
  * definite height (a flex or grid track).
24
24
  */
25
25
  fill?: boolean
26
+ /** The title toggles the body; bind `v-model:collapsed` to control or keep the state. */
27
+ collapsible?: boolean
26
28
  }
27
29
 
28
30
  const props = withDefaults(defineProps<Props>(), {
@@ -32,8 +34,14 @@ const props = withDefaults(defineProps<Props>(), {
32
34
  uppercase: false,
33
35
  divider: 'strong',
34
36
  fill: false,
37
+ collapsible: false,
35
38
  })
36
39
 
40
+ const collapsed = defineModel<boolean>('collapsed', { default: false })
41
+ const bodyId = useId()
42
+ /** `collapsed` hides content only on a card that also shows the toggle to open it again. */
43
+ const hidden = computed(() => props.collapsible && collapsed.value)
44
+
37
45
  const iconPaths = computed(() =>
38
46
  props.icon === undefined ? [] : Array.isArray(props.icon) ? props.icon : [props.icon],
39
47
  )
@@ -41,6 +49,7 @@ const iconPaths = computed(() =>
41
49
  const headerClasses = computed(() => [
42
50
  'mint-section-card__header',
43
51
  props.divider === 'light' ? 'mint-section-card__header--divider-light' : '',
52
+ hidden.value ? 'mint-section-card__header--collapsed' : '',
44
53
  ])
45
54
 
46
55
  const titleClasses = computed(() => [
@@ -51,6 +60,7 @@ const titleClasses = computed(() => [
51
60
  const cardClasses = computed(() => [
52
61
  'mint-section-card',
53
62
  props.fill ? 'mint-section-card--fill' : '',
63
+ hidden.value ? 'mint-section-card--collapsed' : '',
54
64
  ])
55
65
  </script>
56
66
 
@@ -74,20 +84,46 @@ const cardClasses = computed(() => [
74
84
  <path v-for="(d, i) in iconPaths" :key="i" :d="d" />
75
85
  </svg>
76
86
  </span>
77
- <h2 :class="titleClasses">{{ title }}</h2>
78
- <span v-if="count !== undefined" class="mint-section-card__count">{{ count }}</span>
87
+ <!-- Disclosure pattern: the button sits inside the heading, never the reverse. -->
88
+ <h2 :class="titleClasses">
89
+ <button
90
+ v-if="collapsible"
91
+ type="button"
92
+ class="mint-section-card__toggle"
93
+ :aria-expanded="!collapsed"
94
+ :aria-controls="bodyId"
95
+ @click="collapsed = !collapsed"
96
+ >
97
+ {{ title }}
98
+ <span v-if="count !== undefined" class="mint-section-card__count">{{ count }}</span>
99
+ <svg
100
+ class="mint-section-card__chevron"
101
+ viewBox="0 0 24 24"
102
+ fill="none"
103
+ stroke="currentColor"
104
+ stroke-width="2"
105
+ stroke-linecap="round"
106
+ stroke-linejoin="round"
107
+ aria-hidden="true"
108
+ >
109
+ <path d="m6 9 6 6 6-6" />
110
+ </svg>
111
+ </button>
112
+ <template v-else>{{ title }}</template>
113
+ </h2>
114
+ <span v-if="count !== undefined && !collapsible" class="mint-section-card__count">{{ count }}</span>
79
115
  <div class="mint-section-card__spacer"></div>
80
116
  <slot name="actions" />
81
117
  </div>
82
118
  <!-- Controls belong to the card's chrome, so they sit outside the body and
83
119
  stay put while the body scrolls. -->
84
- <div v-if="$slots.toolbar" class="mint-section-card__toolbar">
120
+ <div v-if="$slots.toolbar" v-show="!hidden" class="mint-section-card__toolbar">
85
121
  <slot name="toolbar" />
86
122
  </div>
87
- <div class="mint-section-card__body">
123
+ <div :id="bodyId" v-show="!hidden" class="mint-section-card__body">
88
124
  <slot />
89
125
  </div>
90
- <div v-if="$slots.footer" class="mint-section-card__footer">
126
+ <div v-if="$slots.footer" v-show="!hidden" class="mint-section-card__footer">
91
127
  <slot name="footer" />
92
128
  </div>
93
129
  </section>
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
7
7
  import { autoUpdate, computePosition, offset, shift } from '@floating-ui/dom'
8
- import { Check, CircleHelp, Play } from 'lucide-vue-next'
8
+ import { Check, CircleHelp, Play, X } from 'lucide-vue-next'
9
9
  import { useEventListener } from '../../composables/useEventListener'
10
10
  import type { OnboardingState } from '../../composables/useOnboarding'
11
11
  import OnboardingLayerInternal from './OnboardingLayerInternal.vue'
@@ -50,6 +50,12 @@ watch(open, async (isOpen) => {
50
50
  })
51
51
  onBeforeUnmount(() => stopAutoUpdate?.())
52
52
 
53
+ // The close button removes itself, so focus moves on to Help instead of the page.
54
+ function hideChecklist() {
55
+ props.state.setChecklistHidden(true)
56
+ nextTick(() => helpButton.value?.focus())
57
+ }
58
+
53
59
  function run(action: () => void) {
54
60
  open.value = false
55
61
  action()
@@ -66,19 +72,29 @@ useEventListener(document, 'keydown', (event) => {
66
72
 
67
73
  <template>
68
74
  <div v-if="hasContent" ref="root" class="mint-onboarding-help">
69
- <button
70
- v-if="state.checklistVisible.value"
71
- type="button"
72
- class="mint-onboarding-help__chip"
73
- :class="{ 'mint-onboarding-help__chip--open': open }"
74
- data-onboarding="topbar-checklist"
75
- :aria-expanded="open"
76
- @click="toggle"
77
- >
78
- Getting started
79
- <span class="mint-onboarding-help__count">{{ state.tasksDone.value }}/{{ state.tasks.value.length }}</span>
80
- <span class="mint-onboarding-help__bar" aria-hidden="true"><span :style="{ width: `${progress}%` }" /></span>
81
- </button>
75
+ <span v-if="state.checklistVisible.value" class="mint-onboarding-help__chip-group">
76
+ <button
77
+ type="button"
78
+ class="mint-onboarding-help__chip"
79
+ :class="{ 'mint-onboarding-help__chip--open': open }"
80
+ data-onboarding="topbar-checklist"
81
+ :aria-expanded="open"
82
+ @click="toggle"
83
+ >
84
+ Getting started
85
+ <span class="mint-onboarding-help__count">{{ state.tasksDone.value }}/{{ state.tasks.value.length }}</span>
86
+ <span class="mint-onboarding-help__bar" aria-hidden="true"><span :style="{ width: `${progress}%` }" /></span>
87
+ </button>
88
+ <button
89
+ type="button"
90
+ class="mint-onboarding-help__chip-close"
91
+ aria-label="Hide checklist"
92
+ title="Hide checklist (Help can show it again)"
93
+ @click="hideChecklist"
94
+ >
95
+ <X :size="12" aria-hidden="true" />
96
+ </button>
97
+ </span>
82
98
  <button
83
99
  ref="helpButton"
84
100
  type="button"