Compare commits

...
2 Commits
3 changed files with 147 additions and 48 deletions

No files matched your search

+2
View File
@@ -2,6 +2,7 @@ import { MetaProvider, Title } from "@solidjs/meta"
import { Router } from "@solidjs/router"
import { FileRoutes } from "@solidjs/start/router"
import { Suspense } from "solid-js"
import { PatternRipple } from "./component/pattern-ripple"
import { I18nProvider, useI18n } from "./context/i18n"
import { LanguageProvider } from "./context/language"
import { strip } from "./lib/language"
@@ -23,6 +24,7 @@ export default function App() {
<I18nProvider>
<MetaProvider>
<AppMeta />
<PatternRipple />
<Suspense>{props.children}</Suspense>
</MetaProvider>
</I18nProvider>
@@ -0,0 +1,97 @@
import { onCleanup, onMount } from "solid-js"
const PATTERNS = '[data-page="stats"] [data-slot$="-pattern"]'
const RIPPLE = {
duration: 4400,
brightness: 50,
trail: 400,
trailBody: 100,
front: 90,
ease: 4,
fade: 3.3,
reach: 1.3,
rearm: 400,
}
type Ripple = { x: number; y: number; radius: number; start: number }
// Plays one soft wave of light across a dot pattern when the cursor moves onto its visible dots.
export function PatternRipple() {
onMount(() => {
if (matchMedia("(prefers-reduced-motion: reduce)").matches) return
const ripples = new Map<HTMLElement, Ripple>()
const leftAt = new WeakMap<HTMLElement, number>()
const state = { current: undefined as HTMLElement | undefined, primed: false, running: false }
const frame = (now: number) => {
ripples.forEach((ripple, el) => {
const progress = (now - ripple.start) / RIPPLE.duration
if (progress >= 1) {
ripples.delete(el)
el.style.removeProperty("--dot-ripple")
return
}
el.style.setProperty("--dot-ripple", rippleGradient(ripple, progress))
})
if (ripples.size > 0) return requestAnimationFrame(frame)
state.running = false
}
const start = (el: HTMLElement, clientX: number, clientY: number) => {
const box = el.getBoundingClientRect()
const visible = visibleBox(el)
ripples.set(el, {
x: clientX - box.left,
y: clientY - box.top,
radius: Math.hypot(
Math.max(clientX - visible.left, visible.right - clientX),
Math.max(clientY - visible.top, visible.bottom - clientY),
),
start: performance.now(),
})
if (state.running) return
state.running = true
requestAnimationFrame(frame)
}
// The pattern must be the top element under the cursor, so text cut-outs over a pattern count as off the dots.
// A cursor already resting on a pattern when the page loads does not trigger a ripple.
const move = (event: PointerEvent) => {
const now = performance.now()
const hit = document.elementFromPoint(event.clientX, event.clientY)
const el = hit instanceof HTMLElement && hit.matches(PATTERNS) ? hit : undefined
if (el !== state.current) {
if (state.current) leftAt.set(state.current, now)
if (el && state.primed && now - (leftAt.get(el) ?? -Infinity) >= RIPPLE.rearm) start(el, event.clientX, event.clientY)
state.current = el
}
state.primed = true
}
document.addEventListener("pointermove", move, { passive: true })
onCleanup(() => document.removeEventListener("pointermove", move))
})
return null
}
function rippleGradient(ripple: Ripple, progress: number) {
const r = ripple.radius * RIPPLE.reach * (1 - (1 - progress) ** RIPPLE.ease)
const alpha = RIPPLE.brightness * (1 - progress) ** RIPPLE.fade
const mix = (amount: number) => `color-mix(in srgb, var(--stats-text) ${amount.toFixed(1)}%, transparent)`
const at = (offset: number) => `${Math.max(0, r + offset).toFixed(1)}px`
return `radial-gradient(circle at ${ripple.x}px ${ripple.y}px, transparent ${at(-RIPPLE.trail)}, ${mix((alpha * RIPPLE.trailBody) / 100)} ${at(-RIPPLE.trail / 2)}, ${mix(alpha)} ${at(0)}, transparent ${at(RIPPLE.front)})`
}
// Some patterns are larger than their clipping container, so the ripple only needs to reach the visible part.
function visibleBox(el: HTMLElement) {
const box = el.getBoundingClientRect()
const clip = el.parentElement?.getBoundingClientRect() ?? box
return {
left: Math.max(box.left, clip.left),
top: Math.max(box.top, clip.top),
right: Math.min(box.right, clip.right),
bottom: Math.min(box.bottom, clip.bottom),
}
}
+48 -48
View File
@@ -674,7 +674,7 @@ body {
[data-page="stats"] [data-slot="footer-pattern"] {
height: 16px;
overflow: hidden;
background: var(--stats-hero-pattern);
background-color: var(--stats-hero-pattern);
mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E");
mask-position: center top;
mask-repeat: repeat;
@@ -883,13 +883,18 @@ body {
display: none;
}
/* Ripple layer drawn by PatternRipple. Pattern rules set only background-color so this layer survives. */
[data-page="stats"] [data-slot$="-pattern"] {
background-image: var(--dot-ripple, none);
}
[data-page="stats"] [data-slot="hero-pattern"] {
order: 2;
flex: 0 0 auto;
width: 100%;
height: 16px;
overflow: hidden;
background: var(--stats-hero-pattern);
background-color: var(--stats-hero-pattern);
mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E");
mask-repeat: repeat;
mask-size: 6px 6px;
@@ -1094,7 +1099,6 @@ body {
[data-page="stats"] [data-component="top-models-chart"] {
--top-models-bar-gap: 12px;
--top-models-dot-size: 6px;
--top-models-dot-offset: 2px;
position: relative;
display: grid;
grid-template-rows: 34px minmax(0, 1fr);
@@ -1224,38 +1228,36 @@ body {
padding-inline: calc(var(--top-models-bar-gap) / 2);
outline: none;
cursor: pointer;
/* Resolved by absolutely positioned children against this slot's width. Three 2px dot columns span the bar, so this inset makes the gap between bars equal the gap between dot columns. */
--top-models-bar-inset: calc((100% - 6px) / 6);
}
/* Three dot columns aligned to the bar's left edge, center, and right edge, so they read as rising out of the bar. The SVG has no viewBox, so the 2px dot keeps its size while the cell stretches. */
[data-page="stats"] [data-slot="top-models-bar"]::before {
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: calc(100% - var(--top-models-bar-gap));
transform: translateX(-50%);
inset: 0 var(--top-models-bar-inset);
content: "";
background: var(--stats-dot);
mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E");
mask-position: var(--top-models-dot-offset) top;
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='2' height='2'/%3E%3C/svg%3E");
mask-position: left top;
mask-repeat: repeat;
mask-size: var(--top-models-dot-size) var(--top-models-dot-size);
-webkit-mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E");
-webkit-mask-position: var(--top-models-dot-offset) top;
mask-size: calc((100% - 2px) / 2) var(--top-models-dot-size);
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='2' height='2'/%3E%3C/svg%3E");
-webkit-mask-position: left top;
-webkit-mask-repeat: repeat;
-webkit-mask-size: var(--top-models-dot-size) var(--top-models-dot-size);
-webkit-mask-size: calc((100% - 2px) / 2) var(--top-models-dot-size);
transition: background-color 120ms ease;
}
@supports (width: round(down, 100%, 1px)) {
[data-page="stats"] [data-slot="top-models-bar"]::before {
width: round(down, calc(100% - var(--top-models-bar-gap)), var(--top-models-dot-size));
}
[data-page="stats"] [data-slot="top-models-bar"]:is(:hover, [data-active="true"])::before {
background: color-mix(in srgb, var(--stats-dot), var(--stats-text) 25%);
}
[data-page="stats"] [data-slot="top-models-stack"] {
position: absolute;
right: calc(var(--top-models-bar-gap) / 2);
right: var(--top-models-bar-inset);
bottom: 0;
left: calc(var(--top-models-bar-gap) / 2);
left: var(--top-models-bar-inset);
z-index: 1;
display: grid;
height: var(--top-models-bar-height);
@@ -1875,7 +1877,7 @@ body {
height: 16px;
margin: 16px 0;
overflow: hidden;
background: var(--stats-hero-pattern);
background-color: var(--stats-hero-pattern);
mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E");
mask-position: center top;
mask-repeat: repeat;
@@ -2876,7 +2878,7 @@ body {
width: 100%;
height: 18px;
overflow: hidden;
background: var(--stats-hero-pattern);
background-color: var(--stats-hero-pattern);
mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E");
mask-repeat: repeat;
mask-size: 6px 6px;
@@ -3016,7 +3018,7 @@ body {
min-width: 64px;
height: 64px;
overflow: hidden;
background: color-mix(in srgb, var(--stats-text) 10%, transparent);
background-color: color-mix(in srgb, var(--stats-text) 10%, transparent);
mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E");
mask-position: center top;
mask-repeat: repeat;
@@ -3333,7 +3335,7 @@ body {
width: 100%;
height: 16px;
overflow: hidden;
background: color-mix(in srgb, var(--stats-text) 10%, transparent);
background-color: color-mix(in srgb, var(--stats-text) 10%, transparent);
mask-position: center top;
-webkit-mask-position: center top;
}
@@ -3480,7 +3482,7 @@ body {
width: 100%;
height: 16px;
overflow: hidden;
background: var(--stats-line);
background-color: var(--stats-line);
mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E");
mask-position: center top;
mask-repeat: repeat;
@@ -3954,7 +3956,7 @@ body {
height: 16px;
margin-bottom: 40px;
overflow: hidden;
background: color-mix(in srgb, var(--stats-text) 10%, transparent);
background-color: color-mix(in srgb, var(--stats-text) 10%, transparent);
mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E");
mask-position: center top;
mask-repeat: repeat;
@@ -4079,7 +4081,7 @@ body {
height: 16px;
margin-bottom: 40px;
overflow: hidden;
background: color-mix(in srgb, var(--stats-text) 10%, transparent);
background-color: color-mix(in srgb, var(--stats-text) 10%, transparent);
mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E");
mask-position: center top;
mask-repeat: repeat;
@@ -4545,7 +4547,6 @@ body {
[data-page="stats"] [data-component="model-usage-chart"] {
--model-usage-bar-gap: 6px;
--model-usage-dot-size: 6px;
--model-usage-dot-offset: 2px;
position: relative;
display: grid;
grid-template-rows: 40px minmax(0, 1fr);
@@ -4603,38 +4604,36 @@ body {
padding-inline: calc(var(--model-usage-bar-gap) / 2);
outline: none;
cursor: pointer;
/* Resolved by absolutely positioned children against this slot's width. Three 2px dot columns span the bar, so this inset makes the gap between bars equal the gap between dot columns. */
--model-usage-bar-inset: calc((100% - 6px) / 6);
}
/* Three dot columns aligned to the bar's left edge, center, and right edge, so they read as rising out of the bar. The SVG has no viewBox, so the 2px dot keeps its size while the cell stretches. */
[data-page="stats"] [data-slot="model-usage-column"]::before {
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: calc(100% - var(--model-usage-bar-gap));
transform: translateX(-50%);
inset: 0 var(--model-usage-bar-inset);
content: "";
background: var(--stats-dot);
mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E");
mask-position: var(--model-usage-dot-offset) top;
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='2' height='2'/%3E%3C/svg%3E");
mask-position: left top;
mask-repeat: repeat;
mask-size: var(--model-usage-dot-size) var(--model-usage-dot-size);
-webkit-mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E");
-webkit-mask-position: var(--model-usage-dot-offset) top;
mask-size: calc((100% - 2px) / 2) var(--model-usage-dot-size);
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='2' height='2'/%3E%3C/svg%3E");
-webkit-mask-position: left top;
-webkit-mask-repeat: repeat;
-webkit-mask-size: var(--model-usage-dot-size) var(--model-usage-dot-size);
-webkit-mask-size: calc((100% - 2px) / 2) var(--model-usage-dot-size);
transition: background-color 120ms ease;
}
@supports (width: round(down, 100%, 1px)) {
[data-page="stats"] [data-slot="model-usage-column"]::before {
width: round(down, calc(100% - var(--model-usage-bar-gap)), var(--model-usage-dot-size));
}
[data-page="stats"] [data-slot="model-usage-column"]:is(:hover, [data-active="true"])::before {
background: color-mix(in srgb, var(--stats-dot), var(--stats-text) 25%);
}
[data-page="stats"] [data-slot="model-usage-bar"] {
position: absolute;
right: calc(var(--model-usage-bar-gap) / 2);
right: var(--model-usage-bar-inset);
bottom: 0;
left: calc(var(--model-usage-bar-gap) / 2);
left: var(--model-usage-bar-inset);
z-index: 1;
height: var(--model-usage-fill);
min-height: 0;
@@ -5690,7 +5689,7 @@ body {
height: 16px;
margin-inline: -40px;
overflow: hidden;
background: color-mix(in srgb, var(--stats-text) 10%, transparent);
background-color: color-mix(in srgb, var(--stats-text) 10%, transparent);
mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E");
mask-position: center top;
mask-repeat: repeat;
@@ -8196,7 +8195,8 @@ body {
}
[data-page="stats"] [data-component="model-usage-chart"] {
--model-usage-mobile-bar-width: 16px;
/* 18px puts three 2px dot columns 6px apart, matching the 6px bar gap. */
--model-usage-mobile-bar-width: 18px;
--model-usage-mobile-edge-space: 48px;
--model-usage-mobile-track-width: calc(
var(--model-usage-count) * (var(--model-usage-mobile-bar-width) + var(--model-usage-bar-gap)) +
@@ -8237,7 +8237,7 @@ body {
}
[data-page="stats"] [data-slot="model-usage-column"]::before {
width: 100%;
inset: 0;
}
[data-page="stats"] [data-slot="model-usage-bar"] {