vibecarbon 0.1.7 → 0.2.1

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 (109) hide show
  1. package/carbon/.env.example +3 -2
  2. package/carbon/DEVELOPMENT.md +18 -3
  3. package/carbon/PRODUCTION.md +1 -1
  4. package/carbon/content/docs/cli.mdx +3 -3
  5. package/carbon/content/docs/deployment.mdx +1 -1
  6. package/carbon/content/docs/development.mdx +1 -1
  7. package/carbon/content/docs/getting-started.mdx +0 -1
  8. package/carbon/docker-compose.yml +6 -1
  9. package/carbon/scripts/dev-init.js +1 -1
  10. package/carbon/scripts/generate-dev-configs.sh +5 -7
  11. package/carbon/scripts/validate-dev-configs.sh +5 -7
  12. package/carbon/src/client/components/TechStackSection.tsx +0 -68
  13. package/carbon/src/client/index.css +0 -14
  14. package/carbon/src/client/locales/de.json +0 -18
  15. package/carbon/src/client/locales/en.json +86 -36
  16. package/carbon/src/client/locales/es.json +0 -18
  17. package/carbon/src/client/locales/fr.json +0 -18
  18. package/carbon/src/client/locales/pt.json +0 -18
  19. package/carbon/src/client/pages/Home.tsx +0 -3
  20. package/package.json +4 -2
  21. package/services/index.json +29 -0
  22. package/services/metabase/compose/docker-compose.override.yml +14 -0
  23. package/services/metabase/compose/docker-compose.prod.yml +29 -0
  24. package/services/metabase/compose/docker-compose.yml +68 -0
  25. package/services/metabase/db/init.sh +29 -0
  26. package/services/metabase/docs/integration.md +191 -0
  27. package/services/metabase/k8s/deployment.yaml +88 -0
  28. package/services/metabase/k8s/ingressroute.yaml +22 -0
  29. package/services/metabase/k8s/kustomization.yaml +9 -0
  30. package/services/metabase/k8s/network-policy.yaml +74 -0
  31. package/services/metabase/k8s/pvc.yaml +12 -0
  32. package/services/metabase/k8s/service.yaml +15 -0
  33. package/services/metabase/manifest.json +34 -0
  34. package/services/metabase/scripts/setup.sh +152 -0
  35. package/services/n8n/compose/docker-compose.override.yml +14 -0
  36. package/services/n8n/compose/docker-compose.prod.yml +32 -0
  37. package/services/n8n/compose/docker-compose.yml +99 -0
  38. package/services/n8n/db/init.sh +25 -0
  39. package/services/n8n/docs/SSO.md +124 -0
  40. package/services/n8n/docs/integration.md +155 -0
  41. package/services/n8n/hooks/hooks.js +131 -0
  42. package/services/n8n/k8s/deployment.yaml +116 -0
  43. package/services/n8n/k8s/ingressroute.yaml +24 -0
  44. package/services/n8n/k8s/kustomization.yaml +9 -0
  45. package/services/n8n/k8s/network-policy.yaml +82 -0
  46. package/services/n8n/k8s/pvc.yaml +14 -0
  47. package/services/n8n/k8s/service.yaml +14 -0
  48. package/services/n8n/manifest.json +36 -0
  49. package/services/n8n/scripts/setup.sh +42 -0
  50. package/{carbon/docker-compose.observability.prod.yml → services/observability/compose/docker-compose.prod.yml} +8 -0
  51. package/services/observability/docs/integration.md +31 -0
  52. package/services/observability/k8s/grafana-deployment.yaml +107 -0
  53. package/services/observability/k8s/grafana-provisioning-configmap.yaml +65 -0
  54. package/services/observability/k8s/grafana-pvc.yaml +14 -0
  55. package/services/observability/k8s/grafana-service.yaml +16 -0
  56. package/services/observability/k8s/ingressroute.yaml +22 -0
  57. package/services/observability/k8s/kustomization.yaml +24 -0
  58. package/services/observability/k8s/loki-configmap.yaml +74 -0
  59. package/services/observability/k8s/loki-deployment.yaml +72 -0
  60. package/services/observability/k8s/loki-pvc.yaml +14 -0
  61. package/services/observability/k8s/loki-service.yaml +16 -0
  62. package/services/observability/k8s/network-policy.yaml +141 -0
  63. package/services/observability/k8s/prometheus-configmap.yaml +80 -0
  64. package/services/observability/k8s/prometheus-deployment.yaml +78 -0
  65. package/services/observability/k8s/prometheus-pvc.yaml +14 -0
  66. package/services/observability/k8s/prometheus-rbac.yaml +55 -0
  67. package/services/observability/k8s/prometheus-service.yaml +16 -0
  68. package/services/observability/manifest.json +28 -0
  69. package/services/redis/compose/docker-compose.prod.yml +10 -0
  70. package/services/redis/compose/docker-compose.yml +24 -0
  71. package/services/redis/docs/integration.md +148 -0
  72. package/services/redis/k8s/deployment.yaml +70 -0
  73. package/services/redis/k8s/kustomization.yaml +8 -0
  74. package/services/redis/k8s/network-policy.yaml +20 -0
  75. package/services/redis/k8s/pvc.yaml +12 -0
  76. package/services/redis/k8s/service.yaml +15 -0
  77. package/services/redis/manifest.json +30 -0
  78. package/services/s3/compose/docker-compose.s3-ha.yml +86 -0
  79. package/services/s3/compose/docker-compose.s3.yml +35 -0
  80. package/services/s3/manifest.json +87 -0
  81. package/src/add.js +57 -89
  82. package/src/cli.js +1 -1
  83. package/src/create.js +18 -87
  84. package/src/destroy.js +23 -1
  85. package/src/lib/deploy/bundle.js +4 -2
  86. package/src/lib/deploy/compose/ha.js +11 -8
  87. package/src/lib/deploy/compose/index.js +45 -17
  88. package/src/lib/deploy/image.js +10 -2
  89. package/src/lib/deploy/k8s/k3s.js +23 -4
  90. package/src/lib/deploy/orchestrator.js +13 -1
  91. package/src/lib/deploy/remote-build.js +29 -8
  92. package/src/remove.js +9 -6
  93. package/src/scale.js +14 -1
  94. package/carbon/content/docs/optional-services.mdx +0 -160
  95. package/carbon/src/client/components/MetricsStrip.tsx +0 -48
  96. /package/{carbon/docker-compose.observability.override.yml → services/observability/compose/docker-compose.override.yml} +0 -0
  97. /package/{carbon/docker-compose.observability.yml → services/observability/compose/docker-compose.yml} +0 -0
  98. /package/{carbon → services/observability}/volumes/grafana/dashboards/logs.json +0 -0
  99. /package/{carbon → services/observability}/volumes/grafana/dashboards/overview.json +0 -0
  100. /package/{carbon → services/observability}/volumes/grafana/dashboards/postgresql.json +0 -0
  101. /package/{carbon → services/observability}/volumes/grafana/dashboards.dev/logs.json +0 -0
  102. /package/{carbon → services/observability}/volumes/grafana/dashboards.dev/overview.json +0 -0
  103. /package/{carbon → services/observability}/volumes/grafana/dashboards.dev/postgresql.json +0 -0
  104. /package/{carbon → services/observability}/volumes/grafana/provisioning/dashboards/dashboards.dev.yml +0 -0
  105. /package/{carbon → services/observability}/volumes/grafana/provisioning/dashboards/dashboards.yml +0 -0
  106. /package/{carbon → services/observability}/volumes/grafana/provisioning/datasources/datasources.yml +0 -0
  107. /package/{carbon → services/observability}/volumes/loki/loki-config.yml +0 -0
  108. /package/{carbon → services/observability}/volumes/prometheus/prometheus.yml +0 -0
  109. /package/{carbon → services/observability}/volumes/promtail/promtail-config.yml +0 -0
