@ramme-io/create-app 2.0.0-alpha.4 → 2.0.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 (32) hide show
  1. package/README.md +2 -2
  2. package/index.js +3 -3
  3. package/package.json +1 -1
  4. package/template/index.html +1 -1
  5. package/template/package.json +15 -6
  6. package/template/public/_redirects +1 -0
  7. package/template/src/App.tsx +3 -2
  8. package/template/src/components/AppHeader.tsx +5 -0
  9. package/template/src/components/ScrollToTop.tsx +6 -6
  10. package/template/src/features/ai/pages/AiChat.tsx +136 -37
  11. package/template/src/features/auth/AuthContext.tsx +16 -6
  12. package/template/src/features/config/AppConfigContext.tsx +2 -2
  13. package/template/src/features/docs/pages/EdgeTelemetryDemo.tsx +149 -0
  14. package/template/src/features/onboarding/pages/AboutRamme.tsx +12 -12
  15. package/template/src/features/onboarding/pages/PrototypeGallery.tsx +8 -6
  16. package/template/src/features/onboarding/pages/RammeFeatures.tsx +18 -17
  17. package/template/src/features/onboarding/pages/RammeTutorial.tsx +20 -11
  18. package/template/src/features/onboarding/pages/Welcome.tsx +6 -6
  19. package/template/src/features/styleguide/sections/tables/TablesSection.tsx +25 -5
  20. package/template/src/features/theme/pages/ThemeCustomizerPage.tsx +344 -256
  21. package/template/src/features/theme/utils/ThemeGenerator.logic.ts +587 -0
  22. package/template/src/hooks/__tests__/useStudioHotkeys.test.ts +100 -0
  23. package/template/src/hooks/useStudioHotkeys.ts +36 -0
  24. package/template/src/index.css +91 -1
  25. package/template/src/main.tsx +44 -2
  26. package/template/src/templates/dashboard/DashboardLayout.tsx +6 -1
  27. package/template/src/templates/dashboard/dashboard.sitemap.ts +1 -1
  28. package/template/src/templates/docs/docs.sitemap.ts +8 -0
  29. package/template/src/templates/settings/SettingsLayout.tsx +13 -26
  30. package/template/src/test/setup.ts +1 -0
  31. package/template/tsconfig.app.json +1 -0
  32. package/template/vite.config.ts +7 -0
package/README.md CHANGED
@@ -15,9 +15,9 @@ This CLI scaffolds a production-ready React application with:
15
15
  Get started in seconds. No global installation required.
16
16
 
17
17
  ```bash
18
- npm create @ramme-io/app@alpha my-app
18
+ npm create @ramme-io/app my-app
19
19
  # or
20
- npx @ramme-io/create-app@alpha my-app
20
+ npm create @ramme-io/app my-app
21
21
  ```
22
22
 
23
23
  You will be prompted to name your project.
package/index.js CHANGED
@@ -19,7 +19,7 @@ const projectName = process.argv[2];
19
19
  if (!projectName) {
20
20
  console.error('āŒ Error: Please specify the project directory.');
21
21
  console.log('\nšŸ“– Usage:');
22
- console.log(' npm create @ramme-io/app@alpha <project-name>\n');
22
+ console.log(' npm create @ramme-io/app <project-name>\n');
23
23
  process.exit(1);
24
24
  }
25
25
 
@@ -27,7 +27,7 @@ const templatePath = path.resolve(__dirname, 'template');
27
27
  const destinationPath = path.resolve(process.cwd(), projectName);
28
28
 
29
29
  // THE STABILITY PACT: Hardcoded target versions for the public release
30
- const RELEASE_VERSION = "^2.0.0-alpha.4";
30
+ const RELEASE_VERSION = "^2.0.0";
31
31
 
