added notification toasts (error modals still broken)

This commit is contained in:
2026-01-25 12:59:27 +11:00
parent 25461143ba
commit 959ca96b68
13 changed files with 990 additions and 89 deletions

View File

@@ -56,6 +56,11 @@ func addRoutes(
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
Handler: auth.LoginReq(handlers.Logout(server, auth, conn, discordAPI)),
},
{
Path: "/test",
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
Handler: handlers.Test(server),
},
}
htmxRoutes := []hws.Route{

View File

@@ -0,0 +1,141 @@
package handlers
import (
"encoding/json"
"fmt"
"net/http"
"git.haelnorr.com/h/golib/hws"
)
// NotificationType defines the type of notification
type NotificationType string
const (
NotificationSuccess NotificationType = "success"
NotificationWarning NotificationType = "warning"
NotificationInfo NotificationType = "info"
)
// Notification represents a toast notification (success, warning, info)
type Notification struct {
Type NotificationType `json:"type"`
Title string `json:"title"`
Message string `json:"message"`
}
// ErrorModal represents a full-screen error modal (500, 503)
type ErrorModal struct {
Code int `json:"code"`
Title string `json:"title"`
Message string `json:"message"`
Details string `json:"details"`
}
// setHXTrigger sets the HX-Trigger header with JSON-encoded data
func setHXTrigger(w http.ResponseWriter, event string, data any) {
payload := map[string]any{
event: data,
}
jsonData, err := json.Marshal(payload)
if err != nil {
// Fallback if JSON encoding fails
w.Header().Set("HX-Trigger", event)
return
}
w.Header().Set("HX-Trigger", string(jsonData))
}
// formatErrorDetails extracts and formats error details from wrapped errors
func formatErrorDetails(err error) string {
if err == nil {
return ""
}
// Use %+v format to get stack trace from github.com/pkg/errors
return fmt.Sprintf("%+v", err)
}
// notifyToast sends a toast notification via HX-Trigger header
func notifyToast(
w http.ResponseWriter,
notifType NotificationType,
title string,
message string,
) {
notification := Notification{
Type: notifType,
Title: title,
Message: message,
}
setHXTrigger(w, "showNotification", notification)
}
// notifyErrorModal sends a full-screen error modal via HX-Trigger header
func notifyErrorModal(
s *hws.Server,
w http.ResponseWriter,
statusCode int,
title string,
message string,
err error,
) {
modal := ErrorModal{
Code: statusCode,
Title: title,
Message: message,
Details: formatErrorDetails(err),
}
// Log the error
s.LogError(hws.HWSError{
StatusCode: statusCode,
Message: message,
Error: err,
Level: hws.ErrorERROR,
})
// Set response status
w.WriteHeader(statusCode)
// Send notification via HX-Trigger
setHXTrigger(w, "showErrorModal", modal)
}
// notifySuccess sends a success toast notification
func notifySuccess(w http.ResponseWriter, title string, message string) {
notifyToast(w, NotificationSuccess, title, message)
}
// notifyWarning sends a warning toast notification
func notifyWarning(w http.ResponseWriter, title string, message string) {
notifyToast(w, NotificationWarning, title, message)
}
// notifyInfo sends an info toast notification
func notifyInfo(w http.ResponseWriter, title string, message string) {
notifyToast(w, NotificationInfo, title, message)
}
// notifyInternalServiceError sends a 500 error modal
func notifyInternalServiceError(
s *hws.Server,
w http.ResponseWriter,
message string,
err error,
) {
notifyErrorModal(s, w, 500, "Internal Server Error", message, err)
}
// notifyServiceUnavailable sends a 503 error modal
func notifyServiceUnavailable(
s *hws.Server,
w http.ResponseWriter,
message string,
err error,
) {
notifyErrorModal(s, w, 503, "Service Unavailable", message, err)
}

42
internal/handlers/test.go Normal file
View File

@@ -0,0 +1,42 @@
package handlers
import (
"net/http"
"git.haelnorr.com/h/oslstats/internal/view/page"
"github.com/pkg/errors"
"git.haelnorr.com/h/golib/hws"
)
// Handles responses to the / path. Also serves a 404 Page for paths that
// don't have explicit handlers
func Test(server *hws.Server) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" {
page.Test().Render(r.Context(), w)
} else {
r.ParseForm()
notifytype := r.Form.Get("type")
title := r.Form.Get("title")
message := r.Form.Get("message")
switch notifytype {
case "error":
err := errors.New(message)
notifyInternalServiceError(server, w, title, err)
return
case "warn":
notifyWarning(w, title, message)
return
case "info":
notifyInfo(w, title, message)
return
case "success":
notifySuccess(w, title, message)
return
}
}
},
)
}

