From 09df5d25b8edfb7f8ad9ad414c4b25ada2ef3492 Mon Sep 17 00:00:00 2001 From: Bernard Siebens Date: Fri, 21 Aug 2026 10:27:13 +0200 Subject: [PATCH] Scaffold the mobile member app: PWA shell, push, and app-shell tokens New `mobile` Django app mounted at /app/ -- the installed PWA for Member mode (M1-M7, see design_handoff_rosterchief_platform/README.md). Coach mode (C1-C6) is a later phase and has no routes yet. Foundation pieces: - assets/mobile.css: Tailwind v4 theme reusing management.css's design tokens (same per-club --tenant-* theming pattern), plus the ice/coach accent and mobile's 14px card radius. - PushSubscription model + pywebpush-based sender (mobile/services/push.py), wired to notifications.Notification via a post_save signal so the existing notification system gains a push channel without knowing about PWAs itself. - Per-club manifest.webmanifest + service worker (served at /app/sw.js) + a server-rendered fallback home-screen icon (club initials on secondary_color) for clubs without an uploaded logo -- confirmed with the user as the fallback, never a generic RosterChief mark. - App shell (base.html): navy header, person switcher (every child a signed-in parent manages, plus "Me"), bottom tab bar, safe-area insets. Vendored htmx + Alpine for the screens built on top of it. - Placeholder views/routes for all seven M1-M7 screens so the shell is fully wired end-to-end before each screen is built out individually. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9 --- assets/mobile.css | 419 ++++++++++++++++++++++ mobile/__init__.py | 0 mobile/admin.py | 11 + mobile/apps.py | 8 + mobile/migrations/0001_initial.py | 37 ++ mobile/migrations/__init__.py | 0 mobile/mixins.py | 69 ++++ mobile/models.py | 38 ++ mobile/services/__init__.py | 0 mobile/services/icons.py | 36 ++ mobile/services/push.py | 48 +++ mobile/signals.py | 13 + mobile/templates/mobile/_placeholder.html | 9 + mobile/templates/mobile/base.html | 122 +++++++ mobile/templates/mobile/sw.js | 58 +++ mobile/tests.py | 149 ++++++++ mobile/urls.py | 21 ++ mobile/views.py | 156 ++++++++ package-lock.json | 34 +- package.json | 12 +- pyproject.toml | 3 +- rosterchief/settings.py | 14 +- rosterchief/urls.py | 1 + static/css/app.css | 22 ++ static/css/mobile.css | 2 + static/js/alpine.js | 21 ++ static/js/htmx.js | 1 + static/js/mobile-app.js | 55 +++ uv.lock | 304 ++++++++++++++++ 29 files changed, 1657 insertions(+), 6 deletions(-) create mode 100644 assets/mobile.css create mode 100644 mobile/__init__.py create mode 100644 mobile/admin.py create mode 100644 mobile/apps.py create mode 100644 mobile/migrations/0001_initial.py create mode 100644 mobile/migrations/__init__.py create mode 100644 mobile/mixins.py create mode 100644 mobile/models.py create mode 100644 mobile/services/__init__.py create mode 100644 mobile/services/icons.py create mode 100644 mobile/services/push.py create mode 100644 mobile/signals.py create mode 100644 mobile/templates/mobile/_placeholder.html create mode 100644 mobile/templates/mobile/base.html create mode 100644 mobile/templates/mobile/sw.js create mode 100644 mobile/tests.py create mode 100644 mobile/urls.py create mode 100644 mobile/views.py create mode 100644 static/css/mobile.css create mode 100644 static/js/alpine.js create mode 100644 static/js/htmx.js create mode 100644 static/js/mobile-app.js diff --git a/assets/mobile.css b/assets/mobile.css new file mode 100644 index 0000000..bb4c1a4 --- /dev/null +++ b/assets/mobile.css @@ -0,0 +1,419 @@ +/* RosterChief member app -- the installed PWA (see design_handoff_rosterchief_platform/ + * README.md, "Member mode (mobile, 402x874 reference)"). Its own stylesheet, not shared + * with management.css/controlpanel.css/app.css: the component vocabulary (app header, + * role switcher, bottom tab bar, hero card) has nothing in common with the desktop + * surfaces' daisyUI-shaped one. + * + * Design tokens copied from management.css's own @theme (built against the same design + * doc) rather than re-derived -- same colours, same self-hosted fonts (shared + * static/fonts/ directory, one download for every stylesheet). Two differences: + * - --radius-box is 14px here (mobile cards), not management's 0.75rem/12px (desktop + * "rounded-xl" cards) -- see the design doc's own radius table. + * - --color-ice/--color-ice-ink get their own names (management just calls the same + * #14B8E8 hex "info", since it has no coach-mode concept) -- the design doc is + * explicit that Coach mode uses ice "regardless of club", so it needs a name that + * survives club theming rather than colliding with a generic status colour. + * + * Club theming (design doc, "Club theming"): mobile/templates/mobile/base.html sets + * --tenant-club/--tenant-club-content from Club.secondary_color (app header active tab, + * primary buttons, hero eyebrow) and --tenant-navy from Club.primary_color where the + * club overrides the platform navy header -- same --tenant-* custom-property pattern + * management/templates/management/base.html already uses, so both surfaces stay + * consistent with each other and with the identity page's own live preview. + */ +@import "tailwindcss"; + +@source "../mobile"; + +@theme { + --font-display: "Barlow Condensed", ui-sans-serif, system-ui, sans-serif; + --font-sans: "Barlow", ui-sans-serif, system-ui, sans-serif; + --font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, monospace; + + --color-ink: #0b1220; + --color-navy: var(--tenant-navy, #101e36); + --color-steel: #1b2b47; + --color-hairline: #1e2b42; + --color-paper: #f4f5f7; + --color-line: #e3e6eb; + --color-rule: #eef0f3; + --color-edge: #d6dae1; + --color-stroke: #c9cfd8; + --color-muted: #6c7787; + --color-dim: #8b95a4; + --color-slate: #3a4658; + --color-on-dark: #93a0b4; + --color-on-dark-dim: #7c8aa0; + --color-on-dark-faint: #5c6b85; + + --color-club: var(--tenant-club, #e4002b); + --color-club-dark: var(--tenant-club-dark, #b00021); + --color-club-content: var(--tenant-club-content, #ffffff); + + --color-ok: #14a05a; + --color-ok-bg: #e6f6ee; + --color-ok-border: #bfe7d3; + --color-ok-text: #0c7a43; + + --color-warn: #f0a22e; + --color-warn-bg: #fff5e4; + --color-warn-border: #f6e0b8; + --color-warn-text: #9a6410; + --color-warn-deep: #7a4e08; + + --color-danger-bg: #fdecec; + --color-danger-border: #f5c9ce; + + --color-info: #14b8e8; + --color-info-bg: #eaf7fc; + --color-info-border: #c3e7f4; + --color-info-text: #0a6f91; + + /* Coach-mode accent -- never club-themed, see the file banner. */ + --color-ice: #14b8e8; + --color-ice-ink: #04212c; + + --color-row-sel: #fff7f8; + --color-row-focus: #f4f9ff; + --color-row-warn: #fffdf6; + --color-subhead: #f8f9fa; + + --radius-box: 0.875rem; /* 14px -- mobile card radius, see the file banner */ +} + +/* --- fonts: identical set to management.css/controlpanel.css, same self-hosted files. --- */ + +@font-face { + font-family: "Barlow"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url("../fonts/barlow-latin-400-normal.woff2") format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: "Barlow"; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url("../fonts/barlow-latin-500-normal.woff2") format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: "Barlow"; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url("../fonts/barlow-latin-600-normal.woff2") format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: "Barlow"; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url("../fonts/barlow-latin-700-normal.woff2") format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: "Barlow Condensed"; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url("../fonts/barlow-condensed-latin-600-normal.woff2") format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: "Barlow Condensed"; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url("../fonts/barlow-condensed-latin-700-normal.woff2") format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: "Barlow Condensed"; + font-style: normal; + font-weight: 800; + font-display: swap; + src: url("../fonts/barlow-condensed-latin-800-normal.woff2") format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: "IBM Plex Mono"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url("../fonts/ibm-plex-mono-latin-400-normal.woff2") format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: "IBM Plex Mono"; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url("../fonts/ibm-plex-mono-latin-500-normal.woff2") format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@layer base { + body { + background: var(--color-paper); + color: var(--color-slate); + font-family: var(--font-sans); + /* env(safe-area-inset-*) support -- design doc: "The app content starts below a + 54px status-bar inset and ends above a 26-30px home-indicator inset -- preserve + those safe areas via env(safe-area-inset-*), not fixed padding." */ + padding-top: env(safe-area-inset-top); + padding-bottom: env(safe-area-inset-bottom); + } +} + +@layer components { + /* --- App shell -------------------------------------------------------------- */ + + .app-header { + padding: max(env(safe-area-inset-top), 14px) 16px 14px; + background: var(--color-navy); + color: #fff; + flex-shrink: 0; + } + + .app-crest { + width: 30px; + height: 32px; + background: var(--color-club); + clip-path: polygon(50% 0, 100% 18%, 100% 62%, 50% 100%, 0 62%, 0 18%); + flex-shrink: 0; + object-fit: contain; + } + + /* Role switcher -- design doc "Component" table: full-width segmented pill, + bg-steel track, active segment bg-white text-ink (member mode). Only rendered + when the account holds >=1 staff role -- see mobile/templates/mobile/base.html. */ + .role-switcher { + display: flex; + background: var(--color-steel); + border-radius: 999px; + padding: 4px; + gap: 4px; + margin-top: 10px; + } + + .role-switcher-item { + flex: 1; + height: 36px; + border-radius: 999px; + display: flex; + align-items: center; + justify-content: center; + font-family: var(--font-display); + font-weight: 800; + font-size: 0.875rem; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--color-on-dark); + } + + .role-switcher-item-active { + background: #fff; + color: var(--color-ink); + } + + /* Bottom tab bar -- design doc: "bg-white border-t border-line pt-2 pb-[26px] + (member)... four equal items, 48px tall". pb uses the safe-area inset instead of + the doc's fixed 26px, which was standing in for it. */ + .tab-bar { + flex-shrink: 0; + background: #fff; + border-top: 1px solid var(--color-line); + padding: 8px 8px max(env(safe-area-inset-bottom), 26px); + display: flex; + } + + .tab-bar-item { + flex: 1; + height: 48px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 3px; + color: var(--color-dim); + } + + .tab-bar-item-active { + color: var(--color-club); + } + + .tab-bar-label { + font-family: var(--font-display); + font-weight: 700; + font-size: 0.75rem; + letter-spacing: 0.08em; + text-transform: uppercase; + } + + /* --- Buttons ------------------------------------------------------------------ */ + + .btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + height: 46px; /* design doc: "height 46px mobile" */ + padding-inline: 1.25rem; + border-radius: 0.5rem; + font-family: var(--font-display); + font-weight: 800; + font-size: 1rem; + letter-spacing: 0.1em; + text-transform: uppercase; + border: none; + cursor: pointer; + white-space: nowrap; + } + + .btn:disabled { + opacity: 0.45; + cursor: not-allowed; + } + + .btn-primary { + background: var(--color-club); + color: #fff; + } + + .btn-dark { + background: var(--color-ink); + color: #fff; + } + + .btn-positive { + background: var(--color-ok); + color: #fff; + } + + .btn-secondary { + background: #fff; + border: 1.5px solid var(--color-stroke); + color: var(--color-ink); + } + + .btn-ghost-on-dark { + background: rgba(255, 255, 255, 0.15); + color: #fff; + } + + /* --- Status pills --------------------------------------------------------------- */ + + .pill { + border-radius: 999px; + padding: 5px 10px; + font-family: var(--font-display); + font-weight: 700; + font-size: 0.75rem; + letter-spacing: 0.1em; + text-transform: uppercase; + display: inline-flex; + align-items: center; + } + + .pill-ok { + background: var(--color-ok-bg); + color: var(--color-ok-text); + border: 1px solid var(--color-ok-border); + } + + .pill-danger { + background: var(--color-danger-bg); + color: var(--color-club-dark); + border: 1px solid var(--color-danger-border); + } + + .pill-warn { + background: var(--color-warn-bg); + color: var(--color-warn-text); + border: 1px solid var(--color-warn-border); + } + + .pill-info { + background: var(--color-info-bg); + color: var(--color-info-text); + border: 1px solid var(--color-info-border); + } + + .pill-neutral { + background: var(--color-rule); + color: #4a5566; + border: 1px solid #dde1e7; + } + + /* --- Person switcher chips (M1, M2, M3 -- "a person switcher on anything per-member") --- */ + + .person-chip { + display: flex; + align-items: center; + gap: 8px; + background: #fff; + border: 1.5px solid var(--color-line); + border-radius: 999px; + padding: 5px 12px 5px 5px; + color: var(--color-muted); + flex-shrink: 0; + } + + .person-chip-active { + border-color: var(--color-ink); + color: var(--color-ink); + } + + .person-chip-avatar { + width: 28px; + height: 28px; + border-radius: 999px; + background: var(--color-stroke); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + font-family: var(--font-display); + font-weight: 800; + font-size: 0.875rem; + flex-shrink: 0; + } + + .person-chip-active .person-chip-avatar { + background: var(--color-club); + } + + .person-chip-label { + font-family: var(--font-display); + font-weight: 700; + font-size: 0.9375rem; + letter-spacing: 0.06em; + text-transform: uppercase; + } + + /* --- Cards ---------------------------------------------------------------------- */ + + .m-card { + background: #fff; + border: 1px solid var(--color-line); + border-radius: var(--radius-box); + } + + .m-card-dark { + background: var(--color-ink); + color: #fff; + border-radius: var(--radius-box); + } +} diff --git a/mobile/__init__.py b/mobile/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mobile/admin.py b/mobile/admin.py new file mode 100644 index 0000000..7b10a60 --- /dev/null +++ b/mobile/admin.py @@ -0,0 +1,11 @@ +from django.contrib import admin + +from .models import PushSubscription + + +@admin.register(PushSubscription) +class PushSubscriptionAdmin(admin.ModelAdmin): + list_display = ["member", "club", "user_agent", "created"] + list_filter = ["club"] + search_fields = ["member__first_name", "member__last_name", "endpoint"] + autocomplete_fields = ["member"] diff --git a/mobile/apps.py b/mobile/apps.py new file mode 100644 index 0000000..c027fe0 --- /dev/null +++ b/mobile/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class MobileConfig(AppConfig): + name = "mobile" + + def ready(self): + from . import signals # noqa: F401 diff --git a/mobile/migrations/0001_initial.py b/mobile/migrations/0001_initial.py new file mode 100644 index 0000000..08499af --- /dev/null +++ b/mobile/migrations/0001_initial.py @@ -0,0 +1,37 @@ +# Generated by Django 6.0.6 on 2026-08-21 08:23 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('club', '0029_duesinvoice'), + ('members', '0006_parentclaim_submitted_by_user'), + ] + + operations = [ + migrations.CreateModel( + name='PushSubscription', + fields=[ + ('created', models.DateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', models.DateTimeField(auto_now=True, verbose_name='modified')), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('endpoint', models.URLField(max_length=500, unique=True, verbose_name='endpoint')), + ('p256dh', models.CharField(max_length=255, verbose_name='p256dh key')), + ('auth', models.CharField(max_length=255, verbose_name='auth key')), + ('user_agent', models.CharField(blank=True, max_length=255, verbose_name='user agent')), + ('club', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='club.club')), + ('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='push_subscriptions', to='members.member', verbose_name='member')), + ], + options={ + 'verbose_name': 'push subscription', + 'verbose_name_plural': 'push subscriptions', + 'ordering': ['-created'], + }, + ), + ] diff --git a/mobile/migrations/__init__.py b/mobile/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mobile/mixins.py b/mobile/mixins.py new file mode 100644 index 0000000..3be406f --- /dev/null +++ b/mobile/mixins.py @@ -0,0 +1,69 @@ +"""Shared scaffolding for every Member-mode screen (design_handoff_rosterchief_platform/ +README.md: "there is no parent app... every per-member screen carries a person +switcher at the top" + "the [Coach/Member] switcher only renders for an account +holding >=1 staff role"). One mixin so M1-M7's views (and the subagents building +them) don't each re-derive this. +""" + +from django.conf import settings + +from club.services.access import current_season, has_management_access +from members.models import FamilyMembership, Member +from members.views import ClubScopedPublicMixin +from notifications.models import Notification + + +class PersonScopeMixin(ClubScopedPublicMixin): + """Resolves the signed-in account's own Member record plus every child + they're a parent/guardian of *in this club* (mirrors members.views.MyFamilyView's + own query -- kept separate rather than imported from there, since that view + is public/unauthenticated-reachable and this one is always behind login). + + ``?as=`` re-scopes the current screen to one managed person, + same as the design doc's horizontally-scrolling chip row -- it re-scopes in + place rather than navigating. Falls back to the account's own Member, then + to the first managed child (e.g. a parent with no Member record of their own). + """ + + def dispatch(self, request, *args, **kwargs): + self.me = Member.objects.filter(user=request.user).first() if request.user.is_authenticated else None + self.managed_people = self._managed_people(request) + self.scope_person = self._resolve_scope_person(request) + return super().dispatch(request, *args, **kwargs) + + def _managed_people(self, request): + if self.me is None: + return [] + children = list( + Member.objects.filter( + family_memberships__role=FamilyMembership.FamilyRole.CHILD, + family_memberships__family__memberships__member=self.me, + family_memberships__family__memberships__role__in=[FamilyMembership.FamilyRole.PARENT, FamilyMembership.FamilyRole.GUARDIAN], + member_of__club=request.club, + ).distinct() + ) + return [self.me, *children] + + def _resolve_scope_person(self, request): + requested_id = request.GET.get("as") + if requested_id: + for person in self.managed_people: + if str(person.pk) == requested_id: + return person + return self.managed_people[0] if self.managed_people else None + + def get_context_data(self, **kwargs): + unread_notification_count = 0 + if self.managed_people: + unread_notification_count = Notification.objects.filter(club=self.request.club, member__in=self.managed_people, read_at__isnull=True).count() + + return super().get_context_data( + me=self.me, + managed_people=self.managed_people, + scope_person=self.scope_person, + has_staff_access=self.me is not None and has_management_access(self.request.user, self.request.club), + unread_notification_count=unread_notification_count, + season=current_season(self.request.club), + vapid_public_key=settings.VAPID_PUBLIC_KEY, + **kwargs, + ) diff --git a/mobile/models.py b/mobile/models.py new file mode 100644 index 0000000..75213d0 --- /dev/null +++ b/mobile/models.py @@ -0,0 +1,38 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from members.models import Member +from rosterchief.base import ClubScopedModel, validate_club_scope + + +class PushSubscription(ClubScopedModel): + """One browser/device's Web Push registration for one member -- created by + the subscribe flow in mobile/static/mobile/app.js, sent to whenever + mobile.signals pushes a new notifications.Notification for that member. + + `endpoint` (the URL the browser's own push service gave it) is globally + unique by construction -- a browser only ever has one push registration + per origin -- so it doubles as the natural "already subscribed on this + device" key: re-subscribing replaces the row rather than creating a + second one for the same browser (see mobile.views.PushSubscribeView). + """ + + member = models.ForeignKey(Member, on_delete=models.CASCADE, related_name="push_subscriptions", verbose_name=_("member")) + endpoint = models.URLField(_("endpoint"), max_length=500, unique=True) + p256dh = models.CharField(_("p256dh key"), max_length=255) + auth = models.CharField(_("auth key"), max_length=255) + user_agent = models.CharField(_("user agent"), max_length=255, blank=True) + + class Meta: + verbose_name = _("push subscription") + verbose_name_plural = _("push subscriptions") + ordering = ["-created"] + + def __str__(self): + return f"{self.member} — {self.user_agent or self.endpoint[:40]}" + + def clean(self): + validate_club_scope(self, self.club_id, member_fields=("member",)) + + def as_subscription_info(self) -> dict: + return {"endpoint": self.endpoint, "keys": {"p256dh": self.p256dh, "auth": self.auth}} diff --git a/mobile/services/__init__.py b/mobile/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mobile/services/icons.py b/mobile/services/icons.py new file mode 100644 index 0000000..3aca2e8 --- /dev/null +++ b/mobile/services/icons.py @@ -0,0 +1,36 @@ +"""Server-rendered fallback PWA icon: a club's initials on its own +secondary_color -- confirmed with the user as the fallback for a club that +hasn't uploaded a logo, rather than a shared RosterChief mark (Club.initials' +own docstring: "Never the RosterChief mark -- that would pass our branding +off as the club's own", same reasoning applied to the home-screen icon). + +Rendered on request by mobile.views.AppIconView, not stored -- a club's +colours/initials change rarely enough that regenerating a small PNG per +request is cheaper than adding cache invalidation for it. +""" + +import io + +from PIL import Image, ImageDraw, ImageFont + +_DEFAULT_BACKGROUND = "#e4002b" +_DEFAULT_FOREGROUND = "#ffffff" + + +def render_fallback_icon(club, size: int = 512) -> bytes: + background = club.secondary_color or _DEFAULT_BACKGROUND + foreground = club.secondary_content_color or _DEFAULT_FOREGROUND + + image = Image.new("RGB", (size, size), background) + draw = ImageDraw.Draw(image) + + initials = club.initials or "RC" + font = ImageFont.load_default(size=int(size * 0.42)) + + left, top, right, bottom = draw.textbbox((0, 0), initials, font=font) + text_width, text_height = right - left, bottom - top + draw.text(((size - text_width) / 2 - left, (size - text_height) / 2 - top), initials, font=font, fill=foreground) + + buffer = io.BytesIO() + image.save(buffer, format="PNG") + return buffer.getvalue() diff --git a/mobile/services/push.py b/mobile/services/push.py new file mode 100644 index 0000000..920e6ea --- /dev/null +++ b/mobile/services/push.py @@ -0,0 +1,48 @@ +"""Web Push delivery -- the push channel for notifications.Notification. + +Wired in from mobile.signals (a post_save on Notification), deliberately kept +out of the notifications app itself, which stays channel-agnostic (see that +app's models.py docstring: "the whole point is reusing this for other kinds +of activity later"). A backward dependency the other way -- notifications +importing mobile -- would be the wrong direction: notifications has no +reason to know a PWA exists. +""" + +import json +import logging + +from django.conf import settings +from pywebpush import WebPushException, webpush + +from .. import models + +logger = logging.getLogger(__name__) + + +def send_push_to_member(member, *, title: str, body: str, url: str = "/app/") -> None: + if not settings.VAPID_PRIVATE_KEY: + # Dev/no-config default -- see the settings.py comment next to VAPID_PRIVATE_KEY. + return + + payload = json.dumps({"title": title, "body": body, "url": url}) + for subscription in models.PushSubscription.objects.filter(member=member): + try: + webpush( + subscription_info=subscription.as_subscription_info(), + data=payload, + vapid_private_key=settings.VAPID_PRIVATE_KEY, + vapid_claims={"sub": f"mailto:{settings.VAPID_ADMIN_EMAIL}"}, + ) + except WebPushException as exc: + status = exc.response.status_code if exc.response is not None else None + if status in (404, 410): + # The browser's push service says this registration is gone for good -- + # not a transient failure, so keeping it around would only mean retrying + # a subscription that will never accept a push again. + subscription.delete() + else: + logger.warning("Push send failed for %s: %s", member, exc) + except OSError as exc: + # Never fatal -- same reasoning as every other branded send in this app + # (see e.g. notifications.services._send_email). + logger.warning("Push send failed for %s: %s", member, exc) diff --git a/mobile/signals.py b/mobile/signals.py new file mode 100644 index 0000000..ecafe08 --- /dev/null +++ b/mobile/signals.py @@ -0,0 +1,13 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver + +from notifications.models import Notification + +from .services.push import send_push_to_member + + +@receiver(post_save, sender=Notification) +def push_new_notification(sender, instance, created, **kwargs): + if not created: + return + send_push_to_member(instance.member, title=instance.title, body=instance.body) diff --git a/mobile/templates/mobile/_placeholder.html b/mobile/templates/mobile/_placeholder.html new file mode 100644 index 0000000..c9e9c84 --- /dev/null +++ b/mobile/templates/mobile/_placeholder.html @@ -0,0 +1,9 @@ +{% extends "mobile/base.html" %} +{% load i18n %} + +{% block content %} +
+

{{ screen_title }}

+

{% trans "This screen is coming soon." %}

+
+{% endblock content %} diff --git a/mobile/templates/mobile/base.html b/mobile/templates/mobile/base.html new file mode 100644 index 0000000..7203342 --- /dev/null +++ b/mobile/templates/mobile/base.html @@ -0,0 +1,122 @@ +{% load static i18n %} + +{% comment %} + App shell for Member mode (M1-M7) -- design_handoff_rosterchief_platform/README.md. + Standalone, like management/base.html and controlpanel/base.html: its own + stylesheet (assets/mobile.css), no daisyUI, no shared nav. Mobile-first, + viewport-fit=cover + env(safe-area-inset-*) throughout (see mobile.css's own + file banner) rather than the iOS bezel in the design canvas, which is + presentation-only. + + The Coach/Member role switcher described in the design doc is deliberately + NOT rendered yet: it would switch into a mode with no screens built (Coach + mode is an explicitly separate, later phase). Re-add it here once C1-C6 ship. +{% endcomment %} + + + + + + + + + + + {% block title %}{{ screen_title }} · {{ club.name }}{% endblock title %} + + + + + + + {% if club.secondary_color %} + + {% endif %} + {% if club.primary_color %} + + {% endif %} + + + + + {% block extra_head %}{% endblock extra_head %} + + + +
+ + + {% if managed_people|length > 1 %} + + {% endif %} +
+ +
+
+ {% if messages %} +
+ {% for message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + + {% block content %}{% endblock content %} +
+
+ + + + + {% block extra_body %}{% endblock extra_body %} + + diff --git a/mobile/templates/mobile/sw.js b/mobile/templates/mobile/sw.js new file mode 100644 index 0000000..3d09ef8 --- /dev/null +++ b/mobile/templates/mobile/sw.js @@ -0,0 +1,58 @@ +// RosterChief member app -- service worker. Served at /app/sw.js by +// mobile.views.ServiceWorkerView (not /static/) so its default scope is /app/. +// +// v1 scope: push notifications + a minimal offline-friendly cache for the app +// shell's own static assets. No page-content caching -- every screen here is +// live club data (RSVP status, dues, news), and serving a stale copy while +// offline would be actively misleading. The offline attendance queue +// described in the design doc is Coach mode, a later phase. + +const SHELL_CACHE = "rosterchief-shell-v1"; +const SHELL_ASSETS = ["/static/css/mobile.css", "/static/js/htmx.js", "/static/js/alpine.js", "/static/js/mobile-app.js"]; + +self.addEventListener("install", (event) => { + event.waitUntil(caches.open(SHELL_CACHE).then((cache) => cache.addAll(SHELL_ASSETS))); + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches.keys().then((keys) => Promise.all(keys.filter((key) => key !== SHELL_CACHE).map((key) => caches.delete(key)))), + ); + self.clients.claim(); +}); + +self.addEventListener("fetch", (event) => { + if (event.request.method !== "GET" || !SHELL_ASSETS.some((asset) => event.request.url.endsWith(asset))) return; + event.respondWith(caches.match(event.request).then((cached) => cached || fetch(event.request))); +}); + +self.addEventListener("push", (event) => { + let payload = { title: "RosterChief", body: "" }; + if (event.data) { + try { + payload = event.data.json(); + } catch { + payload.body = event.data.text(); + } + } + event.waitUntil( + self.registration.showNotification(payload.title, { + body: payload.body, + data: { url: payload.url || "/app/" }, + }), + ); +}); + +self.addEventListener("notificationclick", (event) => { + event.notification.close(); + const url = event.notification.data && event.notification.data.url ? event.notification.data.url : "/app/"; + event.waitUntil( + self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clients) => { + for (const client of clients) { + if (client.url === url && "focus" in client) return client.focus(); + } + return self.clients.openWindow(url); + }), + ); +}); diff --git a/mobile/tests.py b/mobile/tests.py new file mode 100644 index 0000000..d7c76d0 --- /dev/null +++ b/mobile/tests.py @@ -0,0 +1,149 @@ +import datetime + +from django.contrib.auth import get_user_model +from django.test import TestCase, override_settings +from django.urls import reverse + +from club.models import Club, ClubMembership, Season +from members.models import Family, FamilyMembership, Member + +from .models import PushSubscription +from .services.icons import render_fallback_icon + +User = get_user_model() + + +def make_club(**kwargs): + return Club.objects.create(name="Ajax United", slug="ajax-united", secondary_color="#e4002b", **kwargs) + + +@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"]) +class MobileShellTests(TestCase): + """The app shell (base.html) plus the PWA plumbing every screen sits on.""" + + @classmethod + def setUpTestData(cls): + cls.club = make_club() + Season.objects.create(club=cls.club, start_date=datetime.date(2025, 8, 1), end_date=datetime.date(2026, 5, 31)) + cls.user = User.objects.create_user(email="parent@example.com", password="pw-secret-123") + cls.member = Member.objects.create(first_name="Lars", last_name="Bakker", email="parent@example.com", user=cls.user) + ClubMembership.objects.create(club=cls.club, member=cls.member, season=Season.objects.first()) + + def _get(self, url_name, **kwargs): + return self.client.get(reverse(f"mobile:{url_name}", kwargs=kwargs), HTTP_HOST="ajax-united.rosterchief.app") + + def test_home_requires_login(self): + response = self._get("home") + self.assertEqual(response.status_code, 302) + + def test_home_renders_the_app_shell_when_signed_in(self): + self.client.force_login(self.user) + + response = self._get("home") + + self.assertEqual(response.status_code, 200) + self.assertContains(response, self.club.name) + self.assertContains(response, 'rel="manifest"') + + def test_manifest_names_the_club_and_points_at_its_icon(self): + response = self._get("manifest") + + self.assertEqual(response["Content-Type"], "application/manifest+json") + self.assertEqual(response.json()["name"], self.club.name) + self.assertIn("icon", response.json()["icons"][0]["src"]) + + def test_icon_falls_back_to_a_rendered_png_without_a_logo(self): + response = self._get("icon", size=192) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response["Content-Type"], "image/png") + + def test_service_worker_is_served_at_the_app_scope(self): + response = self.client.get("/app/sw.js", HTTP_HOST="ajax-united.rosterchief.app") + + self.assertEqual(response.status_code, 200) + self.assertIn("javascript", response["Content-Type"]) + self.assertIn("addEventListener", response.content.decode()) + + def test_person_switcher_lists_managed_children_alongside_me(self): + family = Family.objects.create(name="Bakker") + FamilyMembership.objects.create(family=family, member=self.member, role=FamilyMembership.FamilyRole.PARENT) + child = Member.objects.create(first_name="Noor", last_name="Bakker") + FamilyMembership.objects.create(family=family, member=child, role=FamilyMembership.FamilyRole.CHILD) + ClubMembership.objects.create(club=self.club, member=child, season=Season.objects.first()) + self.client.force_login(self.user) + + response = self._get("home") + + self.assertContains(response, "Noor") + + def test_scope_person_switches_via_the_as_query_param(self): + family = Family.objects.create(name="Bakker") + FamilyMembership.objects.create(family=family, member=self.member, role=FamilyMembership.FamilyRole.PARENT) + child = Member.objects.create(first_name="Noor", last_name="Bakker") + FamilyMembership.objects.create(family=family, member=child, role=FamilyMembership.FamilyRole.CHILD) + ClubMembership.objects.create(club=self.club, member=child, season=Season.objects.first()) + self.client.force_login(self.user) + + response = self.client.get(reverse("mobile:home") + f"?as={child.pk}", HTTP_HOST="ajax-united.rosterchief.app") + + self.assertEqual(response.context["scope_person"], child) + + +@override_settings( + VAPID_PRIVATE_KEY="", + ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", + ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"], +) +class PushSubscribeViewTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.club = make_club() + cls.user = User.objects.create_user(email="parent@example.com", password="pw-secret-123") + cls.member = Member.objects.create(first_name="Lars", last_name="Bakker", user=cls.user) + + def test_subscribing_creates_a_push_subscription_row(self): + self.client.force_login(self.user) + + response = self.client.post( + reverse("mobile:push_subscribe"), + data={"endpoint": "https://push.example.com/abc", "keys": {"p256dh": "key1", "auth": "key2"}}, + content_type="application/json", + HTTP_HOST="ajax-united.rosterchief.app", + ) + + self.assertEqual(response.status_code, 200) + self.assertTrue(PushSubscription.objects.filter(member=self.member, endpoint="https://push.example.com/abc").exists()) + + def test_resubscribing_the_same_endpoint_updates_rather_than_duplicates(self): + self.client.force_login(self.user) + PushSubscription.objects.create(club=self.club, member=self.member, endpoint="https://push.example.com/abc", p256dh="old", auth="old") + + self.client.post( + reverse("mobile:push_subscribe"), + data={"endpoint": "https://push.example.com/abc", "keys": {"p256dh": "new", "auth": "new"}}, + content_type="application/json", + HTTP_HOST="ajax-united.rosterchief.app", + ) + + self.assertEqual(PushSubscription.objects.filter(endpoint="https://push.example.com/abc").count(), 1) + self.assertEqual(PushSubscription.objects.get(endpoint="https://push.example.com/abc").p256dh, "new") + + def test_anonymous_cannot_subscribe(self): + response = self.client.post( + reverse("mobile:push_subscribe"), + data={"endpoint": "https://push.example.com/abc", "keys": {"p256dh": "key1", "auth": "key2"}}, + content_type="application/json", + HTTP_HOST="ajax-united.rosterchief.app", + ) + + self.assertEqual(response.status_code, 302) + + +class RenderFallbackIconTests(TestCase): + def test_renders_a_png_without_a_logo(self): + club = make_club() + + png_bytes = render_fallback_icon(club, size=64) + + self.assertTrue(png_bytes.startswith(b"\x89PNG")) diff --git a/mobile/urls.py b/mobile/urls.py new file mode 100644 index 0000000..1f767b7 --- /dev/null +++ b/mobile/urls.py @@ -0,0 +1,21 @@ +from django.urls import path + +from . import views + +app_name = "mobile" + +urlpatterns = [ + # PWA plumbing. + path("manifest.webmanifest", views.ManifestView.as_view(), name="manifest"), + path("sw.js", views.ServiceWorkerView.as_view(), name="service_worker"), + path("icon/.png", views.AppIconView.as_view(), name="icon"), + path("push/subscribe/", views.PushSubscribeView.as_view(), name="push_subscribe"), + # Member mode (M1-M7). + path("", views.HomeView.as_view(), name="home"), + path("calendar/", views.CalendarView.as_view(), name="calendar"), + path("events//", views.EventDetailView.as_view(), name="event_detail"), + path("news//", views.NewsDetailView.as_view(), name="news_detail"), + path("me/", views.MeView.as_view(), name="me"), + path("me//edit/", views.EditProfileView.as_view(), name="edit_profile"), + path("notifications/", views.NotificationsView.as_view(), name="notifications"), +] diff --git a/mobile/views.py b/mobile/views.py new file mode 100644 index 0000000..000845f --- /dev/null +++ b/mobile/views.py @@ -0,0 +1,156 @@ +"""Member-mode screens (M1-M7) plus the PWA plumbing (manifest, service worker, +icon, push subscribe) they all sit on top of. Coach mode (C1-C6) is a later +phase -- see design_handoff_rosterchief_platform/README.md -- and has no +routes here yet. + +The M1-M7 views below are placeholders: each renders a "coming soon" card +inside the real app shell (base.html), at its final URL name, so the shell +(header, role switcher, tab bar, person switcher) can be verified end-to-end +before every screen is built out one at a time. +""" + +import json + +from django.contrib.auth.mixins import LoginRequiredMixin +from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseRedirect, JsonResponse +from django.template.loader import render_to_string +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ +from django.views import View +from django.views.generic import TemplateView + +from members.models import Member +from members.views import ClubScopedPublicMixin + +from .mixins import PersonScopeMixin +from .models import PushSubscription +from .services.icons import render_fallback_icon + + +class ManifestView(ClubScopedPublicMixin, View): + """Per-club web-app manifest -- the club's own logo (or its server-rendered + initials, see services/icons.py) becomes the home-screen icon, never a + generic RosterChief mark.""" + + def get(self, request): + club = request.club + manifest = { + "name": club.name, + "short_name": club.name[:24], + "start_url": "/app/", + "scope": "/app/", + "display": "standalone", + "background_color": "#101e36", + "theme_color": club.primary_color or "#101e36", + "icons": [ + {"src": reverse("mobile:icon", kwargs={"size": 192}), "sizes": "192x192", "purpose": "any"}, + {"src": reverse("mobile:icon", kwargs={"size": 512}), "sizes": "512x512", "purpose": "any"}, + ], + } + return JsonResponse(manifest, content_type="application/manifest+json") + + +class AppIconView(ClubScopedPublicMixin, View): + def get(self, request, size): + club = request.club + if club.logo: + return HttpResponseRedirect(club.logo.url) + return HttpResponse(render_fallback_icon(club, size=size), content_type="image/png") + + +class ServiceWorkerView(View): + """Served literally at /app/sw.js (mobile/urls.py), not under /static/ -- + a service worker's default scope is everything below the path it's served + from, so this is what makes the worker's scope /app/ without an explicit + Service-Worker-Allowed header.""" + + def get(self, request): + return HttpResponse(render_to_string("mobile/sw.js", {}), content_type="application/javascript") + + +class PushSubscribeView(LoginRequiredMixin, ClubScopedPublicMixin, View): + """Called by mobile/static/mobile/app.js once the browser hands back a + PushSubscription. Keyed on endpoint (globally unique per browser + registration, see PushSubscription's own docstring): re-subscribing the + same browser updates its row instead of creating a duplicate.""" + + def post(self, request): + member = Member.objects.filter(user=request.user).first() + if member is None: + return HttpResponseBadRequest("No member record for this account.") + + try: + payload = json.loads(request.body) + endpoint = payload["endpoint"] + p256dh = payload["keys"]["p256dh"] + auth = payload["keys"]["auth"] + except (KeyError, TypeError, ValueError): + return HttpResponseBadRequest("Malformed subscription payload.") + + PushSubscription.objects.update_or_create( + endpoint=endpoint, + defaults={ + "club": request.club, + "member": member, + "p256dh": p256dh, + "auth": auth, + "user_agent": request.META.get("HTTP_USER_AGENT", "")[:255], + }, + ) + return JsonResponse({"status": "ok"}) + + def delete(self, request): + try: + endpoint = json.loads(request.body)["endpoint"] + except (KeyError, TypeError, ValueError): + return HttpResponseBadRequest("Malformed unsubscribe payload.") + PushSubscription.objects.filter(endpoint=endpoint).delete() + return JsonResponse({"status": "ok"}) + + +class _PlaceholderScreen(PersonScopeMixin, LoginRequiredMixin, TemplateView): + """Stand-in for an M-screen not built yet. Each subclass below is replaced + entirely -- view and template -- when its screen is built; only the URL + name/path in mobile/urls.py needs to stay put.""" + + template_name = "mobile/_placeholder.html" + screen_title = "" + active_tab = "" + + def get_context_data(self, **kwargs): + return super().get_context_data(screen_title=self.screen_title, active_tab=self.active_tab, **kwargs) + + +class HomeView(_PlaceholderScreen): + screen_title = _("Home") + active_tab = "home" + + +class CalendarView(_PlaceholderScreen): + screen_title = _("Calendar") + active_tab = "calendar" + + +class EventDetailView(_PlaceholderScreen): + screen_title = _("Event") + active_tab = "calendar" + + +class NewsDetailView(_PlaceholderScreen): + screen_title = _("News") + active_tab = "news" + + +class MeView(_PlaceholderScreen): + screen_title = _("Me") + active_tab = "me" + + +class EditProfileView(_PlaceholderScreen): + screen_title = _("Edit info") + active_tab = "me" + + +class NotificationsView(_PlaceholderScreen): + screen_title = _("Notifications") + active_tab = "me" diff --git a/package-lock.json b/package-lock.json index 7b9d922..a5ced4f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,9 @@ "": { "name": "rosterchief", "dependencies": { - "chart.js": "^4.5.1" + "alpinejs": "^3.15.0", + "chart.js": "^4.5.1", + "htmx.org": "^2.0.4" }, "devDependencies": { "@fontsource-variable/jetbrains-mono": "^5.2.8", @@ -762,6 +764,30 @@ "node": ">= 20" } }, + "node_modules/@vue/reactivity": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.41.tgz", + "integrity": "sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.41.tgz", + "integrity": "sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==", + "license": "MIT" + }, + "node_modules/alpinejs": { + "version": "3.16.2", + "resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.16.2.tgz", + "integrity": "sha512-6kbqqrRK7iGPYx5upTfqc8Tcl1xhpcurLCxTdn4/ym2Kb68+cYj+2I5Zji53vUIPf7zj3C1DoIvrDkgrNDCXdA==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "~3.5.40" + } + }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -844,6 +870,12 @@ "dev": true, "license": "ISC" }, + "node_modules/htmx.org": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.10.tgz", + "integrity": "sha512-kdeJe7ZVwaS6QMz/ebBIVtZdpwen6L0OQ5GOhPV9MKBb196TCZeZu4yA7ZIQsaLKv7EpXz+So7KSXNuHXhj7Cw==", + "license": "0BSD" + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", diff --git a/package.json b/package.json index a7ee1ae..1cb2209 100644 --- a/package.json +++ b/package.json @@ -3,14 +3,18 @@ "private": true, "type": "module", "scripts": { - "build": "npm run build:app && npm run build:controlpanel && npm run build:management", + "build": "npm run build:app && npm run build:controlpanel && npm run build:management && npm run build:mobile", "build:app": "tailwindcss -i ./assets/app.css -o ./static/css/app.css --minify", "build:controlpanel": "tailwindcss -i ./assets/controlpanel.css -o ./static/css/controlpanel.css --minify", "build:management": "tailwindcss -i ./assets/management.css -o ./static/css/management.css --minify", + "build:mobile": "tailwindcss -i ./assets/mobile.css -o ./static/css/mobile.css --minify", "watch": "tailwindcss -i ./assets/app.css -o ./static/css/app.css --watch", "watch:controlpanel": "tailwindcss -i ./assets/controlpanel.css -o ./static/css/controlpanel.css --watch", "watch:management": "tailwindcss -i ./assets/management.css -o ./static/css/management.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)\"" + "watch:mobile": "tailwindcss -i ./assets/mobile.css -o ./static/css/mobile.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)\"", + "vendor:htmx": "node -e \"const fs=require('fs');const s=fs.readFileSync('node_modules/htmx.org/dist/htmx.min.js','utf8').split('\\n').filter(l=>!l.startsWith('//# sourceMappingURL=')).join('\\n');fs.writeFileSync('static/js/htmx.js',s)\"", + "vendor:alpine": "node -e \"const fs=require('fs');const s=fs.readFileSync('node_modules/alpinejs/dist/cdn.min.js','utf8').split('\\n').filter(l=>!l.startsWith('//# sourceMappingURL=')).join('\\n');fs.writeFileSync('static/js/alpine.js',s)\"" }, "devDependencies": { "@fontsource-variable/jetbrains-mono": "^5.2.8", @@ -25,6 +29,8 @@ "tailwindcss": "^4.1.0" }, "dependencies": { - "chart.js": "^4.5.1" + "alpinejs": "^3.15.0", + "chart.js": "^4.5.1", + "htmx.org": "^2.0.4" } } diff --git a/pyproject.toml b/pyproject.toml index 7b56b4c..67496d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ dependencies = [ "psycopg[binary]>=3.3.4", "python-dateutil>=2.9.0.post0", "python-decouple>=3.8", + "pywebpush>=2.0.3", "requests>=2.34.2", "weasyprint>=69.0", "whitenoise>=6.12.0", @@ -64,7 +65,7 @@ ignore = [ [tool.ruff.lint.isort] known-first-party = [ - "api", "billing", "authentication", "club", "members", "teams", "events", "formbuilder", "shop", "controlpanel", "management", "news", "notifications", "pages", "home", "search", "rosterchief"] + "api", "billing", "authentication", "club", "members", "teams", "events", "formbuilder", "shop", "controlpanel", "management", "mobile", "news", "notifications", "pages", "home", "search", "rosterchief"] [tool.uv.sources] django-lucide = { git = "https://github.com/bsiebens/lucide" } diff --git a/rosterchief/settings.py b/rosterchief/settings.py index a7d058f..9d7f5a0 100644 --- a/rosterchief/settings.py +++ b/rosterchief/settings.py @@ -78,8 +78,11 @@ INSTALLED_APPS = [ "features.apps.FeaturesConfig", "controlpanel.apps.ControlpanelConfig", # Club-facing UI for team managers, coaches and admins -- not controlpanel (platform - # staff managing every club) and not the future parent/player app. + # staff managing every club) and not the mobile member/parent app below. "management.apps.ManagementConfig", + # The installed PWA -- Member mode first (see design_handoff_rosterchief_platform/ + # README.md); Coach mode is a later phase, deliberately not built yet. + "mobile.apps.MobileConfig", # Public, read-only JSON API for a club's own external website -- see api/urls.py. "api.apps.ApiConfig", ] @@ -418,6 +421,15 @@ SERVER_EMAIL = config("DJANGO_SERVER_EMAIL", default=DEFAULT_FROM_EMAIL) #: Where a club admin is told to direct a billing question. Shown in reminder emails. BILLING_CONTACT_EMAIL = config("ROSTERCHIEF_BILLING_CONTACT_EMAIL", default=DEFAULT_FROM_EMAIL) +# Web Push (mobile app, see mobile/services/push.py) -- one VAPID keypair for the whole +# platform, not per club: it identifies the *sender* (RosterChief) to a browser's push +# service, the same way DEFAULT_FROM_EMAIL identifies the sender of an email, and has +# nothing to do with club branding. Generate a pair with `uv run vapid --gen`. Blank in +# dev is fine -- mobile.services.push.send_push_to_member no-ops without a private key. +VAPID_PUBLIC_KEY = config("DJANGO_VAPID_PUBLIC_KEY", default="") +VAPID_PRIVATE_KEY = config("DJANGO_VAPID_PRIVATE_KEY", default="") +VAPID_ADMIN_EMAIL = config("DJANGO_VAPID_ADMIN_EMAIL", default="admin@rosterchief.app") + # Phone numbers (django-phonenumber-field) diff --git a/rosterchief/urls.py b/rosterchief/urls.py index fe06a97..4a09259 100644 --- a/rosterchief/urls.py +++ b/rosterchief/urls.py @@ -34,6 +34,7 @@ urlpatterns = [ path("", include("members.urls")), path("controlpanel/", include("controlpanel.urls")), path("manage/", include("management.urls")), + path("app/", include("mobile.urls")), path("api/v1/", api.urls), # "/" resolves per tenant: a club subdomain lands on the club, the base domain # hands off to the control panel. This is why LOGIN_REDIRECT_URL can stay "/". diff --git a/static/css/app.css b/static/css/app.css index 6c910f6..97ec79e 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -2774,6 +2774,9 @@ .right-0 { right: 0; } + .right-1\.5 { + right: calc(var(--spacing) * 1.5); + } .right-2 { right: calc(var(--spacing) * 2); } @@ -3853,6 +3856,9 @@ .h-1\.5 { height: calc(var(--spacing) * 1.5); } + .h-2 { + height: calc(var(--spacing) * 2); + } .h-4 { height: calc(var(--spacing) * 4); } @@ -3967,6 +3973,9 @@ .w-1\.5 { width: calc(var(--spacing) * 1.5); } + .w-2 { + width: calc(var(--spacing) * 2); + } .w-4 { width: calc(var(--spacing) * 4); } @@ -4741,6 +4750,9 @@ .pt-4 { padding-top: calc(var(--spacing) * 4); } + .pb-0\.5 { + padding-bottom: calc(var(--spacing) * 0.5); + } .pb-2 { padding-bottom: calc(var(--spacing) * 2); } @@ -4858,6 +4870,9 @@ .text-\[17px\] { font-size: 17px; } + .text-\[19px\] { + font-size: 19px; + } .text-\[22px\] { font-size: 22px; } @@ -5260,6 +5275,13 @@ --btn-soft-bg: initial; } } + .btn-secondary { + @layer daisyui.l1.l2 { + --btn-color: var(--color-secondary); + --btn-fg: var(--color-secondary-content); + --btn-soft-bg: initial; + } + } .btn-sm { @layer daisyui.l1.l2 { --fontsize: 0.75rem; diff --git a/static/css/mobile.css b/static/css/mobile.css new file mode 100644 index 0000000..96679d3 --- /dev/null +++ b/static/css/mobile.css @@ -0,0 +1,2 @@ +/*! tailwindcss v4.3.2 | MIT License | https://tailwindcss.com */ +@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{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--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-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial}}}@layer theme{:root,:host{--font-sans:"Barlow", ui-sans-serif, system-ui, sans-serif;--font-mono:"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace;--color-sky-500:oklch(68.5% .169 237.323);--color-gray-500:oklch(55.1% .027 264.364);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-md:28rem;--container-xl:36rem;--container-2xl:42rem;--container-3xl:48rem;--container-6xl:72rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--font-weight-extrabold:800;--tracking-wide:.025em;--tracking-wider:.05em;--leading-relaxed:1.625;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--ease-out:cubic-bezier(0, 0, .2, 1);--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--font-display:"Barlow Condensed", ui-sans-serif, system-ui, sans-serif;--color-ink:#0b1220;--color-navy:var(--tenant-navy,#101e36);--color-steel:#1b2b47;--color-hairline:#1e2b42;--color-paper:#f4f5f7;--color-line:#e3e6eb;--color-rule:#eef0f3;--color-edge:#d6dae1;--color-stroke:#c9cfd8;--color-muted:#6c7787;--color-dim:#8b95a4;--color-slate:#3a4658;--color-on-dark:#93a0b4;--color-on-dark-dim:#7c8aa0;--color-on-dark-faint:#5c6b85;--color-club:var(--tenant-club,#e4002b);--color-club-dark:var(--tenant-club-dark,#b00021);--color-club-content:var(--tenant-club-content,#fff);--color-ok:#14a05a;--color-ok-bg:#e6f6ee;--color-ok-border:#bfe7d3;--color-ok-text:#0c7a43;--color-warn:#f0a22e;--color-warn-bg:#fff5e4;--color-warn-border:#f6e0b8;--color-warn-text:#9a6410;--color-warn-deep:#7a4e08;--color-danger-bg:#fdecec;--color-danger-border:#f5c9ce;--color-info:#14b8e8;--color-info-bg:#eaf7fc;--color-info-border:#c3e7f4;--color-info-text:#0a6f91;--color-ice:#14b8e8;--color-ice-ink:#04212c;--color-row-sel:#fff7f8;--color-row-focus:#f4f9ff;--color-subhead:#f8f9fa;--radius-box:.875rem}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}body{background:var(--color-paper);color:var(--color-slate);font-family:var(--font-sans);padding-top:env(safe-area-inset-top);padding-bottom:env(safe-area-inset-bottom)}}@layer components{.app-header{padding:max(env(safe-area-inset-top), 14px) 16px 14px;background:var(--color-navy);color:#fff;flex-shrink:0}.app-crest{background:var(--color-club);clip-path:polygon(50% 0,100% 18%,100% 62%,50% 100%,0 62%,0 18%);object-fit:contain;flex-shrink:0;width:30px;height:32px}.role-switcher{background:var(--color-steel);border-radius:999px;gap:4px;margin-top:10px;padding:4px;display:flex}.role-switcher-item{height:36px;font-family:var(--font-display);letter-spacing:.1em;text-transform:uppercase;color:var(--color-on-dark);border-radius:999px;flex:1;justify-content:center;align-items:center;font-size:.875rem;font-weight:800;display:flex}.role-switcher-item-active{color:var(--color-ink);background:#fff}.tab-bar{border-top:1px solid var(--color-line);padding:8px 8px max(env(safe-area-inset-bottom), 26px);background:#fff;flex-shrink:0;display:flex}.tab-bar-item{height:48px;color:var(--color-dim);flex-direction:column;flex:1;justify-content:center;align-items:center;gap:3px;display:flex}.tab-bar-item-active{color:var(--color-club)}.tab-bar-label{font-family:var(--font-display);letter-spacing:.08em;text-transform:uppercase;font-size:.75rem;font-weight:700}.btn{height:46px;font-family:var(--font-display);letter-spacing:.1em;text-transform:uppercase;cursor:pointer;white-space:nowrap;border:none;border-radius:.5rem;justify-content:center;align-items:center;gap:.5rem;padding-inline:1.25rem;font-size:1rem;font-weight:800;display:inline-flex}.btn:disabled{opacity:.45;cursor:not-allowed}.btn-primary{background:var(--color-club);color:#fff}.btn-dark{background:var(--color-ink);color:#fff}.btn-positive{background:var(--color-ok);color:#fff}.btn-secondary{border:1.5px solid var(--color-stroke);color:var(--color-ink);background:#fff}.btn-ghost-on-dark{color:#fff;background:#ffffff26}.pill{font-family:var(--font-display);letter-spacing:.1em;text-transform:uppercase;border-radius:999px;align-items:center;padding:5px 10px;font-size:.75rem;font-weight:700;display:inline-flex}.pill-ok{background:var(--color-ok-bg);color:var(--color-ok-text);border:1px solid var(--color-ok-border)}.pill-danger{background:var(--color-danger-bg);color:var(--color-club-dark);border:1px solid var(--color-danger-border)}.pill-warn{background:var(--color-warn-bg);color:var(--color-warn-text);border:1px solid var(--color-warn-border)}.pill-info{background:var(--color-info-bg);color:var(--color-info-text);border:1px solid var(--color-info-border)}.pill-neutral{background:var(--color-rule);color:#4a5566;border:1px solid #dde1e7}.person-chip{border:1.5px solid var(--color-line);color:var(--color-muted);background:#fff;border-radius:999px;flex-shrink:0;align-items:center;gap:8px;padding:5px 12px 5px 5px;display:flex}.person-chip-active{border-color:var(--color-ink);color:var(--color-ink)}.person-chip-avatar{background:var(--color-stroke);color:#fff;width:28px;height:28px;font-family:var(--font-display);border-radius:999px;flex-shrink:0;justify-content:center;align-items:center;font-size:.875rem;font-weight:800;display:flex}.person-chip-active .person-chip-avatar{background:var(--color-club)}.person-chip-label{font-family:var(--font-display);letter-spacing:.06em;text-transform:uppercase;font-size:.9375rem;font-weight:700}.m-card{border:1px solid var(--color-line);border-radius:var(--radius-box);background:#fff}.m-card-dark{background:var(--color-ink);color:#fff;border-radius:var(--radius-box)}}@layer utilities{.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.inset-0{inset:0}.-top-1{top:calc(var(--spacing) * -1)}.top-1\.5{top:calc(var(--spacing) * 1.5)}.top-1\/2{top:50%}.top-2{top:calc(var(--spacing) * 2)}.top-\[3px\]{top:3px}.top-full{top:100%}.-right-1{right:calc(var(--spacing) * -1)}.right-0{right:0}.right-2{right:calc(var(--spacing) * 2)}.right-\[3px\]{right:3px}.bottom-full{bottom:100%}.left-0{left:0}.left-1\.5{left:calc(var(--spacing) * 1.5)}.left-2\.5{left:calc(var(--spacing) * 2.5)}.left-3{left:calc(var(--spacing) * 3)}.left-\[3px\]{left:3px}.isolate{isolation:isolate}.z-10{z-index:10}.z-20{z-index:20}.z-50{z-index:50}.col-span-2{grid-column:span 2/span 2}.col-span-full{grid-column:1/-1}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.mx-auto{margin-inline:auto}.my-1{margin-block:var(--spacing)}.my-2{margin-block:calc(var(--spacing) * 2)}.my-3{margin-block:calc(var(--spacing) * 3)}.my-4{margin-block:calc(var(--spacing) * 4)}.my-6{margin-block:calc(var(--spacing) * 6)}.ms-auto{margin-inline-start:auto}.-mt-0\.5{margin-top:calc(var(--spacing) * -.5)}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-3\.5{margin-top:calc(var(--spacing) * 3.5)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mt-10{margin-top:calc(var(--spacing) * 10)}.mt-auto{margin-top:auto}.mr-1{margin-right:var(--spacing)}.mb-1{margin-bottom:var(--spacing)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-2\.5{margin-bottom:calc(var(--spacing) * 2.5)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-3\.5{margin-bottom:calc(var(--spacing) * 3.5)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-10{margin-bottom:calc(var(--spacing) * 10)}.ml-1{margin-left:var(--spacing)}.ml-2{margin-left:calc(var(--spacing) * 2)}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.aspect-square{aspect-ratio:1}.size-8{width:calc(var(--spacing) * 8);height:calc(var(--spacing) * 8)}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-8\.5{height:calc(var(--spacing) * 8.5)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-11{height:calc(var(--spacing) * 11)}.h-16{height:calc(var(--spacing) * 16)}.h-44{height:calc(var(--spacing) * 44)}.h-48{height:calc(var(--spacing) * 48)}.h-72{height:calc(var(--spacing) * 72)}.h-\[3px\]{height:3px}.h-\[7px\]{height:7px}.h-\[22px\]{height:22px}.h-\[30px\]{height:30px}.h-\[34px\]{height:34px}.h-\[52px\]{height:52px}.h-\[54px\]{height:54px}.h-\[68px\]{height:68px}.h-\[180px\]{height:180px}.h-\[190px\]{height:190px}.h-\[560px\]{height:560px}.h-full{height:100%}.h-px{height:1px}.h-screen{height:100vh}.max-h-60{max-height:calc(var(--spacing) * 60)}.max-h-96{max-height:calc(var(--spacing) * 96)}.min-h-6{min-height:calc(var(--spacing) * 6)}.min-h-screen{min-height:100vh}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-10{width:calc(var(--spacing) * 10)}.w-11{width:calc(var(--spacing) * 11)}.w-12{width:calc(var(--spacing) * 12)}.w-14{width:calc(var(--spacing) * 14)}.w-16{width:calc(var(--spacing) * 16)}.w-20{width:calc(var(--spacing) * 20)}.w-24{width:calc(var(--spacing) * 24)}.w-40{width:calc(var(--spacing) * 40)}.w-44{width:calc(var(--spacing) * 44)}.w-48{width:calc(var(--spacing) * 48)}.w-56{width:calc(var(--spacing) * 56)}.w-60{width:calc(var(--spacing) * 60)}.w-64{width:calc(var(--spacing) * 64)}.w-72{width:calc(var(--spacing) * 72)}.w-80{width:calc(var(--spacing) * 80)}.w-\[7px\]{width:7px}.w-\[36rem\]{width:36rem}.w-\[68px\]{width:68px}.w-\[236px\]{width:236px}.w-\[460px\]{width:460px}.w-auto{width:auto}.w-fit{width:fit-content}.w-full{width:100%}.w-px{width:1px}.max-w-2xl{max-width:var(--container-2xl)}.max-w-3xl{max-width:var(--container-3xl)}.max-w-6xl{max-width:var(--container-6xl)}.max-w-40{max-width:calc(var(--spacing) * 40)}.max-w-\[35\%\]{max-width:35%}.max-w-\[91vw\]{max-width:91vw}.max-w-\[1440px\]{max-width:1440px}.max-w-md{max-width:var(--container-md)}.max-w-none{max-width:none}.max-w-xl{max-width:var(--container-xl)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:0}.min-w-4{min-width:calc(var(--spacing) * 4)}.min-w-40{min-width:calc(var(--spacing) * 40)}.min-w-\[220px\]{min-width:220px}.flex-1{flex:1}.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.border-collapse{border-collapse:collapse}.-translate-y-1\/2{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);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,)}.animate-pulse{animation:var(--animate-pulse)}.cursor-pointer{cursor:pointer}.resize{resize:both}.scrollbar-gutter-stable{scrollbar-gutter:stable}.list-inside{list-style-position:inside}.list-disc{list-style-type:disc}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-row{flex-direction:row}.flex-nowrap{flex-wrap:nowrap}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.justify-start{justify-content:flex-start}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-3\.5{gap:calc(var(--spacing) * 3.5)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-5{gap:calc(var(--spacing) * 5)}.gap-6{gap:calc(var(--spacing) * 6)}.gap-10{gap:calc(var(--spacing) * 10)}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-4{column-gap:calc(var(--spacing) * 4)}.gap-y-2{row-gap:calc(var(--spacing) * 2)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-rule>:not(:last-child)){border-color:var(--color-rule)}.self-start{align-self:flex-start}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-\[10px\]{border-radius:10px}.rounded-\[14px\]{border-radius:14px}.rounded-box{border-radius:var(--radius-box)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-sm{border-radius:var(--radius-sm)}.rounded-xl{border-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-\[1\.5px\]{border-style:var(--tw-border-style);border-width:1.5px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-l-4{border-left-style:var(--tw-border-style);border-left-width:4px}.border-l-\[3px\]{border-left-style:var(--tw-border-style);border-left-width:3px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-\[\#2C3B56\]{border-color:#2c3b56}.border-\[\#DDE1E7\]{border-color:#dde1e7}.border-club{border-color:var(--color-club)}.border-danger-border{border-color:var(--color-danger-border)}.border-edge{border-color:var(--color-edge)}.border-ice{border-color:var(--color-ice)}.border-info{border-color:var(--color-info)}.border-info-border{border-color:var(--color-info-border)}.border-ink{border-color:var(--color-ink)}.border-line{border-color:var(--color-line)}.border-on-dark-faint{border-color:var(--color-on-dark-faint)}.border-rule{border-color:var(--color-rule)}.border-stroke{border-color:var(--color-stroke)}.border-warn{border-color:var(--color-warn)}.border-l-club{border-left-color:var(--color-club)}.border-l-club-dark{border-left-color:var(--color-club-dark)}.border-l-info{border-left-color:var(--color-info)}.border-l-transparent{border-left-color:#0000}.border-l-warn{border-left-color:var(--color-warn)}.bg-\[var\(--club\)\]{background-color:var(--club)}.bg-club{background-color:var(--color-club)}.bg-danger-bg{background-color:var(--color-danger-bg)}.bg-edge{background-color:var(--color-edge)}.bg-hairline{background-color:var(--color-hairline)}.bg-ice{background-color:var(--color-ice)}.bg-info-bg{background-color:var(--color-info-bg)}.bg-ink{background-color:var(--color-ink)}.bg-line{background-color:var(--color-line)}.bg-ok{background-color:var(--color-ok)}.bg-paper{background-color:var(--color-paper)}.bg-row-focus{background-color:var(--color-row-focus)}.bg-row-sel{background-color:var(--color-row-sel)}.bg-rule{background-color:var(--color-rule)}.bg-steel{background-color:var(--color-steel)}.bg-subhead{background-color:var(--color-subhead)}.bg-warn{background-color:var(--color-warn)}.bg-white{background-color:var(--color-white)}.bg-white\/15{background-color:#ffffff26}@supports (color:color-mix(in lab, red, red)){.bg-white\/15{background-color:color-mix(in oklab, var(--color-white) 15%, transparent)}}.stroke-2{stroke-width:2px}.object-contain{object-fit:contain}.object-cover{object-fit:cover}.p-1{padding:var(--spacing)}.p-1\.5{padding:calc(var(--spacing) * 1.5)}.p-2{padding:calc(var(--spacing) * 2)}.p-2\.5{padding:calc(var(--spacing) * 2.5)}.p-3{padding:calc(var(--spacing) * 3)}.p-3\.5{padding:calc(var(--spacing) * 3.5)}.p-4{padding:calc(var(--spacing) * 4)}.p-4\.5{padding:calc(var(--spacing) * 4.5)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.p-10{padding:calc(var(--spacing) * 10)}.p-\[18px\]{padding:18px}.px-1{padding-inline:var(--spacing)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-3\.5{padding-inline:calc(var(--spacing) * 3.5)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-4\.5{padding-inline:calc(var(--spacing) * 4.5)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-7{padding-inline:calc(var(--spacing) * 7)}.px-8{padding-inline:calc(var(--spacing) * 8)}.px-\[3px\]{padding-inline:3px}.px-\[18px\]{padding-inline:18px}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-3\.5{padding-block:calc(var(--spacing) * 3.5)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-5{padding-block:calc(var(--spacing) * 5)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-10{padding-block:calc(var(--spacing) * 10)}.py-14{padding-block:calc(var(--spacing) * 14)}.py-\[5px\]{padding-block:5px}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pt-3\.5{padding-top:calc(var(--spacing) * 3.5)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pb-6{padding-bottom:calc(var(--spacing) * 6)}.pb-\[22px\]{padding-bottom:22px}.pb-\[26px\]{padding-bottom:26px}.pl-2\.5{padding-left:calc(var(--spacing) * 2.5)}.pl-8{padding-left:calc(var(--spacing) * 8)}.pl-9{padding-left:calc(var(--spacing) * 9)}.text-center{text-align:center}.text-right{text-align:right}.align-middle{vertical-align:middle}.font-display{font-family:var(--font-display)}.font-mono{font-family:var(--font-mono)}.font-sans{font-family:var(--font-sans)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[9px\]{font-size:9px}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[13px\]{font-size:13px}.text-\[15px\]{font-size:15px}.text-\[17px\]{font-size:17px}.text-\[22px\]{font-size:22px}.text-\[34px\]{font-size:34px}.text-\[56px\]{font-size:56px}.leading-\[\.94\]{--tw-leading:.94;line-height:.94}.leading-\[\.95\]{--tw-leading:.95;line-height:.95}.leading-\[\.96\]{--tw-leading:.96;line-height:.96}.leading-\[1\.05\]{--tw-leading:1.05;line-height:1.05}.leading-\[1\.5\]{--tw-leading:1.5;line-height:1.5}.leading-\[1\.6\]{--tw-leading:1.6;line-height:1.6}.leading-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-extrabold{--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[\.1em\]{--tw-tracking:.1em;letter-spacing:.1em}.tracking-\[\.04em\]{--tw-tracking:.04em;letter-spacing:.04em}.tracking-\[\.06em\]{--tw-tracking:.06em;letter-spacing:.06em}.tracking-\[\.08em\]{--tw-tracking:.08em;letter-spacing:.08em}.tracking-\[\.12em\]{--tw-tracking:.12em;letter-spacing:.12em}.tracking-\[\.14em\]{--tw-tracking:.14em;letter-spacing:.14em}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.break-all{word-break:break-all}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.whitespace-pre-wrap{white-space:pre-wrap}.text-\[\#4A5566\]{color:#4a5566}.text-club{color:var(--color-club)}.text-club-content{color:var(--color-club-content)}.text-club-dark{color:var(--color-club-dark)}.text-dim{color:var(--color-dim)}.text-edge{color:var(--color-edge)}.text-gray-500{color:var(--color-gray-500)}.text-ice{color:var(--color-ice)}.text-info{color:var(--color-info)}.text-info-text{color:var(--color-info-text)}.text-ink{color:var(--color-ink)}.text-muted{color:var(--color-muted)}.text-ok{color:var(--color-ok)}.text-ok-text{color:var(--color-ok-text)}.text-on-dark{color:var(--color-on-dark)}.text-on-dark-dim{color:var(--color-on-dark-dim)}.text-on-dark-faint{color:var(--color-on-dark-faint)}.text-sky-500{color:var(--color-sky-500)}.text-slate{color:var(--color-slate)}.text-warn{color:var(--color-warn)}.text-warn-text{color:var(--color-warn-text)}.text-white{color:var(--color-white)}.text-white\/80{color:#fffc}@supports (color:color-mix(in lab, red, red)){.text-white\/80{color:color-mix(in oklab, var(--color-white) 80%, transparent)}}.lowercase{text-transform:lowercase}.uppercase{text-transform:uppercase}.italic{font-style:italic}.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,)}.line-through{text-decoration-line:line-through}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.opacity-0{opacity:0}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-80{opacity:.8}.opacity-\[\.72\]{opacity:.72}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[-24px_0_60px_rgba\(11\,18\,32\,\.2\)\]{--tw-shadow:-24px 0 60px var(--tw-shadow-color,#0b122033);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_24px_60px_rgba\(11\,18\,32\,\.18\)\]{--tw-shadow:0 24px 60px var(--tw-shadow-color,#0b12202e);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[inset_3px_0_0_var\(--color-info\)\]{--tw-shadow:inset 3px 0 0 var(--tw-shadow-color,var(--color-info));box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);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,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + 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)}.ring-2{--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)}.ring-offset-2{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.outline{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,)}.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,)}.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.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))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.last\:border-b-0:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.empty\:hidden:empty{display:none}@media (hover:hover){.hover\:border-white:hover{border-color:var(--color-white)}.hover\:bg-ink:hover{background-color:var(--color-ink)}.hover\:bg-rule:hover{background-color:var(--color-rule)}.hover\:bg-steel:hover{background-color:var(--color-steel)}.hover\:bg-subhead:hover{background-color:var(--color-subhead)}.hover\:bg-white\/25:hover{background-color:#ffffff40}@supports (color:color-mix(in lab, red, red)){.hover\:bg-white\/25:hover{background-color:color-mix(in oklab, var(--color-white) 25%, transparent)}}.hover\:text-ink:hover{color:var(--color-ink)}.hover\:text-white:hover{color:var(--color-white)}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-100:hover{opacity:1}}@media (min-width:40rem){.sm\:col-span-2{grid-column:span 2/span 2}.sm\:block{display:block}.sm\:hidden{display:none}.sm\:inline{display:inline}.sm\:h-16{height:calc(var(--spacing) * 16)}.sm\:w-16{width:calc(var(--spacing) * 16)}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.sm\:gap-3{gap:calc(var(--spacing) * 3)}.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}.sm\:text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.sm\:text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}}@media (min-width:48rem){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}}@media (min-width:64rem){.lg\:flex{display:flex}.lg\:w-fit{width:fit-content}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}}@media (min-width:80rem){.xl\:sticky{position:sticky}.xl\:top-4{top:calc(var(--spacing) * 4)}.xl\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.xl\:grid-cols-\[1\.2fr_1fr\]{grid-template-columns:1.2fr 1fr}.xl\:grid-cols-\[1\.6fr_1fr\]{grid-template-columns:1.6fr 1fr}.xl\:grid-cols-\[1\.35fr_1fr\]{grid-template-columns:1.35fr 1fr}.xl\:self-start{align-self:flex-start}}.\[\&\:\:-webkit-details-marker\]\:hidden::-webkit-details-marker{display:none}}@font-face{font-family:Barlow;font-style:normal;font-weight:400;font-display:swap;src:url(../fonts/barlow-latin-400-normal.woff2)format("woff2");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Barlow;font-style:normal;font-weight:500;font-display:swap;src:url(../fonts/barlow-latin-500-normal.woff2)format("woff2");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Barlow;font-style:normal;font-weight:600;font-display:swap;src:url(../fonts/barlow-latin-600-normal.woff2)format("woff2");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Barlow;font-style:normal;font-weight:700;font-display:swap;src:url(../fonts/barlow-latin-700-normal.woff2)format("woff2");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Barlow Condensed;font-style:normal;font-weight:600;font-display:swap;src:url(../fonts/barlow-condensed-latin-600-normal.woff2)format("woff2");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Barlow Condensed;font-style:normal;font-weight:700;font-display:swap;src:url(../fonts/barlow-condensed-latin-700-normal.woff2)format("woff2");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Barlow Condensed;font-style:normal;font-weight:800;font-display:swap;src:url(../fonts/barlow-condensed-latin-800-normal.woff2)format("woff2");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:IBM Plex Mono;font-style:normal;font-weight:400;font-display:swap;src:url(../fonts/ibm-plex-mono-latin-400-normal.woff2)format("woff2");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:IBM Plex Mono;font-style:normal;font-weight:500;font-display:swap;src:url(../fonts/ibm-plex-mono-latin-500-normal.woff2)format("woff2");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";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:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@keyframes pulse{50%{opacity:.5}} \ No newline at end of file diff --git a/static/js/alpine.js b/static/js/alpine.js new file mode 100644 index 0000000..2de9655 --- /dev/null +++ b/static/js/alpine.js @@ -0,0 +1,21 @@ +(()=>{var _t=!1,gt=!1,$=[],xt=-1,je=!1,yt=!1;function mr(e){ri(e)}function _r(){yt=!0}function gr(){yt=!1,yr()}function ri(e){$.includes(e)||($.push(e),e._x_schedulerPriority!==void 0&&(je=!0)),yr()}function xr(e){let t=$.indexOf(e);t!==-1&&t>xt&&$.splice(t,1)}function yr(){if(!gt&&!_t){if(yt)return;_t=!0,queueMicrotask(ni)}}function ni(){_t=!1,gt=!0;for(let e=0;e<$.length;e++)je&&ii(e),$[e](),xt=e;$.length=0,xt=-1,je=!1,gt=!1}function ii(e){let t=new Map,r=$.slice(e).sort((n,i)=>oi(n,i,t));for(let n=0;ne.effect(t,{scheduler:r=>{bt?mr(r):r()}}),Et=e.raw}function vt(e){P=e}function vr(e){let t=()=>{};return[(n,i)=>{let o=i?.priority==="structural"?si++:void 0,s=P(n);return o!==void 0&&s!==void 0&&(s._x_schedulerPriority={el:e,order:o}),e._x_effects||(e._x_effects=new Set,e._x_runEffects=()=>{e._x_effects.forEach(a=>a())}),e._x_effects.add(s),t=()=>{s!==void 0&&(e._x_effects.delete(s),B(s))},s},()=>{t()}]}function Fe(e,t){let r=!0,n,i,o=P(()=>{let s=e(),a=JSON.stringify(s);if(!r&&(typeof s=="object"||s!==n)){let c=typeof n=="object"?JSON.parse(i):n;queueMicrotask(()=>{t(s,c)})}n=s,i=a,r=!1});return()=>B(o)}async function wr(e){_r();try{await e(),await Promise.resolve()}finally{gr()}}var Sr=[],Ar=[],Or=[];function Tr(e){Or.push(e)}function ce(e,t){typeof t=="function"?(e._x_cleanups||(e._x_cleanups=[]),e._x_cleanups.push(t)):(t=e,Ar.push(t))}function $e(e){Sr.push(e)}function Be(e,t,r){e._x_attributeCleanups||(e._x_attributeCleanups={}),e._x_attributeCleanups[t]||(e._x_attributeCleanups[t]=[]),e._x_attributeCleanups[t].push(r)}function wt(e,t){e._x_attributeCleanups&&Object.entries(e._x_attributeCleanups).forEach(([r,n])=>{(t===void 0||t.includes(r))&&(n.forEach(i=>i()),delete e._x_attributeCleanups[r])})}function Nr(e){for(e._x_effects?.forEach(xr);e._x_cleanups?.length;)e._x_cleanups.pop()()}var St=new MutationObserver(Nt),At=!1;function Ee(){St.observe(document,{subtree:!0,childList:!0,attributes:!0,attributeOldValue:!0}),At=!0}function Ot(){ai(),St.disconnect(),At=!1}var be=[];function ai(){let e=St.takeRecords();be.push(()=>e.length>0&&Nt(e));let t=be.length;queueMicrotask(()=>{if(be.length===t)for(;be.length>0;)be.shift()()})}function h(e){if(!At)return e();Ot();let t=e();return Ee(),t}var Tt=!1,Ve=[];function Cr(){Tt=!0}function Rr(){Tt=!1,Nt(Ve),Ve=[]}function Nt(e){if(Tt){Ve=Ve.concat(e);return}let t=[],r=new Set,n=new Map,i=new Map;for(let o=0;o{s.nodeType===1&&s._x_marker&&r.add(s)}),e[o].addedNodes.forEach(s=>{if(s.nodeType===1){if(r.has(s)){r.delete(s);return}s._x_marker||t.push(s)}})),e[o].type==="attributes")){let s=e[o].target,a=e[o].attributeName,c=e[o].oldValue,l=()=>{n.has(s)||n.set(s,[]),n.get(s).push({name:a,value:s.getAttribute(a)})},f=()=>{i.has(s)||i.set(s,[]),i.get(s).push(a)};s.hasAttribute(a)&&c===null?l():s.hasAttribute(a)?(f(),l()):f()}i.forEach((o,s)=>{wt(s,o)}),n.forEach((o,s)=>{Sr.forEach(a=>a(s,o))});for(let o of r)t.some(s=>s.contains(o))||Ar.forEach(s=>s(o));for(let o of t)o.isConnected&&Or.forEach(s=>s(o));t=null,r=null,n=null,i=null}function He(e){return L(H(e))}function k(e,t,r){return e._x_dataStack=[t,...H(r||e)],()=>{e._x_dataStack=e._x_dataStack.filter(n=>n!==t)}}function H(e){return e._x_dataStack?e._x_dataStack:typeof ShadowRoot=="function"&&e instanceof ShadowRoot?H(e.host):e.parentNode?H(e.parentNode):[]}function L(e){return new Proxy({objects:e},ci)}function Dr(e,t){return e===null||e===Object.prototype?null:Object.prototype.hasOwnProperty.call(e,t)?e:Dr(Object.getPrototypeOf(e),t)}var ci={ownKeys({objects:e}){return Array.from(new Set(e.flatMap(t=>Object.keys(t))))},has({objects:e},t){return t==Symbol.unscopables?!1:e.some(r=>Object.prototype.hasOwnProperty.call(r,t)||Reflect.has(r,t))},get({objects:e},t,r){return t=="toJSON"?li:Reflect.get(e.find(n=>Reflect.has(n,t))||{},t,r)},set({objects:e},t,r,n){let i;for(let s of e)if(i=Dr(s,t),i)break;i||(i=e[e.length-1]);let o=Object.getOwnPropertyDescriptor(i,t);return o?.set&&o?.get?o.set.call(n,r)||!0:Reflect.set(i,t,r)}};function li(){return Reflect.ownKeys(this).reduce((t,r)=>(t[r]=Reflect.get(this,r),t),{})}function le(e,t=()=>{}){let r=i=>typeof i=="object"&&!Array.isArray(i)&&i!==null,n=(i,o="")=>{Object.entries(Object.getOwnPropertyDescriptors(i)).forEach(([s,{value:a,enumerable:c}])=>{if(c===!1||a===void 0||typeof a=="object"&&a!==null&&a.__v_skip)return;let l=o===""?s:`${o}.${s}`;typeof a=="object"&&a!==null&&a._x_interceptor?i[s]=a.initialize(e,l,s,t):r(a)&&a!==i&&!(a instanceof Element)&&n(a,l)})};return n(e)}function Ue(e,t=()=>{}){let r={initialValue:void 0,_x_interceptor:!0,initialize(n,i,o,s){return e(this.initialValue,()=>fi(n,i),a=>Ct(n,i,a),i,o,s)}};return t(r),n=>{if(typeof n=="object"&&n!==null&&n._x_interceptor){let i=r.initialize.bind(r);r.initialize=(o,s,a,c)=>{let l=n.initialize(o,s,a,c);return r.initialValue=l,i(o,s,a,c)}}else r.initialValue=n;return r}}function fi(e,t){return t.split(".").reduce((r,n)=>r[n],e)}function Ct(e,t,r){if(typeof t=="string"&&(t=t.split(".")),t.length===1)e[t[0]]=r;else{if(t.length===0)throw error;return e[t[0]]||(e[t[0]]={}),Ct(e[t[0]],t.slice(1),r)}}var Mr={};function b(e,t){Mr[e]=t}function q(e,t){let r=ui(t);return Object.entries(Mr).forEach(([n,i])=>{Object.defineProperty(e,`$${n}`,{get(){return i(t,r)},enumerable:!1})}),e}function ui(e){let[t,r]=Rt(e),n={interceptor:Ue,...t};return ce(e,r),n}function Pr(e,t,r,...n){try{return r(...n)}catch(i){fe(i,e,t)}}function fe(...e){return kr(...e)}var kr=pi;function Lr(e){kr=e}function pi(e,t,r=void 0){e=Object.assign(e??{message:"No error message given."},{el:t,expression:r}),console.warn(`Alpine Expression Error: ${e.message} + +${r?'Expression: "'+r+`" + +`:""}`,t),setTimeout(()=>{throw e},0)}var ue=!0;function ze(e){let t=ue;ue=!1;let r=e();return ue=t,r}function D(e,t,r={}){let n;return x(e,t)(i=>n=i,r),n}function x(...e){return Ir(...e)}var Ir=()=>{};function jr(e){Ir=e}var Fr;function Vr(e){Fr=e}function $r(e,t){let r={};q(r,e);let n=[r,...H(e)],i=typeof t=="function"?di(n,t):mi(n,t,e);return Pr.bind(null,e,t,i)}function di(e,t){return(r=()=>{},{scope:n={},params:i=[],context:o}={})=>{if(!ue){ve(r,t,L([n,...e]),i);return}let s=t.apply(L([n,...e]),i);ve(r,s)}}var Dt={};function hi(e,t){if(Dt[e])return Dt[e];let r=Object.getPrototypeOf(async function(){}).constructor,n=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(async()=>{ ${e} })()`:e,o=(()=>{try{let s=new r(["__self","scope"],`with (scope) { __self.result = ${n} }; __self.finished = true; return __self.result;`);return Object.defineProperty(s,"name",{value:`[Alpine] ${e}`}),s}catch(s){return fe(s,t,e),Promise.resolve()}})();return Dt[e]=o,o}function mi(e,t,r){let n=hi(t,r);return(i=()=>{},{scope:o={},params:s=[],context:a}={})=>{n.result=void 0,n.finished=!1;let c=L([o,...e]);if(typeof n=="function"){let l=n.call(a,n,c).catch(f=>fe(f,r,t));n.finished?(ve(i,n.result,c,s,r),n.result=void 0):l.then(f=>{ve(i,f,c,s,r)}).catch(f=>fe(f,r,t)).finally(()=>n.result=void 0)}}}function ve(e,t,r,n,i){if(ue&&typeof t=="function"){let o=t.apply(r,n);o instanceof Promise?o.then(s=>ve(e,s,r,n)).catch(s=>fe(s,i,t)):e(o)}else typeof t=="object"&&t instanceof Promise?t.then(o=>e(o)):e(t)}function Br(...e){return Fr(...e)}function Hr(e,t,r={}){let n={};q(n,e);let i=[n,...H(e)],o=L([r.scope??{},...i]),s=r.params??[];if(t.includes("await")){let a=Object.getPrototypeOf(async function(){}).constructor,c=/^[\n\s]*if.*\(.*\)/.test(t.trim())||/^(let|const)\s/.test(t.trim())?`(async()=>{ ${t} })()`:t;return new a(["scope"],`with (scope) { let __result = ${c}; return __result }`).call(r.context,o)}else{let a=/^[\n\s]*if.*\(.*\)/.test(t.trim())||/^(let|const)\s/.test(t.trim())?`(()=>{ ${t} })()`:t,l=new Function(["scope"],`with (scope) { let __result = ${a}; return __result }`).call(r.context,o);return typeof l=="function"&&ue?l.apply(o,s):l}}var kt="x-";function N(e=""){return kt+e}function Ur(e){kt=e}var We={};function d(e,t){return We[e]=t,{before(r){if(!We[r]){console.warn(String.raw`Cannot find directive \`${r}\`. \`${e}\` will use the default order of execution`);return}let n=X.indexOf(r);X.splice(n>=0?n:X.indexOf("DEFAULT"),0,e)}}}function zr(e){return Object.keys(We).includes(e)}function Se(e,t,r){if(t=Array.from(t),e._x_virtualDirectives){let o=Object.entries(e._x_virtualDirectives).map(([a,c])=>({name:a,value:c})),s=Lt(o);o=o.map(a=>s.find(c=>c.name===a.name)?{name:`x-bind:${a.name}`,value:`"${a.value}"`}:a),t=t.concat(o)}let n={};return t.map(qr((o,s)=>n[o]=s)).filter(Yr).map(gi(n,r)).sort(xi).map(o=>_i(e,o))}function Lt(e){return Array.from(e).map(qr()).filter(t=>!Yr(t))}var Mt=!1,we=new Map,Wr=Symbol();function Kr(e){Mt=!0;let t=Symbol();Wr=t,we.set(t,[]);let r=()=>{for(;we.get(t).length;)we.get(t).shift()();we.delete(t)},n=()=>{Mt=!1,r()};e(r),n()}function Rt(e){let t=[],r=a=>t.push(a),[n,i]=vr(e);return t.push(i),[{Alpine:U,effect:n,cleanup:r,evaluateLater:x.bind(x,e),evaluate:D.bind(D,e)},()=>t.forEach(a=>a())]}function _i(e,t){let r=()=>{},n=We[t.type]||r,[i,o]=Rt(e);Be(e,t.original,o);let s=()=>{e._x_ignore||e._x_ignoreSelf||(n.inline&&n.inline(e,t,i),n=n.bind(n,e,t,i),Mt?we.get(Wr).push(n):n())};return s.runCleanups=o,s}var Ke=(e,t)=>({name:r,value:n})=>(r.startsWith(e)&&(r=r.replace(e,t)),{name:r,value:n}),qe=e=>e;function qr(e=()=>{}){return({name:t,value:r})=>{let{name:n,value:i}=Gr.reduce((o,s)=>s(o),{name:t,value:r});return n!==t&&e(n,t),{name:n,value:i}}}var Gr=[];function pe(e){Gr.push(e)}function Yr({name:e}){return Jr().test(e)}var Jr=()=>new RegExp(`^${kt}([^:^.]+)\\b`);function gi(e,t){return({name:r,value:n})=>{r===n&&(n="");let i=r.match(Jr()),o=r.match(/:([a-zA-Z0-9\-_:]+)/),s=r.match(/\.[^.\]]+(?=[^\]]*$)/g)||[],a=t||e[r]||r;return{type:i?i[1]:null,value:o?o[1]:null,modifiers:s.map(c=>c.replace(".","")),expression:n,original:a}}}var Pt="DEFAULT",X=["ignore","ref","id","data","anchor","bind","init","for","model","modelable","transition","show","if",Pt,"teleport"];function xi(e,t){let r=X.indexOf(e.type)===-1?Pt:e.type,n=X.indexOf(t.type)===-1?Pt:t.type;return X.indexOf(r)-X.indexOf(n)}function Z(e,t,r={},n={}){return e.dispatchEvent(new CustomEvent(t,{detail:r,bubbles:!0,composed:!0,cancelable:!0,...n}))}function I(e,t){if(typeof ShadowRoot=="function"&&e instanceof ShadowRoot){Array.from(e.children).forEach(i=>I(i,t));return}let r=!1;if(t(e,()=>r=!0),r)return;let n=e.firstElementChild;for(;n;)I(n,t,!1),n=n.nextElementSibling}function w(e,...t){console.warn(`Alpine Warning: ${e}`,...t)}var Xr=!1;function Zr(){Xr&&w("Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems."),Xr=!0,document.body||w("Unable to initialize. Trying to load Alpine before `` is available. Did you forget to add `defer` in Alpine's `