create-mercato-app 0.5.1-develop.2949.009dcdd2d5 → 0.5.1-develop.2954.610bab2d08

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 (23) hide show
  1. package/package.json +1 -1
  2. package/template/package.json.template +4 -4
  3. package/template/scripts/dev-runtime.mjs +14 -42
  4. package/template/scripts/dev-splash-coding-flow.mjs +26 -9
  5. package/template/scripts/dev.mjs +22 -67
  6. package/template/src/app/globals.css +64 -32
  7. package/template/src/components/DemoFeedbackWidget.tsx +16 -9
  8. package/template/src/components/GlobalNoticeBars.tsx +7 -7
  9. package/template/src/components/StartPageContent.tsx +6 -6
  10. package/template/src/components/ui/button.tsx +1 -59
  11. package/template/src/modules/example/__integration__/TC-UMES-005.spec.ts +32 -21
  12. package/template/src/modules/example/api/blog/[id]/route.ts +1 -1
  13. package/template/src/modules/example/backend/umes-integrations/page.tsx +31 -18
  14. package/template/src/modules/example/data/entities.ts +1 -1
  15. package/template/src/modules/example/widgets/components.ts +2 -2
  16. package/template/src/modules/example/widgets/dashboard/todos/widget.client.tsx +5 -4
  17. package/template/src/modules/example/widgets/injection/catalog-seo-report/widget.client.tsx +4 -4
  18. package/template/src/modules/example/widgets/injection/customer-priority-detail/widget.client.tsx +19 -8
  19. package/template/src/modules/example/widgets/injection/portal-recent-activity/widget.client.tsx +4 -4
  20. package/template/src/modules/example/widgets/injection/sales-todos/widget.client.tsx +3 -3
  21. package/template/src/modules/example_customers_sync/api/example-customers-sync/mappings/route.ts +1 -1
  22. package/template/src/modules/ratelimit_probe/api/ping/route.ts +35 -0
  23. package/template/src/modules.ts +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-mercato-app",
3
- "version": "0.5.1-develop.2949.009dcdd2d5",
3
+ "version": "0.5.1-develop.2954.610bab2d08",
4
4
  "type": "module",
5
5
  "description": "Create a new Open Mercato application",
6
6
  "main": "./dist/index.js",
@@ -77,7 +77,7 @@
77
77
  "@uiw/react-markdown-preview": "^5.1.5",
78
78
  "@uiw/react-md-editor": "^4.0.11",
79
79
  "@xyflow/react": "^12.6.0",
80
- "ai": "^6.0.0",
80
+ "ai": "^6.0.168",
81
81
  "awilix": "^12.0.5",
82
82
  "bcryptjs": "^3.0.3",
83
83
  "class-variance-authority": "^0.7.1",
@@ -86,7 +86,7 @@
86
86
  "lucide-react": "^0.556.0",
87
87
  "mammoth": "^1.9.0",
88
88
  "newrelic": "^13.7.0",
89
- "next": "16.2.3",
89
+ "next": "16.2.4",
90
90
  "pg": "8.20.0",
91
91
  "pdfjs-dist": "^5.4.149",
92
92
  "react": "19.2.1",
@@ -100,8 +100,8 @@
100
100
  "semver": "^7.7.3",
101
101
  "tailwind-merge": "^3.4.0",
102
102
  "zod": "^4.1.13",
103
- "@stripe/react-stripe-js": "^3.9.0",
104
- "@stripe/stripe-js": "^7.8.0",
103
+ "@stripe/react-stripe-js": "^6.2.0",
104
+ "@stripe/stripe-js": "^9.2.0",
105
105
  "@open-mercato/gateway-stripe": "{{PACKAGE_VERSION}}",
106
106
  "@open-mercato/sync-akeneo": "{{PACKAGE_VERSION}}"
107
107
  },
@@ -55,9 +55,9 @@ const {
55
55
  stripAnsi,
56
56
  wrapListLines,
57
57
  } = await import(resolveSplashHelpersImport())
58
- const { resolveProjectBinary, resolveSpawnCommand } = await import(resolveSpawnUtilsImport())
58
+ const { resolveSpawnCommand } = await import(resolveSpawnUtilsImport())
59
59
 
60
- const command = resolveProjectBinary(process.platform === 'win32' ? 'mercato.cmd' : 'mercato')
60
+ const command = process.platform === 'win32' ? 'mercato.cmd' : 'mercato'
61
61
  const classic = process.argv.includes('--classic') || isEnabledEnvFlag(process.env.OM_DEV_CLASSIC)
62
62
  const verbose = !classic && (process.argv.includes('--verbose') || process.env.MERCATO_DEV_OUTPUT === 'verbose')
63
63
  const rawPassthrough = classic || verbose
@@ -412,10 +412,10 @@ function spawnMercato(args) {
412
412
  return child
413
413
  }
414
414
 