View File

@@ -1,63 +0,0 @@
package popup
templ Error503Popup() {
<div
x-cloak
x-show="showError503"
class="absolute w-82 left-0 right-0 mt-20 mr-5 ml-auto"
x-transition:enter="transform translate-x-[100%] opacity-0 duration-200"
x-transition:enter-start="opacity-0 translate-x-[100%]"
x-transition:enter-end="opacity-100 translate-x-0"
x-transition:leave="opacity-0 duration-200"
x-transition:leave-start="opacity-100 translate-x-0"
x-transition:leave-end="opacity-0 translate-x-[100%]"
>
<div
role="alert"
class="rounded-sm bg-dark-red p-4"
>
<div class="flex justify-between">
<div class="flex items-center gap-2 text-red w-fit">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="size-5"
>
<path
fill-rule="evenodd"
d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355
12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309
0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75
0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0
01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z"
clip-rule="evenodd"
></path>
</svg>
<strong class="block font-medium">Service Unavailable</strong>
</div>
<div class="flex">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6 text-subtext0 hover:cursor-pointer"
@click="showError503=false"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6 18L18 6M6 6l12 12"
></path>
</svg>
</div>
</div>
<p class="mt-2 text-sm text-red">
The service is currently available. It could be down for maintenance.
Please try again later.
</p>
</div>
</div>
}

View File

@@ -0,0 +1,126 @@
package popup
// ErrorModal displays a full-screen modal for critical errors (500, 503)
templ ErrorModal() {
<div
x-show="errorModal.show"
x-cloak
class="fixed inset-0 z-50 flex items-center justify-center p-4"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
>
<!-- Backdrop (not clickable) -->
<div class="absolute inset-0 bg-crust/80"></div>
<!-- Modal Card -->
<div
class="relative bg-surface0 border-2 border-dark-red rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto"
x-transition:enter="transition ease-out duration-300 delay-100"
x-transition:enter-start="opacity-0 translate-y-4"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 translate-y-4"
role="dialog"
aria-modal="true"
aria-labelledby="error-modal-title"
>
<!-- Header -->
<div class="flex items-start justify-between p-6 border-b border-dark-red">
<div class="flex items-center gap-3">
<!-- Warning Icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="size-8 text-red flex-shrink-0"
>
<path
fill-rule="evenodd"
d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z"
clip-rule="evenodd"
></path>
</svg>
<!-- Title -->
<h2
id="error-modal-title"
class="text-2xl font-bold text-red"
x-text="`${errorModal.code} - ${errorModal.title}`"
></h2>
</div>
<!-- Close Button -->
<button
@click="closeErrorModal()"
class="text-subtext0 hover:text-text transition"
aria-label="Close modal"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6 18L18 6M6 6l12 12"
></path>
</svg>
</button>
</div>
<!-- Body -->
<div class="p-6">
<!-- User Message -->
<p class="text-lg text-subtext0" x-text="errorModal.message"></p>
<!-- Details Dropdown -->
<div class="mt-6">
<details class="bg-mantle rounded-lg p-4">
<summary class="cursor-pointer text-subtext1 font-semibold select-none hover:text-text transition">
Details
<span class="text-xs text-subtext0 ml-2">(click to expand)</span>
</summary>
<div class="mt-4 relative">
<pre
id="error-modal-details"
class="text-xs text-subtext0 font-mono whitespace-pre-wrap break-all bg-surface0 p-4 rounded overflow-x-auto max-h-96"
x-text="errorModal.details"
></pre>
</div>
<button
onclick="copyToClipboard('error-modal-details', this)"
class="mt-2 bg-mauve text-crust px-3 py-1 rounded text-xs hover:bg-mauve/75 transition"
title="Copy to clipboard"
>
Copy
</button>
</details>
</div>
</div>
</div>
</div>
<!-- Copy to Clipboard Script -->
<script>
function copyToClipboard(elementId, button) {
const element = document.getElementById(elementId);
const text = element.innerText;
navigator.clipboard.writeText(text)
.then(() => {
const originalText = button.innerText;
button.innerText = 'Copied!';
setTimeout(() => {
button.innerText = originalText;
}, 2000);
})
.catch(err => {
console.error('Failed to copy:', err);
button.innerText = 'Failed';
});
}
</script>
}

