UI Phenikaa

This commit is contained in:
2026-07-22 10:13:19 +07:00
parent 0714b442be
commit 3da8608cf1
13 changed files with 1079 additions and 183 deletions

View File

@@ -5,12 +5,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light" /> <meta name="color-scheme" content="light" />
<title>Robot Installer</title> <title>Robot Installer</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Manrope:wght@700;800&display=swap"
rel="stylesheet"
/>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -8,13 +8,13 @@
"name": "robot-installer-web-client", "name": "robot-installer-web-client",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@fluentui/react-icons": "^2.0.333",
"@fontsource-variable/montserrat": "^5.3.0",
"@vitejs/plugin-react": "^5.0.0", "@vitejs/plugin-react": "^5.0.0",
"lucide-react": "^0.468.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"vite": "^7.0.0" "vite": "^7.0.0"
}, }
"devDependencies": {}
}, },
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
"version": "7.29.0", "version": "7.29.0",
@@ -234,6 +234,15 @@
"@babel/core": "^7.0.0-0" "@babel/core": "^7.0.0-0"
} }
}, },
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": { "node_modules/@babel/template": {
"version": "7.28.6", "version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -279,6 +288,12 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@emotion/hash": {
"version": "0.9.2",
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz",
"integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==",
"license": "MIT"
},
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.27.7", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
@@ -695,6 +710,64 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@fluentui/react-icons": {
"version": "2.0.333",
"resolved": "https://registry.npmjs.org/@fluentui/react-icons/-/react-icons-2.0.333.tgz",
"integrity": "sha512-HvS5kKw9tGweI3Poy6OGAiFqrt6HGElO46DFhNBeoIQRK/q35mZ2ep0u762MFuvOfLX3bVGSB8frORFTjh1E7A==",
"license": "MIT",
"dependencies": {
"@griffel/react": "^1.6.1",
"tslib": "^2.1.0"
},
"peerDependencies": {
"react": ">=16.8.0 <20.0.0"
}
},
"node_modules/@fontsource-variable/montserrat": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/montserrat/-/montserrat-5.3.0.tgz",
"integrity": "sha512-7PaZoxaxrWLAyrhO46v65An9LhUhfkTExWLhfbywYZCnZEgg/W1rEHnlNmZKjNZ3nJTVYyicqxlJt10z/26yTA==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@griffel/core": {
"version": "1.21.3",
"resolved": "https://registry.npmjs.org/@griffel/core/-/core-1.21.3.tgz",
"integrity": "sha512-FMnlwhtmCRWvXEg2j/6W90wzvW+PFqdrsWFslfmxwS6l9X73gO0dnndKphht9ZOsJODvmLdqlnU1Lh8igg2mKw==",
"license": "MIT",
"dependencies": {
"@emotion/hash": "^0.9.0",
"@griffel/style-types": "^1.4.2",
"csstype": "^3.2.3",
"rtl-css-js": "^1.16.1",
"stylis": "^4.4.0",
"tslib": "^2.1.0"
}
},
"node_modules/@griffel/react": {
"version": "1.7.6",
"resolved": "https://registry.npmjs.org/@griffel/react/-/react-1.7.6.tgz",
"integrity": "sha512-hqkbKRfSN/jKvPFzMoaA+gzoA9INfDyDqt7ypHOJP3KjZWp+n5f/QxLpmvqE3ssqDDS7YsFKN6qPmYBRC2SA7w==",
"license": "MIT",
"dependencies": {
"@griffel/core": "^1.21.3",
"tslib": "^2.1.0"
},
"peerDependencies": {
"react": ">=16.14.0 <20.0.0"
}
},
"node_modules/@griffel/style-types": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/@griffel/style-types/-/style-types-1.4.2.tgz",
"integrity": "sha512-MsSghfpyxR2MpTrYdcCozISsSLkmFjNw94wNPi4bDBRLW8W43718W/ZjmUdVkoM0KXMtJPYuEkx8Mzibqb03qA==",
"license": "MIT",
"dependencies": {
"csstype": "^3.2.3"
}
},
"node_modules/@jridgewell/gen-mapping": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -1248,6 +1321,12 @@
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1400,15 +1479,6 @@
"yallist": "^3.0.2" "yallist": "^3.0.2"
} }
}, },
"node_modules/lucide-react": {
"version": "0.468.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz",
"integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
}
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -1562,6 +1632,15 @@
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
"node_modules/rtl-css-js": {
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/rtl-css-js/-/rtl-css-js-1.16.1.tgz",
"integrity": "sha512-lRQgou1mu19e+Ya0LsTvKrVJ5TYUbqCVPAiImX3UfLTenarvPUl1QFdvu5Z3PYmHT9RCcwIfbjRQBntExyj3Zg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.1.2"
}
},
"node_modules/scheduler": { "node_modules/scheduler": {
"version": "0.27.0", "version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -1586,6 +1665,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/stylis": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz",
"integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==",
"license": "MIT"
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.16", "version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
@@ -1602,6 +1687,12 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/update-browserslist-db": { "node_modules/update-browserslist-db": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",

View File

@@ -10,11 +10,11 @@
"preview": "vite preview --host 0.0.0.0" "preview": "vite preview --host 0.0.0.0"
}, },
"dependencies": { "dependencies": {
"@fluentui/react-icons": "^2.0.333",
"@fontsource-variable/montserrat": "^5.3.0",
"@vitejs/plugin-react": "^5.0.0", "@vitejs/plugin-react": "^5.0.0",
"vite": "^7.0.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"lucide-react": "^0.468.0" "vite": "^7.0.0"
}, }
"devDependencies": {}
} }

View File