415
- function waitForExit(child, label = 'Child process') {
415
+ function waitForExit(child) {
416
416
  return new Promise((resolve) => {
417
417
  child.on('exit', (code, signal) => {
418
- resolve({ label, code, signal })
418
+ resolve({ code, signal })
419
419
  })
420
420
  })
421
421
  }
@@ -441,32 +441,6 @@ function resolveChildExitCode(result, fallback = 1) {
441
441
  return fallback
442
442
  }
443
443
 
444
- function formatChildExitStatus(result) {
445
- if (typeof result?.code === 'number') {
446
- return `exit code ${result.code}`
447
- }
448
- if (result?.signal) {
449
- return `signal ${result.signal}`
450
- }
451
- return 'an unknown status'
452
- }
453
-
454
- function resolveUnexpectedExitCode(result) {
455
- const exitCode = resolveChildExitCode(result, 1)
456
- return exitCode === 0 ? 1 : exitCode
457
- }
458
-
459
- function reportUnexpectedChildExit(result) {
460
- const message = `❌ ${result?.label ?? 'Child process'} exited unexpectedly with ${formatChildExitStatus(result)}`
461
- console.error(message)
462
- rememberRawLog(message)
463
- publishRuntimeFailure(message, {
464
- progressCurrent: splashState.progressCurrent >= runtimeProgressCurrent ? splashState.progressCurrent : runtimeProgressCurrent,
465
- progressLabel: splashState.progressLabel || startupProgress.label,
466
- failureLines: [...collectRuntimeFailureLines(), message].slice(-10),
467
- })
468
- }
469
-
470
444
  function joinBaseUrl(baseUrl, pathname) {
471
445
  return `${String(baseUrl ?? '').replace(/\/$/, '')}${pathname}`
472
446
  }
@@ -1547,16 +1521,15 @@ async function runClassicRuntime() {
1547
1521
 
1548
1522
  const watch = spawnMercato(['generate', 'watch', '--skip-initial'])
1549
1523
  const server = spawnMercato(['server', 'dev'])
1550
- const result = await Promise.race([
1551
- waitForExit(watch, 'Generator watch'),
1552
- waitForExit(server, 'App runtime'),
1553
- ])
1524
+ const result = await Promise.race([waitForExit(watch), waitForExit(server)])
1554
1525
  if (isGracefulShutdownResult(result)) {
1555
1526
  return
1556
1527
  }
1557
1528
 
1558
- reportUnexpectedChildExit(result)
1559
- shutdown(resolveUnexpectedExitCode(result))
1529
+ // Unexpected child exit MUST surface as non-zero even if the child reported
1530
+ // code 0 — hiding a broken runtime as success masks failures from scripts/CI.
1531
+ const childCode = resolveChildExitCode(result, 1)
1532
+ shutdown(childCode === 0 ? 1 : childCode)
1560
1533
  }
1561
1534
 
1562
1535
  if (classic) {
@@ -1571,11 +1544,10 @@ printRuntimePackagesSummary()
1571
1544
  const watch = startFilteredChild(['generate', 'watch', '--skip-initial'], 'Generator watch', classifyWatchLine)
1572
1545
  const server = startFilteredChild(['server', 'dev'], 'App runtime', classifyServerLine)
1573
1546
 
1574
- const result = await Promise.race([
1575
- waitForExit(watch, 'Generator watch'),
1576
- waitForExit(server, 'App runtime'),
1577
- ])
1547
+ const result = await Promise.race([waitForExit(watch), waitForExit(server)])
1578
1548
  if (!isGracefulShutdownResult(result)) {
1579
- reportUnexpectedChildExit(result)
1580
- shutdown(resolveUnexpectedExitCode(result))
1549
+ // Unexpected child exit MUST surface as non-zero even if the child reported
1550
+ // code 0 — hiding a broken runtime as success masks failures from scripts/CI.
1551
+ const childCode = resolveChildExitCode(result, 1)
1552
+ shutdown(childCode === 0 ? 1 : childCode)
1581
1553
  }
@@ -303,11 +303,10 @@ function writeJson(res, statusCode, payload) {
303
303
  res.end(JSON.stringify(payload))
304
304
  }
305
305
 
306
- // Reject any string that would smuggle control characters or NUL bytes through
307
- // the shell-launching codepath. This is the canonical sanitizer for paths and
308
- // shell argument values used by the splash coding flow; CodeQL recognizes the
309
- // explicit reject as a safe boundary.
310
- const SHELL_UNSAFE_CHAR_PATTERN = /[\u0000-\u001f\u007f`$"';&|<>()[\]!^]/
306
+ // Reject control characters and shell metacharacters that could alter command
307
+ // semantics. Quotes are allowed here because these values are passed through
308
+ // `spawn` argument arrays instead of shell interpolation.
309
+ const SHELL_UNSAFE_CHAR_PATTERN = /[\u0000-\u001f\u007f`$&|;<>()[\]{}*!?~]/
311
310
 
312
311
  export function isShellSafePathString(value) {
313
312
  return typeof value === 'string'
@@ -324,11 +323,29 @@ export function assertShellSafePath(value, label) {
324
323
 
325
324
  export { sanitizeLaunchDirectory }
326
325
 
327
- function sanitizeLaunchDirectory(value) {
328
- const homeFallback = path.resolve(os.homedir())
329
- const tmpFallback = path.resolve(os.tmpdir())
330
- const fallback = isShellSafePathString(homeFallback) ? homeFallback : assertShellSafePath(tmpFallback, 'Fallback launch directory')
326
+ function resolveSafeLaunchFallbackDirectory() {
327
+ const candidates = [process.cwd(), os.homedir(), path.parse(process.cwd()).root]
328
+ for (const candidate of candidates) {
329
+ const resolvedCandidate = path.resolve(candidate)
330
+ if (!isShellSafePathString(resolvedCandidate)) {
331
+ continue
332
+ }
331
333
 
334
+ try {
335
+ const stat = fs.statSync(resolvedCandidate)
336
+ if (stat.isDirectory()) {
337
+ return resolvedCandidate
338
+ }
339
+ } catch {
340
+ // Try next fallback candidate
341
+ }
342
+ }
343
+
344
+ return path.parse(process.cwd()).root
345
+ }
346
+
347
+ function sanitizeLaunchDirectory(value) {
348
+ const fallback = resolveSafeLaunchFallbackDirectory()
332
349
  if (!isShellSafePathString(value) || value.trim().length === 0) {
333
350
  return fallback
334
351
  }
@@ -174,6 +174,14 @@ function isEnabledEnvFlag(value) {
174
174
  return ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase())
175
175
  }
176
176
 
177
+ // OM_DEV_AUTO_MIGRATE defaults to ON: yarn dev applies pending migrations once
178
+ // at startup unless the user explicitly opts out. Documented in template AGENTS.md.
179
+ function shouldAutoMigrateOnDev() {
180
+ const raw = process.env.OM_DEV_AUTO_MIGRATE
181
+ if (typeof raw !== 'string') return true
182
+ return !['0', 'false', 'no', 'off'].includes(raw.trim().toLowerCase())
183
+ }
184
+
177
185
  const splashPortConfig = (() => {
178
186
  try {
179
187
  return resolveSplashPortConfig()
@@ -1440,70 +1448,6 @@ async function runPassthroughStage(label, commandArgs, options = {}) {
1440
1448
  console.log(`✅ ${formatProgressLine(label, stageCurrent, stageTotal, resolveProgressPercent(stageCurrent, stageTotal))} in ${formatDuration(Date.now() - startedAt)}`)
1441
1449
  }
1442
1450
 
1443
- function isAutoMigrateEnabled() {
1444
- const raw = process.env.OM_DEV_AUTO_MIGRATE
1445
- if (typeof raw !== 'string') return true
1446
- const normalized = raw.trim().toLowerCase()
1447
- if (normalized === '') return true
1448
- return !['0', 'false', 'no', 'off'].includes(normalized)
1449
- }
1450
-
1451
- async function runAutoMigrateIfEnabled() {
1452
- if (!isAutoMigrateEnabled()) {
1453
- console.log('ℹ️ Skipping auto-migrate: OM_DEV_AUTO_MIGRATE is disabled.')
1454
- return
1455
- }
1456
-
1457
- const label = '🗄️ Auto-applying database migrations'
1458
- const startedAt = Date.now()
1459
- console.log(`${label}...`)
1460
- updateSplashState({
1461
- phase: label,
1462
- detail: 'Running yarn db:migrate before dev launch',
1463
- activity: 'Auto-migrate running',
1464
- })
1465
-
1466
- const child = spawnCommand(yarnCommand, ['db:migrate'], {
1467
- label: 'db:migrate (auto)',
1468
- logFile: getDevRunnerLog(),
1469
- })
1470
- const capturedLines = []
1471
- const capture = (line) => {
1472
- capturedLines.push(line)
1473
- if (capturedLines.length > 200) capturedLines.shift()
1474
- }
1475
- connectLineStream(child.stdout, capture)
1476
- connectLineStream(child.stderr, capture)
1477
-
1478
- const result = await waitForClose(child)
1479
- if (isGracefulShutdownResult(result)) return
1480
-
1481
- const exitCode = resolveChildExitCode(result)
1482
- if (exitCode === 0) {
1483
- console.log(`✅ ${label} in ${formatDuration(Date.now() - startedAt)}`)
1484
- return
1485
- }
1486
-
1487
- console.warn('')
1488
- console.warn('⚠️ Auto-migrate reported a non-zero exit.')
1489
- console.warn(' This usually means a migration conflict — for example, a parallel')
1490
- console.warn(' `yarn db:migrate` from another shell already applied the pending change.')
1491
- console.warn(' Last captured output (tail):')
1492
- for (const line of capturedLines.slice(-20)) {
1493
- console.warn(` | ${line}`)
1494
- }
1495
- console.warn('')
1496
- console.warn(' Suggested next steps:')
1497
- console.warn(' • Re-run `yarn db:migrate` manually to inspect the exact error.')
1498
- console.warn(' • If a migration was only partially applied, roll back with')
1499
- console.warn(' `yarn db:migrate --down` and re-apply.')
1500
- console.warn(' • To disable auto-migrate entirely for this project, set')
1501
- console.warn(' `OM_DEV_AUTO_MIGRATE=0` in `.env.local`.')
1502
- console.warn(' Continuing to launch dev — the app may still work if the schema is')
1503
- console.warn(' actually up to date. If it does not, fix the migration before reloading.')
1504
- console.warn('')
1505
- }
1506
-
1507
1451
  function startPackageWatch() {
1508
1452
  if (classic) {
1509
1453
  const child = spawnCommand(yarnCommand, ['watch:packages'], {
@@ -1629,7 +1573,10 @@ function launchMonorepoAppDev() {
1629
1573
 
1630
1574
  app.on('close', (code, signal) => {
1631
1575
  if (!shuttingDown) {
1632
- shutdown(resolveChildExitCode({ code, signal }, 0))
1576
+ // Unexpected child exit MUST surface as non-zero even if the child reported
1577
+ // code 0 — hiding a broken runtime as success masks failures from scripts/CI.
1578
+ const childCode = resolveChildExitCode({ code, signal }, 1)
1579
+ shutdown(childCode === 0 ? 1 : childCode)
1633
1580
  }
1634
1581
  })
1635
1582
  }
@@ -1720,7 +1667,10 @@ async function runClassicStandaloneDev() {
1720
1667
  await runRawYarnCommand(['install'])
1721
1668
  }
1722
1669
 
1723
- await runAutoMigrateIfEnabled()
1670
+ if (shouldAutoMigrateOnDev()) {
1671
+ await runRawYarnCommand(['db:migrate'])
1672
+ }
1673
+
1724
1674
  launchStandaloneDev()
1725
1675
  }
1726
1676
 
@@ -1751,7 +1701,12 @@ async function main() {
1751
1701
  stageTotal: standaloneStageTotal,
1752
1702
  })
1753
1703
  }
1754
- await runAutoMigrateIfEnabled()
1704
+ if (shouldAutoMigrateOnDev()) {
1705
+ await runPassthroughStage('🗄️ Applying database migrations', ['db:migrate'], {
1706
+ stageCurrent: 2,
1707
+ stageTotal: standaloneStageTotal,
1708
+ })
1709
+ }
1755
1710
  launchStandaloneDev()
1756
1711
  return
1757
1712
  }
@@ -51,27 +51,9 @@ TODO: Fix that latter to have reference by the package names
51
51
  --color-primary: var(--primary);
52
52
  --color-brand-violet: var(--brand-violet);
53
53
  --color-brand-violet-foreground: var(--brand-violet-foreground);
54
-
55
- /* Accent indigo — used by selection controls (checkbox/radio/switch) */
56
- --color-accent-indigo: var(--accent-indigo);
57
- --color-accent-indigo-foreground: var(--accent-indigo-foreground);
58
-
59
- /* Disabled control tokens */
60
54
  --color-bg-disabled: var(--bg-disabled);
61
55
  --color-text-disabled: var(--text-disabled);
62
56
  --color-border-disabled: var(--border-disabled);
63
-
64
- /* Figma focus ring shadow */
65
- --shadow-focus: 0 0 0 2px var(--focus-ring-inner), 0 0 0 4px var(--focus-ring-outer);
66
-
67
- /* Social brand colors (theme-invariant) */
68
- --color-brand-apple: var(--brand-apple);
69
- --color-brand-github: var(--brand-github);
70
- --color-brand-x: var(--brand-x);
71
- --color-brand-google-stroke: var(--brand-google-stroke);
72
- --color-brand-facebook: var(--brand-facebook);
73
- --color-brand-dropbox: var(--brand-dropbox);
74
- --color-brand-linkedin: var(--brand-linkedin);
75
57
  --color-popover-foreground: var(--popover-foreground);
76
58
  --color-popover: var(--popover);
77
59
  --color-card-foreground: var(--card-foreground);
@@ -79,7 +61,7 @@ TODO: Fix that latter to have reference by the package names
79
61
  --radius-sm: calc(var(--radius) - 4px);
80
62
  --radius-md: calc(var(--radius) - 2px);
81
63
  --radius-lg: var(--radius);
82
- --radius-xl: calc(var(--radius) + 4px);
64
+ --radius-xl: calc(var(--radius) + 6px);
83
65
 
84
66
  /* ═══ Design System: Semantic Status Colors ═══ */
85
67
  --color-status-error-bg: var(--status-error-bg);
@@ -107,11 +89,28 @@ TODO: Fix that latter to have reference by the package names
107
89
  --color-status-neutral-border: var(--status-neutral-border);
108
90
  --color-status-neutral-icon: var(--status-neutral-icon);
109
91
 
92
+ /* ═══ Design System: Accent Colors ═══ */
93
+ --color-accent-indigo: var(--accent-indigo);
94
+ --color-accent-indigo-foreground: var(--accent-indigo-foreground);
95
+
96
+ /* ═══ Design System: Brand Colors ═══ */
97
+ --color-brand-violet: var(--brand-violet);
98
+ --color-brand-lime: var(--brand-lime);
99
+
100
+ /* ═══ Design System: Social Brand Colors (theme-invariant) ═══ */
101
+ --color-brand-apple: var(--brand-apple);
102
+ --color-brand-github: var(--brand-github);
103
+ --color-brand-x: var(--brand-x);
104
+ --color-brand-google-stroke: var(--brand-google-stroke);
105
+ --color-brand-facebook: var(--brand-facebook);
106
+ --color-brand-dropbox: var(--brand-dropbox);
107
+ --color-brand-linkedin: var(--brand-linkedin);
108
+
110
109
  /* ═══ Design System: Typography ═══ */
111
110
  --font-size-overline: 0.6875rem;
112
111
  --font-size-overline--line-height: 1rem;
113
112
 
114
- /* ═══ Design System: Z-Index Scale (Tailwind v4 namespace: z-{name} → var(--z-index-{name})) ═══ */
113
+ /* ═══ Design System: Z-Index Scale ═══ */
115
114
  --z-index-base: 0;
116
115
  --z-index-sticky: 10;
117
116
  --z-index-dropdown: 20;
@@ -121,6 +120,25 @@ TODO: Fix that latter to have reference by the package names
121
120
  --z-index-tooltip: 60;
122
121
  --z-index-banner: 70;
123
122
  --z-index-top: 100;
123
+
124
+ /* ═══ Design System: Shadow Scale ═══
125
+ Tuned to ink #101828. Dark mode overrides in .dark below.
126
+ xs → flat controls (button, input, icon-button)
127
+ sm → cards, panels, sections
128
+ md → hover elevation, slightly raised cards
129
+ lg → dialogs, overlays, popovers
130
+ xl → floating panels (drawers, side sheets)
131
+ 2xl → top-level modals, command palette
132
+ */
133
+ --shadow-xs: 0 1px 2px rgb(16 24 40 / 0.05);
134
+ --shadow-sm: 0 1px 3px rgb(16 24 40 / 0.08), 0 1px 2px rgb(16 24 40 / 0.06);
135
+ --shadow-md: 0 4px 12px rgb(16 24 40 / 0.08);
136
+ --shadow-lg: 0 12px 32px rgb(16 24 40 / 0.10);
137
+ --shadow-xl: 0 20px 40px rgb(16 24 40 / 0.12);
138
+ --shadow-2xl: 0 25px 50px rgb(16 24 40 / 0.20);
139
+
140
+ /* Figma button focus: 2px white inner + 4px slate-alpha-16 outer */
141
+ --shadow-focus: 0 0 0 2px var(--focus-ring-inner), 0 0 0 4px var(--focus-ring-outer);
124
142
  }
125
143
 
126
144
  :root {
@@ -128,7 +146,9 @@ TODO: Fix that latter to have reference by the package names
128
146
  --font-geist-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
129
147
  --font-geist-mono: ui-monospace, "SFMono-Regular", "SF Mono", Consolas, "Liberation Mono", Menlo, monospace;
130
148
  --radius: 0.625rem;
131
- /* Focus ring tokens (opt-in via .focus-ring-fancy) */
149
+ /* Brand lime theme-invariant (no .dark override). Brand violet is themed (see below + .dark). */
150
+ --brand-lime: #D4F372;
151
+ /* Focus ring tokens (Figma dual-shadow spec — opt-in via .focus-ring-fancy) */
132
152
  --focus-ring-inner: rgba(255, 255, 255, 1);
133
153
  --focus-ring-outer: rgba(153, 160, 174, 0.16);
134
154
  /* Social brand colors — theme-invariant */
@@ -150,8 +170,10 @@ TODO: Fix that latter to have reference by the package names
150
170
  --brand-violet: oklch(0.55 0.2 293);
151
171
  --brand-violet-foreground: oklch(0.985 0 0);
152
172
  --primary-hover: oklch(0.145 0 0);
173
+ /* Accent indigo — used for selection controls (checkbox, radio, switch) */
153
174
  --accent-indigo: #6366f1;
154
175
  --accent-indigo-foreground: #ffffff;
176
+ /* Disabled control tokens (Figma: bg/weak-25, text/disabled-300, stroke-soft-200) */
155
177
  --bg-disabled: #f7f7f7;
156
178
  --text-disabled: #d1d1d1;
157
179
  --border-disabled: #ebebeb;
@@ -209,11 +231,11 @@ TODO: Fix that latter to have reference by the package names
209
231
  --status-warning-border: oklch(0.830 0.070 80);
210
232
  --status-warning-icon: oklch(0.700 0.160 70);
211
233
 
212
- /* Info (hue ~260° — aligned with --chart-blue) */
213
- --status-info-bg: oklch(0.965 0.015 260);
214
- --status-info-text: oklch(0.370 0.100 260);
215
- --status-info-border: oklch(0.830 0.060 260);
216
- --status-info-icon: oklch(0.546 0.245 262.881); /* = --chart-blue */
234
+ /* Info (hue ~277° — indigo, bridges to brand violet) */
235
+ --status-info-bg: oklch(0.962 0.018 272.314); /* indigo-50 #EEF2FF */
236
+ --status-info-text: oklch(0.359 0.144 278.697); /* indigo-800 #3730A3 */
237
+ --status-info-border: oklch(0.870 0.065 274.039);/* indigo-200 #C7D2FE */
238
+ --status-info-icon: oklch(0.511 0.262 276.966); /* indigo-600 #4F46E5 = --chart-indigo */
217
239
 
218
240
  /* Neutral (achromatic — aligned with --muted) */
219
241
  --status-neutral-bg: oklch(0.965 0 0);
@@ -232,8 +254,16 @@ TODO: Fix that latter to have reference by the package names
232
254
 
233
255
  .dark {
234
256
  color-scheme: dark;
257
+ /* Focus ring tokens — dark variant */
235
258
  --focus-ring-inner: var(--background);
236
259
  --focus-ring-outer: rgba(255, 255, 255, 0.18);
260
+ /* Shadow scale — dark mode: use pure black, higher opacity for visibility */
261
+ --shadow-xs: 0 1px 2px rgb(0 0 0 / 0.30);
262
+ --shadow-sm: 0 1px 3px rgb(0 0 0 / 0.40), 0 1px 2px rgb(0 0 0 / 0.30);
263
+ --shadow-md: 0 4px 12px rgb(0 0 0 / 0.50);
264
+ --shadow-lg: 0 12px 32px rgb(0 0 0 / 0.60);
265
+ --shadow-xl: 0 20px 40px rgb(0 0 0 / 0.70);
266
+ --shadow-2xl: 0 25px 50px rgb(0 0 0 / 0.80);
237
267
  --background: oklch(0.145 0 0);
238
268
  --foreground: oklch(0.985 0 0);
239
269
  --card: oklch(0.205 0 0);
@@ -245,8 +275,10 @@ TODO: Fix that latter to have reference by the package names
245
275
  --brand-violet: oklch(0.65 0.2 293);
246
276
  --brand-violet-foreground: oklch(0.985 0 0);
247
277
  --primary-hover: oklch(0.85 0 0);
278
+ /* Accent indigo — slightly lighter in dark mode for contrast */
248
279
  --accent-indigo: #818cf8;
249
280
  --accent-indigo-foreground: #ffffff;
281
+ /* Disabled control tokens (dark) */
250
282
  --bg-disabled: oklch(0.25 0 0);
251
283
  --text-disabled: oklch(0.45 0 0);
252
284
  --border-disabled: oklch(0.30 0 0);
@@ -304,11 +336,11 @@ TODO: Fix that latter to have reference by the package names
304
336
  --status-warning-border: oklch(0.420 0.060 80);
305
337
  --status-warning-icon: oklch(0.820 0.160 84.429); /* = dark --chart-amber */
306
338
 
307
- /* Info */
308
- --status-info-bg: oklch(0.220 0.025 260);
309
- --status-info-text: oklch(0.840 0.080 260);
310
- --status-info-border: oklch(0.400 0.060 260);
311
- --status-info-icon: oklch(0.623 0.214 259.815); /* = dark --chart-blue */
339
+ /* Info (indigo — bridges to brand violet in dark mode) */
340
+ --status-info-bg: oklch(0.220 0.040 276);
341
+ --status-info-text: oklch(0.785 0.115 274.713); /* indigo-300 #A5B4FC */
342
+ --status-info-border: oklch(0.359 0.144 278.697);/* indigo-800 #3730A3 */
343
+ --status-info-icon: oklch(0.585 0.233 277.117); /* indigo-500 #6366F1 */
312
344
 
313
345
  /* Neutral */
314
346
  --status-neutral-bg: oklch(0.230 0 0);
@@ -324,7 +356,7 @@ TODO: Fix that latter to have reference by the package names
324
356
  body {
325
357
  @apply bg-background text-foreground;
326
358
  }
327
- /* Native form controls — use the DS indigo accent */
359
+ /* Native form controls — use the DS indigo accent instead of browser default */
328
360
  input[type="checkbox"],
329
361
  input[type="radio"] {
330
362
  accent-color: var(--accent-indigo);
@@ -7,6 +7,7 @@ import { MessageCircle, Send } from 'lucide-react'
7
7
  import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@open-mercato/ui/primitives/dialog'
8
8
  import { Button } from '@open-mercato/ui/primitives/button'
9
9
  import { Input } from '@open-mercato/ui/primitives/input'
10
+ import { Textarea } from '@open-mercato/ui/primitives/textarea'
10
11
  import { Checkbox } from '@open-mercato/ui/primitives/checkbox'
11
12
  import { Spinner } from '@open-mercato/ui/primitives/spinner'
12
13
  import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
@@ -200,19 +201,24 @@ export function DemoFeedbackWidget({ demoModeEnabled }: { demoModeEnabled: boole
200
201
  }, [submitState, resetForm])
201
202
 
202
203
  if (!mounted) return null
203
- if (otherModalOpen && !open) return null
204
204
 
205
205
  const caption = CAPTIONS[captionIndex]
206
206
  const currentCaption = t(caption.key, caption.fallback)
207
207
 
208
+ if (otherModalOpen && !open) return null
209
+
210
+ // Brand-gradient floating CTA. Uses brand CSS vars (no hardcoded hex) +
211
+ // z-banner token (no z-[60]) so it stays DS-compliant while keeping the
212
+ // bespoke 135deg / 0-50-100 gradient that the marketing visual depends on
213
+ // (FancyButton's primary variant uses 161.7deg / 0-35.36-70.72 which
214
+ // truncates the violet-to-end transition and looks banded).
208
215
  const floatingButton = (
209
216
  <button
210
217
  type="button"
211
218
  onClick={() => { setOpen(true); if (submitState === 'sent') resetForm() }}
212
- className="fixed bottom-6 right-6 z-banner flex items-center gap-2 rounded-full px-5 py-3 text-sm font-semibold text-white shadow-xl transition-all hover:scale-105 hover:shadow-2xl active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 animate-[subtle-bounce_2s_ease-in-out_infinite]"
219
+ className="fixed bottom-6 right-6 z-banner flex items-center gap-2 rounded-full px-5 py-3 text-sm font-semibold text-foreground shadow-xl transition-all hover:scale-105 hover:shadow-2xl active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 animate-[subtle-bounce_2s_ease-in-out_infinite]"
213
220
  style={{
214
- background: 'linear-gradient(135deg, #B4F372 0%, #EEFB63 50%, #BC9AFF 100%)',
215
- color: '#1B1B1B',
221
+ backgroundImage: 'linear-gradient(135deg, var(--brand-lime, #B4F372) 0%, #EEFB63 50%, var(--brand-violet, #BC9AFF) 100%)',
216
222
  }}
217
223
  aria-label={t('demoFeedback.button.ariaLabel', 'Open feedback form')}
218
224
  >
@@ -272,14 +278,14 @@ export function DemoFeedbackWidget({ demoModeEnabled }: { demoModeEnabled: boole
272
278
  {fieldErrors.email && <p className="text-xs text-status-error-text">{fieldErrors.email}</p>}
273
279
  </div>
274
280
 
275
- <textarea
281
+ <Textarea
276
282
  id="feedback-message"
277
283
  rows={3}
278
284
  placeholder={t('demoFeedback.form.message', 'Your message (optional)')}
279
285
  value={message}
280
286
  onChange={(e) => setMessage(e.target.value)}
281
287
  disabled={submitState === 'sending'}
282
- className="flex w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 resize-none"
288
+ className="resize-none"
283
289
  />
284
290
 
285
291
  <label className="flex items-start gap-2.5 text-xs text-muted-foreground leading-relaxed">
@@ -359,12 +365,13 @@ export function DemoFeedbackWidget({ demoModeEnabled }: { demoModeEnabled: boole
359
365
 
360
366
  <Button
361
367
  type="button"
362
- className="mt-1 w-full gap-2"
368
+ className="mt-1 w-full gap-2 text-foreground"
363
369
  disabled={submitState === 'sending'}
364
370
  onClick={handleSubmit}
365
371
  style={{
366
- background: 'linear-gradient(135deg, #B4F372 0%, #EEFB63 50%, #BC9AFF 100%)',
367
- color: '#1B1B1B',
372
+ // Same brand-gradient as the floating CTA (135deg / 0-50-100,
373
+ // brand vars instead of hex literals to satisfy DS rules).
374
+ backgroundImage: 'linear-gradient(135deg, var(--brand-lime, #B4F372) 0%, #EEFB63 50%, var(--brand-violet, #BC9AFF) 100%)',
368
375
  }}
369
376
  >
370
377
  {submitState === 'sending' ? (
@@ -54,11 +54,11 @@ export function GlobalNoticeBars({ demoModeEnabled }: { demoModeEnabled: boolean
54
54
  }
55
55
 
56
56
  return (
57
- <div className="pointer-events-none fixed inset-x-0 bottom-4 z-banner flex flex-col items-center gap-3 px-4">
57
+ <div className="pointer-events-none fixed inset-x-0 bottom-4 z-[70] flex flex-col items-center gap-3 px-4">
58
58
  {showDemoNotice ? (
59
- <div className="pointer-events-auto w-full max-w-4xl rounded-lg border border-status-warning-border bg-status-warning-bg p-4 shadow-lg backdrop-blur">
59
+ <div className="pointer-events-auto w-full max-w-4xl rounded-lg border border-amber-200 bg-amber-50/90 p-4 shadow-lg backdrop-blur supports-[backdrop-filter]:bg-amber-50/70 dark:border-amber-900/70 dark:bg-amber-950/40">
60
60
  <div className="flex items-start gap-3">
61
- <div className="flex-1 text-sm text-status-warning-text space-y-1">
61
+ <div className="flex-1 text-sm text-amber-900 dark:text-amber-50 space-y-1">
62
62
  <p className="font-medium">{t('notices.demo.title', 'Demo Environment')}</p>
63
63
  <p>
64
64
  {t('notices.demo.description', 'This instance is provided for demo purposes only. Data may be reset at any time and is not retained for any guaranteed period.')}
@@ -69,22 +69,22 @@ export function GlobalNoticeBars({ demoModeEnabled }: { demoModeEnabled: boolean
69
69
  href="https://github.com/open-mercato"
70
70
  target="_blank"
71
71
  rel="noreferrer"
72
- className="underline font-medium hover:text-status-warning-text"
72
+ className="underline font-medium hover:text-amber-800 dark:hover:text-amber-200"
73
73
  >
74
74
  {t('notices.demo.installLink', 'Install Open Mercato locally')}
75
75
  </a>
76
76
  . {t('notices.demo.reviewLinks', 'Review our')}{' '}
77
- <Link className="underline font-medium hover:text-status-warning-text" href="/terms">
77
+ <Link className="underline font-medium hover:text-amber-800 dark:hover:text-amber-200" href="/terms">
78
78
  {t('common.terms')}
79
79
  </Link>{' '}
80
80
  {t('notices.demo.and', 'and')}{' '}
81
- <Link className="underline font-medium hover:text-status-warning-text" href="/privacy">
81
+ <Link className="underline font-medium hover:text-amber-800 dark:hover:text-amber-200" href="/privacy">
82
82
  {t('common.privacy')}
83
83
  </Link>
84
84
  .
85
85
  </p>
86
86
  </div>
87
- <Button variant="ghost" size="icon" onClick={handleDismissDemo} className="shrink-0 text-status-warning-text">
87
+ <Button variant="ghost" size="icon" onClick={handleDismissDemo} className="shrink-0 text-amber-900 dark:text-amber-100">
88
88
  <X className="size-4" />
89
89
  </Button>
90
90
  </div>
@@ -135,16 +135,16 @@ export function StartPageContent({ showStartPage: initialShowStartPage, showOnbo
135
135
  </section>
136
136
  ) : null}
137
137
 
138
- <section className="rounded-lg border bg-status-info-bg border-status-info-border p-4">
138
+ <section className="rounded-lg border bg-blue-50 dark:bg-blue-950/20 border-blue-200 dark:border-blue-900 p-4">
139
139
  <div className="flex items-start gap-3">
140
- <Info className="size-5 text-status-info-icon shrink-0 mt-0.5" />
140
+ <Info className="size-5 text-blue-600 dark:text-blue-400 shrink-0 mt-0.5" />
141
141
  <div className="flex-1">
142
- <h3 className="text-sm font-semibold text-status-info-text mb-1">{t('startPage.defaultPassword.title', 'Default Password')}</h3>
143
- <p className="text-sm text-status-info-text">
142
+ <h3 className="text-sm font-semibold text-blue-900 dark:text-blue-100 mb-1">{t('startPage.defaultPassword.title', 'Default Password')}</h3>
143
+ <p className="text-sm text-blue-800 dark:text-blue-200">
144
144
  {t('startPage.defaultPassword.description1', 'The default password for all demo accounts is')}{' '}
145
- <code className="px-1.5 py-0.5 rounded bg-status-info-bg font-mono text-xs">secret</code>.
145
+ <code className="px-1.5 py-0.5 rounded bg-blue-100 dark:bg-blue-900 font-mono text-xs">secret</code>.
146
146
  {' '}{t('startPage.defaultPassword.description2', 'To change passwords, use the CLI command:')}{' '}
147
- <code className="px-1.5 py-0.5 rounded bg-status-info-bg font-mono text-xs">yarn mercato auth set-password --email &lt;email&gt; --password &lt;newPassword&gt;</code>
147
+ <code className="px-1.5 py-0.5 rounded bg-blue-100 dark:bg-blue-900 font-mono text-xs">yarn mercato auth set-password --email &lt;email&gt; --password &lt;newPassword&gt;</code>
148
148
  <span className="mt-2 block">{t('startPage.defaultPassword.description3', 'Demo account emails are printed in the terminal output during yarn initialize.')}</span>
149
149
  </p>
150
150
  </div>
@@ -1,59 +1 @@
1
- import * as React from "react"
2
- import { Slot } from "@radix-ui/react-slot"
3
- import { cva, type VariantProps } from "class-variance-authority"
4
-
5
- import { cn } from "@open-mercato/shared/lib/utils"
6
-
7
- const buttonVariants = cva(
8
- "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-2 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
9
- {
10
- variants: {
11
- variant: {
12
- default:
13
- "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
14
- destructive:
15
- "bg-destructive text-white shadow-xs hover:bg-destructive/90 aria-invalid:ring-destructive dark:aria-invalid:ring-destructive dark:bg-destructive/10",
16
- outline:
17
- "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
18
- secondary:
19
- "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
20
- ghost:
21
- "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
22
- link: "text-primary underline-offset-4 hover:underline",
23
- },
24
- size: {
25
- default: "h-9 px-4 py-2 has-[>svg]:px-3",
26
- sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
27
- lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
28
- icon: "size-9",
29
- },
30
- },
31
- defaultVariants: {
32
- variant: "default",
33
- size: "default",
34
- },
35
- }
36
- )
37
-
38
- function Button({
39
- className,
40
- variant,
41
- size,
42
- asChild = false,
43
- ...props
44
- }: React.ComponentProps<"button"> &
45
- VariantProps<typeof buttonVariants> & {
46
- asChild?: boolean
47
- }) {
48
- const Comp = asChild ? Slot : "button"
49
-
50
- return (
51
- <Comp
52
- data-slot="button"
53
- className={cn(buttonVariants({ variant, size, className }))}
54
- {...props}
55
- />
56
- )
57
- }
58
-
59
- export { Button, buttonVariants }
1
+ export { Button, buttonVariants } from '@open-mercato/ui/primitives/button'
@@ -46,26 +46,34 @@ test.describe('TC-UMES-005: Phase L — Integration Extensions', () => {
46
46
  })
47
47
 
48
48
  test('TC-UMES-L03: wizard completes all 3 steps and outputs result', async ({ page }) => {
49
- // Step 1 — fill credentials (Tab between fills to commit controlled input state)
49
+ test.setTimeout(60_000)
50
+ // Step 1 — fill credentials (controlled inputs)
50
51
  const apiKeyInput = page.locator('[data-crud-field-id="apiKey"] input')
51
52
  const apiSecretInput = page.locator('[data-crud-field-id="apiSecret"] input')
52
53
  await apiKeyInput.click()
53
- await apiKeyInput.fill('test-key-123')
54
- await page.keyboard.press('Tab')
55
- await apiSecretInput.fill('test-secret-456')
56
- await page.keyboard.press('Tab')
54
+ await apiKeyInput.pressSequentially('test-key-123', { delay: 10 })
55
+ await apiSecretInput.click()
56
+ await apiSecretInput.pressSequentially('test-secret-456', { delay: 10 })
57
+ // Confirm React state caught up before validation-gated Next click
58
+ await expect(apiKeyInput).toHaveValue('test-key-123', { timeout: 5_000 })
59
+ await expect(apiSecretInput).toHaveValue('test-secret-456', { timeout: 5_000 })
57
60
  await page.getByRole('button', { name: 'Next', exact: true }).click()
58
61
 
59
- // Step 2 — select sync direction
60
- const syncSelect = page.locator('[data-crud-field-id="syncDirection"] select')
61
- await expect(syncSelect).toBeVisible()
62
- await syncSelect.selectOption('bidirectional')
62
+ // Step 2 — wait for transition (apiKey input gone, syncDirection rendered)
63
+ await expect(apiKeyInput).toHaveCount(0, { timeout: 10_000 })
64
+ // Wizard uses Radix Select in apps/mercato — interact via combobox + portal option
65
+ const syncTrigger = page.locator('[data-crud-field-id="syncDirection"] [role="combobox"]')
66
+ await expect(syncTrigger).toBeVisible({ timeout: 10_000 })
67
+ await syncTrigger.click()
68
+ await page.getByRole('option', { name: 'Bidirectional', exact: true }).click()
63
69
  await page.getByRole('button', { name: 'Next', exact: true }).click()
64
70
 
65
- // Step 3 — select frequency and complete
66
- const freqSelect = page.locator('[data-crud-field-id="frequency"] select')
67
- await expect(freqSelect).toBeVisible()
68
- await freqSelect.selectOption('daily')
71
+ // Step 3 — wait for syncDirection gone, frequency rendered (Radix Select)
72
+ await expect(page.locator('[data-crud-field-id="syncDirection"]')).toHaveCount(0, { timeout: 10_000 })
73
+ const freqTrigger = page.locator('[data-crud-field-id="frequency"] [role="combobox"]')
74
+ await expect(freqTrigger).toBeVisible({ timeout: 10_000 })
75
+ await freqTrigger.click()
76
+ await page.getByRole('option', { name: 'Daily', exact: true }).click()
69
77
  await page.getByRole('button', { name: 'Complete', exact: true }).click()
70
78
 
71
79
  // Verify wizard result output
@@ -80,19 +88,22 @@ test.describe('TC-UMES-005: Phase L — Integration Extensions', () => {
80
88
  })
81
89
 
82
90
  test('TC-UMES-L04: wizard back button navigates to previous step', async ({ page }) => {
83
- // Fill step 1 and go to step 2 (Tab between fills to commit controlled input state)
91
+ test.setTimeout(60_000)
92
+ // Fill step 1 (controlled inputs — flush state then advance)
84
93
  const apiKeyInput = page.locator('[data-crud-field-id="apiKey"] input')
85
94
  const apiSecretInput = page.locator('[data-crud-field-id="apiSecret"] input')
86
95
  await apiKeyInput.click()
87
- await apiKeyInput.fill('key')
88
- await page.keyboard.press('Tab')
89
- await apiSecretInput.fill('secret')
90
- await page.keyboard.press('Tab')
96
+ await apiKeyInput.pressSequentially('key', { delay: 10 })
97
+ await apiSecretInput.click()
98
+ await apiSecretInput.pressSequentially('secret', { delay: 10 })
99
+ await expect(apiKeyInput).toHaveValue('key', { timeout: 5_000 })
100
+ await expect(apiSecretInput).toHaveValue('secret', { timeout: 5_000 })
91
101
  await page.getByRole('button', { name: 'Next', exact: true }).click()
92
102
 
93
- // Should be on step 2
94
- const syncSelect = page.locator('[data-crud-field-id="syncDirection"] select')
95
- await expect(syncSelect).toBeVisible()
103
+ // Step 2 wait for transition (apiKey input gone, syncDirection rendered as Radix combobox)
104
+ await expect(apiKeyInput).toHaveCount(0, { timeout: 10_000 })
105
+ const syncTrigger = page.locator('[data-crud-field-id="syncDirection"] [role="combobox"]')
106
+ await expect(syncTrigger).toBeVisible({ timeout: 10_000 })
96
107
 
97
108
  // Click back
98
109
  await page.getByRole('button', { name: 'Back', exact: true }).click()
@@ -4,7 +4,7 @@ import { exampleTag, exampleErrorSchema } from '../../openapi'
4
4
 
5
5
  export const metadata = {
6
6
  GET: { requireAuth: true, requireFeatures: ['example.todos.view'] },
7
- POST: { requireAuth: true, requireFeatures: ['example.todos.manage'] },
7
+ POST: { requireAuth: true, requireFeatures: ['example.todos.view'] },
8
8
  }
9
9
 
10
10
  export async function GET(_req: Request, ctx: { params: { id: string } }) {
@@ -3,6 +3,13 @@
3
3
  import * as React from 'react'
4
4
  import { Page, PageBody } from '@open-mercato/ui/backend/Page'
5
5
  import { Button } from '@open-mercato/ui/primitives/button'
6
+ import {
7
+ Select,
8
+ SelectContent,
9
+ SelectItem,
10
+ SelectTrigger,
11
+ SelectValue,
12
+ } from '@open-mercato/ui/primitives/select'
6
13
  import { useT } from '@open-mercato/shared/lib/i18n/context'
7
14
  import { registerIntegration, getAllIntegrations, getIntegrationTitle } from '@open-mercato/shared/modules/integrations/types'
8
15
 
@@ -244,32 +251,38 @@ export default function UmesIntegrationsPage() {
244
251
  {currentStepId === 'scope' && (
245
252
  <div data-crud-field-id="syncDirection" className="space-y-1">
246
253
  <label className="text-sm font-medium">Sync Direction</label>
247
- <select
248
- value={wizardData.syncDirection ?? ''}
249
- onChange={(event) => handleWizardChange('syncDirection', event.target.value)}
250
- className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
254
+ <Select
255
+ value={wizardData.syncDirection || undefined}
256
+ onValueChange={(value) => handleWizardChange('syncDirection', value ?? '')}
251
257
  >
252
- <option value="">Select direction</option>
253
- <option value="push">Push</option>
254
- <option value="pull">Pull</option>
255
- <option value="bidirectional">Bidirectional</option>
256
- </select>
258
+ <SelectTrigger>
259
+ <SelectValue placeholder="Select direction" />
260
+ </SelectTrigger>
261
+ <SelectContent>
262
+ <SelectItem value="push">Push</SelectItem>
263
+ <SelectItem value="pull">Pull</SelectItem>
264
+ <SelectItem value="bidirectional">Bidirectional</SelectItem>
265
+ </SelectContent>
266
+ </Select>
257
267
  </div>
258
268
  )}
259
269
 
260
270
  {currentStepId === 'schedule' && (
261
271
  <div data-crud-field-id="frequency" className="space-y-1">
262
272
  <label className="text-sm font-medium">Frequency</label>
263
- <select
264
- value={wizardData.frequency ?? ''}
265
- onChange={(event) => handleWizardChange('frequency', event.target.value)}
266
- className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
273
+ <Select
274
+ value={wizardData.frequency || undefined}
275
+ onValueChange={(value) => handleWizardChange('frequency', value ?? '')}
267
276
  >
268
- <option value="">Select frequency</option>
269
- <option value="hourly">Hourly</option>
270
- <option value="daily">Daily</option>
271
- <option value="weekly">Weekly</option>
272
- </select>
277
+ <SelectTrigger>
278
+ <SelectValue placeholder="Select frequency" />
279
+ </SelectTrigger>
280
+ <SelectContent>
281
+ <SelectItem value="hourly">Hourly</SelectItem>
282
+ <SelectItem value="daily">Daily</SelectItem>
283
+ <SelectItem value="weekly">Weekly</SelectItem>
284
+ </SelectContent>
285
+ </Select>
273
286
  </div>
274
287
  )}
275
288
 
@@ -1,4 +1,4 @@
1
- import { Entity, PrimaryKey, Property } from '@mikro-orm/decorators/legacy'
1
+ import { Entity, PrimaryKey, Property } from '@mikro-orm/decorators/legacy';
2
2
 
3
3
  @Entity({ tableName: 'example_items' })
4
4
  export class ExampleItem {
@@ -39,7 +39,7 @@ const checkoutTestComponentOverrides: ComponentOverride[] = [
39
39
  React.createElement(
40
40
  'div',
41
41
  {
42
- className: 'rounded-xl border border-dashed border-blue-300 bg-blue-50/40 p-3',
42
+ className: 'rounded-2xl border border-dashed border-blue-300 bg-blue-50/40 p-3',
43
43
  'data-testid': 'example-checkout-summary-wrapper',
44
44
  },
45
45
  React.createElement(Original, props as object)
@@ -57,7 +57,7 @@ const checkoutTestComponentOverrides: ComponentOverride[] = [
57
57
  React.createElement(
58
58
  'div',
59
59
  {
60
- className: 'rounded-xl border border-dashed border-amber-300 bg-amber-50/40 p-3',
60
+ className: 'rounded-2xl border border-dashed border-amber-300 bg-amber-50/40 p-3',
61
61
  'data-testid': 'example-checkout-help-wrapper',
62
62
  },
63
63
  React.createElement(Original, props as object)
@@ -4,6 +4,7 @@ import * as React from 'react'
4
4
  import type { DashboardWidgetComponentProps } from '@open-mercato/shared/modules/dashboard/widgets'
5
5
  import { useT } from '@open-mercato/shared/lib/i18n/context'
6
6
  import { Button } from '@open-mercato/ui/primitives/button'
7
+ import { Input } from '@open-mercato/ui/primitives/input'
7
8
  import { Spinner } from '@open-mercato/ui/primitives/spinner'
8
9
  import { apiCallOrThrow, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
9
10
  import { hydrateTodoSettings, type TodoSettings } from './config'
@@ -138,12 +139,12 @@ const TodoWidgetClient: React.FC<DashboardWidgetComponentProps<TodoSettings>> =
138
139
  <label htmlFor="todo-page-size" className="text-xs font-medium uppercase text-muted-foreground">
139
140
  {t('example.widgets.todo.settings.itemsLabel')}
140
141
  </label>
141
- <input
142
+ <Input
142
143
  id="todo-page-size"
143
144
  type="number"
144
145
  min={1}
145
146
  max={20}
146
- className="w-24 rounded-md border px-2 py-1 text-sm focus-visible:border-ring focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
147
+ className="w-24"
147
148
  value={value.pageSize}
148
149
  onChange={(event) => onSettingsChange({ ...value, pageSize: Number(event.target.value) })}
149
150
  />
@@ -166,9 +167,9 @@ const TodoWidgetClient: React.FC<DashboardWidgetComponentProps<TodoSettings>> =
166
167
  return (
167
168
  <div className="space-y-4">
168
169
  <div className="flex gap-2">
169
- <input
170
+ <Input
170
171
  type="text"
171
- className="flex-1 rounded-md border px-3 py-2 text-sm focus-visible:border-ring focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
172
+ className="flex-1"
172
173
  placeholder={t('example.widgets.todo.input.placeholder')}
173
174
  value={draft}
174
175
  onChange={(event) => setDraft(event.target.value)}
@@ -96,15 +96,15 @@ export default function CatalogSeoReportWidget(_props: InjectionWidgetComponentP
96
96
  ) : loading ? (
97
97
  <p className="mt-2 text-xs text-muted-foreground">{t('common.loading', 'Loading…')}</p>
98
98
  ) : issues.length === 0 ? (
99
- <p className="mt-2 text-xs text-status-success-text">{t('example.widgets.catalogSeoReport.healthy', 'All reviewed items look good!')}</p>
99
+ <p className="mt-2 text-xs text-emerald-700">{t('example.widgets.catalogSeoReport.healthy', 'All reviewed items look good!')}</p>
100
100
  ) : (
101
101
  <ul className="mt-3 space-y-2">
102
102
  {issues.map((issue) => (
103
- <li key={issue.id} className="rounded border border-status-warning-border bg-status-warning-bg px-3 py-2">
103
+ <li key={issue.id} className="rounded border border-amber-200 dark:border-amber-900/70 bg-amber-50 dark:bg-amber-950/40 px-3 py-2">
104
104
  <div className="flex items-center justify-between gap-3">
105
105
  <div>
106
- <div className="text-sm font-medium text-foreground">{issue.title}</div>
107
- <div className="text-overline text-status-warning-text">{issue.issue}</div>
106
+ <div className="text-sm font-medium text-foreground dark:text-amber-50">{issue.title}</div>
107
+ <div className="text-overline text-amber-800 dark:text-amber-300">{issue.issue}</div>
108
108
  </div>
109
109
  <Button asChild size="sm" variant="outline">
110
110
  <a href={`/backend/catalog/products/${issue.id}`} className="text-xs">
@@ -5,6 +5,13 @@ import type { InjectionWidgetComponentProps } from '@open-mercato/shared/modules
5
5
  import { readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
6
6
  import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
7
7
  import { useT } from '@open-mercato/shared/lib/i18n/context'
8
+ import {
9
+ Select,
10
+ SelectContent,
11
+ SelectItem,
12
+ SelectTrigger,
13
+ SelectValue,
14
+ } from '@open-mercato/ui/primitives/select'
8
15
 
9
16
  type PriorityValue = 'low' | 'normal' | 'high' | 'critical'
10
17
  type PriorityItem = { id?: string; priority?: string }
@@ -117,17 +124,21 @@ export default function CustomerPriorityDetailWidget({ context, data, disabled }
117
124
  <div className="rounded-md border border-border p-3">
118
125
  <div className="mb-1 text-sm font-medium text-foreground">{t('example.priority.detail.label')}</div>
119
126
  <div className="text-xs text-muted-foreground mb-2">{t('example.priority.detail.description')}</div>
120
- <select
121
- className="h-9 w-full rounded border border-input bg-background px-3 text-sm"
127
+ <Select
122
128
  value={value}
123
- onChange={(event) => { void handleChange(event) }}
129
+ onValueChange={(next) => { void handleChange({ target: { value: next } } as React.ChangeEvent<HTMLSelectElement>) }}
124
130
  disabled={disabled || loading || saving}
125
131
  >
126
- <option value="low">{t('example.priority.low')}</option>
127
- <option value="normal">{t('example.priority.normal')}</option>
128
- <option value="high">{t('example.priority.high')}</option>
129
- <option value="critical">{t('example.priority.critical')}</option>
130
- </select>
132
+ <SelectTrigger>
133
+ <SelectValue />
134
+ </SelectTrigger>
135
+ <SelectContent>
136
+ <SelectItem value="low">{t('example.priority.low')}</SelectItem>
137
+ <SelectItem value="normal">{t('example.priority.normal')}</SelectItem>
138
+ <SelectItem value="high">{t('example.priority.high')}</SelectItem>
139
+ <SelectItem value="critical">{t('example.priority.critical')}</SelectItem>
140
+ </SelectContent>
141
+ </Select>
131
142
  {loading ? <div className="mt-2 text-xs text-muted-foreground">{t('example.priority.detail.loading')}</div> : null}
132
143
  {saving ? <div className="mt-2 text-xs text-muted-foreground">{t('example.priority.detail.saving')}</div> : null}
133
144
  {error ? <div className="mt-2 text-xs text-destructive">{error}</div> : null}
@@ -10,11 +10,11 @@ const MOCK_ACTIVITY = [
10
10
 
11
11
  function ActivityIcon({ type }: { type: string }) {
12
12
  const colors: Record<string, string> = {
13
- login: 'bg-status-success-bg text-status-success-icon',
14
- profile: 'bg-status-info-bg text-status-info-icon',
15
- order: 'bg-status-warning-bg text-status-warning-icon',
13
+ login: 'bg-emerald-100 text-emerald-600 dark:bg-emerald-900/30 dark:text-emerald-400',
14
+ profile: 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400',
15
+ order: 'bg-amber-100 text-amber-600 dark:bg-amber-900/30 dark:text-amber-400',
16
16
  download: 'bg-violet-100 text-violet-600 dark:bg-violet-900/30 dark:text-violet-400',
17
- security: 'bg-status-error-bg text-status-error-icon',
17
+ security: 'bg-rose-100 text-rose-600 dark:bg-rose-900/30 dark:text-rose-400',
18
18
  }
19
19
  return (
20
20
  <div className={`flex size-8 shrink-0 items-center justify-center rounded-lg text-xs font-bold ${colors[type] ?? 'bg-muted text-muted-foreground'}`}>
@@ -148,10 +148,10 @@ export default function SalesTodosWidget({ context }: InjectionWidgetComponentPr
148
148
  </Button>
149
149
  </form>
150
150
  {lastEvent ? (
151
- <div className="flex items-center gap-2 rounded bg-status-info-bg px-3 py-1.5 text-xs text-status-info-text">
152
- <span className="inline-block h-2 w-2 animate-pulse rounded-full bg-status-info-icon" />
151
+ <div className="flex items-center gap-2 rounded bg-blue-50 px-3 py-1.5 text-xs text-blue-700 dark:bg-blue-950 dark:text-blue-300">
152
+ <span className="inline-block h-2 w-2 animate-pulse rounded-full bg-blue-500" />
153
153
  SSE Event received: <code className="font-mono">{lastEvent.id}</code>
154
- <span className="text-status-info-text">
154
+ <span className="text-blue-500/70">
155
155
  {new Date(lastEvent.timestamp).toLocaleTimeString()}
156
156
  </span>
157
157
  </div>
@@ -1,7 +1,7 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import { z } from 'zod'
3
3
  import type { EntityManager } from '@mikro-orm/postgresql'
4
- import { type Kysely } from 'kysely'
4
+ import { type Kysely, sql } from 'kysely'
5
5
  import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
6
6
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
7
7
  import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
@@ -0,0 +1,35 @@
1
+ import { z } from 'zod'
2
+ import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
3
+
4
+ export const metadata = {
5
+ POST: {
6
+ requireAuth: false,
7
+ rateLimit: { points: 3, duration: 60, keyPrefix: 'ratelimit_probe' },
8
+ },
9
+ }
10
+
11
+ export async function POST() {
12
+ return Response.json({ ok: true })
13
+ }
14
+
15
+ export const openApi: OpenApiRouteDoc = {
16
+ tag: 'RateLimitProbe',
17
+ methods: {
18
+ POST: {
19
+ summary: 'Test-only endpoint with per-route metadata.rateLimit — used to prove rate-limit leakage under OM_INTEGRATION_TEST',
20
+ tags: ['RateLimitProbe'],
21
+ responses: [
22
+ {
23
+ status: 200,
24
+ description: 'Always OK when under the points budget',
25
+ schema: z.object({ ok: z.literal(true) }),
26
+ },
27
+ {
28
+ status: 429,
29
+ description: 'Rate limit exceeded (3 points / 60 s per client IP)',
30
+ schema: z.object({ error: z.string() }),
31
+ },
32
+ ],
33
+ },
34
+ },
35
+ }
@@ -50,6 +50,7 @@ export const enabledModules: ModuleEntry[] = [
50
50
  { id: 'customer_accounts', from: '@open-mercato/core' },
51
51
  { id: 'portal', from: '@open-mercato/core' },
52
52
  { id: 'example', from: '@app' },
53
+ { id: 'ratelimit_probe', from: '@app' },
53
54
  ]
54
55
 
55
56
  if (enabledModules.some((entry) => entry.id === 'example')) {