View File

@@ -1,6 +1,6 @@
package popup
templ Error500Popup() {
templ ErrorPopup() {
<div
x-cloak
x-show="showError500"

View File

@@ -0,0 +1,10 @@
package popup
// ToastContainer displays stacked toast notifications (success, warning, info)
templ ToastContainer() {
<div class="fixed top-20 right-5 z-40 flex flex-col gap-3 max-w-sm pointer-events-none">
<template x-for="toast in toasts" :key="toast.id">
@ToastNotification()
</template>
</div>
}

View File

@@ -0,0 +1,114 @@
package popup
// ToastNotification displays an individual toast notification
templ ToastNotification() {
<div
class="pointer-events-auto rounded-lg shadow-lg overflow-hidden w-full"
:class="{
'bg-dark-green border-2 border-green': toast.type === 'success',
'bg-dark-yellow border-2 border-yellow': toast.type === 'warning',
'bg-dark-blue border-2 border-blue': toast.type === 'info'
}"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 translate-x-full"
x-transition:enter-end="opacity-100 translate-x-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 translate-x-0"
x-transition:leave-end="opacity-0 translate-x-full"
@mouseenter="toast.paused = true"
@mouseleave="toast.paused = false"
role="alert"
>
<!-- Toast Content -->
<div class="p-4">
<div class="flex justify-between items-start gap-3">
<!-- Icon + Content -->
<div class="flex items-start gap-3 flex-1">
<!-- Icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="size-5 flex-shrink-0"
:class="{
'text-green': toast.type === 'success',
'text-yellow': toast.type === 'warning',
'text-blue': toast.type === 'info'
}"
>
<!-- Success Icon (checkmark) -->
<template x-if="toast.type === 'success'">
<path
fill-rule="evenodd"
d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm13.36-1.814a.75.75 0 10-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 00-1.06 1.06l2.25 2.25a.75.75 0 001.14-.094l3.75-5.25z"
clip-rule="evenodd"
></path>
</template>
<!-- Warning Icon (exclamation) -->
<template x-if="toast.type === 'warning'">
<path
fill-rule="evenodd"
d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z"
clip-rule="evenodd"
></path>
</template>
<!-- Info Icon (information) -->
<template x-if="toast.type === 'info'">
<path
fill-rule="evenodd"
d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm8.706-1.442c1.146-.573 2.437.463 2.126 1.706l-.709 2.836.042-.02a.75.75 0 01.67 1.34l-.04.022c-1.147.573-2.438-.463-2.127-1.706l.71-2.836-.042.02a.75.75 0 11-.671-1.34l.041-.022zM12 9a.75.75 0 100-1.5.75.75 0 000 1.5z"
clip-rule="evenodd"
></path>
</template>
</svg>
<!-- Text Content -->
<div class="flex-1 min-w-0">
<p
class="font-semibold"
:class="{
'text-green': toast.type === 'success',
'text-yellow': toast.type === 'warning',
'text-blue': toast.type === 'info'
}"
x-text="toast.title"
></p>
<p class="text-sm text-subtext0 mt-1" x-text="toast.message"></p>
</div>
</div>
<!-- Close Button -->
<button
@click="removeToast(toast.id)"
class="text-subtext0 hover:text-text transition flex-shrink-0"
aria-label="Close notification"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6 18L18 6M6 6l12 12"
></path>
</svg>
</button>
</div>
</div>
<!-- Progress Bar -->
<div class="h-1 bg-surface0">
<div
class="h-full"
:class="{
'bg-green': toast.type === 'success',
'bg-yellow': toast.type === 'warning',
'bg-blue': toast.type === 'info'
}"
:style="`width: ${toast.progress}%`"
></div>
</div>
</div>
}

View File

