Build optimization: pre-built PM3 binaries, ARM64 CI, base image caching

Replace PM3 compile-from-source in pi-gen with pre-built tarball extraction
(saves 43-58 min). Merge stagePM3 into stageDangerousPi as 02-pm3-install
substage, renumber all subsequent substages. Switch CI PM3 build to native
ARM64 runner (ubuntu-24.04-arm64) eliminating QEMU overhead. Add weekly
base-image workflow for pre-baking stages 0-2. Support PM3_TARBALL,
BASE_IMAGE, and APT_PROXY env vars in build-image.sh.

Also includes prior Phase 5 work: theme system, design system integration,
component update system, OS updates, CI build pipeline, and test results.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
michael
2026-03-04 12:01:01 -08:00
parent 2ec89041ef
commit a9acdb85ce
163 changed files with 8124 additions and 921 deletions

View File

@@ -33,7 +33,7 @@ export default function ConnectDialog({ network, onClose, isSubmitting }: Connec
left: 0,
right: 0,
bottom: 0,
background: "rgba(10, 14, 26, 0.9)",
background: "rgba(var(--color-bg-rgb), 0.9)",
backdropFilter: "blur(8px)",
display: "flex",
alignItems: "center",

View File

@@ -279,7 +279,7 @@ export default function DeviceSelector({
style={{
marginBottom: "var(--space-3)",
padding: "var(--space-3)",
background: "rgba(239, 68, 68, 0.1)",
background: "rgba(var(--color-error-rgb), 0.1)",
borderLeft: "3px solid var(--color-error)",
borderRadius: "var(--radius)",
}}
@@ -318,7 +318,7 @@ export default function DeviceSelector({
cursor: isSelectable && !isFlashing ? "pointer" : "not-allowed",
opacity: isSelectable || isFlashing ? 1 : 0.6,
transition: "all 150ms ease",
background: isSelected ? "rgba(37, 99, 235, 0.05)" : "transparent",
background: isSelected ? "rgba(var(--color-primary-rgb), 0.05)" : "transparent",
position: "relative",
}}
onClick={() => {
@@ -336,7 +336,7 @@ export default function DeviceSelector({
left: 0,
right: 0,
bottom: 0,
background: "rgba(0, 0, 0, 0.8)",
background: "rgba(var(--color-bg-rgb), 0.8)",
borderRadius: "var(--radius)",
display: "flex",
flexDirection: "column",
@@ -442,7 +442,7 @@ export default function DeviceSelector({
style={{
marginTop: "var(--space-2)",
padding: "var(--space-2)",
background: "rgba(217, 119, 6, 0.1)",
background: "rgba(var(--color-warning-rgb), 0.1)",
borderLeft: "3px solid var(--color-warning)",
borderRadius: "var(--radius)",
fontSize: "0.75rem",
@@ -538,7 +538,7 @@ export default function DeviceSelector({
left: 0,
right: 0,
bottom: 0,
background: "rgba(0, 0, 0, 0.75)",
background: "rgba(var(--color-bg-rgb), 0.75)",
display: "flex",
justifyContent: "center",
alignItems: "center",
@@ -600,7 +600,7 @@ export default function DeviceSelector({
<div
style={{
background: "rgba(239, 68, 68, 0.1)",
background: "rgba(var(--color-error-rgb), 0.1)",
borderLeft: "3px solid var(--color-error)",
borderRadius: "var(--radius)",
padding: "var(--space-3)",

View File

@@ -30,7 +30,7 @@ export function LoginPrompt() {
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(0, 0, 0, 0.75)",
background: "rgba(var(--color-bg-rgb), 0.75)",
backdropFilter: "blur(4px)",
}}
>

View File

@@ -155,7 +155,7 @@ export default function QRScanner({ onScan, onClose, onError }: QRScannerProps)
left: 0,
right: 0,
bottom: 0,
background: "rgba(10, 14, 26, 0.95)",
background: "rgba(var(--color-bg-rgb), 0.95)",
backdropFilter: "blur(8px)",
display: "flex",
flexDirection: "column",
@@ -203,7 +203,7 @@ export default function QRScanner({ onScan, onClose, onError }: QRScannerProps)
<div
style={{
padding: "var(--space-3)",
background: "rgba(255, 107, 107, 0.1)",
background: "rgba(var(--color-error-rgb), 0.1)",
border: "1px solid var(--color-error)",
borderRadius: "var(--radius)",
marginBottom: "var(--space-4)",

View File

@@ -0,0 +1,155 @@
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from "react";
/* Theme definitions — mirrors @dangerousthings/tokens types */
export interface ThemeDefinition {
id: string;
name: string;
description: string;
supportsModes: ThemeMode[];
defaultMode: ThemeMode;
author?: string;
css_url?: string;
source?: "builtin" | "plugin";
}
export type ThemeBrand = string; // dynamic — supports plugin themes beyond dt/classic/supra
export type ThemeMode = "dark" | "light" | "auto";
/** Hardcoded fallback themes for SSR / offline / initial render */
export const builtinThemes: ThemeDefinition[] = [
{
id: "dt",
name: "Dangerous Things",
description: "Official DT brand \u2014 Tektur font, neon cyberpunk, beveled corners",
supportsModes: ["dark", "light", "auto"],
defaultMode: "dark",
},
{
id: "classic",
name: "Classic Cyberpunk",
description: "Original dark navy aesthetic with magenta accents",
supportsModes: ["dark", "light", "auto"],
defaultMode: "dark",
},
{
id: "supra",
name: "VivoKey Supra",
description: "Material Design 3 with VivoKey blue, rounded corners, elevation shadows",
supportsModes: ["dark", "light", "auto"],
defaultMode: "dark",
},
];
/** @deprecated Use builtinThemes instead */
export const themes = builtinThemes;
export const DEFAULT_THEME: ThemeBrand = "dt";
const BRAND_STORAGE_KEY = "dpi_theme_brand";
const MODE_STORAGE_KEY = "theme"; // backward compatible with existing key
interface ThemeContextValue {
brand: ThemeBrand;
mode: ThemeMode;
setBrand: (brand: ThemeBrand) => void;
setMode: (mode: ThemeMode) => void;
themeDefinition: ThemeDefinition;
availableThemes: ThemeDefinition[];
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
/**
* Inject or update a <link> element for a theme's dynamic token CSS.
* Identified by a fixed id so it can be swapped in place.
*/
function setTokenStylesheet(url: string | undefined) {
if (typeof document === "undefined") return;
const LINK_ID = "dp-theme-tokens";
let link = document.getElementById(LINK_ID) as HTMLLinkElement | null;
if (!url) {
link?.remove();
return;
}
if (link) {
if (link.getAttribute("href") !== url) {
link.setAttribute("href", url);
}
} else {
link = document.createElement("link");
link.id = LINK_ID;
link.rel = "stylesheet";
link.href = url;
document.head.appendChild(link);
}
}
export function ThemeProvider({ children }: { children: ReactNode }) {
const [brand, setBrandState] = useState<ThemeBrand>(() => {
if (typeof window === "undefined") return DEFAULT_THEME;
return localStorage.getItem(BRAND_STORAGE_KEY) || DEFAULT_THEME;
});
const [mode, setModeState] = useState<ThemeMode>(() => {
if (typeof window === "undefined") return "dark";
return (localStorage.getItem(MODE_STORAGE_KEY) as ThemeMode) || "dark";
});
const [availableThemes, setAvailableThemes] = useState<ThemeDefinition[]>(builtinThemes);
// Fetch dynamic theme list from API on mount
useEffect(() => {
let cancelled = false;
(async () => {
try {
const resp = await fetch("/api/system/themes");
if (!resp.ok) return;
const data = await resp.json();
if (!cancelled && Array.isArray(data.themes) && data.themes.length > 0) {
setAvailableThemes(data.themes);
}
} catch {
// API unavailable — keep fallback themes
}
})();
return () => { cancelled = true; };
}, []);
const setBrand = useCallback((newBrand: ThemeBrand) => {
setBrandState(newBrand);
localStorage.setItem(BRAND_STORAGE_KEY, newBrand);
document.documentElement.setAttribute("data-brand", newBrand);
}, []);
const setMode = useCallback((newMode: ThemeMode) => {
setModeState(newMode);
localStorage.setItem(MODE_STORAGE_KEY, newMode);
document.documentElement.setAttribute("data-theme", newMode);
}, []);
// Set DOM attributes + inject token stylesheet when brand/mode/themes change
useEffect(() => {
document.documentElement.setAttribute("data-brand", brand);
document.documentElement.setAttribute("data-theme", mode);
const def = availableThemes.find((t) => t.id === brand);
setTokenStylesheet(def?.css_url);
}, [brand, mode, availableThemes]);
const themeDefinition = availableThemes.find((t) => t.id === brand) || availableThemes[0] || builtinThemes[0];
return (
<ThemeContext.Provider value={{ brand, mode, setBrand, setMode, themeDefinition, availableThemes }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useTheme must be used within ThemeProvider");
return ctx;
}

View File

@@ -7,27 +7,40 @@ import {
ScrollRestoration,
useLocation,
} from "@remix-run/react";
import { useState, useEffect } from "react";
import styles from "./styles.css?url";
import baseStyles from "./styles/base.css?url";
import themeStyles from "@dangerousthings/web/dist/index.css?url";
import { PowerWidget } from "./components/PowerWidget";
import { HeaderWidgets } from "./components/HeaderWidgets";
import { LoginPrompt } from "./components/LoginPrompt";
import { useWebSocketState } from "./hooks/useWebSocket";
import { ThemeProvider, useTheme } from "./lib/ThemeContext";
import type { ThemeMode } from "./lib/ThemeContext";
export const links: LinksFunction = () => [
{ rel: "stylesheet", href: styles },
{ rel: "stylesheet", href: baseStyles },
{ rel: "stylesheet", href: themeStyles },
{ rel: "preload", href: "/fonts/Tektur-VariableFont_wdth,wght.ttf", as: "font", type: "font/ttf", crossOrigin: "anonymous" },
];
/**
* FOUC prevention: static inline script reads localStorage and sets
* data-brand/data-theme before first paint. This is a hardcoded string
* constant (no user input), so it is safe from XSS.
*/
const FOUC_PREVENTION_SCRIPT = `(function(){try{var b=localStorage.getItem('dpi_theme_brand')||'dt';var m=localStorage.getItem('theme')||'dark';document.documentElement.setAttribute('data-brand',b);document.documentElement.setAttribute('data-theme',m);}catch(e){}})();`;
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<html lang="en" data-brand="dt" data-theme="dark">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5" />
<meta name="theme-color" content="#0a0e1a" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Dangerous Pi - Proxmark3 Management Interface" />
<Meta />
<Links />
{/* Prevent FOUC: hardcoded constant string, no user input — safe from XSS */}
<script dangerouslySetInnerHTML={{ __html: FOUC_PREVENTION_SCRIPT }} />
</head>
<body>
{children}
@@ -39,40 +52,30 @@ export function Layout({ children }: { children: React.ReactNode }) {
}
export default function App() {
return (
<ThemeProvider>
<AppInner />
</ThemeProvider>
);
}
function AppInner() {
const location = useLocation();
const wsState = useWebSocketState();
const [theme, setTheme] = useState<"auto" | "dark" | "light">("dark");
const { mode, setMode } = useTheme();
// Initialize theme (default to dark, honor system preference if available)
useEffect(() => {
const savedTheme = localStorage.getItem("theme") as "auto" | "dark" | "light" | null;
if (savedTheme) {
setTheme(savedTheme);
document.documentElement.setAttribute("data-theme", savedTheme);
} else {
// Default to dark for Dangerous Things cyberpunk aesthetic
setTheme("dark");
document.documentElement.setAttribute("data-theme", "dark");
}
}, []);
const toggleTheme = () => {
const themes: Array<"auto" | "dark" | "light"> = ["dark", "auto", "light"];
const currentIndex = themes.indexOf(theme);
const nextTheme = themes[(currentIndex + 1) % themes.length];
setTheme(nextTheme);
localStorage.setItem("theme", nextTheme);
document.documentElement.setAttribute("data-theme", nextTheme);
const toggleMode = () => {
const modes: ThemeMode[] = ["dark", "auto", "light"];
const currentIndex = modes.indexOf(mode);
setMode(modes[(currentIndex + 1) % modes.length]);
};
const navLinks = [
{ to: "/", label: "Dashboard", icon: "" },
{ to: "/commands", label: "Commands", icon: "" },
{ to: "/settings", label: "Settings", icon: "" },
{ to: "/updates", label: "Updates", icon: "🔄" },
{ to: "/logs", label: "Logs", icon: "" },
{ to: "/", label: "Dashboard", icon: "\u25C8" },
{ to: "/commands", label: "Commands", icon: "\u25B6" },
{ to: "/settings", label: "Settings", icon: "\u2699" },
{ to: "/updates", label: "Updates", icon: "\uD83D\uDD04" },
{ to: "/logs", label: "Logs", icon: "\u2261" },
];
return (
@@ -87,13 +90,13 @@ export default function App() {
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
<PowerWidget />
<button
onClick={toggleTheme}
onClick={toggleMode}
className="btn-secondary"
style={{ minWidth: "44px", padding: "0.5rem" }}
aria-label="Toggle theme"
title={`Current theme: ${theme}`}
aria-label="Toggle theme mode"
title={`Current mode: ${mode}`}
>
{theme === "dark" ? "" : theme === "light" ? "" : ""}
{mode === "dark" ? "\u25D0" : mode === "light" ? "\u25CB" : "\u25D1"}
</button>
</div>
</div>

View File

@@ -353,7 +353,7 @@ export default function Dashboard() {
<strong style={{ color: "var(--color-primary)" }}>Dangerous Pi</strong> v{data.health.version}
</p>
<p style={{ fontSize: "0.875rem", marginTop: "var(--space-2)" }}>
Built for <a href="https://dangerousthings.com" target="_blank" rel="noopener noreferrer">Dangerous Things</a>
Built by <a href="https://dangerousthings.com" target="_blank" rel="noopener noreferrer">Dangerous Things</a>
</p>
</div>
</div>

View File

@@ -2,6 +2,8 @@ import { json, type ActionFunctionArgs } from "@remix-run/node";
import { Form, useActionData, useLoaderData, useNavigation, useFetcher } from "@remix-run/react";
import { useState, useEffect, lazy, Suspense } from "react";
import ConnectDialog from "~/components/ConnectDialog";
import { useTheme } from "~/lib/ThemeContext";
import type { ThemeBrand, ThemeMode } from "~/lib/ThemeContext";
// Lazy load QR Scanner
const QRScanner = lazy(() => import("~/components/QRScanner"));
@@ -277,7 +279,8 @@ export default function Settings() {
const { config, wifiStatus, savedNetworks, cpuCores, plugins, sslInfo } = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const [activeSection, setActiveSection] = useState<"general" | "wifi" | "plugins" | "advanced" | "system">("general");
const [activeSection, setActiveSection] = useState<"general" | "wifi" | "plugins" | "advanced" | "system" | "appearance">("general");
const { brand, mode, setBrand, setMode, availableThemes } = useTheme();
const [scannedNetworks, setScannedNetworks] = useState<any[]>([]);
const [selectedNetwork, setSelectedNetwork] = useState<any>(null);
const [showConnectDialog, setShowConnectDialog] = useState(false);
@@ -336,6 +339,7 @@ export default function Settings() {
{ id: "plugins", label: "Plugins", icon: "🔌" },
{ id: "advanced", label: "Advanced", icon: "◈" },
{ id: "system", label: "System", icon: "⚡" },
{ id: "appearance", label: "Theme", icon: "◐" },
];
const getSignalIcon = (strength: number) => {
@@ -348,10 +352,6 @@ export default function Settings() {
return (
<div className="container">
<h1 style={{ marginBottom: "var(--space-6)" }}>
<span style={{ color: "var(--color-secondary)" }}></span> Settings
</h1>
{actionData && "message" in actionData && (
<div
className="card"
@@ -369,8 +369,11 @@ export default function Settings() {
</div>
)}
{/* Section Tabs */}
<div className="card" style={{ marginBottom: "var(--space-4)", padding: "var(--space-2)" }}>
{/* Settings Header + Section Tabs */}
<div className="card" style={{ marginBottom: "var(--space-4)" }}>
<h3 className="card-title">
<span></span> Settings
</h3>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(100px, 1fr))", gap: "var(--space-2)" }}>
{sections.map((section) => (
<button
@@ -775,7 +778,7 @@ export default function Settings() {
left: 0,
right: 0,
bottom: 0,
background: "rgba(10, 14, 26, 0.95)",
background: "rgba(var(--color-bg-rgb), 0.95)",
display: "flex",
alignItems: "center",
justifyContent: "center",
@@ -965,6 +968,52 @@ export default function Settings() {
</div>
)}
{/* Appearance */}
{activeSection === "appearance" && (
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-4)" }}>
<div className="card">
<h3 className="card-title">Theme</h3>
<div className="form-group">
<label className="label">Brand</label>
<div style={{ display: "flex", gap: "var(--space-2)", flexWrap: "wrap" }}>
{availableThemes.map((t) => (
<button
key={t.id}
onClick={() => setBrand(t.id as ThemeBrand)}
className={`btn ${brand === t.id ? "btn-primary" : "btn-secondary"}`}
style={{ flex: "1 1 auto", minWidth: "120px" }}
>
{t.name}
</button>
))}
</div>
<p className="text-muted" style={{ fontSize: "0.75rem", marginTop: "var(--space-2)" }}>
{availableThemes.find((t) => t.id === brand)?.description}
</p>
</div>
<div className="form-group">
<label className="label">Mode</label>
<div style={{ display: "flex", gap: "var(--space-2)" }}>
{(["dark", "auto", "light"] as ThemeMode[]).map((m) => (
<button
key={m}
onClick={() => setMode(m)}
className={`btn ${mode === m ? "btn-primary" : "btn-secondary"}`}
style={{ flex: 1 }}
>
{m === "dark" ? "\u25D0 Dark" : m === "light" ? "\u25CB Light" : "\u25D1 Auto"}
</button>
))}
</div>
<p className="text-muted" style={{ fontSize: "0.75rem", marginTop: "var(--space-2)" }}>
Auto follows your system preference
</p>
</div>
</div>
</div>
)}
{/* System Actions */}
{activeSection === "system" && (
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-4)" }}>

File diff suppressed because it is too large Load Diff

View File

@@ -1,29 +1,10 @@
/* Dangerous Pi - Cyberpunk Theme */
/* Dangerous Pi - Base Styles (Theme-Agnostic) */
/* Optimized for Pi Zero 2 W - Minimal, Fast, Beautiful */
/* ============================================================================
Default token values (overridden by theme files via data-brand attribute)
============================================================================ */
:root {
/* Cyberpunk Color Palette - Dark Mode Default */
--color-bg: #0a0e1a;
--color-surface: #121827;
--color-surface-hover: #1a2332;
--color-border: #1e293b;
--color-text-primary: #e2e8f0;
--color-text-secondary: #94a3b8;
--color-text-muted: #64748b;
/* Neon Accents */
--color-primary: #00ffff; /* Cyan */
--color-primary-dim: #0891b2;
--color-secondary: #ff00ff; /* Magenta */
--color-accent: #00ff88; /* Green */
/* Status Colors */
--color-success: #00ff88;
--color-warning: #ffaa00;
--color-error: #ff0055;
--color-info: #00ffff;
/* Spacing (4px base) */
--space-1: 0.25rem;
--space-2: 0.5rem;
@@ -37,57 +18,25 @@
--radius: 0.5rem;
--radius-lg: 0.75rem;
/* Bevel sizes (DT theme sets real values; Classic/Supra set 0px) */
--bevel-sm: 0px;
--bevel-md: 0px;
--bevel-lg: 0px;
/* Transitions */
--transition-fast: 150ms ease;
--transition: 250ms ease;
/* Monospace for terminal feel */
/* Font stacks — themes override --font-heading and --font-body */
--font-mono: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, monospace;
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-heading: var(--font-sans);
--font-body: var(--font-sans);
}
/* Light mode override (if user prefers) */
@media (prefers-color-scheme: light) {
:root[data-theme="auto"] {
--color-bg: #ffffff;
--color-surface: #f8fafc;
--color-surface-hover: #f1f5f9;
--color-border: #e2e8f0;
--color-text-primary: #0f172a;
--color-text-secondary: #475569;
--color-text-muted: #94a3b8;
--color-primary: #0891b2;
--color-primary-dim: #0e7490;
--color-secondary: #c026d3;
}
}
/* Force dark mode */
:root[data-theme="dark"] {
color-scheme: dark;
}
/* Force light mode */
:root[data-theme="light"] {
--color-bg: #ffffff;
--color-surface: #f8fafc;
--color-surface-hover: #f1f5f9;
--color-border: #e2e8f0;
--color-text-primary: #0f172a;
--color-text-secondary: #475569;
--color-text-muted: #94a3b8;
--color-primary: #0891b2;
--color-primary-dim: #0e7490;
--color-secondary: #c026d3;
color-scheme: light;
}
/* Reset & Base */
/* ============================================================================
Reset & Base
============================================================================ */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
@@ -95,7 +44,7 @@
}
html {
font-family: var(--font-sans);
font-family: var(--font-body);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
@@ -109,17 +58,20 @@ body {
min-height: 100vh;
}
/* Typography */
/* ============================================================================
Typography
============================================================================ */
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-heading);
font-weight: 600;
line-height: 1.25;
margin-bottom: var(--space-4);
}
h1 { font-size: 1.875rem; } /* 30px */
h2 { font-size: 1.5rem; } /* 24px */
h3 { font-size: 1.25rem; } /* 20px */
h4 { font-size: 1.125rem; } /* 18px */
h1 { font-size: 1.875rem; }
h2 { font-size: 1.5rem; }
h3 { font-size: 1.25rem; }
h4 { font-size: 1.125rem; }
p {
margin-bottom: var(--space-4);
@@ -135,7 +87,9 @@ a:hover {
color: var(--color-accent);
}
/* Layout */
/* ============================================================================
Layout
============================================================================ */
.container {
max-width: 1280px;
margin: 0 auto;
@@ -151,7 +105,7 @@ a:hover {
top: 0;
z-index: 100;
backdrop-filter: blur(8px);
background: rgba(18, 24, 39, 0.9);
background: rgba(var(--color-surface-rgb), 0.9);
}
.header-content {
@@ -166,6 +120,7 @@ a:hover {
display: flex;
align-items: center;
gap: var(--space-3);
font-family: var(--font-heading);
font-weight: 700;
font-size: 1.25rem;
color: var(--color-primary);
@@ -180,7 +135,9 @@ a:hover {
border-radius: var(--radius);
}
/* Navigation */
/* ============================================================================
Navigation
============================================================================ */
.nav {
display: flex;
gap: var(--space-2);
@@ -204,7 +161,7 @@ a:hover {
.nav-link.active {
color: var(--color-primary);
border-color: var(--color-primary);
background: rgba(0, 255, 255, 0.1);
background: rgba(var(--color-primary-rgb), 0.1);
}
/* Mobile Navigation */
@@ -230,7 +187,7 @@ a:hover {
}
body {
padding-bottom: 60px; /* Space for bottom nav */
padding-bottom: 60px;
}
}
@@ -241,7 +198,9 @@ a:hover {
margin: 0 auto;
}
/* Cards */
/* ============================================================================
Cards
============================================================================ */
.card {
background: var(--color-surface);
border: 1px solid var(--color-border);
@@ -255,6 +214,7 @@ a:hover {
}
.card-title {
font-family: var(--font-heading);
font-size: 1.125rem;
font-weight: 600;
margin-bottom: var(--space-3);
@@ -267,7 +227,9 @@ a:hover {
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
}
/* Buttons */
/* ============================================================================
Buttons
============================================================================ */
.btn {
display: inline-flex;
align-items: center;
@@ -275,6 +237,7 @@ a:hover {
gap: var(--space-2);
padding: var(--space-3) var(--space-6);
border-radius: var(--radius);
font-family: var(--font-body);
font-weight: 600;
font-size: 0.875rem;
text-transform: uppercase;
@@ -282,21 +245,21 @@ a:hover {
cursor: pointer;
border: 1px solid transparent;
transition: all var(--transition-fast);
min-height: 44px; /* Touch-friendly */
font-family: var(--font-sans);
min-height: 44px;
}
.btn-primary {
background: var(--color-primary);
color: var(--color-bg);
border-color: var(--color-primary);
box-shadow: 0 0 20px rgba(0, 255, 255, 0.3);
box-shadow: 0 0 20px rgba(var(--color-primary-rgb), 0.3);
}
.btn-primary:hover {
background: var(--color-accent);
color: var(--color-bg);
border-color: var(--color-accent);
box-shadow: 0 0 30px rgba(0, 255, 136, 0.4);
box-shadow: 0 0 30px rgba(var(--color-accent-rgb), 0.4);
transform: translateY(-1px);
}
@@ -307,19 +270,20 @@ a:hover {
}
.btn-secondary:hover {
background: rgba(0, 255, 255, 0.1);
background: rgba(var(--color-primary-rgb), 0.1);
border-color: var(--color-accent);
color: var(--color-accent);
}
.btn-danger {
background: var(--color-error);
color: white;
color: var(--color-bg);
border-color: var(--color-error);
}
.btn-danger:hover {
background: #cc0044;
color: var(--color-bg);
filter: brightness(0.85);
transform: translateY(-1px);
}
@@ -329,7 +293,9 @@ a:hover {
transform: none !important;
}
/* Status Badges */
/* ============================================================================
Status Badges
============================================================================ */
.badge {
display: inline-flex;
align-items: center;
@@ -343,25 +309,25 @@ a:hover {
}
.badge-success {
background: rgba(0, 255, 136, 0.2);
background: rgba(var(--color-success-rgb), 0.2);
color: var(--color-success);
border: 1px solid var(--color-success);
}
.badge-error {
background: rgba(255, 0, 85, 0.2);
background: rgba(var(--color-error-rgb), 0.2);
color: var(--color-error);
border: 1px solid var(--color-error);
}
.badge-warning {
background: rgba(255, 170, 0, 0.2);
background: rgba(var(--color-warning-rgb), 0.2);
color: var(--color-warning);
border: 1px solid var(--color-warning);
}
.badge-info {
background: rgba(0, 255, 255, 0.2);
background: rgba(var(--color-info-rgb), 0.2);
color: var(--color-info);
border: 1px solid var(--color-info);
}
@@ -375,7 +341,9 @@ a:hover {
50% { opacity: 0.6; }
}
/* Forms */
/* ============================================================================
Forms
============================================================================ */
.form-group {
margin-bottom: var(--space-4);
}
@@ -406,14 +374,16 @@ a:hover {
.input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(0, 255, 255, 0.1);
box-shadow: 0 0 0 3px rgba(var(--color-primary-rgb), 0.1);
}
.input::placeholder {
color: var(--color-text-muted);
}
/* Terminal/Code Output */
/* ============================================================================
Terminal / Code Output
============================================================================ */
.terminal {
background: var(--color-bg);
border: 1px solid var(--color-border);
@@ -446,7 +416,9 @@ a:hover {
background: var(--color-primary);
}
/* Loading States */
/* ============================================================================
Loading States
============================================================================ */
.skeleton {
background: linear-gradient(
90deg,
@@ -477,7 +449,9 @@ a:hover {
to { transform: rotate(360deg); }
}
/* Toast Notifications */
/* ============================================================================
Toast Notifications
============================================================================ */
.toast {
position: fixed;
bottom: var(--space-6);
@@ -502,7 +476,9 @@ a:hover {
}
}
/* Utility Classes */
/* ============================================================================
Utility Classes
============================================================================ */
.text-primary { color: var(--color-text-primary); }
.text-secondary { color: var(--color-text-secondary); }
.text-muted { color: var(--color-text-muted); }
@@ -547,7 +523,9 @@ a:hover {
outline-offset: 2px;
}
/* Responsive */
/* ============================================================================
Responsive
============================================================================ */
@media (max-width: 768px) {
h1 { font-size: 1.5rem; }
h2 { font-size: 1.25rem; }
@@ -561,7 +539,9 @@ a:hover {
}
}
/* Power Widget */
/* ============================================================================
Power Widget
============================================================================ */
.power-widget {
display: inline-flex;
align-items: center;
@@ -609,12 +589,8 @@ a:hover {
}
@keyframes plug-pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
/* Battery Icon */
@@ -657,7 +633,7 @@ a:hover {
.power-widget--charging {
color: var(--color-accent);
border-color: rgba(0, 255, 136, 0.3);
border-color: rgba(var(--color-accent-rgb), 0.3);
}
.power-widget--full {
@@ -667,23 +643,23 @@ a:hover {
.power-widget--low {
color: var(--color-warning);
border-color: rgba(255, 170, 0, 0.3);
border-color: rgba(var(--color-warning-rgb), 0.3);
}
.power-widget--critical {
color: var(--color-error);
border-color: rgba(255, 0, 85, 0.3);
border-color: rgba(var(--color-error-rgb), 0.3);
animation: critical-pulse 1s ease-in-out infinite;
}
@keyframes critical-pulse {
0%, 100% {
border-color: rgba(255, 0, 85, 0.3);
border-color: rgba(var(--color-error-rgb), 0.3);
box-shadow: none;
}
50% {
border-color: var(--color-error);
box-shadow: 0 0 10px rgba(255, 0, 85, 0.4);
box-shadow: 0 0 10px rgba(var(--color-error-rgb), 0.4);
}
}
@@ -701,7 +677,6 @@ a:hover {
/* ============================================================================
Header Widgets - Notification banners below header
============================================================================ */
.header-widgets {
display: flex;
flex-direction: column;
@@ -733,25 +708,25 @@ a:hover {
}
.widget-info {
background: rgba(0, 255, 255, 0.1);
background: rgba(var(--color-info-rgb), 0.1);
border-left: 3px solid var(--color-info);
color: var(--color-info);
}
.widget-warning {
background: rgba(255, 170, 0, 0.1);
background: rgba(var(--color-warning-rgb), 0.1);
border-left: 3px solid var(--color-warning);
color: var(--color-warning);
}
.widget-error {
background: rgba(255, 0, 85, 0.1);
background: rgba(var(--color-error-rgb), 0.1);
border-left: 3px solid var(--color-error);
color: var(--color-error);
}
.widget-success {
background: rgba(0, 255, 136, 0.1);
background: rgba(var(--color-success-rgb), 0.1);
border-left: 3px solid var(--color-success);
color: var(--color-success);
}
@@ -769,7 +744,7 @@ a:hover {
.widget-action {
padding: var(--space-1) var(--space-3);
background: rgba(255, 255, 255, 0.1);
background: rgba(var(--color-primary-rgb), 0.1);
border-radius: var(--radius-sm);
text-decoration: none;
color: inherit;
@@ -780,7 +755,7 @@ a:hover {
}
.widget-action:hover {
background: rgba(255, 255, 255, 0.2);
background: rgba(var(--color-primary-rgb), 0.2);
}
.widget-dismiss {

View File

@@ -8,6 +8,7 @@
"name": "dangerous-pi-frontend",
"version": "0.1.0",
"dependencies": {
"@dangerousthings/web": "file:../../../dt-design-system/packages/web",
"@remix-run/node": "^2.14.0",
"@remix-run/react": "^2.14.0",
"@remix-run/serve": "^2.14.0",
@@ -26,6 +27,22 @@
"node": ">=18.0.0"
}
},
"../../../dt-design-system/packages/web": {
"name": "@dangerousthings/web",
"version": "0.2.0",
"license": "MIT",
"dependencies": {
"@dangerousthings/tokens": "*"
}
},
"../../../dt-web-theme": {
"version": "0.1.0",
"extraneous": true,
"license": "MIT"
},
"../../dt-web-theme": {
"extraneous": true
},
"node_modules/@babel/code-frame": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
@@ -515,6 +532,10 @@
"node": ">=6.9.0"
}
},
"node_modules/@dangerousthings/web": {
"resolved": "../../../dt-design-system/packages/web",
"link": true
},
"node_modules/@emotion/hash": {
"version": "0.9.2",
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz",

View File

@@ -13,6 +13,7 @@
"@remix-run/node": "^2.14.0",
"@remix-run/react": "^2.14.0",
"@remix-run/serve": "^2.14.0",
"@dangerousthings/web": "file:../../../dt-design-system/packages/web",
"html5-qrcode": "^2.3.8",
"react": "^18.3.1",
"react-dom": "^18.3.1"

View File

@@ -0,0 +1,8 @@
{
"id": "classic",
"name": "Classic Cyberpunk",
"description": "Original dark navy aesthetic with magenta accents",
"supportsModes": ["dark", "light", "auto"],
"defaultMode": "dark",
"author": "Dangerous Things"
}

View File

@@ -0,0 +1,5 @@
/*
* Classic Cyberpunk — Token CSS
*
* Generated by @dangerousthings/tokens during CI builds.
*/

View File

@@ -0,0 +1,8 @@
{
"id": "dt",
"name": "Dangerous Things",
"description": "Official DT brand — Tektur font, neon cyberpunk, beveled corners",
"supportsModes": ["dark", "light", "auto"],
"defaultMode": "dark",
"author": "Dangerous Things"
}

View File

@@ -0,0 +1,11 @@
/*
* Dangerous Things (dt) — Token CSS
*
* This file is generated by @dangerousthings/tokens during CI builds.
* It contains CSS custom properties for brand colors, typography, and
* shape tokens that override the structural base.css defaults.
*
* In development, the bundled @dangerousthings/web/dist/index.css provides
* these tokens. This file is loaded dynamically in production when the
* theme component is updated independently of the frontend.
*/