Containerise: Dockerfile, Compose stack and wildcard TLS

One server now, the same image and env vars for many later: point
DJANGO_DATABASE_URL / DJANGO_REDIS_URL at central services, set a bucket, drop the
db and redis services, run several web containers behind a load balancer. No code
changes.

The wildcard certificate is what shapes this. Subdomain tenancy needs
*.rosterchief.app, and Let's Encrypt will not issue a wildcard over HTTP-01 -- only
DNS-01 -- so Caddy is built with a DNS provider plugin and needs an API token. That
single constraint is why the proxy is Caddy rather than the usual nginx+certbot.

The image apt-installs libpango and friends, which is what WeasyPrint binds to. The
PDF invoices that cannot render on a Mac without Homebrew work in the container by
construction.

Migrations are NOT run by the entrypoint: with more than one web container they
would race, and a starting gunicorn worker is a bad place to discover a failed
migration. Deploy runs them once, explicitly.

Two things the local build check caught, either of which would have failed the
image build at collectstatic (manifest storage treats a missing referenced file as
fatal):

- chart.js ended with a sourceMappingURL pointing at a .map we never vendored.
  Stripped, with an npm script so re-vendoring cannot bring it back.
- The Tailwind INPUT file lived at static/src/app.css, inside the served static
  tree, so collectstatic collected it and then choked on its @import "tailwindcss".
  It belongs outside: it is a build input, not an asset. Now assets/app.css.

Verified locally under gunicorn + WhiteNoise + manifest storage: pages serve and
the CSS comes back hashed. The image itself is unverified -- there is no container
runtime on this machine.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 09:41:51 +02:00
parent e5a93194bf
commit 35d1ec45a7
12 changed files with 705 additions and 5 deletions

13
.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
.git
.venv
node_modules
staticfiles
media
db.sqlite3
.env
*.pyc
__pycache__
.coverage
.idea
.ruff_cache
ARCHITECTURE.pdf

10
.env.compose.example Normal file
View File

@@ -0,0 +1,10 @@
# Copy to .env — read by docker compose itself (not by Django).
ROSTERCHIEF_BASE_DOMAIN=rosterchief.app
ACME_EMAIL=you@example.com
# DNS-01 is the only way to get the *.rosterchief.app wildcard. Token needs DNS:Edit on the zone.
CLOUDFLARE_API_TOKEN=
POSTGRES_DB=rosterchief
POSTGRES_USER=rosterchief
POSTGRES_PASSWORD=

38
.env.production.example Normal file
View File

@@ -0,0 +1,38 @@
# Copy to .env.production and fill in. Everything here is read by python-decouple.
# --- Django ---
DJANGO_SECRET_KEY= # python -c "import secrets; print(secrets.token_urlsafe(64))"
DJANGO_DEBUG=False
# The leading dot matches every club subdomain.
DJANGO_ALLOWED_HOSTS=.rosterchief.app
DJANGO_CSRF_TRUSTED_ORIGINS=https://rosterchief.app,https://*.rosterchief.app
DJANGO_TIME_ZONE=Europe/Brussels
# --- Tenancy ---
# Drives subdomain resolution, the shared session cookie, and the WebAuthn RP ID (one passkey
# across every club).
ROSTERCHIEF_BASE_DOMAIN=rosterchief.app
ROSTERCHIEF_RP_NAME=RosterChief
# --- Services ---
DJANGO_DATABASE_URL=postgres://rosterchief:CHANGEME@db:5432/rosterchief
DJANGO_REDIS_URL=redis://redis:6379/0
# --- HTTPS (off by default in code; the deploy is what turns them on) ---
DJANGO_SECURE_SSL_REDIRECT=True
DJANGO_SESSION_COOKIE_SECURE=True
DJANGO_CSRF_COOKIE_SECURE=True
DJANGO_SECURE_HSTS_SECONDS=31536000
DJANGO_SECURE_HSTS_INCLUDE_SUBDOMAINS=True
# Preload is a one-way door — turn it on only once the wildcard cert has proven itself.
DJANGO_SECURE_HSTS_PRELOAD=False
# --- Static ---
DJANGO_STATICFILES_BACKEND=whitenoise.storage.CompressedManifestStaticFilesStorage
# --- Uploads: set these and club logos move off local disk (required for >1 app server) ---
# AWS_STORAGE_BUCKET_NAME=rosterchief-media
# AWS_S3_ENDPOINT_URL=https://fsn1.your-objectstorage.com
# AWS_S3_REGION_NAME=fsn1
# AWS_ACCESS_KEY_ID=
# AWS_SECRET_ACCESS_KEY=