32
32
  try {
33
33
  console.log(`\nšŸš€ Creating new Ramme app in: ${destinationPath}`);
@@ -79,7 +79,7 @@ try {
79
79
 
80
80
  console.log('šŸŽØ Happy Creating!\n');
81
81
  console.log('šŸ“š Docs: https://ramme.io/docs');
82
- console.log('ļæ½ Discord: https://ramme.io/discord\n');
82
+ console.log('šŸ’¬ Discord: https://ramme.io/discord\n');
83
83
 
84
84
  } catch (err) {
85
85
  console.error('\nāŒ Critical Failure:', err.message);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ramme-io/create-app",
3
- "version": "2.0.0-alpha.4",
3
+ "version": "2.0.1",
4
4
  "description": "The official CLI to create Ramme applications.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8" />
5
5
  <link rel="icon" type="image/svg+xml" href="/orange.png" />
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
- <title>Ramme - App Starter</title>
7
+ <title>Ramme | The High-Fidelity Creator Framework</title>
8
8
  </head>
9
9
  <body>
10
10
  <div id="root"></div>
@@ -1,20 +1,24 @@
1
1
  {
2
2
  "name": "ramme-starter",
3
3
  "private": true,
4
- "version": "2.0.0-alpha.4",
4
+ "version": "2.0.0",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "dev": "vite",
8
8
  "build": "tsc && vite build",
9
9
  "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
10
- "preview": "vite preview"
10
+ "preview": "vite preview",
11
+ "test": "vitest run",
12
+ "test:watch": "vitest"
11
13
  },
12
14
  "dependencies": {
13
15
  "@ai-sdk/google": "^0.0.10",
14
16
  "@google/generative-ai": "^0.24.1",
15
17
  "@radix-ui/react-slot": "^1.2.4",
16
- "@ramme-io/kernel": "^2.0.0-alpha.4",
17
- "@ramme-io/ui": "^2.0.0-alpha.4",
18
+ "@ramme-io/kernel": "workspace:*",
19
+ "@ramme-io/shared": "workspace:*",
20
+ "@ramme-io/ui": "workspace:*",
21
+ "ably": "^2.21.0",
18
22
  "ag-charts-community": "^13.0.0",
19
23
  "ag-charts-react": "^13.0.0",
20
24
  "ag-grid-community": "^31.3.1",
@@ -30,8 +34,8 @@
30
34
  "react": "^18.3.1",
31
35
  "react-datepicker": "^7.3.0",
32
36
  "react-dom": "^18.3.1",
33
- "react-router-dom": "6.22.3",
34
37
  "react-markdown": "^10.1.0",
38
+ "react-router-dom": "6.22.3",
35
39
  "react-select": "^5.8.0",
36
40
  "react-syntax-highlighter": "^16.1.0",
37
41
  "recharts": "^2.12.7",
@@ -41,14 +45,19 @@
41
45
  "zustand": "^5.0.0"
42
46
  },
43
47
  "devDependencies": {
48
+ "@testing-library/jest-dom": "^6.9.1",
49
+ "@testing-library/react": "^16.3.2",
44
50
  "@types/node": "^20.14.10",
45
51
  "@types/react": "^18.3.3",
46
52
  "@types/react-dom": "^18.3.0",
47
53
  "@vitejs/plugin-react": "^4.3.1",
54
+ "@vitest/ui": "^2",
48
55
  "autoprefixer": "^10.4.19",
56
+ "jsdom": "^29.0.0",
49
57
  "postcss": "^8.4.39",
50
58
  "tailwindcss": "^3.4.4",
51
59
  "typescript": "^5.2.2",
52
- "vite": "^5.3.1"
60
+ "vite": "^5.3.1",
61
+ "vitest": "^2.1.9"
53
62
  }
54
63
  }
@@ -0,0 +1 @@
1
+ /* /index.html 200
@@ -15,8 +15,6 @@ import DashboardLayout from './templates/dashboard/DashboardLayout';
15
15
  import DocsLayout from './templates/docs/DocsLayout';
16
16
  import SettingsLayout from './templates/settings/SettingsLayout';
17
17
 
18
-
19
-
20
18
  // --- SITEMAPS ---
21
19
  import { dashboardSitemap } from './templates/dashboard/dashboard.sitemap';
22
20
  import { docsSitemap } from './templates/docs/docs.sitemap';
@@ -24,6 +22,7 @@ import { settingsSitemap } from './templates/settings/settings.sitemap';
24
22
 
25
23
  // --- UTILS ---
26
24
  import ProtectedRoute from './components/ProtectedRoute';
25
+ import { useStudioHotkeys } from './hooks/useStudioHotkeys';
27
26
  import NotFound from './components/NotFound';
28
27
  import ScrollToTop from './components/ScrollToTop';
29
28
  import HashLinkScroll from './components/HashLinkScroll';
@@ -82,6 +81,7 @@ const generateRoutes = (items: any[]) => {
82
81
  };
83
82
 
84
83
  function App() {
84
+ useStudioHotkeys();
85
85
  const liveDashboardRoutes = useDynamicSitemap(dashboardSitemap);
86
86
 
87
87
  return (
@@ -120,6 +120,7 @@ function App() {
120
120
 
121
121
  {/* Settings */}
