<!doctype html>
<html class="{% if initial_theme == 'light' %}{% else %}dark{% endif %}" lang="en" id="umbral-admin-root" data-theme="{{ initial_theme | default('dark') }}">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>{% block title %}{{ site_title }}{% endblock %}</title>
{% if site_description %}<meta name="description" content="{{ site_description }}"/>{% endif %}
{# `responseHandling` swaps content for 400/409/422 too —
the admin's form-submit handlers re-render the form with an
inline error span on validation failure, and HTMX's default
"errors mean no swap" rule would leave the user staring at
a stale form with no feedback. The pattern matches DRF's
400-with-body convention: validation errors are user-fixable
and the response body is the new state to show. #}
<meta name="htmx-config" content='{"selfRequestsOnly":true,"responseHandling":[{"code":"204","swap":false},{"code":"[23]..","swap":true},{"code":"400","swap":true},{"code":"409","swap":true},{"code":"422","swap":true},{"code":"[45]..","swap":false,"error":true}]}'/>
<script>
(function() {
var stored = localStorage.getItem('umbral-admin-theme');
var root = document.documentElement;
if (stored === 'light') {
root.classList.remove('dark');
root.setAttribute('data-theme', 'light');
} else if (stored === 'dark') {
root.classList.add('dark');
root.setAttribute('data-theme', 'dark');
}
})();
</script>
<script>window.umbral = window.umbral || {};</script>
{#
Tailwind CDN is loaded only in development. In production
(settings.environment == "prod"), the framework expects a
pre-compiled stylesheet at {{ static("admin/admin.css") }}. Until that
lands as a phase-4 hardening task, the CDN warning in dev is a
known footgun, documented in docs/v0.0.1/plugins/admin.mdx.
#}
{% if environment == "dev" or environment is undefined %}
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
{% else %}
<link href="{{ static("admin/admin.css") }}" rel="stylesheet"/>
{% endif %}
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script src="https://unpkg.com/lucide@latest"></script>
<script src="https://cdn.jsdelivr.net/npm/apexcharts@5.14.0/dist/apexcharts.min.js"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"/>
<style id="umbral-editor-theme">
.EasyMDEContainer .CodeMirror {
background: var(--surface-container-low);
color: var(--on-surface);
border-color: var(--outline-variant);
border-bottom-left-radius: 0.75rem;
border-bottom-right-radius: 0.75rem;
}
.EasyMDEContainer .CodeMirror-cursor { border-left-color: var(--on-surface); }
.EasyMDEContainer .editor-toolbar {
background: var(--surface-container);
border-color: var(--outline-variant);
border-top-left-radius: 0.75rem;
border-top-right-radius: 0.75rem;
}
.EasyMDEContainer .editor-toolbar button { color: var(--on-surface-variant) !important; }
.EasyMDEContainer .editor-toolbar button:hover,
.EasyMDEContainer .editor-toolbar button.active {
background: var(--surface-container-high);
border-color: var(--outline-variant);
}
.EasyMDEContainer .editor-preview,
.EasyMDEContainer .editor-preview-side {
background: var(--surface-container-lowest);
color: var(--on-surface);
border-color: var(--outline-variant);
}
.EasyMDEContainer .editor-statusbar { color: var(--on-surface-variant); }
.umbral-rte .ql-toolbar.ql-snow {
background: var(--surface-container);
border-color: var(--outline-variant);
border-top-left-radius: 0.75rem;
border-top-right-radius: 0.75rem;
}
.umbral-rte .ql-container.ql-snow {
background: var(--surface-container-low);
color: var(--on-surface);
border-color: var(--outline-variant);
border-bottom-left-radius: 0.75rem;
border-bottom-right-radius: 0.75rem;
min-height: 200px;
}
.umbral-rte .ql-editor.ql-blank::before { color: var(--on-surface-variant); }
.umbral-rte .ql-snow .ql-stroke { stroke: var(--on-surface-variant); }
.umbral-rte .ql-snow .ql-fill { fill: var(--on-surface-variant); }
.umbral-rte .ql-snow .ql-picker { color: var(--on-surface-variant); }
.umbral-rte .ql-snow.ql-toolbar button:hover .ql-stroke,
.umbral-rte .ql-snow.ql-toolbar button.ql-active .ql-stroke { stroke: var(--primary); }
.umbral-rte .ql-snow.ql-toolbar button:hover .ql-fill,
.umbral-rte .ql-snow.ql-toolbar button.ql-active .ql-fill { fill: var(--primary); }
.CodeMirror.umbral-code {
background: var(--surface-container-low);
color: var(--on-surface);
border: 1px solid var(--outline-variant);
border-radius: 0.75rem;
height: auto;
min-height: 220px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.CodeMirror.umbral-code .CodeMirror-gutters {
background: var(--surface-container);
border-right-color: var(--outline-variant);
}
.CodeMirror.umbral-code .CodeMirror-linenumber { color: var(--on-surface-variant); }
.CodeMirror.umbral-code .CodeMirror-cursor { border-left-color: var(--on-surface); }
</style>
<style>
:root {
--surface-container-lowest: oklch(0.992 0.004 95);
--surface-container-low: oklch(0.972 0.006 90);
--surface-container: oklch(0.952 0.008 90);
--surface-container-high: oklch(0.918 0.008 90);
--surface-container-highest:oklch(0.875 0.01 90);
--surface-bright: oklch(0.992 0.004 95);
--surface-dim: oklch(0.944 0.007 90);
--surface: oklch(0.985 0.005 95);
--surface-variant: oklch(0.918 0.008 90);
--surface-tint: oklch(0.35 0.018 80);
--on-surface: oklch(0.16 0.012 80);
--on-surface-variant: oklch(0.42 0.014 85);
--inverse-surface: oklch(0.18 0.012 75);
--inverse-on-surface: oklch(0.955 0.006 90);
--outline: oklch(0.54 0.014 85);
--outline-variant: oklch(0.82 0.01 90);
--divider: oklch(0.16 0.012 80 / 0.10);
--divider-soft: oklch(0.16 0.012 80 / 0.06);
--background: oklch(0.99 0.004 95);
--on-background: oklch(0.16 0.012 80);
--primary: oklch(0.35 0.018 80);
--on-primary: oklch(0.985 0.005 95);
--primary-container: oklch(0.945 0.012 88);
--on-primary-container: oklch(0.22 0.014 80);
--primary-fixed: oklch(0.945 0.012 88);
--primary-fixed-dim: oklch(0.88 0.014 88);
--on-primary-fixed: oklch(0.22 0.014 80);
--on-primary-fixed-variant: oklch(0.35 0.018 80);
--inverse-primary: oklch(0.78 0.014 85);
--secondary: oklch(0.5 0.012 225);
--on-secondary: oklch(0.985 0.005 95);
--secondary-container: oklch(0.93 0.018 225);
--on-secondary-container: oklch(0.23 0.018 225);
--secondary-fixed: oklch(0.93 0.018 225);
--secondary-fixed-dim: oklch(0.84 0.022 225);
--on-secondary-fixed: oklch(0.23 0.018 225);
--on-secondary-fixed-variant:oklch(0.42 0.02 225);
--tertiary: oklch(0.54 0.12 38);
--on-tertiary: oklch(0.985 0.005 95);
--tertiary-container: oklch(0.92 0.05 38);
--on-tertiary-container: oklch(0.25 0.05 38);
--tertiary-fixed: oklch(0.92 0.05 38);
--tertiary-fixed-dim: oklch(0.82 0.06 38);
--on-tertiary-fixed: oklch(0.25 0.05 38);
--on-tertiary-fixed-variant:oklch(0.42 0.08 38);
--error: oklch(0.577 0.245 27.325);
--on-error: oklch(0.985 0.005 95);
--error-container: oklch(0.93 0.07 27.325);
--on-error-container: oklch(0.25 0.08 27.325);
}
.dark {
--surface-container-lowest: oklch(0.135 0.008 75);
--surface-container-low: oklch(0.185 0.01 75);
--surface-container: oklch(0.205 0.011 75);
--surface-container-high: oklch(0.245 0.012 75);
--surface-container-highest:oklch(0.29 0.014 75);
--surface-bright: oklch(0.32 0.014 75);
--surface-dim: oklch(0.145 0.008 75);
--surface: oklch(0.16 0.009 75);
--surface-variant: oklch(0.285 0.014 75);
--surface-tint: oklch(0.72 0.014 85);
--on-surface: oklch(0.955 0.006 90);
--on-surface-variant: oklch(0.72 0.014 85);
--inverse-surface: oklch(0.955 0.006 90);
--inverse-on-surface: oklch(0.18 0.012 75);
--outline: oklch(0.62 0.014 85);
--outline-variant: oklch(0.285 0.014 75 / 70%);
--divider: oklch(0.955 0.006 90 / 0.09);
--divider-soft: oklch(0.955 0.006 90 / 0.05);
--background: oklch(0.145 0.008 75);
--on-background: oklch(0.955 0.006 90);
--primary: oklch(0.955 0.006 90);
--on-primary: oklch(0.18 0.012 75);
--primary-container: oklch(0.27 0.014 75);
--on-primary-container: oklch(0.955 0.006 90);
--primary-fixed: oklch(0.945 0.012 88);
--primary-fixed-dim: oklch(0.82 0.014 88);
--on-primary-fixed: oklch(0.22 0.014 80);
--on-primary-fixed-variant: oklch(0.35 0.018 80);
--inverse-primary: oklch(0.35 0.018 80);
--secondary: oklch(0.7 0.12 225);
--on-secondary: oklch(0.18 0.012 75);
--secondary-container: oklch(0.235 0.012 75);
--on-secondary-container: oklch(0.955 0.006 90);
--secondary-fixed: oklch(0.9 0.018 225);
--secondary-fixed-dim: oklch(0.78 0.022 225);
--on-secondary-fixed: oklch(0.2 0.018 225);
--on-secondary-fixed-variant:oklch(0.42 0.02 225);
--tertiary: oklch(0.72 0.14 38);
--on-tertiary: oklch(0.18 0.012 75);
--tertiary-container: oklch(0.28 0.04 38);
--on-tertiary-container: oklch(0.92 0.05 38);
--tertiary-fixed: oklch(0.92 0.05 38);
--tertiary-fixed-dim: oklch(0.82 0.06 38);
--on-tertiary-fixed: oklch(0.25 0.05 38);
--on-tertiary-fixed-variant:oklch(0.42 0.08 38);
--error: oklch(0.704 0.191 22.216);
--on-error: oklch(0.18 0.012 75);
--error-container: oklch(0.28 0.09 22.216);
--on-error-container: oklch(0.95 0.055 22.216);
}
{%- if brand_color %}
:root, .dark {
--primary: {{ brand_color }};
}
{%- endif %}
body { background-color: var(--background); color: var(--on-background); font-family: 'Inter', sans-serif; -webkit-font-smoothing: antialiased; }
html, body, button, input, select, textarea { font-family: 'Inter', sans-serif; }
.tabular-nums { font-variant-numeric: tabular-nums; }
:focus { outline: none; }
:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
border-radius: 6px;
}
input:focus, select:focus, textarea:focus, button:focus { outline: none; }
input:focus-visible, select:focus-visible, textarea:focus-visible {
border-color: var(--primary) !important;
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 20%, transparent);
outline: none;
}
input:disabled, select:disabled, textarea:disabled {
outline: none !important;
box-shadow: none !important;
cursor: not-allowed;
}
input:disabled:focus-visible, select:disabled:focus-visible, textarea:disabled:focus-visible {
border-color: var(--outline-variant) !important;
box-shadow: none !important;
outline: none !important;
}
input[type="date"], input[type="datetime-local"], input[type="time"] {
background-color: var(--surface-container-low);
border: 1px solid var(--outline-variant);
border-radius: 0.75rem;
padding: 8px 16px;
color: var(--on-surface);
font-family: 'Inter', sans-serif;
font-size: 14px;
line-height: 20px;
}
input[type="date"]::-webkit-calendar-picker-indicator,
input[type="datetime-local"]::-webkit-calendar-picker-indicator,
input[type="time"]::-webkit-calendar-picker-indicator {
filter: invert(0.5);
cursor: pointer;
}
#umbral-admin-content { overflow-x: hidden; }
.scrollbar-none::-webkit-scrollbar { display: none; }
.scrollbar-none { -ms-overflow-style: none; scrollbar-width: none; }
.admin-scrollbar {
scrollbar-width: thin;
scrollbar-color: var(--outline-variant) transparent;
}
.admin-scrollbar::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.admin-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.admin-scrollbar::-webkit-scrollbar-thumb {
background: var(--outline-variant);
border-radius: 9999px;
}
.admin-scrollbar::-webkit-scrollbar-thumb:hover {
background: var(--outline);
}
:root {
--admin-sidebar-width: 260px;
--admin-sidebar-collapsed: 68px;
}
#umbral-admin-sidebar {
width: var(--admin-sidebar-width);
transform: translateX(0);
transition: width 180ms cubic-bezier(0.22, 1, 0.36, 1), transform 180ms cubic-bezier(0.22, 1, 0.36, 1);
}
#umbral-admin-main {
margin-left: var(--admin-sidebar-width);
transition: margin-left 180ms cubic-bezier(0.22, 1, 0.36, 1);
}
.sidebar-text,
.sidebar-meta,
.sidebar-search-wrap,
.sidebar-section-label,
.sidebar-footer-text {
transition: opacity 120ms ease, transform 120ms ease;
}
body.sidebar-collapsed #umbral-admin-sidebar {
width: var(--admin-sidebar-collapsed);
scrollbar-width: thin;
scrollbar-color: var(--outline-variant) var(--surface-container);
}
body.sidebar-collapsed #umbral-admin-sidebar::-webkit-scrollbar {
width: 2px;
}
body.sidebar-collapsed #umbral-admin-sidebar::-webkit-scrollbar-track {
background: var(--surface-container);
}
body.sidebar-collapsed #umbral-admin-main {
margin-left: var(--admin-sidebar-collapsed);
}
body.sidebar-collapsed .sidebar-text,
body.sidebar-collapsed .sidebar-meta,
body.sidebar-collapsed .sidebar-search-wrap,
body.sidebar-collapsed .sidebar-section-label,
body.sidebar-collapsed .sidebar-footer-text {
display: none;
opacity: 0;
transform: translateX(-4px);
pointer-events: none;
width: 0;
max-width: 0;
overflow: hidden;
}
body.sidebar-collapsed .sidebar-link {
justify-content: center;
padding-left: 0.625rem;
padding-right: 0.625rem;
}
#umbral-admin-mobile-scrim {
display: none;
}
#umbral-sidebar-tooltip {
position: fixed;
z-index: 90;
left: calc(var(--admin-sidebar-collapsed) + 10px);
top: 0;
display: none;
max-width: 220px;
padding: 6px 9px;
border: 1px solid var(--outline-variant);
border-radius: 8px;
background: var(--inverse-surface);
color: var(--inverse-on-surface);
font-size: 12px;
line-height: 16px;
font-weight: 500;
box-shadow: 0 12px 32px oklch(0.12 0.01 75 / 0.22);
pointer-events: none;
white-space: nowrap;
}
#umbral-palette-results {
scrollbar-width: thin;
scrollbar-color: var(--outline-variant) var(--surface);
}
#umbral-palette-results::-webkit-scrollbar {
width: 6px;
}
#umbral-palette-results::-webkit-scrollbar-track {
background: var(--surface);
}
#umbral-palette-results::-webkit-scrollbar-thumb {
background: var(--outline-variant);
border: 1px solid var(--surface);
border-radius: 9999px;
}
#umbral-palette-results::-webkit-scrollbar-thumb:hover {
background: var(--outline);
}
body.sidebar-collapsed #umbral-sidebar-tooltip[data-open="true"] {
display: block;
}
.umbral-widget-card {
grid-column: span var(--widget-cols) / span var(--widget-cols);
min-height: calc(132px * var(--widget-rows));
}
@media (max-width: 1023px) {
.umbral-widget-card {
grid-column: span 6 / span 6;
}
}
@media (max-width: 767px) {
#umbral-admin-sidebar {
width: min(86vw, 320px);
transform: translateX(-105%);
box-shadow: 0 24px 80px oklch(0.12 0.01 75 / 0.35);
}
body.sidebar-open #umbral-admin-sidebar {
transform: translateX(0);
}
#umbral-admin-main {
margin-left: 0;
}
body.sidebar-open #umbral-admin-mobile-scrim {
display: block;
}
.umbral-widget-card {
grid-column: 1 / -1;
min-height: calc(128px * var(--widget-rows));
}
}
</style>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
colors: {
"surface-container-lowest": "var(--surface-container-lowest)",
"surface-container-low": "var(--surface-container-low)",
"surface-container": "var(--surface-container)",
"surface-container-high": "var(--surface-container-high)",
"surface-container-highest": "var(--surface-container-highest)",
"surface-bright": "var(--surface-bright)",
"surface-dim": "var(--surface-dim)",
"surface": "var(--surface)",
"surface-variant": "var(--surface-variant)",
"surface-tint": "var(--surface-tint)",
"on-surface": "var(--on-surface)",
"on-surface-variant": "var(--on-surface-variant)",
"inverse-surface": "var(--inverse-surface)",
"inverse-on-surface": "var(--inverse-on-surface)",
"outline": "var(--outline)",
"outline-variant": "var(--outline-variant)",
"background": "var(--background)",
"on-background": "var(--on-background)",
"primary": "var(--primary)",
"on-primary": "var(--on-primary)",
"primary-container": "var(--primary-container)",
"on-primary-container": "var(--on-primary-container)",
"primary-fixed": "var(--primary-fixed)",
"primary-fixed-dim": "var(--primary-fixed-dim)",
"on-primary-fixed": "var(--on-primary-fixed)",
"on-primary-fixed-variant": "var(--on-primary-fixed-variant)",
"inverse-primary": "var(--inverse-primary)",
"secondary": "var(--secondary)",
"on-secondary": "var(--on-secondary)",
"secondary-container": "var(--secondary-container)",
"on-secondary-container": "var(--on-secondary-container)",
"secondary-fixed": "var(--secondary-fixed)",
"secondary-fixed-dim": "var(--secondary-fixed-dim)",
"on-secondary-fixed": "var(--on-secondary-fixed)",
"on-secondary-fixed-variant": "var(--on-secondary-fixed-variant)",
"tertiary": "var(--tertiary)",
"on-tertiary": "var(--on-tertiary)",
"tertiary-container": "var(--tertiary-container)",
"on-tertiary-container": "var(--on-tertiary-container)",
"tertiary-fixed": "var(--tertiary-fixed)",
"tertiary-fixed-dim": "var(--tertiary-fixed-dim)",
"on-tertiary-fixed": "var(--on-tertiary-fixed)",
"on-tertiary-fixed-variant": "var(--on-tertiary-fixed-variant)",
"error": "var(--error)",
"on-error": "var(--on-error)",
"error-container": "var(--error-container)",
"on-error-container": "var(--on-error-container)",
},
spacing: {
"sidebar-width": "260px",
"sidebar-collapsed": "68px",
"topbar-height": "56px",
"gutter": "20px",
"base": "4px",
"xs": "4px",
"sm": "8px",
"md": "16px",
"lg": "24px",
"xl": "32px",
},
borderRadius: {
DEFAULT: "0.125rem",
sm: "0.125rem",
lg: "0.25rem",
xl: "0.5rem",
"2xl": "0.75rem",
"auth-card": "14px",
full: "9999px",
},
fontFamily: {
display: ["Inter"],
h1: ["Inter"],
h2: ["Inter"],
h3: ["Inter"],
"body-lg": ["Inter"],
"body-md": ["Inter"],
"body-sm": ["Inter"],
"label-md": ["Inter"],
"label-sm": ["Inter"],
"data-mono":["Inter"],
},
fontSize: {
display: ["30px", { lineHeight:"38px", letterSpacing:"-0.02em", fontWeight:"700" }],
h1: ["24px", { lineHeight:"32px", letterSpacing:"-0.015em", fontWeight:"600" }],
h2: ["20px", { lineHeight:"28px", letterSpacing:"-0.01em", fontWeight:"600" }],
h3: ["16px", { lineHeight:"24px", fontWeight:"600" }],
"body-lg": ["16px", { lineHeight:"24px", fontWeight:"400" }],
"body-md": ["14px", { lineHeight:"20px", fontWeight:"400" }],
"body-sm": ["13px", { lineHeight:"18px", fontWeight:"400" }],
"label-md": ["12px", { lineHeight:"16px", letterSpacing:"0.01em", fontWeight:"500" }],
"label-sm": ["11px", { lineHeight:"14px", letterSpacing:"0.03em", fontWeight:"600" }],
"data-mono":["14px", { lineHeight:"20px", fontWeight:"400" }],
},
},
},
};
</script>
{% block extra_head %}{% endblock %}
</head>
<body hx-headers='{"X-CSRF-Token": "{{ csrf_token }}"}' {% block body_attrs %}class="bg-background text-on-surface antialiased"{% endblock %}>
{% block body %}{% endblock %}
<script>var umbralAdminBase = '{{ admin_base }}';</script>
<script src="{{ static("admin/admin.js") }}" defer></script>
{% block extra_scripts %}{% endblock %}
{# Command palette slot — loaded by ⌘K / Ctrl-K #}
<div id="umbral-palette-slot"></div>
{# Toast notification container #}
<div id="umbral-toast-container" class="fixed bottom-4 right-4 z-[300] flex flex-col gap-sm pointer-events-none" aria-live="polite"></div>
{# gaps2 #3: change-password dialog as a `<template>` element
rather than JS-built string concatenation. The JS opener clones
the content and patches the form's `hx-post` to the right
`<table>/<id>/change-password` URL. Designers and Tailwind's
content scanner both see real HTML; the form's classes get
compiled into shop.css the same way every other markup block
does. #}
<template id="umbral-change-password-dialog-template">
<div id="umbral-dialog-overlay"
class="fixed inset-0 z-[200] flex items-center justify-center bg-background/60 backdrop-blur-sm"
onclick="if(event.target===this)umbral.closeDialog()"
role="dialog"
aria-modal="true">
<div class="bg-surface-container border border-outline-variant rounded-[14px] shadow-2xl w-full max-w-sm mx-md p-xl flex flex-col gap-lg">
<div class="flex items-center gap-md">
<div class="w-10 h-10 rounded-xl bg-primary-container/20 border border-primary/20 flex items-center justify-center flex-shrink-0">
<i data-lucide="key" class="w-5 h-5 text-primary"></i>
</div>
<h2 class="font-h3 text-h3 text-on-surface">Change password</h2>
</div>
<form data-change-pw-form
class="space-y-sm"
hx-swap="none"
hx-on::after-request="umbral.closeDialog()">
<input type="password" name="new_password" placeholder="New password" required
autocomplete="new-password"
class="w-full bg-surface-container-low border border-outline-variant rounded-xl px-md py-sm text-body-md text-on-surface focus:outline-none focus:ring-1 focus:ring-primary" />
<input type="password" name="confirm_password" placeholder="Confirm password" required
autocomplete="new-password"
class="w-full bg-surface-container-low border border-outline-variant rounded-xl px-md py-sm text-body-md text-on-surface focus:outline-none focus:ring-1 focus:ring-primary" />
<div class="flex justify-end gap-md pt-sm">
<button type="button" onclick="umbral.closeDialog()"
class="px-lg py-sm rounded-xl border border-outline-variant text-on-surface-variant hover:bg-surface-container-high font-label-md text-label-md transition-all">Cancel</button>
<button type="submit"
class="px-lg py-sm rounded-xl bg-primary text-on-primary hover:opacity-90 font-label-md text-label-md transition-all">Change</button>
</div>
</form>
</div>
</div>
</template>
</body>
</html>