1
.gitignore vendored
View File

@@ -379,3 +379,4 @@ pyrightconfig.json
# End of https://www.toptal.com/developers/gitignore/api/python,pycharm,django%
# Node
node_modules/
staticfiles/

73
Dockerfile Normal file
View File

@@ -0,0 +1,73 @@
# syntax=docker/dockerfile:1
# --- 1. the stylesheet -------------------------------------------------------
# Tailwind is a build-time concern: the CSS it emits is committed, but building it here means
# the image never depends on someone having remembered to run `npm run build`.
FROM node:22-slim AS css
WORKDIR /build
COPY package.json package-lock.json ./
RUN npm ci
COPY assets ./assets
COPY templates ./templates
COPY controlpanel ./controlpanel
COPY billing ./billing
RUN npm run build
# --- 2. the runtime ----------------------------------------------------------
FROM python:3.14-slim AS app
# WeasyPrint binds to these at import: no pango, no invoices. This is also why building the
# PDF path in a container is easier than on a Mac — apt has what Homebrew would have to.
RUN apt-get update && apt-get install --no-install-recommends -y \
libpango-1.0-0 \
libpangoft2-1.0-0 \
libharfbuzz0b \
libffi8 \
libjpeg62-turbo \
libopenjp2-7 \
shared-mime-info \
curl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
PATH="/app/.venv/bin:$PATH"
WORKDIR /app
# Dependencies first: they change far less often than the code, so this layer caches.
COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev --no-install-project
COPY . .
COPY --from=css /build/static/css/app.css ./static/css/app.css
RUN --mount=type=cache,target=/root/.cache/uv uv sync --frozen --no-dev
# collectstatic needs a settings module that imports: a throwaway key, never used at runtime.
RUN DJANGO_SECRET_KEY=build-only-not-a-secret \
DJANGO_STATICFILES_BACKEND=whitenoise.storage.CompressedManifestStaticFilesStorage \
python manage.py collectstatic --noinput
RUN useradd --system --uid 1000 rosterchief && chown -R rosterchief /app
USER rosterchief
EXPOSE 8000
# Migrations are NOT run here. With more than one app container they would race, and a failed
# migration inside a starting web process is a bad place to find out — deploy runs them once,
# explicitly (see DEPLOYMENT.md).
CMD ["gunicorn", "rosterchief.wsgi:application", \
"--bind", "0.0.0.0:8000", \
"--workers", "3", \
"--threads", "4", \
"--timeout", "60", \
"--access-logfile", "-", \
"--error-logfile", "-"]

View File

@@ -2,8 +2,9 @@
/* Scan Django templates for utility classes (Tailwind's auto-detection doesn't
know about our template dirs). */
@source "../../templates";
@source "../../controlpanel";
@source "../templates";
@source "../controlpanel";
@source "../billing";
/* daisyUI: light is the default, dark applies automatically when the OS asks
for it. An explicit data-theme on <html> (set by the toggle) overrides both. */

72
compose.yaml Normal file
View File