122
122
  <Route path="/settings/*" element={<SettingsLayout />}>
123
+ <Route index element={<Navigate to="general" replace />} />
123
124
  {generateRoutes(settingsSitemap)}
124
125
  </Route>
125
126
 
@@ -77,6 +77,11 @@ const AppHeader: React.FC<AppHeaderProps> = ({
77
77
  label="Documents"
78
78
  onClick={() => navigate('/docs')}
79
79
  />
80
+ <ButtonItem
81
+ icon="cpu"
82
+ label="Bodewell"
83
+ onClick={() => navigate('/bodewell/fleet')}
84
+ />
80
85
 
81
86
  <ButtonItem
82
87
  icon="refresh-cw"
@@ -5,12 +5,12 @@ const ScrollToTop = () => {
5
5
  const { pathname } = useLocation();
6
6
 
7
7
  useEffect(() => {
8
- // "instant" prevents the user from seeing the page scroll up
9
- window.scrollTo({
10
- top: 0,
11
- left: 0,
12
- behavior: 'instant',
13
- });
8
+ const container = document.getElementById('main-scroll-container');
9
+ if (container) {
10
+ container.scrollTo({ top: 0, left: 0, behavior: 'instant' as ScrollBehavior });
11
+ } else {
12
+ window.scrollTo({ top: 0, left: 0, behavior: 'instant' as ScrollBehavior });
13
+ }
14
14
  }, [pathname]);
15
15
 
16
16
  return null;
@@ -1,61 +1,160 @@
1
- // ramme-app-starter/template/src/pages/AiChat.tsx
2
- import React, { useState } from 'react';
3
- import {
4
- Card,
5
- PageHeader,
6
- Conversation,
7
- Message,
8
- PromptInput,
1
+ import React, { useState, useEffect, useRef } from 'react';
2
+ import ReactMarkdown from 'react-markdown';
3
+ import remarkGfm from 'remark-gfm';
4
+ import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
5
+ import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
6
+ import {
7
+ Card, Conversation, Message, PromptInput, PageHeader, Icon,
9
8
  } from '@ramme-io/ui';
10
- import { useMockChat } from '../../assistant/useMockChat'; // <--- The new brain
9
+
10
+ const systemInstruction = `
11
+ You are the Ramme OS Assistant, part of the internal dev team.
12
+ Ramme is a high-performance, browser-native modular web-OS (Kernel + UI).
13
+ It is NOT the Instagram wrapper by Niklas Higle.
14
+ Answer as a Senior Engineer helping with a monorepo project.
15
+ Use numbered lists for steps and code blocks for commands.
16
+ `;
11
17
 
12
18
  const AiChat: React.FC = () => {
13
- const { messages, isLoading, sendMessage } = useMockChat();
14
19
  const [input, setInput] = useState('');
20
+ const [isLoading, setIsLoading] = useState(false);
21
+ const [apiHistory, setApiHistory] = useState<any[]>([]);
22
+ const scrollRef = useRef<HTMLDivElement>(null);
23
+ const [messages, setMessages] = useState([
24
+ { id: 'init', author: 'AI Agent', content: 'Ramme OS Assistant online. How can I help you build today?', isUser: false }
25
+ ]);
26
+
27
+ useEffect(() => {
28
+ if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
29
+ }, [messages, isLoading]);
30
+
31
+ const handleSubmit = async (e?: React.FormEvent) => {
32
+ if (e) e.preventDefault();
33
+ if (!input.trim() || isLoading) return;
15
34
 
16
- const handleSubmit = (e: React.FormEvent) => {
17
- e.preventDefault();
18
- sendMessage(input);
35
+ const userText = input;
36
+ setMessages(prev => [...prev, { id: `u-${Date.now()}`, author: 'You', content: userText, isUser: true }]);
19
37
  setInput('');
38
+ setIsLoading(true);
39
+
40
+ try {
41
+ const apiKey = localStorage.getItem('ramme_vault_gemini_key');
42
+ if (!apiKey) {
43
+ setMessages(prev => [...prev, {
44
+ id: `err-${Date.now()}`,
45
+ author: 'System',
46
+ content: 'No API key found. Please configure your Gemini API key in Settings → Theme Engine.',
47
+ isUser: false
48
+ }]);
49
+ setIsLoading(false);
50
+ return;
51
+ }
52
+
53
+ const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${apiKey}`, {
54
+ method: 'POST',
55
+ headers: { 'Content-Type': 'application/json' },
56
+ body: JSON.stringify({
57
+ contents: [
58
+ { role: "user", parts: [{ text: `INSTRUCTION: ${systemInstruction}` }] },
59
+ { role: "model", parts: [{ text: "Understood." }] },
60
+ ...apiHistory,
61
+ { role: "user", parts: [{ text: userText }] }
62
+ ]
63
+ })
64
+ });
65
+
66
+ const data = await response.json();
67
+ const aiText = data.candidates?.[0]?.content?.parts?.[0]?.text || "No response.";
68
+
69
+ setMessages(prev => [...prev, { id: `ai-${Date.now()}`, author: 'AI Agent', content: aiText, isUser: false }]);
70
+ setApiHistory([...apiHistory, { role: "user", parts: [{ text: userText }] }, { role: "model", parts: [{ text: aiText }] }]);
71
+ } catch (err: any) {
72
+ setMessages(prev => [...prev, { id: `err-${Date.now()}`, author: 'System', content: `Error: ${err.message}`, isUser: false }]);
73
+ } finally {
74
+ setIsLoading(false);
75
+ }
20
76
  };
21
77
 
22
78
  return (
23
- <div className="p-4 sm:p-6 lg:p-8 space-y-8 h-[calc(100vh-64px)] flex flex-col">
79
+ <div className="p-4 sm:p-6 lg:p-8 space-y-6 h-[calc(100vh-64px)] flex flex-col">
24
80
  <PageHeader
25
81
  title="AI Assistant"
26
82
  description="Full-screen command center for Ramme AI."
27
83
  />
28
84
 
29
- {/* We use flex-1 on the Card to make it fill the remaining screen space
30
- perfectly, creating that immersive "ChatGPT" feel.
31
- */}
32
- <Card className="flex-1 flex flex-col min-h-0 shadow-sm border-border/50">
33
- <div className="flex-1 overflow-hidden">
85
+ <Card className="flex-1 flex flex-col min-h-0 shadow-sm border-border/50 overflow-hidden">
86
+ <div className="px-4 py-2 border-b flex justify-between items-center bg-muted/20">
87
+ <div className="flex items-center gap-2">
88
+ <div className={`w-2 h-2 rounded-full ${isLoading ? 'bg-amber-500 animate-pulse' : 'bg-emerald-500'}`} />
89
+ <span className="font-bold text-[10px] uppercase tracking-widest opacity-60">Ramme Assistant</span>
90
+ </div>
91
+ </div>
92
+
93
+ <div ref={scrollRef} className="flex-1 overflow-y-auto px-4 py-4 custom-scrollbar">
34
94
  <Conversation>
35
95
  {messages.map((msg) => (
36
- <Message
37
- key={msg.id}
38
- author={msg.author}
39
- content={msg.content}
40
- isUser={msg.isUser}
41
- loading={msg.loading}
42
- suggestions={msg.suggestions}
43
- onSuggestionClick={(s) => sendMessage(s)}
44
- />
96
+ <div key={msg.id} className={`flex ${msg.isUser ? 'justify-end' : 'justify-start'} mb-3`}>
97
+ <div className="max-w-[98%] w-full">
98
+ <Message
99
+ author={msg.author}
100
+ isUser={msg.isUser}
101
+ // @ts-ignore
102
+ content={
103
+ <div className="prose prose-sm dark:prose-invert max-w-none text-foreground leading-relaxed">
104
+ <ReactMarkdown
105
+ remarkPlugins={[remarkGfm]}
106
+ components={{
107
+ p: ({children}) => <p className="mb-4 last:mb-0">{children}</p>,
108
+ ol: ({children}) => <ol className="list-decimal pl-8 mb-4 space-y-2">{children}</ol>,
109
+ ul: ({children}) => <ul className="list-disc pl-8 mb-4 space-y-2">{children}</ul>,
110
+ li: ({children}) => <li className="pl-1">{children}</li>,
111
+ code({ node, inline, className, children, ...props }: any) {
112
+ const match = /language-(\w+)/.exec(className || '');
113
+ return !inline && match ? (
114
+ <div className="my-5 rounded-lg overflow-hidden border border-border bg-[#0d0d0d] shadow-xl">
115
+ <div className="px-3 py-1 bg-white/5 border-b border-white/10 flex justify-between">
116
+ <span className="text-[9px] text-muted-foreground uppercase">{match[1]}</span>
117
+ </div>
118
+ <SyntaxHighlighter
119
+ style={oneDark}
120
+ language={match[1]}
121
+ PreTag="div"
122
+ customStyle={{ margin: 0, padding: '1.2rem', fontSize: '0.8rem', background: 'transparent' }}
123
+ {...props}
124
+ >
125
+ {String(children).replace(/\n$/, '')}
126
+ </SyntaxHighlighter>
127
+ </div>
128
+ ) : (
129
+ <code className="bg-muted px-1.5 py-0.5 rounded font-mono text-primary text-[0.9em]" {...props}>
130
+ {children}
131
+ </code>
132
+ );
133
+ },
134
+ }}
135
+ >
136
+ {msg.content}
137
+ </ReactMarkdown>
138
+ </div>
139
+ }
140
+ />
141
+ </div>
142
+ </div>
45
143
  ))}
46
- {isLoading && <Message author="AI Agent" isUser={false} loading />}
47
144
  </Conversation>
48
145
  </div>
49
-
50
- <div className="p-4 border-t bg-card">
51
- <PromptInput
52
- value={input}
53
- onChange={(e) => setInput(e.target.value)}
54
- onSubmit={handleSubmit}
55
- placeholder="Type a command or ask a question..."
56
- />
146
+
147
+ <div className="p-4 border-t bg-card/40 backdrop-blur-sm">
148
+ <PromptInput value={input} onChange={(e) => setInput(e.target.value)} onSubmit={handleSubmit} placeholder="Ask about Ramme..." />
57
149
  </div>
58
150
  </Card>
151
+
152
+ <style dangerouslySetInnerHTML={{ __html: `
153
+ .custom-scrollbar::-webkit-scrollbar { width: 4px; }
154
+ .custom-scrollbar::-webkit-scrollbar-track { background: transparent; }
155
+ .custom-scrollbar::-webkit-scrollbar-thumb { background: rgb(var(--app-primary-color), 0.2); border-radius: 10px; }
156
+ .custom-scrollbar::-webkit-scrollbar-thumb:hover { background: rgb(var(--app-primary-color), 0.5); }
157
+ `}} />
59
158
  </div>
60
159
  );
61
160
  };
@@ -7,6 +7,19 @@ const EMERGENCY_ADMIN: User = {
7
7
  id: 'usr_admin_force',
8
8
  name: 'Alex Admin (Recovery)',
9
9
  email: 'alex@example.com',
10
+ password: 'admin',
11
+ role: 'admin',
12
+ status: 'active',
13
+ joinedAt: new Date().toISOString(),
14
+ lastActive: new Date().toISOString()
15
+ };
16
+
17
+ // 🌐 DEFAULT GUEST: Auto-login for frictionless marketing site experience
18
+ const DEFAULT_GUEST: User = {
19
+ id: 'usr_guest',
20
+ name: 'Guest Creator',
21
+ email: 'guest@ramme.io',
22
+ password: 'guest',
10
23
  role: 'admin',
11
24
  status: 'active',
12
25
  joinedAt: new Date().toISOString(),
@@ -27,10 +40,10 @@ interface AuthContextType {
27
40
  const AuthContext = createContext<AuthContextType | undefined>(undefined);
28
41
 
29
42
  export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
30
- const [user, setUser] = useState<User | null>(null);
31
- const [isLoading, setIsLoading] = useState(true);
43
+ const [user, setUser] = useState<User | null>(DEFAULT_GUEST);
44
+ const [isLoading, setIsLoading] = useState(false);
32
45
 
33
- // 1. Mount: Check for existing session
46
+ // 1. Mount: Check for existing session, fall back to guest
34
47
  useEffect(() => {
35
48
  const storedUser = localStorage.getItem(SESSION_KEY);
36
49
  if (storedUser) {
@@ -38,15 +51,12 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
38
51
  const parsed = JSON.parse(storedUser);
39
52
  if (parsed && parsed.id) {
40
53
  setUser(parsed);
41
- } else {
42
- localStorage.removeItem(SESSION_KEY);
43
54
  }
44
55
  } catch (e) {
45
56
  console.error("Failed to parse session", e);
46
57
  localStorage.removeItem(SESSION_KEY);
47
58
  }
48
59
  }
49
- setIsLoading(false);
50
60
  }, []);
51
61
 
52
62
  // 2. Action: Login
@@ -14,8 +14,8 @@ export const AppConfigProvider = ({ children }: { children: ReactNode }) => {
14
14
 
15
15
  const [enableCRT, setEnableCRT] = useState(() => {
16
16
  const stored = localStorage.getItem('app_enable_crt');
17
- // Default to true for that retro-first experience
18
- return stored !== null ? JSON.parse(stored) : true;
17
+ // Default to false for clean marketing site clarity
18
+ return stored !== null ? JSON.parse(stored) : false;
19
19
  });
20
20
 
21
21
  useEffect(() => {
@@ -0,0 +1,149 @@
1
+ import React, { useEffect, useRef, useState } from 'react';
2
+ import { useDevice } from '@ramme-io/kernel';
3
+ import { CircularDial, WaterPumpControl, TelemetryLog } from '@ramme-io/ui';
4
+ import type { TelemetryLogRow } from '@ramme-io/ui';
5
+ import type { PumpMode } from '@ramme-io/ui';
6
+ import { StandardPageLayout } from '../../../components/layout/StandardPageLayout';
7
+
8
+ const EdgeTelemetryDemo: React.FC = () => {
9
+ const telemetry = useDevice();
10
+ const [fps, setFps] = useState(0);
11
+ const [pumpMode, setPumpMode] = useState<PumpMode>('AUTO');
12
+ const [targetMoisture, setTargetMoisture] = useState(40);
13
+ const [logData, setLogData] = useState<TelemetryLogRow[]>([]);
14
+ const logBuffer = useRef<TelemetryLogRow[]>([]);
15
+ const frameCount = useRef(0);
16
+ const rafId = useRef(0);
17
+
18
+ // Append incoming telemetry to the historical log buffer (capped at 100)
19
+ useEffect(() => {
20
+ if (!telemetry) return;
21
+ const row: TelemetryLogRow = {
22
+ timestamp: Date.now(),
23
+ soilMoisture: telemetry.soilMoisture,
24
+ ambientLight: telemetry.ambientLight,
25
+ temperature: telemetry.temperature,
26
+ };
27
+ logBuffer.current = [...logBuffer.current.slice(-99), row];
28
+ setLogData(logBuffer.current);
29
+ }, [telemetry]);
30
+
31
+ // Continuous RAF loop tallies frames; 1s interval reads & resets the tally
32
+ useEffect(() => {
33
+ const tick = () => {
34
+ frameCount.current += 1;
35
+ rafId.current = requestAnimationFrame(tick);
36
+ };
37
+ rafId.current = requestAnimationFrame(tick);
38
+
39
+ const interval = setInterval(() => {
40
+ setFps(frameCount.current);
41
+ frameCount.current = 0;
42
+ }, 1000);
43
+
44
+ return () => {
45
+ cancelAnimationFrame(rafId.current);
46
+ clearInterval(interval);
47
+ };
48
+ }, []);
49
+
50
+ return (
51
+ <StandardPageLayout
52
+ title="Edge Telemetry Engine"
53
+ description="Hardware-in-the-loop simulation testing 50Hz MQTT ingestion and Zero Jank UI rendering."
54
+ >
55
+ {/* Status Bar */}
56
+ <div className="flex items-center gap-4 p-4 rounded-2xl bg-card border border-border font-mono text-xs mb-8">
57
+ <div className="flex items-center gap-2">
58
+ <div className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
59
+ <span className="font-bold text-foreground uppercase tracking-widest">Kernel Link Active</span>
60
+ </div>
61
+ <div className="ml-auto flex items-center gap-4">
62
+ <span className={fps > 20 ? "text-red-500 font-bold" : "text-green-500 font-bold"}>
63
+ {fps} FPS
64
+ </span>
65
+ <span className="text-muted-foreground">
66
+ Target: ~15 FPS | Simulator: 50Hz
67
+ </span>
68
+ </div>
69
+ </div>
70
+
71
+ {/* Telemetry Grid */}
72
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
73
+ {/* Soil Moisture Dial */}
74
+ <div className="flex flex-col items-center gap-4 p-8 rounded-3xl bg-card border border-border">
75
+ {telemetry ? (
76
+ <CircularDial
77
+ value={telemetry.soilMoisture ?? 0}
78
+ label="Soil Moisture"
79
+ unit="%"
80
+ size={180}
81
+ />
82
+ ) : (
83
+ <div className="text-muted-foreground text-sm">Awaiting telemetry...</div>
84
+ )}
85
+ </div>
86
+
87
+ {/* Ambient Light Dial */}
88
+ <div className="flex flex-col items-center gap-4 p-8 rounded-3xl bg-card border border-border">
89
+ {telemetry ? (
90
+ <CircularDial
91
+ value={telemetry.ambientLight ?? 0}
92
+ min={0}
93
+ max={100000}
94
+ label="Ambient Light"
95
+ unit=" lx"
96
+ size={180}
97
+ />
98
+ ) : (
99
+ <div className="text-muted-foreground text-sm">Awaiting telemetry...</div>
100
+ )}
101
+ </div>
102
+
103
+ {/* Temperature Dial */}
104
+ <div className="flex flex-col items-center gap-4 p-8 rounded-3xl bg-card border border-border">
105
+ {telemetry ? (
106
+ <CircularDial
107
+ value={telemetry.temperature ?? 0}
108
+ min={-40}
109
+ max={125}
110
+ label="Temperature"
111
+ unit="°C"
112
+ size={180}
113
+ />
114
+ ) : (
115
+ <div className="text-muted-foreground text-sm">Awaiting telemetry...</div>
116
+ )}
117
+ </div>
118
+ </div>
119
+
120
+ {/* Command: Water Pump Actuation */}
121
+ <div className="mt-8">
122
+ <WaterPumpControl
123
+ pumpMode={pumpMode}
124
+ targetMoisture={targetMoisture}
125
+ onCommand={(payload) => {
126
+ setPumpMode(payload.pumpMode);
127
+ setTargetMoisture(payload.targetMoisture);
128
+ console.log('[MQTT PUBLISH MOCK]', payload);
129
+ }}
130
+ className="max-w-md"
131
+ />
132
+ </div>
133
+
134
+ {/* Raw Payload Debug */}
135
+ <div className="mt-8 p-6 rounded-2xl bg-card border border-border">
136
+ <div className="font-mono text-[10px] uppercase tracking-[0.3em] text-muted-foreground mb-3 font-bold">Raw Smoothed Payload</div>
137
+ <pre className="font-mono text-sm text-foreground/80 leading-relaxed tabular-nums">
138
+ {JSON.stringify(telemetry, null, 2)}
139
+ </pre>
140
+ </div>
141
+ {/* Telemetry Log — AG Grid virtualized scroll */}
142
+ <div className="mt-8">
143
+ <TelemetryLog rowData={logData} maxRows={100} height="320px" />
144
+ </div>
145
+ </StandardPageLayout>
146
+ );
147
+ };
148
+
149
+ export default EdgeTelemetryDemo;