@@ -1,30 +1,31 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { import {
Activity, ArrowCounterclockwise20Regular as RotateCcw,
AlertCircle, ArrowDownload20Regular as Download,
Box, ArrowSync20Regular as RefreshCcw,
CheckCircle2, Board20Regular as Cpu,
Clipboard, Bot24Filled as PackageCheck,
Cpu, Box20Regular as Box,
Download, CheckmarkCircle20Filled as CheckCircle2,
ExternalLink, Clipboard20Regular as Clipboard,
HardDrive, Delete20Regular as Trash2,
Loader2, Dismiss20Regular as X,
PackageCheck, ErrorCircle20Filled as XCircle,
Play, HardDrive20Regular as HardDrive,
PlugZap, Open20Regular as ExternalLink,
RefreshCcw, Play20Filled as Play,
RotateCcw, PlugConnected20Regular as PlugZap,
Search, Pulse20Regular as Activity,
Server, Search20Regular as Search,
Settings, Server20Regular as Server,
ShieldCheck, Settings20Regular as Settings,
Trash2, ShieldCheckmark20Filled as ShieldCheck,
WifiOff, SpinnerIos20Regular as Loader2,
X, Warning20Filled as AlertCircle,
XCircle WifiOff20Regular as WifiOff
} from 'lucide-react'; } from '@fluentui/react-icons';
import '@fontsource-variable/montserrat';
import { import {
DEFAULT_AGENT_BASE_URL, DEFAULT_AGENT_BASE_URL,
DEFAULT_PACKAGE_BASE_URL, DEFAULT_PACKAGE_BASE_URL,
@@ -755,7 +756,7 @@ function App() {
<aside className="sidebar"> <aside className="sidebar">
<div className="brand-block"> <div className="brand-block">
<div className="brand-mark"> <div className="brand-mark">
<PackageCheck size={20} aria-hidden="true" /> <PackageCheck fontSize={20} aria-hidden="true" />
</div> </div>
<div className="brand-copy"> <div className="brand-copy">
<strong>Robot Installer</strong> <strong>Robot Installer</strong>
@@ -790,7 +791,7 @@ function App() {
</div> </div>
</div> </div>
<button className="btn btn-secondary full" type="button" onClick={openEndpointDialog}> <button className="btn btn-secondary full" type="button" onClick={openEndpointDialog}>
<Settings size={15} aria-hidden="true" /> <Settings fontSize={15} aria-hidden="true" />
Endpoint settings Endpoint settings
</button> </button>
</div> </div>
@@ -810,10 +811,10 @@ function App() {
tone={agentStatusTone} tone={agentStatusTone}
/> />
<a className="icon-button" href={joinUrl(packageBaseUrl, '/api/apps')} target="_blank" rel="noreferrer" title="Open package API"> <a className="icon-button" href={joinUrl(packageBaseUrl, '/api/apps')} target="_blank" rel="noreferrer" title="Open package API">
<ExternalLink size={17} aria-hidden="true" /> <ExternalLink fontSize={17} aria-hidden="true" />
</a> </a>
<button className="btn btn-secondary" type="button" onClick={refreshAll}> <button className="btn btn-secondary" type="button" onClick={refreshAll}>
<RefreshCcw size={15} aria-hidden="true" /> <RefreshCcw fontSize={15} aria-hidden="true" />
Retry Retry
</button> </button>
</div> </div>
@@ -832,13 +833,13 @@ function App() {
{canUseAgentEndpoint && !agentHealth && ( {canUseAgentEndpoint && !agentHealth && (
<div className="offline-banner"> <div className="offline-banner">
<AlertCircle size={19} aria-hidden="true" /> <AlertCircle fontSize={19} aria-hidden="true" />
<div> <div>
<strong>{agentTargetLabel} is offline</strong> <strong>{agentTargetLabel} is offline</strong>
<code>{agentCommand}</code> <code>{agentCommand}</code>
</div> </div>
<button className="btn btn-secondary" type="button" onClick={copyInstallCommand}> <button className="btn btn-secondary" type="button" onClick={copyInstallCommand}>
<Clipboard size={15} aria-hidden="true" /> <Clipboard fontSize={15} aria-hidden="true" />
Copy Copy
</button> </button>
</div> </div>
@@ -846,13 +847,13 @@ function App() {
{canUseAgentEndpoint && agentNeedsUpdate && ( {canUseAgentEndpoint && agentNeedsUpdate && (
<div className="offline-banner agent-update-banner"> <div className="offline-banner agent-update-banner">
<AlertCircle size={19} aria-hidden="true" /> <AlertCircle fontSize={19} aria-hidden="true" />
<div> <div>
<strong>Agent {latestAgentPackage.version} is ready</strong> <strong>Agent {latestAgentPackage.version} is ready</strong>
<code>{agentCommand}</code> <code>{agentCommand}</code>
</div> </div>
<button className="btn btn-secondary" type="button" onClick={copyInstallCommand}> <button className="btn btn-secondary" type="button" onClick={copyInstallCommand}>
<Clipboard size={15} aria-hidden="true" /> <Clipboard fontSize={15} aria-hidden="true" />
Copy Copy
</button> </button>
</div> </div>
@@ -864,7 +865,7 @@ function App() {
<label className="filter-field wide"> <label className="filter-field wide">
<span>Search</span> <span>Search</span>
<div className="input-with-icon"> <div className="input-with-icon">
<Search size={15} aria-hidden="true" /> <Search fontSize={15} aria-hidden="true" />
<input <input
value={query} value={query}
onChange={(event) => setQuery(event.target.value)} onChange={(event) => setQuery(event.target.value)}
@@ -952,7 +953,7 @@ function App() {
runAppAction('install', app); runAppAction('install', app);
}} }}
> >
{installBusy ? <Loader2 className="spin" size={14} aria-hidden="true" /> : <Play size={14} aria-hidden="true" />} {installBusy ? <Loader2 className="spin" fontSize={14} aria-hidden="true" /> : <Play fontSize={14} aria-hidden="true" />}
Install Install
</button> </button>
)} )}
@@ -966,7 +967,7 @@ function App() {
runAppAction('update', app); runAppAction('update', app);
}} }}
> >
{updateBusy ? <Loader2 className="spin" size={14} aria-hidden="true" /> : <RotateCcw size={14} aria-hidden="true" />} {updateBusy ? <Loader2 className="spin" fontSize={14} aria-hidden="true" /> : <RotateCcw fontSize={14} aria-hidden="true" />}
Retry Retry
</button> </button>
)} )}
@@ -980,7 +981,7 @@ function App() {
runAppAction('update', app); runAppAction('update', app);
}} }}
> >
{updateBusy ? <Loader2 className="spin" size={14} aria-hidden="true" /> : <RotateCcw size={14} aria-hidden="true" />} {updateBusy ? <Loader2 className="spin" fontSize={14} aria-hidden="true" /> : <RotateCcw fontSize={14} aria-hidden="true" />}
Update Update
</button> </button>
)} )}
@@ -993,7 +994,7 @@ function App() {
title={`Open ${app.appName}`} title={`Open ${app.appName}`}
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
> >
<ExternalLink size={14} aria-hidden="true" /> <ExternalLink fontSize={14} aria-hidden="true" />
Open App Open App
</a> </a>
)} )}
@@ -1008,7 +1009,7 @@ function App() {
runAppAction('remove', app); runAppAction('remove', app);
}} }}
> >
{removeBusy ? <Loader2 className="spin" size={16} aria-hidden="true" /> : <Trash2 size={16} aria-hidden="true" />} {removeBusy ? <Loader2 className="spin" fontSize={16} aria-hidden="true" /> : <Trash2 fontSize={16} aria-hidden="true" />}
</button> </button>
)} )}
</div> </div>
@@ -1084,7 +1085,7 @@ function AgentStatusButton({ title, detail, tone, onClick }) {
return ( return (
<button className={`agent-status-button tone-${tone || 'info'}`} type="button" onClick={onClick}> <button className={`agent-status-button tone-${tone || 'info'}`} type="button" onClick={onClick}>
<span className="agent-status-icon"> <span className="agent-status-icon">
<Icon className={tone === 'info' ? 'spin' : ''} size={16} aria-hidden="true" /> <Icon className={tone === 'info' ? 'spin' : ''} fontSize={16} aria-hidden="true" />
</span> </span>
<span className="agent-status-copy"> <span className="agent-status-copy">
<strong>{title}</strong> <strong>{title}</strong>
@@ -1130,7 +1131,7 @@ function EndpointDialog({ draftSettings, onApply, onCancel, onChange }) {
<p>Changes only apply after you press Apply.</p> <p>Changes only apply after you press Apply.</p>
</div> </div>
<button className="icon-button subtle" type="button" title="Close" onClick={onCancel}> <button className="icon-button subtle" type="button" title="Close" onClick={onCancel}>
<X size={16} aria-hidden="true" /> <X fontSize={16} aria-hidden="true" />
</button> </button>
</div> </div>
@@ -1163,7 +1164,7 @@ function EndpointDialog({ draftSettings, onApply, onCancel, onChange }) {
Cancel Cancel
</button> </button>
<button className="btn btn-primary" type="submit"> <button className="btn btn-primary" type="submit">
<Settings size={15} aria-hidden="true" /> <Settings fontSize={15} aria-hidden="true" />
Apply endpoints Apply endpoints
</button> </button>
</div> </div>
@@ -1176,7 +1177,7 @@ function EndpointDialog({ draftSettings, onApply, onCancel, onChange }) {
function StatusBox({ icon: Icon, title, detail, tone }) { function StatusBox({ icon: Icon, title, detail, tone }) {
return ( return (
<div className={`status-box tone-${tone || 'muted'}`}> <div className={`status-box tone-${tone || 'muted'}`}>
<span className="status-icon"><Icon size={16} aria-hidden="true" /></span> <span className="status-icon"><Icon fontSize={16} aria-hidden="true" /></span>
<div> <div>
<strong>{title}</strong> <strong>{title}</strong>
<span>{detail}</span> <span>{detail}</span>
@@ -1188,7 +1189,7 @@ function StatusBox({ icon: Icon, title, detail, tone }) {
function ClientOsNotice({ agentCommand, canShowCommand, isWindows, onCopyCommand, osLabel }) { function ClientOsNotice({ agentCommand, canShowCommand, isWindows, onCopyCommand, osLabel }) {
return ( return (
<div className={`offline-banner client-os-banner ${canShowCommand ? 'command-visible' : ''}`}> <div className={`offline-banner client-os-banner ${canShowCommand ? 'command-visible' : ''}`}>
<AlertCircle size={19} aria-hidden="true" /> <AlertCircle fontSize={19} aria-hidden="true" />
<div> <div>
<strong>{isWindows ? 'Remote Ubuntu endpoint needed' : 'Local Agent requires Linux'}</strong> <strong>{isWindows ? 'Remote Ubuntu endpoint needed' : 'Local Agent requires Linux'}</strong>
<p> <p>
@@ -1200,7 +1201,7 @@ function ClientOsNotice({ agentCommand, canShowCommand, isWindows, onCopyCommand
</div> </div>
{canShowCommand && ( {canShowCommand && (
<button className="btn btn-secondary" type="button" onClick={onCopyCommand}> <button className="btn btn-secondary" type="button" onClick={onCopyCommand}>
<Clipboard size={15} aria-hidden="true" /> <Clipboard fontSize={15} aria-hidden="true" />
Copy Copy
</button> </button>
)} )}
@@ -1268,11 +1269,11 @@ function TaskPanel({ task, onClear, onRefresh }) {
<div className="panel-actions"> <div className="panel-actions">
<span className={statusBadgeClass(task.status)}>{task.status || 'queued'}</span> <span className={statusBadgeClass(task.status)}>{task.status || 'queued'}</span>
<button className="icon-button subtle" type="button" title="Refresh task" onClick={onRefresh} disabled={!canRefresh}> <button className="icon-button subtle" type="button" title="Refresh task" onClick={onRefresh} disabled={!canRefresh}>
<RefreshCcw size={16} aria-hidden="true" /> <RefreshCcw fontSize={16} aria-hidden="true" />
</button> </button>
{canClear && ( {canClear && (
<button className="icon-button subtle" type="button" title="Clear task" onClick={onClear}> <button className="icon-button subtle" type="button" title="Clear task" onClick={onClear}>
<XCircle size={16} aria-hidden="true" /> <XCircle fontSize={16} aria-hidden="true" />
</button> </button>
)} )}
</div> </div>
@@ -1305,7 +1306,7 @@ function TaskPanel({ task, onClear, onRefresh }) {
<div className="component-list task-components"> <div className="component-list task-components">
<div className="component-list-title"> <div className="component-list-title">
<Activity size={15} aria-hidden="true" /> <Activity fontSize={15} aria-hidden="true" />
Components Components
</div> </div>
{components.map((component) => ( {components.map((component) => (
@@ -1351,7 +1352,7 @@ function ServiceChecks({ checks = [], status = 'not-checked', showSource = false
return ( return (
<div className="service-check-list"> <div className="service-check-list">
<div className="service-check-empty"> <div className="service-check-empty">
<Activity size={14} aria-hidden="true" /> <Activity fontSize={14} aria-hidden="true" />
<span>{message || 'Service status unavailable'}</span> <span>{message || 'Service status unavailable'}</span>
</div> </div>
</div> </div>
@@ -1364,8 +1365,8 @@ function ServiceChecks({ checks = [], status = 'not-checked', showSource = false
<div className="service-check-row" key={`${check.componentId || ''}:${check.serviceName}`}> <div className="service-check-row" key={`${check.componentId || ''}:${check.serviceName}`}>
<div className="service-check-name"> <div className="service-check-name">
{check.healthy {check.healthy
? <CheckCircle2 className="service-check-icon healthy" size={14} aria-hidden="true" /> ? <CheckCircle2 className="service-check-icon healthy" fontSize={14} aria-hidden="true" />
: <AlertCircle className="service-check-icon unhealthy" size={14} aria-hidden="true" />} : <AlertCircle className="service-check-icon unhealthy" fontSize={14} aria-hidden="true" />}
<span> <span>
<strong>{check.serviceName}</strong> <strong>{check.serviceName}</strong>
<small> <small>
@@ -1412,9 +1413,9 @@ function AgentPanel({ health, systemInfo, status, title, endpoint, latestAgentPa
<p>{status.message || endpoint || '127.0.0.1:5010'}</p> <p>{status.message || endpoint || '127.0.0.1:5010'}</p>
</div> </div>
<div className="panel-actions"> <div className="panel-actions">
{needsUpdate ? <AlertCircle className="panel-state warning" size={20} aria-hidden="true" /> : health ? <CheckCircle2 className="panel-state success" size={20} aria-hidden="true" /> : <XCircle className="panel-state danger" size={20} aria-hidden="true" />} {needsUpdate ? <AlertCircle className="panel-state warning" fontSize={20} aria-hidden="true" /> : health ? <CheckCircle2 className="panel-state success" fontSize={20} aria-hidden="true" /> : <XCircle className="panel-state danger" fontSize={20} aria-hidden="true" />}
<button className="icon-button subtle" type="button" title="Close" onClick={onClose}> <button className="icon-button subtle" type="button" title="Close" onClick={onClose}>
<X size={16} aria-hidden="true" /> <X fontSize={16} aria-hidden="true" />
</button> </button>
</div> </div>
</div> </div>
@@ -1454,18 +1455,18 @@ function AgentPanel({ health, systemInfo, status, title, endpoint, latestAgentPa
</div> </div>
</dl> </dl>
<div className="agent-metrics"> <div className="agent-metrics">
<span><Cpu size={14} aria-hidden="true" /> {systemInfo?.kernel || 'kernel -'}</span> <span><Cpu fontSize={14} aria-hidden="true" /> {systemInfo?.kernel || 'kernel -'}</span>
<span><HardDrive size={14} aria-hidden="true" /> {systemInfo?.diskFree || 'disk -'}</span> <span><HardDrive fontSize={14} aria-hidden="true" /> {systemInfo?.diskFree || 'disk -'}</span>
</div> </div>
{showAgentActions && ( {showAgentActions && (
<div className="agent-update-action"> <div className="agent-update-action">
<button className={`btn ${needsUpdate ? 'btn-warning' : 'btn-secondary'}`} type="button" onClick={onCopyUpdate}> <button className={`btn ${needsUpdate ? 'btn-warning' : 'btn-secondary'}`} type="button" onClick={onCopyUpdate}>
<Clipboard size={15} aria-hidden="true" /> <Clipboard fontSize={15} aria-hidden="true" />
{needsUpdate ? 'Copy update command' : 'Copy Agent command'} {needsUpdate ? 'Copy update command' : 'Copy Agent command'}
</button> </button>
{needsUpdate && latestAgentPackage?.downloadUrl && ( {needsUpdate && latestAgentPackage?.downloadUrl && (
<a className="btn btn-secondary" href={latestAgentPackage.downloadUrl} target="_blank" rel="noreferrer"> <a className="btn btn-secondary" href={latestAgentPackage.downloadUrl} target="_blank" rel="noreferrer">
<Download size={15} aria-hidden="true" /> <Download fontSize={15} aria-hidden="true" />
Latest .deb Latest .deb
</a> </a>
)} )}
@@ -1488,7 +1489,7 @@ function AppDetailPanel({ app, detail, manifest, status, packageBaseUrl }) {
<h2>{app?.appName || 'App detail'}</h2> <h2>{app?.appName || 'App detail'}</h2>
<p>{app?.appCode || app?.appId || status.message || packageBaseUrl}</p> <p>{app?.appCode || app?.appId || status.message || packageBaseUrl}</p>
</div> </div>
{status.state === 'loading' ? <Loader2 className="spin panel-state" size={20} aria-hidden="true" /> : <Box className="panel-state" size={20} aria-hidden="true" />} {status.state === 'loading' ? <Loader2 className="spin panel-state" fontSize={20} aria-hidden="true" /> : <Box className="panel-state" fontSize={20} aria-hidden="true" />}
</div> </div>
{app ? ( {app ? (
@@ -1510,7 +1511,7 @@ function AppDetailPanel({ app, detail, manifest, status, packageBaseUrl }) {
<div className="component-list"> <div className="component-list">
<div className="component-list-title"> <div className="component-list-title">
<ShieldCheck size={15} aria-hidden="true" /> <ShieldCheck fontSize={15} aria-hidden="true" />
Components Components
</div> </div>
{status.state === 'danger' && ( {status.state === 'danger' && (
@@ -1533,7 +1534,7 @@ function AppDetailPanel({ app, detail, manifest, status, packageBaseUrl }) {
{app.installed && installedServiceStatus !== 'not-checked' && ( {app.installed && installedServiceStatus !== 'not-checked' && (
<div className="service-health-panel"> <div className="service-health-panel">
<div className="component-list-title"> <div className="component-list-title">
<Activity size={15} aria-hidden="true" /> <Activity fontSize={15} aria-hidden="true" />
Service health Service health
<span className={`badge badge-${installedServiceStatus === 'healthy' ? 'success' : installedServiceStatus === 'unhealthy' ? 'danger' : 'muted'}`}> <span className={`badge badge-${installedServiceStatus === 'healthy' ? 'success' : installedServiceStatus === 'unhealthy' ? 'danger' : 'muted'}`}>
{serviceCheckSummary(installedServiceStatus, installedServiceChecks)} {serviceCheckSummary(installedServiceStatus, installedServiceChecks)}
@@ -1559,7 +1560,7 @@ function Toast({ toast }) {
const Icon = tone === 'danger' ? AlertCircle : tone === 'success' ? CheckCircle2 : Activity; const Icon = tone === 'danger' ? AlertCircle : tone === 'success' ? CheckCircle2 : Activity;
return ( return (
<div className={`toast tone-${tone || 'info'}`}> <div className={`toast tone-${tone || 'info'}`}>
<Icon size={17} aria-hidden="true" /> <Icon fontSize={17} aria-hidden="true" />
<span>{toast.message}</span> <span>{toast.message}</span>
</div> </div>
); );

View File

@@ -1,28 +1,93 @@
:root { :root {
--primary: #3755c3; color-scheme: light;
--primary-dim: #2848b7;
--primary-container: #dde1ff; /* Phenikaa-X universal tokens — design-language.md v0.4.4 */
--on-primary: #f8f7ff; --blue-50: #eaedf5;
--background: #f7f9fb; --blue-100: #c5cce0;
--surface-lowest: #ffffff; --blue-200: #8c9ac4;
--surface-low: #f0f4f7; --blue-300: #6e7ca8;
--surface: #e8eff3; --blue-500: #223771;
--surface-high: #e1e9ee; --blue-700: #1a2a57;
--on-surface: #2a3439; --blue-900: #1e2243;
--on-surface-variant: #566166; --orange-100: #fcd9c4;
--outline-variant: #a9b4b9; --orange-300: #f58220;
--danger: #b42318; --orange-500: #f26522;
--danger-bg: #fee4e2; --orange-700: #c44e14;
--success: #067647; --neutral-0: #ffffff;
--success-bg: #dcfae6; --neutral-50: #f7f8fb;
--warning: #b54708; --neutral-100: #eeedf6;
--warning-bg: #fef0c7; --neutral-200: #dddfea;
--info: #175cd3; --neutral-300: #c5c8d8;
--info-bg: #d1e9ff; --neutral-400: #9fa3b8;
--muted-bg: #e2e8f0; --neutral-450: #8c90a8;
--radius: 8px; --neutral-500: #757a91;
--shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.06); --neutral-600: #565b70;
--shadow-lg: 0 24px 60px rgba(15, 23, 42, 0.18); --neutral-700: #3c4054;
--neutral-800: #272a3a;
--neutral-900: #161824;
--surface-base: var(--neutral-0);
--surface-raised: var(--neutral-50);
--surface-brand-tint: var(--neutral-100);
--text-primary: var(--neutral-900);
--text-secondary: var(--neutral-600);
--text-tertiary: var(--neutral-500);
--border-subtle: var(--neutral-300);
--border-strong: var(--neutral-450);
--brand-primary: var(--blue-500);
--brand-primary-strong: var(--blue-700);
--brand-accent: var(--orange-500);
--brand-accent-strong: var(--orange-700);
--semantic-success: #2e7d32;
--semantic-success-bg: #e8f5e9;
--semantic-warning: #9a5b00;
--semantic-warning-icon: #cb6119;
--semantic-warning-bg: #fff4db;
--semantic-error: #c62828;
--semantic-error-bg: #ffebee;
--semantic-info: #1565c0;
--semantic-info-bg: #e7f1fc;
--space-xxs: 4px;
--space-xs: 8px;
--space-sm: 12px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 32px;
--space-2xl: 48px;
--radius-sm: 2px;
--radius-md: 4px;
--radius-lg: 8px;
--elevation-1: 0 1px 3px rgba(22, 24, 36, 0.12);
--elevation-2: 0 4px 12px rgba(22, 24, 36, 0.18);
/* Compatibility aliases used by the existing components. */
--primary: var(--brand-primary);
--primary-dim: var(--brand-primary-strong);
--primary-container: var(--blue-50);
--on-primary: var(--neutral-0);
--background: var(--surface-raised);
--surface-lowest: var(--surface-base);
--surface-low: var(--surface-raised);
--surface: var(--surface-brand-tint);
--surface-high: var(--neutral-200);
--on-surface: var(--text-primary);
--on-surface-variant: var(--text-secondary);
--outline: var(--border-strong);
--outline-variant: var(--border-subtle);
--danger: var(--semantic-error);
--danger-bg: var(--semantic-error-bg);
--success: var(--semantic-success);
--success-bg: var(--semantic-success-bg);
--warning: var(--semantic-warning);
--warning-bg: var(--semantic-warning-bg);
--info: var(--semantic-info);
--info-bg: var(--semantic-info-bg);
--muted-bg: var(--neutral-200);
--radius: var(--radius-md);
--shadow-sm: var(--elevation-1);
--shadow-lg: var(--elevation-2);
} }
* { * {
@@ -40,8 +105,9 @@ body {
margin: 0; margin: 0;
background: var(--background); background: var(--background);
color: var(--on-surface); color: var(--on-surface);
font-family: "Inter", Arial, sans-serif; font-family: "Montserrat Variable", Montserrat, "Segoe UI", sans-serif;
font-size: 14px; font-size: 15px;
line-height: 1.5;
overflow: hidden; overflow: hidden;
} }
@@ -75,7 +141,7 @@ p {
h1, h1,
h2, h2,
.brand-copy strong { .brand-copy strong {
font-family: "Manrope", Arial, sans-serif; font-family: "Montserrat Variable", Montserrat, "Segoe UI", sans-serif;
} }
code { code {
@@ -1446,3 +1512,290 @@ tbody tr.selected-row td.action-col {
} }
} }
/* --------------------------------------------------------------------------
Phenikaa-X application theme
Keeps the existing component contract while applying the Layer-1 language.
-------------------------------------------------------------------------- */
::selection {
background: var(--orange-100);
color: var(--text-primary);
}
:focus-visible {
outline: 2px solid var(--blue-500);
outline-offset: 2px;
}
.sidebar {
background: var(--blue-900);
border-color: var(--neutral-700);
}
.brand-block {
border-bottom: 1px solid rgba(197, 204, 224, 0.18);
min-height: 72px;
padding: var(--space-md);
}
.brand-mark {
background: var(--brand-accent);
border-radius: var(--radius-md);
box-shadow: none;
color: var(--neutral-0);
height: 40px;
width: 40px;
}
.brand-copy strong,
.sidebar .status-box strong,
.sidebar .endpoint-summary-row strong {
color: var(--neutral-0);
}
.brand-copy strong {
font-size: 15px;
font-weight: 700;
letter-spacing: -0.01em;
}
.brand-copy span,
.sidebar .nav-label,
.sidebar .endpoint-summary-row span,
.sidebar .status-box div > span {
color: var(--blue-100);
}
.brand-copy span,
.nav-label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
}
.sidebar .status-box,
.sidebar .endpoint-summary {
background: rgba(255, 255, 255, 0.055);
border-color: rgba(197, 204, 224, 0.2);
}
.sidebar .status-box:hover,
.sidebar .endpoint-summary:hover {
background: rgba(255, 255, 255, 0.085);
border-color: rgba(197, 204, 224, 0.35);
}
.sidebar .btn-secondary {
background: transparent;
border-color: var(--blue-300);
color: var(--neutral-0);
}
.sidebar .btn-secondary:hover {
background: rgba(255, 255, 255, 0.1);
border-color: var(--blue-100);
}
.topbar {
background: rgba(255, 255, 255, 0.96);
border-color: var(--border-subtle);
min-height: 64px;
}
.topbar-title span {
color: var(--text-tertiary);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.05em;
}
.topbar-title strong {
color: var(--text-primary);
font-size: 14px;
font-weight: 600;
}
.page {
padding: var(--space-lg);
}
.page h1 {
color: var(--text-primary);
font-size: 25px;
font-weight: 700;
letter-spacing: -0.025em;
line-height: 1.2;
}
.panel,
.table-panel,
.dialog-panel,
.offline-banner,
.agent-status-button {
border-color: var(--border-subtle);
border-radius: var(--radius-md);
box-shadow: var(--elevation-1);
}
.panel,
.table-panel,
.dialog-panel {
background: var(--surface-base);
}
.panel-header,
.dialog-header,
.dialog-actions,
.table-wrap thead,
.page-filters {
border-color: var(--border-subtle);
}
.btn,
.icon-button,
input,
select,
textarea {
border-radius: var(--radius-md);
}
.btn {
font-size: 13px;
font-weight: 600;
}
.btn-primary {
background: var(--brand-primary);
color: var(--neutral-0);
}
.btn-primary:hover {
background: var(--brand-primary-strong);
}
.btn-warning {
background: var(--brand-accent);
color: var(--neutral-900);
}
.btn-warning:hover {
background: var(--brand-accent-strong);
color: var(--neutral-0);
}
.btn-secondary,
.icon-button {
background: var(--surface-base);
border-color: var(--border-subtle);
color: var(--text-secondary);
}
.btn-secondary:hover,
.icon-button:hover,
.icon-button.subtle:hover {
background: var(--blue-50);
border-color: var(--blue-200);
color: var(--brand-primary);
}
.settings-field input,
.filter-field input,
.filter-field select,
input,
select,
textarea {
background: var(--surface-base);
border-color: var(--border-strong);
color: var(--text-primary);
}
.settings-field input:focus,
.filter-field input:focus,
.filter-field select:focus,
input:focus,
select:focus,
textarea:focus {
border-color: var(--brand-primary);
box-shadow: 0 0 0 3px rgba(34, 55, 113, 0.16);
}
table {
color: var(--text-primary);
}
thead {
background: var(--surface-brand-tint);
}
th {
color: var(--blue-700);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.045em;
}
td {
border-color: var(--neutral-200);
font-size: 13px;
}
tbody tr:hover,
.selected-row {
background: var(--blue-50);
}
.badge {
border-radius: var(--radius-sm);
font-weight: 600;
}
.badge-success {
background: var(--semantic-success-bg);
color: var(--semantic-success);
}
.badge-danger {
background: var(--semantic-error-bg);
color: var(--semantic-error);
}
.badge-warning {
background: var(--semantic-warning-bg);
color: var(--semantic-warning);
}
.badge-info,
.badge-primary {
background: var(--semantic-info-bg);
color: var(--semantic-info);
}
code,
.task-log-list {
background: var(--blue-900);
color: var(--neutral-100);
}
@media (max-width: 980px) {
.sidebar {
border-bottom-color: var(--neutral-700);
}
}
@media (max-width: 680px) {
.page {
padding: var(--space-md);
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}

View File

@@ -8,6 +8,8 @@
"name": "robot-installer-web-server", "name": "robot-installer-web-server",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@fluentui/svg-icons": "^1.1.333",
"@fontsource-variable/montserrat": "^5.3.0",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"ejs": "^3.1.10", "ejs": "^3.1.10",
"express": "^4.19.2", "express": "^4.19.2",
@@ -276,6 +278,21 @@
"node": ">=20" "node": ">=20"
} }
}, },
"node_modules/@fluentui/svg-icons": {
"version": "1.1.333",
"resolved": "https://registry.npmjs.org/@fluentui/svg-icons/-/svg-icons-1.1.333.tgz",
"integrity": "sha512-uSFMDQq25/sVgHvD9KhP+c1rZrHWqIA0OZkofu9Ii3Q5b72DqLgqOreABHbPD67U5XfsFc4iPlZzWEl1849Obg==",
"license": "MIT"
},
"node_modules/@fontsource-variable/montserrat": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/montserrat/-/montserrat-5.3.0.tgz",
"integrity": "sha512-7PaZoxaxrWLAyrhO46v65An9LhUhfkTExWLhfbywYZCnZEgg/W1rEHnlNmZKjNZ3nJTVYyicqxlJt10z/26yTA==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@js-joda/core": { "node_modules/@js-joda/core": {
"version": "5.7.0", "version": "5.7.0",
"resolved": "https://registry.npmjs.org/@js-joda/core/-/core-5.7.0.tgz", "resolved": "https://registry.npmjs.org/@js-joda/core/-/core-5.7.0.tgz",

View File

@@ -9,6 +9,8 @@
"dev": "node server.js" "dev": "node server.js"
}, },
"dependencies": { "dependencies": {
"@fluentui/svg-icons": "^1.1.333",
"@fontsource-variable/montserrat": "^5.3.0",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"ejs": "^3.1.10", "ejs": "^3.1.10",
"express": "^4.19.2", "express": "^4.19.2",

View File

@@ -1,28 +1,92 @@
:root { :root {
--primary: #3755c3; color-scheme: light;
--primary-dim: #2848b7;
--primary-container: #dde1ff; /* Phenikaa-X universal tokens — design-language.md v0.4.4 */
--on-primary: #f8f7ff; --blue-50: #eaedf5;
--background: #f7f9fb; --blue-100: #c5cce0;
--surface-lowest: #ffffff; --blue-200: #8c9ac4;
--surface-low: #f0f4f7; --blue-300: #6e7ca8;
--surface: #e8eff3; --blue-500: #223771;
--surface-high: #e1e9ee; --blue-700: #1a2a57;
--on-surface: #2a3439; --blue-900: #1e2243;
--on-surface-variant: #566166; --orange-100: #fcd9c4;
--outline: #717c82; --orange-300: #f58220;
--outline-variant: #a9b4b9; --orange-500: #f26522;
--danger: #b42318; --orange-700: #c44e14;
--danger-bg: #fee4e2; --neutral-0: #ffffff;
--success: #067647; --neutral-50: #f7f8fb;
--success-bg: #dcfae6; --neutral-100: #eeedf6;
--warning: #b54708; --neutral-200: #dddfea;
--warning-bg: #fef0c7; --neutral-300: #c5c8d8;
--info: #175cd3; --neutral-400: #9fa3b8;
--info-bg: #d1e9ff; --neutral-450: #8c90a8;
--radius: 8px; --neutral-500: #757a91;
--shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.06); --neutral-600: #565b70;
--shadow-lg: 0 24px 60px rgba(15, 23, 42, 0.18); --neutral-700: #3c4054;
--neutral-800: #272a3a;
--neutral-900: #161824;
--surface-base: var(--neutral-0);
--surface-raised: var(--neutral-50);
--surface-brand-tint: var(--neutral-100);
--text-primary: var(--neutral-900);
--text-secondary: var(--neutral-600);
--text-tertiary: var(--neutral-500);
--border-subtle: var(--neutral-300);
--border-strong: var(--neutral-450);
--brand-primary: var(--blue-500);
--brand-primary-strong: var(--blue-700);
--brand-accent: var(--orange-500);
--brand-accent-strong: var(--orange-700);
--semantic-success: #2e7d32;
--semantic-success-bg: #e8f5e9;
--semantic-warning: #9a5b00;
--semantic-warning-icon: #cb6119;
--semantic-warning-bg: #fff4db;
--semantic-error: #c62828;
--semantic-error-bg: #ffebee;
--semantic-info: #1565c0;
--semantic-info-bg: #e7f1fc;
--space-xxs: 4px;
--space-xs: 8px;
--space-sm: 12px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 32px;
--space-2xl: 48px;
--radius-sm: 2px;
--radius-md: 4px;
--radius-lg: 8px;
--elevation-1: 0 1px 3px rgba(22, 24, 36, 0.12);
--elevation-2: 0 4px 12px rgba(22, 24, 36, 0.18);
/* Compatibility aliases used by the existing components. */
--primary: var(--brand-primary);
--primary-dim: var(--brand-primary-strong);
--primary-container: var(--blue-50);
--on-primary: var(--neutral-0);
--background: var(--surface-raised);
--surface-lowest: var(--surface-base);
--surface-low: var(--surface-raised);
--surface: var(--surface-brand-tint);
--surface-high: var(--neutral-200);
--on-surface: var(--text-primary);
--on-surface-variant: var(--text-secondary);
--outline: var(--border-strong);
--outline-variant: var(--border-subtle);
--danger: var(--semantic-error);
--danger-bg: var(--semantic-error-bg);
--success: var(--semantic-success);
--success-bg: var(--semantic-success-bg);
--warning: var(--semantic-warning);
--warning-bg: var(--semantic-warning-bg);
--info: var(--semantic-info);
--info-bg: var(--semantic-info-bg);
--radius: var(--radius-md);
--shadow-sm: var(--elevation-1);
--shadow-lg: var(--elevation-2);
} }
* { * {
@@ -39,8 +103,9 @@ body {
margin: 0; margin: 0;
background: var(--background); background: var(--background);
color: var(--on-surface); color: var(--on-surface);
font-family: "Inter", Arial, sans-serif; font-family: "Montserrat Variable", Montserrat, "Segoe UI", sans-serif;
font-size: 14px; font-size: 15px;
line-height: 1.5;
overflow: hidden; overflow: hidden;
} }
@@ -48,7 +113,7 @@ h1,
h2, h2,
h3, h3,
.brand-copy strong { .brand-copy strong {
font-family: "Manrope", Arial, sans-serif; font-family: "Montserrat Variable", Montserrat, "Segoe UI", sans-serif;
} }
h1, h1,
@@ -79,17 +144,13 @@ button:disabled {
opacity: 0.5; opacity: 0.5;
} }
.material-symbols-outlined { .fluent-icon {
direction: ltr; background: currentColor;
display: inline-flex; display: inline-flex;
font-family: "Material Symbols Outlined"; flex: 0 0 auto;
font-size: 1.25rem; height: 1.25rem;
font-style: normal; mask: var(--fluent-icon-url) center / contain no-repeat;
font-variation-settings: "FILL" 0, "wght" 400, "GRAD" 0, "opsz" 20; width: 1.25rem;
letter-spacing: 0;
line-height: 1;
text-transform: none;
white-space: nowrap;
} }
.app-shell { .app-shell {
@@ -304,7 +365,7 @@ button:disabled {
.notification-panel-header strong { .notification-panel-header strong {
color: #111827; color: #111827;
font-family: "Manrope", Arial, sans-serif; font-family: "Montserrat Variable", Montserrat, "Segoe UI", sans-serif;
font-size: 15px; font-size: 15px;
} }
@@ -394,8 +455,10 @@ button:disabled {
width: 34px; width: 34px;
} }
.notification-item-icon .material-symbols-outlined { .notification-item-icon .material-symbols-outlined,
font-size: 19px; .notification-item-icon .fluent-icon {
height: 19px;
width: 19px;
} }
.notification-item.tone-success .notification-item-icon { .notification-item.tone-success .notification-item-icon {
@@ -468,9 +531,11 @@ button:disabled {
text-align: center; text-align: center;
} }
.notification-empty .material-symbols-outlined { .notification-empty .material-symbols-outlined,
.notification-empty .fluent-icon {
color: #94a3b8; color: #94a3b8;
font-size: 32px; height: 32px;
width: 32px;
} }
.notification-empty strong { .notification-empty strong {
@@ -705,7 +770,7 @@ button:disabled {
.metric-card strong { .metric-card strong {
color: #111827; color: #111827;
font-family: "Manrope", Arial, sans-serif; font-family: "Montserrat Variable", Montserrat, "Segoe UI", sans-serif;
font-size: 28px; font-size: 28px;
font-weight: 800; font-weight: 800;
} }
@@ -1208,9 +1273,11 @@ tbody tr:hover td.action-col {
text-align: center; text-align: center;
} }
.file-dropzone-content > .material-symbols-outlined { .file-dropzone-content > .material-symbols-outlined,
.file-dropzone-content > .fluent-icon {
color: var(--primary); color: var(--primary);
font-size: 34px; height: 34px;
width: 34px;
} }
.file-dropzone-content strong { .file-dropzone-content strong {
@@ -1237,7 +1304,8 @@ tbody tr:hover td.action-col {
padding: 10px; padding: 10px;
} }
.file-preview > .material-symbols-outlined { .file-preview > .material-symbols-outlined,
.file-preview > .fluent-icon {
align-items: center; align-items: center;
background: var(--primary-container); background: var(--primary-container);
border-radius: var(--radius); border-radius: var(--radius);
@@ -1798,3 +1866,323 @@ tbody tr:hover td.action-col {
width: 100%; width: 100%;
} }
} }
/* --------------------------------------------------------------------------
Phenikaa-X application theme
Keeps the existing EJS/JavaScript contract while applying Layer-1 tokens.
-------------------------------------------------------------------------- */
::selection {
background: var(--orange-100);
color: var(--text-primary);
}
:focus-visible {
outline: 2px solid var(--blue-500);
outline-offset: 2px;
}
.sidebar {
background: var(--blue-900);
border-color: var(--neutral-700);
}
.brand-block {
border-bottom: 1px solid rgba(197, 204, 224, 0.18);
min-height: 72px;
padding: var(--space-md);
}
.brand-mark {
background: var(--brand-accent);
border-radius: var(--radius-md);
box-shadow: none;
color: var(--neutral-0);
height: 40px;
width: 40px;
}
.brand-copy strong {
color: var(--neutral-0);
font-size: 15px;
font-weight: 700;
letter-spacing: -0.01em;
}
.brand-copy span,
.sidebar .nav-label,
.sidebar-copyright {
color: var(--blue-100);
}
.brand-copy span,
.nav-label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
}
.nav-section {
gap: var(--space-xxs);
padding: var(--space-sm);
}
.nav-item {
border-left-width: 4px;
border-radius: 0 var(--radius-md) var(--radius-md) 0;
color: var(--blue-100);
font-size: 13px;
font-weight: 500;
gap: var(--space-sm);
min-height: 44px;
}
.nav-item:hover {
background: rgba(255, 255, 255, 0.08);
color: var(--neutral-0);
}
.nav-item.active {
background: rgba(255, 255, 255, 0.12);
border-left-color: var(--brand-accent);
color: var(--neutral-0);
font-weight: 600;
}
.topbar {
background: rgba(255, 255, 255, 0.96);
border-color: var(--border-subtle);
min-height: 64px;
}
.page {
padding: var(--space-lg);
}
.page-header h1,
.auth-heading h1 {
color: var(--text-primary);
font-size: 25px;
font-weight: 700;
letter-spacing: -0.025em;
line-height: 1.2;
}
.panel,
.table-panel,
.metric-card,
.modal-content,
.notification-panel,
.empty-state,
.auth-panel {
background: var(--surface-base);
border-color: var(--border-subtle);
border-radius: var(--radius-md);
box-shadow: var(--elevation-1);
}
.modal-content,
.notification-panel,
.auth-panel {
box-shadow: var(--elevation-2);
}
.panel-header,
.modal-header,
.modal-actions,
.page-filters,
thead,
td {
border-color: var(--border-subtle);
}
.btn,
.icon-button,
input,
select,
textarea {
border-radius: var(--radius-md);
}
.btn {
font-size: 13px;
font-weight: 600;
}
.btn-primary {
background: var(--brand-primary);
color: var(--neutral-0);
}
.btn-primary:hover {
background: var(--brand-primary-strong);
}
.btn-warning {
background: var(--brand-accent);
color: var(--neutral-900);
}
.btn-warning:hover {
background: var(--brand-accent-strong);
color: var(--neutral-0);
}
.btn-secondary,
.icon-button {
background: var(--surface-base);
border-color: var(--border-subtle);
color: var(--text-secondary);
}
.btn-secondary:hover,
.icon-button:hover,
.icon-button.subtle:hover {
background: var(--blue-50);
border-color: var(--blue-200);
color: var(--brand-primary);
}
.form-field input,
.form-field select,
.form-field textarea,
.filter-field input,
.filter-field select,
input,
select,
textarea {
background: var(--surface-base);
border-color: var(--border-strong);
color: var(--text-primary);
}
.form-field input:focus,
.form-field select:focus,
.form-field textarea:focus,
.filter-field input:focus,
.filter-field select:focus,
input:focus,
select:focus,
textarea:focus {
border-color: var(--brand-primary);
box-shadow: 0 0 0 3px rgba(34, 55, 113, 0.16);
}
thead {
background: var(--surface-brand-tint);
}
th {
color: var(--blue-700);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.045em;
}
td {
font-size: 13px;
}
tbody tr:hover {
background: var(--blue-50);
}
.badge {
border-radius: var(--radius-sm);
font-weight: 600;
}
.badge-success {
background: var(--semantic-success-bg);
color: var(--semantic-success);
}
.badge-danger {
background: var(--semantic-error-bg);
color: var(--semantic-error);
}
.badge-warning {
background: var(--semantic-warning-bg);
color: var(--semantic-warning);
}
.badge-info,
.badge-primary {
background: var(--semantic-info-bg);
color: var(--semantic-info);
}
.empty-state > .fluent-icon {
color: var(--brand-primary);
height: 42px;
width: 42px;
}
.error-state > .fluent-icon {
color: var(--semantic-error);
}
.auth-shell {
background:
linear-gradient(135deg, rgba(30, 34, 67, 0.98), rgba(34, 55, 113, 0.93) 42%, rgba(247, 248, 251, 0.98) 42.1%),
var(--surface-raised);
}
.auth-panel {
border-top: 4px solid var(--brand-accent);
gap: var(--space-lg);
padding: var(--space-lg);
}
.auth-brand .brand-copy strong {
color: var(--text-primary);
}
.auth-brand .brand-copy span {
color: var(--text-tertiary);
}
.auth-confirm-icon {
background: var(--blue-50);
border-radius: var(--radius-md);
color: var(--brand-primary);
}
.auth-confirm-icon .fluent-icon {
height: 28px;
width: 28px;
}
.toast {
background: var(--blue-900);
border-radius: var(--radius-md);
box-shadow: var(--elevation-2);
}
@media (max-width: 900px) {
.sidebar {
box-shadow: var(--elevation-2);
}
}
@media (max-width: 560px) {
.page {
padding: var(--space-md);
}
.auth-page {
padding: var(--space-md);
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}

View File

@@ -2,6 +2,64 @@
const body = document.body; const body = document.body;
const menuButton = document.getElementById('mobileMenuBtn'); const menuButton = document.getElementById('mobileMenuBtn');
const sidebarBackdrop = document.getElementById('sidebarBackdrop'); const sidebarBackdrop = document.getElementById('sidebarBackdrop');
const FLUENT_ICON_FILES = Object.freeze({
add: 'add_20_regular.svg',
add_box: 'box_24_regular.svg',
apps: 'apps_24_regular.svg',
archive: 'archive_24_regular.svg',
attach_file: 'attach_20_regular.svg',
campaign: 'megaphone_20_regular.svg',
check_circle: 'checkmark_circle_20_filled.svg',
close: 'dismiss_20_regular.svg',
dashboard: 'grid_24_regular.svg',
database_off: 'database_warning_20_regular.svg',
delete: 'delete_20_regular.svg',
deployed_code: 'cube_24_regular.svg',
download: 'arrow_download_20_regular.svg',
draft: 'document_24_regular.svg',
edit: 'edit_20_regular.svg',
error: 'error_circle_20_filled.svg',
forward_to_inbox: 'mail_arrow_forward_20_regular.svg',
group: 'people_24_regular.svg',
inventory_2: 'box_24_regular.svg',
link_off: 'link_dismiss_20_regular.svg',
login: 'arrow_enter_20_regular.svg',
logout: 'sign_out_20_regular.svg',
mark_email_unread: 'mail_unread_24_regular.svg',
memory: 'memory_16_regular.svg',
menu: 'navigation_20_regular.svg',
notifications: 'alert_20_regular.svg',
notifications_none: 'alert_off_20_regular.svg',
person_add: 'person_add_20_regular.svg',
precision_manufacturing: 'bot_24_filled.svg',
save: 'save_20_regular.svg',
search_off: 'search_24_regular.svg',
send: 'send_20_regular.svg',
stars: 'star_20_filled.svg',
swap_horiz: 'arrow_swap_20_regular.svg',
sync: 'arrow_sync_20_regular.svg',
upgrade: 'arrow_up_20_regular.svg',
upload: 'arrow_upload_20_regular.svg',
upload_file: 'document_arrow_up_24_regular.svg',
visibility: 'eye_20_regular.svg',
warning: 'warning_20_filled.svg'
});
function applyFluentIcon(element, iconName) {
if (!element) return;
const fileName = FLUENT_ICON_FILES[iconName] || 'question_20_regular.svg';
element.classList.remove('material-symbols-outlined');
element.classList.add('fluent-icon');
element.style.setProperty('--fluent-icon-url', `url("/vendor/fluent-icons/${fileName}")`);
element.setAttribute('aria-hidden', 'true');
element.textContent = '';
}
function hydrateFluentIcons(root) {
root.querySelectorAll('.material-symbols-outlined').forEach((element) => {
applyFluentIcon(element, element.textContent.trim());
});
}
function initNotiflix() { function initNotiflix() {
if (!window.Notiflix) return; if (!window.Notiflix) return;
@@ -11,8 +69,8 @@
position: 'right-top', position: 'right-top',
distance: '16px', distance: '16px',
timeout: 2600, timeout: 2600,
borderRadius: '8px', borderRadius: '4px',
fontFamily: 'Inter, sans-serif', fontFamily: 'Montserrat Variable, Montserrat, sans-serif',
fontSize: '13px', fontSize: '13px',
messageMaxLength: 180, messageMaxLength: 180,
clickToClose: true, clickToClose: true,
@@ -21,35 +79,35 @@
useIcon: true, useIcon: true,
zindex: 5000, zindex: 5000,
success: { success: {
background: '#067647', background: '#2e7d32',
textColor: '#ffffff' textColor: '#ffffff'
}, },
failure: { failure: {
background: '#b42318', background: '#c62828',
textColor: '#ffffff' textColor: '#ffffff'
}, },
warning: { warning: {
background: '#b54708', background: '#9a5b00',
textColor: '#ffffff' textColor: '#ffffff'
}, },
info: { info: {
background: '#3755c3', background: '#223771',
textColor: '#ffffff' textColor: '#ffffff'
} }
}); });
window.Notiflix.Confirm.init({ window.Notiflix.Confirm.init({
width: '360px', width: '360px',
borderRadius: '8px', borderRadius: '4px',
fontFamily: 'Inter, sans-serif', fontFamily: 'Montserrat Variable, Montserrat, sans-serif',
titleColor: '#111827', titleColor: '#161824',
titleFontSize: '16px', titleFontSize: '16px',
messageColor: '#475569', messageColor: '#565b70',
messageFontSize: '13px', messageFontSize: '13px',
okButtonBackground: '#3755c3', okButtonBackground: '#223771',
okButtonColor: '#ffffff', okButtonColor: '#ffffff',
cancelButtonBackground: '#e2e8f0', cancelButtonBackground: '#eeedf6',
cancelButtonColor: '#334155', cancelButtonColor: '#3c4054',
backOverlayColor: 'rgba(15, 23, 42, 0.42)', backOverlayColor: 'rgba(15, 23, 42, 0.42)',
zindex: 5001, zindex: 5001,
cssAnimationStyle: 'zoom' cssAnimationStyle: 'zoom'
@@ -701,9 +759,7 @@
const icon = document.createElement('span'); const icon = document.createElement('span');
icon.className = 'notification-item-icon'; icon.className = 'notification-item-icon';
const iconGlyph = document.createElement('span'); const iconGlyph = document.createElement('span');
iconGlyph.className = 'material-symbols-outlined'; applyFluentIcon(iconGlyph, getNotificationIcon(notification));
iconGlyph.setAttribute('aria-hidden', 'true');
iconGlyph.textContent = getNotificationIcon(notification);
icon.appendChild(iconGlyph); icon.appendChild(iconGlyph);
const copy = document.createElement('span'); const copy = document.createElement('span');
@@ -926,6 +982,7 @@
}); });
} }
hydrateFluentIcons(document);
initNotiflix(); initNotiflix();
initFileDropzones(); initFileDropzones();
initRegistrationUniqueChecks(); initRegistrationUniqueChecks();

View File

@@ -127,6 +127,8 @@ app.use(express.urlencoded({ extended: true }));
app.use(express.json({ limit: '32kb' })); app.use(express.json({ limit: '32kb' }));
app.use(express.static(path.join(__dirname, 'public'))); app.use(express.static(path.join(__dirname, 'public')));
app.use('/vendor/notiflix', express.static(path.join(__dirname, 'node_modules/notiflix/dist'))); app.use('/vendor/notiflix', express.static(path.join(__dirname, 'node_modules/notiflix/dist')));
app.use('/vendor/montserrat', express.static(path.join(__dirname, 'node_modules/@fontsource-variable/montserrat')));
app.use('/vendor/fluent-icons', express.static(path.join(__dirname, 'node_modules/@fluentui/svg-icons/icons')));
app.use(applyPublicApiCors); app.use(applyPublicApiCors);
app.get('/packages/agent/latest.deb', asyncRoute(async (req, res) => { app.get('/packages/agent/latest.deb', asyncRoute(async (req, res) => {
const arch = normalizeAgentArch(req.query.arch); const arch = normalizeAgentArch(req.query.arch);

View File

@@ -4,10 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title><%= title %> | Robot Installer</title> <title><%= title %> | Robot Installer</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="stylesheet" href="/vendor/montserrat/index.css">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/vendor/notiflix/notiflix-<%= notiflixVersion %>.min.css"> <link rel="stylesheet" href="/vendor/notiflix/notiflix-<%= notiflixVersion %>.min.css">
<link rel="stylesheet" href="/css/styles.css"> <link rel="stylesheet" href="/css/styles.css">
</head> </head>

View File

@@ -4,10 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title><%= title %> | Robot Installer</title> <title><%= title %> | Robot Installer</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="stylesheet" href="/vendor/montserrat/index.css">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/vendor/notiflix/notiflix-<%= notiflixVersion %>.min.css"> <link rel="stylesheet" href="/vendor/notiflix/notiflix-<%= notiflixVersion %>.min.css">
<link rel="stylesheet" href="/css/styles.css"> <link rel="stylesheet" href="/css/styles.css">
</head> </head>

View File

@@ -4,10 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title><%= title %> | Robot Installer</title> <title><%= title %> | Robot Installer</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="stylesheet" href="/vendor/montserrat/index.css">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/vendor/notiflix/notiflix-<%= notiflixVersion %>.min.css"> <link rel="stylesheet" href="/vendor/notiflix/notiflix-<%= notiflixVersion %>.min.css">
<link rel="stylesheet" href="/css/styles.css"> <link rel="stylesheet" href="/css/styles.css">
</head> </head>