Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 138 additions & 0 deletions assets/app.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,141 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('../fonts/InterVariable.woff2') format('woff2');
}

@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('../fonts/JetBrainsMono-Regular.woff2') format('woff2');
}

/* Accent shades as RGB triples so Tailwind opacity modifiers keep working
(see tailwind.config.js). Dark values first, light remaps below. */
:root {
--color-primary-light: 52 130 239;
--color-secondary-light: 130 103 243;
--color-tertiary-light: 21 211 241;
color-scheme: dark;
}

:root[data-theme="light"] {
--color-primary-light: 17 102 221;
--color-secondary-light: 99 65 240;
--color-tertiary-light: 14 116 144;
color-scheme: light;
}

html {
@apply h-full bg-slate-950 text-gray-300 antialiased;
/* Always reserve the scrollbar's space, otherwise the centered content
shifts sideways between short pages and pages that scroll. */
scrollbar-gutter: stable;
}

html[data-theme="light"] {
@apply bg-slate-50 text-slate-600;
}

body {
@apply h-full font-sans;
}

/* Code ligatures would turn `->` into an arrow glyph; readers copy what
they see, so they stay off. */
:where(pre, code, kbd, samp, .font-mono) {
font-variant-ligatures: none;
font-feature-settings: "liga" 0, "calt" 0;
}

::selection {
background-color: #1e3a8a; /* blue-900 */
}

:root[data-theme="light"] ::selection {
background-color: #bfdbfe; /* blue-200 */
}

* {
scrollbar-width: thin;
scrollbar-color: #334155 transparent; /* slate-700 */
}

:root[data-theme="light"] * {
scrollbar-color: #cbd5e1 transparent; /* slate-300 */
}

@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
}

