Compare commits

...

13 Commits

Author SHA1 Message Date
1233851b87 Fixed missing * in CSS comments. 2026-03-14 03:44:45 -04:00
b6d7060e79 Added descriptive comments to the script file. 2026-03-14 03:44:20 -04:00
90dcc10956 Update tab content corner radius consistency 2026-03-12 12:26:52 -04:00
29f9371c1e Add skeuomorphic modern style support 2026-03-12 12:18:04 -04:00
edc6d84ede Add Synthwave, Vaporwave, Terminal, and High Contrast styles. 2026-03-12 04:16:26 -04:00
fc3e81c2d6 Add Apple-inspired Flat Design style 2026-03-12 03:13:17 -04:00
Miguel Astor
1472c14d44 Add Material Design style 2026-03-08 12:36:14 -04:00
Miguel Astor
c0c25e2719 Add By Runner and By Source charts to playtime reports
- Add runners and sources charts to modern and platinum templates
- Update responsive layout to use grid for multiple charts
- Update .gitignore to exclude pga.db
2026-03-06 06:19:06 -04:00
Miguel Astor
afd11fba3a Separated the modern theme script into it's own file. 2026-03-03 03:56:39 -04:00
Miguel Astor
b56b7176a8 Extract CSS styles to separate files for better maintainability
- Create templates/brutalism.css, glassmorphism.css, neumorphism.css
- Update styles.py to read CSS from files instead of inline strings
- Support --template with .css files (auto-detects modern style)
- CSS files can now be edited with proper syntax highlighting

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-03 03:29:25 -04:00
Miguel Astor
15a8072804 Unify modern templates into single template with style system
- Create templates/modern.html as unified base for brutalism, glassmorphism, neumorphism
- Add styles.py with CSS and chart config for each style
- Add --style argument to generate_report.py (overrides --template)
- Remove individual brutalism.html, glassmorphism.html, neumorphism.html
- Keep platinum.html separate due to unique Mac OS 9 structure

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-03 03:23:43 -04:00
Miguel Astor
31e8d152ae Add By Runner tab to summaries section in all templates
Extract runner field from Lutris database and display playtime
grouped by runner (wine, linux, steam, dosbox, etc.) in a new
third tab alongside Top Games and By Category.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-03 03:09:29 -04:00
Miguel Astor
aa9719cbfe Update CLAUDE.md documentation
- Document all four available templates (platinum, brutalism, glassmorphism, neumorphism)
- Fix template path to use templates/ folder
- Update theme toggle description (auto/light/dark with persistent preference)
- Add responsive design mention
- Fix filtered categories list

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-26 17:29:28 -04:00
21 changed files with 7539 additions and 3754 deletions

1
.gitignore vendored
View File

@@ -259,3 +259,4 @@ flycheck_*.el
# Built Visual Studio Code Extensions
*.vsix
pga.db

80
AGENTS.md Normal file
View File

@@ -0,0 +1,80 @@
# CLAUDE.md
This file provides guidance to AI coding agents when working with code in this repository.
## Project Overview
Lutris Year in Review is a Python script that generates a static HTML playtime report from a Lutris gaming platform SQLite database. The report displays game playtime statistics with interactive Chart.js visualizations.
## Commands
Generate report with defaults:
```bash
python generate_report.py
```
Generate report with modern style:
```bash
python generate_report.py --style glassmorphism --output report.html
python generate_report.py --style brutalism --output report.html
python generate_report.py --style skeuo --output report.html
python generate_report.py --style neumorphism --output report.html
python generate_report.py --style material --output report.html
python generate_report.py --style flat --output report.html
python generate_report.py --style synthwave --output report.html
python generate_report.py --style vaporwave --output report.html
python generate_report.py --style terminal --output report.html
python generate_report.py --style highcontrast --output report.html
```
Generate report with legacy Platinum template:
```bash
python generate_report.py --template templates/platinum.html --output report.html
```
All options:
```bash
python generate_report.py --db pga.db --output report.html --top 10 --background background.png --style glassmorphism
```
## Architecture
**Report generator (`generate_report.py`):**
- Reads Lutris SQLite database (`pga.db`) containing games, categories, and playtime data
- Embeds all data (games JSON, background image as base64) directly into a self-contained HTML file
- Loads HTML template from `templates/` folder (default: `templates/platinum.html`)
**HTML templates (`templates/`):**
- **modern.html**: Unified template for modern styles (brutalism, glassmorphism, skeuo, neumorphism, material, flat, synthwave, vaporwave, terminal, highcontrast)
- **platinum.html**: Legacy Mac OS 9 Platinum visual style (separate template due to unique structure)
- All templates use Chart.js doughnut charts and dynamic JavaScript filtering
- Placeholder tokens like `__ALL_GAMES__`, `__BACKGROUND_IMAGE__` are replaced at generation time
- Modern templates support light/dark/auto theme toggle button
**Style system (`styles.py`):**
- CSS definitions for each modern style (brutalism, glassmorphism, skeuo, neumorphism, material, flat, synthwave, vaporwave, terminal, highcontrast)
- Theme configurations (colors, fonts, chart options) injected via `__THEME_CSS__`
- Use `--style` argument to select a modern style instead of `--template`
**Javascript (`templates/script.js`):**
- A single common script is used for each modern style (brutalism, glassmorphism, skeuo, neumorphism, material, flat, synthwave, vaporwave, terminal, highcontrast)
- Theme configurations (colors, fonts, chart options) injected via `__THEME_CONFIG__`
- Data inserted via `__ALL_GAMES__` and `__TOP_N__`
**Database schema (`schema.py`):**
- Reference file documenting Lutris database structure
- Key tables: `games` (with `playtime`, `service` fields), `categories`, `games_categories` (many-to-many join)
**Generated output (`report.html`):**
- Fully static, can be hosted on any web server
- Client-side filtering by service (Steam, GOG, itch.io, local)
- Expandable "Others" row in games table
- Light/dark/auto theme toggle button with persistent preference
- Responsive design for mobile and desktop
## Key Data Relationships
- Games have a `service` field (steam, gog, itchio, humblebundle, or NULL for local)
- Games link to categories via `games_categories` join table
- Categories like `.hidden` and `favorite` are filtered out in the report display
- `playtime` is cumulative hours (REAL), not per-session data

View File

@@ -1,48 +1 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Lutris Year in Review is a Python script that generates a static HTML playtime report from a Lutris gaming platform SQLite database. The report displays game playtime statistics with interactive Chart.js visualizations.
## Commands
Generate report with defaults:
```bash
python generate_report.py
```
Generate report with custom options:
```bash
python generate_report.py --db pga.db --output report.html --top 10 --background background.png --template platinum.html
```
## Architecture
**Report generator (`generate_report.py`):**
- Reads Lutris SQLite database (`pga.db`) containing games, categories, and playtime data
- Embeds all data (games JSON, background image as base64) directly into a self-contained HTML file
- Loads HTML template from external file (default: `platinum.html`)
**HTML template (`platinum.html`):**
- Chart.js doughnut charts and dynamic JavaScript filtering
- Mac OS 9 Platinum visual style with placeholder tokens for assets
- Tokens like `__ALL_GAMES__`, `__BACKGROUND_IMAGE__` are replaced at generation time
**Database schema (`schema.py`):**
- Reference file documenting Lutris database structure
- Key tables: `games` (with `playtime`, `service` fields), `categories`, `games_categories` (many-to-many join)
**Generated output (`report.html`):**
- Fully static, can be hosted on any web server
- Client-side filtering by service (Steam, GOG, itch.io, local)
- Expandable "Others" row in games table
- Light/dark mode support via CSS `prefers-color-scheme`
## Key Data Relationships
- Games have a `service` field (steam, gog, itchio, humblebundle, or NULL for local)
- Games link to categories via `games_categories` join table
- Categories like `.hidden`, `favorite`, `Horny` are filtered out in the report
- `playtime` is cumulative hours (REAL), not per-session data
@AGENTS.md

View File

