better fan ui
This commit is contained in:
27
src/app.css
27
src/app.css
@@ -722,3 +722,30 @@ a.hover-underline-animation:hover::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
/* ── Nook landing: fan card shake, builds with fan speed ───────── */
|
||||
.fan-card-shake {
|
||||
transform-origin: left center;
|
||||
animation-name: fan-shake;
|
||||
animation-duration: var(--fan-speed, 0.4s);
|
||||
animation-iteration-count: infinite;
|
||||
animation-timing-function: ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes fan-shake {
|
||||
0%, 100% { transform: rotate(0deg); }
|
||||
50% { transform: rotate(calc(var(--fan-amp, 0deg) * -1)); }
|
||||
}
|
||||
|
||||
/* Sputtering sparks off the fan card at max speed */
|
||||
.fan-spark {
|
||||
position: absolute;
|
||||
border-radius: 9999px;
|
||||
background: #ffd23f;
|
||||
box-shadow: 0 0 4px 1px rgba(255, 140, 0, 0.9);
|
||||
animation: fan-spark-fly var(--sd, 0.5s) ease-out var(--sdel, 0s) infinite;
|
||||
}
|
||||
@keyframes fan-spark-fly {
|
||||
0% { transform: translate(0, 0) scale(1); opacity: 0; }
|
||||
10% { opacity: 1; }
|
||||
100% { transform: translate(var(--sx, 20px), var(--sy, 30px)) scale(0.4); opacity: 0; }
|
||||
}
|
||||
|
||||
@@ -250,14 +250,25 @@ function ApprovalDemo() {
|
||||
|
||||
/* ── Demo 3: fans throttle up under load, then back down ──────────── */
|
||||
|
||||
/* Spark pool: deterministic pseudo-random, count gated by fan speed. */
|
||||
const sparkRand = (i: number, k: number) => {
|
||||
const x = Math.sin(i * 127.1 + k * 311.7) * 43758.5453;
|
||||
return x - Math.floor(x);
|
||||
};
|
||||
const SPARKS = Array.from({ length: 20 }, (_, i) => ({
|
||||
sx: `${Math.round(10 + sparkRand(i, 1) * 26)}px`,
|
||||
sy: `${Math.round(18 + sparkRand(i, 2) * 30)}px`,
|
||||
sd: `${(0.4 + sparkRand(i, 3) * 0.25).toFixed(2)}s`,
|
||||
sdel: `${(sparkRand(i, 4) * 1.2).toFixed(2)}s`,
|
||||
size: sparkRand(i, 5) > 0.5 ? 3 : 2
|
||||
}));
|
||||
|
||||
function FansDemo() {
|
||||
const [rpm, setRpm] = createSignal(1270);
|
||||
const [maxed, setMaxed] = createSignal(false);
|
||||
onMount(() => {
|
||||
let frame = 0;
|
||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||
/* Linear ramp toward `target` — close enough to the app's spring
|
||||
at marketing-frame rates, with zero easing-library surface. */
|
||||
const ramp = (target: number, durationMs: number) => {
|
||||
cancelAnimationFrame(frame);
|
||||
const start = rpm();
|
||||
@@ -293,8 +304,20 @@ function FansDemo() {
|
||||
{ rpm: Math.round(rpm() * 0.96), frac: (rpm() * 0.96) / 6000 }
|
||||
];
|
||||
|
||||
const shakeIntensity = () =>
|
||||
Math.max(0, Math.min(1, (rpm() - 2500) / (6000 - 2500)));
|
||||
/* 5 sparks when the shake starts, ~20 at max speed. */
|
||||
const sparkCount = () =>
|
||||
shakeIntensity() > 0 ? 5 + Math.floor(shakeIntensity() * 15) : 0;
|
||||
return (
|
||||
<ModuleCard class="mx-auto w-full max-w-[320px]">
|
||||
<div class="relative mx-auto w-full max-w-[320px]">
|
||||
<ModuleCard
|
||||
class="fan-card-shake relative z-10"
|
||||
style={{
|
||||
"--fan-amp": `${(shakeIntensity() * 2.6).toFixed(2)}deg`,
|
||||
"--fan-speed": `${(0.4 - shakeIntensity() * 0.28).toFixed(3)}s`
|
||||
}}
|
||||
>
|
||||
<div class="space-y-3 text-left">
|
||||
<For each={fans()}>
|
||||
{(f, i) => (
|
||||
@@ -338,7 +361,33 @@ function FansDemo() {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</ModuleCard>
|
||||
</ModuleCard>
|
||||
<div
|
||||
class="pointer-events-none absolute z-0"
|
||||
style={{
|
||||
right: "6px",
|
||||
bottom: "-2px",
|
||||
opacity: sparkCount() > 0 ? "1" : "0",
|
||||
transition: "opacity 0.3s"
|
||||
}}
|
||||
>
|
||||
<For each={SPARKS.slice(0, sparkCount())}>
|
||||
{(s) => (
|
||||
<span
|
||||
class="fan-spark"
|
||||
style={{
|
||||
width: `${s.size}px`,
|
||||
height: `${s.size}px`,
|
||||
"--sx": s.sx,
|
||||
"--sy": s.sy,
|
||||
"--sd": s.sd,
|
||||
"--sdel": s.sdel
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -33,13 +33,18 @@ export const CARD_STROKE = "rgba(255,255,255,0.07)";
|
||||
* One module-card surface shared by every mock panel — the app's
|
||||
* cardChrome(): rounded 12, hairline stroke, white-on-black fill.
|
||||
*/
|
||||
export function ModuleCard(props: { children: JSX.Element; class?: string }) {
|
||||
export function ModuleCard(props: {
|
||||
children: JSX.Element;
|
||||
class?: string;
|
||||
style?: Record<string, string>;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
class={`rounded-xl ${props.class ?? ""}`}
|
||||
style={{
|
||||
background: CARD_FILL,
|
||||
"box-shadow": `inset 0 0 0 1px ${CARD_STROKE}`
|
||||
"box-shadow": `inset 0 0 0 1px ${CARD_STROKE}`,
|
||||
...props.style
|
||||
}}
|
||||
>
|
||||
<div class="p-3">{props.children}</div>
|
||||
|
||||
Reference in New Issue
Block a user