diff --git a/internal/web/handlers.go b/internal/web/handlers.go index ef335bf..96a426b 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -186,7 +186,7 @@ func (s *Server) handleChannel(w http.ResponseWriter, r *http.Request, ch Channe } var ( - demoVariants = []string{"rain", "petals", "jacaranda", "motes", "leaves"} + demoVariants = []string{"rain", "petals", "jacaranda", "motes", "leaves", "clear", "clouds", "snow", "fog", "storm"} demoIntensities = []string{"light", "medium", "heavy"} demoPhases = []string{"day", "dawn", "dusk", "night"} ) diff --git a/internal/web/static/css/output.css b/internal/web/static/css/output.css index e1a39ee..41f1c5b 100644 --- a/internal/web/static/css/output.css +++ b/internal/web/static/css/output.css @@ -1 +1 @@ -*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}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;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}body{font-family:Nunito,system-ui,sans-serif}html{scroll-behavior:smooth}body,html{overflow-x:hidden}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.pete-channel-nav{scrollbar-width:none;-ms-overflow-style:none}.pete-channel-nav::-webkit-scrollbar{display:none}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.-right-6{right:-1.5rem}.-top-6{top:-1.5rem}.-z-10{z-index:-10}.-z-\[5\]{z-index:-5}.z-50{z-index:50}.mx-auto{margin-left:auto;margin-right:auto}.mb-1{margin-bottom:.25rem}.mb-10{margin-bottom:2.5rem}.mb-12{margin-bottom:3rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-8{margin-bottom:2rem}.mr-1{margin-right:.25rem}.mt-1{margin-top:.25rem}.mt-10{margin-top:2.5rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.line-clamp-2{-webkit-line-clamp:2}.line-clamp-2,.line-clamp-3{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical}.line-clamp-3{-webkit-line-clamp:3}.block{display:block}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.aspect-\[16\/10\]{aspect-ratio:16/10}.h-12{height:3rem}.h-20{height:5rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-9{height:2.25rem}.h-full{height:100%}.max-h-\[55vh\]{max-height:55vh}.max-h-\[60vh\]{max-height:60vh}.min-h-screen{min-height:100vh}.w-12{width:3rem}.w-20{width:5rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-9{width:2.25rem}.w-full{width:100%}.min-w-0{min-width:0}.max-w-2xl{max-width:42rem}.max-w-6xl{max-width:72rem}.max-w-full{max-width:100%}.max-w-md{max-width:28rem}.max-w-xl{max-width:36rem}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.-translate-y-0{--tw-translate-y:-0px}.-translate-y-0,.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.resize{resize:both}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2rem*var(--tw-space-y-reverse))}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-3xl{border-radius:1.5rem}.rounded-full{border-radius:9999px}.rounded-md{border-radius:.375rem}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-transparent{border-color:transparent}.bg-\[color\:var\(--accent\)\]{background-color:var(--accent)}.bg-\[color\:var\(--bg\)\]{background-color:var(--bg)}.bg-\[color\:var\(--bg-grad\)\]{background-color:var(--bg-grad)}.bg-\[color\:var\(--card\)\]{background-color:var(--card)}.bg-transparent{background-color:transparent}.object-cover{-o-object-fit:cover;object-fit:cover}.p-1{padding:.25rem}.p-10{padding:2.5rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.py-0{padding-top:0;padding-bottom:0}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.pb-1{padding-bottom:.25rem}.pb-10{padding-bottom:2.5rem}.pb-24{padding-bottom:6rem}.pb-4{padding-bottom:1rem}.pt-3{padding-top:.75rem}.pt-8{padding-top:2rem}.pt-\[10vh\]{padding-top:10vh}.text-center{text-align:center}.align-\[-4px\]{vertical-align:-4px}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[12rem\]{font-size:12rem}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.leading-snug{line-height:1.375}.leading-tight{line-height:1.25}.tracking-\[0\.2em\]{letter-spacing:.2em}.tracking-tight{letter-spacing:-.025em}.tracking-wider{letter-spacing:.05em}.text-\[color\:var\(--ink\)\]{color:var(--ink)}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.text-white\/85{color:hsla(0,0%,100%,.85)}.decoration-4{text-decoration-thickness:4px}.underline-offset-2{text-underline-offset:2px}.underline-offset-4{text-underline-offset:4px}.accent-\[color\:var\(--accent\)\]{accent-color:var(--accent)}.opacity-20{opacity:.2}.opacity-75{opacity:.75}.opacity-80{opacity:.8}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-sm{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.outline-none{outline:2px solid transparent;outline-offset:2px}.blur{--tw-blur:blur(8px)}.blur,.grayscale{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)}.grayscale{--tw-grayscale:grayscale(100%)}.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-blur-sm{--tw-backdrop-blur:blur(4px);-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,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-1000{transition-duration:1s}.duration-500{transition-duration:.5s}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}.font-display{font-family:Fredoka,Nunito,system-ui,sans-serif;letter-spacing:-.01em}.shadow-pete{box-shadow:0 4px 0 rgba(60,40,20,.1),0 8px 24px rgba(60,40,20,.08)}.shadow-pete-lg{box-shadow:0 6px 0 rgba(60,40,20,.12),0 16px 32px rgba(60,40,20,.12)}.bg-theme-gaming{background-color:#4caf7d}.bg-theme-tech{background-color:#5aa9e6}.bg-theme-politics{background-color:#e07a5f}.bg-theme-eu{background-color:#039}.bg-theme-music{background-color:#b079d6}.bg-theme-anime{background-color:#ec5e8a}.bg-theme-foss{background-color:#d97706}.bg-theme-kids{background-color:#14b8a6}.bg-theme-finance{background-color:#10b981}.bg-theme-lego{background-color:#d01012}.text-theme-gaming{color:#2d8a5a}.text-theme-tech{color:#2f7fb8}.text-theme-politics{color:#b8523a}.text-theme-eu{color:#039}.text-theme-music{color:#8a4fb8}.text-theme-anime{color:#c33a6a}.text-theme-foss{color:#b8530a}.text-theme-kids{color:#0f766e}.text-theme-finance{color:#059669}.text-theme-lego{color:#b00d0e}.decoration-theme-gaming{text-decoration-color:#4caf7d}.decoration-theme-tech{text-decoration-color:#5aa9e6}.decoration-theme-politics{text-decoration-color:#e07a5f}.decoration-theme-eu{text-decoration-color:#039}.decoration-theme-music{text-decoration-color:#b079d6}.decoration-theme-anime{text-decoration-color:#ec5e8a}.decoration-theme-foss{text-decoration-color:#d97706}.decoration-theme-kids{text-decoration-color:#14b8a6}.decoration-theme-finance{text-decoration-color:#10b981}.decoration-theme-lego{text-decoration-color:#d01012}.border-theme-gaming{border-color:#4caf7d}.border-theme-tech{border-color:#5aa9e6}.border-theme-politics{border-color:#e07a5f}.border-theme-eu{border-color:#039}.border-theme-music{border-color:#b079d6}.border-theme-anime{border-color:#ec5e8a}.border-theme-foss{border-color:#d97706}.border-theme-kids{border-color:#14b8a6}.border-theme-finance{border-color:#10b981}.border-theme-lego{border-color:#d01012}.glow-theme-gaming{box-shadow:0 0 0 2px rgba(76,175,125,.35),0 0 24px 4px rgba(76,175,125,.45)}.glow-theme-gaming,.glow-theme-tech{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-tech{box-shadow:0 0 0 2px rgba(90,169,230,.35),0 0 24px 4px rgba(90,169,230,.45)}.glow-theme-politics{box-shadow:0 0 0 2px rgba(224,122,95,.35),0 0 24px 4px rgba(224,122,95,.45)}.glow-theme-eu,.glow-theme-politics{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-eu{box-shadow:0 0 0 2px rgba(0,51,153,.35),0 0 24px 4px rgba(0,51,153,.45)}.glow-theme-music{box-shadow:0 0 0 2px rgba(176,121,214,.35),0 0 24px 4px rgba(176,121,214,.45)}.glow-theme-anime,.glow-theme-music{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-anime{box-shadow:0 0 0 2px rgba(236,94,138,.35),0 0 24px 4px rgba(236,94,138,.45)}.glow-theme-foss{box-shadow:0 0 0 2px rgba(217,119,6,.35),0 0 24px 4px rgba(217,119,6,.45)}.glow-theme-foss,.glow-theme-kids{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-kids{box-shadow:0 0 0 2px rgba(20,184,166,.35),0 0 24px 4px rgba(20,184,166,.45)}.glow-theme-finance{box-shadow:0 0 0 2px rgba(16,185,129,.35),0 0 24px 4px rgba(16,185,129,.45)}.glow-theme-finance,.glow-theme-lego{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-lego{box-shadow:0 0 0 2px rgba(208,16,18,.35),0 0 24px 4px rgba(208,16,18,.45)}@keyframes pete-glow-pulse{0%,to{filter:brightness(1)}50%{filter:brightness(1.08)}}.line-clamp-3{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}.paywall-stamp{position:absolute;inset:0;pointer-events:none;display:flex;align-items:center;justify-content:center;z-index:5;background:rgba(255,250,240,.18)}.paywall-stamp:before{content:"PAYWALLED";font-family:var(--font-display,ui-sans-serif),system-ui,sans-serif;font-weight:900;font-size:clamp(1rem,3.2vw,1.75rem);letter-spacing:.12em;color:rgba(180,30,30,.85);border:.28rem double rgba(180,30,30,.85);padding:.3rem .9rem;transform:rotate(-15deg);text-shadow:0 1px 0 hsla(0,0%,100%,.4);box-shadow:inset 0 0 0 2px hsla(0,0%,100%,.15);background:rgba(255,240,230,.55);border-radius:.25rem;filter:contrast(1.05)}:root,html[data-phase=day]{--bg:#fff7e4;--bg-grad:#ffeec2;--card:#fff;--ink:#3a2e1f;--accent:#f2a541}html[data-phase=dawn]{--bg:#ffe7d6;--bg-grad:#ffc9c9;--card:#fff4ea;--ink:#4a2e2a;--accent:#ff8a65}html[data-phase=dusk]{--bg:#ffd6a8;--bg-grad:#f7a07e;--card:#fff1de;--ink:#3d2417;--accent:#e6553a}html[data-phase=night]{--bg:#1a1f3a;--bg-grad:#2a3358;--card:#2d365a;--ink:#f1ecd8;--accent:#f9d976}.hover\:-translate-y-0\.5:hover{--tw-translate-y:-0.125rem}.hover\:-translate-y-0\.5:hover,.hover\:-translate-y-1:hover{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:-translate-y-1:hover{--tw-translate-y:-0.25rem}.hover\:text-\[color\:var\(--ink\)\]:hover{color:var(--ink)}.hover\:shadow-pete-lg:hover{box-shadow:0 6px 0 rgba(60,40,20,.12),0 16px 32px rgba(60,40,20,.12)}.group:hover .group-hover\:-rotate-6{--tw-rotate:-6deg}.group:hover .group-hover\:-rotate-6,.group:hover .group-hover\:scale-105{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group:hover .group-hover\:scale-105{--tw-scale-x:1.05;--tw-scale-y:1.05}.group:hover .group-hover\:underline{text-decoration-line:underline}@media (min-width:640px){.sm\:block{display:block}.sm\:inline{display:inline}.sm\:flex{display:flex}.sm\:inline-flex{display:inline-flex}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:gap-2{gap:.5rem}.sm\:gap-4{gap:1rem}.sm\:p-10{padding:2.5rem}.sm\:pt-12{padding-top:3rem}.sm\:text-2xl{font-size:1.5rem;line-height:2rem}.sm\:text-5xl{font-size:3rem;line-height:1}.sm\:text-6xl{font-size:3.75rem;line-height:1}.sm\:text-lg{font-size:1.125rem;line-height:1.75rem}}@media (min-width:1024px){.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}} \ No newline at end of file +*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}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;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}body{font-family:Nunito,system-ui,sans-serif}html{scroll-behavior:smooth}body,html{overflow-x:hidden}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.pete-channel-nav{scrollbar-width:none;-ms-overflow-style:none}.pete-channel-nav::-webkit-scrollbar{display:none}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.-right-6{right:-1.5rem}.-top-6{top:-1.5rem}.-z-10{z-index:-10}.-z-\[5\]{z-index:-5}.z-50{z-index:50}.mx-auto{margin-left:auto;margin-right:auto}.mb-1{margin-bottom:.25rem}.mb-10{margin-bottom:2.5rem}.mb-12{margin-bottom:3rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-8{margin-bottom:2rem}.mr-1{margin-right:.25rem}.mt-1{margin-top:.25rem}.mt-10{margin-top:2.5rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.line-clamp-2{-webkit-line-clamp:2}.line-clamp-2,.line-clamp-3{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical}.line-clamp-3{-webkit-line-clamp:3}.block{display:block}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.aspect-\[16\/10\]{aspect-ratio:16/10}.h-12{height:3rem}.h-20{height:5rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-9{height:2.25rem}.h-full{height:100%}.max-h-\[55vh\]{max-height:55vh}.max-h-\[60vh\]{max-height:60vh}.min-h-screen{min-height:100vh}.w-12{width:3rem}.w-20{width:5rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-9{width:2.25rem}.w-full{width:100%}.min-w-0{min-width:0}.max-w-2xl{max-width:42rem}.max-w-6xl{max-width:72rem}.max-w-full{max-width:100%}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.max-w-xl{max-width:36rem}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.-translate-y-0{--tw-translate-y:-0px}.-translate-y-0,.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.resize{resize:both}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2rem*var(--tw-space-y-reverse))}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-3xl{border-radius:1.5rem}.rounded-full{border-radius:9999px}.rounded-md{border-radius:.375rem}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-transparent{border-color:transparent}.bg-\[color\:var\(--accent\)\]{background-color:var(--accent)}.bg-\[color\:var\(--bg\)\]{background-color:var(--bg)}.bg-\[color\:var\(--bg-grad\)\]{background-color:var(--bg-grad)}.bg-\[color\:var\(--card\)\]{background-color:var(--card)}.bg-transparent{background-color:transparent}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0\.5{padding:.125rem}.p-1{padding:.25rem}.p-10{padding:2.5rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.py-0{padding-top:0;padding-bottom:0}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.pb-1{padding-bottom:.25rem}.pb-10{padding-bottom:2.5rem}.pb-24{padding-bottom:6rem}.pb-4{padding-bottom:1rem}.pt-1{padding-top:.25rem}.pt-3{padding-top:.75rem}.pt-8{padding-top:2rem}.pt-\[10vh\]{padding-top:10vh}.pt-\[12vh\]{padding-top:12vh}.text-center{text-align:center}.align-\[-4px\]{vertical-align:-4px}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[12rem\]{font-size:12rem}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction)}.leading-none{line-height:1}.leading-snug{line-height:1.375}.leading-tight{line-height:1.25}.tracking-\[0\.2em\]{letter-spacing:.2em}.tracking-tight{letter-spacing:-.025em}.tracking-wider{letter-spacing:.05em}.text-\[color\:var\(--ink\)\]{color:var(--ink)}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.text-white\/85{color:hsla(0,0%,100%,.85)}.underline{text-decoration-line:underline}.decoration-4{text-decoration-thickness:4px}.underline-offset-2{text-underline-offset:2px}.underline-offset-4{text-underline-offset:4px}.accent-\[color\:var\(--accent\)\]{accent-color:var(--accent)}.opacity-20{opacity:.2}.opacity-75{opacity:.75}.opacity-80{opacity:.8}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-sm{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.outline-none{outline:2px solid transparent;outline-offset:2px}.outline{outline-style:solid}.blur{--tw-blur:blur(8px)}.blur,.grayscale{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)}.grayscale{--tw-grayscale:grayscale(100%)}.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-blur-sm{--tw-backdrop-blur:blur(4px);-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,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-1000{transition-duration:1s}.duration-500{transition-duration:.5s}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}.font-display{font-family:Fredoka,Nunito,system-ui,sans-serif;letter-spacing:-.01em}.shadow-pete{box-shadow:0 4px 0 rgba(60,40,20,.1),0 8px 24px rgba(60,40,20,.08)}.shadow-pete-lg{box-shadow:0 6px 0 rgba(60,40,20,.12),0 16px 32px rgba(60,40,20,.12)}.bg-theme-gaming{background-color:#4caf7d}.bg-theme-tech{background-color:#5aa9e6}.bg-theme-politics{background-color:#e07a5f}.bg-theme-eu{background-color:#039}.bg-theme-music{background-color:#b079d6}.bg-theme-anime{background-color:#ec5e8a}.bg-theme-foss{background-color:#d97706}.bg-theme-kids{background-color:#14b8a6}.bg-theme-finance{background-color:#10b981}.bg-theme-lego{background-color:#d01012}.text-theme-gaming{color:#2d8a5a}.text-theme-tech{color:#2f7fb8}.text-theme-politics{color:#b8523a}.text-theme-eu{color:#039}.text-theme-music{color:#8a4fb8}.text-theme-anime{color:#c33a6a}.text-theme-foss{color:#b8530a}.text-theme-kids{color:#0f766e}.text-theme-finance{color:#059669}.text-theme-lego{color:#b00d0e}.decoration-theme-gaming{text-decoration-color:#4caf7d}.decoration-theme-tech{text-decoration-color:#5aa9e6}.decoration-theme-politics{text-decoration-color:#e07a5f}.decoration-theme-eu{text-decoration-color:#039}.decoration-theme-music{text-decoration-color:#b079d6}.decoration-theme-anime{text-decoration-color:#ec5e8a}.decoration-theme-foss{text-decoration-color:#d97706}.decoration-theme-kids{text-decoration-color:#14b8a6}.decoration-theme-finance{text-decoration-color:#10b981}.decoration-theme-lego{text-decoration-color:#d01012}.border-theme-gaming{border-color:#4caf7d}.border-theme-tech{border-color:#5aa9e6}.border-theme-politics{border-color:#e07a5f}.border-theme-eu{border-color:#039}.border-theme-music{border-color:#b079d6}.border-theme-anime{border-color:#ec5e8a}.border-theme-foss{border-color:#d97706}.border-theme-kids{border-color:#14b8a6}.border-theme-finance{border-color:#10b981}.border-theme-lego{border-color:#d01012}.glow-theme-gaming{box-shadow:0 0 0 2px rgba(76,175,125,.35),0 0 24px 4px rgba(76,175,125,.45)}.glow-theme-gaming,.glow-theme-tech{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-tech{box-shadow:0 0 0 2px rgba(90,169,230,.35),0 0 24px 4px rgba(90,169,230,.45)}.glow-theme-politics{box-shadow:0 0 0 2px rgba(224,122,95,.35),0 0 24px 4px rgba(224,122,95,.45)}.glow-theme-eu,.glow-theme-politics{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-eu{box-shadow:0 0 0 2px rgba(0,51,153,.35),0 0 24px 4px rgba(0,51,153,.45)}.glow-theme-music{box-shadow:0 0 0 2px rgba(176,121,214,.35),0 0 24px 4px rgba(176,121,214,.45)}.glow-theme-anime,.glow-theme-music{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-anime{box-shadow:0 0 0 2px rgba(236,94,138,.35),0 0 24px 4px rgba(236,94,138,.45)}.glow-theme-foss{box-shadow:0 0 0 2px rgba(217,119,6,.35),0 0 24px 4px rgba(217,119,6,.45)}.glow-theme-foss,.glow-theme-kids{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-kids{box-shadow:0 0 0 2px rgba(20,184,166,.35),0 0 24px 4px rgba(20,184,166,.45)}.glow-theme-finance{box-shadow:0 0 0 2px rgba(16,185,129,.35),0 0 24px 4px rgba(16,185,129,.45)}.glow-theme-finance,.glow-theme-lego{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-lego{box-shadow:0 0 0 2px rgba(208,16,18,.35),0 0 24px 4px rgba(208,16,18,.45)}@keyframes pete-glow-pulse{0%,to{filter:brightness(1)}50%{filter:brightness(1.08)}}.line-clamp-3{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}.paywall-stamp{position:absolute;inset:0;pointer-events:none;display:flex;align-items:center;justify-content:center;z-index:5;background:rgba(255,250,240,.18)}.paywall-stamp:before{content:"PAYWALLED";font-family:var(--font-display,ui-sans-serif),system-ui,sans-serif;font-weight:900;font-size:clamp(1rem,3.2vw,1.75rem);letter-spacing:.12em;color:rgba(180,30,30,.85);border:.28rem double rgba(180,30,30,.85);padding:.3rem .9rem;transform:rotate(-15deg);text-shadow:0 1px 0 hsla(0,0%,100%,.4);box-shadow:inset 0 0 0 2px hsla(0,0%,100%,.15);background:rgba(255,240,230,.55);border-radius:.25rem;filter:contrast(1.05)}:root,html[data-phase=day]{--bg:#fff7e4;--bg-grad:#ffeec2;--card:#fff;--ink:#3a2e1f;--accent:#f2a541}html[data-phase=dawn]{--bg:#ffe7d6;--bg-grad:#ffc9c9;--card:#fff4ea;--ink:#4a2e2a;--accent:#ff8a65}html[data-phase=dusk]{--bg:#ffd6a8;--bg-grad:#f7a07e;--card:#fff1de;--ink:#3d2417;--accent:#e6553a}html[data-phase=night]{--bg:#1a1f3a;--bg-grad:#2a3358;--card:#2d365a;--ink:#f1ecd8;--accent:#f9d976}.empty\:hidden:empty{display:none}.hover\:-translate-y-0\.5:hover{--tw-translate-y:-0.125rem}.hover\:-translate-y-0\.5:hover,.hover\:-translate-y-1:hover{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:-translate-y-1:hover{--tw-translate-y:-0.25rem}.hover\:text-\[color\:var\(--ink\)\]:hover{color:var(--ink)}.hover\:brightness-105:hover{--tw-brightness:brightness(1.05);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)}.hover\:shadow-pete-lg:hover{box-shadow:0 6px 0 rgba(60,40,20,.12),0 16px 32px rgba(60,40,20,.12)}.focus\:border-\[color\:var\(--accent\)\]:focus{border-color:var(--accent)}.disabled\:opacity-50:disabled{opacity:.5}.group:hover .group-hover\:-rotate-6{--tw-rotate:-6deg}.group:hover .group-hover\:-rotate-6,.group:hover .group-hover\:scale-105{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group:hover .group-hover\:scale-105{--tw-scale-x:1.05;--tw-scale-y:1.05}.group:hover .group-hover\:underline{text-decoration-line:underline}@media (min-width:640px){.sm\:block{display:block}.sm\:inline{display:inline}.sm\:flex{display:flex}.sm\:inline-flex{display:inline-flex}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:gap-2{gap:.5rem}.sm\:gap-4{gap:1rem}.sm\:p-10{padding:2.5rem}.sm\:pt-12{padding-top:3rem}.sm\:text-2xl{font-size:1.5rem;line-height:2rem}.sm\:text-5xl{font-size:3rem;line-height:1}.sm\:text-6xl{font-size:3.75rem;line-height:1}.sm\:text-lg{font-size:1.125rem;line-height:1.75rem}}@media (min-width:1024px){.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}} \ No newline at end of file diff --git a/internal/web/static/js/weather-forecast.js b/internal/web/static/js/weather-forecast.js new file mode 100644 index 0000000..88969fb --- /dev/null +++ b/internal/web/static/js/weather-forecast.js @@ -0,0 +1,379 @@ +// Live local weather. When the visitor saves a postal code we geocode it once +// (Zippopotam — free, no key), then poll Open-Meteo (free, no key) for the +// current conditions + a 5-day forecast. The result drives two bits of UI — a +// header chip and a home-page card — and overrides the seasonal canvas via +// PeteWeather.set(). Everything lives in the browser: no server calls, no keys. +// +// Politeness: the geocode never changes for a postal code, so it's cached +// forever. Forecasts are cached for 2 hours so a reload (or hopping between +// pages) doesn't re-hit Open-Meteo. +(function () { + var LOC_KEY = "pete.weather.loc.v1"; // {country, postal, lat, lon, name, unit} + var CACHE_KEY = "pete.weather.cache.v1"; // {key, fetchedAt, current, daily} + var TTL_MS = 2 * 60 * 60 * 1000; // 2 hours + + // ---- storage helpers ----------------------------------------------------- + function readJSON(key) { + try { var r = localStorage.getItem(key); return r ? JSON.parse(r) : null; } + catch (e) { return null; } + } + function writeJSON(key, val) { + try { localStorage.setItem(key, JSON.stringify(val)); } catch (e) {} + } + function getLoc() { return readJSON(LOC_KEY); } + function setLoc(loc) { + if (loc) writeJSON(LOC_KEY, loc); + else { try { localStorage.removeItem(LOC_KEY); } catch (e) {} } + } + + // ---- WMO weather code → canvas variant + label + emoji ------------------- + // https://open-meteo.com/en/docs — `weather_code` is the WMO code. + function describe(code, isDay) { + var c = Number(code); + function r(variant, intensity, label, dayEmoji, nightEmoji) { + return { variant: variant, intensity: intensity, label: label, + emoji: (isDay === 0 && nightEmoji) ? nightEmoji : dayEmoji }; + } + if (c === 0) return r("clear", "heavy", "Clear sky", "☀️", "🌙"); + if (c === 1) return r("clouds", "light", "Mainly clear", "🌤️", "🌙"); + if (c === 2) return r("clouds", "medium", "Partly cloudy", "⛅", "☁️"); + if (c === 3) return r("clouds", "heavy", "Overcast", "☁️", "☁️"); + if (c === 45 || c === 48) return r("fog", "heavy", "Fog", "🌫️", "🌫️"); + if (c === 51) return r("rain", "light", "Light drizzle", "🌦️", "🌧️"); + if (c === 53) return r("rain", "medium", "Drizzle", "🌦️", "🌧️"); + if (c === 55) return r("rain", "heavy", "Heavy drizzle", "🌧️", "🌧️"); + if (c === 56 || c === 57) return r("rain", "medium", "Freezing drizzle", "🌧️", "🌧️"); + if (c === 61) return r("rain", "light", "Light rain", "🌦️", "🌧️"); + if (c === 63) return r("rain", "medium", "Rain", "🌧️", "🌧️"); + if (c === 65) return r("rain", "heavy", "Heavy rain", "🌧️", "🌧️"); + if (c === 66 || c === 67) return r("rain", "medium", "Freezing rain", "🌧️", "🌧️"); + if (c === 71) return r("snow", "light", "Light snow", "🌨️", "🌨️"); + if (c === 73) return r("snow", "medium", "Snow", "🌨️", "🌨️"); + if (c === 75) return r("snow", "heavy", "Heavy snow", "❄️", "❄️"); + if (c === 77) return r("snow", "light", "Snow grains", "🌨️", "🌨️"); + if (c === 80) return r("rain", "medium", "Light showers", "🌦️", "🌧️"); + if (c === 81) return r("rain", "medium", "Showers", "🌧️", "🌧️"); + if (c === 82) return r("rain", "heavy", "Heavy showers", "⛈️", "⛈️"); + if (c === 85) return r("snow", "medium", "Snow showers", "🌨️", "🌨️"); + if (c === 86) return r("snow", "heavy", "Heavy snow showers", "❄️", "❄️"); + if (c === 95) return r("storm", "heavy", "Thunderstorm", "⛈️", "⛈️"); + if (c === 96 || c === 99) return r("storm", "heavy", "Thunderstorm + hail", "⛈️", "⛈️"); + return r("clouds", "medium", "—", "☁️", "☁️"); + } + + // ---- API calls ----------------------------------------------------------- + function geocode(country, postal) { + var url = "https://api.zippopotam.us/" + encodeURIComponent(country.toLowerCase()) + + "/" + encodeURIComponent(postal); + return fetch(url).then(function (res) { + if (!res.ok) throw new Error("Unknown postal code"); + return res.json(); + }).then(function (data) { + var place = data.places && data.places[0]; + if (!place) throw new Error("Unknown postal code"); + return { + lat: parseFloat(place.latitude), + lon: parseFloat(place.longitude), + name: place["place name"] + (place["state abbreviation"] ? ", " + place["state abbreviation"] : "") + }; + }); + } + + function fetchForecast(loc) { + var unit = loc.unit === "fahrenheit" ? "fahrenheit" : "celsius"; + var url = "https://api.open-meteo.com/v1/forecast" + + "?latitude=" + loc.lat + "&longitude=" + loc.lon + + "¤t=temperature_2m,weather_code,is_day" + + "&daily=weather_code,temperature_2m_max,temperature_2m_min" + + "&temperature_unit=" + unit + + "&timezone=auto&forecast_days=5"; + return fetch(url).then(function (res) { + if (!res.ok) throw new Error("Forecast unavailable"); + return res.json(); + }); + } + + // Cache key ties a forecast to one location; changing location invalidates it. + function cacheKey(loc) { return loc.country + ":" + loc.postal + ":" + (loc.unit || "celsius"); } + + function loadForecast(loc, force) { + var key = cacheKey(loc); + var cached = readJSON(CACHE_KEY); + var fresh = cached && cached.key === key && cached.fetchedAt && + (nowMs() - cached.fetchedAt) < TTL_MS; + if (fresh && !force) return Promise.resolve(cached); + return fetchForecast(loc).then(function (data) { + var entry = { + key: key, + fetchedAt: nowMs(), + current: data.current, + daily: data.daily, + unitSymbol: (data.current_units && data.current_units.temperature_2m) || "°" + }; + writeJSON(CACHE_KEY, entry); + return entry; + }).catch(function (err) { + // Stale-but-present beats nothing if the network blips. + if (cached && cached.key === key) return cached; + throw err; + }); + } + + function nowMs() { return new Date().getTime(); } + + // ---- rendering ----------------------------------------------------------- + var chipEl = document.querySelector("[data-weather-chip]"); + var chipBtn = document.querySelector("[data-weather-loc]"); + var cardMount = document.querySelector("[data-weather-card]"); + + function setChip(html, title) { + if (chipEl) chipEl.innerHTML = html; + if (chipBtn && title) chipBtn.title = title; + } + + function temp(v, sym) { return Math.round(v) + (sym || "°"); } + + function weekday(iso) { + var d = new Date(iso + "T00:00:00"); + return ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][d.getDay()]; + } + + // The /weather page is a variant preview — never let the live forecast + // hijack the canvas the user is inspecting. + var isDemoPage = location.pathname === "/weather"; + + function applyBackground(desc) { + if (isDemoPage) return; + if (window.PeteWeather && typeof window.PeteWeather.set === "function") { + window.PeteWeather.set(desc.variant, desc.intensity); + } + } + + function renderForecast(loc, entry) { + var cur = entry.current; + var sym = entry.unitSymbol; + var desc = describe(cur.weather_code, cur.is_day); + applyBackground(desc); + + setChip(desc.emoji + " " + temp(cur.temperature_2m, sym) + "", + loc.name + " · " + desc.label); + + if (!cardMount) return; + var daily = entry.daily; + var days = ""; + for (var i = 0; i < daily.time.length; i++) { + var dd = describe(daily.weather_code[i], 1); + days += + '
' + + '' + (i === 0 ? "Today" : weekday(daily.time[i])) + '' + + '' + + '' + Math.round(daily.temperature_2m_max[i]) + '°' + + '' + Math.round(daily.temperature_2m_min[i]) + '°' + + '
'; + } + cardMount.innerHTML = + '
' + + '
' + + '
' + + '' + + '
' + + '
' + + '' + temp(cur.temperature_2m, sym) + '' + + '' + desc.label + '' + + '
' + + '
📍 ' + escapeHtml(loc.name) + '
' + + '
' + + '
' + + '' + + '
' + + '
' + days + '
' + + '
'; + // The injected "Change location" button needs the same handler. + var changeBtn = cardMount.querySelector("[data-weather-loc]"); + if (changeBtn) changeBtn.addEventListener("click", openPopover); + } + + function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, function (c) { + return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]; + }); + } + + function showError(msg) { + setChip("📍 Weather", "Set your location"); + if (cardMount && getLoc()) { + cardMount.innerHTML = + '
' + + escapeHtml(msg) + ' · ' + + '
'; + var b = cardMount.querySelector("[data-weather-loc]"); + if (b) b.addEventListener("click", openPopover); + } + } + + // ---- location popover ---------------------------------------------------- + // Built in JS so layout.html only needs the chip button. Class strings are + // full literals so Tailwind's content scan keeps them. + var popover = null; + var selectedUnit = "celsius"; // default for everyone; °F is opt-in + + function setUnit(u) { + selectedUnit = u === "fahrenheit" ? "fahrenheit" : "celsius"; + if (!popover) return; + popover.querySelectorAll("[data-loc-unit]").forEach(function (b) { + var on = b.getAttribute("data-loc-unit") === selectedUnit; + b.classList.toggle("bg-[color:var(--accent)]", on); + b.classList.toggle("text-white", on); + b.classList.toggle("text-[color:var(--ink)]/60", !on); + }); + } + + function defaultCountry() { + var lang = (navigator.language || "en-US"); + var m = lang.match(/[-_]([A-Za-z]{2})$/); + return m ? m[1].toUpperCase() : "US"; + } + + function buildPopover() { + var dlg = document.createElement("div"); + dlg.id = "pete-weather-loc"; + dlg.className = "hidden fixed inset-0 z-50 bg-[color:var(--ink)]/40 backdrop-blur-sm flex items-start justify-center pt-[12vh] px-4"; + dlg.setAttribute("role", "dialog"); + dlg.setAttribute("aria-modal", "true"); + dlg.innerHTML = + '
' + + '
' + + '

Weather location

' + + '' + + '
' + + '
' + + '

Enter your postal code to see the local forecast. The background follows the weather. Saved in this browser only.

' + + '
' + + '' + + '' + + '
' + + '
' + + 'Units' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '
' + + '' + + '' + + '
' + + '
' + + '
'; + document.body.appendChild(dlg); + + // A small, friendly country list (Zippopotam supports ~60). Common ones up + // top; the user can type any postal code that matches their selected country. + var countries = [ + ["US", "🇺🇸 United States"], ["PT", "🇵🇹 Portugal"], ["GB", "🇬🇧 United Kingdom"], + ["ES", "🇪🇸 Spain"], ["FR", "🇫🇷 France"], ["DE", "🇩🇪 Germany"], ["IT", "🇮🇹 Italy"], + ["NL", "🇳🇱 Netherlands"], ["BE", "🇧🇪 Belgium"], ["CH", "🇨🇭 Switzerland"], + ["AT", "🇦🇹 Austria"], ["IE", "🇮🇪 Ireland"], ["DK", "🇩🇰 Denmark"], + ["SE", "🇸🇪 Sweden"], ["PL", "🇵🇱 Poland"], ["CZ", "🇨🇿 Czechia"], + ["CA", "🇨🇦 Canada"], ["AU", "🇦🇺 Australia"], ["NZ", "🇳🇿 New Zealand"], + ["BR", "🇧🇷 Brazil"], ["MX", "🇲🇽 Mexico"], ["JP", "🇯🇵 Japan"], ["IN", "🇮🇳 India"] + ]; + var sel = dlg.querySelector("[data-loc-country]"); + countries.forEach(function (c) { + var o = document.createElement("option"); + o.value = c[0]; o.textContent = c[1]; + sel.appendChild(o); + }); + + dlg.addEventListener("click", function (e) { if (e.target === dlg) closePopover(); }); + dlg.querySelector("[data-loc-close]").addEventListener("click", closePopover); + dlg.querySelector("[data-loc-clear]").addEventListener("click", function () { + setLoc(null); + try { localStorage.removeItem(CACHE_KEY); } catch (e) {} + closePopover(); + // Hand the background back to the server's seasonal default. + if (window.PeteWeather) window.PeteWeather.set(seasonalDefault.variant, seasonalDefault.intensity); + setChip("📍 Weather", "Set your location"); + if (cardMount) cardMount.innerHTML = ""; + }); + dlg.querySelectorAll("[data-loc-unit]").forEach(function (b) { + b.addEventListener("click", function () { setUnit(b.getAttribute("data-loc-unit")); }); + }); + dlg.querySelector("[data-loc-save]").addEventListener("click", onSave); + dlg.querySelector("[data-loc-postal]").addEventListener("keydown", function (e) { + if (e.key === "Enter") onSave(); + }); + return dlg; + } + + function showLocError(msg) { + var el = popover.querySelector("[data-loc-error]"); + if (!el) return; + if (msg) { el.textContent = msg; el.classList.remove("hidden"); } + else { el.classList.add("hidden"); } + } + + function onSave() { + var country = popover.querySelector("[data-loc-country]").value; + var postal = popover.querySelector("[data-loc-postal]").value.trim(); + var saveBtn = popover.querySelector("[data-loc-save]"); + if (!postal) { showLocError("Enter a postal code."); return; } + showLocError(""); + saveBtn.disabled = true; saveBtn.textContent = "…"; + geocode(country, postal).then(function (geo) { + var loc = { + country: country, postal: postal, + lat: geo.lat, lon: geo.lon, name: geo.name, + unit: selectedUnit + }; + setLoc(loc); + try { localStorage.removeItem(CACHE_KEY); } catch (e) {} + closePopover(); + run(true); + }).catch(function (err) { + showLocError((err && err.message) || "Couldn't find that postal code."); + }).then(function () { + saveBtn.disabled = false; saveBtn.textContent = "Save"; + }); + } + + function openPopover() { + if (!popover) popover = buildPopover(); + var loc = getLoc(); + popover.querySelector("[data-loc-country]").value = loc ? loc.country : defaultCountry(); + popover.querySelector("[data-loc-postal]").value = loc ? loc.postal : ""; + setUnit(loc && loc.unit ? loc.unit : "celsius"); + showLocError(""); + popover.classList.remove("hidden"); + var inp = popover.querySelector("[data-loc-postal]"); + if (inp) setTimeout(function () { inp.focus(); }, 30); + } + function closePopover() { + if (popover) popover.classList.add("hidden"); + } + + // ---- boot ---------------------------------------------------------------- + // Remember the server's seasonal pick so "Clear" can restore it. + var seasonalDefault = { + variant: document.documentElement.dataset.weather || "", + intensity: document.documentElement.dataset.intensity || "heavy" + }; + + function run(force) { + var loc = getLoc(); + if (!loc) { setChip("📍 Weather", "Set your location"); return; } + setChip("📍 " + escapeHtml(loc.name.split(",")[0]) + "…", "Loading forecast…"); + loadForecast(loc, force) + .then(function (entry) { renderForecast(loc, entry); }) + .catch(function () { showError("Forecast unavailable"); }); + } + + document.addEventListener("DOMContentLoaded", function () { + if (chipBtn) chipBtn.addEventListener("click", openPopover); + document.addEventListener("keydown", function (e) { + if (e.key === "Escape") closePopover(); + }); + run(false); + }); +})(); diff --git a/internal/web/static/js/weather.js b/internal/web/static/js/weather.js index 69415b1..db012d9 100644 --- a/internal/web/static/js/weather.js +++ b/internal/web/static/js/weather.js @@ -1,13 +1,19 @@ -// Canvas-based seasonal weather. The server picks variant + intensity from -// the Lisbon-local date; this script reads those off and -// renders the appropriate particles. Each variant has a hand-drawn shape -// so silhouettes are recognizable instead of a generic blob. +// Canvas-based weather rendered behind the page. Two drivers feed it: +// +// 1. The server picks a *seasonal* variant + intensity from the Lisbon-local +// date and writes them to . This is the +// default when the visitor hasn't set a location. +// 2. weather-forecast.js, when a postal code is saved, fetches the real +// forecast and calls PeteWeather.set(variant, intensity) to override the +// background with live conditions. +// +// Each variant has a hand-drawn shape so silhouettes are recognizable instead +// of a generic blob. Seasonal variants: rain, petals, jacaranda, motes, leaves. +// Live-weather variants add: clear, clouds, snow, fog, storm. (function () { var root = document.documentElement; var canvas = document.getElementById("pete-weather"); if (!canvas) return; - var variant = root.dataset.weather; - if (!variant) return; var reducedMotion = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches; var STORAGE_KEY = "pete-weather-off"; @@ -47,22 +53,53 @@ resize(); window.addEventListener("resize", resize); - var counts = { rain: 160, petals: 50, jacaranda: 55, motes: 70, leaves: 38 }; - var mults = { light: 0.6, medium: 1.0, heavy: 1.4 }; - var N = Math.round((counts[variant] || 0) * (mults[root.dataset.intensity] || 1)); + var counts = { + rain: 160, petals: 50, jacaranda: 55, motes: 70, leaves: 38, + snow: 150, clouds: 7, fog: 7, clear: 0, storm: 200 + }; + var mults = { light: 0.6, medium: 1.0, heavy: 1.4 }; function rand(a, b) { return a + Math.random() * (b - a); } + var variant = null; // current rendered variant + var intensity = "heavy"; // light | medium | heavy + var particles = []; + var flash = 0; // lightning flash decay (storm only) + var nextBolt = 2; // seconds until next lightning bolt (storm only) + function spawn(initial) { var p = {}; p.x = rand(0, W); p.y = initial ? rand(0, H) : rand(-40, -10); p.z = rand(0.7, 1.3); // depth — affects size + speed + alpha - if (variant === "rain") { + if (variant === "rain" || variant === "storm") { p.vy = rand(700, 1100) * p.z; p.vx = -rand(60, 120); p.len = rand(10, 18) * p.z; p.alpha = rand(0.25, 0.55); + } else if (variant === "snow") { + p.vy = rand(35, 75) * p.z; + p.swayAmp = rand(12, 34); + p.swayFreq = rand(0.3, 0.8); + p.swayPhase = rand(0, Math.PI * 2); + p.size = rand(2, 4.6) * p.z; + p.alpha = rand(0.65, 0.95); + } else if (variant === "clouds") { + // Soft puffs drifting across the upper sky. + p.y = initial ? rand(0, H * 0.5) : rand(-H * 0.1, H * 0.45); + p.x = initial ? rand(0, W) : -rand(120, 320); + p.vx = rand(8, 22); + p.size = rand(80, 170) * p.z; + p.alpha = rand(0.32, 0.55); + p.sprite = Math.floor(rand(0, 3)); + } else if (variant === "fog") { + // Wide translucent bands creeping sideways. + p.y = rand(H * 0.1, H); + p.x = initial ? rand(0, W) : -rand(200, 500); + p.vx = rand(6, 16); + p.w = rand(260, 520); + p.h = rand(70, 150); + p.alpha = rand(0.05, 0.14); } else if (variant === "petals" || variant === "jacaranda") { p.vy = rand(60, 120); p.swayAmp = rand(20, 50); @@ -103,9 +140,6 @@ return p; } - var particles = []; - for (var i = 0; i < N; i++) particles.push(spawn(true)); - var night = root.dataset.phase === "night"; function refreshNight() { night = root.dataset.phase === "night"; } @@ -126,6 +160,165 @@ ctx.stroke(); } + function drawSnow(p) { + // Soft round flake with a faint glow so it reads on light and dark palettes. + var col = night ? "235,242,255" : "255,255,255"; + ctx.fillStyle = "rgba(" + col + "," + p.alpha + ")"; + ctx.beginPath(); + ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); + ctx.fill(); + if (!night) { + // A thin cool outline keeps white flakes visible against cream backgrounds. + ctx.strokeStyle = "rgba(150,180,220," + (p.alpha * 0.5) + ")"; + ctx.lineWidth = 0.6; + ctx.stroke(); + } + } + + // Pre-baked cloud sprites: clustered blobs softened with a heavy blur so each + // cloud reads as one fluffy mass. Built once per palette (day/night) onto + // offscreen canvases, then just drifted — cheap to animate. + var cloudSprites = null; + var cloudSpritesNight = null; + + // Three silhouettes for variety. Each is a list of blobs [cx, cy, r] in a + // 0..1 box (x across, y down) with a flattish base around y≈0.66. + var cloudShapes = [ + [[0.50, 0.50, 0.26], [0.34, 0.56, 0.20], [0.66, 0.56, 0.20], [0.42, 0.40, 0.18], + [0.58, 0.38, 0.17], [0.22, 0.60, 0.15], [0.78, 0.60, 0.15], [0.50, 0.62, 0.22]], + [[0.46, 0.52, 0.24], [0.30, 0.58, 0.18], [0.62, 0.50, 0.22], [0.74, 0.58, 0.16], + [0.40, 0.40, 0.16], [0.56, 0.36, 0.15], [0.18, 0.62, 0.13], [0.84, 0.62, 0.12]], + [[0.52, 0.50, 0.28], [0.36, 0.56, 0.19], [0.68, 0.56, 0.18], [0.48, 0.36, 0.16], + [0.26, 0.60, 0.14], [0.74, 0.60, 0.15], [0.60, 0.62, 0.18], [0.40, 0.62, 0.18]] + ]; + + function makeCloudSprite(col, shape) { + var c = document.createElement("canvas"); + var w = 320, h = 224; + c.width = w; c.height = h; + var cx = c.getContext("2d"); + cx.filter = "blur(18px)"; // the softness that turns blobs into a cloud + cx.fillStyle = "rgba(" + col + ",1)"; + for (var i = 0; i < shape.length; i++) { + cx.beginPath(); + cx.arc(shape[i][0] * w, shape[i][1] * h, shape[i][2] * w, 0, Math.PI * 2); + cx.fill(); + } + return c; + } + + function ensureCloudSprites() { + if (cloudSprites && cloudSpritesNight === night) return; + // Day sky is warm cream so clouds need a cool slate-grey; night uses a pale + // tone against the dark palette. + var col = night ? "206,216,236" : "150,164,190"; + cloudSprites = cloudShapes.map(function (s) { return makeCloudSprite(col, s); }); + cloudSpritesNight = night; + } + + function drawCloud(p) { + ensureCloudSprites(); + var spr = cloudSprites[p.sprite % cloudSprites.length]; + var w = p.size * 2.6; + var h = w * (spr.height / spr.width); + ctx.globalAlpha = p.alpha; + ctx.drawImage(spr, p.x - w / 2, p.y - h / 2, w, h); + ctx.globalAlpha = 1; + } + + function drawFog(p) { + var col = night ? "150,160,180" : "230,228,224"; + var g = ctx.createLinearGradient(p.x - p.w / 2, 0, p.x + p.w / 2, 0); + g.addColorStop(0, "rgba(" + col + ",0)"); + g.addColorStop(0.5, "rgba(" + col + "," + p.alpha + ")"); + g.addColorStop(1, "rgba(" + col + ",0)"); + ctx.fillStyle = g; + ctx.beginPath(); + ctx.ellipse(p.x, p.y, p.w / 2, p.h / 2, 0, 0, Math.PI * 2); + ctx.fill(); + } + + function drawMoon(cx, cy, mr) { + var TAU = Math.PI * 2; + ctx.save(); + // Clip everything to the lunar disc so shading stays inside the sphere. + ctx.beginPath(); + ctx.arc(cx, cy, mr, 0, TAU); + ctx.clip(); + + // Spherical body shading — light source upper-right, terminator lower-left. + var lx = cx + mr * 0.45, ly = cy - mr * 0.45; + var body = ctx.createRadialGradient(lx, ly, mr * 0.1, cx, cy, mr * 1.35); + body.addColorStop(0, "rgba(249,251,255,0.97)"); + body.addColorStop(0.55, "rgba(214,222,240,0.92)"); + body.addColorStop(1, "rgba(140,151,180,0.85)"); + ctx.fillStyle = body; + ctx.fillRect(cx - mr, cy - mr, mr * 2, mr * 2); + + // Limb darkening — fade the edge so the disc reads as a ball, not a coin. + var limb = ctx.createRadialGradient(cx, cy, mr * 0.62, cx, cy, mr); + limb.addColorStop(0, "rgba(18,24,46,0)"); + limb.addColorStop(1, "rgba(18,24,46,0.4)"); + ctx.fillStyle = limb; + ctx.fillRect(cx - mr, cy - mr, mr * 2, mr * 2); + ctx.restore(); + + // Soft outer halo, drawn unclipped around the disc. + var halo = ctx.createRadialGradient(cx, cy, mr, cx, cy, mr * 2.4); + halo.addColorStop(0, "rgba(220,230,255,0.28)"); + halo.addColorStop(1, "rgba(220,230,255,0)"); + ctx.fillStyle = halo; + ctx.beginPath(); + ctx.arc(cx, cy, mr * 2.4, 0, TAU); + ctx.fill(); + } + + function drawClearGlow(t) { + // No particles — render a single calm sun (day) or moon (night) glow that + // breathes very slowly, plus a few stars at night. + var breathe = 0.5 + 0.5 * Math.sin(t * 0.25); + var cx = W * 0.82, cy = H * 0.18, r = Math.min(W, H) * 0.5; + if (night) { + // Ambient sky glow around the moon. + var g = ctx.createRadialGradient(cx, cy, 0, cx, cy, r); + g.addColorStop(0, "rgba(220,230,255," + (0.12 + 0.05 * breathe) + ")"); + g.addColorStop(1, "rgba(220,230,255,0)"); + ctx.fillStyle = g; + ctx.fillRect(0, 0, W, H); + drawMoon(cx, cy, r * 0.16); + } else { + var gd = ctx.createRadialGradient(cx, cy, 0, cx, cy, r); + gd.addColorStop(0, "rgba(255,224,150," + (0.30 + 0.10 * breathe) + ")"); + gd.addColorStop(0.5, "rgba(255,214,120," + (0.10 + 0.04 * breathe) + ")"); + gd.addColorStop(1, "rgba(255,214,120,0)"); + ctx.fillStyle = gd; + ctx.fillRect(0, 0, W, H); + } + } + + // Deterministic star field for clear nights (seeded so they don't jitter). + var stars = []; + function buildStars() { + stars = []; + var n = 60; + for (var i = 0; i < n; i++) { + // Cheap LCG-ish spread keyed by index — stable across frames. + var sx = ((i * 73 + 11) % 100) / 100; + var sy = ((i * 37 + 7) % 100) / 100; + stars.push({ x: sx, y: sy * 0.7, r: 0.6 + (i % 3) * 0.5, ph: (i % 7) }); + } + } + function drawStars(t) { + for (var i = 0; i < stars.length; i++) { + var s = stars[i]; + var tw = 0.4 + 0.6 * (0.5 + 0.5 * Math.sin(t * 0.8 + s.ph)); + ctx.fillStyle = "rgba(255,255,255," + (0.5 * tw) + ")"; + ctx.beginPath(); + ctx.arc(s.x * W, s.y * H, s.r, 0, Math.PI * 2); + ctx.fill(); + } + } + function drawPetals(p) { // Five-petal almond/cherry blossom viewed face-on, with a yellow center. var s = p.size; @@ -219,9 +412,30 @@ ctx.clearRect(0, 0, W, H); refreshNight(); + if (variant === "clear") { + drawClearGlow(t); + if (night) drawStars(t); + raf = requestAnimationFrame(step); + return; + } + + // Storm: drive the lightning flash before drawing rain over it. + if (variant === "storm") { + nextBolt -= dt; + if (nextBolt <= 0) { + flash = 1; + nextBolt = rand(2.5, 7); + } + if (flash > 0) { + ctx.fillStyle = "rgba(235,240,255," + (flash * 0.5) + ")"; + ctx.fillRect(0, 0, W, H); + flash = Math.max(0, flash - dt * 4); // ~0.25s decay + } + } + for (var i = 0; i < particles.length; i++) { var p = particles[i]; - if (variant === "rain") { + if (variant === "rain" || variant === "storm") { p.x += p.vx * dt; p.y += p.vy * dt; if (p.y > H + 20 || p.x < -40) { @@ -230,6 +444,21 @@ continue; } drawRain(p); + } else if (variant === "snow") { + p.y += p.vy * dt; + var sxOff = Math.sin(t * p.swayFreq + p.swayPhase) * p.swayAmp; + if (p.y > H + 10) { particles[i] = spawn(false); continue; } + var ox = p.x; p.x = ox + sxOff; + drawSnow(p); + p.x = ox; + } else if (variant === "clouds") { + p.x += p.vx * dt; + if (p.x - p.size * 1.3 > W + 40) { particles[i] = spawn(false); continue; } + drawCloud(p); + } else if (variant === "fog") { + p.x += p.vx * dt; + if (p.x - p.w / 2 > W + 40) { particles[i] = spawn(false); continue; } + drawFog(p); } else if (variant === "motes") { p.x += p.vx * dt; p.y += p.vy * dt; @@ -256,6 +485,7 @@ } raf = requestAnimationFrame(step); } + function start() { if (raf) return; last = performance.now(); @@ -267,9 +497,34 @@ ctx.clearRect(0, 0, W, H); } + // Build the particle pool for a variant and (re)start rendering if enabled. + function configure(v, inten) { + variant = v || null; + intensity = inten || "heavy"; + flash = 0; nextBolt = rand(1.5, 4); + particles = []; + if (variant === "clear") { buildStars(); } + if (!variant) { stop(); return; } + var N = Math.round((counts[variant] || 0) * (mults[intensity] || 1)); + for (var i = 0; i < N; i++) particles.push(spawn(true)); + if (enabled) start(); + } + var enabled = !userDisabled() && !reducedMotion; + + // Public hook so weather-forecast.js can swap the seasonal default for live + // conditions. Passing a falsy variant clears the canvas. + window.PeteWeather = { + set: function (v, inten) { + root.dataset.weather = v || ""; + if (inten) root.dataset.intensity = inten; + configure(v, inten || intensity); + }, + isEnabled: function () { return enabled; } + }; + syncBtn(enabled); - if (enabled) start(); + configure(root.dataset.weather, root.dataset.intensity); if (toggleBtn) { toggleBtn.addEventListener("click", function () { diff --git a/internal/web/templates/index.html b/internal/web/templates/index.html index 3853c4e..560b4a1 100644 --- a/internal/web/templates/index.html +++ b/internal/web/templates/index.html @@ -11,6 +11,8 @@

+
+ {{if .JustPosted}}
diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html index bab6661..2b9fa4c 100644 --- a/internal/web/templates/layout.html +++ b/internal/web/templates/layout.html @@ -60,6 +60,11 @@ Toggle weather animation {{end}} +