diff --git a/CHANGELOG.md b/CHANGELOG.md
index 935d97d..38e3001 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,32 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
---
+## v26.05.07 (2026-05-31)
+
+### Admin dashboard — brand refresh & UI foundation
+
+First pass of the best-in-class admin dashboard overhaul.
+
+- **Logo-aligned theme.** The palette is retargeted to the pyfly logo's vivid
+ lime-green brand — accent/primary, sidebar, focus/active states and all chart
+ palettes are green; dark surfaces shifted from navy to a desaturated
+ dark-forest charcoal. Light theme accents greened to match.
+- **Typography.** Switched the UI font to **Maven Pro** (rounded,
+ friendly-professional — matches the logo), keeping JetBrains Mono for
+ tabular/numeric data.
+- **Stat cards.** Implemented the previously-empty overview stat-card icons
+ (health / beans / uptime / profiles) and refined card depth (subtle gradient,
+ hover lift, bolder headers).
+- **Asset caching.** Admin static assets now serve with `Cache-Control: no-cache`
+ and the SPA injects a `?v={__version__}` query, so theme/JS updates are picked
+ up on upgrade instead of being served stale from the browser cache.
+- **Responsive.** Verified zero horizontal overflow at mobile (390px) and desktop
+ (1440px); mobile drawer, stacked stat cards and horizontally-scrollable tables
+ all behave.
+- `pyfly.__version__` kept in sync with the packaged version.
+
+---
+
## v26.05.06 (2026-05-31)
### Hardening pass — framework-wide bug fixes
diff --git a/pyproject.toml b/pyproject.toml
index 4548348..c56c60a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -7,7 +7,7 @@ name = "pyfly"
# CalVer YY.MM.PATCH — package metadata uses PEP 440 normalized form (26.5.4);
# git tag, GitHub release and human-readable display use leading-zero form
# (v26.05.04) to match the Java/.NET/Go siblings.
-version = "26.5.6"
+version = "26.5.7"
description = "The official Python implementation of the Firefly Framework — DI, CQRS, EDA, hexagonal architecture, and more."
readme = "README.md"
license = "Apache-2.0"
diff --git a/src/pyfly/__init__.py b/src/pyfly/__init__.py
index 15a42a2..86ccddd 100644
--- a/src/pyfly/__init__.py
+++ b/src/pyfly/__init__.py
@@ -13,4 +13,4 @@
# limitations under the License.
"""PyFly — Enterprise Python Framework."""
-__version__ = "26.05.06"
+__version__ = "26.05.07"
diff --git a/src/pyfly/admin/adapters/starlette.py b/src/pyfly/admin/adapters/starlette.py
index d70b534..d47763d 100644
--- a/src/pyfly/admin/adapters/starlette.py
+++ b/src/pyfly/admin/adapters/starlette.py
@@ -22,6 +22,7 @@
from starlette.responses import JSONResponse, Response, StreamingResponse
from starlette.routing import Mount, Route
from starlette.staticfiles import StaticFiles
+from starlette.types import Scope
if TYPE_CHECKING:
from pyfly.admin.config import AdminProperties
@@ -46,6 +47,21 @@
from pyfly.admin.server.instance_registry import InstanceRegistry
+class _NoCacheStaticFiles(StaticFiles):
+ """Serve admin assets with ``Cache-Control: no-cache`` so browsers revalidate.
+
+ The dashboard's JS is loaded as ES modules whose relative imports carry no
+ version query, so a plain far-future cache would serve stale views/styles
+ after a framework upgrade. ``no-cache`` keeps revalidation cheap (304s) while
+ guaranteeing updated assets are always picked up.
+ """
+
+ async def get_response(self, path: str, scope: Scope) -> Response:
+ response: Response = await super().get_response(path, scope)
+ response.headers["Cache-Control"] = "no-cache"
+ return response
+
+
class AdminRouteBuilder:
"""Builds Starlette routes for the admin dashboard."""
@@ -158,7 +174,7 @@ def build_routes(self) -> list[Route | Mount]:
routes.append(
Mount(
f"{base}/static",
- app=StaticFiles(packages=[("pyfly.admin", "static")]),
+ app=_NoCacheStaticFiles(packages=[("pyfly.admin", "static")]),
name="admin-static",
)
)
@@ -376,6 +392,9 @@ async def _handle_sse_server(self, request: Request) -> Response:
async def _handle_spa(self, request: Request) -> Response:
"""Serve index.html for SPA client-side routing."""
import importlib.resources
+ import re
+
+ from pyfly import __version__
index_path = importlib.resources.files("pyfly.admin") / "static" / "index.html"
content = index_path.read_text(encoding="utf-8")
@@ -383,4 +402,12 @@ async def _handle_spa(self, request: Request) -> Response:
# correctly regardless of whether the browser path has a trailing slash.
base_href = self._props.path.rstrip("/") + "/"
content = content.replace("
", f'\n ', 1)
+ # Version-stamp local static assets so a framework upgrade busts stale
+ # browser caches (otherwise a cached themes.css/admin.css/app.js keeps the
+ # old look after an upgrade).
+ content = re.sub(
+ r'(href|src)="(static/[^"?]+)"',
+ rf'\1="\2?v={__version__}"',
+ content,
+ )
return Response(content, media_type="text/html")
diff --git a/src/pyfly/admin/static/css/admin.css b/src/pyfly/admin/static/css/admin.css
index 2aca38b..b595130 100644
--- a/src/pyfly/admin/static/css/admin.css
+++ b/src/pyfly/admin/static/css/admin.css
@@ -1,7 +1,7 @@
/* PyFly Admin Dashboard — Main Stylesheet */
/* ── Google Fonts ───────────────────────────────────────────────── */
-@import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&family=JetBrains+Mono:wght@400;500;600&display=swap');
+@import url('https://fonts.googleapis.com/css2?family=Maven+Pro:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap');
/* ── Reset / Base ───────────────────────────────────────────────── */
*,
@@ -245,12 +245,12 @@ a:hover {
position: relative;
background:
linear-gradient(
- rgba(59, 130, 246, 0.015) 1px,
+ rgba(95, 191, 46, 0.018) 1px,
transparent 1px
),
linear-gradient(
90deg,
- rgba(59, 130, 246, 0.015) 1px,
+ rgba(95, 191, 46, 0.018) 1px,
transparent 1px
);
background-size: 40px 40px;
@@ -258,16 +258,21 @@ a:hover {
/* ── Cards ──────────────────────────────────────────────────────── */
.admin-card {
- background: var(--admin-surface);
+ background:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.018), transparent 120px),
+ var(--admin-surface);
border: 1px solid var(--admin-border);
border-radius: var(--admin-radius-lg);
box-shadow: var(--admin-shadow);
overflow: hidden;
- transition: box-shadow var(--admin-transition);
+ transition:
+ box-shadow var(--admin-transition),
+ border-color var(--admin-transition);
}
.admin-card:hover {
box-shadow: var(--admin-shadow-lg);
+ border-color: var(--admin-primary-dim);
}
.admin-card-header {
@@ -281,7 +286,8 @@ a:hover {
.admin-card-header h3 {
font-family: var(--admin-font-sans);
font-size: 0.95rem;
- font-weight: 600;
+ font-weight: 700;
+ letter-spacing: -0.01em;
color: var(--admin-text);
margin: 0;
}
@@ -654,20 +660,27 @@ a:hover {
/* ── Stat Cards ─────────────────────────────────────────────────── */
.stat-card {
- background: var(--admin-surface);
+ background:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent 90px),
+ var(--admin-surface);
border: 1px solid var(--admin-border);
border-radius: var(--admin-radius-lg);
padding: 20px;
display: flex;
align-items: flex-start;
justify-content: space-between;
- transition: all var(--admin-transition);
+ gap: 12px;
+ transition:
+ transform var(--admin-transition),
+ border-color var(--admin-transition),
+ box-shadow var(--admin-transition);
box-shadow: var(--admin-shadow);
}
.stat-card:hover {
border-color: var(--admin-primary-dim);
box-shadow: var(--admin-shadow-lg);
+ transform: translateY(-2px);
}
.stat-card-content {
@@ -695,13 +708,14 @@ a:hover {
}
.stat-card-icon {
- width: 40px;
- height: 40px;
+ width: 42px;
+ height: 42px;
border-radius: var(--admin-radius);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
+ box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.04);
}
.stat-card-icon svg {
diff --git a/src/pyfly/admin/static/css/themes.css b/src/pyfly/admin/static/css/themes.css
index e4e7d31..455f9b6 100644
--- a/src/pyfly/admin/static/css/themes.css
+++ b/src/pyfly/admin/static/css/themes.css
@@ -2,17 +2,18 @@
:root,
[data-theme="dark"] {
- --admin-bg: #0c1222;
- --admin-bg-subtle: #111a2e;
- --admin-surface: #162032;
- --admin-surface-hover: #1c2a42;
- --admin-surface-active: #243352;
- --admin-text: #e2e8f0;
- --admin-text-secondary: #94a3b8;
- --admin-text-muted: #64748b;
- --admin-primary: #3b82f6;
- --admin-primary-hover: #60a5fa;
- --admin-primary-dim: rgba(59, 130, 246, 0.15);
+ --admin-bg: #0b120d;
+ --admin-bg-subtle: #0f1812;
+ --admin-surface: #141f18;
+ --admin-surface-hover: #1b2a20;
+ --admin-surface-active: #233529;
+ --admin-text: #e6f0e0;
+ --admin-text-secondary: #9db59a;
+ --admin-text-muted: #6b806a;
+ /* Brand accent — derived from the pyfly logo's vivid lime-green */
+ --admin-primary: #5fbf2e;
+ --admin-primary-hover: #7ad94a;
+ --admin-primary-dim: rgba(95, 191, 46, 0.16);
--admin-success: #10b981;
--admin-success-dim: rgba(16, 185, 129, 0.15);
--admin-danger: #f43f5e;
@@ -21,14 +22,14 @@
--admin-warning-dim: rgba(245, 158, 11, 0.15);
--admin-info: #06b6d4;
--admin-info-dim: rgba(6, 182, 212, 0.15);
- --admin-border: #1e293b;
- --admin-border-subtle: #162032;
- --admin-sidebar-bg: #070d1a;
- --admin-sidebar-text: #94a3b8;
- --admin-sidebar-text-active: #e2e8f0;
- --admin-sidebar-hover: #0f1729;
- --admin-sidebar-active: #162032;
- --admin-sidebar-accent: #3b82f6;
+ --admin-border: #213127;
+ --admin-border-subtle: #18241c;
+ --admin-sidebar-bg: #070d09;
+ --admin-sidebar-text: #9db59a;
+ --admin-sidebar-text-active: #e6f0e0;
+ --admin-sidebar-hover: #0e160f;
+ --admin-sidebar-active: #15211a;
+ --admin-sidebar-accent: #5fbf2e;
--admin-sidebar-width: 260px;
--admin-navbar-height: 56px;
--admin-radius: 8px;
@@ -36,9 +37,9 @@
--admin-radius-lg: 12px;
--admin-shadow: 0 1px 3px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.2);
--admin-shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.4);
- --admin-font-sans: 'DM Sans', -apple-system, BlinkMacSystemFont, sans-serif;
+ --admin-font-sans: 'Maven Pro', -apple-system, BlinkMacSystemFont, sans-serif;
--admin-font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
- --admin-card-bg: #1a2744;
+ --admin-card-bg: #16241a;
--admin-transition: 150ms ease;
}
@@ -51,9 +52,9 @@
--admin-text: #0f172a;
--admin-text-secondary: #475569;
--admin-text-muted: #94a3b8;
- --admin-primary: #2563eb;
- --admin-primary-hover: #3b82f6;
- --admin-primary-dim: rgba(37, 99, 235, 0.1);
+ --admin-primary: #4a9e1f;
+ --admin-primary-hover: #5fbf2e;
+ --admin-primary-dim: rgba(74, 158, 31, 0.12);
--admin-success: #059669;
--admin-success-dim: rgba(5, 150, 105, 0.1);
--admin-danger: #e11d48;
@@ -69,7 +70,7 @@
--admin-sidebar-text-active: #f1f5f9;
--admin-sidebar-hover: #1e293b;
--admin-sidebar-active: #1e293b;
- --admin-sidebar-accent: #3b82f6;
+ --admin-sidebar-accent: #5fbf2e;
--admin-shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04);
--admin-card-bg: #ffffff;
--admin-shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.1);
diff --git a/src/pyfly/admin/static/js/charts.js b/src/pyfly/admin/static/js/charts.js
index 1219a61..8158e3f 100644
--- a/src/pyfly/admin/static/js/charts.js
+++ b/src/pyfly/admin/static/js/charts.js
@@ -28,9 +28,9 @@ export function hexToRgba(hex, alpha) {
}
function resolveColor(colorVar) {
- if (!colorVar) return cssVar('--admin-primary') || '#3b82f6';
+ if (!colorVar) return cssVar('--admin-primary') || '#5fbf2e';
if (colorVar.startsWith('#')) return colorVar;
- return cssVar(colorVar) || cssVar('--admin-primary') || '#3b82f6';
+ return cssVar(colorVar) || cssVar('--admin-primary') || '#5fbf2e';
}
function themeDefaults() {
@@ -117,7 +117,7 @@ export function createLineChart(canvas, options = {}) {
*/
export function createBarChart(canvas, options = {}) {
const theme = themeDefaults();
- const defaultColor = cssVar('--admin-primary') || '#3b82f6';
+ const defaultColor = cssVar('--admin-primary') || '#5fbf2e';
const colors = (options.colors || []).map(c => resolveColor(c));
const chart = new Chart(canvas, {
diff --git a/src/pyfly/admin/static/js/views/overview.js b/src/pyfly/admin/static/js/views/overview.js
index 39d9a30..ea73af4 100644
--- a/src/pyfly/admin/static/js/views/overview.js
+++ b/src/pyfly/admin/static/js/views/overview.js
@@ -35,7 +35,7 @@ function formatUptime(seconds) {
/**
* Create a stat card element.
*/
-function createStatCard({ label, value, subtitle, iconClass = 'primary' }) {
+function createStatCard({ label, value, subtitle, iconClass = 'primary', icon = '' }) {
const card = document.createElement('div');
card.className = 'stat-card';
@@ -67,13 +67,22 @@ function createStatCard({ label, value, subtitle, iconClass = 'primary' }) {
card.appendChild(content);
- const icon = document.createElement('div');
- icon.className = `stat-card-icon ${iconClass}`;
- card.appendChild(icon);
+ const iconEl = document.createElement('div');
+ iconEl.className = `stat-card-icon ${iconClass}`;
+ if (icon) iconEl.innerHTML = icon;
+ card.appendChild(iconEl);
return card;
}
+// Feather-style line icons (20×20, inherit `currentColor` from the icon tint).
+const STAT_ICONS = {
+ health: '',
+ beans: '',
+ uptime: '',
+ profiles: '',
+};
+
/**
* Build a wiring progress bar item.
*/
@@ -182,13 +191,16 @@ export async function render(container, api) {
// 1) Health status
const healthBadge = createStatusBadge(health.status || 'UNKNOWN');
- statsRow.appendChild(createStatCard({ label: 'Health Status', value: healthBadge, iconClass: 'success' }));
+ statsRow.appendChild(
+ createStatCard({ label: 'Health Status', value: healthBadge, iconClass: 'success', icon: STAT_ICONS.health }),
+ );
// 2) Total beans
statsRow.appendChild(createStatCard({
label: 'Total Beans',
value: String(beans.total != null ? beans.total : 0),
iconClass: 'primary',
+ icon: STAT_ICONS.beans,
}));
// 3) Uptime
@@ -197,6 +209,7 @@ export async function render(container, api) {
value: formatUptime(app.uptime_seconds),
subtitle: `Port ${app.web_port || 8080}`,
iconClass: 'info',
+ icon: STAT_ICONS.uptime,
}));
// 4) Active profiles
@@ -206,6 +219,7 @@ export async function render(container, api) {
value: profiles.length > 0 ? profiles.join(', ') : 'default',
subtitle: `Python ${app.python_version || ''}`,
iconClass: 'warning',
+ icon: STAT_ICONS.profiles,
}));
wrapper.appendChild(statsRow);
diff --git a/uv.lock b/uv.lock
index 6552274..216d1a6 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1589,7 +1589,7 @@ wheels = [
[[package]]
name = "pyfly"
-version = "26.5.6"
+version = "26.5.7"
source = { editable = "." }
dependencies = [
{ name = "pydantic" },