feat(UI): add Night Ops dark mode with theme toggle

Add a warm charcoal dark mode ("Night Ops") using CSS variable swapping
under [data-theme="dark"]. All 23 desert palette variables are overridden
with dark-mode counterparts, and ~313 generic Tailwind classes (bg-white,
text-gray-*, border-gray-*) are replaced with semantic tokens.

Infrastructure:
- CSS variable overrides in app.css for both themes
- ThemeProvider + useTheme hook (localStorage + KV store sync)
- ThemeToggle component (moon/sun icons, "Night Ops"/"Day Ops" labels)
- FOUC prevention script in inertia_layout.edge
- Toggle placed in StyledSidebar and Footer for access on every page

Color replacements across 50 files:
- bg-white → bg-surface-primary
- bg-gray-50/100 → bg-surface-secondary
- text-gray-900/800 → text-text-primary
- text-gray-600/500 → text-text-secondary/text-text-muted
- border-gray-200/300 → border-border-subtle/border-border-default
- text-desert-white → text-white (fixes invisible text on colored bg)
- Button hover/active states use dedicated btn-green-hover/active vars

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Sherwood 2026-03-16 09:17:05 -07:00 committed by Jake Turner
parent ed0b0f76ec
commit b1edef27e8
53 changed files with 503 additions and 306 deletions

View File

@ -1,3 +1,3 @@
import { KVStoreKey } from "../types/kv_store.js"; import { KVStoreKey } from "../types/kv_store.js";
export const SETTINGS_KEYS: KVStoreKey[] = ['chat.suggestionsEnabled', 'chat.lastModel', 'ui.hasVisitedEasySetup', 'system.earlyAccess', 'ai.assistantCustomName']; export const SETTINGS_KEYS: KVStoreKey[] = ['chat.suggestionsEnabled', 'chat.lastModel', 'ui.hasVisitedEasySetup', 'ui.theme', 'system.earlyAccess', 'ai.assistantCustomName'];

View File

@ -11,6 +11,7 @@ import { generateUUID } from '~/lib/util'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools' import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import NotificationsProvider from '~/providers/NotificationProvider' import NotificationsProvider from '~/providers/NotificationProvider'
import { ThemeProvider } from '~/providers/ThemeProvider'
import { UsePageProps } from '../../types/system' import { UsePageProps } from '../../types/system'
const appName = import.meta.env.VITE_APP_NAME || 'Project N.O.M.A.D.' const appName = import.meta.env.VITE_APP_NAME || 'Project N.O.M.A.D.'
@ -38,14 +39,16 @@ createInertiaApp({
const showDevtools = ['development', 'staging'].includes(environment) const showDevtools = ['development', 'staging'].includes(environment)
createRoot(el).render( createRoot(el).render(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<TransmitProvider baseUrl={window.location.origin} enableLogging={true}> <ThemeProvider>
<NotificationsProvider> <TransmitProvider baseUrl={window.location.origin} enableLogging={true}>
<ModalsProvider> <NotificationsProvider>
<App {...props} /> <ModalsProvider>
{showDevtools && <ReactQueryDevtools initialIsOpen={false} buttonPosition='bottom-left' />} <App {...props} />
</ModalsProvider> {showDevtools && <ReactQueryDevtools initialIsOpen={false} buttonPosition='bottom-left' />}
</NotificationsProvider> </ModalsProvider>
</TransmitProvider> </NotificationsProvider>
</TransmitProvider>
</ThemeProvider>
</QueryClientProvider> </QueryClientProvider>
) )
}, },

View File

@ -32,7 +32,7 @@ const ActiveDownloads = ({ filetype, withHeader = false }: ActiveDownloadProps)
</div> </div>
)) ))
) : ( ) : (
<p className="text-gray-500">No active downloads</p> <p className="text-text-muted">No active downloads</p>
)} )}
</div> </div>
</> </>

View File

@ -35,7 +35,7 @@ const ActiveEmbedJobs = ({ withHeader = false }: ActiveEmbedJobsProps) => {
</div> </div>
)) ))
) : ( ) : (
<p className="text-gray-500">No files are currently being processed</p> <p className="text-text-muted">No files are currently being processed</p>
)} )}
</div> </div>
</> </>

View File

@ -33,7 +33,7 @@ const ActiveModelDownloads = ({ withHeader = false }: ActiveModelDownloadsProps)
</div> </div>
)) ))
) : ( ) : (
<p className="text-gray-500">No active model downloads</p> <p className="text-text-muted">No active model downloads</p>
)} )}
</div> </div>
</> </>

View File