@layer components {
/* Code and data windows stay dark in both themes, like the code samples on
patchlevel.dev: the dump is a terminal, not a document. */
.code-window {
@apply rounded-xl border border-slate-800 bg-slate-950 text-gray-300 light:border-slate-800 light:bg-slate-900;
}

.json {
@apply code-window overflow-x-auto px-4 py-3 font-mono text-xs leading-relaxed whitespace-pre;
}

.json_key, .json_key span {
@apply text-tertiary-light light:text-[#15d3f1];
}

.json_string, .json_string span {
@apply text-green-400;
}

.json_number {
@apply text-primary-light light:text-[#3482ef];
}

.json_bool {
@apply text-amber-300;
}
}

/* Plain CSS, outside @layer: these selectors never appear in the templates, so a
layered rule would be purged as unused. */
dialog[open] {
animation: dialog-in 200ms cubic-bezier(0.16, 1, 0.3, 1);
}

dialog::backdrop {
background: rgb(2 6 23 / 0.7);
backdrop-filter: blur(4px);
}

:root[data-theme="light"] dialog::backdrop {
background: rgb(15 23 42 / 0.4);
}

@keyframes dialog-in {
from {
opacity: 0;
transform: translateY(8px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
169 changes: 169 additions & 0 deletions assets/app.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import './app.css';

/* --- Preferences: theme and content width ------------------------------ */

const lightQuery = window.matchMedia('(prefers-color-scheme: light)');

function applyTheme(preference) {
const light = preference === 'light' || (preference === 'auto' && lightQuery.matches);
document.documentElement.setAttribute('data-theme', light ? 'light' : 'dark');
}

function applyWidth(preference) {
if (preference === 'full') {
document.documentElement.setAttribute('data-width', 'full');
} else {
document.documentElement.removeAttribute('data-width');
}
}

// The inline script in base.html.twig applies the stored values before paint; this only wires the switches.
const preferences = {
theme: {key: 'patchlevel-dashboard-theme', values: ['light', 'dark', 'auto'], fallback: 'auto', apply: applyTheme},
width: {key: 'patchlevel-dashboard-width', values: ['fixed', 'full'], fallback: 'fixed', apply: applyWidth},
};

function readPreference(name) {
const {key, values, fallback} = preferences[name];

try {
const stored = localStorage.getItem(key);
if (values.includes(stored)) {
return stored;
}
} catch {
// storage disabled
}

return fallback;
}

function syncSwitch(name, value) {
document.querySelectorAll(`[data-pref="${name}"]`).forEach((button) => {
button.setAttribute('aria-checked', String(button.dataset.value === value));
});
}

document.querySelectorAll('[data-pref]').forEach((button) => {
button.addEventListener('click', () => {
const name = button.dataset.pref;
const value = button.dataset.value;

try {
localStorage.setItem(preferences[name].key, value);
} catch {
// storage disabled
}

preferences[name].apply(value);
syncSwitch(name, value);
});
});

Object.keys(preferences).forEach((name) => syncSwitch(name, readPreference(name)));

lightQuery.addEventListener('change', () => {
if (readPreference('theme') === 'auto') {
applyTheme('auto');
}
});

/* --- Mobile navigation -------------------------------------------------- */

const sidebar = document.getElementById('sidebar');
const sidebarBackdrop = document.getElementById('sidebar-backdrop');

function setSidebar(open) {
if (!sidebar) {
return;
}

sidebar.classList.toggle('-translate-x-full', !open);
sidebarBackdrop?.classList.toggle('hidden', !open);
document.querySelectorAll('[data-sidebar-toggle]').forEach((button) => {
button.setAttribute('aria-expanded', String(open));
});

if (open) {
sidebar.querySelector('a, button')?.focus();
}
}

document.querySelectorAll('[data-sidebar-toggle]').forEach((button) => {
button.addEventListener('click', () => {
setSidebar(sidebar.classList.contains('-translate-x-full'));
});
});

sidebarBackdrop?.addEventListener('click', () => setSidebar(false));

window.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && sidebar && !sidebar.classList.contains('-translate-x-full')) {
setSidebar(false);
}
});

/* --- Detail dialogs ----------------------------------------------------- */

document.querySelectorAll('[data-modal]').forEach((button) => {
button.addEventListener('click', (event) => {
event.preventDefault();
document.getElementById(button.dataset.modal)?.showModal();
});
});

document.querySelectorAll('dialog').forEach((dialog) => {
dialog.querySelectorAll('[data-modal-close]').forEach((button) => {
button.addEventListener('click', () => dialog.close());
});

// A click on the backdrop lands on the dialog element itself.
dialog.addEventListener('click', (event) => {
if (event.target === dialog) {
dialog.close();
}
});
});

/* --- Copy to clipboard -------------------------------------------------- */

document.querySelectorAll('[data-clipboard]').forEach((button) => {
button.addEventListener('click', (event) => {
event.preventDefault();
navigator.clipboard.writeText(button.dataset.clipboard).then(() => {
button.setAttribute('data-copied', '');
window.setTimeout(() => button.removeAttribute('data-copied'), 1500);
});
});
});

/* --- Inspection: mark the event the URL hash points at ------------------ */

if (window.location.hash) {
const marker = document.getElementById('marker-' + window.location.hash.slice(1));
marker?.classList.remove('invisible');
}

document.querySelectorAll('[data-add-hash]').forEach((element) => {
element.href += window.location.hash;
});

/* --- JSON highlighting -------------------------------------------------- */

function escapeHtml(text) {
return text.replace(/[<>&]/g, (char) => ({'<': '&lt;', '>': '&gt;', '&': '&amp;'}[char]));
}

function highlightJson(text) {
return text
.replace(/(("([^"]|\\")+?[^\\]")|"")(\s*.)/ig, (str, g1, g2, g3, g4) => {
const type = g4.trim() === ':' ? 'json_key' : 'json_string';
return "<span class='" + type + "'>" + escapeHtml(g1) + '</span>' + g4;
})
.replace(/-?\d+\.?\d*((E|e)[+]\d+)?/ig, "<span class='json_number'>$&</span>")
.replace(/false|true|null/ig, "<span class='json_bool'>$&</span>");
}

document.querySelectorAll('.json').forEach((element) => {
element.innerHTML = highlightJson(element.innerHTML);
});
7 changes: 7 additions & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,13 @@ bundle assets are picked up automatically. The bundle depends on `symfony/asset`
`symfony/asset-mapper` for this.
:::

## Appearance

The dashboard follows your operating system's color scheme by default. The switches at the
bottom of the sidebar let you pin it to light or dark, and choose between a fixed content width
and one that fits the window. Both choices are stored in the browser's local storage, so they
survive reloads and apply to every dashboard page.

## Open the dashboard

Start your application and visit the prefix you chose:
Expand Down
1 change: 1 addition & 0 deletions docs/introduction.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ production once it is placed behind proper authentication.
* List all registered [events](events.md) together with their listeners and subscribers
* View and control [subscriptions](subscriptions.md): boot, run, pause, reactivate, rebuild or remove
* [Customize](events.md) how events are rendered with the `#[Inspect]` attribute
* Light and dark mode and a fixed or full content width, chosen in the sidebar

## Installation

Expand Down
Loading
Loading