@@ -0,0 +1,72 @@
# One server. The same image and the same environment variables run a multi-server
# deployment: point DJANGO_DATABASE_URL / DJANGO_REDIS_URL at your central services, set a
# bucket, drop the `db` and `redis` services, and run several `web` containers behind a load
# balancer. Nothing in the code changes.
name: rosterchief
services:
caddy:
build:
context: ./deploy/caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
environment:
# A wildcard certificate for *.rosterchief.app cannot be issued over HTTP-01 — Let's
# Encrypt only does wildcards via DNS-01. That is why Caddy needs a DNS API token, and
# why this image is built with the provider's DNS plugin rather than pulled as-is.
ROSTERCHIEF_BASE_DOMAIN: ${ROSTERCHIEF_BASE_DOMAIN:?set the base domain, e.g. rosterchief.app}
ACME_EMAIL: ${ACME_EMAIL:?set an email for Let's Encrypt}
CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN:?DNS-01 needs an API token with DNS:Edit on the zone}
volumes:
- ./deploy/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
- web
web:
build: .
restart: unless-stopped
env_file: .env.production
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8000/healthz"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
db:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-rosterchief}
POSTGRES_USER: ${POSTGRES_USER:-rosterchief}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set a database password}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-rosterchief}"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
restart: unless-stopped
command: ["redis-server", "--save", "", "--appendonly", "no"]
# Cache only, so nothing here needs to survive a restart. It is not optional though: it
# is what keeps every gunicorn worker agreeing about which feature flags are on.
volumes:
pgdata:
caddy_data:
caddy_config:

24
deploy/caddy/Caddyfile Normal file
View File

@@ -0,0 +1,24 @@
{
email {$ACME_EMAIL}
}
# The bare domain (control panel, admin, auth) and every club subdomain, on one certificate.
{$ROSTERCHIEF_BASE_DOMAIN}, *.{$ROSTERCHIEF_BASE_DOMAIN} {
tls {
dns cloudflare {$CLOUDFLARE_API_TOKEN}
}
encode zstd gzip
# X-Forwarded-Proto is what SECURE_PROXY_SSL_HEADER reads. Without it Django believes every
# request is plain HTTP: request.is_secure() goes false, WebAuthn disagrees with the browser
# about the origin, and the SSL redirect becomes a loop.
reverse_proxy web:8000 {
header_up X-Forwarded-Proto {scheme}
header_up X-Real-IP {remote_host}
}
log {
output stdout
}
}

9
deploy/caddy/Dockerfile Normal file
View File

@@ -0,0 +1,9 @@
# Caddy with a DNS plugin. The stock image cannot solve a DNS-01 challenge, and DNS-01 is the
# only way Let's Encrypt issues the *.rosterchief.app wildcard that subdomain tenancy needs.
#
# Swap the module if your DNS lives elsewhere: caddy-dns/route53, caddy-dns/digitalocean, ...
FROM caddy:2-builder AS builder
RUN xcaddy build --with github.com/caddy-dns/cloudflare
FROM caddy:2
COPY --from=builder /usr/bin/caddy /usr/bin/caddy

View File