@ -43,7 +43,7 @@ export default function Alert({
} }
const getIconColor = () => { const getIconColor = () => {
if (variant === 'solid') return 'text-desert-white' if (variant === 'solid') return 'text-white'
switch (type) { switch (type) {
case 'warning': case 'warning':
return 'text-desert-orange' return 'text-desert-orange'
@ -81,15 +81,15 @@ export default function Alert({
case 'solid': case 'solid':
variantStyles.push( variantStyles.push(
type === 'warning' type === 'warning'
? 'bg-desert-orange text-desert-white border border-desert-orange-dark' ? 'bg-desert-orange text-white border border-desert-orange-dark'
: type === 'error' : type === 'error'
? 'bg-desert-red text-desert-white border border-desert-red-dark' ? 'bg-desert-red text-white border border-desert-red-dark'
: type === 'success' : type === 'success'
? 'bg-desert-olive text-desert-white border border-desert-olive-dark' ? 'bg-desert-olive text-white border border-desert-olive-dark'
: type === 'info' : type === 'info'
? 'bg-desert-green text-desert-white border border-desert-green-dark' ? 'bg-desert-green text-white border border-desert-green-dark'
: type === 'info-inverted' : type === 'info-inverted'
? 'bg-desert-tan text-desert-white border border-desert-tan-dark' ? 'bg-desert-tan text-white border border-desert-tan-dark'
: '' : ''
) )
return classNames(baseStyles, 'shadow-lg', ...variantStyles) return classNames(baseStyles, 'shadow-lg', ...variantStyles)
@ -112,7 +112,7 @@ export default function Alert({
} }
const getTitleColor = () => { const getTitleColor = () => {
if (variant === 'solid') return 'text-desert-white' if (variant === 'solid') return 'text-white'
switch (type) { switch (type) {
case 'warning': case 'warning':
@ -131,7 +131,7 @@ export default function Alert({
} }
const getMessageColor = () => { const getMessageColor = () => {
if (variant === 'solid') return 'text-desert-white text-opacity-90' if (variant === 'solid') return 'text-white text-opacity-90'
switch (type) { switch (type) {
case 'warning': case 'warning':
@ -149,7 +149,7 @@ export default function Alert({
const getCloseButtonStyles = () => { const getCloseButtonStyles = () => {
if (variant === 'solid') { if (variant === 'solid') {
return 'text-desert-white hover:text-desert-white hover:bg-black hover:bg-opacity-20' return 'text-white hover:text-white hover:bg-black hover:bg-opacity-20'
} }
switch (type) { switch (type) {

View File

@ -9,18 +9,18 @@ interface BouncingDotsProps {
export default function BouncingDots({ text, containerClassName, textClassName }: BouncingDotsProps) { export default function BouncingDots({ text, containerClassName, textClassName }: BouncingDotsProps) {
return ( return (
<div className={clsx("flex items-center justify-center gap-2", containerClassName)}> <div className={clsx("flex items-center justify-center gap-2", containerClassName)}>
<span className={clsx("text-gray-600", textClassName)}>{text}</span> <span className={clsx("text-text-secondary", textClassName)}>{text}</span>
<span className="flex gap-1 mt-1"> <span className="flex gap-1 mt-1">
<span <span
className="w-1.5 h-1.5 bg-gray-600 rounded-full animate-bounce" className="w-1.5 h-1.5 bg-text-secondary rounded-full animate-bounce"
style={{ animationDelay: '0ms' }} style={{ animationDelay: '0ms' }}
/> />
<span <span
className="w-1.5 h-1.5 bg-gray-600 rounded-full animate-bounce" className="w-1.5 h-1.5 bg-text-secondary rounded-full animate-bounce"
style={{ animationDelay: '150ms' }} style={{ animationDelay: '150ms' }}
/> />
<span <span
className="w-1.5 h-1.5 bg-gray-600 rounded-full animate-bounce" className="w-1.5 h-1.5 bg-text-secondary rounded-full animate-bounce"
style={{ animationDelay: '300ms' }} style={{ animationDelay: '300ms' }}
/> />
</span> </span>

View File

@ -63,7 +63,7 @@ const DownloadURLModal: React.FC<DownloadURLModalProps> = ({
large large
> >
<div className="flex flex-col pb-4"> <div className="flex flex-col pb-4">
<p className="text-gray-700 mb-8"> <p className="text-text-secondary mb-8">
Enter the URL of the map region file you wish to download. The URL must be publicly Enter the URL of the map region file you wish to download. The URL must be publicly
reachable and end with .pmtiles. A preflight check will be run to verify the file's reachable and end with .pmtiles. A preflight check will be run to verify the file's
availability, type, and approximate size. availability, type, and approximate size.
@ -76,11 +76,11 @@ const DownloadURLModal: React.FC<DownloadURLModalProps> = ({
value={url} value={url}
onChange={(e) => setUrl(e.target.value)} onChange={(e) => setUrl(e.target.value)}
/> />
<div className="min-h-24 max-h-96 overflow-y-auto bg-gray-50 p-4 rounded border border-gray-300 text-left"> <div className="min-h-24 max-h-96 overflow-y-auto bg-surface-secondary p-4 rounded border border-border-default text-left">
{messages.map((message, idx) => ( {messages.map((message, idx) => (
<p <p
key={idx} key={idx}
className="text-sm text-gray-900 font-mono leading-relaxed break-words mb-3" className="text-sm text-text-primary font-mono leading-relaxed break-words mb-3"
> >
{message} {message}
</p> </p>

View File

@ -1,14 +1,16 @@
import { usePage } from '@inertiajs/react' import { usePage } from '@inertiajs/react'
import { UsePageProps } from '../../types/system' import { UsePageProps } from '../../types/system'
import ThemeToggle from '~/components/ThemeToggle'
export default function Footer() { export default function Footer() {
const { appVersion } = usePage().props as unknown as UsePageProps const { appVersion } = usePage().props as unknown as UsePageProps
return ( return (
<footer className=""> <footer>
<div className="flex justify-center border-t border-gray-900/10 py-4"> <div className="flex items-center justify-center gap-3 border-t border-border-subtle py-4">
<p className="text-sm/6 text-gray-600"> <p className="text-sm/6 text-text-secondary">
Project N.O.M.A.D. Command Center v{appVersion} Project N.O.M.A.D. Command Center v{appVersion}
</p> </p>
<ThemeToggle />
</div> </div>
</footer> </footer>
) )

View File

@ -94,7 +94,7 @@ export default function HorizontalBarChart({
className={classNames( className={classNames(
'absolute top-1/2 -translate-y-1/2 font-bold text-sm', 'absolute top-1/2 -translate-y-1/2 font-bold text-sm',
item.value > 15 item.value > 15
? 'left-3 text-desert-white drop-shadow-md' ? 'left-3 text-white drop-shadow-md'
: 'right-3 text-desert-green' : 'right-3 text-desert-green'
)} )}
> >

View File

@ -31,8 +31,8 @@ export type InstallActivityFeedProps = {
const InstallActivityFeed: React.FC<InstallActivityFeedProps> = ({ activity, className, withHeader = false }) => { const InstallActivityFeed: React.FC<InstallActivityFeedProps> = ({ activity, className, withHeader = false }) => {
return ( return (
<div className={classNames('bg-white shadow-sm rounded-lg p-6', className)}> <div className={classNames('bg-surface-primary shadow-sm rounded-lg p-6', className)}>
{withHeader && <h2 className="text-lg font-semibold text-gray-900">Installation Activity</h2>} {withHeader && <h2 className="text-lg font-semibold text-text-primary">Installation Activity</h2>}
<ul role="list" className={classNames("space-y-6 text-desert-green", withHeader ? 'mt-6' : '')}> <ul role="list" className={classNames("space-y-6 text-desert-green", withHeader ? 'mt-6' : '')}>
{activity.map((activityItem, activityItemIdx) => ( {activity.map((activityItem, activityItemIdx) => (
<li key={activityItem.timestamp} className="relative flex gap-x-4"> <li key={activityItem.timestamp} className="relative flex gap-x-4">
@ -42,7 +42,7 @@ const InstallActivityFeed: React.FC<InstallActivityFeedProps> = ({ activity, cla
'absolute left-0 top-0 flex w-6 justify-center' 'absolute left-0 top-0 flex w-6 justify-center'
)} )}
> >
<div className="w-px bg-gray-200" /> <div className="w-px bg-border-subtle" />
</div> </div>
<> <>
<div className="relative flex size-6 flex-none items-center justify-center bg-transparent"> <div className="relative flex size-6 flex-none items-center justify-center bg-transparent">
@ -51,16 +51,16 @@ const InstallActivityFeed: React.FC<InstallActivityFeedProps> = ({ activity, cla
) : activityItem.type === 'update-rollback' ? ( ) : activityItem.type === 'update-rollback' ? (
<IconCircleX aria-hidden="true" className="size-6 text-red-500" /> <IconCircleX aria-hidden="true" className="size-6 text-red-500" />
) : ( ) : (
<div className="size-1.5 rounded-full bg-gray-100 ring-1 ring-gray-300" /> <div className="size-1.5 rounded-full bg-surface-secondary ring-1 ring-border-default" />
)} )}
</div> </div>
<p className="flex-auto py-0.5 text-xs/5 text-gray-500"> <p className="flex-auto py-0.5 text-xs/5 text-text-muted">
<span className="font-semibold text-gray-900">{activityItem.service_name}</span> -{' '} <span className="font-semibold text-text-primary">{activityItem.service_name}</span> -{' '}
{activityItem.type.charAt(0).toUpperCase() + activityItem.type.slice(1)} {activityItem.type.charAt(0).toUpperCase() + activityItem.type.slice(1)}
</p> </p>
<time <time
dateTime={activityItem.timestamp} dateTime={activityItem.timestamp}
className="flex-none py-0.5 text-xs/5 text-gray-500" className="flex-none py-0.5 text-xs/5 text-text-muted"
> >
{activityItem.timestamp} {activityItem.timestamp}
</time> </time>

View File

@ -17,10 +17,10 @@ const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
return ( return (
<div className={`flex flex-col items-center justify-center ${className}`}> <div className={`flex flex-col items-center justify-center ${className}`}>
<div <div
className={`w-8 h-8 border-[3px] ${light ? 'border-white' : 'border-slate-400'} border-t-transparent rounded-full animate-spin`} className={`w-8 h-8 border-[3px] ${light ? 'border-white' : 'border-text-muted'} border-t-transparent rounded-full animate-spin`}
></div> ></div>
{!iconOnly && ( {!iconOnly && (
<div className={light ? 'text-white mt-2' : 'text-slate-800 mt-2'}> <div className={light ? 'text-white mt-2' : 'text-text-primary mt-2'}>
{text || 'Loading...'} {text || 'Loading...'}
</div> </div>
)} )}

View File

@ -9,14 +9,14 @@ const ProgressBar = ({ progress, speed }: { progress: number; speed?: string })
return ( return (
<div className="flex flex-col"> <div className="flex flex-col">
<div className="relative w-full h-2 bg-gray-200 rounded"> <div className="relative w-full h-2 bg-border-subtle rounded">
<div <div
className="absolute top-0 left-0 h-full bg-desert-green rounded" className="absolute top-0 left-0 h-full bg-desert-green rounded"
style={{ width: `${progress}%` }} style={{ width: `${progress}%` }}
/> />
</div> </div>
{speed && ( {speed && (
<div className="mt-1 text-sm text-gray-500"> <div className="mt-1 text-sm text-text-muted">
Est. Speed: {speed} Est. Speed: {speed}
</div> </div>
)} )}

View File

@ -81,7 +81,7 @@ export default function StorageProjectionBar({
className={classNames( className={classNames(
'absolute top-1/2 -translate-y-1/2 font-bold text-sm', 'absolute top-1/2 -translate-y-1/2 font-bold text-sm',
projectedTotalPercent > 15 projectedTotalPercent > 15
? 'left-3 text-desert-white drop-shadow-md' ? 'left-3 text-white drop-shadow-md'
: 'right-3 text-desert-green' : 'right-3 text-desert-green'
)} )}
> >

View File

@ -56,9 +56,9 @@ const StyledButton: React.FC<StyledButtonProps> = ({
switch (variant) { switch (variant) {
case 'primary': case 'primary':
return clsx( return clsx(
'bg-desert-green text-desert-white', 'bg-desert-green text-white',
'hover:bg-desert-green-dark hover:shadow-lg', 'hover:bg-btn-green-hover hover:shadow-lg',
'active:bg-desert-green-darker', 'active:bg-btn-green-active',
'disabled:bg-desert-green-light disabled:text-desert-stone-light', 'disabled:bg-desert-green-light disabled:text-desert-stone-light',
baseTransition, baseTransition,
baseHover baseHover
@ -66,7 +66,7 @@ const StyledButton: React.FC<StyledButtonProps> = ({
case 'secondary': case 'secondary':
return clsx( return clsx(
'bg-desert-tan text-desert-white', 'bg-desert-tan text-white',
'hover:bg-desert-tan-dark hover:shadow-lg', 'hover:bg-desert-tan-dark hover:shadow-lg',
'active:bg-desert-tan-dark', 'active:bg-desert-tan-dark',
'disabled:bg-desert-tan-lighter disabled:text-desert-stone-light', 'disabled:bg-desert-tan-lighter disabled:text-desert-stone-light',
@ -76,7 +76,7 @@ const StyledButton: React.FC<StyledButtonProps> = ({
case 'danger': case 'danger':
return clsx( return clsx(
'bg-desert-red text-desert-white', 'bg-desert-red text-white',
'hover:bg-desert-red-dark hover:shadow-lg', 'hover:bg-desert-red-dark hover:shadow-lg',
'active:bg-desert-red-dark', 'active:bg-desert-red-dark',
'disabled:bg-desert-red-lighter disabled:text-desert-stone-light', 'disabled:bg-desert-red-lighter disabled:text-desert-stone-light',
@ -86,7 +86,7 @@ const StyledButton: React.FC<StyledButtonProps> = ({
case 'action': case 'action':
return clsx( return clsx(
'bg-desert-orange text-desert-white', 'bg-desert-orange text-white',
'hover:bg-desert-orange-light hover:shadow-lg', 'hover:bg-desert-orange-light hover:shadow-lg',
'active:bg-desert-orange-dark', 'active:bg-desert-orange-dark',
'disabled:bg-desert-orange-lighter disabled:text-desert-stone-light', 'disabled:bg-desert-orange-lighter disabled:text-desert-stone-light',
@ -96,7 +96,7 @@ const StyledButton: React.FC<StyledButtonProps> = ({
case 'success': case 'success':
return clsx( return clsx(
'bg-desert-olive text-desert-white', 'bg-desert-olive text-white',
'hover:bg-desert-olive-dark hover:shadow-lg', 'hover:bg-desert-olive-dark hover:shadow-lg',
'active:bg-desert-olive-dark', 'active:bg-desert-olive-dark',
'disabled:bg-desert-olive-lighter disabled:text-desert-stone-light', 'disabled:bg-desert-olive-lighter disabled:text-desert-stone-light',
@ -116,8 +116,8 @@ const StyledButton: React.FC<StyledButtonProps> = ({
case 'outline': case 'outline':
return clsx( return clsx(
'bg-transparent border-2 border-desert-green text-desert-green', 'bg-transparent border-2 border-desert-green text-desert-green',
'hover:bg-desert-green hover:text-desert-white hover:border-desert-green-dark', 'hover:bg-desert-green hover:text-white hover:border-btn-green-hover',
'active:bg-desert-green-dark active:border-desert-green-darker', 'active:bg-btn-green-hover active:border-btn-green-active',
'disabled:border-desert-green-lighter disabled:text-desert-stone-light', 'disabled:border-desert-green-lighter disabled:text-desert-stone-light',
baseTransition, baseTransition,
baseHover baseHover

View File

@ -48,7 +48,7 @@ const StyledModal: React.FC<StyledModalProps> = ({
> >
<DialogBackdrop <DialogBackdrop
transition transition
className="fixed inset-0 bg-gray-500/75 transition-opacity data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in" className="fixed inset-0 bg-black/50 transition-opacity data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in"
/> />
<div className="fixed inset-0 z-10 w-screen overflow-y-auto"> <div className="fixed inset-0 z-10 w-screen overflow-y-auto">
<div <div
@ -60,14 +60,14 @@ const StyledModal: React.FC<StyledModalProps> = ({
<DialogPanel <DialogPanel
transition transition
className={classNames( className={classNames(
'relative transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left shadow-xl transition-all data-[closed]:translate-y-4 data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in sm:my-8 sm:p-6 data-[closed]:sm:translate-y-0 data-[closed]:sm:scale-95', 'relative transform overflow-hidden rounded-lg bg-surface-primary px-4 pb-4 pt-5 text-left shadow-xl transition-all data-[closed]:translate-y-4 data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in sm:my-8 sm:p-6 data-[closed]:sm:translate-y-0 data-[closed]:sm:scale-95',
large ? 'sm:max-w-7xl !w-full' : 'sm:max-w-lg' large ? 'sm:max-w-7xl !w-full' : 'sm:max-w-lg'
)} )}
> >
<div> <div>
{icon && <div className="flex items-center justify-center">{icon}</div>} {icon && <div className="flex items-center justify-center">{icon}</div>}
<div className="mt-3 text-center sm:mt-5"> <div className="mt-3 text-center sm:mt-5">
<DialogTitle as="h3" className="text-base font-semibold text-gray-900"> <DialogTitle as="h3" className="text-base font-semibold text-text-primary">
{title} {title}
</DialogTitle> </DialogTitle>
<div className="mt-2 !h-fit">{children}</div> <div className="mt-2 !h-fit">{children}</div>

View File

@ -5,6 +5,7 @@ import { IconArrowLeft } from '@tabler/icons-react'
import { usePage } from '@inertiajs/react' import { usePage } from '@inertiajs/react'
import { UsePageProps } from '../../types/system' import { UsePageProps } from '../../types/system'
import { IconMenu2, IconX } from '@tabler/icons-react' import { IconMenu2, IconX } from '@tabler/icons-react'
import ThemeToggle from '~/components/ThemeToggle'
type SidebarItem = { type SidebarItem = {
name: string name: string
@ -37,7 +38,7 @@ const StyledSidebar: React.FC<StyledSidebarProps> = ({ title, items }) => {
className={classNames( className={classNames(
item.current item.current
? 'bg-desert-green text-white' ? 'bg-desert-green text-white'
: 'text-black hover:bg-desert-green-light hover:text-white', : 'text-text-primary hover:bg-desert-green-light hover:text-white',
'group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold' 'group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold'
)} )}
> >
@ -53,7 +54,7 @@ const StyledSidebar: React.FC<StyledSidebarProps> = ({ title, items }) => {
<div className="flex grow flex-col gap-y-5 overflow-y-auto bg-desert-sand px-6 ring-1 ring-white/5 pt-4 shadow-md"> <div className="flex grow flex-col gap-y-5 overflow-y-auto bg-desert-sand px-6 ring-1 ring-white/5 pt-4 shadow-md">
<div className="flex h-16 shrink-0 items-center"> <div className="flex h-16 shrink-0 items-center">
<img src="/project_nomad_logo.png" alt="Project Nomad Logo" className="h-16 w-16" /> <img src="/project_nomad_logo.png" alt="Project Nomad Logo" className="h-16 w-16" />
<h1 className="ml-3 text-xl font-semibold text-black">{title}</h1> <h1 className="ml-3 text-xl font-semibold text-text-primary">{title}</h1>
</div> </div>
<nav className="flex flex-1 flex-col"> <nav className="flex flex-1 flex-col">
<ul role="list" className="flex flex-1 flex-col gap-y-7"> <ul role="list" className="flex flex-1 flex-col gap-y-7">
@ -75,8 +76,9 @@ const StyledSidebar: React.FC<StyledSidebarProps> = ({ title, items }) => {
</li> </li>
</ul> </ul>
</nav> </nav>
<div className="mb-4 text-center text-sm text-gray-600"> <div className="mb-4 flex flex-col items-center gap-1 text-sm text-text-secondary">
<p>Project N.O.M.A.D. Command Center v{appVersion}</p> <p>Project N.O.M.A.D. Command Center v{appVersion}</p>
<ThemeToggle />
</div> </div>
</div> </div>
) )

View File

@ -74,19 +74,19 @@ function StyledTable<T extends { [key: string]: any }>({
return ( return (
<div <div
className={classNames( className={classNames(
'w-full overflow-x-auto bg-white ring-1 ring-gray-300 sm:mx-0 sm:rounded-lg p-1 shadow-md', 'w-full overflow-x-auto bg-surface-primary ring-1 ring-border-default sm:mx-0 sm:rounded-lg p-1 shadow-md',
className className
)} )}
ref={ref} ref={ref}
{...containerProps} {...containerProps}
> >
<table className="min-w-full overflow-auto" {...restTableProps}> <table className="min-w-full overflow-auto" {...restTableProps}>
<thead className='border-b border-gray-200 '> <thead className='border-b border-border-subtle '>
<tr> <tr>
{expandable && ( {expandable && (
<th <th
className={classNames( className={classNames(
'whitespace-nowrap text-left font-semibold text-gray-900 w-12', 'whitespace-nowrap text-left font-semibold text-text-primary w-12',
compact ? `${leftPadding} py-2` : `${leftPadding} py-4 pr-3` compact ? `${leftPadding} py-2` : `${leftPadding} py-4 pr-3`
)} )}
/> />
@ -95,7 +95,7 @@ function StyledTable<T extends { [key: string]: any }>({
<th <th
key={index} key={index}
className={classNames( className={classNames(
'whitespace-nowrap text-left font-semibold text-gray-900', 'whitespace-nowrap text-left font-semibold text-text-primary',
compact ? `${leftPadding} py-2` : `${leftPadding} py-4 pr-3` compact ? `${leftPadding} py-2` : `${leftPadding} py-4 pr-3`
)} )}
> >
@ -121,8 +121,8 @@ function StyledTable<T extends { [key: string]: any }>({
'translateY' in record ? 'translateY(' + record.transformY + 'px)' : undefined, 'translateY' in record ? 'translateY(' + record.transformY + 'px)' : undefined,
}} }}
className={classNames( className={classNames(
rowLines ? 'border-b border-gray-200' : '', rowLines ? 'border-b border-border-subtle' : '',
onRowClick ? `cursor-pointer hover:bg-gray-100 ` : '' onRowClick ? `cursor-pointer hover:bg-surface-secondary ` : ''
)} )}
> >
{expandable && ( {expandable && (
@ -134,7 +134,7 @@ function StyledTable<T extends { [key: string]: any }>({
onClick={(e) => toggleRowExpansion(record, recordIdx, e)} onClick={(e) => toggleRowExpansion(record, recordIdx, e)}
> >
<button <button
className="text-gray-500 hover:text-gray-700 focus:outline-none" className="text-text-muted hover:text-text-primary focus:outline-none"
aria-label={isExpanded ? 'Collapse row' : 'Expand row'} aria-label={isExpanded ? 'Collapse row' : 'Expand row'}
> >
<svg <svg
@ -172,7 +172,7 @@ function StyledTable<T extends { [key: string]: any }>({
))} ))}
</tr> </tr>
{expandable && isExpanded && ( {expandable && isExpanded && (
<tr className="bg-gray-50"> <tr className="bg-surface-secondary">
<td colSpan={columns.length + 1}> <td colSpan={columns.length + 1}>
{expandable.expandedRowRender(record, recordIdx)} {expandable.expandedRowRender(record, recordIdx)}
</td> </td>
@ -183,7 +183,7 @@ function StyledTable<T extends { [key: string]: any }>({
})} })}
{!loading && data.length === 0 && ( {!loading && data.length === 0 && (
<tr> <tr>
<td colSpan={columns.length + (expandable ? 1 : 0)} className="!text-center py-8 text-gray-500"> <td colSpan={columns.length + (expandable ? 1 : 0)} className="!text-center py-8 text-text-muted">
{noDataText} {noDataText}
</td> </td>
</tr> </tr>

View File

@ -0,0 +1,24 @@
import { IconSun, IconMoon } from '@tabler/icons-react'
import { useThemeContext } from '~/providers/ThemeProvider'
interface ThemeToggleProps {
compact?: boolean
}
export default function ThemeToggle({ compact = false }: ThemeToggleProps) {
const { theme, toggleTheme } = useThemeContext()
const isDark = theme === 'dark'
return (
<button
onClick={toggleTheme}
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-sm transition-colors
text-desert-stone hover:text-desert-green-darker"
aria-label={isDark ? 'Switch to Day Ops' : 'Switch to Night Ops'}
title={isDark ? 'Switch to Day Ops' : 'Switch to Night Ops'}
>
{isDark ? <IconSun className="size-4" /> : <IconMoon className="size-4" />}
{!compact && <span>{isDark ? 'Day Ops' : 'Night Ops'}</span>}
</button>
)
}

View File

@ -88,7 +88,7 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
leaveFrom="opacity-100 scale-100" leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95" leaveTo="opacity-0 scale-95"
> >
<Dialog.Panel className="w-full max-w-4xl transform overflow-hidden rounded-lg bg-white shadow-xl transition-all"> <Dialog.Panel className="w-full max-w-4xl transform overflow-hidden rounded-lg bg-surface-primary shadow-xl transition-all">
{/* Header */} {/* Header */}
<div className="bg-desert-green px-6 py-4"> <div className="bg-desert-green px-6 py-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@ -101,7 +101,7 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
<Dialog.Title className="text-xl font-semibold text-white"> <Dialog.Title className="text-xl font-semibold text-white">
{category.name} {category.name}
</Dialog.Title> </Dialog.Title>
<p className="text-sm text-gray-200">{category.description}</p> <p className="text-sm text-text-muted">{category.description}</p>
</div> </div>
</div> </div>
<button <button
@ -115,7 +115,7 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
{/* Content */} {/* Content */}
<div className="p-6"> <div className="p-6">
<p className="text-gray-600 mb-6"> <p className="text-text-secondary mb-6">
Select a tier based on your storage capacity and needs. Higher tiers include all content from lower tiers. Select a tier based on your storage capacity and needs. Higher tiers include all content from lower tiers.
</p> </p>
@ -138,30 +138,30 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
'border-2 rounded-lg p-5 cursor-pointer transition-all', 'border-2 rounded-lg p-5 cursor-pointer transition-all',
isSelected isSelected
? 'border-desert-green bg-desert-green/5 shadow-md' ? 'border-desert-green bg-desert-green/5 shadow-md'
: 'border-gray-200 hover:border-desert-green/50 hover:shadow-sm' : 'border-border-subtle hover:border-desert-green/50 hover:shadow-sm'
)} )}
> >
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<h3 className="text-lg font-semibold text-gray-900"> <h3 className="text-lg font-semibold text-text-primary">
{tier.name} {tier.name}
</h3> </h3>
{includedTierName && ( {includedTierName && (
<span className="text-xs text-gray-500"> <span className="text-xs text-text-muted">
(includes {includedTierName}) (includes {includedTierName})
</span> </span>
)} )}
</div> </div>
<p className="text-gray-600 text-sm mb-3">{tier.description}</p> <p className="text-text-secondary text-sm mb-3">{tier.description}</p>
{/* Resources preview - only show this tier's own resources */} {/* Resources preview - only show this tier's own resources */}
<div className="bg-gray-50 rounded p-3"> <div className="bg-surface-secondary rounded p-3">
<p className="text-xs text-gray-500 mb-2 font-medium"> <p className="text-xs text-text-muted mb-2 font-medium">
{includedTierName ? ( {includedTierName ? (
<> <>
{ownResourceCount} additional {ownResourceCount === 1 ? 'resource' : 'resources'} {ownResourceCount} additional {ownResourceCount === 1 ? 'resource' : 'resources'}
<span className="text-gray-400"> (plus everything in {includedTierName})</span> <span className="text-text-muted"> (plus everything in {includedTierName})</span>
</> </>
) : ( ) : (
<>{ownResourceCount} {ownResourceCount === 1 ? 'resource' : 'resources'} included</> <>{ownResourceCount} {ownResourceCount === 1 ? 'resource' : 'resources'} included</>
@ -172,8 +172,8 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
<div key={idx} className="flex items-start text-sm"> <div key={idx} className="flex items-start text-sm">
<IconCheck size={14} className="text-desert-green mr-1.5 mt-0.5 flex-shrink-0" /> <IconCheck size={14} className="text-desert-green mr-1.5 mt-0.5 flex-shrink-0" />
<div> <div>
<span className="text-gray-700">{resource.title}</span> <span className="text-text-primary">{resource.title}</span>
<span className="text-gray-400 text-xs ml-1"> <span className="text-text-muted text-xs ml-1">
({formatBytes(resource.size_mb * 1024 * 1024, 0)}) ({formatBytes(resource.size_mb * 1024 * 1024, 0)})
</span> </span>
</div> </div>
@ -184,14 +184,14 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
</div> </div>
<div className="ml-4 text-right flex-shrink-0"> <div className="ml-4 text-right flex-shrink-0">
<div className="text-lg font-semibold text-gray-900"> <div className="text-lg font-semibold text-text-primary">
{formatBytes(totalSize, 1)} {formatBytes(totalSize, 1)}
</div> </div>
<div className={classNames( <div className={classNames(
'w-6 h-6 rounded-full border-2 flex items-center justify-center mt-2 ml-auto', 'w-6 h-6 rounded-full border-2 flex items-center justify-center mt-2 ml-auto',
isSelected isSelected
? 'border-desert-green bg-desert-green' ? 'border-desert-green bg-desert-green'
: 'border-gray-300' : 'border-border-default'
)}> )}>
{isSelected && <IconCheck size={16} className="text-white" />} {isSelected && <IconCheck size={16} className="text-white" />}
</div> </div>
@ -203,7 +203,7 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
</div> </div>
{/* Info note */} {/* Info note */}
<div className="mt-6 flex items-start gap-2 text-sm text-gray-500 bg-blue-50 p-3 rounded"> <div className="mt-6 flex items-start gap-2 text-sm text-text-muted bg-blue-50 p-3 rounded">
<IconInfoCircle size={18} className="text-blue-500 flex-shrink-0 mt-0.5" /> <IconInfoCircle size={18} className="text-blue-500 flex-shrink-0 mt-0.5" />
<p> <p>
You can change your selection at any time. Click Submit to confirm your choice. You can change your selection at any time. Click Submit to confirm your choice.
@ -212,7 +212,7 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
</div> </div>
{/* Footer */} {/* Footer */}
<div className="bg-gray-50 px-6 py-4 flex justify-end gap-3"> <div className="bg-surface-secondary px-6 py-4 flex justify-end gap-3">
<button <button
onClick={handleSubmit} onClick={handleSubmit}
disabled={!localSelectedSlug} disabled={!localSelectedSlug}
@ -220,7 +220,7 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
'px-4 py-2 rounded-md font-medium transition-colors', 'px-4 py-2 rounded-md font-medium transition-colors',
localSelectedSlug localSelectedSlug
? 'bg-desert-green text-white hover:bg-desert-green/90' ? 'bg-desert-green text-white hover:bg-desert-green/90'
: 'bg-gray-300 text-gray-500 cursor-not-allowed' : 'bg-border-default text-text-muted cursor-not-allowed'
)} )}
> >
Submit Submit

View File

@ -60,12 +60,12 @@ export default function UpdateServiceModal({
icon={<IconArrowUp className="h-12 w-12 text-desert-green" />} icon={<IconArrowUp className="h-12 w-12 text-desert-green" />}
> >
<div className="space-y-4"> <div className="space-y-4">
<p className="text-gray-700"> <p className="text-text-primary">
Update <strong>{record.friendly_name || record.service_name}</strong> from{' '} Update <strong>{record.friendly_name || record.service_name}</strong> from{' '}
<code className="bg-gray-100 px-1.5 py-0.5 rounded text-sm">{currentTag}</code> to{' '} <code className="bg-surface-secondary px-1.5 py-0.5 rounded text-sm">{currentTag}</code> to{' '}
<code className="bg-gray-100 px-1.5 py-0.5 rounded text-sm">{selectedVersion}</code>? <code className="bg-surface-secondary px-1.5 py-0.5 rounded text-sm">{selectedVersion}</code>?
</p> </p>
<p className="text-sm text-gray-500"> <p className="text-sm text-text-muted">
Your data and configuration will be preserved during the update. Your data and configuration will be preserved during the update.
{versions.find((v) => v.tag === selectedVersion)?.releaseUrl && ( {versions.find((v) => v.tag === selectedVersion)?.releaseUrl && (
<> <>
@ -95,14 +95,14 @@ export default function UpdateServiceModal({
<> <>
<div className="mt-3 max-h-48 overflow-y-auto border rounded-lg divide-y"> <div className="mt-3 max-h-48 overflow-y-auto border rounded-lg divide-y">
{loadingVersions ? ( {loadingVersions ? (
<div className="p-4 text-center text-gray-500 text-sm">Loading versions...</div> <div className="p-4 text-center text-text-muted text-sm">Loading versions...</div>
) : versions.length === 0 ? ( ) : versions.length === 0 ? (
<div className="p-4 text-center text-gray-500 text-sm">No other versions available</div> <div className="p-4 text-center text-text-muted text-sm">No other versions available</div>
) : ( ) : (
versions.map((v) => ( versions.map((v) => (
<label <label
key={v.tag} key={v.tag}
className="flex items-center gap-3 px-4 py-2.5 hover:bg-gray-50 cursor-pointer" className="flex items-center gap-3 px-4 py-2.5 hover:bg-surface-secondary cursor-pointer"
> >
<input <input
type="radio" type="radio"
@ -112,7 +112,7 @@ export default function UpdateServiceModal({
onChange={() => setSelectedVersion(v.tag)} onChange={() => setSelectedVersion(v.tag)}
className="text-desert-green focus:ring-desert-green" className="text-desert-green focus:ring-desert-green"
/> />
<span className="text-sm font-medium text-gray-900">{v.tag}</span> <span className="text-sm font-medium text-text-primary">{v.tag}</span>
{v.isLatest && ( {v.isLatest && (
<span className="text-xs bg-desert-green/10 text-desert-green px-2 py-0.5 rounded-full"> <span className="text-xs bg-desert-green/10 text-desert-green px-2 py-0.5 rounded-full">
Latest Latest
@ -133,7 +133,7 @@ export default function UpdateServiceModal({
)) ))
)} )}
</div> </div>
<p className="mt-2 text-sm text-gray-500"> <p className="mt-2 text-sm text-text-muted">
It's not recommended to upgrade to a new major version (e.g. 1.8.2 &rarr; 2.0.0) unless you have verified compatibility with your current configuration. Always review the release notes and test in a staging environment if possible. It's not recommended to upgrade to a new major version (e.g. 1.8.2 &rarr; 2.0.0) unless you have verified compatibility with your current configuration. Always review the release notes and test in a staging environment if possible.
</p> </p>
</> </>

View File

@ -37,11 +37,11 @@ const WikipediaSelector: React.FC<WikipediaSelectorProps> = ({
{/* Header with Wikipedia branding */} {/* Header with Wikipedia branding */}
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-white flex items-center justify-center shadow-sm"> <div className="w-10 h-10 rounded-full bg-white flex items-center justify-center shadow-sm">
<IconWorld className="w-6 h-6 text-gray-700" /> <IconWorld className="w-6 h-6 text-text-primary" />
</div> </div>
<div> <div>
<h3 className="text-xl font-semibold text-gray-900">Wikipedia</h3> <h3 className="text-xl font-semibold text-text-primary">Wikipedia</h3>
<p className="text-sm text-gray-500">Select your preferred Wikipedia package</p> <p className="text-sm text-text-muted">Select your preferred Wikipedia package</p>
</div> </div>
</div> </div>
@ -78,7 +78,7 @@ const WikipediaSelector: React.FC<WikipediaSelectorProps> = ({
? 'border-desert-green bg-desert-green/10' ? 'border-desert-green bg-desert-green/10'
: isSelected : isSelected
? 'border-lime-500 bg-lime-50' ? 'border-lime-500 bg-lime-50'
: 'border-gray-200 bg-white hover:border-gray-300' : 'border-border-subtle bg-surface-primary hover:border-border-default'
)} )}
> >
{/* Status badges */} {/* Status badges */}
@ -104,8 +104,8 @@ const WikipediaSelector: React.FC<WikipediaSelectorProps> = ({
{/* Option content */} {/* Option content */}
<div className="pr-16 flex flex-col h-full"> <div className="pr-16 flex flex-col h-full">
<h4 className="text-lg font-semibold text-gray-900 mb-1">{option.name}</h4> <h4 className="text-lg font-semibold text-text-primary mb-1">{option.name}</h4>
<p className="text-sm text-gray-600 mb-3 flex-grow">{option.description}</p> <p className="text-sm text-text-secondary mb-3 flex-grow">{option.description}</p>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{/* Radio indicator */} {/* Radio indicator */}
<div <div
@ -115,7 +115,7 @@ const WikipediaSelector: React.FC<WikipediaSelectorProps> = ({
? isInstalled ? isInstalled
? 'border-desert-green bg-desert-green' ? 'border-desert-green bg-desert-green'
: 'border-lime-500 bg-lime-500' : 'border-lime-500 bg-lime-500'
: 'border-gray-300' : 'border-border-default'
)} )}
> >
{isSelected && <IconCheck size={12} className="text-white" />} {isSelected && <IconCheck size={12} className="text-white" />}
@ -123,7 +123,7 @@ const WikipediaSelector: React.FC<WikipediaSelectorProps> = ({
<span <span
className={classNames( className={classNames(
'text-sm font-medium px-2 py-1 rounded', 'text-sm font-medium px-2 py-1 rounded',
option.size_mb === 0 ? 'bg-gray-100 text-gray-500' : 'bg-gray-100 text-gray-700' option.size_mb === 0 ? 'bg-surface-secondary text-text-muted' : 'bg-surface-secondary text-text-secondary'
)} )}
> >
{option.size_mb === 0 ? 'No download' : formatBytes(option.size_mb * 1024 * 1024, 1)} {option.size_mb === 0 ? 'No download' : formatBytes(option.size_mb * 1024 * 1024, 1)}

View File

@ -85,19 +85,19 @@ export default function ChatInterface({
} }
return ( return (
<div className="flex-1 flex flex-col min-h-0 bg-white shadow-sm"> <div className="flex-1 flex flex-col min-h-0 bg-surface-primary shadow-sm">
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6"> <div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
{messages.length === 0 ? ( {messages.length === 0 ? (
<div className="h-full flex items-center justify-center"> <div className="h-full flex items-center justify-center">
<div className="text-center max-w-md"> <div className="text-center max-w-md">
<IconWand className="h-16 w-16 text-desert-green mx-auto mb-4 opacity-50" /> <IconWand className="h-16 w-16 text-desert-green mx-auto mb-4 opacity-50" />
<h3 className="text-lg font-medium text-gray-700 mb-2">Start a conversation</h3> <h3 className="text-lg font-medium text-text-primary mb-2">Start a conversation</h3>
<p className="text-gray-500 text-sm"> <p className="text-text-muted text-sm">
Interact with your installed language models directly in the Command Center. Interact with your installed language models directly in the Command Center.
</p> </p>
{chatSuggestionsEnabled && chatSuggestions && chatSuggestions.length > 0 && !chatSuggestionsLoading && ( {chatSuggestionsEnabled && chatSuggestions && chatSuggestions.length > 0 && !chatSuggestionsLoading && (
<div className="mt-8"> <div className="mt-8">
<h4 className="text-sm font-medium text-gray-600 mb-2">Suggestions:</h4> <h4 className="text-sm font-medium text-text-secondary mb-2">Suggestions:</h4>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{chatSuggestions.map((suggestion, index) => ( {chatSuggestions.map((suggestion, index) => (
<button <button
@ -109,7 +109,7 @@ export default function ChatInterface({
textareaRef.current?.focus() textareaRef.current?.focus()
}, 0) }, 0)
}} }}
className="px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm text-gray-700 transition-colors" className="px-4 py-2 bg-surface-secondary hover:bg-surface-secondary rounded-lg text-sm text-text-primary transition-colors"
> >
{suggestion} {suggestion}
</button> </button>
@ -120,7 +120,7 @@ export default function ChatInterface({
{/* Display bouncing dots while loading suggestions */} {/* Display bouncing dots while loading suggestions */}
{chatSuggestionsEnabled && chatSuggestionsLoading && <BouncingDots text="Thinking" containerClassName="mt-8" />} {chatSuggestionsEnabled && chatSuggestionsLoading && <BouncingDots text="Thinking" containerClassName="mt-8" />}
{!chatSuggestionsEnabled && ( {!chatSuggestionsEnabled && (
<div className="mt-8 text-sm text-gray-500"> <div className="mt-8 text-sm text-text-muted">
Need some inspiration? Enable chat suggestions in settings to get started with example prompts. Need some inspiration? Enable chat suggestions in settings to get started with example prompts.
</div> </div>
)} )}
@ -144,7 +144,7 @@ export default function ChatInterface({
{isLoading && ( {isLoading && (
<div className="flex gap-4 justify-start"> <div className="flex gap-4 justify-start">
<ChatAssistantAvatar /> <ChatAssistantAvatar />
<div className="max-w-[70%] rounded-lg px-4 py-3 bg-gray-100 text-gray-800"> <div className="max-w-[70%] rounded-lg px-4 py-3 bg-surface-secondary text-text-primary">
<BouncingDots text="Thinking" /> <BouncingDots text="Thinking" />
</div> </div>
</div> </div>
@ -154,7 +154,7 @@ export default function ChatInterface({
</> </>
)} )}
</div> </div>
<div className="border-t border-gray-200 bg-white px-6 py-4 flex-shrink-0 min-h-[90px]"> <div className="border-t border-border-subtle bg-surface-primary px-6 py-4 flex-shrink-0 min-h-[90px]">
<form onSubmit={handleSubmit} className="flex gap-3 items-end"> <form onSubmit={handleSubmit} className="flex gap-3 items-end">
<div className="flex-1 relative"> <div className="flex-1 relative">
<textarea <textarea
@ -163,7 +163,7 @@ export default function ChatInterface({
onChange={handleInput} onChange={handleInput}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder={`Type your message to ${aiAssistantName}... (Shift+Enter for new line)`} placeholder={`Type your message to ${aiAssistantName}... (Shift+Enter for new line)`}
className="w-full resize-none rounded-lg border border-gray-300 px-4 py-3 pr-12 focus:outline-none focus:ring-2 focus:ring-desert-green focus:border-transparent disabled:bg-gray-50 disabled:text-gray-500" className="w-full resize-none rounded-lg border border-border-default px-4 py-3 pr-12 focus:outline-none focus:ring-2 focus:ring-desert-green focus:border-transparent disabled:bg-surface-secondary disabled:text-text-muted"
rows={1} rows={1}
disabled={isLoading} disabled={isLoading}
style={{ maxHeight: '200px' }} style={{ maxHeight: '200px' }}
@ -175,7 +175,7 @@ export default function ChatInterface({
className={classNames( className={classNames(
'p-3 rounded-lg transition-all duration-200 flex-shrink-0 mb-2', 'p-3 rounded-lg transition-all duration-200 flex-shrink-0 mb-2',
!input.trim() || isLoading !input.trim() || isLoading
? 'bg-gray-300 text-gray-500 cursor-not-allowed' ? 'bg-border-default text-text-muted cursor-not-allowed'
: 'bg-desert-green text-white hover:bg-desert-green/90 hover:scale-105' : 'bg-desert-green text-white hover:bg-desert-green/90 hover:scale-105'
)} )}
> >
@ -187,7 +187,7 @@ export default function ChatInterface({
</button> </button>
</form> </form>
{!rewriteModelAvailable && ( {!rewriteModelAvailable && (
<div className="text-sm text-gray-500 mt-2"> <div className="text-sm text-text-muted mt-2">
The {DEFAULT_QUERY_REWRITE_MODEL} model is not installed. Consider{' '} The {DEFAULT_QUERY_REWRITE_MODEL} model is not installed. Consider{' '}
<button <button
onClick={() => setDownloadDialogOpen(true)} onClick={() => setDownloadDialogOpen(true)}
@ -210,7 +210,7 @@ export default function ChatInterface({
onCancel={() => setDownloadDialogOpen(false)} onCancel={() => setDownloadDialogOpen(false)}
onClose={() => setDownloadDialogOpen(false)} onClose={() => setDownloadDialogOpen(false)}
> >
<p className="text-gray-700"> <p className="text-text-primary">
This will dispatch a background download job for{' '} This will dispatch a background download job for{' '}
<span className="font-mono font-medium">{DEFAULT_QUERY_REWRITE_MODEL}</span> and may take some time to complete. The model <span className="font-mono font-medium">{DEFAULT_QUERY_REWRITE_MODEL}</span> and may take some time to complete. The model
will be used to rewrite queries for improved RAG retrieval performance. will be used to rewrite queries for improved RAG retrieval performance.

View File

@ -12,7 +12,7 @@ export default function ChatMessageBubble({ message }: ChatMessageBubbleProps) {
<div <div
className={classNames( className={classNames(
'max-w-[70%] rounded-lg px-4 py-3', 'max-w-[70%] rounded-lg px-4 py-3',
message.role === 'user' ? 'bg-desert-green text-white' : 'bg-gray-100 text-gray-800' message.role === 'user' ? 'bg-desert-green text-white' : 'bg-surface-secondary text-text-primary'
)} )}
> >
{message.isThinking && message.thinking && ( {message.isThinking && message.thinking && (
@ -27,13 +27,13 @@ export default function ChatMessageBubble({ message }: ChatMessageBubbleProps) {
</div> </div>
)} )}
{!message.isThinking && message.thinking && ( {!message.isThinking && message.thinking && (
<details className="mb-3 rounded border border-gray-200 bg-gray-50 text-xs"> <details className="mb-3 rounded border border-border-subtle bg-surface-secondary text-xs">
<summary className="cursor-pointer px-3 py-2 font-medium text-gray-500 hover:text-gray-700 select-none"> <summary className="cursor-pointer px-3 py-2 font-medium text-text-muted hover:text-text-primary select-none">
{message.thinkingDuration !== undefined {message.thinkingDuration !== undefined
? `Thought for ${message.thinkingDuration}s` ? `Thought for ${message.thinkingDuration}s`
: 'Reasoning'} : 'Reasoning'}
</summary> </summary>
<div className="px-3 pb-3 prose prose-xs max-w-none text-gray-600 max-h-48 overflow-y-auto border-t border-gray-200 pt-2"> <div className="px-3 pb-3 prose prose-xs max-w-none text-text-secondary max-h-48 overflow-y-auto border-t border-border-subtle pt-2">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{message.thinking}</ReactMarkdown> <ReactMarkdown remarkPlugins={[remarkGfm]}>{message.thinking}</ReactMarkdown>
</div> </div>
</details> </details>
@ -77,7 +77,7 @@ export default function ChatMessageBubble({ message }: ChatMessageBubbleProps) {
h2: ({ children }) => <h2 className="text-lg font-bold mb-2">{children}</h2>, h2: ({ children }) => <h2 className="text-lg font-bold mb-2">{children}</h2>,
h3: ({ children }) => <h3 className="text-base font-bold mb-2">{children}</h3>, h3: ({ children }) => <h3 className="text-base font-bold mb-2">{children}</h3>,
blockquote: ({ children }) => ( blockquote: ({ children }) => (
<blockquote className="border-l-4 border-gray-400 pl-4 italic my-2"> <blockquote className="border-l-4 border-border-default pl-4 italic my-2">
{children} {children}
</blockquote> </blockquote>
), ),
@ -105,7 +105,7 @@ export default function ChatMessageBubble({ message }: ChatMessageBubbleProps) {
<div <div
className={classNames( className={classNames(
'text-xs mt-2', 'text-xs mt-2',
message.role === 'user' ? 'text-white/70' : 'text-gray-500' message.role === 'user' ? 'text-white/70' : 'text-text-muted'
)} )}
> >
{message.timestamp.toLocaleTimeString([], { {message.timestamp.toLocaleTimeString([], {

View File

@ -23,7 +23,7 @@ export default function ChatModal({ open, onClose }: ChatModalProps) {
<div className="fixed inset-0 flex items-center justify-center p-4"> <div className="fixed inset-0 flex items-center justify-center p-4">
<DialogPanel <DialogPanel
transition transition
className="relative bg-white rounded-xl shadow-2xl w-full max-w-7xl h-[85vh] flex overflow-hidden transition-all data-[closed]:scale-95 data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in" className="relative bg-surface-primary rounded-xl shadow-2xl w-full max-w-7xl h-[85vh] flex overflow-hidden transition-all data-[closed]:scale-95 data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in"
> >
<Chat enabled={open} isInModal onClose={onClose} suggestionsEnabled={parseBoolean(settings.data?.value)} /> <Chat enabled={open} isInModal onClose={onClose} suggestionsEnabled={parseBoolean(settings.data?.value)} />
</DialogPanel> </DialogPanel>

View File

@ -39,8 +39,8 @@ export default function ChatSidebar({
} }
return ( return (
<div className="w-64 bg-gray-50 border-r border-gray-200 flex flex-col h-full"> <div className="w-64 bg-surface-secondary border-r border-border-subtle flex flex-col h-full">
<div className="p-4 border-b border-gray-200 h-[75px] flex items-center justify-center"> <div className="p-4 border-b border-border-subtle h-[75px] flex items-center justify-center">
<StyledButton onClick={onNewChat} icon="IconPlus" variant="primary" fullWidth> <StyledButton onClick={onNewChat} icon="IconPlus" variant="primary" fullWidth>
New Chat New Chat
</StyledButton> </StyledButton>
@ -48,7 +48,7 @@ export default function ChatSidebar({
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{sessions.length === 0 ? ( {sessions.length === 0 ? (
<div className="p-4 text-center text-gray-500 text-sm">No previous chats</div> <div className="p-4 text-center text-text-muted text-sm">No previous chats</div>
) : ( ) : (
<div className="p-2 space-y-1"> <div className="p-2 space-y-1">
{sessions.map((session) => ( {sessions.map((session) => (
@ -59,14 +59,14 @@ export default function ChatSidebar({
'w-full text-left px-3 py-2 rounded-lg transition-colors group', 'w-full text-left px-3 py-2 rounded-lg transition-colors group',
activeSessionId === session.id activeSessionId === session.id
? 'bg-desert-green text-white' ? 'bg-desert-green text-white'
: 'hover:bg-gray-200 text-gray-700' : 'hover:bg-surface-secondary text-text-primary'
)} )}
> >
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<IconMessage <IconMessage
className={classNames( className={classNames(
'h-5 w-5 mt-0.5 shrink-0', 'h-5 w-5 mt-0.5 shrink-0',
activeSessionId === session.id ? 'text-white' : 'text-gray-400' activeSessionId === session.id ? 'text-white' : 'text-text-muted'
)} )}
/> />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
@ -75,7 +75,7 @@ export default function ChatSidebar({
<div <div
className={classNames( className={classNames(
'text-xs truncate mt-0.5', 'text-xs truncate mt-0.5',
activeSessionId === session.id ? 'text-white/80' : 'text-gray-500' activeSessionId === session.id ? 'text-white/80' : 'text-text-muted'
)} )}
> >
{session.lastMessage} {session.lastMessage}

View File

@ -106,7 +106,7 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
cancelText='Cancel' cancelText='Cancel'
confirmVariant='primary' confirmVariant='primary'
> >
<p className='text-gray-700'> <p className='text-text-primary'>
This will scan the NOMAD's storage directories for any new files and queue them for processing. This is useful if you've manually added files to the storage or want to ensure everything is up to date. This will scan the NOMAD's storage directories for any new files and queue them for processing. This is useful if you've manually added files to the storage or want to ensure everything is up to date.
This may cause a temporary increase in resource usage if new files are found and being processed. Are you sure you want to proceed? This may cause a temporary increase in resource usage if new files are found and being processed. Are you sure you want to proceed?
</p> </p>
@ -117,18 +117,18 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/30 backdrop-blur-sm transition-opacity"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/30 backdrop-blur-sm transition-opacity">
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col"> <div className="bg-surface-primary rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
<div className="flex items-center justify-between p-6 border-b border-gray-200 shrink-0"> <div className="flex items-center justify-between p-6 border-b border-border-subtle shrink-0">
<h2 className="text-2xl font-semibold text-gray-800">Knowledge Base</h2> <h2 className="text-2xl font-semibold text-text-primary">Knowledge Base</h2>
<button <button
onClick={onClose} onClick={onClose}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors" className="p-2 hover:bg-surface-secondary rounded-lg transition-colors"
> >
<IconX className="h-6 w-6 text-gray-500" /> <IconX className="h-6 w-6 text-text-muted" />
</button> </button>
</div> </div>
<div className="overflow-y-auto flex-1 p-6"> <div className="overflow-y-auto flex-1 p-6">
<div className="bg-white rounded-lg border shadow-md overflow-hidden"> <div className="bg-surface-primary rounded-lg border shadow-md overflow-hidden">
<div className="p-6"> <div className="p-6">
<FileUploader <FileUploader
ref={fileUploaderRef} ref={fileUploaderRef}
@ -151,7 +151,7 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
</StyledButton> </StyledButton>
</div> </div>
</div> </div>
<div className="border-t bg-white p-6"> <div className="border-t bg-surface-primary p-6">
<h3 className="text-lg font-semibold text-desert-green mb-4"> <h3 className="text-lg font-semibold text-desert-green mb-4">
Why upload documents to your Knowledge Base? Why upload documents to your Knowledge Base?
</h3> </h3>
@ -232,7 +232,7 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
accessor: 'source', accessor: 'source',
title: 'File Name', title: 'File Name',
render(record) { render(record) {
return <span className="text-gray-700">{sourceToDisplayName(record.source)}</span> return <span className="text-text-primary">{sourceToDisplayName(record.source)}</span>
}, },
}, },
{ {
@ -244,7 +244,7 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
if (isConfirming) { if (isConfirming) {
return ( return (
<div className="flex items-center gap-2 justify-end"> <div className="flex items-center gap-2 justify-end">
<span className="text-sm text-gray-600">Remove from knowledge base?</span> <span className="text-sm text-text-secondary">Remove from knowledge base?</span>
<StyledButton <StyledButton
variant='danger' variant='danger'
size='sm' size='sm'

View File

@ -159,7 +159,7 @@ export default function Chat({
cancelText="Cancel" cancelText="Cancel"
confirmVariant="danger" confirmVariant="danger"
> >
<p className="text-gray-700"> <p className="text-text-primary">
Are you sure you want to delete all chat sessions? This action cannot be undone and all Are you sure you want to delete all chat sessions? This action cannot be undone and all
conversations will be permanently deleted. conversations will be permanently deleted.
</p> </p>
@ -345,7 +345,7 @@ export default function Chat({
return ( return (
<div <div
className={classNames( className={classNames(
'flex border border-gray-200 overflow-hidden shadow-sm w-full', 'flex border border-border-subtle overflow-hidden shadow-sm w-full',
isInModal ? 'h-full rounded-lg' : 'h-screen' isInModal ? 'h-full rounded-lg' : 'h-screen'
)} )}
> >
@ -358,17 +358,17 @@ export default function Chat({
isInModal={isInModal} isInModal={isInModal}
/> />
<div className="flex-1 flex flex-col min-h-0"> <div className="flex-1 flex flex-col min-h-0">
<div className="px-6 py-3 border-b border-gray-200 bg-gray-50 flex items-center justify-between h-[75px] flex-shrink-0"> <div className="px-6 py-3 border-b border-border-subtle bg-surface-secondary flex items-center justify-between h-[75px] flex-shrink-0">
<h2 className="text-lg font-semibold text-gray-800"> <h2 className="text-lg font-semibold text-text-primary">
{activeSession?.title || 'New Chat'} {activeSession?.title || 'New Chat'}
</h2> </h2>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label htmlFor="model-select" className="text-sm text-gray-600"> <label htmlFor="model-select" className="text-sm text-text-secondary">
Model: Model:
</label> </label>
{isLoadingModels ? ( {isLoadingModels ? (
<div className="text-sm text-gray-500">Loading models...</div> <div className="text-sm text-text-muted">Loading models...</div>
) : installedModels.length === 0 ? ( ) : installedModels.length === 0 ? (
<div className="text-sm text-red-600">No models installed</div> <div className="text-sm text-red-600">No models installed</div>
) : ( ) : (
@ -376,7 +376,7 @@ export default function Chat({
id="model-select" id="model-select"
value={selectedModel} value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)} onChange={(e) => setSelectedModel(e.target.value)}
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-desert-green focus:border-transparent bg-white" className="px-3 py-1.5 border border-border-default rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-desert-green focus:border-transparent bg-surface-primary"
> >
{installedModels.map((model) => ( {installedModels.map((model) => (
<option key={model.name} value={model.name}> <option key={model.name} value={model.name}>
@ -393,9 +393,9 @@ export default function Chat({
onClose() onClose()
} }
}} }}
className="rounded-lg hover:bg-gray-100 transition-colors" className="rounded-lg hover:bg-surface-secondary transition-colors"
> >
<IconX className="h-6 w-6 text-gray-500" /> <IconX className="h-6 w-6 text-text-muted" />
</button> </button>
)} )}
</div> </div>

View File

@ -31,11 +31,11 @@ const Input: React.FC<InputProps> = ({
<div className={classNames(className)}> <div className={classNames(className)}>
<label <label
htmlFor={name} htmlFor={name}
className={classNames("block text-base/6 font-medium text-gray-700", labelClassName)} className={classNames("block text-base/6 font-medium text-text-primary", labelClassName)}
> >
{label}{required ? "*" : ""} {label}{required ? "*" : ""}
</label> </label>
{helpText && <p className="mt-1 text-sm text-gray-500">{helpText}</p>} {helpText && <p className="mt-1 text-sm text-text-muted">{helpText}</p>}
<div className={classNames("mt-1.5", containerClassName)}> <div className={classNames("mt-1.5", containerClassName)}>
<div className="relative"> <div className="relative">
{leftIcon && ( {leftIcon && (
@ -49,7 +49,7 @@ const Input: React.FC<InputProps> = ({
placeholder={props.placeholder || label} placeholder={props.placeholder || label}
className={classNames( className={classNames(
inputClassName, inputClassName,
"block w-full rounded-md bg-white px-3 py-2 text-base text-gray-900 border border-gray-400 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-primary sm:text-sm/6", "block w-full rounded-md bg-surface-primary px-3 py-2 text-base text-text-primary border border-border-default placeholder:text-text-muted focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-primary sm:text-sm/6",
leftIcon ? "pl-10" : "pl-3", leftIcon ? "pl-10" : "pl-3",
error ? "!border-red-500 focus:outline-red-500 !bg-red-100" : "" error ? "!border-red-500 focus:outline-red-500 !bg-red-100" : ""
)} )}

View File

@ -26,12 +26,12 @@ export default function Switch({
{label && ( {label && (
<label <label
htmlFor={switchId} htmlFor={switchId}
className="text-base font-medium text-gray-900 cursor-pointer" className="text-base font-medium text-text-primary cursor-pointer"
> >
{label} {label}
</label> </label>
)} )}
{description && <p className="text-sm text-gray-500 mt-1">{description}</p>} {description && <p className="text-sm text-text-muted mt-1">{description}</p>}
</div> </div>
)} )}
<div className="flex items-center ml-4"> <div className="flex items-center ml-4">
@ -45,7 +45,7 @@ export default function Switch({
className={clsx( className={clsx(
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent', 'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent',
'transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-desert-green focus:ring-offset-2', 'transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-desert-green focus:ring-offset-2',
checked ? 'bg-desert-green' : 'bg-gray-200', checked ? 'bg-desert-green' : 'bg-border-default',
disabled ? 'opacity-50 cursor-not-allowed' : '' disabled ? 'opacity-50 cursor-not-allowed' : ''
)} )}
> >

View File

@ -9,11 +9,11 @@ interface BackToHomeHeaderProps {
export default function BackToHomeHeader({ className, children }: BackToHomeHeaderProps) { export default function BackToHomeHeader({ className, children }: BackToHomeHeaderProps) {
return ( return (
<div className={classNames('flex border-b border-gray-900/10 p-4', className)}> <div className={classNames('flex border-b border-border-subtle p-4', className)}>
<div className="justify-self-start"> <div className="justify-self-start">
<Link href="/home" className="flex items-center"> <Link href="/home" className="flex items-center">
<IconArrowLeft className="mr-2" size={24} /> <IconArrowLeft className="mr-2" size={24} />
<p className="text-lg text-gray-600">Back to Home</p> <p className="text-lg text-text-secondary">Back to Home</p>
</Link> </Link>
</div> </div>
<div className="flex-grow flex flex-col justify-center">{children}</div> <div className="flex-grow flex flex-col justify-center">{children}</div>

View File

@ -9,11 +9,11 @@ export function Table({ children }: { children: React.ReactNode }) {
} }
export function TableHead({ children }: { children: React.ReactNode }) { export function TableHead({ children }: { children: React.ReactNode }) {
return <thead className="bg-desert-green-dark">{children}</thead> return <thead className="bg-desert-green">{children}</thead>
} }
export function TableBody({ children }: { children: React.ReactNode }) { export function TableBody({ children }: { children: React.ReactNode }) {
return <tbody className="divide-y divide-desert-tan-lighter/50 bg-white">{children}</tbody> return <tbody className="divide-y divide-desert-tan-lighter/50 bg-surface-primary">{children}</tbody>
} }
export function TableRow({ children }: { children: React.ReactNode }) { export function TableRow({ children }: { children: React.ReactNode }) {
@ -22,7 +22,7 @@ export function TableRow({ children }: { children: React.ReactNode }) {
export function TableHeader({ children }: { children: React.ReactNode }) { export function TableHeader({ children }: { children: React.ReactNode }) {
return ( return (
<th className="px-5 py-3 text-left text-sm font-semibold text-desert-white tracking-wide"> <th className="px-5 py-3 text-left text-sm font-semibold text-white tracking-wide">
{children} {children}
</th> </th>
) )

View File

@ -45,8 +45,8 @@ export default function InfoCard({ title, icon, data, variant = 'default' }: Inf
/> />
<div className="relative flex items-center gap-3"> <div className="relative flex items-center gap-3">
{icon && <div className="text-desert-white opacity-80">{icon}</div>} {icon && <div className="text-white opacity-80">{icon}</div>}
<h3 className="text-lg font-bold text-desert-white uppercase tracking-wide">{title}</h3> <h3 className="text-lg font-bold text-white uppercase tracking-wide">{title}</h3>
</div> </div>
<div className="absolute top-0 right-0 w-24 h-24 transform translate-x-8 -translate-y-8"> <div className="absolute top-0 right-0 w-24 h-24 transform translate-x-8 -translate-y-8">
<div className="w-full h-full bg-desert-green-dark opacity-30 transform rotate-45" /> <div className="w-full h-full bg-desert-green-dark opacity-30 transform rotate-45" />

View File

@ -3,39 +3,119 @@
@theme { @theme {
--color-desert-white: #f6f6f4; --color-desert-white: #f6f6f4;
--color-desert-sand: #f7eedc; --color-desert-sand: #f7eedc;
--color-desert-green-darker: #2a2a15; --color-desert-green-darker: #2a2a15;
--color-desert-green-dark: #353518; --color-desert-green-dark: #353518;
--color-desert-green: #424420; --color-desert-green: #424420;
--color-desert-green-light: #babaaa; --color-desert-green-light: #babaaa;
--color-desert-green-lighter: #d4d4c8; --color-desert-green-lighter: #d4d4c8;
--color-desert-orange-dark: #8a3d0f; --color-desert-orange-dark: #8a3d0f;
--color-desert-orange: #a84a12; --color-desert-orange: #a84a12;
--color-desert-orange-light: #c85815; --color-desert-orange-light: #c85815;
--color-desert-orange-lighter: #e69556; --color-desert-orange-lighter: #e69556;
--color-desert-tan-dark: #6b5d4f; --color-desert-tan-dark: #6b5d4f;
--color-desert-tan: #8b7355; --color-desert-tan: #8b7355;
--color-desert-tan-light: #a8927a; --color-desert-tan-light: #a8927a;
--color-desert-tan-lighter: #c9b99f; --color-desert-tan-lighter: #c9b99f;
--color-desert-red-dark: #7a2e2e; --color-desert-red-dark: #7a2e2e;
--color-desert-red: #994444; --color-desert-red: #994444;
--color-desert-red-light: #b05555; --color-desert-red-light: #b05555;
--color-desert-red-lighter: #d88989; --color-desert-red-lighter: #d88989;
--color-desert-olive-dark: #5a5c3a; --color-desert-olive-dark: #5a5c3a;
--color-desert-olive: #6d7042; --color-desert-olive: #6d7042;
--color-desert-olive-light: #858a55; --color-desert-olive-light: #858a55;
--color-desert-olive-lighter: #a5ab7d; --color-desert-olive-lighter: #a5ab7d;
--color-desert-stone-dark: #5c5c54; --color-desert-stone-dark: #5c5c54;
--color-desert-stone: #75756a; --color-desert-stone: #75756a;
--color-desert-stone-light: #8f8f82; --color-desert-stone-light: #8f8f82;
--color-desert-stone-lighter: #afafa5; --color-desert-stone-lighter: #afafa5;
/* Semantic surface/text tokens (for replacing generic gray/white Tailwind classes) */
--color-surface-primary: #ffffff;
--color-surface-secondary: #f9fafb;
--color-surface-elevated: #ffffff;
--color-text-primary: #111827;
--color-text-secondary: #6b7280;
--color-text-muted: #9ca3af;
--color-border-default: #d1d5db;
--color-border-subtle: #e5e7eb;
/* Button interactive states (green hover/active swap conflicts with text color inversion) */
--color-btn-green-hover: #353518;
--color-btn-green-active: #2a2a15;
} }
body { body {
background-color: var(--color-desert-sand); background-color: var(--color-desert-sand);
color: var(--color-text-primary);
transition: background-color 0.2s ease, color 0.2s ease;
}
/* Night Ops — warm charcoal dark mode */
[data-theme="dark"] {
/* Backgrounds: light sand → warm charcoal */
--color-desert-sand: #1c1b16;
--color-desert-white: #2a2918;
/* Text greens: dark text → light text for readability */
--color-desert-green-darker: #f7eedc;
--color-desert-green-dark: #e8dfc8;
/* Accent green: slightly brighter for dark bg visibility */
--color-desert-green: #525530;
/* Light variants → dark variants (hover bg, disabled states) */
--color-desert-green-light: #3a3c24;
--color-desert-green-lighter: #2d2e1c;
/* Orange: brighter for contrast on dark surfaces */
--color-desert-orange-dark: #c85815;
--color-desert-orange: #c85815;
--color-desert-orange-light: #e69556;
--color-desert-orange-lighter: #f0b87a;
/* Tan: lightened for readability */
--color-desert-tan-dark: #c9b99f;
--color-desert-tan: #a8927a;
--color-desert-tan-light: #8b7355;
--color-desert-tan-lighter: #6b5d4f;
/* Red: lightened for dark bg */
--color-desert-red-dark: #d88989;
--color-desert-red: #b05555;
--color-desert-red-light: #994444;
--color-desert-red-lighter: #7a2e2e;
/* Olive: lightened */
--color-desert-olive-dark: #a5ab7d;
--color-desert-olive: #858a55;
--color-desert-olive-light: #6d7042;
--color-desert-olive-lighter: #5a5c3a;
/* Stone: lightened */
--color-desert-stone-dark: #afafa5;
--color-desert-stone: #8f8f82;
--color-desert-stone-light: #75756a;
--color-desert-stone-lighter: #5c5c54;
/* Semantic surface overrides */
--color-surface-primary: #2a2918;
--color-surface-secondary: #353420;
--color-surface-elevated: #3d3c2a;
--color-text-primary: #f7eedc;
--color-text-secondary: #afafa5;
--color-text-muted: #8f8f82;
--color-border-default: #424420;
--color-border-subtle: #353420;
/* Button interactive states: darker green for hover/active on dark bg */
--color-btn-green-hover: #474a28;
--color-btn-green-active: #3a3c24;
color-scheme: dark;
} }

View File

@ -0,0 +1,47 @@
import { useState, useEffect, useCallback } from 'react'
import api from '~/lib/api'
export type Theme = 'light' | 'dark'
const STORAGE_KEY = 'nomad:theme'
function getInitialTheme(): Theme {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored === 'dark' || stored === 'light') return stored
} catch {}
return 'light'
}
export function useTheme() {
const [theme, setThemeState] = useState<Theme>(getInitialTheme)
const setTheme = useCallback((newTheme: Theme) => {
setThemeState(newTheme)
document.documentElement.setAttribute('data-theme', newTheme)
try {
localStorage.setItem(STORAGE_KEY, newTheme)
} catch {}
// Fire-and-forget KV store sync for cross-device persistence
api.updateSetting('ui.theme', newTheme).catch(() => {})
}, [])
const toggleTheme = useCallback(() => {
setThemeState((prev) => {
const next = prev === 'light' ? 'dark' : 'light'
document.documentElement.setAttribute('data-theme', next)
try {
localStorage.setItem(STORAGE_KEY, next)
} catch {}
api.updateSetting('ui.theme', next).catch(() => {})
return next
})
}, [])
// Apply theme on mount
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme)
}, [])
return { theme, setTheme, toggleTheme }
}

View File

@ -18,7 +18,7 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
window.location.pathname !== '/home' && ( window.location.pathname !== '/home' && (
<Link href="/home" className="absolute top-60 md:top-48 left-4 flex items-center"> <Link href="/home" className="absolute top-60 md:top-48 left-4 flex items-center">
<IconArrowLeft className="mr-2" size={24} /> <IconArrowLeft className="mr-2" size={24} />
<p className="text-lg text-gray-600">Back to Home</p> <p className="text-lg text-text-secondary">Back to Home</p>
</Link> </Link>
)} )}
<div <div

View File

@ -45,7 +45,7 @@ export default function SettingsLayout({ children }: { children: React.ReactNode
] ]
return ( return (
<div className="min-h-screen flex flex-row bg-stone-50/90"> <div className="min-h-screen flex flex-row bg-surface-secondary/90">
<StyledSidebar title="Settings" items={navigation} /> <StyledSidebar title="Settings" items={navigation} />
{children} {children}
</div> </div>

View File

@ -25,7 +25,7 @@ export default function EasySetupWizardComplete() {
/> />
)} )}
<div className="max-w-7xl mx-auto px-4 py-8"> <div className="max-w-7xl mx-auto px-4 py-8">
<div className="bg-white rounded-md shadow-md p-6"> <div className="bg-surface-primary rounded-md shadow-md p-6">
<StyledSectionHeader title="App Installation Activity" className=" mb-4" /> <StyledSectionHeader title="App Installation Activity" className=" mb-4" />
<InstallActivityFeed <InstallActivityFeed
activity={installActivity} activity={installActivity}

View File

@ -444,7 +444,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
<nav aria-label="Progress" className="px-6 pt-6"> <nav aria-label="Progress" className="px-6 pt-6">
<ol <ol
role="list" role="list"
className="divide-y divide-gray-300 rounded-md md:flex md:divide-y-0 md:justify-between border border-desert-green" className="divide-y divide-border-default rounded-md md:flex md:divide-y-0 md:justify-between border border-desert-green"
> >
{steps.map((step, stepIdx) => ( {steps.map((step, stepIdx) => (
<li key={step.number} className="relative md:flex-1 md:flex md:justify-center"> <li key={step.number} className="relative md:flex-1 md:flex md:justify-center">
@ -454,7 +454,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
<span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-desert-green"> <span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-desert-green">
<IconCheck aria-hidden="true" className="size-6 text-white" /> <IconCheck aria-hidden="true" className="size-6 text-white" />
</span> </span>
<span className="ml-4 text-lg font-medium text-gray-900">{step.label}</span> <span className="ml-4 text-lg font-medium text-text-primary">{step.label}</span>
</span> </span>
</div> </div>
) : currentStep === step.number ? ( ) : currentStep === step.number ? (
@ -470,10 +470,10 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
) : ( ) : (
<div className="group flex items-center md:justify-center"> <div className="group flex items-center md:justify-center">
<span className="flex items-center px-6 py-2 text-sm font-medium"> <span className="flex items-center px-6 py-2 text-sm font-medium">
<span className="flex size-10 shrink-0 items-center justify-center rounded-full border-2 border-gray-300"> <span className="flex size-10 shrink-0 items-center justify-center rounded-full border-2 border-border-default">
<span className="text-gray-500">{step.number}</span> <span className="text-text-muted">{step.number}</span>
</span> </span>
<span className="ml-4 text-lg font-medium text-gray-500">{step.label}</span> <span className="ml-4 text-lg font-medium text-text-muted">{step.label}</span>
</span> </span>
</div> </div>
)} )}
@ -489,7 +489,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
fill="none" fill="none"
viewBox="0 0 22 80" viewBox="0 0 22 80"
preserveAspectRatio="none" preserveAspectRatio="none"
className={`size-full ${currentStep > step.number ? 'text-desert-green' : 'text-gray-300'}`} className={`size-full ${currentStep > step.number ? 'text-desert-green' : 'text-text-muted'}`}
> >
<path <path
d="M0 -2L20 40L0 82" d="M0 -2L20 40L0 82"
@ -565,7 +565,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
? 'border-desert-green bg-desert-green/20 cursor-default' ? 'border-desert-green bg-desert-green/20 cursor-default'
: selected : selected
? 'border-desert-green bg-desert-green shadow-md cursor-pointer' ? 'border-desert-green bg-desert-green shadow-md cursor-pointer'
: 'border-desert-stone-light bg-white hover:border-desert-green hover:shadow-sm cursor-pointer' : 'border-desert-stone-light bg-surface-primary hover:border-desert-green hover:shadow-sm cursor-pointer'
)} )}
> >
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
@ -574,7 +574,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
<h3 <h3
className={classNames( className={classNames(
'text-xl font-bold', 'text-xl font-bold',
installed ? 'text-gray-700' : selected ? 'text-white' : 'text-gray-900' installed ? 'text-text-primary' : selected ? 'text-white' : 'text-text-primary'
)} )}
> >
{capability.name} {capability.name}
@ -588,7 +588,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
<p <p
className={classNames( className={classNames(
'text-sm mt-0.5', 'text-sm mt-0.5',
installed ? 'text-gray-500' : selected ? 'text-green-100' : 'text-gray-500' installed ? 'text-text-muted' : selected ? 'text-green-100' : 'text-text-muted'
)} )}
> >
Powered by {capability.technicalName} Powered by {capability.technicalName}
@ -596,7 +596,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
<p <p
className={classNames( className={classNames(
'text-sm mt-3', 'text-sm mt-3',
installed ? 'text-gray-600' : selected ? 'text-white' : 'text-gray-600' installed ? 'text-text-secondary' : selected ? 'text-white' : 'text-text-secondary'
)} )}
> >
{capability.description} {capability.description}
@ -605,7 +605,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
<ul <ul
className={classNames( className={classNames(
'mt-3 space-y-1', 'mt-3 space-y-1',
installed ? 'text-gray-600' : selected ? 'text-white' : 'text-gray-600' installed ? 'text-text-secondary' : selected ? 'text-white' : 'text-text-secondary'
)} )}
> >
{capability.features.map((feature, idx) => ( {capability.features.map((feature, idx) => (
@ -661,15 +661,15 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<div className="text-center mb-6"> <div className="text-center mb-6">
<h2 className="text-3xl font-bold text-gray-900 mb-2">What do you want NOMAD to do?</h2> <h2 className="text-3xl font-bold text-text-primary mb-2">What do you want NOMAD to do?</h2>
<p className="text-gray-600"> <p className="text-text-secondary">
Select the capabilities you need. You can always add more later. Select the capabilities you need. You can always add more later.
</p> </p>
</div> </div>
{allInstalled ? ( {allInstalled ? (
<div className="text-center py-12"> <div className="text-center py-12">
<p className="text-gray-600 text-lg"> <p className="text-text-secondary text-lg">
All available capabilities are already installed! All available capabilities are already installed!
</p> </p>
<StyledButton <StyledButton
@ -685,7 +685,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
{/* Core Capabilities */} {/* Core Capabilities */}
{existingCoreCapabilities.length > 0 && ( {existingCoreCapabilities.length > 0 && (
<div> <div>
<h3 className="text-lg font-semibold text-gray-700 mb-4">Core Capabilities</h3> <h3 className="text-lg font-semibold text-text-primary mb-4">Core Capabilities</h3>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{existingCoreCapabilities.map((capability) => {existingCoreCapabilities.map((capability) =>
renderCapabilityCard(capability, true) renderCapabilityCard(capability, true)
@ -701,11 +701,11 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
onClick={() => setShowAdditionalTools(!showAdditionalTools)} onClick={() => setShowAdditionalTools(!showAdditionalTools)}
className="flex items-center justify-between w-full text-left" className="flex items-center justify-between w-full text-left"
> >
<h3 className="text-md font-medium text-gray-500">Additional Tools</h3> <h3 className="text-md font-medium text-text-muted">Additional Tools</h3>
{showAdditionalTools ? ( {showAdditionalTools ? (
<IconChevronUp size={20} className="text-gray-400" /> <IconChevronUp size={20} className="text-text-muted" />
) : ( ) : (
<IconChevronDown size={20} className="text-gray-400" /> <IconChevronDown size={20} className="text-text-muted" />
)} )}
</button> </button>
{showAdditionalTools && ( {showAdditionalTools && (
@ -726,8 +726,8 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
const renderStep2 = () => ( const renderStep2 = () => (
<div className="space-y-6"> <div className="space-y-6">
<div className="text-center mb-6"> <div className="text-center mb-6">
<h2 className="text-3xl font-bold text-gray-900 mb-2">Choose Map Regions</h2> <h2 className="text-3xl font-bold text-text-primary mb-2">Choose Map Regions</h2>
<p className="text-gray-600"> <p className="text-text-secondary">
Select map region collections to download for offline use. You can always download more Select map region collections to download for offline use. You can always download more
regions later. regions later.
</p> </p>
@ -763,7 +763,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
</div> </div>
) : ( ) : (
<div className="text-center py-12"> <div className="text-center py-12">
<p className="text-gray-600 text-lg">No map collections available at this time.</p> <p className="text-text-secondary text-lg">No map collections available at this time.</p>
</div> </div>
)} )}
</div> </div>
@ -779,8 +779,8 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="text-center mb-6"> <div className="text-center mb-6">
<h2 className="text-3xl font-bold text-gray-900 mb-2">Choose Content</h2> <h2 className="text-3xl font-bold text-text-primary mb-2">Choose Content</h2>
<p className="text-gray-600"> <p className="text-text-secondary">
{isAiSelected && isInformationSelected {isAiSelected && isInformationSelected
? 'Select AI models and content categories for offline use.' ? 'Select AI models and content categories for offline use.'
: isAiSelected : isAiSelected
@ -795,12 +795,12 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
{isAiSelected && ( {isAiSelected && (
<div className="mb-8"> <div className="mb-8">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-white border border-gray-200 flex items-center justify-center shadow-sm"> <div className="w-10 h-10 rounded-full bg-surface-primary border border-border-subtle flex items-center justify-center shadow-sm">
<IconCpu className="w-6 h-6 text-gray-700" /> <IconCpu className="w-6 h-6 text-text-primary" />
</div> </div>
<div> <div>
<h3 className="text-xl font-semibold text-gray-900">AI Models</h3> <h3 className="text-xl font-semibold text-text-primary">AI Models</h3>
<p className="text-sm text-gray-500">Select models to download for offline AI</p> <p className="text-sm text-text-muted">Select models to download for offline AI</p>
</div> </div>
</div> </div>
@ -818,7 +818,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
'p-4 rounded-lg border-2 transition-all cursor-pointer', 'p-4 rounded-lg border-2 transition-all cursor-pointer',
selectedAiModels.includes(model.name) selectedAiModels.includes(model.name)
? 'border-desert-green bg-desert-green shadow-md' ? 'border-desert-green bg-desert-green shadow-md'
: 'border-desert-stone-light bg-white hover:border-desert-green hover:shadow-sm', : 'border-desert-stone-light bg-surface-primary hover:border-desert-green hover:shadow-sm',
!isOnline && 'opacity-50 cursor-not-allowed' !isOnline && 'opacity-50 cursor-not-allowed'
)} )}
> >
@ -827,7 +827,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
<h4 <h4
className={classNames( className={classNames(
'text-lg font-semibold mb-1', 'text-lg font-semibold mb-1',
selectedAiModels.includes(model.name) ? 'text-white' : 'text-gray-900' selectedAiModels.includes(model.name) ? 'text-white' : 'text-text-primary'
)} )}
> >
{model.name} {model.name}
@ -835,7 +835,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
<p <p
className={classNames( className={classNames(
'text-sm mb-2', 'text-sm mb-2',
selectedAiModels.includes(model.name) ? 'text-white' : 'text-gray-600' selectedAiModels.includes(model.name) ? 'text-white' : 'text-text-secondary'
)} )}
> >
{model.description} {model.description}
@ -846,7 +846,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
'text-xs', 'text-xs',
selectedAiModels.includes(model.name) selectedAiModels.includes(model.name)
? 'text-green-100' ? 'text-green-100'
: 'text-gray-500' : 'text-text-muted'
)} )}
> >
Size: {model.tags[0].size} Size: {model.tags[0].size}
@ -870,8 +870,8 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
))} ))}
</div> </div>
) : ( ) : (
<div className="text-center py-8 bg-gray-50 rounded-lg"> <div className="text-center py-8 bg-surface-secondary rounded-lg">
<p className="text-gray-600">No recommended AI models available at this time.</p> <p className="text-text-secondary">No recommended AI models available at this time.</p>
</div> </div>
)} )}
</div> </div>
@ -881,7 +881,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
{isInformationSelected && ( {isInformationSelected && (
<> <>
{/* Divider between AI Models and Wikipedia */} {/* Divider between AI Models and Wikipedia */}
{isAiSelected && <hr className="my-8 border-gray-200" />} {isAiSelected && <hr className="my-8 border-border-subtle" />}
<div className="mb-8"> <div className="mb-8">
{isLoadingWikipedia ? ( {isLoadingWikipedia ? (
@ -905,15 +905,15 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
{isInformationSelected && ( {isInformationSelected && (
<> <>
{/* Divider between Wikipedia and Additional Content */} {/* Divider between Wikipedia and Additional Content */}
<hr className="my-8 border-gray-200" /> <hr className="my-8 border-border-subtle" />
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-white border border-gray-200 flex items-center justify-center shadow-sm"> <div className="w-10 h-10 rounded-full bg-surface-primary border border-border-subtle flex items-center justify-center shadow-sm">
<IconBooks className="w-6 h-6 text-gray-700" /> <IconBooks className="w-6 h-6 text-text-primary" />
</div> </div>
<div> <div>
<h3 className="text-xl font-semibold text-gray-900">Additional Content</h3> <h3 className="text-xl font-semibold text-text-primary">Additional Content</h3>
<p className="text-sm text-gray-500">Curated collections for offline reference</p> <p className="text-sm text-text-muted">Curated collections for offline reference</p>
</div> </div>
</div> </div>
@ -955,7 +955,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
{/* Show message if no capabilities requiring content are selected */} {/* Show message if no capabilities requiring content are selected */}
{!isAiSelected && !isInformationSelected && ( {!isAiSelected && !isInformationSelected && (
<div className="text-center py-12"> <div className="text-center py-12">
<p className="text-gray-600 text-lg"> <p className="text-text-secondary text-lg">
No content-based capabilities selected. You can skip this step or go back to select No content-based capabilities selected. You can skip this step or go back to select
capabilities that require content. capabilities that require content.
</p> </p>
@ -976,8 +976,8 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="text-center mb-6"> <div className="text-center mb-6">
<h2 className="text-3xl font-bold text-gray-900 mb-2">Review Your Selections</h2> <h2 className="text-3xl font-bold text-text-primary mb-2">Review Your Selections</h2>
<p className="text-gray-600">Review your choices before starting the setup process.</p> <p className="text-text-secondary">Review your choices before starting the setup process.</p>
</div> </div>
{!hasSelections ? ( {!hasSelections ? (
@ -990,8 +990,8 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
) : ( ) : (
<div className="space-y-6"> <div className="space-y-6">
{selectedServices.length > 0 && ( {selectedServices.length > 0 && (
<div className="bg-white rounded-lg border-2 border-desert-stone-light p-6"> <div className="bg-surface-primary rounded-lg border-2 border-desert-stone-light p-6">
<h3 className="text-xl font-semibold text-gray-900 mb-4"> <h3 className="text-xl font-semibold text-text-primary mb-4">
Capabilities to Install Capabilities to Install
</h3> </h3>
<ul className="space-y-2"> <ul className="space-y-2">
@ -1000,9 +1000,9 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
.map((capability) => ( .map((capability) => (
<li key={capability.id} className="flex items-center"> <li key={capability.id} className="flex items-center">
<IconCheck size={20} className="text-desert-green mr-2" /> <IconCheck size={20} className="text-desert-green mr-2" />
<span className="text-gray-700"> <span className="text-text-primary">
{capability.name} {capability.name}
<span className="text-gray-400 text-sm ml-2"> <span className="text-text-muted text-sm ml-2">
({capability.technicalName}) ({capability.technicalName})
</span> </span>
</span> </span>
@ -1013,8 +1013,8 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
)} )}
{selectedMapCollections.length > 0 && ( {selectedMapCollections.length > 0 && (
<div className="bg-white rounded-lg border-2 border-desert-stone-light p-6"> <div className="bg-surface-primary rounded-lg border-2 border-desert-stone-light p-6">
<h3 className="text-xl font-semibold text-gray-900 mb-4"> <h3 className="text-xl font-semibold text-text-primary mb-4">
Map Collections to Download ({selectedMapCollections.length}) Map Collections to Download ({selectedMapCollections.length})
</h3> </h3>
<ul className="space-y-2"> <ul className="space-y-2">
@ -1023,7 +1023,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
return ( return (
<li key={slug} className="flex items-center"> <li key={slug} className="flex items-center">
<IconCheck size={20} className="text-desert-green mr-2" /> <IconCheck size={20} className="text-desert-green mr-2" />
<span className="text-gray-700">{collection?.name || slug}</span> <span className="text-text-primary">{collection?.name || slug}</span>
</li> </li>
) )
})} })}
@ -1032,8 +1032,8 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
)} )}
{selectedTiers.size > 0 && ( {selectedTiers.size > 0 && (
<div className="bg-white rounded-lg border-2 border-desert-stone-light p-6"> <div className="bg-surface-primary rounded-lg border-2 border-desert-stone-light p-6">
<h3 className="text-xl font-semibold text-gray-900 mb-4"> <h3 className="text-xl font-semibold text-text-primary mb-4">
Content Categories ({selectedTiers.size}) Content Categories ({selectedTiers.size})
</h3> </h3>
{Array.from(selectedTiers.entries()).map(([categorySlug, tier]) => { {Array.from(selectedTiers.entries()).map(([categorySlug, tier]) => {
@ -1044,16 +1044,16 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
<div key={categorySlug} className="mb-4 last:mb-0"> <div key={categorySlug} className="mb-4 last:mb-0">
<div className="flex items-center mb-2"> <div className="flex items-center mb-2">
<IconCheck size={20} className="text-desert-green mr-2" /> <IconCheck size={20} className="text-desert-green mr-2" />
<span className="text-gray-900 font-medium"> <span className="text-text-primary font-medium">
{category.name} - {tier.name} {category.name} - {tier.name}
</span> </span>
<span className="text-gray-500 text-sm ml-2"> <span className="text-text-muted text-sm ml-2">
({resources.length} files) ({resources.length} files)
</span> </span>
</div> </div>
<ul className="ml-7 space-y-1"> <ul className="ml-7 space-y-1">
{resources.map((resource, idx) => ( {resources.map((resource, idx) => (
<li key={idx} className="text-sm text-gray-600"> <li key={idx} className="text-sm text-text-secondary">
{resource.title} {resource.title}
</li> </li>
))} ))}
@ -1065,17 +1065,17 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
)} )}
{selectedWikipedia && selectedWikipedia !== 'none' && ( {selectedWikipedia && selectedWikipedia !== 'none' && (
<div className="bg-white rounded-lg border-2 border-desert-stone-light p-6"> <div className="bg-surface-primary rounded-lg border-2 border-desert-stone-light p-6">
<h3 className="text-xl font-semibold text-gray-900 mb-4">Wikipedia</h3> <h3 className="text-xl font-semibold text-text-primary mb-4">Wikipedia</h3>
{(() => { {(() => {
const option = wikipediaState?.options.find((o) => o.id === selectedWikipedia) const option = wikipediaState?.options.find((o) => o.id === selectedWikipedia)
return option ? ( return option ? (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center"> <div className="flex items-center">
<IconCheck size={20} className="text-desert-green mr-2" /> <IconCheck size={20} className="text-desert-green mr-2" />
<span className="text-gray-700">{option.name}</span> <span className="text-text-primary">{option.name}</span>
</div> </div>
<span className="text-gray-500 text-sm"> <span className="text-text-muted text-sm">
{option.size_mb > 0 {option.size_mb > 0
? `${(option.size_mb / 1024).toFixed(1)} GB` ? `${(option.size_mb / 1024).toFixed(1)} GB`
: 'No download'} : 'No download'}
@ -1087,8 +1087,8 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
)} )}
{selectedAiModels.length > 0 && ( {selectedAiModels.length > 0 && (
<div className="bg-white rounded-lg border-2 border-desert-stone-light p-6"> <div className="bg-surface-primary rounded-lg border-2 border-desert-stone-light p-6">
<h3 className="text-xl font-semibold text-gray-900 mb-4"> <h3 className="text-xl font-semibold text-text-primary mb-4">
AI Models to Download ({selectedAiModels.length}) AI Models to Download ({selectedAiModels.length})
</h3> </h3>
<ul className="space-y-2"> <ul className="space-y-2">
@ -1098,10 +1098,10 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
<li key={modelName} className="flex items-center justify-between"> <li key={modelName} className="flex items-center justify-between">
<div className="flex items-center"> <div className="flex items-center">
<IconCheck size={20} className="text-desert-green mr-2" /> <IconCheck size={20} className="text-desert-green mr-2" />
<span className="text-gray-700">{modelName}</span> <span className="text-text-primary">{modelName}</span>
</div> </div>
{model?.tags?.[0]?.size && ( {model?.tags?.[0]?.size && (
<span className="text-gray-500 text-sm">{model.tags[0].size}</span> <span className="text-text-muted text-sm">{model.tags[0].size}</span>
)} )}
</li> </li>
) )
@ -1135,7 +1135,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
/> />
)} )}
<div className="max-w-7xl mx-auto px-4 py-8"> <div className="max-w-7xl mx-auto px-4 py-8">
<div className="bg-white rounded-md shadow-md"> <div className="bg-surface-primary rounded-md shadow-md">
{renderStepIndicator()} {renderStepIndicator()}
{storageInfo && ( {storageInfo && (
<div className="px-6 pt-4"> <div className="px-6 pt-4">
@ -1165,7 +1165,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
</StyledButton> </StyledButton>
)} )}
<p className="text-sm text-gray-600"> <p className="text-sm text-text-secondary">
{(() => { {(() => {
const count = [...CORE_CAPABILITIES, ...ADDITIONAL_TOOLS].filter((cap) => const count = [...CORE_CAPABILITIES, ...ADDITIONAL_TOOLS].filter((cap) =>
cap.services.some((s) => selectedServices.includes(s)) cap.services.some((s) => selectedServices.includes(s))

View File

@ -161,7 +161,7 @@ export default function Home(props: {
return ( return (
<a key={item.label} href={item.to} target={item.target}> <a key={item.label} href={item.to} target={item.target}>
<div className="relative rounded border-desert-green border-2 bg-desert-green hover:bg-transparent hover:text-black text-white transition-colors shadow-sm h-48 flex flex-col items-center justify-center cursor-pointer text-center px-4"> <div className="relative rounded border-desert-green border-2 bg-desert-green hover:bg-transparent hover:text-text-primary text-white transition-colors shadow-sm h-48 flex flex-col items-center justify-center cursor-pointer text-center px-4">
{shouldHighlight && ( {shouldHighlight && (
<span className="absolute top-2 right-2 flex items-center justify-center"> <span className="absolute top-2 right-2 flex items-center justify-center">
<span <span

View File

@ -20,10 +20,10 @@ export default function Maps(props: {
<Head title="Maps" /> <Head title="Maps" />
<div className="relative w-full h-screen overflow-hidden"> <div className="relative w-full h-screen overflow-hidden">
{/* Nav and alerts are overlayed */} {/* Nav and alerts are overlayed */}
<div className="absolute top-0 left-0 right-0 z-50 flex justify-between p-4 bg-gray-50 backdrop-blur-sm shadow-sm"> <div className="absolute top-0 left-0 right-0 z-50 flex justify-between p-4 bg-surface-secondary backdrop-blur-sm shadow-sm">
<Link href="/home" className="flex items-center"> <Link href="/home" className="flex items-center">
<IconArrowLeft className="mr-2" size={24} /> <IconArrowLeft className="mr-2" size={24} />
<p className="text-lg text-gray-600">Back to Home</p> <p className="text-lg text-text-secondary">Back to Home</p>
</Link> </Link>
<Link href="/settings/maps" className='mr-4'> <Link href="/settings/maps" className='mr-4'>
<StyledButton variant="primary" icon="IconSettings"> <StyledButton variant="primary" icon="IconSettings">

View File

@ -90,7 +90,7 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
confirmVariant="primary" confirmVariant="primary"
icon={<IconDownload className="h-12 w-12 text-desert-green" />} icon={<IconDownload className="h-12 w-12 text-desert-green" />}
> >
<p className="text-gray-700"> <p className="text-text-primary">
Are you sure you want to install {service.friendly_name || service.service_name}? This Are you sure you want to install {service.friendly_name || service.service_name}? This
will start the service and make it available in your Project N.O.M.A.D. instance. It may will start the service and make it available in your Project N.O.M.A.D. instance. It may
take some time to complete. take some time to complete.
@ -214,7 +214,7 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
confirmText={'Force Reinstall'} confirmText={'Force Reinstall'}
cancelText="Cancel" cancelText="Cancel"
> >
<p className="text-gray-700"> <p className="text-text-primary">
Are you sure you want to force reinstall {record.service_name}? This will{' '} Are you sure you want to force reinstall {record.service_name}? This will{' '}
<strong>WIPE ALL DATA</strong> for this service and cannot be undone. You should <strong>WIPE ALL DATA</strong> for this service and cannot be undone. You should
only do this if the service is malfunctioning and other troubleshooting steps have only do this if the service is malfunctioning and other troubleshooting steps have
@ -285,7 +285,7 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
confirmText={record.status === 'running' ? 'Stop' : 'Start'} confirmText={record.status === 'running' ? 'Stop' : 'Start'}
cancelText="Cancel" cancelText="Cancel"
> >
<p className="text-gray-700"> <p className="text-text-primary">
Are you sure you want to {record.status === 'running' ? 'stop' : 'start'}{' '} Are you sure you want to {record.status === 'running' ? 'stop' : 'start'}{' '}
{record.service_name}? {record.service_name}?
</p> </p>
@ -311,7 +311,7 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
confirmText={'Restart'} confirmText={'Restart'}
cancelText="Cancel" cancelText="Cancel"
> >
<p className="text-gray-700"> <p className="text-text-primary">
Are you sure you want to restart {record.service_name}? Are you sure you want to restart {record.service_name}?
</p> </p>
</StyledModal>, </StyledModal>,
@ -338,7 +338,7 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div> <div>
<h1 className="text-4xl font-semibold">Apps</h1> <h1 className="text-4xl font-semibold">Apps</h1>
<p className="text-gray-500 mt-1"> <p className="text-text-muted mt-1">
Manage the applications that are available in your Project N.O.M.A.D. instance. Nightly update checks will automatically detect when new versions of these apps are available. Manage the applications that are available in your Project N.O.M.A.D. instance. Nightly update checks will automatically detect when new versions of these apps are available.
</p> </p>
</div> </div>
@ -364,7 +364,7 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
return ( return (
<div className="flex flex-col"> <div className="flex flex-col">
<p>{record.friendly_name || record.service_name}</p> <p>{record.friendly_name || record.service_name}</p>
<p className="text-sm text-gray-500">{record.description}</p> <p className="text-sm text-text-muted">{record.description}</p>
</div> </div>
) )
}, },
@ -398,7 +398,7 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
if (record.available_update_version) { if (record.available_update_version) {
return ( return (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="text-gray-500">{currentTag}</span> <span className="text-text-muted">{currentTag}</span>
<IconArrowUp className="h-4 w-4 text-desert-green" /> <IconArrowUp className="h-4 w-4 text-desert-green" />
<span className="text-desert-green font-semibold"> <span className="text-desert-green font-semibold">
{record.available_update_version} {record.available_update_version}
@ -406,7 +406,7 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
</div> </div>
) )
} }
return <span className="text-gray-600">{currentTag}</span> return <span className="text-text-secondary">{currentTag}</span>
}, },
}, },
{ {

View File

@ -12,16 +12,16 @@ export default function LegalPage() {
{/* License Agreement */} {/* License Agreement */}
<section className="mb-10"> <section className="mb-10">
<h2 className="text-2xl font-semibold mb-4">License Agreement</h2> <h2 className="text-2xl font-semibold mb-4">License Agreement</h2>
<p className="text-gray-700 mb-3">Copyright 2024-2026 Crosstalk Solutions, LLC</p> <p className="text-text-primary mb-3">Copyright 2024-2026 Crosstalk Solutions, LLC</p>
<p className="text-gray-700 mb-3"> <p className="text-text-primary mb-3">
Licensed under the Apache License, Version 2.0 (the &quot;License&quot;); Licensed under the Apache License, Version 2.0 (the &quot;License&quot;);
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
You may obtain a copy of the License at You may obtain a copy of the License at
</p> </p>
<p className="text-gray-700 mb-3"> <p className="text-text-primary mb-3">
<a href="https://www.apache.org/licenses/LICENSE-2.0" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">https://www.apache.org/licenses/LICENSE-2.0</a> <a href="https://www.apache.org/licenses/LICENSE-2.0" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">https://www.apache.org/licenses/LICENSE-2.0</a>
</p> </p>
<p className="text-gray-700"> <p className="text-text-primary">
Unless required by applicable law or agreed to in writing, software Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an &quot;AS IS&quot; BASIS, distributed under the License is distributed on an &quot;AS IS&quot; BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@ -33,11 +33,11 @@ export default function LegalPage() {
{/* Third-Party Software */} {/* Third-Party Software */}
<section className="mb-10"> <section className="mb-10">
<h2 className="text-2xl font-semibold mb-4">Third-Party Software Attribution</h2> <h2 className="text-2xl font-semibold mb-4">Third-Party Software Attribution</h2>
<p className="text-gray-700 mb-4"> <p className="text-text-primary mb-4">
Project N.O.M.A.D. integrates the following open source projects. We are grateful to Project N.O.M.A.D. integrates the following open source projects. We are grateful to
their developers and communities: their developers and communities:
</p> </p>
<ul className="space-y-3 text-gray-700"> <ul className="space-y-3 text-text-primary">
<li> <li>
<strong>Kiwix</strong> - Offline Wikipedia and content reader (GPL-3.0 License) <strong>Kiwix</strong> - Offline Wikipedia and content reader (GPL-3.0 License)
<br /> <br />
@ -74,10 +74,10 @@ export default function LegalPage() {
{/* Privacy Statement */} {/* Privacy Statement */}
<section className="mb-10"> <section className="mb-10">
<h2 className="text-2xl font-semibold mb-4">Privacy Statement</h2> <h2 className="text-2xl font-semibold mb-4">Privacy Statement</h2>
<p className="text-gray-700 mb-3"> <p className="text-text-primary mb-3">
Project N.O.M.A.D. is designed with privacy as a core principle: Project N.O.M.A.D. is designed with privacy as a core principle:
</p> </p>
<ul className="list-disc list-inside space-y-2 text-gray-700"> <ul className="list-disc list-inside space-y-2 text-text-primary">
<li><strong>Zero Telemetry:</strong> N.O.M.A.D. does not collect, transmit, or store any usage data, analytics, or telemetry.</li> <li><strong>Zero Telemetry:</strong> N.O.M.A.D. does not collect, transmit, or store any usage data, analytics, or telemetry.</li>
<li><strong>Local-First:</strong> All your data, downloaded content, AI conversations, and notes remain on your device.</li> <li><strong>Local-First:</strong> All your data, downloaded content, AI conversations, and notes remain on your device.</li>
<li><strong>No Accounts Required:</strong> N.O.M.A.D. operates without user accounts or authentication by default.</li> <li><strong>No Accounts Required:</strong> N.O.M.A.D. operates without user accounts or authentication by default.</li>
@ -88,17 +88,17 @@ export default function LegalPage() {
{/* Content Disclaimer */} {/* Content Disclaimer */}
<section className="mb-10"> <section className="mb-10">
<h2 className="text-2xl font-semibold mb-4">Content Disclaimer</h2> <h2 className="text-2xl font-semibold mb-4">Content Disclaimer</h2>
<p className="text-gray-700 mb-3"> <p className="text-text-primary mb-3">
Project N.O.M.A.D. provides tools to download and access content from third-party sources Project N.O.M.A.D. provides tools to download and access content from third-party sources
including Wikipedia, Wikibooks, medical references, educational platforms, and other including Wikipedia, Wikibooks, medical references, educational platforms, and other
publicly available resources. publicly available resources.
</p> </p>
<p className="text-gray-700 mb-3"> <p className="text-text-primary mb-3">
Crosstalk Solutions, LLC does not create, control, verify, or guarantee the accuracy, Crosstalk Solutions, LLC does not create, control, verify, or guarantee the accuracy,
completeness, or reliability of any third-party content. The inclusion of any content completeness, or reliability of any third-party content. The inclusion of any content
does not constitute an endorsement. does not constitute an endorsement.
</p> </p>
<p className="text-gray-700"> <p className="text-text-primary">
Users are responsible for evaluating the appropriateness and accuracy of any content Users are responsible for evaluating the appropriateness and accuracy of any content
they download and use. they download and use.
</p> </p>
@ -107,15 +107,15 @@ export default function LegalPage() {
{/* Medical Disclaimer */} {/* Medical Disclaimer */}
<section className="mb-10"> <section className="mb-10">
<h2 className="text-2xl font-semibold mb-4">Medical and Emergency Information Disclaimer</h2> <h2 className="text-2xl font-semibold mb-4">Medical and Emergency Information Disclaimer</h2>
<p className="text-gray-700 mb-3"> <p className="text-text-primary mb-3">
Some content available through N.O.M.A.D. includes medical references, first aid guides, Some content available through N.O.M.A.D. includes medical references, first aid guides,
and emergency preparedness information. This content is provided for general and emergency preparedness information. This content is provided for general
informational purposes only. informational purposes only.
</p> </p>
<p className="text-gray-700 mb-3 font-semibold"> <p className="text-text-primary mb-3 font-semibold">
This information is NOT a substitute for professional medical advice, diagnosis, or treatment. This information is NOT a substitute for professional medical advice, diagnosis, or treatment.
</p> </p>
<ul className="list-disc list-inside space-y-2 text-gray-700 mb-3"> <ul className="list-disc list-inside space-y-2 text-text-primary mb-3">
<li>Always seek the advice of qualified health providers with questions about medical conditions.</li> <li>Always seek the advice of qualified health providers with questions about medical conditions.</li>
<li>Never disregard professional medical advice or delay seeking it because of something you read in offline content.</li> <li>Never disregard professional medical advice or delay seeking it because of something you read in offline content.</li>
<li>In a medical emergency, call emergency services immediately if available.</li> <li>In a medical emergency, call emergency services immediately if available.</li>
@ -126,15 +126,15 @@ export default function LegalPage() {
{/* Data Storage Notice */} {/* Data Storage Notice */}
<section className="mb-10"> <section className="mb-10">
<h2 className="text-2xl font-semibold mb-4">Data Storage</h2> <h2 className="text-2xl font-semibold mb-4">Data Storage</h2>
<p className="text-gray-700 mb-3"> <p className="text-text-primary mb-3">
All data associated with Project N.O.M.A.D. is stored locally on your device: All data associated with Project N.O.M.A.D. is stored locally on your device:
</p> </p>
<ul className="list-disc list-inside space-y-2 text-gray-700"> <ul className="list-disc list-inside space-y-2 text-text-primary">
<li><strong>Installation Directory:</strong> /opt/project-nomad</li> <li><strong>Installation Directory:</strong> /opt/project-nomad</li>
<li><strong>Downloaded Content:</strong> /opt/project-nomad/storage</li> <li><strong>Downloaded Content:</strong> /opt/project-nomad/storage</li>
<li><strong>Application Data:</strong> Stored in Docker volumes on your local system</li> <li><strong>Application Data:</strong> Stored in Docker volumes on your local system</li>
</ul> </ul>
<p className="text-gray-700 mt-3"> <p className="text-text-primary mt-3">
You maintain full control over your data. Uninstalling N.O.M.A.D. or deleting these You maintain full control over your data. Uninstalling N.O.M.A.D. or deleting these
directories will permanently remove all associated data. directories will permanently remove all associated data.
</p> </p>

View File

@ -104,7 +104,7 @@ export default function MapsManager(props: {
cancelText="Cancel" cancelText="Cancel"
confirmVariant="danger" confirmVariant="danger"
> >
<p className="text-gray-700"> <p className="text-text-secondary">
Are you sure you want to delete {file.name}? This action cannot be undone. Are you sure you want to delete {file.name}? This action cannot be undone.
</p> </p>
</StyledModal>, </StyledModal>,
@ -136,7 +136,7 @@ export default function MapsManager(props: {
cancelText="Cancel" cancelText="Cancel"
confirmVariant="primary" confirmVariant="primary"
> >
<p className="text-gray-700"> <p className="text-text-secondary">
Are you sure you want to download <strong>{isCollection ? record.name : record}</strong>? Are you sure you want to download <strong>{isCollection ? record.name : record}</strong>?
It may take some time for it to be available depending on the file size and your internet It may take some time for it to be available depending on the file size and your internet
connection. connection.
@ -180,7 +180,7 @@ export default function MapsManager(props: {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex flex-col"> <div className="flex flex-col">
<h1 className="text-4xl font-semibold mb-2">Maps Manager</h1> <h1 className="text-4xl font-semibold mb-2">Maps Manager</h1>
<p className="text-gray-500">Manage your stored map files and explore new regions!</p> <p className="text-text-muted">Manage your stored map files and explore new regions!</p>
</div> </div>
<div className="flex space-x-4"> <div className="flex space-x-4">
@ -220,7 +220,7 @@ export default function MapsManager(props: {
/> />
))} ))}
{curatedCollections && curatedCollections.length === 0 && ( {curatedCollections && curatedCollections.length === 0 && (
<p className="text-gray-500">No curated collections available.</p> <p className="text-text-muted">No curated collections available.</p>
)} )}
</div> </div>
<div className="mt-12 mb-6 flex items-center justify-between"> <div className="mt-12 mb-6 flex items-center justify-between">

View File

@ -82,7 +82,7 @@ export default function ModelsPage(props: {
confirmText="Reinstall" confirmText="Reinstall"
cancelText="Cancel" cancelText="Cancel"
> >
<p className="text-gray-700"> <p className="text-text-primary">
This will recreate the {aiAssistantName} container with GPU support enabled. This will recreate the {aiAssistantName} container with GPU support enabled.
Your downloaded models will be preserved. The service will be briefly Your downloaded models will be preserved. The service will be briefly
unavailable during reinstall. unavailable during reinstall.
@ -190,7 +190,7 @@ export default function ModelsPage(props: {
cancelText="Cancel" cancelText="Cancel"
confirmVariant="primary" confirmVariant="primary"
> >
<p className="text-gray-700"> <p className="text-text-primary">
Are you sure you want to delete this model? You will need to download it again if you want Are you sure you want to delete this model? You will need to download it again if you want
to use it in the future. to use it in the future.
</p> </p>
@ -224,7 +224,7 @@ export default function ModelsPage(props: {
<div className="xl:pl-72 w-full"> <div className="xl:pl-72 w-full">
<main className="px-12 py-6"> <main className="px-12 py-6">
<h1 className="text-4xl font-semibold mb-4">{aiAssistantName}</h1> <h1 className="text-4xl font-semibold mb-4">{aiAssistantName}</h1>
<p className="text-gray-500 mb-4"> <p className="text-text-muted mb-4">
Easily manage the {aiAssistantName}'s settings and installed models. We recommend Easily manage the {aiAssistantName}'s settings and installed models. We recommend
starting with smaller models first to see how they perform on your system before moving starting with smaller models first to see how they perform on your system before moving
on to larger ones. on to larger ones.
@ -259,7 +259,7 @@ export default function ModelsPage(props: {
)} )}
<StyledSectionHeader title="Settings" className="mt-8 mb-4" /> <StyledSectionHeader title="Settings" className="mt-8 mb-4" />
<div className="bg-white rounded-lg border-2 border-gray-200 p-6"> <div className="bg-surface-primary rounded-lg border-2 border-border-subtle p-6">
<div className="space-y-4"> <div className="space-y-4">
<Switch <Switch
checked={chatSuggestionsEnabled} checked={chatSuggestionsEnabled}
@ -300,7 +300,7 @@ export default function ModelsPage(props: {
debouncedSetQuery(e.target.value) debouncedSetQuery(e.target.value)
}} }}
className="w-1/3" className="w-1/3"
leftIcon={<IconSearch className="w-5 h-5 text-gray-400" />} leftIcon={<IconSearch className="w-5 h-5 text-text-muted" />}
/> />
<StyledButton <StyledButton
variant="secondary" variant="secondary"
@ -323,7 +323,7 @@ export default function ModelsPage(props: {
return ( return (
<div className="flex flex-col"> <div className="flex flex-col">
<p className="text-lg font-semibold">{record.name}</p> <p className="text-lg font-semibold">{record.name}</p>
<p className="text-sm text-gray-500">{record.description}</p> <p className="text-sm text-text-muted">{record.description}</p>
</div> </div>
) )
}, },
@ -342,49 +342,49 @@ export default function ModelsPage(props: {
expandable={{ expandable={{
expandedRowRender: (record) => ( expandedRowRender: (record) => (
<div className="pl-14"> <div className="pl-14">
<div className="bg-white overflow-hidden"> <div className="bg-surface-primary overflow-hidden">
<table className="min-w-full divide-y divide-gray-200"> <table className="min-w-full divide-y divide-border-subtle">
<thead className="bg-white"> <thead className="bg-surface-primary">
<tr> <tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
Tag Tag
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
Input Type Input Type
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
Context Size Context Size
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
Model Size Model Size
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
Action Action
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody className="bg-white divide-y divide-gray-200"> <tbody className="bg-surface-primary divide-y divide-border-subtle">
{record.tags.map((tag, tagIndex) => { {record.tags.map((tag, tagIndex) => {
const isInstalled = props.models.installedModels.some( const isInstalled = props.models.installedModels.some(
(mod) => mod.name === tag.name (mod) => mod.name === tag.name
) )
return ( return (
<tr key={tagIndex} className="hover:bg-slate-50"> <tr key={tagIndex} className="hover:bg-surface-secondary">
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm font-medium text-gray-900"> <span className="text-sm font-medium text-text-primary">
{tag.name} {tag.name}
</span> </span>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm text-gray-600">{tag.input || 'N/A'}</span> <span className="text-sm text-text-secondary">{tag.input || 'N/A'}</span>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm text-gray-600"> <span className="text-sm text-text-secondary">
{tag.context || 'N/A'} {tag.context || 'N/A'}
</span> </span>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm text-gray-600">{tag.size || 'N/A'}</span> <span className="text-sm text-text-secondary">{tag.size || 'N/A'}</span>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<StyledButton <StyledButton

View File

@ -71,7 +71,7 @@ export default function SettingsPage(props: {
confirmText="Reinstall" confirmText="Reinstall"
cancelText="Cancel" cancelText="Cancel"
> >
<p className="text-gray-700"> <p className="text-text-primary">
This will recreate the AI Assistant container with GPU support enabled. This will recreate the AI Assistant container with GPU support enabled.
Your downloaded models will be preserved. The service will be briefly Your downloaded models will be preserved. The service will be briefly
unavailable during reinstall. unavailable during reinstall.
@ -313,7 +313,7 @@ export default function SettingsPage(props: {
style={{ width: `${memoryUsagePercent}%` }} style={{ width: `${memoryUsagePercent}%` }}
></div> ></div>
<div className="absolute inset-0 flex items-center justify-center"> <div className="absolute inset-0 flex items-center justify-center">
<span className="text-sm font-bold text-desert-white drop-shadow-md z-10"> <span className="text-sm font-bold text-white drop-shadow-md z-10">
{memoryUsagePercent}% Utilized {memoryUsagePercent}% Utilized
</span> </span>
</div> </div>

View File

@ -107,7 +107,7 @@ function ContentUpdatesSection() {
<div className="mt-8"> <div className="mt-8">
<StyledSectionHeader title="Content Updates" /> <StyledSectionHeader title="Content Updates" />
<div className="bg-white rounded-lg border shadow-md overflow-hidden p-6"> <div className="bg-surface-primary rounded-lg border shadow-md overflow-hidden p-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-desert-stone-dark"> <p className="text-desert-stone-dark">
Check if newer versions of your installed ZIM files and maps are available. Check if newer versions of your installed ZIM files and maps are available.
@ -431,7 +431,7 @@ export default function SystemUpdatePage(props: { system: Props }) {
/> />
</div> </div>
)} )}
<div className="bg-white rounded-lg border shadow-md overflow-hidden"> <div className="bg-surface-primary rounded-lg border shadow-md overflow-hidden">
<div className="p-8 text-center"> <div className="p-8 text-center">
<div className="flex justify-center mb-4">{getStatusIcon()}</div> <div className="flex justify-center mb-4">{getStatusIcon()}</div>
@ -526,7 +526,7 @@ export default function SystemUpdatePage(props: { system: Props }) {
</div> </div>
)} )}
</div> </div>
<div className="border-t bg-white p-6"> <div className="border-t bg-surface-primary p-6">
<h3 className="text-lg font-semibold text-desert-green mb-4"> <h3 className="text-lg font-semibold text-desert-green mb-4">
What happens during an update? What happens during an update?
</h3> </h3>
@ -596,7 +596,7 @@ export default function SystemUpdatePage(props: { system: Props }) {
/> />
</div> </div>
<StyledSectionHeader title="Early Access" className="mt-8" /> <StyledSectionHeader title="Early Access" className="mt-8" />
<div className="bg-white rounded-lg border shadow-md overflow-hidden mt-6 p-6"> <div className="bg-surface-primary rounded-lg border shadow-md overflow-hidden mt-6 p-6">
<Switch <Switch
checked={earlyAccessSetting.data?.value || false} checked={earlyAccessSetting.data?.value || false}
onChange={(newVal) => { onChange={(newVal) => {
@ -608,7 +608,7 @@ export default function SystemUpdatePage(props: { system: Props }) {
/> />
</div> </div>
<ContentUpdatesSection /> <ContentUpdatesSection />
<div className="bg-white rounded-lg border shadow-md overflow-hidden py-6 mt-12"> <div className="bg-surface-primary rounded-lg border shadow-md overflow-hidden py-6 mt-12">
<div className="flex flex-col md:flex-row justify-between items-center p-8 gap-y-8 md:gap-y-0 gap-x-8"> <div className="flex flex-col md:flex-row justify-between items-center p-8 gap-y-8 md:gap-y-0 gap-x-8">
<div> <div>
<h2 className="max-w-xl text-lg font-bold text-desert-green sm:text-xl lg:col-span-7"> <h2 className="max-w-xl text-lg font-bold text-desert-green sm:text-xl lg:col-span-7">
@ -648,7 +648,7 @@ export default function SystemUpdatePage(props: { system: Props }) {
{showLogs && ( {showLogs && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"> <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-lg shadow-2xl max-w-4xl w-full max-h-[80vh] flex flex-col"> <div className="bg-surface-primary rounded-lg shadow-2xl max-w-4xl w-full max-h-[80vh] flex flex-col">
<div className="p-6 border-b border-desert-stone-light flex justify-between items-center"> <div className="p-6 border-b border-desert-stone-light flex justify-between items-center">
<h3 className="text-xl font-bold text-desert-green">Update Logs</h3> <h3 className="text-xl font-bold text-desert-green">Update Logs</h3>
<button <button

View File

@ -39,7 +39,7 @@ export default function ZimPage() {
cancelText="Cancel" cancelText="Cancel"
confirmVariant="danger" confirmVariant="danger"
> >
<p className="text-gray-700"> <p className="text-text-secondary">
Are you sure you want to delete {file.name}? This action cannot be undone. Are you sure you want to delete {file.name}? This action cannot be undone.
</p> </p>
</StyledModal>, </StyledModal>,
@ -62,7 +62,7 @@ export default function ZimPage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex flex-col"> <div className="flex flex-col">
<h1 className="text-4xl font-semibold mb-2">Content Manager</h1> <h1 className="text-4xl font-semibold mb-2">Content Manager</h1>
<p className="text-gray-500"> <p className="text-text-muted">
Manage your stored content files. Manage your stored content files.
</p> </p>
</div> </div>
@ -94,7 +94,7 @@ export default function ZimPage() {
accessor: 'summary', accessor: 'summary',
title: 'Summary', title: 'Summary',
render: (record) => ( render: (record) => (
<span className="text-gray-600 text-sm line-clamp-2"> <span className="text-text-secondary text-sm line-clamp-2">
{record.summary || '—'} {record.summary || '—'}
</span> </span>
), ),

View File

@ -159,7 +159,7 @@ export default function ZimRemoteExplorer() {
cancelText="Cancel" cancelText="Cancel"
confirmVariant="primary" confirmVariant="primary"
> >
<p className="text-gray-700"> <p className="text-text-primary">
Are you sure you want to download{' '} Are you sure you want to download{' '}
<strong>{record.title}</strong>? It may take some time for it <strong>{record.title}</strong>? It may take some time for it
to be available depending on the file size and your internet connection. The Kiwix to be available depending on the file size and your internet connection. The Kiwix
@ -277,7 +277,7 @@ export default function ZimRemoteExplorer() {
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div className="flex flex-col"> <div className="flex flex-col">
<h1 className="text-4xl font-semibold mb-2">Content Explorer</h1> <h1 className="text-4xl font-semibold mb-2">Content Explorer</h1>
<p className="text-gray-500">Browse and download content for offline reading!</p> <p className="text-text-muted">Browse and download content for offline reading!</p>
</div> </div>
</div> </div>
{!isOnline && ( {!isOnline && (
@ -310,13 +310,13 @@ export default function ZimRemoteExplorer() {
{/* Wikipedia Selector */} {/* Wikipedia Selector */}
{isLoadingWikipedia ? ( {isLoadingWikipedia ? (
<div className="mt-8 bg-white rounded-lg border border-gray-200 p-6"> <div className="mt-8 bg-surface-primary rounded-lg border border-border-subtle p-6">
<div className="flex justify-center py-6"> <div className="flex justify-center py-6">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-desert-green"></div> <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-desert-green"></div>
</div> </div>
</div> </div>
) : wikipediaState && wikipediaState.options.length > 0 ? ( ) : wikipediaState && wikipediaState.options.length > 0 ? (
<div className="mt-8 bg-white rounded-lg border border-gray-200 p-6"> <div className="mt-8 bg-surface-primary rounded-lg border border-border-subtle p-6">
<WikipediaSelector <WikipediaSelector
options={wikipediaState.options} options={wikipediaState.options}
currentSelection={wikipediaState.currentSelection} currentSelection={wikipediaState.currentSelection}
@ -332,12 +332,12 @@ export default function ZimRemoteExplorer() {
{/* Tiered Category Collections */} {/* Tiered Category Collections */}
<div className="flex items-center gap-3 mt-8 mb-4"> <div className="flex items-center gap-3 mt-8 mb-4">
<div className="w-10 h-10 rounded-full bg-white border border-gray-200 flex items-center justify-center shadow-sm"> <div className="w-10 h-10 rounded-full bg-surface-primary border border-border-subtle flex items-center justify-center shadow-sm">
<IconBooks className="w-6 h-6 text-gray-700" /> <IconBooks className="w-6 h-6 text-text-primary" />
</div> </div>
<div> <div>
<h3 className="text-xl font-semibold text-gray-900">Additional Content</h3> <h3 className="text-xl font-semibold text-text-primary">Additional Content</h3>
<p className="text-sm text-gray-500">Curated collections for offline reference</p> <p className="text-sm text-text-muted">Curated collections for offline reference</p>
</div> </div>
</div> </div>
{categories && categories.length > 0 ? ( {categories && categories.length > 0 ? (
@ -363,7 +363,7 @@ export default function ZimRemoteExplorer() {
/> />
</> </>
) : ( ) : (
<p className="text-gray-500 mt-4">No curated content categories available.</p> <p className="text-text-muted mt-4">No curated content categories available.</p>
)} )}
<StyledSectionHeader title="Browse the Kiwix Library" className="mt-12 mb-4" /> <StyledSectionHeader title="Browse the Kiwix Library" className="mt-12 mb-4" />
<div className="flex justify-start mt-4"> <div className="flex justify-start mt-4">
@ -377,7 +377,7 @@ export default function ZimRemoteExplorer() {
debouncedSetQuery(e.target.value) debouncedSetQuery(e.target.value)
}} }}
className="w-1/3" className="w-1/3"
leftIcon={<IconSearch className="w-5 h-5 text-gray-400" />} leftIcon={<IconSearch className="w-5 h-5 text-text-muted" />}
/> />
</div> </div>
<StyledTable<RemoteZimFileEntry & { actions?: any }> <StyledTable<RemoteZimFileEntry & { actions?: any }>

View File

@ -61,7 +61,7 @@ const NotificationsProvider = ({ children }: { children: React.ReactNode }) => {
{notifications.map((notification) => ( {notifications.map((notification) => (
<div <div
key={notification.id} key={notification.id}
className={`mb-4 p-4 rounded shadow-md border border-slate-300 bg-white max-w-96`} className={`mb-4 p-4 rounded shadow-md border border-border-default bg-surface-primary max-w-96`}
onClick={() => removeNotification(notification.id)} onClick={() => removeNotification(notification.id)}
> >
<div className="flex flex-row items-start gap-3"> <div className="flex flex-row items-start gap-3">

View File

@ -0,0 +1,27 @@
import { createContext, useContext } from 'react'
import { useTheme, Theme } from '~/hooks/useTheme'
interface ThemeContextType {
theme: Theme
setTheme: (theme: Theme) => void
toggleTheme: () => void
}
const ThemeContext = createContext<ThemeContextType>({
theme: 'light',
setTheme: () => {},
toggleTheme: () => {},
})
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const themeState = useTheme()
return (
<ThemeContext.Provider value={themeState}>
{children}
</ThemeContext.Provider>
)
}
export function useThemeContext() {
return useContext(ThemeContext)
}

View File

@ -11,6 +11,17 @@
<title inertia>Project N.O.M.A.D</title> <title inertia>Project N.O.M.A.D</title>
<script>
(function() {
try {
var theme = localStorage.getItem('nomad:theme');
if (theme === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
}
} catch(e) {}
})();
</script>
@stack('dumper') @stack('dumper')
@viteReactRefresh() @viteReactRefresh()
@inertiaHead() @inertiaHead()

View File

@ -7,6 +7,7 @@ export const KV_STORE_SCHEMA = {
'system.latestVersion': 'string', 'system.latestVersion': 'string',
'system.earlyAccess': 'boolean', 'system.earlyAccess': 'boolean',
'ui.hasVisitedEasySetup': 'boolean', 'ui.hasVisitedEasySetup': 'boolean',
'ui.theme': 'string',
'ai.assistantCustomName': 'string', 'ai.assistantCustomName': 'string',
} as const } as const