@@ -21,9 +21,25 @@ import json
import sqlite3
from pathlib import Path
from styles import get_theme_css, get_theme_config
# Directory where this script is located (for finding template.html)
SCRIPT_DIR = Path(__file__).parent
# Modern styles that use the unified template
MODERN_STYLES = [
"brutalism",
"glassmorphism",
"skeuo",
"neumorphism",
"material",
"flat",
"synthwave",
"vaporwave",
"terminal",
"highcontrast",
]
def load_template(template_file: str) -> str:
"""Load the HTML template from the specified file."""
@@ -31,6 +47,12 @@ def load_template(template_file: str) -> str:
return template_path.read_text(encoding="utf-8")
def load_script(script_file: str) -> str:
"""Load the JS script from the specified file."""
script_path = SCRIPT_DIR / script_file
return script_path.read_text(encoding="utf-8")
def load_asset_as_base64(path: Path, mime_type: str) -> str:
"""Load a file and return it as a base64 data URL."""
if path.exists():
@@ -49,7 +71,7 @@ def get_all_games(db_path: str) -> tuple[list[dict], int]:
total_library = cursor.fetchone()[0]
cursor.execute("""
SELECT id, name, playtime, COALESCE(service, 'local') as service
SELECT id, name, playtime, COALESCE(service, 'local') as service, COALESCE(runner, 'unknown') as runner
FROM games
WHERE playtime > 0
ORDER BY playtime DESC
@@ -75,14 +97,23 @@ def get_all_games(db_path: str) -> tuple[list[dict], int]:
"name": row[1],
"playtime": row[2],
"service": row[3],
"categories": game_categories.get(row[0], [])
"runner": row[4],
"categories": game_categories.get(row[0], []),
}
for row in games_rows
]
return games, total_library
def generate_report(db_path: str, output_path: str, top_n: int, assets_dir: str, template_file: str, bg_image_path: str = None) -> None:
def generate_report(
db_path: str,
output_path: str,
top_n: int,
assets_dir: str,
template_file: str,
bg_image_path: str | None = None,
style: str | None = None,
) -> None:
"""Generate the HTML report."""
all_games, total_library = get_all_games(db_path)
@@ -100,31 +131,84 @@ def generate_report(db_path: str, output_path: str, top_n: int, assets_dir: str,
background_image = load_asset_as_base64(Path(bg_image_path), "image/png")
background_image_custom = f"url('{background_image}')"
else:
background_image = load_asset_as_base64(assets_path / "Others" / "stripes.png", "image/png")
background_image_custom = "none" # For templates that prefer no default background
background_image = load_asset_as_base64(
assets_path / "Others" / "stripes.png", "image/png"
)
background_image_custom = (
"none" # For templates that prefer no default background
)
# Check if using modern unified template
if style and style in MODERN_STYLES:
html = load_template("templates/modern.html")
# Inject theme CSS and config
theme_css = get_theme_css(style)
theme_config = get_theme_config(style)
# Inject javascript
javascript = load_script("templates/script.js")
javascript = javascript.replace("__ALL_GAMES__", json.dumps(all_games))
javascript = javascript.replace("__TOP_N__", str(top_n))
javascript = javascript.replace("__THEME_CONFIG__", theme_config)
html = html.replace("__THEME_CSS__", theme_css)
html = html.replace("__SCRIPT__", javascript)
html = html.replace("__TOTAL_LIBRARY__", str(total_library))
html = html.replace("__BACKGROUND_IMAGE__", background_image)
html = html.replace("__BACKGROUND_IMAGE_CUSTOM__", background_image_custom)
else:
# Legacy template handling (platinum and others)
# Load fonts
font_charcoal = load_asset_as_base64(assets_path / "Charcoal.ttf", "font/truetype")
font_charcoal = load_asset_as_base64(
assets_path / "Charcoal.ttf", "font/truetype"
)
font_monaco = load_asset_as_base64(assets_path / "MONACO.TTF", "font/truetype")
# Load images
titlebar_bg = load_asset_as_base64(assets_path / "Windows" / "title-1-active.png", "image/png")
title_stripes = load_asset_as_base64(assets_path / "Windows" / "title-1-active.png", "image/png")
close_btn = load_asset_as_base64(assets_path / "Windows" / "close-active.png", "image/png")
hide_btn = load_asset_as_base64(assets_path / "Windows" / "maximize-active.png", "image/png")
shade_btn = load_asset_as_base64(assets_path / "Windows" / "shade-active.png", "image/png")
check_off = load_asset_as_base64(assets_path / "Check-Radio" / "check-normal.png", "image/png")
check_on = load_asset_as_base64(assets_path / "Check-Radio" / "check-active.png", "image/png")
titlebar_bg = load_asset_as_base64(
assets_path / "Windows" / "title-1-active.png", "image/png"
)
title_stripes = load_asset_as_base64(
assets_path / "Windows" / "title-1-active.png", "image/png"
)
close_btn = load_asset_as_base64(
assets_path / "Windows" / "close-active.png", "image/png"
)
hide_btn = load_asset_as_base64(
assets_path / "Windows" / "maximize-active.png", "image/png"
)
shade_btn = load_asset_as_base64(
assets_path / "Windows" / "shade-active.png", "image/png"
)
check_off = load_asset_as_base64(
assets_path / "Check-Radio" / "check-normal.png", "image/png"
)
check_on = load_asset_as_base64(
assets_path / "Check-Radio" / "check-active.png", "image/png"
)
# Load scrollbar images
scrollbar_trough_v = load_asset_as_base64(assets_path / "Scrollbars" / "trough-scrollbar-vert.png", "image/png")
scrollbar_thumb_v = load_asset_as_base64(assets_path / "Scrollbars" / "slider-vertical.png", "image/png")
scrollbar_up = load_asset_as_base64(assets_path / "Scrollbars" / "stepper-up.png", "image/png")
scrollbar_down = load_asset_as_base64(assets_path / "Scrollbars" / "stepper-down.png", "image/png")
scrollbar_trough_v = load_asset_as_base64(
assets_path / "Scrollbars" / "trough-scrollbar-vert.png", "image/png"
)
scrollbar_thumb_v = load_asset_as_base64(
assets_path / "Scrollbars" / "slider-vertical.png", "image/png"
)
scrollbar_up = load_asset_as_base64(
assets_path / "Scrollbars" / "stepper-up.png", "image/png"
)
scrollbar_down = load_asset_as_base64(
assets_path / "Scrollbars" / "stepper-down.png", "image/png"
)
# Load tab images
tab_active = load_asset_as_base64(assets_path / "Tabs" / "tab-top-active.png", "image/png")
tab_inactive = load_asset_as_base64(assets_path / "Tabs" / "tab-top.png", "image/png")
tab_active = load_asset_as_base64(
assets_path / "Tabs" / "tab-top-active.png", "image/png"
)
tab_inactive = load_asset_as_base64(
assets_path / "Tabs" / "tab-top.png", "image/png"
)
html = load_template(template_file)
html = html.replace("__ALL_GAMES__", json.dumps(all_games))
@@ -162,33 +246,50 @@ def main():
parser.add_argument(
"--db",
default="pga.db",
help="Path to the Lutris SQLite database (default: pga.db)"
help="Path to the Lutris SQLite database (default: pga.db)",
)
parser.add_argument(
"--output",
default="report.html",
help="Output HTML file path (default: report.html)"
help="Output HTML file path (default: report.html)",
)
parser.add_argument(
"--top",
type=int,
default=10,
help="Number of top games to show individually (default: 10)"
help="Number of top games to show individually (default: 10)",
)
parser.add_argument(
"--assets",
default="templates/Platinum",
help="Path to Platinum assets directory (default: templates/Platinum)"
help="Path to Platinum assets directory (default: templates/Platinum)",
)
parser.add_argument(
"--background",
default=None,
help="Path to background image for tiling (default: Platinum stripes pattern)"
help="Path to background image for tiling (default: Platinum stripes pattern)",
)
parser.add_argument(
"--template",
default="templates/platinum.html",
help="HTML template file to use (default: templates/platinum.html)"
help="HTML template file to use (default: templates/platinum.html). Ignored if --style is used.",
)
parser.add_argument(
"--style",
choices=[
"brutalism",
"glassmorphism",
"skeuo",
"neumorphism",
"material",
"flat",
"synthwave",
"vaporwave",
"terminal",
"highcontrast",
],
default=None,
help="Modern style to use (brutalism, glassmorphism, skeuo, neumorphism, material, flat, synthwave, vaporwave, terminal, highcontrast). Overrides --template.",
)
args = parser.parse_args()
@@ -201,12 +302,39 @@ def main():
print(f"Error: Assets directory not found: {args.assets}")
return 1
# Determine style from --style or --template
style = args.style
# If no --style provided, check if --template points to a modern CSS file
if not style and args.template.endswith(".css"):
template_path = Path(args.template)
style_name = template_path.stem # e.g., "brutalism" from "brutalism.css"
if style_name in MODERN_STYLES:
style = style_name
# Validate template/style
if style and style in MODERN_STYLES:
template_path = SCRIPT_DIR / "templates" / "modern.html"
css_path = SCRIPT_DIR / "templates" / f"{style}.css"
if not css_path.exists():
print(f"Error: CSS file not found: {css_path}")
return 1
else:
template_path = SCRIPT_DIR / args.template
if not template_path.exists():
print(f"Error: Template file not found: {template_path}")
return 1
generate_report(args.db, args.output, args.top, args.assets, args.template, args.background)
generate_report(
args.db,
args.output,
args.top,
args.assets,
args.template,
args.background,
style,
)
return 0

341
styles.py Normal file
View File

@@ -0,0 +1,341 @@
"""CSS styles and theme configurations for modern templates."""
####################################################################################################
# Copyright (C) 2026 by WallyHackenslacker wallyhackenslacker@noreply.git.hackenslacker.space #
# #
# Permission to use, copy, modify, and/or distribute this software for any purpose with or without #
# fee is hereby granted. #
# #
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS #
# SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE #
# AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES #
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, #
# NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE #
# OF THIS SOFTWARE. #
####################################################################################################
import json
from pathlib import Path
# Directory where templates are located
TEMPLATES_DIR = Path(__file__).parent / "templates"
# Theme configurations for Chart.js
THEME_CONFIGS = {
"brutalism": {
"colors": [
"#ff0000",
"#0000ff",
"#ffff00",
"#00ff00",
"#ff00ff",
"#00ffff",
"#ff8800",
"#8800ff",
"#0088ff",
"#88ff00",
"#888888",
],
"fontFamily": "'Courier New', monospace",
"fontWeight": "bold",
"pointStyle": "rect",
"textColorLight": "#000000",
"textColorDark": "#ffffff",
"borderColorLight": "#000000",
"borderColorDark": "#ffffff",
"borderWidth": 3,
"tooltipBg": "#000000",
"tooltipTitleColor": "#ffffff",
"tooltipBodyColor": "#ffffff",
"tooltipBorderColor": "#ffffff",
"tooltipBorderWidth": 2,
"tooltipCornerRadius": 0,
"uppercaseTooltip": True,
},
"glassmorphism": {
"colors": [
"#6366f1",
"#8b5cf6",
"#ec4899",
"#f43f5e",
"#f97316",
"#eab308",
"#22c55e",
"#14b8a6",
"#06b6d4",
"#3b82f6",
"#64748b",
],
"fontFamily": "'Inter', sans-serif",
"fontWeight": "normal",
"pointStyle": "circle",
"textColorLight": "#1a1a2e",
"textColorDark": "#f0f0f5",
"borderColorLight": "rgba(255, 255, 255, 0.2)",
"borderColorDark": "rgba(255, 255, 255, 0.2)",
"borderWidth": 2,
"tooltipBg": "rgba(0, 0, 0, 0.8)",
"tooltipTitleColor": "#ffffff",
"tooltipBodyColor": "#ffffff",
"tooltipBorderColor": "transparent",
"tooltipBorderWidth": 0,
"tooltipCornerRadius": 8,
"uppercaseTooltip": False,
},
"skeuo": {
"colors": [
"#8b4a2c",
"#a65d37",
"#bf7748",
"#d58a60",
"#e0a97f",
"#6d4f34",
"#857153",
"#9e8362",
"#b58f6c",
"#c9a67f",
"#7f7f7f",
],
"fontFamily": "'Trebuchet MS', 'Lucida Grande', sans-serif",
"fontWeight": "600",
"pointStyle": "rectRounded",
"textColorLight": "#2c2218",
"textColorDark": "#f4e7d5",
"borderColorLight": "rgba(75, 55, 36, 0.35)",
"borderColorDark": "rgba(219, 188, 156, 0.3)",
"borderWidth": 2,
"tooltipBg": "rgba(46, 33, 23, 0.92)",
"tooltipTitleColor": "#f4e7d5",
"tooltipBodyColor": "#f4e7d5",
"tooltipBorderColor": "#d5a67f",
"tooltipBorderWidth": 1,
"tooltipCornerRadius": 10,
"uppercaseTooltip": False,
},
"neumorphism": {
"colors": [
"#6366f1",
"#8b5cf6",
"#ec4899",
"#f43f5e",
"#f97316",
"#eab308",
"#22c55e",
"#14b8a6",
"#06b6d4",
"#3b82f6",
"#64748b",
],
"fontFamily": "'Inter', sans-serif",
"fontWeight": "normal",
"pointStyle": "circle",
"textColorLight": "#2d3436",
"textColorDark": "#f0f0f5",
"borderColorLight": "rgba(255, 255, 255, 0.3)",
"borderColorDark": "rgba(255, 255, 255, 0.3)",
"borderWidth": 3,
"tooltipBg": "rgba(0, 0, 0, 0.8)",
"tooltipTitleColor": "#ffffff",
"tooltipBodyColor": "#ffffff",
"tooltipBorderColor": "transparent",
"tooltipBorderWidth": 0,
"tooltipCornerRadius": 8,
"uppercaseTooltip": False,
},
"material": {
"colors": [
"#6200ee",
"#03dac6",
"#3700b3",
"#018786",
"#b00020",
"#ff0266",
"#aa00ff",
"#0091ea",
"#00c853",
"#ffd600",
"#757575",
],
"fontFamily": "'Roboto', sans-serif",
"fontWeight": "normal",
"pointStyle": "circle",
"textColorLight": "#212121",
"textColorDark": "#ffffff",
"borderColorLight": "#ffffff",
"borderColorDark": "#1e1e1e",
"borderWidth": 2,
"tooltipBg": "rgba(97, 97, 97, 0.9)",
"tooltipTitleColor": "#ffffff",
"tooltipBodyColor": "#ffffff",
"tooltipBorderColor": "transparent",
"tooltipBorderWidth": 0,
"tooltipCornerRadius": 4,
"uppercaseTooltip": False,
},
"flat": {
"colors": [
"#ffadad",
"#ffd6a5",
"#fdffb6",
"#caffbf",
"#9bf6ff",
"#a0c4ff",
"#bdb2ff",
"#ffc6ff",
"#ff9aa2",
"#e2f0cb",
"#b5ead7",
],
"fontFamily": "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
"fontWeight": "500",
"pointStyle": "circle",
"textColorLight": "#1d1d1f",
"textColorDark": "#f5f5f7",
"borderColorLight": "#ffffff",
"borderColorDark": "#1c1c1e",
"borderWidth": 2,
"tooltipBg": "rgba(0, 0, 0, 0.8)",
"tooltipTitleColor": "#ffffff",
"tooltipBodyColor": "#ffffff",
"tooltipBorderColor": "transparent",
"tooltipBorderWidth": 0,
"tooltipCornerRadius": 12,
"uppercaseTooltip": False,
},
"synthwave": {
"colors": [
"#ff00ff",
"#00ffff",
"#ffff00",
"#ff0055",
"#00ff99",
"#7a5af8",
"#ff8800",
"#ff00aa",
"#00ccff",
"#ccff00",
"#999999",
],
"fontFamily": "'Orbitron', 'Segoe UI', sans-serif",
"fontWeight": "bold",
"pointStyle": "triangle",
"textColorLight": "#2d004d",
"textColorDark": "#ff00ff",
"borderColorLight": "rgba(255, 0, 255, 0.2)",
"borderColorDark": "rgba(0, 255, 255, 0.2)",
"borderWidth": 2,
"tooltipBg": "rgba(45, 0, 77, 0.9)",
"tooltipTitleColor": "#00ffff",
"tooltipBodyColor": "#ffffff",
"tooltipBorderColor": "#ff00ff",
"tooltipBorderWidth": 1,
"tooltipCornerRadius": 0,
"uppercaseTooltip": True,
},
"vaporwave": {
"colors": [
"#ff71ce",
"#01cdfe",
"#05ffa1",
"#b967ff",
"#fffb96",
"#ff99cc",
"#99ccff",
"#ccff99",
"#ffcc99",
"#ffffcc",
"#e0e0e0",
],
"fontFamily": "'MS PGothic', 'Palatino Linotype', serif",
"fontWeight": "normal",
"pointStyle": "circle",
"textColorLight": "#ff71ce",
"textColorDark": "#01cdfe",
"borderColorLight": "rgba(255, 255, 255, 0.5)",
"borderColorDark": "rgba(255, 255, 255, 0.2)",
"borderWidth": 1,
"tooltipBg": "rgba(255, 113, 206, 0.8)",
"tooltipTitleColor": "#ffffff",
"tooltipBodyColor": "#ffffff",
"tooltipBorderColor": "#01cdfe",
"tooltipBorderWidth": 2,
"tooltipCornerRadius": 4,
"uppercaseTooltip": False,
},
"terminal": {
"colors": [
"#00ff00",
"#00cc00",
"#009900",
"#006600",
"#003300",
"#33ff33",
"#66ff66",
"#99ff99",
"#ccffcc",
"#ffffff",
"#888888",
],
"fontFamily": "'Courier New', Courier, monospace",
"fontWeight": "bold",
"pointStyle": "rectRot",
"textColorLight": "#006600",
"textColorDark": "#00ff00",
"borderColorLight": "#000000",
"borderColorDark": "#00ff00",
"borderWidth": 1,
"tooltipBg": "#000000",
"tooltipTitleColor": "#00ff00",
"tooltipBodyColor": "#00ff00",
"tooltipBorderColor": "#00ff00",
"tooltipBorderWidth": 1,
"tooltipCornerRadius": 0,
"uppercaseTooltip": True,
},
"highcontrast": {
"colors": [
"#888888",
"#444444",
"#cccccc",
"#666666",
"#aaaaaa",
"#333333",
"#dddddd",
"#555555",
"#bbbbbb",
"#222222",
"#999999",
],
"fontFamily": "Arial, Helvetica, sans-serif",
"fontWeight": "900",
"pointStyle": "rect",
"textColorLight": "#000000",
"textColorDark": "#ffffff",
"borderColorLight": "#000000",
"borderColorDark": "#ffffff",
"borderWidth": 4,
"tooltipBg": "#000000",
"tooltipTitleColor": "#ffffff",
"tooltipBodyColor": "#ffffff",
"tooltipBorderColor": "#ffffff",
"tooltipBorderWidth": 3,
"tooltipCornerRadius": 0,
"uppercaseTooltip": True,
},
}
def get_theme_css(style: str) -> str:
"""Get the CSS for a given style by reading from the corresponding .css file."""
css_file = TEMPLATES_DIR / f"{style}.css"
if css_file.exists():
return css_file.read_text(encoding="utf-8")
# Fallback to glassmorphism if style not found
fallback = TEMPLATES_DIR / "glassmorphism.css"
return fallback.read_text(encoding="utf-8")
def get_theme_config(style: str) -> str:
"""Get the theme config as JSON string for a given style."""
config = THEME_CONFIGS.get(style, THEME_CONFIGS["glassmorphism"])
return json.dumps(config)

628
templates/brutalism.css Normal file
View File

@@ -0,0 +1,628 @@
/****************************************************************************************************
* Copyright (C) 2026 by WallyHackenslacker wallyhackenslacker@noreply.git.hackenslacker.space *
* *
* Permission to use, copy, modify, and/or distribute this software for any purpose with or without *
* fee is hereby granted. *
* *
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS *
* SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE *
* AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES *
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, *
* NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE *
* OF THIS SOFTWARE. *
****************************************************************************************************/
:root {
--bg-base: #ffffff;
--bg-primary: #ffffff;
--bg-secondary: #f0f0f0;
--bg-tertiary: #e0e0e0;
--text-primary: #000000;
--text-secondary: #333333;
--text-muted: #666666;
--border-color: #000000;
--border-width: 3px;
--accent-color: #ff0000;
--accent-secondary: #0000ff;
--accent-tertiary: #ffff00;
--selection-bg: #ffff00;
--selection-text: #000000;
--shadow-offset: 6px;
}
[data-theme="dark"] {
--bg-base: #000000;
--bg-primary: #000000;
--bg-secondary: #111111;
--bg-tertiary: #222222;
--text-primary: #ffffff;
--text-secondary: #cccccc;
--text-muted: #888888;
--border-color: #ffffff;
--accent-color: #ff3333;
--accent-secondary: #3333ff;
--accent-tertiary: #ffff33;
--selection-bg: #ff3333;
--selection-text: #000000;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg-base: #000000;
--bg-primary: #000000;
--bg-secondary: #111111;
--bg-tertiary: #222222;
--text-primary: #ffffff;
--text-secondary: #cccccc;
--text-muted: #888888;
--border-color: #ffffff;
--accent-color: #ff3333;
--accent-secondary: #3333ff;
--accent-tertiary: #ffff33;
--selection-bg: #ff3333;
--selection-text: #000000;
}
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Courier New', Courier, monospace;
font-size: 14px;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background-color: var(--bg-base);
background-image: __BACKGROUND_IMAGE_CUSTOM__;
background-size: cover;
background-position: center;
background-attachment: fixed;
min-height: 100vh;
color: var(--text-primary);
line-height: 1.4;
}
/* Theme Toggle Button */
.theme-toggle {
position: fixed;
top: 20px;
right: 20px;
z-index: 1000;
width: 50px;
height: 50px;
border: var(--border-width) solid var(--border-color);
background: var(--bg-primary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
box-shadow: var(--shadow-offset) var(--shadow-offset) 0 var(--border-color);
transition: transform 0.1s, box-shadow 0.1s;
}
.theme-toggle:hover {
transform: translate(-2px, -2px);
box-shadow: calc(var(--shadow-offset) + 2px) calc(var(--shadow-offset) + 2px) 0 var(--border-color);
}
.theme-toggle:active {
transform: translate(2px, 2px);
box-shadow: calc(var(--shadow-offset) - 2px) calc(var(--shadow-offset) - 2px) 0 var(--border-color);
}
.theme-toggle .icon {
line-height: 1;
}
.theme-toggle .icon-auto {
position: relative;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.theme-toggle .icon-auto::before,
.theme-toggle .icon-auto::after {
position: absolute;
font-size: 20px;
line-height: 1;
}
.theme-toggle .icon-auto::before {
content: '☀️';
clip-path: inset(0 50% 0 0);
}
.theme-toggle .icon-auto::after {
content: '🌙';
clip-path: inset(0 0 0 50%);
}
/* Card Style */
.card {
background: var(--bg-primary);
border: var(--border-width) solid var(--border-color);
box-shadow: var(--shadow-offset) var(--shadow-offset) 0 var(--border-color);
margin-bottom: 24px;
overflow: hidden;
}
.card-header {
padding: 12px 16px;
border-bottom: var(--border-width) solid var(--border-color);
background: var(--accent-tertiary);
}
.card-title {
font-size: 18px;
font-weight: 900;
text-transform: uppercase;
letter-spacing: 2px;
color: var(--text-primary);
}
[data-theme="dark"] .card-title {
color: #000000;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .card-title {
color: #000000;
}
}
.card-content {
padding: 20px;
}
.summaries-content {
padding: 0;
padding-top: 16px;
}
/* Filters */
.filters {
display: flex;
justify-content: center;
gap: 12px;
flex-wrap: wrap;
}
.filter-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
padding: 8px 12px;
background: var(--bg-secondary);
border: 2px solid var(--border-color);
font-weight: 700;
text-transform: uppercase;
font-size: 12px;
transition: transform 0.1s, box-shadow 0.1s;
}
.filter-label:hover {
transform: translate(-2px, -2px);
box-shadow: 4px 4px 0 var(--border-color);
}
.filter-label input[type="checkbox"] {
appearance: none;
-webkit-appearance: none;
width: 20px;
height: 20px;
border: 2px solid var(--border-color);
background: var(--bg-primary);
cursor: pointer;
position: relative;
}
.filter-label input[type="checkbox"]:checked {
background: var(--accent-color);
}
.filter-label input[type="checkbox"]:checked::after {
content: '\2715';
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
font-size: 14px;
font-weight: 900;
color: var(--bg-primary);
}
.filter-label .service-name {
text-transform: uppercase;
font-weight: 700;
color: var(--text-primary);
}
.filter-label .service-count {
color: var(--text-muted);
font-size: 11px;
}
/* Stats */
.stats {
display: flex;
justify-content: center;
gap: 20px;
flex-wrap: wrap;
}
.stat {
text-align: center;
padding: 20px 30px;
background: var(--bg-secondary);
border: var(--border-width) solid var(--border-color);
min-width: 150px;
}
.stat-value {
font-size: 36px;
font-weight: 900;
color: var(--accent-color);
font-variant-numeric: tabular-nums;
}
.stat-label {
font-size: 11px;
color: var(--text-muted);
margin-top: 4px;
text-transform: uppercase;
letter-spacing: 1px;
font-weight: 700;
}
/* Charts */
.charts-wrapper {
display: grid;
grid-auto-columns: minmax(280px, 450px);
grid-template-columns: repeat(auto-fill, minmax(280px, 450px));
grid-gap: 24px;
justify-content: center;
flex-wrap: wrap;
}
.chart-container {
min-width: 280px;
max-width: 450px;
padding: 16px;
background: var(--bg-secondary);
border: var(--border-width) solid var(--border-color);
}
.chart-title {
font-size: 14px;
font-weight: 900;
text-align: center;
margin-bottom: 12px;
color: var(--text-primary);
text-transform: uppercase;
letter-spacing: 1px;
}
@media (max-width: 700px) {
.charts-wrapper {
flex-direction: column;
align-items: center;
}
.chart-container {
max-width: 100%;
width: 100%;
}
}
/* Tabs */
.tabs {
display: flex;
gap: 0;
padding: 0 16px;
margin-bottom: -3px;
position: relative;
z-index: 1;
}
.tab {
padding: 12px 24px;
cursor: pointer;
color: var(--text-secondary);
font-weight: 900;
text-transform: uppercase;
font-size: 12px;
letter-spacing: 1px;
background: var(--bg-tertiary);
border: var(--border-width) solid var(--border-color);
border-bottom: none;
margin-right: -3px;
transition: none;
}
.tab:hover {
color: var(--text-primary);
background: var(--bg-secondary);
}
.tab.active {
color: var(--accent-color);
background: var(--bg-secondary);
position: relative;
}
.tab.active::after {
content: '';
position: absolute;
bottom: -3px;
left: 0;
right: 0;
height: 3px;
background: var(--bg-secondary);
}
.tab-content {
background: var(--bg-secondary);
border: var(--border-width) solid var(--border-color);
padding: 16px;
}
.tab-panel {
display: none;
}
.tab-panel.active {
display: block;
}
/* Tables */
.table-wrapper {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
th {
padding: 12px 16px;
text-align: left;
font-weight: 900;
color: var(--text-primary);
border-bottom: var(--border-width) solid var(--border-color);
text-transform: uppercase;
font-size: 11px;
letter-spacing: 1px;
background: var(--bg-tertiary);
}
td {
padding: 12px 16px;
border-bottom: 1px solid var(--border-color);
color: var(--text-primary);
}
tr:hover td {
background: var(--selection-bg);
color: var(--selection-text);
}
tr:hover .service-badge {
background: var(--border-color);
color: var(--bg-primary);
}
tr:last-child td {
border-bottom: none;
}
.time {
font-variant-numeric: tabular-nums;
font-weight: 700;
}
.percent {
font-variant-numeric: tabular-nums;
text-align: right;
color: var(--text-muted);
font-weight: 700;
}
.color-box {
display: inline-block;
width: 14px;
height: 14px;
margin-right: 8px;
vertical-align: middle;
border: 2px solid var(--border-color);
}
.service-badge {
display: inline-block;
font-size: 10px;
padding: 2px 6px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-muted);
margin-left: 8px;
text-transform: uppercase;
font-weight: 700;
letter-spacing: 0.5px;
}
.category-badge {
display: inline-block;
font-size: 10px;
padding: 2px 6px;
background: var(--accent-secondary);
border: 1px solid var(--border-color);
color: var(--bg-primary);
margin-left: 4px;
text-transform: uppercase;
font-weight: 700;
letter-spacing: 0.5px;
}
.no-data {
text-align: center;
padding: 40px;
color: var(--text-muted);
font-weight: 700;
text-transform: uppercase;
}
/* Others row expansion */
.others-row {
cursor: pointer;
}
.others-row td:first-child::before {
content: '\25B6';
display: inline-block;
margin-right: 8px;
font-size: 10px;
transition: transform 0.1s;
}
.others-row.expanded td:first-child::before {
transform: rotate(90deg);
}
.others-detail {
display: none;
background: var(--bg-tertiary);
}
.others-detail.visible {
display: table-row;
}
.others-detail td {
padding-left: 32px;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 12px;
height: 12px;
}
::-webkit-scrollbar-track {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
}
::-webkit-scrollbar-thumb {
background: var(--text-muted);
border: 1px solid var(--border-color);
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary);
}
/* Responsive */
@media (max-width: 600px) {
body {
padding: 12px;
}
.stats {
gap: 12px;
}
.stat {
padding: 16px 20px;
min-width: 100px;
}
.stat-value {
font-size: 28px;
}
.filter-label {
padding: 6px 10px;
font-size: 11px;
}
.tabs {
padding: 0 12px;
}
.tab {
padding: 10px 16px;
font-size: 11px;
}
.theme-toggle {
width: 44px;
height: 44px;
font-size: 18px;
}
}
/* Scroll to Top Button */
.scroll-top {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 1000;
width: 50px;
height: 50px;
border: var(--border-width) solid var(--border-color);
background: var(--bg-primary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: var(--shadow-offset) var(--shadow-offset) 0 var(--border-color);
opacity: 0;
visibility: hidden;
transition: opacity 0.2s, visibility 0.2s, transform 0.1s, box-shadow 0.1s;
}
.scroll-top.visible {
opacity: 1;
visibility: visible;
}
.scroll-top:hover {
transform: translate(-2px, -2px);
box-shadow: calc(var(--shadow-offset) + 2px) calc(var(--shadow-offset) + 2px) 0 var(--border-color);
}
.scroll-top:active {
transform: translate(2px, 2px);
box-shadow: calc(var(--shadow-offset) - 2px) calc(var(--shadow-offset) - 2px) 0 var(--border-color);
}
.scroll-top-arrow {
width: 0;
height: 0;
border-left: 10px solid transparent;
border-right: 10px solid transparent;
border-bottom: 12px solid var(--text-primary);
}
@media (max-width: 600px) {
.scroll-top {
width: 44px;
height: 44px;
}
.scroll-top-arrow {
border-left-width: 8px;
border-right-width: 8px;
border-bottom-width: 10px;
}
}

File diff suppressed because it is too large Load Diff

595
templates/flat.css Normal file
View File

@@ -0,0 +1,595 @@
/****************************************************************************************************
* Copyright (C) 2026 by WallyHackenslacker wallyhackenslacker@noreply.git.hackenslacker.space *
* *
* Permission to use, copy, modify, and/or distribute this software for any purpose with or without *
* fee is hereby granted. *
* *
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS *
* SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE *
* AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES *
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, *
* NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE *
* OF THIS SOFTWARE. *
****************************************************************************************************/
:root {
/* Light Mode Variables */
--bg-base: #f5f5f7; /* Apple-like light gray background */
--bg-primary: #ffffff;
--bg-secondary: #f0f0f5;
--bg-tertiary: #e5e5ea;
--text-primary: #1d1d1f; /* Almost black */
--text-secondary: #86868b;
--text-muted: #aeaeb2;
--border-color: #d2d2d7;
--shadow-color: rgba(0, 0, 0, 0.05);
/* Pastel Accent Colors */
--accent-color: #007aff; /* Apple Blue */
--accent-hover: #0062cc;
--accent-secondary: #5ac8fa; /* Apple Light Blue */
/* Selection */
--selection-bg: rgba(0, 122, 255, 0.1);
--selection-text: #007aff;
/* UI metrics */
--card-radius: 12px;
--button-radius: 8px;
--border-width: 1px;
}
[data-theme="dark"] {
/* Dark Mode Variables */
--bg-base: #000000;
--bg-primary: #1c1c1e;
--bg-secondary: #2c2c2e;
--bg-tertiary: #3a3a3c;
--text-primary: #f5f5f7;
--text-secondary: #86868b;
--text-muted: #636366;
--border-color: #38383a;
--shadow-color: rgba(0, 0, 0, 0.2);
--accent-color: #0a84ff; /* Apple Dark Mode Blue */
--accent-hover: #409cff;
--selection-bg: rgba(10, 132, 255, 0.15);
--selection-text: #0a84ff;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg-base: #000000;
--bg-primary: #1c1c1e;
--bg-secondary: #2c2c2e;
--bg-tertiary: #3a3a3c;
--text-primary: #f5f5f7;
--text-secondary: #86868b;
--text-muted: #636366;
--border-color: #38383a;
--shadow-color: rgba(0, 0, 0, 0.2);
--accent-color: #0a84ff;
--accent-hover: #409cff;
--selection-bg: rgba(10, 132, 255, 0.15);
--selection-text: #0a84ff;
}
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
font-size: 15px; /* Slightly larger for readability */
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background-color: var(--bg-base);
background-image: url('__BACKGROUND_IMAGE__');
background-size: cover;
background-position: center;
background-attachment: fixed;
min-height: 100vh;
color: var(--text-primary);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
/* Theme Toggle Button */
.theme-toggle {
position: fixed;
top: 20px;
right: 20px;
z-index: 1000;
width: 44px;
height: 44px;
border-radius: 50%;
border: 1px solid var(--border-color);
background: var(--bg-primary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
box-shadow: 0 2px 8px var(--shadow-color);
transition: all 0.2s ease;
color: var(--text-primary);
}
.theme-toggle:hover {
background: var(--bg-secondary);
transform: translateY(-1px);
}
.theme-toggle:active {
transform: translateY(0);
}
.theme-toggle .icon-auto {
position: relative;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.theme-toggle .icon-auto::before,
.theme-toggle .icon-auto::after {
position: absolute;
font-size: 20px;
line-height: 1;
}
.theme-toggle .icon-auto::before {
content: '☀️';
clip-path: inset(0 50% 0 0);
}
.theme-toggle .icon-auto::after {
content: '🌙';
clip-path: inset(0 0 0 50%);
}
/* Card Style */
.card {
background: var(--bg-primary);
border-radius: var(--card-radius);
border: 1px solid var(--border-color);
box-shadow: 0 4px 12px var(--shadow-color);
margin-bottom: 24px;
overflow: hidden;
transition: box-shadow 0.3s ease;
}
.card:hover {
box-shadow: 0 6px 16px var(--shadow-color);
}
.card-header {
padding: 16px 24px;
border-bottom: 1px solid var(--border-color);
background: var(--bg-primary); /* Flat header */
}
.card-title {
font-size: 17px; /* Apple uses specific sizing */
font-weight: 600;
color: var(--text-primary);
letter-spacing: -0.01em;
}
.card-content {
padding: 24px;
}
.summaries-content {
padding: 0;
padding-top: 0; /* Tabs sit flush */
}
/* Filters */
.filters {
display: flex;
justify-content: center;
gap: 12px;
flex-wrap: wrap;
}
.filter-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
padding: 8px 16px;
border-radius: 20px; /* Pill shape */
background: var(--bg-secondary);
border: 1px solid transparent;
transition: all 0.2s ease;
font-weight: 500;
font-size: 13px;
color: var(--text-primary);
}
.filter-label:hover {
background: var(--bg-tertiary);
}
.filter-label input[type="checkbox"] {
appearance: none;
-webkit-appearance: none;
width: 18px;
height: 18px;
border-radius: 4px; /* Rounded checkbox */
border: 1px solid var(--text-muted);
background: var(--bg-primary);
cursor: pointer;
position: relative;
transition: all 0.2s ease;
margin: 0;
}
.filter-label input[type="checkbox"]:checked {
background: var(--accent-color);
border-color: var(--accent-color);
}
.filter-label input[type="checkbox"]:checked::after {
content: '';
position: absolute;
left: 6px;
top: 2px;
width: 4px;
height: 9px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
.filter-label .service-name {
text-transform: capitalize;
}
.filter-label .service-count {
color: var(--text-secondary);
font-size: 12px;
}
/* Stats */
.stats {
display: flex;
justify-content: center;
gap: 24px;
flex-wrap: wrap;
}
.stat {
text-align: center;
padding: 24px 32px;
background: var(--bg-secondary);
border-radius: var(--card-radius);
min-width: 160px;
border: 1px solid transparent; /* Prepare for border */
}
.stat-value {
font-size: 32px;
font-weight: 700;
color: var(--accent-color);
font-variant-numeric: tabular-nums;
letter-spacing: -0.02em;
}
.stat-label {
font-size: 13px;
color: var(--text-secondary);
margin-top: 4px;
font-weight: 500;
}
/* Charts */
.charts-wrapper {
display: grid;
grid-auto-columns: minmax(280px, 450px);
grid-template-columns: repeat(auto-fill, minmax(280px, 450px));
grid-gap: 24px;
justify-content: center;
flex-wrap: wrap;
}
.chart-container {
min-width: 280px;
max-width: 450px;
padding: 20px;
background: var(--bg-primary);
border-radius: var(--card-radius);
border: 1px solid var(--border-color);
}
.chart-title {
font-size: 15px;
font-weight: 600;
text-align: center;
margin-bottom: 16px;
color: var(--text-primary);
}
@media (max-width: 700px) {
.charts-wrapper {
flex-direction: column;
align-items: center;
}
.chart-container {
max-width: 100%;
width: 100%;
}
}
/* Tabs */
.tabs {
display: flex;
padding: 0 24px;
margin-bottom: 0;
position: relative;
z-index: 1;
background: var(--bg-primary);
border-bottom: 1px solid var(--border-color);
}
.tab {
padding: 14px 0;
margin-right: 24px;
cursor: pointer;
color: var(--text-secondary);
font-weight: 500;
font-size: 14px;
background: transparent;
border-bottom: 2px solid transparent;
transition: color 0.2s, border-bottom-color 0.2s;
}
.tab:hover {
color: var(--text-primary);
}
.tab.active {
color: var(--accent-color);
border-bottom: 2px solid var(--accent-color);
}
.tab-content {
background: var(--bg-primary);
padding: 0;
}
.tab-panel {
display: none;
}
.tab-panel.active {
display: block;
}
/* Tables */
.table-wrapper {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
th {
padding: 12px 24px;
text-align: left;
font-weight: 600;
color: var(--text-secondary);
border-bottom: 1px solid var(--border-color);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
td {
padding: 12px 24px;
border-bottom: 1px solid var(--border-color);
color: var(--text-primary);
height: 48px;
}
tr:hover td {
background: var(--bg-secondary);
}
tr:last-child td {
border-bottom: none;
}
.time {
font-variant-numeric: tabular-nums;
font-weight: 500;
}
.percent {
font-variant-numeric: tabular-nums;
text-align: right;
color: var(--text-secondary);
}
.color-box {
display: inline-block;
width: 10px;
height: 10px;
margin-right: 12px;
vertical-align: middle;
border-radius: 2px; /* Slight rounding */
}
.service-badge {
display: inline-block;
font-size: 11px;
padding: 2px 8px;
background: var(--bg-secondary);
border-radius: 4px;
color: var(--text-secondary);
margin-left: 8px;
text-transform: capitalize;
font-weight: 500;
}
.category-badge {
display: inline-block;
font-size: 11px;
padding: 2px 8px;
background: var(--selection-bg);
border-radius: 4px;
color: var(--selection-text);
margin-left: 4px;
text-transform: capitalize;
font-weight: 500;
}
.no-data {
text-align: center;
padding: 40px;
color: var(--text-muted);
}
/* Others row expansion */
.others-row {
cursor: pointer;
}
.others-row td:first-child::before {
content: '';
display: inline-block;
width: 0;
height: 0;
border-left: 5px solid var(--text-muted);
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
margin-right: 10px;
transition: transform 0.2s ease;
vertical-align: middle;
}
.others-row.expanded td:first-child::before {
transform: rotate(90deg);
}
.others-detail {
display: none;
background: var(--bg-base); /* Slightly darker/lighter background for details */
}
.others-detail.visible {
display: table-row;
}
.others-detail td {
padding-left: 48px;
font-size: 13px;
color: var(--text-secondary);
}
/* Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--text-muted);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary);
}
/* Responsive */
@media (max-width: 600px) {
body {
padding: 16px;
}
.stats {
gap: 16px;
}
.stat {
padding: 16px 20px;
min-width: 100px;
}
.stat-value {
font-size: 28px;
}
.filter-label {
padding: 6px 12px;
font-size: 13px;
}
.tabs {
padding: 0 16px;
overflow-x: auto;
}
.tab {
padding: 12px 0;
margin-right: 16px;
white-space: nowrap;
}
}
/* Scroll to Top Button */
.scroll-top {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 1000;
width: 44px;
height: 44px;
border-radius: 50%;
border: 1px solid var(--border-color);
background: var(--bg-primary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px var(--shadow-color);
opacity: 0;
visibility: hidden;
transition: all 0.2s ease;
color: var(--text-primary);
}
.scroll-top.visible {
opacity: 1;
visibility: visible;
}
.scroll-top:hover {
transform: translateY(-2px);
background: var(--bg-secondary);
}
.scroll-top-arrow {
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-bottom: 8px solid currentColor;
}

592
templates/glassmorphism.css Normal file
View File

@@ -0,0 +1,592 @@
/****************************************************************************************************
* Copyright (C) 2026 by WallyHackenslacker wallyhackenslacker@noreply.git.hackenslacker.space *
* *
* Permission to use, copy, modify, and/or distribute this software for any purpose with or without *
* fee is hereby granted. *
* *
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS *
* SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE *
* AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES *
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, *
* NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE *
* OF THIS SOFTWARE. *
****************************************************************************************************/
:root {
--bg-primary: rgba(255, 255, 255, 0.25);
--bg-secondary: rgba(255, 255, 255, 0.15);
--bg-tertiary: rgba(255, 255, 255, 0.1);
--text-primary: #1a1a2e;
--text-secondary: #4a4a6a;
--text-muted: #6a6a8a;
--border-color: rgba(255, 255, 255, 0.3);
--shadow-color: rgba(0, 0, 0, 0.1);
--accent-color: #6366f1;
--accent-hover: #4f46e5;
--selection-bg: rgba(99, 102, 241, 0.3);
--glass-blur: 20px;
--card-radius: 16px;
}
[data-theme="dark"] {
--bg-primary: rgba(30, 30, 50, 0.6);
--bg-secondary: rgba(40, 40, 70, 0.5);
--bg-tertiary: rgba(50, 50, 80, 0.4);
--text-primary: #f0f0f5;
--text-secondary: #c0c0d0;
--text-muted: #9090a0;
--border-color: rgba(255, 255, 255, 0.1);
--shadow-color: rgba(0, 0, 0, 0.3);
--accent-color: #818cf8;
--accent-hover: #6366f1;
--selection-bg: rgba(129, 140, 248, 0.3);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg-primary: rgba(30, 30, 50, 0.6);
--bg-secondary: rgba(40, 40, 70, 0.5);
--bg-tertiary: rgba(50, 50, 80, 0.4);
--text-primary: #f0f0f5;
--text-secondary: #c0c0d0;
--text-muted: #9090a0;
--border-color: rgba(255, 255, 255, 0.1);
--shadow-color: rgba(0, 0, 0, 0.3);
--accent-color: #818cf8;
--accent-hover: #6366f1;
--selection-bg: rgba(129, 140, 248, 0.3);
}
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
font-size: 14px;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background-color: #667eea;
background-image: url('__BACKGROUND_IMAGE__');
background-size: cover;
background-position: center;
background-attachment: fixed;
min-height: 100vh;
color: var(--text-primary);
line-height: 1.5;
}
/* Theme Toggle Button */
.theme-toggle {
position: fixed;
top: 20px;
right: 20px;
z-index: 1000;
width: 48px;
height: 48px;
border-radius: 50%;
border: 1px solid var(--border-color);
background: var(--bg-primary);
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
box-shadow: 0 8px 32px var(--shadow-color);
transition: all 0.3s ease;
}
.theme-toggle:hover {
transform: scale(1.1);
background: var(--bg-secondary);
}
.theme-toggle .icon {
transition: transform 0.3s ease;
}
.theme-toggle .icon-auto {
position: relative;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.theme-toggle .icon-auto::before,
.theme-toggle .icon-auto::after {
position: absolute;
font-size: 20px;
line-height: 1;
}
.theme-toggle .icon-auto::before {
content: '☀️';
clip-path: inset(0 50% 0 0);
}
.theme-toggle .icon-auto::after {
content: '🌙';
clip-path: inset(0 0 0 50%);
}
/* Card Style */
.card {
background: var(--bg-primary);
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
border-radius: var(--card-radius);
border: 1px solid var(--border-color);
box-shadow: 0 8px 32px var(--shadow-color);
margin-bottom: 24px;
overflow: hidden;
transition: all 0.3s ease;
}
.card:hover {
box-shadow: 0 12px 40px var(--shadow-color);
}
.card-header {
padding: 16px 20px;
border-bottom: 1px solid var(--border-color);
background: var(--bg-tertiary);
}
.card-title {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.card-content {
padding: 20px;
}
.summaries-content {
padding: 0;
padding-top: 16px;
}
/* Filters */
.filters {
display: flex;
justify-content: center;
gap: 16px;
flex-wrap: wrap;
}
.filter-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
padding: 8px 16px;
border-radius: 24px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
transition: all 0.2s ease;
}
.filter-label:hover {
background: var(--bg-tertiary);
}
.filter-label input[type="checkbox"] {
appearance: none;
-webkit-appearance: none;
width: 18px;
height: 18px;
border-radius: 4px;
border: 2px solid var(--text-muted);
cursor: pointer;
position: relative;
transition: all 0.2s ease;
}
.filter-label input[type="checkbox"]:checked {
background: var(--accent-color);
border-color: var(--accent-color);
}
.filter-label input[type="checkbox"]:checked::after {
content: '';
position: absolute;
left: 5px;
top: 2px;
width: 4px;
height: 8px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
.filter-label .service-name {
text-transform: capitalize;
font-weight: 500;
color: var(--text-primary);
}
.filter-label .service-count {
color: var(--text-muted);
font-size: 12px;
}
/* Stats */
.stats {
display: flex;
justify-content: center;
gap: 24px;
flex-wrap: wrap;
}
.stat {
text-align: center;
padding: 20px 32px;
background: var(--bg-secondary);
border-radius: 12px;
border: 1px solid var(--border-color);
min-width: 140px;
}
.stat-value {
font-size: 28px;
font-weight: 700;
color: var(--accent-color);
font-variant-numeric: tabular-nums;
}
.stat-label {
font-size: 12px;
color: var(--text-muted);
margin-top: 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Charts */
.charts-wrapper {
display: grid;
grid-auto-columns: minmax(280px, 450px);
grid-template-columns: repeat(auto-fill, minmax(280px, 450px));
grid-gap: 24px;
justify-content: center;
}
.chart-container {
min-width: 280px;
max-width: 450px;
padding: 16px;
background: var(--bg-secondary);
border-radius: 12px;
border: 1px solid var(--border-color);
}
.chart-title {
font-size: 14px;
font-weight: 600;
text-align: center;
margin-bottom: 12px;
color: var(--text-primary);
}
@media (max-width: 700px) {
.charts-wrapper {
flex-direction: column;
align-items: center;
}
.chart-container {
max-width: 100%;
width: 100%;
}
}
/* Tabs */
.tabs {
display: flex;
gap: 8px;
padding: 0 20px;
margin-bottom: -1px;
position: relative;
z-index: 1;
}
.tab {
padding: 12px 24px;
cursor: pointer;
color: var(--text-secondary);
font-weight: 500;
border-radius: 12px 12px 0 0;
background: var(--bg-tertiary);
border: 1px solid transparent;
border-bottom: none;
transition: all 0.2s ease;
}
.tab:hover {
color: var(--text-primary);
background: var(--bg-secondary);
}
.tab.active {
color: var(--accent-color);
background: var(--bg-secondary);
border-color: var(--border-color);
}
.tab-content {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 16px;
}
.tab-panel {
display: none;
}
.tab-panel.active {
display: block;
}
/* Tables */
.table-wrapper {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
th {
padding: 12px 16px;
text-align: left;
font-weight: 600;
color: var(--text-secondary);
border-bottom: 2px solid var(--border-color);
text-transform: uppercase;
font-size: 11px;
letter-spacing: 0.5px;
}
td {
padding: 12px 16px;
border-bottom: 1px solid var(--border-color);
color: var(--text-primary);
}
tr:hover td {
background: var(--selection-bg);
}
tr:last-child td {
border-bottom: none;
}
.time {
font-variant-numeric: tabular-nums;
font-weight: 500;
}
.percent {
font-variant-numeric: tabular-nums;
text-align: right;
color: var(--text-muted);
}
.color-box {
display: inline-block;
width: 12px;
height: 12px;
margin-right: 8px;
vertical-align: middle;
border-radius: 3px;
}
.service-badge {
display: inline-block;
font-size: 10px;
padding: 2px 8px;
background: var(--bg-tertiary);
border-radius: 12px;
color: var(--text-muted);
margin-left: 8px;
text-transform: capitalize;
font-weight: 500;
}
.category-badge {
display: inline-block;
font-size: 10px;
padding: 2px 8px;
background: rgba(99, 102, 241, 0.2);
border-radius: 12px;
color: var(--accent-color);
margin-left: 4px;
text-transform: capitalize;
font-weight: 500;
}
.no-data {
text-align: center;
padding: 40px;
color: var(--text-muted);
}
/* Others row expansion */
.others-row {
cursor: pointer;
}
.others-row td:first-child::before {
content: '';
display: inline-block;
width: 0;
height: 0;
border-left: 6px solid var(--text-muted);
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
margin-right: 8px;
transition: transform 0.2s ease;
}
.others-row.expanded td:first-child::before {
transform: rotate(90deg);
}
.others-detail {
display: none;
background: var(--bg-tertiary);
}
.others-detail.visible {
display: table-row;
}
.others-detail td {
padding-left: 32px;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-tertiary);
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: var(--text-muted);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary);
}
/* Responsive */
@media (max-width: 600px) {
body {
padding: 12px;
}
.stats {
gap: 12px;
}
.stat {
padding: 16px 20px;
min-width: 100px;
}
.stat-value {
font-size: 22px;
}
.filter-label {
padding: 6px 12px;
font-size: 13px;
}
.tabs {
padding: 0 12px;
}
.tab {
padding: 10px 16px;
font-size: 13px;
}
.theme-toggle {
width: 40px;
height: 40px;
font-size: 16px;
}
}
/* Scroll to Top Button */
.scroll-top {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 1000;
width: 48px;
height: 48px;
border-radius: 50%;
border: 1px solid var(--border-color);
background: var(--bg-primary);
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8px 32px var(--shadow-color);
opacity: 0;
visibility: hidden;
transition: all 0.3s ease;
}
.scroll-top.visible {
opacity: 1;
visibility: visible;
}
.scroll-top:hover {
transform: scale(1.1);
background: var(--bg-secondary);
}
.scroll-top-arrow {
width: 0;
height: 0;
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-bottom: 10px solid var(--text-primary);
}
@media (max-width: 600px) {
.scroll-top {
width: 40px;
height: 40px;
}
.scroll-top-arrow {
border-left-width: 6px;
border-right-width: 6px;
border-bottom-width: 8px;
}
}

File diff suppressed because it is too large Load Diff

435
templates/highcontrast.css Normal file
View File

@@ -0,0 +1,435 @@
/****************************************************************************************************
* Copyright (C) 2026 by WallyHackenslacker wallyhackenslacker@noreply.git.hackenslacker.space *
* *
* Permission to use, copy, modify, and/or distribute this software for any purpose with or without *
* fee is hereby granted. *
* *
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS *
* SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE *
* AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES *
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, *
* NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE *
* OF THIS SOFTWARE. *
****************************************************************************************************/
:root {
--bg-base: #ffffff;
--bg-primary: #ffffff;
--bg-secondary: #ffffff;
--bg-tertiary: #000000;
--text-primary: #000000;
--text-secondary: #000000;
--text-muted: #000000;
--border-color: #000000;
--accent-color: #000000;
--accent-hover: #000000;
--selection-bg: #000000;
--selection-text: #ffffff;
--border-width: 4px;
}
[data-theme="dark"] {
--bg-base: #000000;
--bg-primary: #000000;
--bg-secondary: #000000;
--bg-tertiary: #ffffff;
--text-primary: #ffffff;
--text-secondary: #ffffff;
--text-muted: #ffffff;
--border-color: #ffffff;
--accent-color: #ffffff;
--accent-hover: #ffffff;
--selection-bg: #ffffff;
--selection-text: #000000;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg-base: #000000;
--bg-primary: #000000;
--bg-secondary: #000000;
--bg-tertiary: #ffffff;
--text-primary: #ffffff;
--text-secondary: #ffffff;
--text-muted: #ffffff;
--border-color: #ffffff;
--accent-color: #ffffff;
--accent-hover: #ffffff;
--selection-bg: #ffffff;
--selection-text: #000000;
}
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: Arial, Helvetica, sans-serif;
font-size: 16px;
font-weight: 700;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background-color: var(--bg-base);
background-image: __BACKGROUND_IMAGE_CUSTOM__;
background-size: cover;
background-position: center;
background-attachment: fixed;
min-height: 100vh;
color: var(--text-primary);
line-height: 1.2;
}
/* Theme Toggle Button */
.theme-toggle {
position: fixed;
top: 20px;
right: 20px;
z-index: 1000;
width: 60px;
height: 60px;
border: var(--border-width) solid var(--border-color);
background: var(--bg-primary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 30px;
color: var(--text-primary);
}
.theme-toggle:hover {
background: var(--text-primary);
color: var(--bg-primary);
}
.theme-toggle .icon-auto {
position: relative;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
}
.theme-toggle .icon-auto::before,
.theme-toggle .icon-auto::after {
position: absolute;
font-size: 24px;
line-height: 1;
}
.theme-toggle .icon-auto::before {
content: 'L';
clip-path: inset(0 50% 0 0);
}
.theme-toggle .icon-auto::after {
content: 'D';
clip-path: inset(0 0 0 50%);
}
/* Card Style */
.card {
background: var(--bg-primary);
border: var(--border-width) solid var(--border-color);
margin-bottom: 30px;
}
.card-header {
padding: 20px;
border-bottom: var(--border-width) solid var(--border-color);
background: var(--bg-primary);
}
.card-title {
font-size: 30px;
font-weight: 900;
text-transform: uppercase;
}
.card-content {
padding: 20px;
}
.summaries-content {
padding: 0;
}
/* Filters */
.filters {
display: flex;
justify-content: center;
gap: 20px;
flex-wrap: wrap;
}
.filter-label {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
user-select: none;
padding: 10px;
border: var(--border-width) solid var(--border-color);
text-transform: uppercase;
}
.filter-label:hover {
background: var(--text-primary);
color: var(--bg-primary);
}
.filter-label input[type="checkbox"] {
appearance: none;
-webkit-appearance: none;
width: 24px;
height: 24px;
border: var(--border-width) solid var(--border-color);
background: var(--bg-primary);
cursor: pointer;
position: relative;
}
.filter-label input[type="checkbox"]:checked {
background: var(--text-primary);
}
.filter-label input[type="checkbox"]:checked::after {
content: 'X';
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
font-size: 16px;
font-weight: 900;
color: var(--bg-primary);
}
/* Stats */
.stats {
display: flex;
justify-content: center;
gap: 30px;
flex-wrap: wrap;
}
.stat {
text-align: center;
padding: 20px;
border: var(--border-width) solid var(--border-color);
min-width: 200px;
}
.stat-value {
font-size: 48px;
font-weight: 900;
}
.stat-label {
font-size: 16px;
text-transform: uppercase;
}
/* Charts */
.charts-wrapper {
display: grid;
grid-auto-columns: minmax(300px, 450px);
grid-template-columns: repeat(auto-fill, minmax(300px, 450px));
grid-gap: 30px;
justify-content: center;
}
.chart-container {
padding: 20px;
border: var(--border-width) solid var(--border-color);
}
.chart-title {
font-size: 20px;
font-weight: 900;
text-align: center;
margin-bottom: 20px;
text-transform: uppercase;
}
@media (max-width: 700px) {
.charts-wrapper {
flex-direction: column;
align-items: center;
}
.chart-container {
max-width: 100%;
width: 100%;
}
}
/* Tabs */
.tabs {
display: flex;
gap: 0;
}
.tab {
padding: 15px 30px;
cursor: pointer;
font-weight: 900;
text-transform: uppercase;
border: var(--border-width) solid var(--border-color);
flex: 1;
text-align: center;
}
.tab.active {
background: var(--text-primary);
color: var(--bg-primary);
}
.tab-content {
border: var(--border-width) solid var(--border-color);
border-top: none;
}
.tab-panel {
display: none;
}
.tab-panel.active {
display: block;
}
/* Tables */
.table-wrapper {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
th {
padding: 15px;
text-align: left;
border-bottom: var(--border-width) solid var(--border-color);
text-transform: uppercase;
font-size: 14px;
}
td {
padding: 15px;
border-bottom: 2px solid var(--border-color);
}
tr:hover td {
background: var(--selection-bg);
color: var(--selection-text);
}
.color-box {
display: inline-block;
width: 20px;
height: 20px;
margin-right: 10px;
border: 2px solid var(--border-color);
}
.service-badge {
padding: 2px 8px;
border: 2px solid var(--border-color);
margin-left: 10px;
text-transform: uppercase;
}
.category-badge {
padding: 2px 8px;
background: var(--text-primary);
color: var(--bg-primary);
margin-left: 5px;
text-transform: uppercase;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 20px;
}
::-webkit-scrollbar-track {
background: var(--bg-primary);
border-left: var(--border-width) solid var(--border-color);
}
::-webkit-scrollbar-thumb {
background: var(--text-primary);
}
/* Scroll to Top Button */
.scroll-top {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 1000;
width: 60px;
height: 60px;
border: var(--border-width) solid var(--border-color);
background: var(--bg-primary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-primary);
opacity: 0;
visibility: hidden;
}
.scroll-top.visible {
opacity: 1;
visibility: visible;
}
.scroll-top:hover {
background: var(--text-primary);
color: var(--bg-primary);
}
.scroll-top-arrow {
width: 0;
height: 0;
border-left: 15px solid transparent;
border-right: 15px solid transparent;
border-bottom: 20px solid currentColor;
}
/* Others row expansion */
.others-row {
cursor: pointer;
}
.others-row td:first-child::before {
content: '\25B6';
display: inline-block;
margin-right: 10px;
font-size: 14px;
transition: transform 0.2s ease;
vertical-align: middle;
}
.others-row.expanded td:first-child::before {
transform: rotate(90deg);
}
.others-detail {
display: none;
background: var(--bg-secondary);
}
.others-detail.visible {
display: table-row;
}
.others-detail td {
padding-left: 48px;
}

607
templates/material.css Normal file
View File

@@ -0,0 +1,607 @@
/****************************************************************************************************
* Copyright (C) 2026 by WallyHackenslacker wallyhackenslacker@noreply.git.hackenslacker.space *
* *
* Permission to use, copy, modify, and/or distribute this software for any purpose with or without *
* fee is hereby granted. *
* *
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS *
* SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE *
* AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES *
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, *
* NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE *
* OF THIS SOFTWARE. *
****************************************************************************************************/
:root {
--bg-base: #fafafa;
--bg-primary: #ffffff;
--bg-secondary: #f5f5f5;
--bg-tertiary: #eeeeee;
--text-primary: #212121;
--text-secondary: #757575;
--text-muted: #bdbdbd;
--border-color: #e0e0e0;
--shadow-color: rgba(0, 0, 0, 0.2);
--accent-color: #6200ee;
--accent-hover: #3700b3;
--accent-secondary: #03dac6;
--selection-bg: rgba(98, 0, 238, 0.12);
--selection-text: #6200ee;
--card-radius: 4px;
--elevation-1: 0 2px 1px -1px rgba(0,0,0,0.2), 0 1px 1px 0 rgba(0,0,0,0.14), 0 1px 3px 0 rgba(0,0,0,0.12);
--elevation-2: 0 3px 1px -2px rgba(0,0,0,0.2), 0 2px 2px 0 rgba(0,0,0,0.14), 0 1px 5px 0 rgba(0,0,0,0.12);
--elevation-4: 0 2px 4px -1px rgba(0,0,0,0.2), 0 4px 5px 0 rgba(0,0,0,0.14), 0 1px 10px 0 rgba(0,0,0,0.12);
}
[data-theme="dark"] {
--bg-base: #121212;
--bg-primary: #1e1e1e;
--bg-secondary: #2d2d2d;
--bg-tertiary: #383838;
--text-primary: #ffffff;
--text-secondary: #b0b0b0;
--text-muted: #707070;
--border-color: #383838;
--shadow-color: rgba(0, 0, 0, 0.4);
--accent-color: #bb86fc;
--accent-hover: #9965f4;
--accent-secondary: #03dac6;
--selection-bg: rgba(187, 134, 252, 0.12);
--selection-text: #bb86fc;
--elevation-1: 0 2px 1px -1px rgba(0,0,0,0.4), 0 1px 1px 0 rgba(0,0,0,0.28), 0 1px 3px 0 rgba(0,0,0,0.24);
--elevation-2: 0 3px 1px -2px rgba(0,0,0,0.4), 0 2px 2px 0 rgba(0,0,0,0.28), 0 1px 5px 0 rgba(0,0,0,0.24);
--elevation-4: 0 2px 4px -1px rgba(0,0,0,0.4), 0 4px 5px 0 rgba(0,0,0,0.28), 0 1px 10px 0 rgba(0,0,0,0.24);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg-base: #121212;
--bg-primary: #1e1e1e;
--bg-secondary: #2d2d2d;
--bg-tertiary: #383838;
--text-primary: #ffffff;
--text-secondary: #b0b0b0;
--text-muted: #707070;
--border-color: #383838;
--shadow-color: rgba(0, 0, 0, 0.4);
--accent-color: #bb86fc;
--accent-hover: #9965f4;
--accent-secondary: #03dac6;
--selection-bg: rgba(187, 134, 252, 0.12);
--selection-text: #bb86fc;
--elevation-1: 0 2px 1px -1px rgba(0,0,0,0.4), 0 1px 1px 0 rgba(0,0,0,0.28), 0 1px 3px 0 rgba(0,0,0,0.24);
--elevation-2: 0 3px 1px -2px rgba(0,0,0,0.4), 0 2px 2px 0 rgba(0,0,0,0.28), 0 1px 5px 0 rgba(0,0,0,0.24);
--elevation-4: 0 2px 4px -1px rgba(0,0,0,0.4), 0 4px 5px 0 rgba(0,0,0,0.28), 0 1px 10px 0 rgba(0,0,0,0.24);
}
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Oxygen, Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
font-size: 14px;
max-width: 1200px;
margin: 0 auto;
padding: 24px;
background-color: var(--bg-base);
background-image: url('__BACKGROUND_IMAGE__');
background-size: cover;
background-position: center;
background-attachment: fixed;
min-height: 100vh;
color: var(--text-primary);
line-height: 1.5;
transition: background-color 0.3s ease, color 0.3s ease;
}
/* Theme Toggle Button */
.theme-toggle {
position: fixed;
top: 24px;
right: 24px;
z-index: 1000;
width: 48px;
height: 48px;
border-radius: 50%;
border: none;
background: var(--bg-primary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
box-shadow: var(--elevation-2);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
color: var(--text-primary);
}
.theme-toggle:hover {
box-shadow: var(--elevation-4);
background: var(--bg-secondary);
}
.theme-toggle:active {
box-shadow: var(--elevation-1);
}
.theme-toggle .icon-auto {
position: relative;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.theme-toggle .icon-auto::before,
.theme-toggle .icon-auto::after {
position: absolute;
font-size: 20px;
line-height: 1;
}
.theme-toggle .icon-auto::before {
content: '☀️';
clip-path: inset(0 50% 0 0);
}
.theme-toggle .icon-auto::after {
content: '🌙';
clip-path: inset(0 0 0 50%);
}
/* Card Style */
.card {
background: var(--bg-primary);
border-radius: var(--card-radius);
box-shadow: var(--elevation-1);
margin-bottom: 24px;
overflow: hidden;
transition: box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.card:hover {
box-shadow: var(--elevation-2);
}
.card-header {
padding: 16px 24px;
border-bottom: 1px solid var(--border-color);
background: var(--bg-primary);
}
.card-title {
font-size: 20px;
font-weight: 500;
color: var(--text-primary);
letter-spacing: 0.15px;
}
.card-content {
padding: 24px;
}
.summaries-content {
padding: 0;
padding-top: 0;
}
/* Filters */
.filters {
display: flex;
justify-content: center;
gap: 12px;
flex-wrap: wrap;
}
.filter-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
padding: 8px 16px;
border-radius: 16px; /* Chip style */
background: var(--bg-secondary);
border: 1px solid transparent;
transition: background-color 0.2s, box-shadow 0.2s;
font-weight: 500;
font-size: 14px;
color: var(--text-primary);
}
.filter-label:hover {
background: var(--bg-tertiary);
}
.filter-label input[type="checkbox"] {
appearance: none;
-webkit-appearance: none;
width: 18px;
height: 18px;
border-radius: 2px;
border: 2px solid var(--text-secondary);
background: transparent;
cursor: pointer;
position: relative;
transition: all 0.2s ease;
margin: 0;
}
.filter-label input[type="checkbox"]:checked {
background: var(--accent-color);
border-color: var(--accent-color);
}
.filter-label input[type="checkbox"]:checked::after {
content: '';
position: absolute;
left: 5px;
top: 1px;
width: 4px;
height: 9px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
.filter-label .service-name {
text-transform: capitalize;
}
.filter-label .service-count {
color: var(--text-secondary);
font-size: 12px;
}
/* Stats */
.stats {
display: flex;
justify-content: center;
gap: 24px;
flex-wrap: wrap;
}
.stat {
text-align: center;
padding: 24px 32px;
background: var(--bg-primary);
border-radius: var(--card-radius);
min-width: 160px;
/* No border or extra bg needed inside the card, just spacing */
}
.stat-value {
font-size: 34px;
font-weight: 400;
color: var(--accent-color);
font-variant-numeric: tabular-nums;
letter-spacing: 0.25px;
}
.stat-label {
font-size: 12px;
color: var(--text-secondary);
margin-top: 4px;
text-transform: uppercase;
letter-spacing: 1.5px;
font-weight: 500;
}
/* Charts */
.charts-wrapper {
display: grid;
grid-auto-columns: minmax(280px, 450px);
grid-template-columns: repeat(auto-fill, minmax(280px, 450px));
grid-gap: 24px;
justify-content: center;
flex-wrap: wrap;
}
.chart-container {
min-width: 280px;
max-width: 450px;
padding: 16px;
background: var(--bg-primary);
border-radius: var(--card-radius);
/* Charts sit inside the main card content, so no extra background needed if not wanted,
but let's give them a subtle border or just rely on spacing.
Material design often puts charts on cards. Here they are inside a card.
Let's leave them clean. */
}
.chart-title {
font-size: 16px;
font-weight: 500;
text-align: center;
margin-bottom: 16px;
color: var(--text-primary);
letter-spacing: 0.15px;
}
@media (max-width: 700px) {
.charts-wrapper {
flex-direction: column;
align-items: center;
}
.chart-container {
max-width: 100%;
width: 100%;
}
}
/* Tabs */
.tabs {
display: flex;
padding: 0;
margin-bottom: 0;
position: relative;
z-index: 1;
background: var(--bg-primary);
box-shadow: 0 1px 0 var(--border-color); /* Bottom border for the tab bar */
}
.tab {
padding: 14px 24px;
cursor: pointer;
color: var(--text-secondary);
font-weight: 500;
text-transform: uppercase;
font-size: 14px;
letter-spacing: 1.25px;
background: transparent;
border-bottom: 2px solid transparent;
transition: background-color 0.2s, color 0.2s;
flex: 1;
text-align: center;
max-width: 200px;
}
.tab:hover {
background-color: rgba(0, 0, 0, 0.04); /* Ripple-like hover */
color: var(--text-primary);
}
[data-theme="dark"] .tab:hover {
background-color: rgba(255, 255, 255, 0.08);
}
.tab.active {
color: var(--accent-color);
border-bottom: 2px solid var(--accent-color);
}
.tab-content {
background: var(--bg-primary);
padding: 0; /* Table fills the space */
}
.tab-panel {
display: none;
}
.tab-panel.active {
display: block;
}
/* Tables */
.table-wrapper {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
th {
padding: 16px 24px;
text-align: left;
font-weight: 500;
color: var(--text-secondary);
border-bottom: 1px solid var(--border-color);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px; /* Material column header style */
}
td {
padding: 14px 24px;
border-bottom: 1px solid var(--border-color);
color: var(--text-primary);
height: 52px; /* Material dense table row height */
}
tr:hover td {
background: var(--bg-secondary);
}
tr:last-child td {
border-bottom: none;
}
.time {
font-variant-numeric: tabular-nums;
font-weight: 400;
}
.percent {
font-variant-numeric: tabular-nums;
text-align: right;
color: var(--text-secondary);
}
.color-box {
display: inline-block;
width: 12px;
height: 12px;
margin-right: 12px;
vertical-align: middle;
border-radius: 50%; /* Circles for material */
}
.service-badge {
display: inline-block;
font-size: 11px;
padding: 2px 8px;
background: var(--bg-secondary);
border-radius: 4px;
color: var(--text-secondary);
margin-left: 8px;
text-transform: capitalize;
font-weight: 500;
}
.category-badge {
display: inline-block;
font-size: 11px;
padding: 2px 8px;
background: var(--selection-bg);
border-radius: 4px;
color: var(--selection-text);
margin-left: 4px;
text-transform: capitalize;
font-weight: 500;
}
.no-data {
text-align: center;
padding: 40px;
color: var(--text-muted);
}
/* Others row expansion */
.others-row {
cursor: pointer;
}
.others-row td:first-child::before {
content: '';
display: inline-block;
width: 0;
height: 0;
border-left: 5px solid var(--text-secondary);
border-top: 5px solid transparent;
border-bottom: 5px solid transparent;
margin-right: 12px;
transition: transform 0.2s ease;
vertical-align: middle;
}
.others-row.expanded td:first-child::before {
transform: rotate(90deg);
}
.others-detail {
display: none;
background: var(--bg-base); /* Slightly darker/lighter background for details */
}
.others-detail.visible {
display: table-row;
}
.others-detail td {
padding-left: 48px;
font-size: 13px;
color: var(--text-secondary);
}
/* Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--text-muted);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary);
}
/* Responsive */
@media (max-width: 600px) {
body {
padding: 16px;
}
.stats {
gap: 16px;
}
.stat {
padding: 16px 20px;
min-width: 100px;
}
.stat-value {
font-size: 28px;
}
.filter-label {
padding: 6px 12px;
font-size: 13px;
}
.tabs {
flex-wrap: wrap;
}
.tab {
padding: 12px 16px;
font-size: 12px;
max-width: none;
}
}
/* Scroll to Top Button */
.scroll-top {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 1000;
width: 48px;
height: 48px;
border-radius: 50%;
border: none;
background: var(--bg-primary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: var(--elevation-2);
opacity: 0;
visibility: hidden;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
color: var(--text-primary);
}
.scroll-top.visible {
opacity: 1;
visibility: visible;
}
.scroll-top:hover {
box-shadow: var(--elevation-4);
background: var(--bg-secondary);
}
.scroll-top-arrow {
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-bottom: 8px solid currentColor;
}

158
templates/modern.html Normal file
View File

@@ -0,0 +1,158 @@
<!--
Copyright (C) 2026 by WallyHackenslacker wallyhackenslacker@noreply.git.hackenslacker.space
Permission to use, copy, modify, and/or distribute this software for any purpose with or without
fee is hereby granted.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS
SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE
AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT,
NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE
OF THIS SOFTWARE.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Lutris Playtime Report</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
/* Theme-specific CSS injected here */
__THEME_CSS__
</style>
</head>
<body>
<!-- Scroll to Top Button -->
<button class="scroll-top" id="scroll-top" title="Scroll to top">
<div class="scroll-top-arrow"></div>
</button>
<!-- Theme Toggle Button -->
<button class="theme-toggle" id="theme-toggle" title="Toggle theme">
<span class="icon" id="theme-icon"></span>
</button>
<div class="card">
<div class="card-header">
<h1 class="card-title">Lutris Playtime Report</h1>
</div>
<div class="card-content">
<div class="filters" id="filters"></div>
</div>
</div>
<div class="card">
<div class="card-header">
<h2 class="card-title">Statistics</h2>
</div>
<div class="card-content">
<div class="stats">
<div class="stat">
<div class="stat-value" id="total-library">__TOTAL_LIBRARY__</div>
<div class="stat-label">Games in Library</div>
</div>
<div class="stat">
<div class="stat-value" id="total-games">0</div>
<div class="stat-label">Games Played</div>
</div>
<div class="stat">
<div class="stat-value" id="total-time">0h</div>
<div class="stat-label">Total Playtime</div>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h2 class="card-title">Playtime Distribution</h2>
</div>
<div class="card-content">
<div class="charts-wrapper">
<div class="chart-container">
<div class="chart-title">Top Games</div>
<canvas id="playtime-chart"></canvas>
</div>
<div class="chart-container">
<div class="chart-title">By Category</div>
<canvas id="categories-chart"></canvas>
</div>
<div class="chart-container">
<div class="chart-title">By Runner</div>
<canvas id="runners-chart"></canvas>
</div>
<div class="chart-container">
<div class="chart-title">By Source</div>
<canvas id="sources-chart"></canvas>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h2 class="card-title">Summaries</h2>
</div>
<div class="card-content summaries-content">
<div class="tabs">
<div class="tab active" data-tab="games">Top Games</div>
<div class="tab" data-tab="categories">By Category</div>
<div class="tab" data-tab="runners">By Runner</div>
</div>
<div class="tab-content">
<div class="tab-panel active" id="tab-games">
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>#</th>
<th>Game</th>
<th>Playtime</th>
<th style="text-align: right">%</th>
</tr>
</thead>
<tbody id="games-table"></tbody>
</table>
</div>
</div>
<div class="tab-panel" id="tab-categories">
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>#</th>
<th>Category</th>
<th>Playtime</th>
<th style="text-align: right">%</th>
</tr>
</thead>
<tbody id="categories-table"></tbody>
</table>
</div>
</div>
<div class="tab-panel" id="tab-runners">
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>#</th>
<th>Runner</th>
<th>Playtime</th>
<th style="text-align: right">%</th>
</tr>
</thead>
<tbody id="runners-table"></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<script>
__SCRIPT__
</script>
</body>
</html>

633
templates/neumorphism.css Normal file
View File

@@ -0,0 +1,633 @@
/****************************************************************************************************
* Copyright (C) 2026 by WallyHackenslacker wallyhackenslacker@noreply.git.hackenslacker.space *
* *
* Permission to use, copy, modify, and/or distribute this software for any purpose with or without *
* fee is hereby granted. *
* *
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS *
* SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE *
* AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES *
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, *
* NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE *
* OF THIS SOFTWARE. *
****************************************************************************************************/
:root {
--bg-base: #e0e5ec;
--bg-primary: #e0e5ec;
--bg-secondary: #e0e5ec;
--bg-tertiary: #d1d9e6;
--text-primary: #2d3436;
--text-secondary: #4a5568;
--text-muted: #718096;
--accent-color: #6366f1;
--accent-hover: #4f46e5;
--selection-bg: rgba(99, 102, 241, 0.15);
--shadow-light: rgba(255, 255, 255, 0.8);
--shadow-dark: rgba(163, 177, 198, 0.6);
--shadow-inset-light: rgba(255, 255, 255, 0.7);
--shadow-inset-dark: rgba(163, 177, 198, 0.5);
--card-radius: 20px;
--border-color: transparent;
}
[data-theme="dark"] {
--bg-base: #2d3436;
--bg-primary: #2d3436;
--bg-secondary: #2d3436;
--bg-tertiary: #353b3d;
--text-primary: #f0f0f5;
--text-secondary: #b0b8c0;
--text-muted: #8090a0;
--accent-color: #818cf8;
--accent-hover: #6366f1;
--selection-bg: rgba(129, 140, 248, 0.2);
--shadow-light: rgba(255, 255, 255, 0.05);
--shadow-dark: rgba(0, 0, 0, 0.4);
--shadow-inset-light: rgba(255, 255, 255, 0.03);
--shadow-inset-dark: rgba(0, 0, 0, 0.3);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg-base: #2d3436;
--bg-primary: #2d3436;
--bg-secondary: #2d3436;
--bg-tertiary: #353b3d;
--text-primary: #f0f0f5;
--text-secondary: #b0b8c0;
--text-muted: #8090a0;
--accent-color: #818cf8;
--accent-hover: #6366f1;
--selection-bg: rgba(129, 140, 248, 0.2);
--shadow-light: rgba(255, 255, 255, 0.05);
--shadow-dark: rgba(0, 0, 0, 0.4);
--shadow-inset-light: rgba(255, 255, 255, 0.03);
--shadow-inset-dark: rgba(0, 0, 0, 0.3);
}
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
font-size: 14px;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background-color: var(--bg-base);
min-height: 100vh;
color: var(--text-primary);
line-height: 1.5;
}
/* Theme Toggle Button */
.theme-toggle {
position: fixed;
top: 20px;
right: 20px;
z-index: 1000;
width: 48px;
height: 48px;
border-radius: 50%;
border: none;
background: var(--bg-primary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
box-shadow:
6px 6px 12px var(--shadow-dark),
-6px -6px 12px var(--shadow-light);
transition: all 0.3s ease;
}
.theme-toggle:hover {
transform: scale(1.05);
}
.theme-toggle:active {
box-shadow:
inset 4px 4px 8px var(--shadow-inset-dark),
inset -4px -4px 8px var(--shadow-inset-light);
}
.theme-toggle .icon {
transition: transform 0.3s ease;
}
.theme-toggle .icon-auto {
position: relative;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.theme-toggle .icon-auto::before,
.theme-toggle .icon-auto::after {
position: absolute;
font-size: 20px;
line-height: 1;
}
.theme-toggle .icon-auto::before {
content: '☀️';
clip-path: inset(0 50% 0 0);
}
.theme-toggle .icon-auto::after {
content: '🌙';
clip-path: inset(0 0 0 50%);
}
/* Card Style */
.card {
background: var(--bg-primary);
border-radius: var(--card-radius);
box-shadow:
8px 8px 16px var(--shadow-dark),
-8px -8px 16px var(--shadow-light);
margin-bottom: 24px;
overflow: hidden;
transition: all 0.3s ease;
}
.card-header {
padding: 16px 20px;
background: var(--bg-tertiary);
border-radius: var(--card-radius) var(--card-radius) 0 0;
}
.card-title {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.card-content {
padding: 20px;
}
.summaries-content {
padding: 0;
padding-top: 16px;
}
/* Filters */
.filters {
display: flex;
justify-content: center;
gap: 16px;
flex-wrap: wrap;
}
.filter-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
padding: 10px 18px;
border-radius: 30px;
background: var(--bg-secondary);
box-shadow:
4px 4px 8px var(--shadow-dark),
-4px -4px 8px var(--shadow-light);
transition: all 0.2s ease;
}
.filter-label:hover {
transform: translateY(-2px);
}
.filter-label:active {
box-shadow:
inset 3px 3px 6px var(--shadow-inset-dark),
inset -3px -3px 6px var(--shadow-inset-light);
}
.filter-label input[type="checkbox"] {
appearance: none;
-webkit-appearance: none;
width: 20px;
height: 20px;
border-radius: 6px;
background: var(--bg-secondary);
cursor: pointer;
position: relative;
transition: all 0.2s ease;
box-shadow:
inset 2px 2px 4px var(--shadow-inset-dark),
inset -2px -2px 4px var(--shadow-inset-light);
}
.filter-label input[type="checkbox"]:checked {
background: var(--accent-color);
box-shadow:
inset 2px 2px 4px rgba(0, 0, 0, 0.2),
inset -2px -2px 4px rgba(255, 255, 255, 0.1);
}
.filter-label input[type="checkbox"]:checked::after {
content: '';
position: absolute;
left: 6px;
top: 3px;
width: 5px;
height: 9px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
.filter-label .service-name {
text-transform: capitalize;
font-weight: 500;
color: var(--text-primary);
}
.filter-label .service-count {
color: var(--text-muted);
font-size: 12px;
}
/* Stats */
.stats {
display: flex;
justify-content: center;
gap: 24px;
flex-wrap: wrap;
}
.stat {
text-align: center;
padding: 24px 36px;
background: var(--bg-secondary);
border-radius: 16px;
min-width: 140px;
box-shadow:
6px 6px 12px var(--shadow-dark),
-6px -6px 12px var(--shadow-light);
}
.stat-value {
font-size: 28px;
font-weight: 700;
color: var(--accent-color);
font-variant-numeric: tabular-nums;
}
.stat-label {
font-size: 12px;
color: var(--text-muted);
margin-top: 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Charts */
.charts-wrapper {
display: grid;
grid-auto-columns: minmax(280px, 450px);
grid-template-columns: repeat(auto-fill, minmax(280px, 450px));
grid-gap: 24px;
justify-content: center;
flex-wrap: wrap;
}
.chart-container {
min-width: 280px;
max-width: 450px;
padding: 20px;
background: var(--bg-secondary);
border-radius: 16px;
box-shadow:
inset 4px 4px 8px var(--shadow-inset-dark),
inset -4px -4px 8px var(--shadow-inset-light);
}
.chart-title {
font-size: 14px;
font-weight: 600;
text-align: center;
margin-bottom: 12px;
color: var(--text-primary);
}
@media (max-width: 700px) {
.charts-wrapper {
flex-direction: column;
align-items: center;
}
.chart-container {
max-width: 100%;
width: 100%;
}
}
/* Tabs */
.tabs {
display: flex;
gap: 8px;
padding: 0 20px;
margin-bottom: -1px;
position: relative;
z-index: 1;
}
.tab {
padding: 12px 24px;
cursor: pointer;
color: var(--text-secondary);
font-weight: 500;
border-radius: 16px 16px 0 0;
background: var(--bg-tertiary);
transition: all 0.2s ease;
box-shadow:
4px -4px 8px var(--shadow-dark),
-4px -4px 8px var(--shadow-light);
}
.tab:hover {
color: var(--text-primary);
}
.tab.active {
color: var(--accent-color);
background: var(--bg-secondary);
box-shadow:
inset 2px 2px 4px var(--shadow-inset-dark),
inset -2px -2px 4px var(--shadow-inset-light);
}
.tab-content {
background: var(--bg-secondary);
border-radius: 16px;
padding: 16px;
box-shadow:
inset 4px 4px 8px var(--shadow-inset-dark),
inset -4px -4px 8px var(--shadow-inset-light);
}
.tab-panel {
display: none;
}
.tab-panel.active {
display: block;
}
/* Tables */
.table-wrapper {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
th {
padding: 12px 16px;
text-align: left;
font-weight: 600;
color: var(--text-secondary);
border-bottom: 2px solid var(--bg-tertiary);
text-transform: uppercase;
font-size: 11px;
letter-spacing: 0.5px;
}
td {
padding: 12px 16px;
border-bottom: 1px solid var(--bg-tertiary);
color: var(--text-primary);
}
tr:hover td {
background: var(--selection-bg);
}
tr:last-child td {
border-bottom: none;
}
.time {
font-variant-numeric: tabular-nums;
font-weight: 500;
}
.percent {
font-variant-numeric: tabular-nums;
text-align: right;
color: var(--text-muted);
}
.color-box {
display: inline-block;
width: 12px;
height: 12px;
margin-right: 8px;
vertical-align: middle;
border-radius: 4px;
box-shadow:
2px 2px 4px var(--shadow-dark),
-2px -2px 4px var(--shadow-light);
}
.service-badge {
display: inline-block;
font-size: 10px;
padding: 3px 10px;
background: var(--bg-tertiary);
border-radius: 12px;
color: var(--text-muted);
margin-left: 8px;
text-transform: capitalize;
font-weight: 500;
box-shadow:
inset 1px 1px 2px var(--shadow-inset-dark),
inset -1px -1px 2px var(--shadow-inset-light);
}
.category-badge {
display: inline-block;
font-size: 10px;
padding: 3px 10px;
background: var(--bg-tertiary);
border-radius: 12px;
color: var(--accent-color);
margin-left: 4px;
text-transform: capitalize;
font-weight: 500;
box-shadow:
inset 1px 1px 2px var(--shadow-inset-dark),
inset -1px -1px 2px var(--shadow-inset-light);
}
.no-data {
text-align: center;
padding: 40px;
color: var(--text-muted);
}
/* Others row expansion */
.others-row {
cursor: pointer;
}
.others-row td:first-child::before {
content: '';
display: inline-block;
width: 0;
height: 0;
border-left: 6px solid var(--text-muted);
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
margin-right: 8px;
transition: transform 0.2s ease;
}
.others-row.expanded td:first-child::before {
transform: rotate(90deg);
}
.others-detail {
display: none;
background: var(--bg-tertiary);
}
.others-detail.visible {
display: table-row;
}
.others-detail td {
padding-left: 32px;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-tertiary);
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: var(--text-muted);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary);
}
/* Responsive */
@media (max-width: 600px) {
body {
padding: 12px;
}
.stats {
gap: 12px;
}
.stat {
padding: 16px 20px;
min-width: 100px;
}
.stat-value {
font-size: 22px;
}
.filter-label {
padding: 8px 14px;
font-size: 13px;
}
.tabs {
padding: 0 12px;
}
.tab {
padding: 10px 16px;
font-size: 13px;
}
.theme-toggle {
width: 40px;
height: 40px;
font-size: 16px;
}
}
/* Scroll to Top Button */
.scroll-top {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 1000;
width: 48px;
height: 48px;
border-radius: 50%;
border: none;
background: var(--bg-primary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow:
6px 6px 12px var(--shadow-dark),
-6px -6px 12px var(--shadow-light);
opacity: 0;
visibility: hidden;
transition: all 0.3s ease;
}
.scroll-top.visible {
opacity: 1;
visibility: visible;
}
.scroll-top:hover {
transform: scale(1.05);
}
.scroll-top:active {
box-shadow:
inset 4px 4px 8px var(--shadow-inset-dark),
inset -4px -4px 8px var(--shadow-inset-light);
}
.scroll-top-arrow {
width: 0;
height: 0;
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-bottom: 10px solid var(--text-primary);
}
@media (max-width: 600px) {
.scroll-top {
width: 40px;
height: 40px;
}
.scroll-top-arrow {
border-left-width: 6px;
border-right-width: 6px;
border-bottom-width: 8px;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,16 @@
<!--
Copyright (C) 2026 by WallyHackenslacker wallyhackenslacker@noreply.git.hackenslacker.space
Permission to use, copy, modify, and/or distribute this software for any purpose with or without
fee is hereby granted.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS
SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE
AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT,
NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE
OF THIS SOFTWARE.
-->
<!DOCTYPE html>
<html lang="en">
<head>
@@ -204,14 +217,15 @@
/* Chart containers */
.charts-wrapper {
display: flex;
gap: 20px;
display: grid;
grid-auto-columns: minmax(280px, 450px);
grid-template-columns: repeat(auto-fill, minmax(280px, 450px));
grid-gap: 20px;
justify-content: center;
flex-wrap: wrap;
}
.chart-container {
position: relative;
flex: 1;
min-width: 280px;
max-width: 450px;
padding: 10px;
@@ -567,6 +581,14 @@
<div class="chart-title">By Category</div>
<canvas id="categories-chart"></canvas>
</div>
<div class="chart-container">
<div class="chart-title">By Runner</div>
<canvas id="runners-chart"></canvas>
</div>
<div class="chart-container">
<div class="chart-title">By Source</div>
<canvas id="sources-chart"></canvas>
</div>
</div>
</div>
</div>
@@ -582,6 +604,7 @@
<div class="tabs">
<div class="tab active" data-tab="games">Top Games</div>
<div class="tab" data-tab="categories">By Category</div>
<div class="tab" data-tab="runners">By Runner</div>
</div>
<div class="tab-content">
<div class="tab-panel active" id="tab-games">
@@ -614,6 +637,21 @@
</table>
</div>
</div>
<div class="tab-panel" id="tab-runners">
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>#</th>
<th>Runner</th>
<th>Playtime</th>
<th style="text-align: right">%</th>
</tr>
</thead>
<tbody id="runners-table"></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
@@ -665,8 +703,12 @@
let chart = null;
let categoriesChart = null;
let runnersChart = null;
let sourcesChart = null;
const ctx = document.getElementById('playtime-chart').getContext('2d');
const ctxCategories = document.getElementById('categories-chart').getContext('2d');
const ctxRunners = document.getElementById('runners-chart').getContext('2d');
const ctxSources = document.getElementById('sources-chart').getContext('2d');
function getSelectedServices() {
const checkboxes = filtersDiv.querySelectorAll('input[type="checkbox"]');
@@ -728,12 +770,36 @@
const categoriesData = Object.values(categoryMap)
.sort((a, b) => b.playtime - a.playtime);
return { chartData: topGames, othersGames, categoriesData, totalPlaytime, totalGames };
const runnerMap = {};
filtered.forEach(g => {
const runner = g.runner || 'unknown';
if (!runnerMap[runner]) {
runnerMap[runner] = { name: runner, playtime: 0, gameCount: 0 };
}
runnerMap[runner].playtime += g.playtime;
runnerMap[runner].gameCount++;
});
const runnersData = Object.values(runnerMap)
.sort((a, b) => b.playtime - a.playtime);
const sourceMap = {};
filtered.forEach(g => {
const source = g.service || 'unknown';
if (!sourceMap[source]) {
sourceMap[source] = { name: source, playtime: 0, gameCount: 0 };
}
sourceMap[source].playtime += g.playtime;
sourceMap[source].gameCount++;
});
const sourcesData = Object.values(sourceMap)
.sort((a, b) => b.playtime - a.playtime);
return { chartData: topGames, othersGames, categoriesData, runnersData, sourcesData, totalPlaytime, totalGames };
}
function updateDisplay() {
const selectedServices = getSelectedServices();
const { chartData, othersGames, categoriesData, totalPlaytime, totalGames } = getFilteredData(selectedServices);
const { chartData, othersGames, categoriesData, runnersData, sourcesData, totalPlaytime, totalGames } = getFilteredData(selectedServices);
document.getElementById('total-games').textContent = totalGames;
document.getElementById('total-time').textContent = formatTime(totalPlaytime);
@@ -744,12 +810,20 @@
if (categoriesChart) {
categoriesChart.destroy();
}
if (runnersChart) {
runnersChart.destroy();
}
if (sourcesChart) {
sourcesChart.destroy();
}
if (chartData.length === 0) {
document.getElementById('games-table').innerHTML =
'<tr><td colspan="4" class="no-data">No games match the selected filters</td></tr>';
document.getElementById('categories-table').innerHTML =
'<tr><td colspan="4" class="no-data">No categories found</td></tr>';
document.getElementById('runners-table').innerHTML =
'<tr><td colspan="4" class="no-data">No runners found</td></tr>';
return;
}
@@ -863,6 +937,116 @@
});
}
// Runners Chart
const topRunnersChart = runnersData.slice(0, topN);
const otherRunnersChart = runnersData.slice(topN);
const runnersChartData = topRunnersChart.map(r => ({
name: r.name,
playtime: r.playtime
}));
if (otherRunnersChart.length > 0) {
const othersPlaytime = otherRunnersChart.reduce((sum, r) => sum + r.playtime, 0);
runnersChartData.push({
name: `Others (${otherRunnersChart.length} runners)`,
playtime: othersPlaytime
});
}
if (runnersChartData.length > 0) {
runnersChart = new Chart(ctxRunners, {
type: 'doughnut',
data: {
labels: runnersChartData.map(r => r.name),
datasets: [{
data: runnersChartData.map(r => r.playtime),
backgroundColor: colors.slice(0, runnersChartData.length),
borderColor: '#FFFFFF',
borderWidth: 1
}]
},
options: {
responsive: true,
plugins: {
legend: {
position: 'bottom',
labels: {
color: '#000000',
font: {
family: "'Charcoal', 'Chicago', Geneva, sans-serif",
size: 10
},
padding: 10
}
},
tooltip: {
callbacks: {
label: function(context) {
const value = context.raw;
const percent = ((value / totalPlaytime) * 100).toFixed(1);
return ' ' + formatTime(value) + ' (' + percent + '%)';
}
}
}
}
}
});
}
// Sources Chart
const topSourcesChart = sourcesData.slice(0, topN);
const otherSourcesChart = sourcesData.slice(topN);
const sourcesChartData = topSourcesChart.map(s => ({
name: s.name,
playtime: s.playtime
}));
if (otherSourcesChart.length > 0) {
const othersPlaytime = otherSourcesChart.reduce((sum, s) => sum + s.playtime, 0);
sourcesChartData.push({
name: `Others (${otherSourcesChart.length} sources)`,
playtime: othersPlaytime
});
}
if (sourcesChartData.length > 0) {
sourcesChart = new Chart(ctxSources, {
type: 'doughnut',
data: {
labels: sourcesChartData.map(s => s.name),
datasets: [{
data: sourcesChartData.map(s => s.playtime),
backgroundColor: colors.slice(0, sourcesChartData.length),
borderColor: '#FFFFFF',
borderWidth: 1
}]
},
options: {
responsive: true,
plugins: {
legend: {
position: 'bottom',
labels: {
color: '#000000',
font: {
family: "'Charcoal', 'Chicago', Geneva, sans-serif",
size: 10
},
padding: 10
}
},
tooltip: {
callbacks: {
label: function(context) {
const value = context.raw;
const percent = ((value / totalPlaytime) * 100).toFixed(1);
return ' ' + formatTime(value) + ' (' + percent + '%)';
}
}
}
}
}
});
}
const tbody = document.getElementById('games-table');
tbody.innerHTML = '';
chartData.forEach((game, index) => {
@@ -988,6 +1172,71 @@
});
}
}
const runnersTbody = document.getElementById('runners-table');
runnersTbody.innerHTML = '';
if (runnersData.length === 0) {
runnersTbody.innerHTML = '<tr><td colspan="4" class="no-data">No runners found</td></tr>';
} else {
const topRunners = runnersData.slice(0, topN);
const otherRunners = runnersData.slice(topN);
topRunners.forEach((runner, index) => {
const percent = ((runner.playtime / totalPlaytime) * 100).toFixed(1);
const row = document.createElement('tr');
row.innerHTML = `
<td>${index + 1}</td>
<td>
<span class="color-box" style="background: ${colors[index]}"></span>
${runner.name} <span class="service-badge">${runner.gameCount} games</span>
</td>
<td class="time">${formatTime(runner.playtime)}</td>
<td class="percent">${percent}%</td>
`;
runnersTbody.appendChild(row);
});
if (otherRunners.length > 0) {
const othersPlaytime = otherRunners.reduce((sum, r) => sum + r.playtime, 0);
const othersPercent = ((othersPlaytime / totalPlaytime) * 100).toFixed(1);
const othersIndex = topRunners.length;
const othersRow = document.createElement('tr');
othersRow.className = 'others-row';
othersRow.innerHTML = `
<td>${othersIndex + 1}</td>
<td>
<span class="color-box" style="background: ${colors[othersIndex]}"></span>
Others (${otherRunners.length} runners)
</td>
<td class="time">${formatTime(othersPlaytime)}</td>
<td class="percent">${othersPercent}%</td>
`;
runnersTbody.appendChild(othersRow);
const detailRows = [];
otherRunners.forEach((otherRunner, otherIndex) => {
const otherPercent = ((otherRunner.playtime / totalPlaytime) * 100).toFixed(1);
const detailRow = document.createElement('tr');
detailRow.className = 'others-detail';
detailRow.innerHTML = `
<td>${othersIndex + 1}.${otherIndex + 1}</td>
<td>
${otherRunner.name} <span class="service-badge">${otherRunner.gameCount} games</span>
</td>
<td class="time">${formatTime(otherRunner.playtime)}</td>
<td class="percent">${otherPercent}%</td>
`;
runnersTbody.appendChild(detailRow);
detailRows.push(detailRow);
});
othersRow.addEventListener('click', () => {
othersRow.classList.toggle('expanded');
detailRows.forEach(dr => dr.classList.toggle('visible'));
});
}
}
}
filtersDiv.addEventListener('change', updateDisplay);

889
templates/script.js Normal file
View File

@@ -0,0 +1,889 @@
/****************************************************************************************************
* Copyright (C) 2026 by WallyHackenslacker wallyhackenslacker@noreply.git.hackenslacker.space *
* *
* Permission to use, copy, modify, and/or distribute this software for any purpose with or without *
* fee is hereby granted. *
* *
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS *
* SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE *
* AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES *
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, *
* NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE *
* OF THIS SOFTWARE. *
****************************************************************************************************/
/*
* Report UI controller
* --------------------
* This script powers all client-side behavior for the generated report page:
* 1) Theme handling (auto/light/dark) + persistence in localStorage
* 2) Data aggregation from embedded game data
* 3) Chart.js chart creation and refresh after filter/theme changes
* 4) Table rendering with expandable "Others" rows
* 5) Small UI helpers (tabs, scroll-to-top button)
*
* The generator injects placeholders such as __ALL_GAMES__, __TOP_N__, and __THEME_CONFIG__
* before this script reaches the browser.
*/
// Theme management
const themeToggle = document.getElementById('theme-toggle');
const themeIcon = document.getElementById('theme-icon');
const themes = ['auto', 'light', 'dark'];
let currentThemeIndex = 0;
// Returns the current OS/browser theme preference.
function getSystemTheme() {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
/*
* Updates button icon/title to reflect currently selected mode.
*
* Note: "auto" does not hard-force a theme; it follows system preference.
*/
function updateThemeIcon() {
const theme = themes[currentThemeIndex];
if (theme === 'auto') {
themeIcon.textContent = '';
themeIcon.className = 'icon icon-auto';
themeToggle.title = 'Theme: Auto (click to change)';
} else if (theme === 'light') {
themeIcon.textContent = '\u2600\uFE0F';
themeIcon.className = 'icon';
themeToggle.title = 'Theme: Light (click to change)';
} else {
themeIcon.textContent = '\uD83C\uDF19';
themeIcon.className = 'icon';
themeToggle.title = 'Theme: Dark (click to change)';
}
}
/*
* Applies the selected theme mode to the root element and re-syncs all dependent UI.
*
* Behavior:
* - auto: remove data-theme so CSS/media-query driven defaults can apply
* - light/dark: set data-theme explicitly for deterministic styling
*
* Also updates chart legend colors because those are configured in JS, not pure CSS.
*/
function applyTheme() {
const theme = themes[currentThemeIndex];
if (theme === 'auto') {
document.documentElement.removeAttribute('data-theme');
} else {
document.documentElement.setAttribute('data-theme', theme);
}
updateThemeIcon();
localStorage.setItem('theme', theme);
updateChartColors();
}
// Restores previously saved theme mode; defaults to "auto" when absent/invalid.
function loadSavedTheme() {
const saved = localStorage.getItem('theme');
if (saved) {
currentThemeIndex = themes.indexOf(saved);
if (currentThemeIndex === -1) currentThemeIndex = 0;
}
applyTheme();
}
// Cycle theme modes in a fixed order: auto -> light -> dark -> auto.
themeToggle.addEventListener('click', () => {
currentThemeIndex = (currentThemeIndex + 1) % themes.length;
applyTheme();
});
// When system theme changes and mode is "auto", refresh theme-dependent UI pieces.
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (themes[currentThemeIndex] === 'auto') {
updateThemeIcon();
updateChartColors();
}
});
// Data and chart logic
const allGames = __ALL_GAMES__;
const topN = __TOP_N__;
// Theme-specific configuration
const themeConfig = __THEME_CONFIG__;
// Converts decimal hours to a compact human-friendly string (e.g. 1.5 -> "1h 30m").
function formatTime(hours) {
const h = Math.floor(hours);
const m = Math.round((hours - h) * 60);
if (h === 0) return m + 'm';
if (m === 0) return h + 'h';
return h + 'h ' + m + 'm';
}
/*
* Aggregates game list by service/source for checkbox filter creation.
*
* Output is sorted by playtime descending so the most relevant services appear first.
*/
function getServices() {
const services = {};
allGames.forEach(g => {
const s = g.service;
if (!services[s]) services[s] = { count: 0, playtime: 0 };
services[s].count++;
services[s].playtime += g.playtime;
});
return Object.entries(services)
.sort((a, b) => b[1].playtime - a[1].playtime)
.map(([name, data]) => ({ name, ...data }));
}
const services = getServices();
const filtersDiv = document.getElementById('filters');
// Build one checkbox per service so users can include/exclude data dynamically.
services.forEach(service => {
const label = document.createElement('label');
label.className = 'filter-label';
label.innerHTML = `
<input type="checkbox" value="${service.name}" checked>
<span class="service-name">${service.name}</span>
<span class="service-count">(${service.count})</span>
`;
filtersDiv.appendChild(label);
});
let chart = null;
let categoriesChart = null;
let runnersChart = null;
let sourcesChart = null;
const ctx = document.getElementById('playtime-chart').getContext('2d');
const ctxCategories = document.getElementById('categories-chart').getContext('2d');
const ctxRunners = document.getElementById('runners-chart').getContext('2d');
const ctxSources = document.getElementById('sources-chart').getContext('2d');
// Initialize theme after chart variables are declared
loadSavedTheme();
// Reads currently checked service filters from the filter panel.
function getSelectedServices() {
const checkboxes = filtersDiv.querySelectorAll('input[type="checkbox"]');
return Array.from(checkboxes)
.filter(cb => cb.checked)
.map(cb => cb.value);
}
/*
* Produces all derived datasets required by charts and tables for the current filters.
*
* Returns:
* - chartData: top games list used by the main playtime chart/table
* - othersGames: the overflow games collapsed into chartData's "Others" bucket
* - categoriesData/runnersData/sourcesData: grouped aggregations for secondary views
* - totalPlaytime/totalGames: summary statistics for header counters and percentages
*
* Example (topN = 5):
* - 18 filtered games => 5 explicit entries + 1 synthetic "Others (13 games)" entry
* - the 13 overflow games are still accessible in expandable table detail rows
*/
function getFilteredData(selectedServices) {
const filtered = allGames
.filter(g => selectedServices.includes(g.service))
.sort((a, b) => b.playtime - a.playtime);
if (filtered.length === 0) {
return { chartData: [], othersGames: [], categoriesData: [], totalPlaytime: 0, totalGames: 0 };
}
const totalPlaytime = filtered.reduce((sum, g) => sum + g.playtime, 0);
const totalGames = filtered.length;
// Keep only top N games for chart readability.
const topGames = filtered.slice(0, topN).map(g => ({
name: g.name,
playtime: g.playtime,
service: g.service,
categories: g.categories || []
}));
let othersGames = [];
// Collapse remaining entries into a single "Others" slice while preserving detail rows.
if (filtered.length > topN) {
othersGames = filtered.slice(topN).map(g => ({
name: g.name,
playtime: g.playtime,
service: g.service,
categories: g.categories || []
}));
const othersPlaytime = othersGames.reduce((sum, g) => sum + g.playtime, 0);
const othersCount = othersGames.length;
topGames.push({
name: `Others (${othersCount} games)`,
playtime: othersPlaytime,
service: 'others'
});
}
/*
* Category aggregation notes:
* - A game's full playtime contributes to each of its categories.
* - Internal/special categories are intentionally hidden from report views.
*
* Example:
* Game X (10h) in [RPG, Coop] increases RPG by 10h and Coop by 10h.
*/
const categoryMap = {};
filtered.forEach(g => {
if (g.categories && g.categories.length > 0) {
g.categories.forEach(cat => {
if (cat === '.hidden' || cat === 'favorite') return;
if (!categoryMap[cat]) {
categoryMap[cat] = { name: cat, playtime: 0, gameCount: 0 };
}
categoryMap[cat].playtime += g.playtime;
categoryMap[cat].gameCount++;
});
}
});
const categoriesData = Object.values(categoryMap)
.sort((a, b) => b.playtime - a.playtime);
// Aggregate by runner (wine, native, dosbox, etc.). Missing values become "unknown".
const runnerMap = {};
filtered.forEach(g => {
const runner = g.runner || 'unknown';
if (!runnerMap[runner]) {
runnerMap[runner] = { name: runner, playtime: 0, gameCount: 0 };
}
runnerMap[runner].playtime += g.playtime;
runnerMap[runner].gameCount++;
});
const runnersData = Object.values(runnerMap)
.sort((a, b) => b.playtime - a.playtime);
// Aggregate by source/service to power the Sources chart.
const sourceMap = {};
filtered.forEach(g => {
const source = g.service || 'unknown';
if (!sourceMap[source]) {
sourceMap[source] = { name: source, playtime: 0, gameCount: 0 };
}
sourceMap[source].playtime += g.playtime;
sourceMap[source].gameCount++;
});
const sourcesData = Object.values(sourceMap)
.sort((a, b) => b.playtime - a.playtime);
return { chartData: topGames, othersGames, categoriesData, runnersData, sourcesData, totalPlaytime, totalGames };
}
// Theme-aware chart text color (explicit mode or system-driven when in auto).
function getChartTextColor() {
const theme = themes[currentThemeIndex];
if (theme === 'dark') return themeConfig.textColorDark;
if (theme === 'light') return themeConfig.textColorLight;
return getSystemTheme() === 'dark' ? themeConfig.textColorDark : themeConfig.textColorLight;
}
// Theme-aware border color for chart segment separators.
function getChartBorderColor() {
const theme = themes[currentThemeIndex];
if (theme === 'dark') return themeConfig.borderColorDark;
if (theme === 'light') return themeConfig.borderColorLight;
return getSystemTheme() === 'dark' ? themeConfig.borderColorDark : themeConfig.borderColorLight;
}
/*
* Updates existing chart instances after theme changes.
*
* We only patch runtime options that are theme-sensitive here (legend text color),
* then call chart.update() so Chart.js redraws without rebuilding datasets.
*/
function updateChartColors() {
const textColor = getChartTextColor();
if (typeof chart !== 'undefined' && chart) {
chart.options.plugins.legend.labels.color = textColor;
chart.update();
}
if (typeof categoriesChart !== 'undefined' && categoriesChart) {
categoriesChart.options.plugins.legend.labels.color = textColor;
categoriesChart.update();
}
if (typeof runnersChart !== 'undefined' && runnersChart) {
runnersChart.options.plugins.legend.labels.color = textColor;
runnersChart.update();
}
if (typeof sourcesChart !== 'undefined' && sourcesChart) {
sourcesChart.options.plugins.legend.labels.color = textColor;
sourcesChart.update();
}
}
/*
* Main render pipeline.
*
* This function is triggered on:
* - initial page load
* - service filter changes
*
* Steps:
* 1) read active filters and compute all derived data
* 2) refresh summary counters
* 3) rebuild charts
* 4) rebuild data tables (including expandable Others rows)
*/
function updateDisplay() {
const selectedServices = getSelectedServices();
const { chartData, othersGames, categoriesData, runnersData, sourcesData, totalPlaytime, totalGames } = getFilteredData(selectedServices);
document.getElementById('total-games').textContent = totalGames;
document.getElementById('total-time').textContent = formatTime(totalPlaytime);
// Destroy old chart instances before creating new ones to avoid stacked canvases/memory leaks.
if (chart) {
chart.destroy();
}
if (categoriesChart) {
categoriesChart.destroy();
}
if (runnersChart) {
runnersChart.destroy();
}
if (sourcesChart) {
sourcesChart.destroy();
}
if (chartData.length === 0) {
document.getElementById('games-table').innerHTML =
'<tr><td colspan="4" class="no-data">No games match the selected filters</td></tr>';
document.getElementById('categories-table').innerHTML =
'<tr><td colspan="4" class="no-data">No categories found</td></tr>';
document.getElementById('runners-table').innerHTML =
'<tr><td colspan="4" class="no-data">No runners found</td></tr>';
return;
}
const textColor = getChartTextColor();
const borderColor = getChartBorderColor();
/*
* Primary chart: top games by playtime (plus optional Others slice).
*
* Tooltip details:
* - title: game/slice name
* - beforeBody: service label (when available and not Others)
* - label: absolute time + percentage of currently filtered total
*/
chart = new Chart(ctx, {
type: 'doughnut',
data: {
labels: chartData.map(g => g.name),
datasets: [{
data: chartData.map(g => g.playtime),
backgroundColor: themeConfig.colors.slice(0, chartData.length),
borderColor: borderColor,
borderWidth: themeConfig.borderWidth
}]
},
options: {
responsive: true,
plugins: {
legend: {
position: 'bottom',
labels: {
color: textColor,
font: {
family: themeConfig.fontFamily,
size: 11,
weight: themeConfig.fontWeight
},
padding: 12,
usePointStyle: true,
pointStyle: themeConfig.pointStyle
}
},
tooltip: {
backgroundColor: themeConfig.tooltipBg,
titleColor: themeConfig.tooltipTitleColor,
bodyColor: themeConfig.tooltipBodyColor,
borderColor: themeConfig.tooltipBorderColor,
borderWidth: themeConfig.tooltipBorderWidth,
titleFont: { weight: 'bold', family: themeConfig.fontFamily },
bodyFont: { weight: 'normal', family: themeConfig.fontFamily },
cornerRadius: themeConfig.tooltipCornerRadius,
padding: 12,
callbacks: {
title: function(context) {
return themeConfig.uppercaseTooltip ? context[0].label.toUpperCase() : context[0].label;
},
beforeBody: function(context) {
const index = context[0].dataIndex;
const service = chartData[index].service;
if (service && service !== 'others') {
return themeConfig.uppercaseTooltip ? service.toUpperCase() : service.charAt(0).toUpperCase() + service.slice(1);
}
return '';
},
label: function(context) {
const value = context.raw;
const percent = ((value / totalPlaytime) * 100).toFixed(1);
return formatTime(value) + ' (' + percent + '%)';
}
}
}
}
}
});
/*
* Categories chart uses the same topN + Others strategy as games.
*
* Example (topN = 8):
* 12 categories => 8 visible slices + "Others (4 categories)" slice.
*/
const topCategoriesChart = categoriesData.slice(0, topN);
const otherCategoriesChart = categoriesData.slice(topN);
const categoriesChartData = topCategoriesChart.map(c => ({
name: c.name,
playtime: c.playtime
}));
if (otherCategoriesChart.length > 0) {
const othersPlaytime = otherCategoriesChart.reduce((sum, c) => sum + c.playtime, 0);
categoriesChartData.push({
name: `Others (${otherCategoriesChart.length} categories)`,
playtime: othersPlaytime
});
}
if (categoriesChartData.length > 0) {
categoriesChart = new Chart(ctxCategories, {
type: 'doughnut',
data: {
labels: categoriesChartData.map(c => c.name),
datasets: [{
data: categoriesChartData.map(c => c.playtime),
backgroundColor: themeConfig.colors.slice(0, categoriesChartData.length),
borderColor: borderColor,
borderWidth: themeConfig.borderWidth
}]
},
options: {
responsive: true,
plugins: {
legend: {
position: 'bottom',
labels: {
color: textColor,
font: {
family: themeConfig.fontFamily,
size: 11,
weight: themeConfig.fontWeight
},
padding: 12,
usePointStyle: true,
pointStyle: themeConfig.pointStyle
}
},
tooltip: {
backgroundColor: themeConfig.tooltipBg,
titleColor: themeConfig.tooltipTitleColor,
bodyColor: themeConfig.tooltipBodyColor,
borderColor: themeConfig.tooltipBorderColor,
borderWidth: themeConfig.tooltipBorderWidth,
cornerRadius: themeConfig.tooltipCornerRadius,
padding: 12,
titleFont: { family: themeConfig.fontFamily },
bodyFont: { family: themeConfig.fontFamily },
callbacks: {
label: function(context) {
const value = context.raw;
const percent = ((value / totalPlaytime) * 100).toFixed(1);
return ' ' + formatTime(value) + ' (' + percent + '%)';
}
}
}
}
}
});
}
// Runners chart: identical bucketing strategy applied to runner aggregation.
const topRunnersChart = runnersData.slice(0, topN);
const otherRunnersChart = runnersData.slice(topN);
const runnersChartData = topRunnersChart.map(r => ({
name: r.name,
playtime: r.playtime
}));
if (otherRunnersChart.length > 0) {
const othersPlaytime = otherRunnersChart.reduce((sum, r) => sum + r.playtime, 0);
runnersChartData.push({
name: `Others (${otherRunnersChart.length} runners)`,
playtime: othersPlaytime
});
}
if (runnersChartData.length > 0) {
runnersChart = new Chart(ctxRunners, {
type: 'doughnut',
data: {
labels: runnersChartData.map(r => r.name),
datasets: [{
data: runnersChartData.map(r => r.playtime),
backgroundColor: themeConfig.colors.slice(0, runnersChartData.length),
borderColor: borderColor,
borderWidth: themeConfig.borderWidth
}]
},
options: {
responsive: true,
plugins: {
legend: {
position: 'bottom',
labels: {
color: textColor,
font: {
family: themeConfig.fontFamily,
size: 11,
weight: themeConfig.fontWeight
},
padding: 12,
usePointStyle: true,
pointStyle: themeConfig.pointStyle
}
},
tooltip: {
backgroundColor: themeConfig.tooltipBg,
titleColor: themeConfig.tooltipTitleColor,
bodyColor: themeConfig.tooltipBodyColor,
borderColor: themeConfig.tooltipBorderColor,
borderWidth: themeConfig.tooltipBorderWidth,
cornerRadius: themeConfig.tooltipCornerRadius,
padding: 12,
titleFont: { family: themeConfig.fontFamily },
bodyFont: { family: themeConfig.fontFamily },
callbacks: {
label: function(context) {
const value = context.raw;
const percent = ((value / totalPlaytime) * 100).toFixed(1);
return ' ' + formatTime(value) + ' (' + percent + '%)';
}
}
}
}
}
});
}
// Sources chart: grouped by service/source, also with topN + Others bucketing.
const topSourcesChart = sourcesData.slice(0, topN);
const otherSourcesChart = sourcesData.slice(topN);
const sourcesChartData = topSourcesChart.map(s => ({
name: s.name,
playtime: s.playtime
}));
if (otherSourcesChart.length > 0) {
const othersPlaytime = otherSourcesChart.reduce((sum, s) => sum + s.playtime, 0);
sourcesChartData.push({
name: `Others (${otherSourcesChart.length} sources)`,
playtime: othersPlaytime
});
}
if (sourcesChartData.length > 0) {
sourcesChart = new Chart(ctxSources, {
type: 'doughnut',
data: {
labels: sourcesChartData.map(s => s.name),
datasets: [{
data: sourcesChartData.map(s => s.playtime),
backgroundColor: themeConfig.colors.slice(0, sourcesChartData.length),
borderColor: borderColor,
borderWidth: themeConfig.borderWidth
}]
},
options: {
responsive: true,
plugins: {
legend: {
position: 'bottom',
labels: {
color: textColor,
font: {
family: themeConfig.fontFamily,
size: 11,
weight: themeConfig.fontWeight
},
padding: 12,
usePointStyle: true,
pointStyle: themeConfig.pointStyle
}
},
tooltip: {
backgroundColor: themeConfig.tooltipBg,
titleColor: themeConfig.tooltipTitleColor,
bodyColor: themeConfig.tooltipBodyColor,
borderColor: themeConfig.tooltipBorderColor,
borderWidth: themeConfig.tooltipBorderWidth,
cornerRadius: themeConfig.tooltipCornerRadius,
padding: 12,
titleFont: { family: themeConfig.fontFamily },
bodyFont: { family: themeConfig.fontFamily },
callbacks: {
label: function(context) {
const value = context.raw;
const percent = ((value / totalPlaytime) * 100).toFixed(1);
return ' ' + formatTime(value) + ' (' + percent + '%)';
}
}
}
}
}
});
}
/*
* Games table rendering
* ---------------------
* Mirrors the main chart ordering and values.
*
* "Others" behavior:
* - parent row shows combined totals
* - click toggles child detail rows for each hidden game
* - detail index format uses parent.child numbering (e.g. 6.1, 6.2)
*/
const tbody = document.getElementById('games-table');
tbody.innerHTML = '';
chartData.forEach((game, index) => {
// Percentages are always based on currently filtered total playtime.
const percent = ((game.playtime / totalPlaytime) * 100).toFixed(1);
const isOthers = game.service === 'others';
const serviceBadge = !isOthers
? `<span class="service-badge">${game.service}</span>`
: '';
const categoriesBadges = !isOthers && game.categories && game.categories.length > 0
? game.categories
.filter(cat => cat !== '.hidden' && cat !== 'favorite')
.map(cat => `<span class="category-badge">${cat}</span>`)
.join('')
: '';
const row = document.createElement('tr');
if (isOthers) {
row.className = 'others-row';
}
row.innerHTML = `
<td>${index + 1}</td>
<td>
<span class="color-box" style="background: ${themeConfig.colors[index]}"></span>
${game.name}${serviceBadge}${categoriesBadges}
</td>
<td class="time">${formatTime(game.playtime)}</td>
<td class="percent">${percent}%</td>
`;
tbody.appendChild(row);
if (isOthers && othersGames.length > 0) {
const detailRows = [];
othersGames.forEach((otherGame, otherIndex) => {
const otherPercent = ((otherGame.playtime / totalPlaytime) * 100).toFixed(1);
const otherCategoriesBadges = otherGame.categories && otherGame.categories.length > 0
? otherGame.categories
.filter(cat => cat !== '.hidden' && cat !== 'favorite')
.map(cat => `<span class="category-badge">${cat}</span>`)
.join('')
: '';
const detailRow = document.createElement('tr');
detailRow.className = 'others-detail';
detailRow.innerHTML = `
<td>${index + 1}.${otherIndex + 1}</td>
<td>
${otherGame.name}
<span class="service-badge">${otherGame.service}</span>${otherCategoriesBadges}
</td>
<td class="time">${formatTime(otherGame.playtime)}</td>
<td class="percent">${otherPercent}%</td>
`;
tbody.appendChild(detailRow);
detailRows.push(detailRow);
});
// Expand/collapse all detail rows tied to this "Others" summary row.
row.addEventListener('click', () => {
row.classList.toggle('expanded');
detailRows.forEach(dr => dr.classList.toggle('visible'));
});
}
});
// Categories table mirrors chart grouping and supports expandable "Others" rows.
const catTbody = document.getElementById('categories-table');
catTbody.innerHTML = '';
if (categoriesData.length === 0) {
catTbody.innerHTML = '<tr><td colspan="4" class="no-data">No categories found</td></tr>';
} else {
const topCategories = categoriesData.slice(0, topN);
const otherCategories = categoriesData.slice(topN);
topCategories.forEach((cat, index) => {
const percent = ((cat.playtime / totalPlaytime) * 100).toFixed(1);
const row = document.createElement('tr');
row.innerHTML = `
<td>${index + 1}</td>
<td>
<span class="color-box" style="background: ${themeConfig.colors[index]}"></span>
${cat.name} <span class="service-badge">${cat.gameCount} games</span>
</td>
<td class="time">${formatTime(cat.playtime)}</td>
<td class="percent">${percent}%</td>
`;
catTbody.appendChild(row);
});
if (otherCategories.length > 0) {
const othersPlaytime = otherCategories.reduce((sum, c) => sum + c.playtime, 0);
const othersPercent = ((othersPlaytime / totalPlaytime) * 100).toFixed(1);
const othersIndex = topCategories.length;
const othersRow = document.createElement('tr');
othersRow.className = 'others-row';
othersRow.innerHTML = `
<td>${othersIndex + 1}</td>
<td>
<span class="color-box" style="background: ${themeConfig.colors[othersIndex]}"></span>
Others (${otherCategories.length} categories)
</td>
<td class="time">${formatTime(othersPlaytime)}</td>
<td class="percent">${othersPercent}%</td>
`;
catTbody.appendChild(othersRow);
const detailRows = [];
otherCategories.forEach((otherCat, otherIndex) => {
const otherPercent = ((otherCat.playtime / totalPlaytime) * 100).toFixed(1);
const detailRow = document.createElement('tr');
detailRow.className = 'others-detail';
detailRow.innerHTML = `
<td>${othersIndex + 1}.${otherIndex + 1}</td>
<td>
${otherCat.name} <span class="service-badge">${otherCat.gameCount} games</span>
</td>
<td class="time">${formatTime(otherCat.playtime)}</td>
<td class="percent">${otherPercent}%</td>
`;
catTbody.appendChild(detailRow);
detailRows.push(detailRow);
});
othersRow.addEventListener('click', () => {
othersRow.classList.toggle('expanded');
detailRows.forEach(dr => dr.classList.toggle('visible'));
});
}
}
// Runners table mirrors chart grouping and supports expandable "Others" rows.
const runnersTbody = document.getElementById('runners-table');
runnersTbody.innerHTML = '';
if (runnersData.length === 0) {
runnersTbody.innerHTML = '<tr><td colspan="4" class="no-data">No runners found</td></tr>';
} else {
const topRunners = runnersData.slice(0, topN);
const otherRunners = runnersData.slice(topN);
topRunners.forEach((runner, index) => {
const percent = ((runner.playtime / totalPlaytime) * 100).toFixed(1);
const row = document.createElement('tr');
row.innerHTML = `
<td>${index + 1}</td>
<td>
<span class="color-box" style="background: ${themeConfig.colors[index]}"></span>
${runner.name} <span class="service-badge">${runner.gameCount} games</span>
</td>
<td class="time">${formatTime(runner.playtime)}</td>
<td class="percent">${percent}%</td>
`;
runnersTbody.appendChild(row);
});
if (otherRunners.length > 0) {
const othersPlaytime = otherRunners.reduce((sum, r) => sum + r.playtime, 0);
const othersPercent = ((othersPlaytime / totalPlaytime) * 100).toFixed(1);
const othersIndex = topRunners.length;
const othersRow = document.createElement('tr');
othersRow.className = 'others-row';
othersRow.innerHTML = `
<td>${othersIndex + 1}</td>
<td>
<span class="color-box" style="background: ${themeConfig.colors[othersIndex]}"></span>
Others (${otherRunners.length} runners)
</td>
<td class="time">${formatTime(othersPlaytime)}</td>
<td class="percent">${othersPercent}%</td>
`;
runnersTbody.appendChild(othersRow);
const detailRows = [];
otherRunners.forEach((otherRunner, otherIndex) => {
const otherPercent = ((otherRunner.playtime / totalPlaytime) * 100).toFixed(1);
const detailRow = document.createElement('tr');
detailRow.className = 'others-detail';
detailRow.innerHTML = `
<td>${othersIndex + 1}.${otherIndex + 1}</td>
<td>
${otherRunner.name} <span class="service-badge">${otherRunner.gameCount} games</span>
</td>
<td class="time">${formatTime(otherRunner.playtime)}</td>
<td class="percent">${otherPercent}%</td>
`;
runnersTbody.appendChild(detailRow);
detailRows.push(detailRow);
});
othersRow.addEventListener('click', () => {
othersRow.classList.toggle('expanded');
detailRows.forEach(dr => dr.classList.toggle('visible'));
});
}
}
}
// Re-render whenever any service checkbox state changes.
filtersDiv.addEventListener('change', updateDisplay);
// Initial render with all services selected by default.
updateDisplay();
// Tab switching
// Activates selected tab and matching panel while deactivating the rest.
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
const tabId = tab.dataset.tab;
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
document.getElementById('tab-' + tabId).classList.add('active');
});
});
// Scroll to top button
const scrollTopBtn = document.getElementById('scroll-top');
// Show the floating button only after user has scrolled past a threshold.
function updateScrollTopVisibility() {
if (window.scrollY > 100) {
scrollTopBtn.classList.add('visible');
} else {
scrollTopBtn.classList.remove('visible');
}
}
window.addEventListener('scroll', updateScrollTopVisibility);
updateScrollTopVisibility();
// Smoothly jump to top for better UX on long reports.
scrollTopBtn.addEventListener('click', () => {
window.scrollTo({
top: 0,
behavior: 'smooth'
});
});

628
templates/skeuo.css Normal file
View File

@@ -0,0 +1,628 @@
/****************************************************************************************************
* Copyright (C) 2026 by WallyHackenslacker wallyhackenslacker@noreply.git.hackenslacker.space *
* *
* Permission to use, copy, modify, and/or distribute this software for any purpose with or without *
* fee is hereby granted. *
* *
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS *
* SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE *
* AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES *
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, *
* NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE *
* OF THIS SOFTWARE. *
****************************************************************************************************/
:root {
--bg-primary: #efe4d3;
--bg-secondary: #e5d6c0;
--bg-tertiary: #dcc8aa;
--bg-panel: linear-gradient(180deg, #f6ecdf 0%, #e5d6c0 100%);
--text-primary: #2c2218;
--text-secondary: #5a4732;
--text-muted: #7d6750;
--border-color: #b79f83;
--shadow-color: rgba(40, 28, 15, 0.35);
--shine-color: rgba(255, 255, 255, 0.75);
--accent-color: #8b4a2c;
--accent-hover: #6e3a22;
--selection-bg: rgba(139, 74, 44, 0.18);
--card-radius: 14px;
--bevel-shadow:
inset 1px 1px 0 var(--shine-color),
inset -1px -1px 0 rgba(120, 90, 60, 0.35),
0 8px 18px var(--shadow-color);
}
[data-theme="dark"] {
--bg-primary: #2d2520;
--bg-secondary: #392f27;
--bg-tertiary: #44382d;
--bg-panel: linear-gradient(180deg, #46392d 0%, #2f261f 100%);
--text-primary: #f4e7d5;
--text-secondary: #d5bea3;
--text-muted: #b89f82;
--border-color: #6b5642;
--shadow-color: rgba(0, 0, 0, 0.6);
--shine-color: rgba(255, 232, 205, 0.15);
--accent-color: #d58a60;
--accent-hover: #e7a07a;
--selection-bg: rgba(213, 138, 96, 0.16);
--bevel-shadow:
inset 1px 1px 0 rgba(255, 236, 214, 0.12),
inset -1px -1px 0 rgba(0, 0, 0, 0.35),
0 10px 22px var(--shadow-color);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg-primary: #2d2520;
--bg-secondary: #392f27;
--bg-tertiary: #44382d;
--bg-panel: linear-gradient(180deg, #46392d 0%, #2f261f 100%);
--text-primary: #f4e7d5;
--text-secondary: #d5bea3;
--text-muted: #b89f82;
--border-color: #6b5642;
--shadow-color: rgba(0, 0, 0, 0.6);
--shine-color: rgba(255, 232, 205, 0.15);
--accent-color: #d58a60;
--accent-hover: #e7a07a;
--selection-bg: rgba(213, 138, 96, 0.16);
--bevel-shadow:
inset 1px 1px 0 rgba(255, 236, 214, 0.12),
inset -1px -1px 0 rgba(0, 0, 0, 0.35),
0 10px 22px var(--shadow-color);
}
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: "Trebuchet MS", "Lucida Grande", "Segoe UI", sans-serif;
font-size: 14px;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background-color: #7c644d;
background-image:
radial-gradient(circle at 20% 15%, rgba(255, 255, 255, 0.2), transparent 36%),
radial-gradient(circle at 82% 80%, rgba(0, 0, 0, 0.28), transparent 38%),
url('__BACKGROUND_IMAGE__');
background-size: auto, auto, cover;
background-position: center;
background-attachment: fixed;
min-height: 100vh;
color: var(--text-primary);
line-height: 1.5;
}
.theme-toggle {
position: fixed;
top: 20px;
right: 20px;
z-index: 1000;
width: 48px;
height: 48px;
border-radius: 50%;
border: 1px solid var(--border-color);
background: var(--bg-panel);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
box-shadow: var(--bevel-shadow);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.theme-toggle:hover {
transform: translateY(-1px);
}
.theme-toggle:active {
transform: translateY(1px);
box-shadow:
inset 1px 1px 0 rgba(100, 72, 45, 0.35),
inset -1px -1px 0 rgba(255, 255, 255, 0.45),
0 4px 10px var(--shadow-color);
}
.theme-toggle .icon {
transition: transform 0.3s ease;
}
.theme-toggle .icon-auto {
position: relative;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.theme-toggle .icon-auto::before,
.theme-toggle .icon-auto::after {
position: absolute;
font-size: 20px;
line-height: 1;
}
.theme-toggle .icon-auto::before {
content: "\2600\FE0F";
clip-path: inset(0 50% 0 0);
}
.theme-toggle .icon-auto::after {
content: "\1F319";
clip-path: inset(0 0 0 50%);
}
.card {
background: var(--bg-panel);
border-radius: var(--card-radius);
border: 1px solid var(--border-color);
box-shadow: var(--bevel-shadow);
margin-bottom: 24px;
overflow: hidden;
}
.card-header {
padding: 16px 20px;
border-bottom: 1px solid var(--border-color);
background: linear-gradient(180deg, var(--bg-tertiary) 0%, var(--bg-secondary) 100%);
box-shadow: inset 0 1px 0 var(--shine-color);
}
.card-title {
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
letter-spacing: 0.2px;
text-shadow: 0 1px 0 var(--shine-color);
}
.card-content {
padding: 20px;
}
.summaries-content {
padding: 0;
padding-top: 16px;
}
.filters {
display: flex;
justify-content: center;
gap: 16px;
flex-wrap: wrap;
}
.filter-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
padding: 8px 16px;
border-radius: 999px;
background: linear-gradient(180deg, var(--bg-primary) 0%, var(--bg-secondary) 100%);
border: 1px solid var(--border-color);
box-shadow: inset 0 1px 0 var(--shine-color), 0 2px 4px rgba(0, 0, 0, 0.2);
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.filter-label:hover {
transform: translateY(-1px);
}
.filter-label input[type="checkbox"] {
appearance: none;
-webkit-appearance: none;
width: 18px;
height: 18px;
border-radius: 4px;
border: 1px solid var(--border-color);
cursor: pointer;
position: relative;
background: linear-gradient(180deg, #fff3e6 0%, #d7b892 100%);
box-shadow: inset 0 1px 1px rgba(255, 255, 255, 0.6), inset 0 -1px 2px rgba(0, 0, 0, 0.25);
}
[data-theme="dark"] .filter-label input[type="checkbox"] {
background: linear-gradient(180deg, #5b4a3d 0%, #2f261f 100%);
box-shadow: inset 0 1px 1px rgba(255, 241, 225, 0.15), inset 0 -1px 2px rgba(0, 0, 0, 0.45);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .filter-label input[type="checkbox"] {
background: linear-gradient(180deg, #5b4a3d 0%, #2f261f 100%);
box-shadow: inset 0 1px 1px rgba(255, 241, 225, 0.15), inset 0 -1px 2px rgba(0, 0, 0, 0.45);
}
}
.filter-label input[type="checkbox"]:checked {
background: linear-gradient(180deg, var(--accent-color) 0%, var(--accent-hover) 100%);
border-color: rgba(0, 0, 0, 0.25);
}
.filter-label input[type="checkbox"]:checked::after {
content: "";
position: absolute;
left: 5px;
top: 2px;
width: 4px;
height: 8px;
border: solid #f8efe3;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
.filter-label .service-name {
text-transform: capitalize;
font-weight: 600;
color: var(--text-primary);
}
.filter-label .service-count {
color: var(--text-muted);
font-size: 12px;
}
.stats {
display: flex;
justify-content: center;
gap: 24px;
flex-wrap: wrap;
}
.stat {
text-align: center;
padding: 20px 32px;
background: linear-gradient(180deg, var(--bg-primary) 0%, var(--bg-secondary) 100%);
border-radius: 12px;
border: 1px solid var(--border-color);
box-shadow: inset 0 1px 0 var(--shine-color), 0 4px 12px rgba(0, 0, 0, 0.2);
min-width: 140px;
}
.stat-value {
font-size: 28px;
font-weight: 700;
color: var(--accent-color);
font-variant-numeric: tabular-nums;
text-shadow: 0 1px 0 var(--shine-color);
}
.stat-label {
font-size: 12px;
color: var(--text-muted);
margin-top: 4px;
text-transform: uppercase;
letter-spacing: 0.8px;
}
.charts-wrapper {
display: grid;
grid-auto-columns: minmax(280px, 450px);
grid-template-columns: repeat(auto-fill, minmax(280px, 450px));
grid-gap: 24px;
justify-content: center;
}
.chart-container {
min-width: 280px;
max-width: 450px;
padding: 16px;
background: linear-gradient(180deg, var(--bg-primary) 0%, var(--bg-secondary) 100%);
border-radius: 12px;
border: 1px solid var(--border-color);
box-shadow: inset 0 1px 0 var(--shine-color), 0 6px 14px rgba(0, 0, 0, 0.24);
}
.chart-title {
font-size: 14px;
font-weight: 700;
text-align: center;
margin-bottom: 12px;
color: var(--text-primary);
text-shadow: 0 1px 0 var(--shine-color);
}
@media (max-width: 700px) {
.charts-wrapper {
flex-direction: column;
align-items: center;
}
.chart-container {
max-width: 100%;
width: 100%;
}
}
.tabs {
display: flex;
gap: 8px;
padding: 0 20px;
margin-bottom: -1px;
position: relative;
z-index: 1;
}
.tab {
padding: 12px 24px;
cursor: pointer;
color: var(--text-secondary);
font-weight: 700;
border-radius: 12px 12px 0 0;
background: linear-gradient(180deg, #d5bea3 0%, #bca084 100%);
border: 1px solid var(--border-color);
border-bottom: none;
box-shadow: inset 0 1px 0 rgba(255, 247, 237, 0.7);
transition: transform 0.15s ease, color 0.15s ease;
}
.tab:hover {
color: var(--text-primary);
}
.tab.active {
color: var(--accent-color);
background: linear-gradient(180deg, var(--bg-primary) 0%, var(--bg-secondary) 100%);
transform: translateY(1px);
}
.tab-content {
background: linear-gradient(180deg, var(--bg-primary) 0%, var(--bg-secondary) 100%);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 16px;
box-shadow: inset 0 1px 0 var(--shine-color);
}
.tab-panel {
display: none;
}
.tab-panel.active {
display: block;
}
.table-wrapper {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
th {
padding: 12px 16px;
text-align: left;
font-weight: 700;
color: var(--text-secondary);
border-bottom: 2px solid var(--border-color);
text-transform: uppercase;
font-size: 11px;
letter-spacing: 0.6px;
}
td {
padding: 12px 16px;
border-bottom: 1px solid rgba(88, 68, 50, 0.28);
color: var(--text-primary);
}
tr:hover td {
background: var(--selection-bg);
}
tr:last-child td {
border-bottom: none;
}
.time {
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.percent {
font-variant-numeric: tabular-nums;
text-align: right;
color: var(--text-muted);
}
.color-box {
display: inline-block;
width: 12px;
height: 12px;
margin-right: 8px;
vertical-align: middle;
border-radius: 3px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.4), 0 1px 2px rgba(0, 0, 0, 0.3);
}
.service-badge {
display: inline-block;
font-size: 10px;
padding: 2px 8px;
background: linear-gradient(180deg, var(--bg-primary) 0%, var(--bg-tertiary) 100%);
border-radius: 12px;
color: var(--text-muted);
margin-left: 8px;
text-transform: capitalize;
font-weight: 700;
border: 1px solid rgba(110, 84, 58, 0.45);
}
.category-badge {
display: inline-block;
font-size: 10px;
padding: 2px 8px;
background: linear-gradient(180deg, #cfa98b 0%, #b17f5f 100%);
border-radius: 12px;
color: #2d1c12;
margin-left: 4px;
text-transform: capitalize;
font-weight: 700;
border: 1px solid rgba(59, 35, 20, 0.25);
}
[data-theme="dark"] .category-badge {
color: #1d130d;
}
.no-data {
text-align: center;
padding: 40px;
color: var(--text-muted);
}
.others-row {
cursor: pointer;
}
.others-row td:first-child::before {
content: "";
display: inline-block;
width: 0;
height: 0;
border-left: 6px solid var(--text-muted);
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
margin-right: 8px;
transition: transform 0.2s ease;
}
.others-row.expanded td:first-child::before {
transform: rotate(90deg);
}
.others-detail {
display: none;
background: rgba(179, 144, 111, 0.2);
}
.others-detail.visible {
display: table-row;
}
.others-detail td {
padding-left: 32px;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: linear-gradient(180deg, var(--bg-secondary) 0%, var(--bg-tertiary) 100%);
border-radius: 999px;
border: 1px solid rgba(112, 84, 55, 0.45);
}
::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, #c89f7d 0%, #936647 100%);
border-radius: 999px;
border: 1px solid rgba(73, 49, 30, 0.45);
}
::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, #d5ad8c 0%, #a7724f 100%);
}
@media (max-width: 600px) {
body {
padding: 12px;
}
.stats {
gap: 12px;
}
.stat {
padding: 16px 20px;
min-width: 100px;
}
.stat-value {
font-size: 22px;
}
.filter-label {
padding: 6px 12px;
font-size: 13px;
}
.tabs {
padding: 0 12px;
}
.tab {
padding: 10px 16px;
font-size: 13px;
}
.theme-toggle {
width: 40px;
height: 40px;
font-size: 16px;
}
}
.scroll-top {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 1000;
width: 48px;
height: 48px;
border-radius: 50%;
border: 1px solid var(--border-color);
background: var(--bg-panel);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: var(--bevel-shadow);
opacity: 0;
visibility: hidden;
transition: opacity 0.2s ease, visibility 0.2s ease, transform 0.2s ease;
}
.scroll-top.visible {
opacity: 1;
visibility: visible;
}
.scroll-top:hover {
transform: translateY(-2px);
}
.scroll-top-arrow {
width: 0;
height: 0;
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-bottom: 10px solid var(--text-primary);
}
@media (max-width: 600px) {
.scroll-top {
width: 40px;
height: 40px;
}
.scroll-top-arrow {
border-left-width: 6px;
border-right-width: 6px;
border-bottom-width: 8px;
}
}

583
templates/synthwave.css Normal file
View File

@@ -0,0 +1,583 @@
/****************************************************************************************************
* Copyright (C) 2026 by WallyHackenslacker wallyhackenslacker@noreply.git.hackenslacker.space *
* *
* Permission to use, copy, modify, and/or distribute this software for any purpose with or without *
* fee is hereby granted. *
* *
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS *
* SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE *
* AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES *
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, *
* NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE *
* OF THIS SOFTWARE. *
****************************************************************************************************/
@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700;900&display=swap');
:root {
--bg-base: #2d004d;
--bg-primary: #1a0033;
--bg-secondary: #240046;
--bg-tertiary: #3c096c;
--text-primary: #ff00ff;
--text-secondary: #00ffff;
--text-muted: #7a5af8;
--border-color: #ff00ff;
--accent-color: #00ffff;
--accent-hover: #ffffff;
--accent-secondary: #ffff00;
--selection-bg: rgba(255, 0, 255, 0.3);
--selection-text: #ffffff;
--shadow-color: rgba(255, 0, 255, 0.5);
--glow-text: 0 0 10px var(--text-primary);
--glow-border: 0 0 10px var(--border-color);
--card-radius: 0px;
--border-width: 2px;
}
[data-theme="light"] {
--bg-base: #f0e6ff;
--bg-primary: #ffffff;
--bg-secondary: #f8f0ff;
--bg-tertiary: #e0ccff;
--text-primary: #9d00ff;
--text-secondary: #00bfff;
--text-muted: #7a5af8;
--border-color: #9d00ff;
--shadow-color: rgba(157, 0, 255, 0.2);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg-base: #2d004d;
--bg-primary: #1a0033;
--bg-secondary: #240046;
--bg-tertiary: #3c096c;
--text-primary: #ff00ff;
--text-secondary: #00ffff;
--text-muted: #7a5af8;
--border-color: #ff00ff;
--accent-color: #00ffff;
--accent-hover: #ffffff;
--selection-bg: rgba(255, 0, 255, 0.3);
--selection-text: #ffffff;
}
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Orbitron', sans-serif;
font-size: 14px;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background-color: var(--bg-base);
background-image: __BACKGROUND_IMAGE_CUSTOM__;
background-size: cover;
background-position: center;
background-attachment: fixed;
min-height: 100vh;
color: var(--text-primary);
line-height: 1.5;
text-shadow: var(--glow-text);
}
/* Theme Toggle Button */
.theme-toggle {
position: fixed;
top: 20px;
right: 20px;
z-index: 1000;
width: 50px;
height: 50px;
border: var(--border-width) solid var(--border-color);
background: var(--bg-primary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
box-shadow: var(--glow-border);
transition: all 0.3s ease;
color: var(--text-primary);
}
.theme-toggle:hover {
transform: scale(1.1);
box-shadow: 0 0 20px var(--border-color);
}
.theme-toggle .icon-auto {
position: relative;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.theme-toggle .icon-auto::before,
.theme-toggle .icon-auto::after {
position: absolute;
font-size: 20px;
line-height: 1;
}
.theme-toggle .icon-auto::before {
content: '☀️';
clip-path: inset(0 50% 0 0);
}
.theme-toggle .icon-auto::after {
content: '🌙';
clip-path: inset(0 0 0 50%);
}
/* Card Style */
.card {
background: var(--bg-primary);
border: var(--border-width) solid var(--border-color);
box-shadow: var(--glow-border);
margin-bottom: 30px;
overflow: hidden;
position: relative;
}
.card::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, var(--accent-color), transparent);
}
.card-header {
padding: 16px 24px;
border-bottom: var(--border-width) solid var(--border-color);
background: var(--bg-secondary);
}
.card-title {
font-size: 20px;
font-weight: 900;
text-transform: uppercase;
letter-spacing: 3px;
color: var(--text-secondary);
text-shadow: 0 0 10px var(--text-secondary);
}
.card-content {
padding: 24px;
}
.summaries-content {
padding: 0;
padding-top: 16px;
}
/* Filters */
.filters {
display: flex;
justify-content: center;
gap: 16px;
flex-wrap: wrap;
}
.filter-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
padding: 10px 20px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
transition: all 0.2s ease;
font-weight: 700;
text-transform: uppercase;
font-size: 12px;
}
.filter-label:hover {
box-shadow: 0 0 15px var(--border-color);
transform: translateY(-2px);
}
.filter-label input[type="checkbox"] {
appearance: none;
-webkit-appearance: none;
width: 18px;
height: 18px;
border: 1px solid var(--border-color);
background: var(--bg-primary);
cursor: pointer;
position: relative;
transition: all 0.2s ease;
}
.filter-label input[type="checkbox"]:checked {
background: var(--accent-color);
box-shadow: 0 0 10px var(--accent-color);
}
.filter-label input[type="checkbox"]:checked::after {
content: '';
position: absolute;
left: 5px;
top: 2px;
width: 5px;
height: 9px;
border: solid var(--bg-primary);
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
.filter-label .service-name {
color: var(--text-secondary);
}
.filter-label .service-count {
color: var(--text-muted);
font-size: 11px;
}
/* Stats */
.stats {
display: flex;
justify-content: center;
gap: 24px;
flex-wrap: wrap;
}
.stat {
text-align: center;
padding: 24px 32px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
min-width: 160px;
box-shadow: inset 0 0 10px var(--shadow-color);
}
.stat-value {
font-size: 36px;
font-weight: 900;
color: var(--accent-color);
text-shadow: 0 0 15px var(--accent-color);
font-variant-numeric: tabular-nums;
}
.stat-label {
font-size: 12px;
color: var(--text-muted);
margin-top: 4px;
text-transform: uppercase;
letter-spacing: 2px;
}
/* Charts */
.charts-wrapper {
display: grid;
grid-auto-columns: minmax(280px, 450px);
grid-template-columns: repeat(auto-fill, minmax(280px, 450px));
grid-gap: 30px;
justify-content: center;
}
.chart-container {
min-width: 280px;
max-width: 450px;
padding: 20px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
box-shadow: inset 0 0 20px var(--shadow-color);
}
.chart-title {
font-size: 16px;
font-weight: 700;
text-align: center;
margin-bottom: 20px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 2px;
}
@media (max-width: 700px) {
.charts-wrapper {
flex-direction: column;
align-items: center;
}
.chart-container {
max-width: 100%;
width: 100%;
}
}
/* Tabs */
.tabs {
display: flex;
gap: 0;
padding: 0 24px;
margin-bottom: -1px;
position: relative;
z-index: 1;
}
.tab {
padding: 14px 28px;
cursor: pointer;
color: var(--text-muted);
font-weight: 700;
text-transform: uppercase;
font-size: 13px;
letter-spacing: 2px;
background: var(--bg-secondary);
border: var(--border-width) solid var(--border-color);
border-bottom: none;
transition: all 0.3s ease;
margin-right: 4px;
}
.tab:hover {
color: var(--text-secondary);
box-shadow: 0 -5px 10px var(--shadow-color);
}
.tab.active {
color: var(--accent-color);
background: var(--bg-primary);
border-bottom-color: var(--bg-primary);
box-shadow: 0 -5px 15px var(--shadow-color);
position: relative;
}
.tab-content {
background: var(--bg-primary);
border: var(--border-width) solid var(--border-color);
padding: 24px;
box-shadow: var(--glow-border);
}
.tab-panel {
display: none;
}
.tab-panel.active {
display: block;
}
/* Tables */
.table-wrapper {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
th {
padding: 16px 24px;
text-align: left;
font-weight: 900;
color: var(--text-secondary);
border-bottom: var(--border-width) solid var(--border-color);
text-transform: uppercase;
letter-spacing: 2px;
font-size: 12px;
}
td {
padding: 14px 24px;
border-bottom: 1px solid var(--bg-tertiary);
color: var(--text-primary);
}
tr:hover td {
background: var(--selection-bg);
color: var(--selection-text);
}
tr:last-child td {
border-bottom: none;
}
.time {
font-variant-numeric: tabular-nums;
font-weight: 700;
}
.percent {
font-variant-numeric: tabular-nums;
text-align: right;
color: var(--text-muted);
}
.color-box {
display: inline-block;
width: 12px;
height: 12px;
margin-right: 12px;
vertical-align: middle;
box-shadow: 0 0 5px currentColor;
}
.service-badge {
display: inline-block;
font-size: 10px;
padding: 3px 10px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-secondary);
margin-left: 10px;
text-transform: uppercase;
font-weight: 700;
}
.category-badge {
display: inline-block;
font-size: 10px;
padding: 3px 10px;
background: var(--accent-color);
border: 1px solid var(--border-color);
color: var(--bg-primary);
margin-left: 6px;
text-transform: uppercase;
font-weight: 700;
}
.no-data {
text-align: center;
padding: 40px;
color: var(--text-muted);
}
/* Others row expansion */
.others-row {
cursor: pointer;
}
.others-row td:first-child::before {
content: '\25B6';
display: inline-block;
margin-right: 10px;
font-size: 12px;
transition: transform 0.3s ease;
}
.others-row.expanded td:first-child::before {
transform: rotate(90deg);
}
.others-detail {
display: none;
background: var(--bg-secondary);
}
.others-detail.visible {
display: table-row;
}
.others-detail td {
padding-left: 48px;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 12px;
height: 12px;
}
::-webkit-scrollbar-track {
background: var(--bg-primary);
}
::-webkit-scrollbar-thumb {
background: var(--border-color);
box-shadow: 0 0 10px var(--border-color);
}
::-webkit-scrollbar-thumb:hover {
background: var(--accent-color);
}
/* Responsive */
@media (max-width: 600px) {
body {
padding: 12px;
}
.stats {
gap: 16px;
}
.stat {
padding: 20px;
min-width: 120px;
}
.stat-value {
font-size: 28px;
}
.filter-label {
padding: 8px 14px;
font-size: 11px;
}
.tabs {
padding: 0 12px;
}
.tab {
padding: 12px 16px;
font-size: 11px;
}
}
/* Scroll to Top Button */
.scroll-top {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 1000;
width: 50px;
height: 50px;
border: var(--border-width) solid var(--border-color);
background: var(--bg-primary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: var(--glow-border);
opacity: 0;
visibility: hidden;
transition: all 0.3s ease;
color: var(--text-primary);
}
.scroll-top.visible {
opacity: 1;
visibility: visible;
}
.scroll-top:hover {
transform: scale(1.1);
box-shadow: 0 0 20px var(--border-color);
}
.scroll-top-arrow {
width: 0;
height: 0;
border-left: 10px solid transparent;
border-right: 10px solid transparent;
border-bottom: 12px solid currentColor;
}

459
templates/terminal.css Normal file
View File

@@ -0,0 +1,459 @@
/****************************************************************************************************
* Copyright (C) 2026 by WallyHackenslacker wallyhackenslacker@noreply.git.hackenslacker.space *
* *
* Permission to use, copy, modify, and/or distribute this software for any purpose with or without *
* fee is hereby granted. *
* *
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS *
* SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE *
* AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES *
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, *
* NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE *
* OF THIS SOFTWARE. *
****************************************************************************************************/
:root {
--bg-base: #000000;
--bg-primary: #000000;
--bg-secondary: #0a0a0a;
--bg-tertiary: #1a1a1a;
--text-primary: #00ff00;
--text-secondary: #00cc00;
--text-muted: #006600;
--border-color: #00ff00;
--accent-color: #00ff00;
--accent-hover: #33ff33;
--selection-bg: #00ff00;
--selection-text: #000000;
--border-width: 1px;
--scanline-opacity: 0.1;
}
[data-theme="light"] {
--bg-base: #f0f0f0;
--bg-primary: #ffffff;
--bg-secondary: #e8e8e8;
--bg-tertiary: #d0d0d0;
--text-primary: #006600;
--text-secondary: #004400;
--text-muted: #002200;
--border-color: #006600;
--accent-color: #006600;
--accent-hover: #008800;
--selection-bg: #006600;
--selection-text: #ffffff;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg-base: #000000;
--bg-primary: #000000;
--bg-secondary: #0a0a0a;
--bg-tertiary: #1a1a1a;
--text-primary: #00ff00;
--text-secondary: #00cc00;
--text-muted: #006600;
--border-color: #00ff00;
--accent-color: #00ff00;
--accent-hover: #33ff33;
--selection-bg: #00ff00;
--selection-text: #000000;
}
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Courier New', Courier, monospace;
font-size: 14px;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background-color: var(--bg-base);
background-image: __BACKGROUND_IMAGE_CUSTOM__;
background-size: cover;
background-position: center;
background-attachment: fixed;
min-height: 100vh;
color: var(--text-primary);
line-height: 1.4;
position: relative;
}
body::before {
content: " ";
display: block;
position: fixed;
top: 0;
left: 0;
bottom: 0;
right: 0;
background: linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.25) 50%), linear-gradient(90deg, rgba(255, 0, 0, 0.06), rgba(0, 255, 0, 0.02), rgba(0, 0, 255, 0.06));
z-index: 2000;
background-size: 100% 2px, 3px 100%;
pointer-events: none;
}
/* Theme Toggle Button */
.theme-toggle {
position: fixed;
top: 20px;
right: 20px;
z-index: 2100;
width: 40px;
height: 40px;
border: var(--border-width) solid var(--border-color);
background: var(--bg-primary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-primary);
transition: all 0.1s;
}
.theme-toggle:hover {
background: var(--text-primary);
color: var(--bg-primary);
}
.theme-toggle .icon-auto {
position: relative;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.theme-toggle .icon-auto::before,
.theme-toggle .icon-auto::after {
position: absolute;
font-size: 18px;
line-height: 1;
}
.theme-toggle .icon-auto::before {
content: 'S';
clip-path: inset(0 50% 0 0);
}
.theme-toggle .icon-auto::after {
content: 'T';
clip-path: inset(0 0 0 50%);
}
/* Card Style */
.card {
background: var(--bg-primary);
border: var(--border-width) solid var(--border-color);
margin-bottom: 20px;
position: relative;
}
.card-header {
padding: 8px 16px;
border-bottom: var(--border-width) solid var(--border-color);
background: var(--bg-tertiary);
}
.card-header::before {
content: "> ";
}
.card-title {
display: inline;
font-size: 14px;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 1px;
}
.card-content {
padding: 16px;
}
.summaries-content {
padding: 0;
padding-top: 10px;
}
/* Filters */
.filters {
display: flex;
justify-content: flex-start;
gap: 20px;
flex-wrap: wrap;
}
.filter-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
padding: 4px;
}
.filter-label:hover {
background: var(--text-muted);
color: var(--bg-primary);
}
.filter-label input[type="checkbox"] {
appearance: none;
-webkit-appearance: none;
width: 14px;
height: 14px;
border: 1px solid var(--border-color);
background: var(--bg-primary);
cursor: pointer;
position: relative;
}
.filter-label input[type="checkbox"]:checked {
background: var(--text-primary);
}
.filter-label input[type="checkbox"]:checked::after {
content: 'X';
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
font-size: 10px;
font-weight: bold;
color: var(--bg-primary);
}
.filter-label .service-name {
text-transform: uppercase;
}
/* Stats */
.stats {
display: flex;
justify-content: flex-start;
gap: 40px;
flex-wrap: wrap;
}
.stat {
min-width: 120px;
}
.stat-value {
font-size: 24px;
font-weight: bold;
}
.stat-label {
font-size: 12px;
color: var(--text-secondary);
text-transform: uppercase;
}
/* Charts */
.charts-wrapper {
display: grid;
grid-auto-columns: minmax(280px, 450px);
grid-template-columns: repeat(auto-fill, minmax(280px, 450px));
grid-gap: 20px;
justify-content: center;
}
.chart-container {
padding: 10px;
border: 1px dashed var(--border-color);
}
.chart-title {
font-size: 12px;
text-align: center;
margin-bottom: 10px;
text-transform: uppercase;
}
@media (max-width: 700px) {
.charts-wrapper {
flex-direction: column;
align-items: center;
}
.chart-container {
max-width: 100%;
width: 100%;
}
}
/* Tabs */
.tabs {
display: flex;
gap: 0;
padding: 0 16px;
}
.tab {
padding: 8px 16px;
cursor: pointer;
color: var(--text-secondary);
text-transform: uppercase;
font-size: 12px;
border: 1px solid transparent;
}
.tab:hover {
color: var(--text-primary);
text-decoration: underline;
}
.tab.active {
color: var(--bg-primary);
background: var(--text-primary);
border: 1px solid var(--text-primary);
}
.tab-content {
border-top: 1px solid var(--border-color);
padding: 16px;
}
.tab-panel {
display: none;
}
.tab-panel.active {
display: block;
}
/* Tables */
.table-wrapper {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
th {
padding: 8px;
text-align: left;
color: var(--text-primary);
border-bottom: 2px solid var(--border-color);
text-transform: uppercase;
}
td {
padding: 8px;
border-bottom: 1px solid var(--text-muted);
}
tr:hover td {
background: var(--text-primary);
color: var(--bg-primary);
}
.color-box {
display: inline-block;
width: 10px;
height: 10px;
margin-right: 8px;
border: 1px solid currentColor;
}
.service-badge {
font-size: 10px;
padding: 0 4px;
border: 1px solid currentColor;
margin-left: 8px;
}
.category-badge {
font-size: 10px;
padding: 0 4px;
background: var(--text-muted);
color: var(--bg-primary);
margin-left: 4px;
}
/* Others row expansion */
.others-row {
cursor: pointer;
}
.others-row td:first-child::before {
content: '[+] ';
}
.others-row.expanded td:first-child::before {
content: '[-] ';
}
.others-detail {
display: none;
background: var(--bg-tertiary);
}
.others-detail.visible {
display: table-row;
}
.others-detail td {
padding-left: 32px;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 10px;
}
::-webkit-scrollbar-track {
background: var(--bg-primary);
}
::-webkit-scrollbar-thumb {
background: var(--text-muted);
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-primary);
}
/* Scroll to Top Button */
.scroll-top {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 2100;
width: 40px;
height: 40px;
border: var(--border-width) solid var(--border-color);
background: var(--bg-primary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-primary);
opacity: 0;
visibility: hidden;
}
.scroll-top.visible {
opacity: 1;
visibility: visible;
}
.scroll-top:hover {
background: var(--text-primary);
color: var(--bg-primary);
}
.scroll-top-arrow::before {
content: "^";
font-size: 20px;
font-weight: bold;
}

474
templates/vaporwave.css Normal file
View File

@@ -0,0 +1,474 @@
/****************************************************************************************************
* Copyright (C) 2026 by WallyHackenslacker wallyhackenslacker@noreply.git.hackenslacker.space *
* *
* Permission to use, copy, modify, and/or distribute this software for any purpose with or without *
* fee is hereby granted. *
* *
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS *
* SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE *
* AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES *
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, *
* NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE *
* OF THIS SOFTWARE. *
****************************************************************************************************/
:root {
--bg-base: #ffb7ff;
--bg-primary: rgba(255, 255, 255, 0.8);
--bg-secondary: #01cdfe;
--bg-tertiary: #fffb96;
--text-primary: #ff71ce;
--text-secondary: #01cdfe;
--text-muted: #b967ff;
--border-color: #05ffa1;
--accent-color: #ff71ce;
--accent-hover: #b967ff;
--accent-secondary: #05ffa1;
--selection-bg: rgba(255, 113, 206, 0.3);
--selection-text: #ffffff;
--shadow-color: rgba(1, 205, 254, 0.3);
--card-radius: 4px;
--border-width: 3px;
}
[data-theme="dark"] {
--bg-base: #120458;
--bg-primary: rgba(18, 4, 88, 0.9);
--bg-secondary: #ff71ce;
--bg-tertiary: #01cdfe;
--text-primary: #05ffa1;
--text-secondary: #ff71ce;
--text-muted: #b967ff;
--border-color: #01cdfe;
--shadow-color: rgba(255, 113, 206, 0.5);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg-base: #120458;
--bg-primary: rgba(18, 4, 88, 0.9);
--bg-secondary: #ff71ce;
--bg-tertiary: #01cdfe;
--text-primary: #05ffa1;
--text-secondary: #ff71ce;
--text-muted: #b967ff;
--border-color: #01cdfe;
--shadow-color: rgba(255, 113, 206, 0.5);
}
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'MS PGothic', 'Palatino Linotype', 'Book Antiqua', Palatino, serif;
font-size: 14px;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background-color: var(--bg-base);
background-image: linear-gradient(45deg, var(--bg-base) 25%, transparent 25%),
linear-gradient(-45deg, var(--bg-base) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, var(--bg-tertiary) 75%),
linear-gradient(-45deg, transparent 75%, var(--bg-tertiary) 75%);
background-size: 100px 100px;
background-image: __BACKGROUND_IMAGE_CUSTOM__;
background-attachment: fixed;
min-height: 100vh;
color: var(--text-primary);
line-height: 1.6;
}
/* Theme Toggle Button */
.theme-toggle {
position: fixed;
top: 20px;
right: 20px;
z-index: 1000;
width: 48px;
height: 48px;
border: var(--border-width) solid var(--border-color);
background: var(--bg-primary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
box-shadow: 8px 8px 0 var(--shadow-color);
transition: all 0.2s ease;
}
.theme-toggle:hover {
transform: translate(-4px, -4px);
box-shadow: 12px 12px 0 var(--shadow-color);
}
.theme-toggle .icon-auto {
position: relative;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.theme-toggle .icon-auto::before,
.theme-toggle .icon-auto::after {
position: absolute;
font-size: 20px;
line-height: 1;
}
.theme-toggle .icon-auto::before {
content: '☀️';
clip-path: inset(0 50% 0 0);
}
.theme-toggle .icon-auto::after {
content: '🌙';
clip-path: inset(0 0 0 50%);
}
/* Card Style */
.card {
background: var(--bg-primary);
border: var(--border-width) solid var(--border-color);
box-shadow: 10px 10px 0 var(--shadow-color);
margin-bottom: 40px;
overflow: hidden;
}
.card-header {
padding: 16px 24px;
border-bottom: var(--border-width) solid var(--border-color);
background: linear-gradient(90deg, var(--bg-secondary), var(--text-primary));
}
.card-title {
font-size: 24px;
font-weight: 400;
font-style: italic;
color: #ffffff;
text-shadow: 3px 3px 0 var(--text-muted);
}
.card-content {
padding: 24px;
}
.summaries-content {
padding: 0;
padding-top: 20px;
}
/* Filters */
.filters {
display: flex;
justify-content: center;
gap: 16px;
flex-wrap: wrap;
}
.filter-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
padding: 8px 16px;
background: var(--bg-tertiary);
border: 2px solid var(--border-color);
transition: all 0.2s ease;
font-style: italic;
}
.filter-label:hover {
transform: scale(1.05);
background: #ffffff;
}
.filter-label input[type="checkbox"] {
appearance: none;
-webkit-appearance: none;
width: 20px;
height: 20px;
border: 2px solid var(--border-color);
background: #ffffff;
cursor: pointer;
position: relative;
}
.filter-label input[type="checkbox"]:checked {
background: var(--text-primary);
}
.filter-label input[type="checkbox"]:checked::after {
content: 'OK';
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
font-size: 10px;
font-weight: bold;
color: #ffffff;
}
.filter-label .service-name {
color: var(--text-muted);
}
.filter-label .service-count {
color: var(--text-secondary);
font-size: 11px;
}
/* Stats */
.stats {
display: flex;
justify-content: center;
gap: 30px;
flex-wrap: wrap;
}
.stat {
text-align: center;
padding: 20px 30px;
background: #ffffff;
border: 2px solid var(--border-color);
min-width: 150px;
box-shadow: 5px 5px 0 var(--text-muted);
}
.stat-value {
font-size: 32px;
font-weight: 400;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
}
.stat-label {
font-size: 11px;
color: var(--text-secondary);
margin-top: 4px;
text-transform: lowercase;
font-style: italic;
}
/* Charts */
.charts-wrapper {
display: grid;
grid-auto-columns: minmax(280px, 450px);
grid-template-columns: repeat(auto-fill, minmax(280px, 450px));
grid-gap: 30px;
justify-content: center;
}
.chart-container {
min-width: 280px;
max-width: 450px;
padding: 20px;
background: #ffffff;
border: var(--border-width) solid var(--border-color);
}
.chart-title {
font-size: 18px;
font-style: italic;
text-align: center;
margin-bottom: 20px;
color: var(--text-primary);
}
@media (max-width: 700px) {
.charts-wrapper {
flex-direction: column;
align-items: center;
}
.chart-container {
max-width: 100%;
width: 100%;
}
}
/* Tabs */
.tabs {
display: flex;
gap: 10px;
padding: 0 24px;
}
.tab {
padding: 12px 24px;
cursor: pointer;
color: var(--text-muted);
font-style: italic;
background: #ffffff;
border: 2px solid var(--border-color);
border-bottom: none;
transition: all 0.2s ease;
}
.tab:hover {
background: var(--bg-tertiary);
}
.tab.active {
color: var(--text-primary);
background: var(--bg-tertiary);
font-weight: bold;
transform: translateY(-5px);
box-shadow: 5px 0 0 var(--shadow-color);
}
.tab-content {
background: #ffffff;
border: var(--border-width) solid var(--border-color);
padding: 24px;
box-shadow: 10px 10px 0 var(--shadow-color);
}
.tab-panel {
display: none;
}
.tab-panel.active {
display: block;
}
/* Tables */
.table-wrapper {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
th {
padding: 12px 20px;
text-align: left;
font-style: italic;
color: var(--text-secondary);
border-bottom: 2px solid var(--border-color);
}
td {
padding: 12px 20px;
border-bottom: 1px solid #f0f0f0;
color: var(--text-muted);
}
tr:hover td {
background: var(--selection-bg);
color: var(--text-primary);
}
.color-box {
display: inline-block;
width: 14px;
height: 14px;
margin-right: 10px;
vertical-align: middle;
border: 1px solid #000;
}
.service-badge {
font-size: 10px;
padding: 2px 8px;
background: var(--bg-secondary);
color: #ffffff;
margin-left: 8px;
}
.category-badge {
font-size: 10px;
padding: 2px 8px;
background: var(--bg-tertiary);
color: var(--text-primary);
margin-left: 4px;
border: 1px solid var(--border-color);
}
/* Scrollbar */
::-webkit-scrollbar {
width: 15px;
}
::-webkit-scrollbar-track {
background: var(--bg-tertiary);
}
::-webkit-scrollbar-thumb {
background: var(--text-primary);
border: 3px solid var(--bg-tertiary);
}
/* Scroll to Top Button */
.scroll-top {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 1000;
width: 50px;
height: 50px;
border: var(--border-width) solid var(--border-color);
background: var(--bg-primary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 8px 8px 0 var(--shadow-color);
opacity: 0;
visibility: hidden;
transition: all 0.2s ease;
}
.scroll-top.visible {
opacity: 1;
visibility: visible;
}
.scroll-top-arrow {
width: 0;
height: 0;
border-left: 10px solid transparent;
border-right: 10px solid transparent;
border-bottom: 12px solid var(--text-primary);
}
/* Others row expansion */
.others-row {
cursor: pointer;
}
.others-row td:first-child::before {
content: '\25B6';
display: inline-block;
margin-right: 10px;
font-size: 10px;
transition: transform 0.2s ease;
vertical-align: middle;
}
.others-row.expanded td:first-child::before {
transform: rotate(90deg);
}
.others-detail {
display: none;
background: var(--bg-tertiary);
}
.others-detail.visible {
display: table-row;
}
.others-detail td {
padding-left: 48px;
font-size: 13px;
color: var(--text-muted);
}