@@ -3,8 +3,9 @@
"private": true,
"type": "module",
"scripts": {
"build": "tailwindcss -i ./static/src/app.css -o ./static/css/app.css --minify",
"watch": "tailwindcss -i ./static/src/app.css -o ./static/css/app.css --watch"
"build": "tailwindcss -i ./assets/app.css -o ./static/css/app.css --minify",
"watch": "tailwindcss -i ./assets/app.css -o ./static/css/app.css --watch",
"vendor:chart": "node -e \"const fs=require('fs');const s=fs.readFileSync('node_modules/chart.js/dist/chart.umd.js','utf8').split('\\n').filter(l=>!l.startsWith('//# sourceMappingURL=')).join('\\n');fs.writeFileSync('static/js/chart.js',s)\""
},
"devDependencies": {
"@fontsource-variable/jetbrains-mono": "^5.2.8",

View File

@@ -37,7 +37,9 @@
--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);
--font-ubuntu: "Ubuntu", ui-sans-serif, system-ui, sans-serif;
--font-roboto: "Roboto", ui-sans-serif, system-ui, sans-serif;
--font-tourney: "Tourney", ui-sans-serif, system-ui, sans-serif;
}
}
@layer base {
@@ -189,6 +191,67 @@
}
}
@layer utilities {
.diff {
@layer daisyui.l1.l2 {
position: relative;
display: grid;
width: 100%;
overflow: hidden;
webkit-user-select: none;
user-select: none;
align-items: normal;
grid-template-rows: 1fr 1.8rem 1fr;
direction: ltr;
container-type: inline-size;
grid-template-columns: auto 1fr;
&:focus-visible, &:has(.diff-item-1:focus-visible) {
outline-style: var(--tw-outline-style);
outline-width: 2px;
outline-offset: 1px;
outline-color: var(--color-base-content);
}
&:focus-visible {
outline-style: var(--tw-outline-style);
outline-width: 2px;
outline-offset: 1px;
outline-color: var(--color-base-content);
.diff-resizer {
min-width: 95cqi;
max-width: 95cqi;
}
}
&:has(.diff-item-1:focus-visible) {
outline-style: var(--tw-outline-style);
outline-width: 2px;
outline-offset: 1px;
.diff-resizer {
min-width: 5cqi;
max-width: 5cqi;
}
}
&:hover {
.diff-item-2 {
&::after {
height: 2.4rem;
}
}
}
@supports (-webkit-overflow-scrolling: touch) and (overflow: -webkit-paged-x) {
&:focus {
.diff-resizer {
min-width: 5cqi;
max-width: 5cqi;
}
}
&:has(.diff-item-1:focus) {
.diff-resizer {
min-width: 95cqi;
max-width: 95cqi;
}
}
}
}
}
.tooltip {
@layer daisyui.l1.l2.l3 {
position: relative;
@@ -726,6 +789,57 @@
}
}
}
.diff-item-2 {
@layer daisyui.l1.l2.l3 {
position: relative;
grid-column-start: 1;
grid-row: span 3 / span 3;
grid-row-start: 1;
&:after {
pointer-events: none;
position: absolute;
top: calc(1 / 2 * 100%);
right: 1px;
bottom: 0;
z-index: 2;
border-radius: calc(infinity * 1px);
background-color: var(--color-base-100);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-base-100) 98%, transparent);
}
width: 1.2rem;
height: 1.8rem;
border: 2px solid var(--color-base-100);
content: "";
box-shadow: 0 0 0 2px #0000002a;
outline: 2px solid var(--color-base-content);
@supports (color: color-mix(in lab, red, red)) {
outline: 2px solid color-mix(in oklab, var(--color-base-content) 10%, #0000);
}
outline-offset: -3px;
translate: 50% -50%;
transition: height 0.3s linear(0, 0.931 13.8%, 1.196 21.4%, 1.343 29.8%, 1.378 36%, 1.365 43.2%, 1.059 78%, 1);
}
> * {
pointer-events: none;
position: absolute;
top: 0;
bottom: 0;
left: 0;
height: 100%;
width: 100cqi;
max-width: none;
object-fit: cover;
object-position: center;
}
@supports (-webkit-overflow-scrolling: touch) and (overflow: -webkit-paged-x) {
&:after {
--tw-content: none;
content: var(--tw-content);
}
}
}
}
.dropdown {
@layer daisyui.l1.l2.l3 {
position: relative;
@@ -974,6 +1088,23 @@
}
}
}
.loading {
@layer daisyui.l1.l2.l3 {
pointer-events: none;
display: inline-block;
aspect-ratio: 1 / 1;
background-color: currentcolor;
vertical-align: middle;
width: calc(var(--size-selector, 0.25rem) * 6);
mask-size: 100%;
mask-repeat: no-repeat;
mask-position: center;
mask-image: url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='black' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cg transform-origin='center'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' stroke-linecap='round'%3E%3CanimateTransform attributeName='transform' type='rotate' from='0 12 12' to='360 12 12' dur='8s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dasharray' values='0,150;42,150;42,150' keyTimes='0;0.475;1' dur='6s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dashoffset' values='0;-16;-59' keyTimes='0;0.475;1' dur='6s' repeatCount='indefinite'/%3E%3C/circle%3E%3C/g%3E%3C/svg%3E");
@media (prefers-reduced-motion: no-preference) {
mask-image: url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='black' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cg transform-origin='center'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' stroke-linecap='round'%3E%3CanimateTransform attributeName='transform' type='rotate' from='0 12 12' to='360 12 12' dur='2s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dasharray' values='0,150;42,150;42,150' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dashoffset' values='0;-16;-59' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3C/circle%3E%3C/g%3E%3C/svg%3E");
}
}
}
.collapse {
&:not(td, tr, colgroup) {
visibility: revert-layer;
@@ -1141,6 +1272,44 @@
}
}
}
.\!filter {
@layer daisyui.l1.l2.l3 {
display: flex !important;
flex-wrap: wrap !important;
[type="radio"] {
width: auto !important;
}
input {
overflow: hidden !important;
opacity: 100% !important;
scale: 1 !important;
transition: visibility 0.1s allow-discrete, margin 0.1s, opacity 0.3s, padding 0.3s, border-width 0.1s !important;
&.filter-reset {
aspect-ratio: 1 / 1 !important;
&::after {
--tw-content: "×" !important;
content: var(--tw-content) !important;
}
}
}
> input:not(:last-child), > :not(:last-child) input {
margin-inline-end: 0.25rem !important;
}
}
@layer daisyui.l1 {
&:not(:has(:checked:not(.filter-reset))) :is(.filter-reset, [type="reset"]):not(:focus-visible) {
visibility: hidden !important;
}
&:not(:has(:checked:not(.filter-reset))) :is(.filter-reset, [type="reset"]):not(:focus-visible), &:not(:has(:focus-visible)):has(:checked:not(.filter-reset, [type="checkbox"])) :is(input, button):not(:checked, .filter-reset, [type="reset"]) {
margin-inline: 0 !important;
width: 0 !important;
padding-inline: 0 !important;
opacity: 0% !important;
scale: 0 !important;
border-width: 0 !important;
}
}
}
.filter {
@layer daisyui.l1.l2.l3 {
display: flex;
@@ -1179,6 +1348,36 @@
}
}
}
.validator-hint {
@layer daisyui.l1.l2.l3 {
visibility: hidden;
margin-top: calc(0.25rem * 2);
font-size: 0.75rem;
}
}
.validator {
@layer daisyui.l1.l2.l3 {
&:user-valid, &:has(:user-valid) {
&, &:focus, &:checked, &[aria-checked="true"], &:focus-within {
--input-color: var(--color-success);
}
}
&:user-invalid, &:has(:user-invalid), &[aria-invalid]:not([aria-invalid="false"]), &:has([aria-invalid]:not([aria-invalid="false"])) {
&, &:focus, &:checked, &[aria-checked="true"], &:focus-within {
--input-color: var(--color-error);
}
& ~ .validator-hint {
visibility: visible;
color: var(--color-error);
}
}
}
&:user-invalid, &:has(:user-invalid), &[aria-invalid]:not([aria-invalid="false"]), &:has([aria-invalid]:not([aria-invalid="false"])) {
& ~ .validator-hint {
display: revert-layer;
}
}
}
.collapse-open {
@layer daisyui.l1.l2 {
grid-template-rows: max-content 1fr;
@@ -1550,6 +1749,105 @@
}
}
}
.range {
@layer daisyui.l1.l2.l3 {
appearance: none;
webkit-appearance: none;
--range-thumb: var(--color-base-100);
--range-thumb-size: calc(var(--size-selector, 0.25rem) * 6);
--range-progress: currentColor;
--range-fill: 1;
--range-p: 0.25rem;
--range-bg: currentColor;
@supports (color: color-mix(in lab, red, red)) {
--range-bg: color-mix(in oklab, currentColor 10%, #0000);
}
--range-fill-x: calc(
(var(--range-dir, 1) * -100cqw) - (var(--range-dir, 1) * var(--range-thumb-size) / 2)
);
--range-fill-y: 0;
--range-fill-spread: calc(100cqw * var(--range-fill));
cursor: pointer;
overflow: hidden;
background-color: transparent;
vertical-align: middle;
width: clamp(3rem, 20rem, 100%);
--radius-selector-max: calc(
var(--radius-selector) + var(--radius-selector) + var(--radius-selector)
);
border-radius: calc(var(--radius-selector) + min(var(--range-p), var(--radius-selector-max)));
border: none;
height: var(--range-thumb-size);
[dir="rtl"] & {
--range-dir: -1;
}
&:focus {
outline: none;
}
&:focus-visible {
outline: 2px solid;
outline-offset: 2px;
}
&::-webkit-slider-runnable-track {
width: 100%;
background-color: var(--range-bg);
border-radius: var(--radius-selector);
height: calc(var(--range-thumb-size) * 0.5);
}
@media (forced-colors: active) {
&::-webkit-slider-runnable-track {
border: 1px solid;
}
}
@media (forced-colors: active) {
&::-moz-range-track {
border: 1px solid;
}
}
&::-webkit-slider-thumb {
position: relative;
box-sizing: border-box;
border-radius: calc(var(--radius-selector) + min(var(--range-p), var(--radius-selector-max)));
background-color: var(--range-thumb);
height: var(--range-thumb-size);
width: var(--range-thumb-size);
border: var(--range-p) solid;
appearance: none;
webkit-appearance: none;
inset-block-start: 50%;
color: var(--range-progress);
transform: translateY(-50%);
box-shadow: 0 -1px oklch(0% 0 0 / calc(var(--depth) * 0.1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset, 0 1px currentColor, 0 0 0 2rem var(--range-thumb) inset, var(--range-fill-x) var(--range-fill-y) 0 var(--range-fill-spread);
@supports (color: color-mix(in lab, red, red)) {
box-shadow: 0 -1px oklch(0% 0 0 / calc(var(--depth) * 0.1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset, 0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000), 0 0 0 2rem var(--range-thumb) inset, var(--range-fill-x) var(--range-fill-y) 0 var(--range-fill-spread);
}
}
&::-moz-range-track {
width: 100%;
background-color: var(--range-bg);
border-radius: var(--radius-selector);
height: calc(var(--range-thumb-size) * 0.5);
}
&::-moz-range-thumb {
position: relative;
box-sizing: border-box;
border-radius: calc(var(--radius-selector) + min(var(--range-p), var(--radius-selector-max)));
background-color: currentColor;
height: var(--range-thumb-size);
width: var(--range-thumb-size);
border: var(--range-p) solid;
color: var(--range-progress);
box-shadow: 0 -1px oklch(0% 0 0 / calc(var(--depth) * 0.1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset, 0 1px currentColor, 0 0 0 2rem var(--range-thumb) inset, var(--range-fill-x) var(--range-fill-y) 0 var(--range-fill-spread);
@supports (color: color-mix(in lab, red, red)) {
box-shadow: 0 -1px oklch(0% 0 0 / calc(var(--depth) * 0.1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset, 0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000), 0 0 0 2rem var(--range-thumb) inset, var(--range-fill-x) var(--range-fill-y) 0 var(--range-fill-spread);
}
}
&:disabled {
cursor: not-allowed;
opacity: 30%;
}
}
}
.indicator {
@layer daisyui.l1.l2.l3 {
position: relative;
@@ -1791,6 +2089,27 @@
}
}
}
.diff-resizer {
@layer daisyui.l1.l2.l3 {
position: relative;
isolation: isolate;
z-index: 2;
grid-column-start: 1;
grid-row-start: 2;
height: calc(0.25rem * 3);
width: 50cqi;
max-width: calc(100cqi - 1rem);
min-width: 1rem;
resize: horizontal;
overflow: hidden;
opacity: 0%;
transform: scaleY(5) translate(0.32rem, 50%);
cursor: ew-resize;
transform-origin: 100% 100%;
clip-path: inset(calc(100% - 0.75rem) 0 0 calc(100% - 0.75rem));
transition: min-width 0.3s ease-out, max-width 0.3s ease-out;
}
}
.select {
@layer daisyui.l1.l2.l3 {
position: relative;
@@ -1977,6 +2296,42 @@
}
}
}
.swap {
@layer daisyui.l1.l2 {
position: relative;
display: inline-grid;
cursor: pointer;
place-content: center;
vertical-align: middle;
webkit-user-select: none;
user-select: none;
input {
appearance: none;
border: none;
}
> * {
grid-column-start: 1;
grid-row-start: 1;
@media (prefers-reduced-motion: no-preference) {
transition-property: transform, rotate, opacity;
transition-duration: 0.2s;
transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
}
}
.swap-on, .swap-indeterminate, input:indeterminate ~ .swap-on {
opacity: 0%;
}
input:is(:checked, :indeterminate) {
& ~ .swap-off {
opacity: 0%;
}
}
input:checked ~ .swap-on, input:indeterminate ~ .swap-indeterminate {
opacity: 100%;
backface-visibility: visible;
}
}
}
.collapse-title {
@layer daisyui.l1.l2.l3 {
grid-column-start: 1;
@@ -2333,12 +2688,21 @@
}
}
}
.absolute {
position: absolute;
}
.fixed {
position: fixed;
}
.relative {
position: relative;
}
.static {
position: static;
}
.sticky {
position: sticky;
}
.dropdown-right {
@layer daisyui.l1.l2 {
--anchor-h: right;
@@ -2873,6 +3237,62 @@
.my-6 {
margin-block: calc(var(--spacing) * 6);
}
.breadcrumbs {
@layer daisyui.l1.l2.l3 {
max-width: 100%;
overflow-x: auto;
padding-block: calc(0.25rem * 2);
> menu, > ul, > ol {
display: flex;
min-height: min-content;
align-items: center;
white-space: nowrap;
> li {
display: flex;
align-items: center;
> * {
display: flex;
cursor: pointer;
align-items: center;
gap: calc(0.25rem * 2);
&:hover {
@media (hover: hover) {
text-decoration-line: underline;
}
}
&:focus {
--tw-outline-style: none;
outline-style: none;
@media (forced-colors: active) {
outline: 2px solid transparent;
outline-offset: 2px;
}
}
&:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
}
& + *:before {
content: "";
margin-inline-start: calc(0.25rem * 2);
margin-inline-end: calc(0.25rem * 3);
display: block;
height: calc(0.25rem * 1.5);
width: calc(0.25rem * 1.5);
opacity: 40%;
rotate: 45deg;
border-top: 1px solid;
border-right: 1px solid;
background-color: #0000;
}
[dir="rtl"] & + *:before {
rotate: -135deg;
}
}
}
}
}
.label {
@layer daisyui.l1.l2.l3 {
display: inline-flex;
@@ -3101,6 +3521,16 @@
}
}
}
.fieldset {
@layer daisyui.l1.l2.l3 {
display: grid;
gap: calc(0.25rem * 1.5);
padding-block: 0.25rem;
font-size: 0.75rem;
grid-template-columns: 1fr;
grid-auto-rows: max-content;
}
}
.card-actions {
@layer daisyui.l1.l2.l3 {
display: flex;
@@ -3183,9 +3613,15 @@
}
}
}
.\!hidden {
display: none !important;
}
.block {
display: block;
}
.contents {
display: contents;
}
.flex {
display: flex;
}
@@ -3210,6 +3646,12 @@
.table {
display: table;
}
.table-caption {
display: table-caption;
}
.table-cell {
display: table-cell;
}
.h-16 {
height: calc(var(--spacing) * 16);
}
@@ -3708,6 +4150,12 @@
.text-warning {
color: var(--color-warning);
}
.lowercase {
text-transform: lowercase;
}
.uppercase {
text-transform: uppercase;
}
.tabular-nums {
--tw-numeric-spacing: tabular-nums;
font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
@@ -3755,6 +4203,17 @@
outline-style: var(--tw-outline-style);
outline-width: 1px;
}
.blur {
--tw-blur: blur(8px);
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,);
}
.invert {
--tw-invert: invert(100%);
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,);
}
.\!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,) !important;
}
.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,);
}

File diff suppressed because one or more lines are too long