@@ -542,7 +542,7 @@ export async function executeDeployment(args, gatheredConfig) {
542
542
  );
543
543
  s.stop('Compose HA deployment complete');
544
544
  } else {
545
- const { setupServer, dockerLoginOnServer, startComposeStack } = await import(
545
+ const { setupServer, dockerLoginOnServer, startComposeStack, runMigrations } = await import(
546
546
  './compose/index.js'
547
547
  );
548
548
  const { setupServerFiles, waitForSSH: waitForComposeSSH } = await import(
@@ -782,6 +782,18 @@ export async function executeDeployment(args, gatheredConfig) {
782
782
  startComposeStack(serverIp, sshKeyPath, projectConfig.projectName, services),
783
783
  );
784
784
 
785
+ // Apply app migrations + reload PostgREST. The orchestrator's inline
786
+ // single-compose path previously skipped this (only deployComposeHA ran
787
+ // runMigrations), so compose-single shipped an EMPTY app schema — 0 public
788
+ // tables, every DB-backed feature 500'd, and db_schema verify failed with
789
+ // PGRST205. runMigrations waits for supabase_admin, applies each
790
+ // supabase/migrations/* with ON_ERROR_STOP=1 (a real failure aborts the
791
+ // deploy rather than silently shipping a schema-less prod), then reloads
792
+ // PostgREST's schema cache. Mirrors the HA path.
793
+ await perfAsync('deploy.compose.migrations', () =>
794
+ runMigrations(serverIp, sshKeyPath, projectConfig.projectName),
795
+ );
796
+
785
797
  // --- Verifiable success gate ---
786
798
  // Probe the app's own /api/health endpoint on the server itself (via
787
799
  // localhost + Host header), bypassing DNS/TLS. A successful deploy
@@ -36,8 +36,13 @@ export async function buildRemote(ip, sshKeyPath, imageTag, cwd, buildArgs = {})
36
36
  // key path via env var rather than string-interpolating into the script
37
37
  // to keep shell-escaping concerns out of the picture (paths with spaces
38
38
  // or quotes would otherwise break the wrapper).
39
+ // ServerAliveInterval/CountMax: BuildKit holds a long-lived SSH session for
40
+ // the build; on a congested/cross-region link it can stall and the http2
41
+ // session helper drops with "error reading preface ... file already closed".
42
+ // Keepalives let ssh detect a dead peer (~60s) and fail the attempt cleanly
43
+ // so the retry below can re-establish, instead of hanging to the build timeout.
39
44
  const sshWrapper = `#!/bin/bash
40
- exec /usr/bin/ssh -i "$VIBECARBON_SSH_KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -o ConnectTimeout=30 "$@"
45
+ exec /usr/bin/ssh -i "$VIBECARBON_SSH_KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -o ConnectTimeout=30 -o ServerAliveInterval=15 -o ServerAliveCountMax=4 "$@"
41
46
  `;
42
47
  writeFileSync(sshWrapperPath, sshWrapper, { mode: 0o755 });
43
48
 
@@ -103,15 +108,31 @@ exec /usr/bin/ssh -i "$VIBECARBON_SSH_KEY" -o StrictHostKeyChecking=no -o UserKn
103
108
  // (compose-ha 2026-05-09T00:38 e2 failure). Treat the false return as a
104
109
  // hard failure so the error path captures + reports it.
105
110
  const buildT = perfTimer('deploy.image.remoteBuild');
106
- const buildOk = runCommand(args, {
107
- cwd,
108
- stdio: 'inherit',
109
- env,
110
- timeout: 600000,
111
- });
111
+ // BuildKit-over-SSH is flaky on first connect to a freshly-booted (often
112
+ // cross-region) VPS: the session helper intermittently drops mid-build with
113
+ // `http2: server: error reading preface ... file already closed` / an SSH
114
+ // reset, and runCommand returns false. Retry a few times — each attempt
115
+ // re-establishes a fresh SSH/BuildKit session, and BuildKit's cache makes
116
+ // retries cheap. Without this a single transient drop failed the whole
117
+ // deploy (compose-ha standby) or left no image so `docker compose up` later
118
+ // hit "pull access denied" (compose scale, e2e loop run #1).
119
+ const BUILD_ATTEMPTS = 3;
120
+ let buildOk = false;
121
+ for (let attempt = 1; attempt <= BUILD_ATTEMPTS; attempt++) {
122
+ buildOk = runCommand(args, { cwd, stdio: 'inherit', env, timeout: 600000 });
123
+ if (buildOk !== false) break;
124
+ if (attempt < BUILD_ATTEMPTS) {
125
+ console.error(
126
+ `[remote-build] docker build on ${ip} failed (attempt ${attempt}/${BUILD_ATTEMPTS}) — retrying in ${3 * attempt}s`,
127
+ );
128
+ await new Promise((r) => setTimeout(r, 3000 * attempt));
129
+ }
130
+ }
112
131
  buildT.end();
113
132
  if (buildOk === false) {
114
- throw new Error(`docker build failed on ${ip} for tag ${imageTag}`);
133
+ throw new Error(
134
+ `docker build failed on ${ip} for tag ${imageTag} after ${BUILD_ATTEMPTS} attempts`,
135
+ );
115
136
  }
116
137
 
117
138
  s.stop(`Image built natively: ${imageTag}`);
package/src/remove.js CHANGED
@@ -49,15 +49,15 @@ const SPEC = {
49
49
  { name: 'y', boolean: true, description: 'Skip confirmation prompts' },
50
50
  { name: 'force', boolean: true, description: 'Skip confirmation prompt (alias for -y)' },
51
51
  {
52
- name: 'offline',
52
+ name: 'online',
53
53
  boolean: true,
54
- description: 'Use bundled service definitions (for air-gapped environments)',
54
+ description: 'Fetch the latest service definitions from GitHub instead of the packaged copy',
55
55
  },
56
56
  ],
57
57
  examples: [
58
58
  { command: 'vibecarbon remove', description: 'prompts for a feature to remove' },
59
- { command: 'vibecarbon remove n8n', description: 'remove a specific feature' },
60
- { command: 'vibecarbon remove n8n -force', description: 'remove without confirmation' },
59
+ { command: 'vibecarbon remove redis', description: 'remove a specific feature' },
60
+ { command: 'vibecarbon remove redis -force', description: 'remove without confirmation' },
61
61
  ],
62
62
  };
63
63
 
@@ -94,7 +94,10 @@ function removeDirectory(dirPath) {
94
94
  // ============================================================================
95
95
 
96
96
  async function removeService(serviceName, options) {
97
- const { force, offline } = options;
97
+ const { force } = options;
98
+ // Default to the packaged service definitions (mirrors `add`); `-online`
99
+ // opts into fetching the latest from GitHub.
100
+ const offline = !options.online;
98
101
 
99
102
  const projectManifest = loadManifest();
100
103
 
@@ -262,7 +265,7 @@ async function main(cliArgs) {
262
265
  for (const serviceName of services) {
263
266
  const result = await removeService(serviceName, {
264
267
  force,
265
- offline: !!values.offline,
268
+ online: !!values.online,
266
269
  });
267
270
  if (!result) {
268
271
  success = false;
package/src/scale.js CHANGED
@@ -439,9 +439,22 @@ async function scaleCompose(environment, envConfig, projectConfig, options = {})
439
439
  // Reuses the same code that does direct-mode deploy builds.
440
440
  if (oldAppImage && isLocalOnlyImageTag(oldAppImage)) {
441
441
  s.start(`Building image ${oldAppImage} on new server...`);
442
- await perfAsync('scale.sideloadImage', () =>
442
+ const built = await perfAsync('scale.sideloadImage', () =>
443
443
  buildRemote(newIp, sshKeyPath, oldAppImage, process.cwd()),
444
444
  );
445
+ // buildRemote returns false on failure (after its own retries). The app
446
+ // image is local-only, so if it isn't built on the new server, the
447
+ // `docker compose up` below pulls it and dies ~a minute later with
448
+ // "pull access denied / repository does not exist" — a confusing
449
+ // downstream symptom. Fail loudly here instead; this guard was missing
450
+ // (the HA deploy path has the equivalent check). RCA: e2e loop run #1.
451
+ if (!built) {
452
+ s.stop('Remote image build failed on new server', 1);
453
+ throw new Error(
454
+ `scale: failed to build local app image ${oldAppImage} on new server ${newIp} ` +
455
+ `(buildRemote exhausted its retries) — aborting before compose up.`,
456
+ );
457
+ }
445
458
  s.stop('Image built on new server');
446
459
  }
447
460
 
@@ -1,160 +0,0 @@
1
- ---
2
- title: "Optional Services"
3
- description: "Add workflow automation, analytics, caching, monitoring, and CI/CD to your project."
4
- order: 10
5
- ---
6
-
7
- ## Overview
8
-
9
- Vibecarbon ships with a core stack (Supabase, Traefik, Hono API, React SPA). You can extend it with optional services using the CLI:
10
-
11
- ```bash
12
- # Interactive — prompts for which service to add
13
- npx vibecarbon add
14
-
15
- # Or seed the service name to skip the prompt
16
- npx vibecarbon add <service>
17
- ```
18
-
19
- Each service adds Docker Compose files, Kubernetes manifests, environment variables, and feature flags to your project. Running the command multiple times is safe — it won't duplicate anything.
20
-
21
- ```bash
22
- # See available services in the interactive prompt body
23
- npx vibecarbon add
24
-
25
- # Remove a previously added service
26
- npx vibecarbon remove <service>
27
- ```
28
-
29
- ## n8n — Workflow Automation
30
-
31
- A self-hosted workflow automation platform for building integrations, scheduled jobs, and webhook-driven pipelines.
32
-
33
- ```bash
34
- npx vibecarbon add n8n
35
- ```
36
-
37
- **What gets added:**
38
- - `docker-compose.n8n.yml` — n8n container with PostgreSQL backend
39
- - `k8s/base/n8n/` — Kubernetes manifests
40
- - `volumes/db/n8n-init.sh` — Database initialization script
41
- - Environment variables: `N8N_WEBHOOK_URL`, `N8N_API_KEY`
42
- - Feature flags: `VITE_N8N_ENABLED`, `N8N_ENABLED`
43
-
44
- | Setting | Value |
45
- |---------|-------|
46
- | Dev URL | http://n8n.localhost |
47
- | Port | 5678 |
48
- | Production auth | Traefik ForwardAuth (`super_admin` role) |
49
-
50
- ## Metabase — Business Intelligence
51
-
52
- A self-hosted analytics dashboard that connects directly to your Supabase PostgreSQL database.
53
-
54
- ```bash
55
- npx vibecarbon add metabase
56
- ```
57
-
58
- **What gets added:**
59
- - `docker-compose.metabase.yml` — Metabase container
60
- - `k8s/base/metabase/` — Kubernetes manifests
61
- - `volumes/db/metabase-init.sh` — Database initialization script
62
- - Environment variables: `METABASE_ADMIN_EMAIL`, `METABASE_ADMIN_PASSWORD`
63
- - Feature flags: `VITE_METABASE_ENABLED`, `METABASE_ENABLED`
64
-
65
- | Setting | Value |
66
- |---------|-------|
67
- | Dev URL | http://metabase.localhost |
68
- | Port | 3001 |
69
- | Production auth | Traefik ForwardAuth (`super_admin` role) |
70
-
71
- ## Redis — Caching & Sessions
72
-
73
- An in-memory data store for server-side caching, rate limiting, and session storage.
74
-
75
- ```bash
76
- npx vibecarbon add redis
77
- ```
78
-
79
- **What gets added:**
80
- - `docker-compose.redis.yml` — Redis container
81
- - `k8s/base/redis/` — Kubernetes manifests
82
- - Environment variables: `REDIS_PASSWORD` (auto-generated)
83
- - Feature flags: `VITE_REDIS_ENABLED`, `REDIS_ENABLED`
84
-
85
- | Setting | Value |
86
- |---------|-------|
87
- | Host | localhost:6379 |
88
- | Credentials | Stored in `.env.local` (`REDIS_PASSWORD`) |
89
-
90
- When `REDIS_URL` is set, the built-in rate limiter automatically uses Redis for distributed limiting across multiple replicas. Without Redis, it falls back to an in-memory store — this works for single-instance deployments but does not share state across replicas.
91
-
92
- ## Observability — Monitoring Stack
93
-
94
- Enables the built-in Prometheus, Grafana, and Loki stack for metrics, dashboards, and log aggregation.
95
-
96
- ```bash
97
- npx vibecarbon add observability
98
- ```
99
-
100
- **What gets added:**
101
- - Enables `docker-compose.observability.yml` (already bundled in your project)
102
- - Feature flags: `VITE_OBSERVABILITY_ENABLED`, `OBSERVABILITY_ENABLED`
103
-
104
- | Setting | Value |
105
- |---------|-------|
106
- | Grafana URL | http://grafana.localhost |
107
- | Prometheus Port | 9190 |
108
- | Loki Port | 3100 |
109
- | Production auth | Traefik ForwardAuth (`super_admin` role) |
110
-
111
- ## GitHub — Repository & CI/CD
112
-
113
- CI/CD lives in `vibecarbon configure`, not `vibecarbon add`. It creates the GitHub repository (if missing), installs the canonical build + deploy workflows under `.github/workflows/`, rewrites image references to `ghcr.io/<owner>/<project>`, and flips `cicdEnabled: true` in `vibecarbon.json`.
114
-
115
- ```bash
116
- npx vibecarbon configure
117
- ```
118
-
119
- Pick **CI/CD** from the feature list. The wizard handles repo creation, image-ref rewriting, and workflow installation in one pass.
120
-
121
- **Prerequisites:** The [GitHub CLI](https://cli.github.com/) (`gh`) must be installed and authenticated.
122
-
123
- **What gets added:**
124
- - `.github/workflows/vibecarbon-build.yml` — image build pipeline
125
- - `.github/workflows/deploy.yml` — k8s push-deploy pipeline
126
- - Container image + Flux GitRepository references updated to `<owner>/<project>`
127
-
128
- ## Production Auth for Admin Services
129
-
130
- In development, n8n, Metabase, and Grafana are accessible without authentication. In production, they are protected by Traefik ForwardAuth — only users with the `super_admin` role can access them.
131
-
132
- The dev override compose files disable this requirement locally. When adding a new service that needs protection, follow the same pattern:
133
-
134
- 1. Add `traefik.http.routers.<name>.middlewares=super-admin-auth@file` in the service compose file
135
- 2. Create a dev override that sets the middleware to empty
136
-
137
- ## Feature Flags
138
-
139
- Each service sets client and server feature flags in `.env.local`:
140
-
141
- | Service | Client Flag | Server Flag |
142
- |---------|------------|-------------|
143
- | n8n | `VITE_N8N_ENABLED` | `N8N_ENABLED` |
144
- | Metabase | `VITE_METABASE_ENABLED` | `METABASE_ENABLED` |
145
- | Redis | `VITE_REDIS_ENABLED` | `REDIS_ENABLED` |
146
- | Observability | `VITE_OBSERVABILITY_ENABLED` | `OBSERVABILITY_ENABLED` |
147
-
148
- Use these flags to conditionally render UI elements or enable server-side integrations:
149
-
150
- ```typescript
151
- // Client-side
152
- if (import.meta.env.VITE_N8N_ENABLED === 'true') {
153
- // Show n8n link in admin sidebar
154
- }
155
-
156
- // Server-side
157
- if (process.env.REDIS_ENABLED === 'true') {
158
- // Use Redis for caching
159
- }
160
- ```
@@ -1,48 +0,0 @@
1
- import { motion, useInView } from 'framer-motion';
2
- import { useRef } from 'react';
3
- import { useTranslation } from 'react-i18next';
4
-
5
- export function MetricsStrip() {
6
- const { t } = useTranslation();
7
- const ref = useRef<HTMLElement>(null);
8
- const isInView = useInView(ref, { once: true, margin: '-50px' });
9
-
10
- const METRICS = [
11
- { value: t('landing.metrics.time.value'), label: t('landing.metrics.time.label') },
12
- { value: t('landing.metrics.services.value'), label: t('landing.metrics.services.label') },
13
- { value: t('landing.metrics.config.value'), label: t('landing.metrics.config.label') },
14
- { value: t('landing.metrics.commands.value'), label: t('landing.metrics.commands.label') },
15
- ];
16
-
17
- return (
18
- <section ref={ref} className="relative overflow-hidden border-y border-border/30">
19
- <div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-primary/30 to-transparent" />
20
- <div className="absolute inset-x-0 bottom-0 h-px bg-gradient-to-r from-transparent via-primary/30 to-transparent" />
21
-
22
- {/* Horizontal gradient band behind */}
23
- <div className="absolute inset-0 bg-gradient-to-r from-transparent via-primary/5 to-transparent" />
24
- <div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-[800px] h-[300px] rounded-full bg-primary/6 blur-[120px] pointer-events-none" />
25
-
26
- <div className="relative mx-auto max-w-7xl px-6 py-16">
27
- <div className="grid grid-cols-2 md:grid-cols-4 divide-x divide-border/30">
28
- {METRICS.map((metric, i) => (
29
- <motion.div
30
- key={metric.label}
31
- initial={{ opacity: 0, y: 20 }}
32
- animate={isInView ? { opacity: 1, y: 0 } : {}}
33
- transition={{ duration: 0.5, delay: i * 0.1 }}
34
- className="px-6 py-4 text-center hover:bg-primary/[0.03] transition-colors duration-300"
35
- >
36
- <div className="metric-value text-2xl sm:text-3xl md:text-4xl font-black tracking-tight mb-2">
37
- {metric.value}
38
- </div>
39
- <div className="text-xs sm:text-sm text-muted-foreground uppercase tracking-widest">
40
- {metric.label}
41
- </div>
42
- </motion.div>
43
- ))}
44
- </div>
45
- </div>
46
- </section>
47
- );
48
- }