@@ -34,35 +34,139 @@ templ Global(title string) {
htmx.logAll();
</script>
}
<script>
const bodyData = {
showError500: false,
showError503: false,
// handle errors from the server on HTMX requests
handleHtmxError(event) {
const errorCode = event.detail.errorInfo.error;
// internal server error
if (errorCode.includes("Code 500")) {
this.showError500 = true;
setTimeout(() => (this.showError500 = false), 6000);
<script>
const bodyData = {
// Error modal (blocking, full-screen) - for 500/503 errors
errorModal: {
show: false,
code: 0,
title: '',
message: '',
details: ''
},
// Toast notifications (non-blocking, stacked) - for success/warning/info
toasts: [],
toastIdCounter: 0,
// Handle HTMX beforeSwap event - intercept both errors and successes
handleBeforeSwap(event) {
const xhr = event.detail.xhr;
if (!xhr) return;
const status = xhr.status;
const trigger = xhr.getResponseHeader('HX-Trigger');
if (!trigger) return;
try {
const data = JSON.parse(trigger);
// Handle 500/503 error modals
if ((status === 500 || status === 503) && data.showErrorModal) {
this.errorModal = {
show: true,
code: data.showErrorModal.code,
title: data.showErrorModal.title,
message: data.showErrorModal.message,
details: data.showErrorModal.details
};
// Prevent swap but allow error logging
event.detail.shouldSwap = false;
return;
}
// Handle success/warning/info toasts (200-299)
if (status >= 200 && status < 300 && data.showNotification) {
this.addToast(data.showNotification);
}
} catch (e) {
console.error('Failed to parse HX-Trigger:', e);
}
},
// Add toast to stack
addToast(notification) {
const toast = {
id: ++this.toastIdCounter,
type: notification.type,
title: notification.title,
message: notification.message,
paused: false,
progress: 0
};
this.toasts.push(toast);
// Determine timeout based on type
const timeout = notification.type === 'warning' ? 5000 : 3000;
// Start progress animation
this.animateToastProgress(toast.id, timeout);
},
// Animate toast progress bar and auto-dismiss
animateToastProgress(toastId, duration) {
const toast = this.toasts.find(t => t.id === toastId);
if (!toast) return;
const startTime = performance.now();
let totalPausedTime = 0;
let pauseStartTime = null;
const animate = (currentTime) => {
const toast = this.toasts.find(t => t.id === toastId);
if (!toast) return; // Toast was manually removed
if (toast.paused) {
// Track when pause started
if (pauseStartTime === null) {
pauseStartTime = currentTime;
}
// service not available error
if (errorCode.includes("Code 503")) {
this.showError503 = true;
setTimeout(() => (this.showError503 = false), 6000);
// Keep animating while paused
requestAnimationFrame(animate);
return;
} else {
// If we were paused, accumulate the paused time
if (pauseStartTime !== null) {
totalPausedTime += currentTime - pauseStartTime;
pauseStartTime = null;
}
},
}
// Calculate actual elapsed time (excluding paused time)
const elapsed = currentTime - startTime - totalPausedTime;
toast.progress = Math.min((elapsed / duration) * 100, 100);
if (elapsed >= duration) {
this.removeToast(toastId);
} else {
requestAnimationFrame(animate);
}
};
</script>
requestAnimationFrame(animate);
},
// Remove toast from stack
removeToast(id) {
this.toasts = this.toasts.filter(t => t.id !== id);
},
// Close error modal
closeErrorModal() {
this.errorModal.show = false;
}
};
</script>
</head>
<body
class="bg-base text-text ubuntu-mono-regular overflow-x-hidden"
x-data="bodyData"
x-on:htmx:error="handleHtmxError($event)"
>
@popup.Error500Popup()
@popup.Error503Popup()
<body
class="bg-base text-text ubuntu-mono-regular overflow-x-hidden"
x-data="bodyData"
x-on:htmx:before-swap="handleBeforeSwap($event)"
x-on:keydown.escape.window="closeErrorModal()"
>
@popup.ErrorModal()
@popup.ToastContainer()
<div
id="main-content"
class="flex flex-col h-screen justify-between"

View File

@@ -0,0 +1,71 @@
package page
import "git.haelnorr.com/h/oslstats/internal/view/layout"
// Page content for the test notification page
templ Test() {
@layout.Global("Notification Test") {
<div class="flex items-center justify-center min-h-[calc(100vh-200px)]">
<div class="w-full max-w-md px-4">
<!-- Title -->
<h1 class="text-4xl font-bold text-center text-text mb-8">
Notification Test
</h1>
<!-- Form Card -->
<div class="bg-surface0 rounded-lg shadow-lg p-6 border border-overlay0">
<form hx-post="/test" hx-swap="none" class="flex flex-col gap-4">
<!-- Notification Type -->
<div class="flex flex-col gap-2">
<label for="type" class="text-sm font-semibold text-subtext1">
Notification Type
</label>
<select
name="type"
id="type"
class="bg-base text-text border border-overlay0 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-mauve"
>
<option value="error">Error Modal</option>
<option value="warn">Warning Toast</option>
<option value="info">Info Toast</option>
<option value="success">Success Toast</option>
</select>
</div>
<!-- Title Input -->
<div class="flex flex-col gap-2">
<label for="title" class="text-sm font-semibold text-subtext1">
Title
</label>
<input
type="text"
name="title"
id="title"
placeholder="Enter notification title"
class="bg-base text-text border border-overlay0 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-mauve"
/>
</div>
<!-- Message Input -->
<div class="flex flex-col gap-2">
<label for="message" class="text-sm font-semibold text-subtext1">
Message
</label>
<input
type="text"
name="message"
id="message"
placeholder="Enter notification message"
class="bg-base text-text border border-overlay0 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-mauve"
/>
</div>
<!-- Submit Button -->
<button
type="submit"
class="mt-2 bg-mauve text-crust font-semibold px-6 py-3 rounded-lg hover:bg-mauve/75 transition"
>
Send Notification
</button>
</form>
</div>
</div>
</div>
}
}

BIN
oslstats Executable file

Binary file not shown.

View File

@@ -15,11 +15,14 @@
--color-maroon: var(--maroon);
--color-peach: var(--peach);
--color-yellow: var(--yellow);
--color-dark-yellow: var(--dark-yellow);
--color-green: var(--green);
--color-dark-green: var(--dark-green);
--color-teal: var(--teal);
--color-sky: var(--sky);
--color-sapphire: var(--sapphire);
--color-blue: var(--blue);
--color-dark-blue: var(--dark-blue);
--color-lavender: var(--lavender);
--color-text: var(--text);
--color-subtext1: var(--subtext1);
@@ -45,11 +48,14 @@
--maroon: hsl(355, 76%, 59%);
--peach: hsl(22, 99%, 52%);
--yellow: hsl(35, 77%, 49%);
--dark-yellow: hsl(35, 50%, 85%);
--green: hsl(109, 58%, 40%);
--dark-green: hsl(109, 35%, 85%);
--teal: hsl(183, 74%, 35%);
--sky: hsl(197, 97%, 46%);
--sapphire: hsl(189, 70%, 42%);
--blue: hsl(220, 91%, 54%);
--dark-blue: hsl(220, 50%, 85%);
--lavender: hsl(231, 97%, 72%);
--text: hsl(234, 16%, 35%);
--subtext1: hsl(233, 13%, 41%);
@@ -75,11 +81,14 @@
--maroon: hsl(350, 65%, 77%);
--peach: hsl(23, 92%, 75%);
--yellow: hsl(41, 86%, 83%);
--dark-yellow: hsl(41, 30%, 25%);
--green: hsl(115, 54%, 76%);
--dark-green: hsl(115, 25%, 22%);
--teal: hsl(170, 57%, 73%);
--sky: hsl(189, 71%, 73%);
--sapphire: hsl(199, 76%, 69%);
--blue: hsl(217, 92%, 76%);
--dark-blue: hsl(217, 30%, 25%);
--lavender: hsl(232, 97%, 85%);
--text: hsl(226, 64%, 88%);
--subtext1: hsl(227, 35%, 80%);

View File

@@ -10,6 +10,7 @@
monospace;
--spacing: 0.25rem;
--breakpoint-xl: 80rem;
--container-sm: 24rem;
--container-md: 28rem;
--container-2xl: 42rem;
--container-7xl: 80rem;
@@ -39,10 +40,42 @@
--radius-sm: 0.25rem;
--radius-lg: 0.5rem;
--radius-xl: 0.75rem;
--ease-in: cubic-bezier(0.4, 0, 1, 1);
--ease-out: cubic-bezier(0, 0, 0.2, 1);
--default-transition-duration: 150ms;
--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
--default-font-family: var(--font-sans);
--default-mono-font-family: var(--font-mono);
--color-rosewater: var(--rosewater);
--color-flamingo: var(--flamingo);
--color-pink: var(--pink);
--color-mauve: var(--mauve);
--color-red: var(--red);
--color-dark-red: var(--dark-red);
--color-maroon: var(--maroon);
--color-peach: var(--peach);
--color-yellow: var(--yellow);
--color-dark-yellow: var(--dark-yellow);
--color-green: var(--green);
--color-dark-green: var(--dark-green);
--color-teal: var(--teal);
--color-sky: var(--sky);
--color-sapphire: var(--sapphire);
--color-blue: var(--blue);
--color-dark-blue: var(--dark-blue);
--color-lavender: var(--lavender);
--color-text: var(--text);
--color-subtext1: var(--subtext1);
--color-subtext0: var(--subtext0);
--color-overlay2: var(--overlay2);
--color-overlay1: var(--overlay1);
--color-overlay0: var(--overlay0);
--color-surface2: var(--surface2);
--color-surface1: var(--surface1);
--color-surface0: var(--surface0);
--color-base: var(--base);
--color-mantle: var(--mantle);
--color-crust: var(--crust);
}
}
@layer base {
@@ -194,6 +227,15 @@
}
}
@layer utilities {
.pointer-events-auto {
pointer-events: auto;
}
.pointer-events-none {
pointer-events: none;
}
.collapse {
visibility: collapse;
}
.visible {
visibility: visible;
}
@@ -211,12 +253,18 @@
.absolute {
position: absolute;
}
.fixed {
position: fixed;
}
.relative {
position: relative;
}
.static {
position: static;
}
.inset-0 {
inset: calc(var(--spacing) * 0);
}
.end-0 {
inset-inline-end: calc(var(--spacing) * 0);
}
@@ -229,21 +277,57 @@
.top-4 {
top: calc(var(--spacing) * 4);
}
.top-20 {
top: calc(var(--spacing) * 20);
}
.right-0 {
right: calc(var(--spacing) * 0);
}
.right-5 {
right: calc(var(--spacing) * 5);
}
.bottom-0 {
bottom: calc(var(--spacing) * 0);
}
.left-0 {
left: calc(var(--spacing) * 0);
}
.z-3 {
z-index: 3;
}
.z-10 {
z-index: 10;
}
.z-40 {
z-index: 40;
}
.z-50 {
z-index: 50;
}
.container {
width: 100%;
@media (width >= 40rem) {
max-width: 40rem;
}
@media (width >= 48rem) {
max-width: 48rem;
}
@media (width >= 64rem) {
max-width: 64rem;
}
@media (width >= 80rem) {
max-width: 80rem;
}
@media (width >= 96rem) {
max-width: 96rem;
}
}
.mx-auto {
margin-inline: auto;
}
.mt-1 {
margin-top: calc(var(--spacing) * 1);
}
.mt-1\.5 {
margin-top: calc(var(--spacing) * 1.5);
}
@@ -280,6 +364,9 @@
.mr-5 {
margin-right: calc(var(--spacing) * 5);
}
.mb-8 {
margin-bottom: calc(var(--spacing) * 8);
}
.mb-auto {
margin-bottom: auto;
}
@@ -321,6 +408,13 @@
width: calc(var(--spacing) * 6);
height: calc(var(--spacing) * 6);
}
.size-8 {
width: calc(var(--spacing) * 8);
height: calc(var(--spacing) * 8);
}
.h-1 {
height: calc(var(--spacing) * 1);
}
.h-16 {
height: calc(var(--spacing) * 16);
}
@@ -330,6 +424,15 @@
.h-screen {
height: 100vh;
}
.max-h-96 {
max-height: calc(var(--spacing) * 96);
}
.max-h-\[90vh\] {
max-height: 90vh;
}
.min-h-\[calc\(100vh-200px\)\] {
min-height: calc(100vh - 200px);
}
.w-26 {
width: calc(var(--spacing) * 26);
}
@@ -360,9 +463,30 @@
.max-w-screen-xl {
max-width: var(--breakpoint-xl);
}
.max-w-sm {
max-width: var(--container-sm);
}
.min-w-0 {
min-width: calc(var(--spacing) * 0);
}
.flex-1 {
flex: 1;
}
.flex-shrink {
flex-shrink: 1;
}
.flex-shrink-0 {
flex-shrink: 0;
}
.flex-grow {
flex-grow: 1;
}
.grow {
flex-grow: 1;
}
.border-collapse {
border-collapse: collapse;
}
.translate-x-0 {
--tw-translate-x: calc(var(--spacing) * 0);
translate: var(--tw-translate-x) var(--tw-translate-y);
@@ -371,12 +495,27 @@
--tw-translate-x: 100%;
translate: var(--tw-translate-x) var(--tw-translate-y);
}
.translate-x-full {
--tw-translate-x: 100%;
translate: var(--tw-translate-x) var(--tw-translate-y);
}
.translate-y-0 {
--tw-translate-y: calc(var(--spacing) * 0);
translate: var(--tw-translate-x) var(--tw-translate-y);
}
.translate-y-4 {
--tw-translate-y: calc(var(--spacing) * 4);
translate: var(--tw-translate-x) var(--tw-translate-y);
}
.transform {
transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);
}
.cursor-pointer {
cursor: pointer;
}
.resize {
resize: both;
}
.flex-col {
flex-direction: column;
}
@@ -389,6 +528,9 @@
.items-center {
align-items: center;
}
.items-start {
align-items: flex-start;
}
.justify-between {
justify-content: space-between;
}
@@ -401,6 +543,12 @@
.gap-2 {
gap: calc(var(--spacing) * 2);
}
.gap-3 {
gap: calc(var(--spacing) * 3);
}
.gap-4 {
gap: calc(var(--spacing) * 4);
}
.gap-6 {
gap: calc(var(--spacing) * 6);
}
@@ -434,6 +582,11 @@
border-color: var(--surface2);
}
}
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.overflow-hidden {
overflow: hidden;
}
@@ -443,6 +596,9 @@
.overflow-x-hidden {
overflow-x: hidden;
}
.overflow-y-auto {
overflow-y: auto;
}
.rounded {
border-radius: 0.25rem;
}
@@ -466,6 +622,16 @@
border-style: var(--tw-border-style);
border-width: 2px;
}
.border-b {
border-bottom-style: var(--tw-border-style);
border-bottom-width: 1px;
}
.border-blue {
border-color: var(--blue);
}
.border-dark-red {
border-color: var(--dark-red);
}
.border-green {
border-color: var(--green);
}
@@ -481,15 +647,36 @@
.border-transparent {
border-color: transparent;
}
.border-yellow {
border-color: var(--yellow);
}
.bg-base {
background-color: var(--base);
}
.bg-blue {
background-color: var(--blue);
}
.bg-crust {
background-color: var(--crust);
}
.bg-crust\/80 {
background-color: var(--crust);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--crust) 80%, transparent);
}
}
.bg-dark-blue {
background-color: var(--dark-blue);
}
.bg-dark-green {
background-color: var(--dark-green);
}
.bg-dark-red {
background-color: var(--dark-red);
}
.bg-dark-yellow {
background-color: var(--dark-yellow);
}
.bg-green {
background-color: var(--green);
}
@@ -508,6 +695,9 @@
.bg-teal {
background-color: var(--teal);
}
.bg-yellow {
background-color: var(--yellow);
}
.p-2 {
padding: calc(var(--spacing) * 2);
}
@@ -517,6 +707,9 @@
.p-4 {
padding: calc(var(--spacing) * 4);
}
.p-6 {
padding: calc(var(--spacing) * 6);
}
.px-2 {
padding-inline: calc(var(--spacing) * 2);
}
@@ -529,6 +722,9 @@
.px-5 {
padding-inline: calc(var(--spacing) * 5);
}
.px-6 {
padding-inline: calc(var(--spacing) * 6);
}
.py-1 {
padding-block: calc(var(--spacing) * 1);
}
@@ -617,9 +813,15 @@
.whitespace-pre-wrap {
white-space: pre-wrap;
}
.text-blue {
color: var(--blue);
}
.text-crust {
color: var(--crust);
}
.text-green {
color: var(--green);
}
.text-mantle {
color: var(--mantle);
}
@@ -638,6 +840,15 @@
.text-text {
color: var(--text);
}
.text-yellow {
color: var(--yellow);
}
.lowercase {
text-transform: lowercase;
}
.underline {
text-decoration-line: underline;
}
.opacity-0 {
opacity: 0%;
}
@@ -652,15 +863,46 @@
--tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
.shadow-xl {
--tw-shadow: 0 20px 25px -5px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 8px 10px -6px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
.outline {
outline-style: var(--tw-outline-style);
outline-width: 1px;
}
.filter {
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
}
.transition {
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events;
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
transition-duration: var(--tw-duration, var(--default-transition-duration));
}
.transition-all {
transition-property: all;
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
transition-duration: var(--tw-duration, var(--default-transition-duration));
}
.delay-100 {
transition-delay: 100ms;
}
.duration-200 {
--tw-duration: 200ms;
transition-duration: 200ms;
}
.duration-300 {
--tw-duration: 300ms;
transition-duration: 300ms;
}
.ease-in {
--tw-ease: var(--ease-in);
transition-timing-function: var(--ease-in);
}
.ease-out {
--tw-ease: var(--ease-out);
transition-timing-function: var(--ease-out);
}
.outline-none {
--tw-outline-style: none;
outline-style: none;
@@ -786,6 +1028,23 @@
border-color: var(--red);
}
}
.focus\:ring-2 {
&:focus {
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
}
.focus\:ring-mauve {
&:focus {
--tw-ring-color: var(--mauve);
}
}
.focus\:outline-none {
&:focus {
--tw-outline-style: none;
outline-style: none;
}
}
.disabled\:pointer-events-none {
&:disabled {
pointer-events: none;
@@ -940,11 +1199,14 @@
--maroon: hsl(355, 76%, 59%);
--peach: hsl(22, 99%, 52%);
--yellow: hsl(35, 77%, 49%);
--dark-yellow: hsl(35, 50%, 85%);
--green: hsl(109, 58%, 40%);
--dark-green: hsl(109, 35%, 85%);
--teal: hsl(183, 74%, 35%);
--sky: hsl(197, 97%, 46%);
--sapphire: hsl(189, 70%, 42%);
--blue: hsl(220, 91%, 54%);
--dark-blue: hsl(220, 50%, 85%);
--lavender: hsl(231, 97%, 72%);
--text: hsl(234, 16%, 35%);
--subtext1: hsl(233, 13%, 41%);
@@ -969,11 +1231,14 @@
--maroon: hsl(350, 65%, 77%);
--peach: hsl(23, 92%, 75%);
--yellow: hsl(41, 86%, 83%);
--dark-yellow: hsl(41, 30%, 25%);
--green: hsl(115, 54%, 76%);
--dark-green: hsl(115, 25%, 22%);
--teal: hsl(170, 57%, 73%);
--sky: hsl(189, 71%, 73%);
--sapphire: hsl(199, 76%, 69%);
--blue: hsl(217, 92%, 76%);
--dark-blue: hsl(217, 30%, 25%);
--lavender: hsl(232, 97%, 85%);
--text: hsl(226, 64%, 88%);
--subtext1: hsl(227, 35%, 80%);
@@ -1135,10 +1400,72 @@
inherits: false;
initial-value: 0 0 #0000;
}
@property --tw-outline-style {
syntax: "*";
inherits: false;
initial-value: solid;
}
@property --tw-blur {
syntax: "*";
inherits: false;
}
@property --tw-brightness {
syntax: "*";
inherits: false;
}
@property --tw-contrast {
syntax: "*";
inherits: false;
}
@property --tw-grayscale {
syntax: "*";
inherits: false;
}
@property --tw-hue-rotate {
syntax: "*";
inherits: false;
}
@property --tw-invert {
syntax: "*";
inherits: false;
}
@property --tw-opacity {
syntax: "*";
inherits: false;
}
@property --tw-saturate {
syntax: "*";
inherits: false;
}
@property --tw-sepia {
syntax: "*";
inherits: false;
}
@property --tw-drop-shadow {
syntax: "*";
inherits: false;
}
@property --tw-drop-shadow-color {
syntax: "*";
inherits: false;
}
@property --tw-drop-shadow-alpha {
syntax: "<percentage>";
inherits: false;
initial-value: 100%;
}
@property --tw-drop-shadow-size {
syntax: "*";
inherits: false;
}
@property --tw-duration {
syntax: "*";
inherits: false;
}
@property --tw-ease {
syntax: "*";
inherits: false;
}
@layer properties {
@supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
*, ::before, ::after, ::backdrop {
@@ -1170,7 +1497,22 @@
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-offset-shadow: 0 0 #0000;
--tw-outline-style: solid;
--tw-blur: initial;
--tw-brightness: initial;
--tw-contrast: initial;
--tw-grayscale: initial;
--tw-hue-rotate: initial;
--tw-invert: initial;
--tw-opacity: initial;
--tw-saturate: initial;
--tw-sepia: initial;
--tw-drop-shadow: initial;
--tw-drop-shadow-color: initial;
--tw-drop-shadow-alpha: 100%;
--tw-drop-shadow-size: initial;
--tw-duration: initial;
--tw-ease: initial;
}
}
}