mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-23 21:16:06 +00:00
feat(tui): redesign free-limit upsell dialog with animated GO logo
- Replace generic dialog content with custom panel layout - Add animated GO block logo reusing the existing opencode logo shimmer/click-burst system via a new shape prop on Logo - Add idle shimmer mode: continuous tight white specular highlight traveling across lit letter pixels only (shadow cells excluded) - Add BgPulse component: radial primary-tinted rings emanating from the GO center, phase-synced with the logo shimmer so rings emit exactly when the shine reaches the center of GO - Pixel-accurate centering via refs on the logo box - Buttons reordered: 'don't show again' bottom-left, 'subscribe' bottom-right and default-selected
This commit is contained in:
parent
0e86466f99
commit
84fa5f0645
4 changed files with 336 additions and 96 deletions
93
packages/opencode/src/cli/cmd/tui/component/bg-pulse.tsx
Normal file
93
packages/opencode/src/cli/cmd/tui/component/bg-pulse.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { BoxRenderable, RGBA } from "@opentui/core"
|
||||
import { createMemo, createSignal, For, onCleanup, onMount } from "solid-js"
|
||||
import { tint, useTheme } from "@tui/context/theme"
|
||||
|
||||
const PERIOD = 4600
|
||||
const RINGS = 3
|
||||
const WIDTH = 4.2
|
||||
const TAIL = 10.5
|
||||
const AMP = 0.4
|
||||
const TAIL_AMP = 0.14
|
||||
const BREATH_AMP = 0.06
|
||||
const BREATH_SPEED = 0.0008
|
||||
// Offset so bg ring emits from GO center at the moment the logo shine arrives there.
|
||||
// Logo shine travels ~0.29 of its period to cross from origin to GO center.
|
||||
const PHASE_OFFSET = 0.29
|
||||
|
||||
export function BgPulse(props: { centerX?: number; centerY?: number }) {
|
||||
const { theme } = useTheme()
|
||||
const [now, setNow] = createSignal(performance.now())
|
||||
const [size, setSize] = createSignal<{ width: number; height: number }>({ width: 0, height: 0 })
|
||||
let box: BoxRenderable | undefined
|
||||
|
||||
const timer = setInterval(() => setNow(performance.now()), 50)
|
||||
onCleanup(() => clearInterval(timer))
|
||||
|
||||
const sync = () => {
|
||||
if (!box) return
|
||||
setSize({ width: box.width, height: box.height })
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
sync()
|
||||
box?.on("resize", sync)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
box?.off("resize", sync)
|
||||
})
|
||||
|
||||
const grid = createMemo(() => {
|
||||
const t = now()
|
||||
const w = size().width
|
||||
const h = size().height
|
||||
if (w === 0 || h === 0) return [] as RGBA[][]
|
||||
const cxv = props.centerX ?? w / 2
|
||||
const cyv = props.centerY ?? h / 2
|
||||
const reach = Math.hypot(Math.max(cxv, w - cxv), Math.max(cyv, h - cyv) * 2) + TAIL
|
||||
const rows = [] as RGBA[][]
|
||||
for (let y = 0; y < h; y++) {
|
||||
const row = [] as RGBA[]
|
||||
for (let x = 0; x < w; x++) {
|
||||
const dx = x + 0.5 - cxv
|
||||
const dy = (y + 0.5 - cyv) * 2
|
||||
const dist = Math.hypot(dx, dy)
|
||||
let level = 0
|
||||
for (let i = 0; i < RINGS; i++) {
|
||||
const offset = i / RINGS
|
||||
const phase = (t / PERIOD + offset - PHASE_OFFSET + 1) % 1
|
||||
const envelope = Math.sin(phase * Math.PI)
|
||||
const eased = envelope * envelope * (3 - 2 * envelope)
|
||||
const head = phase * reach
|
||||
const delta = dist - head
|
||||
const crest = Math.abs(delta) < WIDTH ? 0.5 + 0.5 * Math.cos((delta / WIDTH) * Math.PI) : 0
|
||||
const tail = delta < 0 && delta > -TAIL ? (1 + delta / TAIL) ** 2.3 : 0
|
||||
level += (crest * AMP + tail * TAIL_AMP) * eased
|
||||
}
|
||||
const breath = (0.5 + 0.5 * Math.sin(t * BREATH_SPEED)) * BREATH_AMP
|
||||
const strength = Math.min(1, level / RINGS + breath)
|
||||
row.push(tint(theme.backgroundPanel, theme.primary, strength * 0.7))
|
||||
}
|
||||
rows.push(row)
|
||||
}
|
||||
return rows
|
||||
})
|
||||
|
||||
return (
|
||||
<box ref={(item: BoxRenderable) => (box = item)} width="100%" height="100%">
|
||||
<For each={grid()}>
|
||||
{(row) => (
|
||||
<box flexDirection="row">
|
||||
<For each={row}>
|
||||
{(color) => (
|
||||
<text bg={color} fg={color} selectable={false}>
|
||||
{" "}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,12 +1,16 @@
|
|||
import { RGBA, TextAttributes } from "@opentui/core"
|
||||
import { BoxRenderable, RGBA, TextAttributes } from "@opentui/core"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import open from "open"
|
||||
import { createSignal } from "solid-js"
|
||||
import { createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { selectedForeground, useTheme } from "@tui/context/theme"
|
||||
import { useDialog, type DialogContext } from "@tui/ui/dialog"
|
||||
import { Link } from "@tui/ui/link"
|
||||
import { GoLogo } from "./logo"
|
||||
import { BgPulse } from "./bg-pulse"
|
||||
|
||||
const GO_URL = "https://opencode.ai/go"
|
||||
const PAD_X = 3
|
||||
const PAD_TOP_OUTER = 1
|
||||
|
||||
export type DialogGoUpsellProps = {
|
||||
onClose?: (dontShowAgain?: boolean) => void
|
||||
|
|
@ -27,62 +31,96 @@ export function DialogGoUpsell(props: DialogGoUpsellProps) {
|
|||
const dialog = useDialog()
|
||||
const { theme } = useTheme()
|
||||
const fg = selectedForeground(theme)
|
||||
const [selected, setSelected] = createSignal(0)
|
||||
const [selected, setSelected] = createSignal<"dismiss" | "subscribe">("subscribe")
|
||||
const [center, setCenter] = createSignal<{ x: number; y: number } | undefined>()
|
||||
let content: BoxRenderable | undefined
|
||||
let logoBox: BoxRenderable | undefined
|
||||
|
||||
const sync = () => {
|
||||
if (!content || !logoBox) return
|
||||
setCenter({
|
||||
x: logoBox.x - content.x + logoBox.width / 2,
|
||||
y: logoBox.y - content.y + logoBox.height / 2,
|
||||
})
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
sync()
|
||||
content?.on("resize", sync)
|
||||
logoBox?.on("resize", sync)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
content?.off("resize", sync)
|
||||
logoBox?.off("resize", sync)
|
||||
})
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (evt.name === "left" || evt.name === "right" || evt.name === "tab") {
|
||||
setSelected((s) => (s === 0 ? 1 : 0))
|
||||
setSelected((s) => (s === "subscribe" ? "dismiss" : "subscribe"))
|
||||
return
|
||||
}
|
||||
if (evt.name !== "return") return
|
||||
if (selected() === 0) subscribe(props, dialog)
|
||||
else dismiss(props, dialog)
|
||||
if (evt.name === "return") {
|
||||
if (selected() === "subscribe") subscribe(props, dialog)
|
||||
else dismiss(props, dialog)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text}>
|
||||
Free limit reached
|
||||
</text>
|
||||
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
<box ref={(item: BoxRenderable) => (content = item)}>
|
||||
<box position="absolute" top={-PAD_TOP_OUTER} left={0} right={0} bottom={0} zIndex={0}>
|
||||
<BgPulse centerX={center()?.x} centerY={(center()?.y ?? 0) + PAD_TOP_OUTER} />
|
||||
</box>
|
||||
<box gap={1} paddingBottom={1}>
|
||||
<text fg={theme.textMuted}>
|
||||
Subscribe to OpenCode Go to keep going with reliable access to the best open-source models, starting at
|
||||
$5/month.
|
||||
</text>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<box paddingLeft={PAD_X} paddingRight={PAD_X} paddingBottom={1} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text}>
|
||||
Free limit reached
|
||||
</text>
|
||||
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<box>
|
||||
<text fg={theme.textMuted}>
|
||||
Subscribe to OpenCode Go to keep going with reliable access to the best open-source models, starting at
|
||||
$5/month.
|
||||
</text>
|
||||
</box>
|
||||
<box alignItems="center" gap={1} paddingBottom={1}>
|
||||
<box ref={(item: BoxRenderable) => (logoBox = item)}>
|
||||
<GoLogo />
|
||||
</box>
|
||||
<Link href={GO_URL} fg={theme.primary} />
|
||||
</box>
|
||||
</box>
|
||||
<box flexDirection="row" justifyContent="flex-end" gap={1} paddingBottom={1}>
|
||||
<box
|
||||
paddingLeft={3}
|
||||
paddingRight={3}
|
||||
backgroundColor={selected() === 0 ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
|
||||
onMouseOver={() => setSelected(0)}
|
||||
onMouseUp={() => subscribe(props, dialog)}
|
||||
>
|
||||
<text fg={selected() === 0 ? fg : theme.text} attributes={selected() === 0 ? TextAttributes.BOLD : undefined}>
|
||||
subscribe
|
||||
</text>
|
||||
</box>
|
||||
<box
|
||||
paddingLeft={3}
|
||||
paddingRight={3}
|
||||
backgroundColor={selected() === 1 ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
|
||||
onMouseOver={() => setSelected(1)}
|
||||
onMouseUp={() => dismiss(props, dialog)}
|
||||
>
|
||||
<text
|
||||
fg={selected() === 1 ? fg : theme.textMuted}
|
||||
attributes={selected() === 1 ? TextAttributes.BOLD : undefined}
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<box
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
backgroundColor={selected() === "dismiss" ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
|
||||
onMouseOver={() => setSelected("dismiss")}
|
||||
onMouseUp={() => dismiss(props, dialog)}
|
||||
>
|
||||
don't show again
|
||||
</text>
|
||||
<text
|
||||
fg={selected() === "dismiss" ? fg : theme.textMuted}
|
||||
attributes={selected() === "dismiss" ? TextAttributes.BOLD : undefined}
|
||||
>
|
||||
don't show again
|
||||
</text>
|
||||
</box>
|
||||
<box
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
backgroundColor={selected() === "subscribe" ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
|
||||
onMouseOver={() => setSelected("subscribe")}
|
||||
onMouseUp={() => subscribe(props, dialog)}
|
||||
>
|
||||
<text
|
||||
fg={selected() === "subscribe" ? fg : theme.text}
|
||||
attributes={selected() === "subscribe" ? TextAttributes.BOLD : undefined}
|
||||
>
|
||||
subscribe
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,24 @@ import { BoxRenderable, MouseButton, MouseEvent, RGBA, TextAttributes } from "@o
|
|||
import { For, createMemo, createSignal, onCleanup, type JSX } from "solid-js"
|
||||
import { useTheme, tint } from "@tui/context/theme"
|
||||
import * as Sound from "@tui/util/sound"
|
||||
import { logo } from "@/cli/logo"
|
||||
import { go, logo } from "@/cli/logo"
|
||||
|
||||
export type LogoShape = {
|
||||
left: string[]
|
||||
right: string[]
|
||||
}
|
||||
|
||||
const IDLE_PERIOD = 4600
|
||||
const IDLE_RINGS = 3
|
||||
const IDLE_WIDTH = 1.4
|
||||
const IDLE_TAIL = 6.5
|
||||
const IDLE_AMP = 0.9
|
||||
const IDLE_PEAK = 2.2
|
||||
const IDLE_TAIL_AMP = 0.3
|
||||
const IDLE_PULSE = 0.0014
|
||||
const IDLE_PULSE_AMP = 0.08
|
||||
const IDLE_NOISE = 0.14
|
||||
const IDLE_ORIGIN = { x: -1.2, y: -0.8 }
|
||||
|
||||
// Shadow markers (rendered chars in parens):
|
||||
// _ = full shadow cell (space with bg=shadow)
|
||||
|
|
@ -74,9 +91,6 @@ type Frame = {
|
|||
spark: number
|
||||
}
|
||||
|
||||
const LEFT = logo.left[0]?.length ?? 0
|
||||
const FULL = logo.left.map((line, i) => line + " ".repeat(GAP) + logo.right[i])
|
||||
const SPAN = Math.hypot(FULL[0]?.length ?? 0, FULL.length * 2) * 0.94
|
||||
const NEAR = [
|
||||
[1, 0],
|
||||
[1, 1],
|
||||
|
|
@ -140,7 +154,7 @@ function noise(x: number, y: number, t: number) {
|
|||
}
|
||||
|
||||
function lit(char: string) {
|
||||
return char !== " " && char !== "_" && char !== "~"
|
||||
return char !== " " && char !== "_" && char !== "~" && char !== ","
|
||||
}
|
||||
|
||||
function key(x: number, y: number) {
|
||||
|
|
@ -188,12 +202,12 @@ function route(list: Array<{ x: number; y: number }>) {
|
|||
return path
|
||||
}
|
||||
|
||||
function mapGlyphs() {
|
||||
function mapGlyphs(full: string[]) {
|
||||
const cells = [] as Array<{ x: number; y: number }>
|
||||
|
||||
for (let y = 0; y < FULL.length; y++) {
|
||||
for (let x = 0; x < (FULL[y]?.length ?? 0); x++) {
|
||||
if (lit(FULL[y]?.[x] ?? " ")) cells.push({ x, y })
|
||||
for (let y = 0; y < full.length; y++) {
|
||||
for (let x = 0; x < (full[y]?.length ?? 0); x++) {
|
||||
if (lit(full[y]?.[x] ?? " ")) cells.push({ x, y })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -237,9 +251,25 @@ function mapGlyphs() {
|
|||
return { glyph, trace, center }
|
||||
}
|
||||
|
||||
const MAP = mapGlyphs()
|
||||
type LogoContext = {
|
||||
LEFT: number
|
||||
FULL: string[]
|
||||
SPAN: number
|
||||
MAP: ReturnType<typeof mapGlyphs>
|
||||
shape: LogoShape
|
||||
}
|
||||
|
||||
function shimmer(x: number, y: number, frame: Frame) {
|
||||
function build(shape: LogoShape): LogoContext {
|
||||
const LEFT = shape.left[0]?.length ?? 0
|
||||
const FULL = shape.left.map((line, i) => line + " ".repeat(GAP) + shape.right[i])
|
||||
const SPAN = Math.hypot(FULL[0]?.length ?? 0, FULL.length * 2) * 0.94
|
||||
return { LEFT, FULL, SPAN, MAP: mapGlyphs(FULL), shape }
|
||||
}
|
||||
|
||||
const DEFAULT = build(logo)
|
||||
const GO = build(go)
|
||||
|
||||
function shimmer(x: number, y: number, frame: Frame, ctx: LogoContext) {
|
||||
return frame.list.reduce((best, item) => {
|
||||
const age = frame.t - item.at
|
||||
if (age < SHIMMER_IN || age > LIFE) return best
|
||||
|
|
@ -247,7 +277,7 @@ function shimmer(x: number, y: number, frame: Frame) {
|
|||
const dy = y * 2 + 1 - item.y
|
||||
const dist = Math.hypot(dx, dy)
|
||||
const p = age / LIFE
|
||||
const r = SPAN * (1 - (1 - p) ** EXPAND)
|
||||
const r = ctx.SPAN * (1 - (1 - p) ** EXPAND)
|
||||
const lag = r - dist
|
||||
if (lag < 0.18 || lag > SHIMMER_OUT) return best
|
||||
const band = Math.exp(-(((lag - 1.05) / 0.68) ** 2))
|
||||
|
|
@ -258,19 +288,19 @@ function shimmer(x: number, y: number, frame: Frame) {
|
|||
}, 0)
|
||||
}
|
||||
|
||||
function remain(x: number, y: number, item: Release, t: number) {
|
||||
function remain(x: number, y: number, item: Release, t: number, ctx: LogoContext) {
|
||||
const age = t - item.at
|
||||
if (age < 0 || age > LIFE) return 0
|
||||
const p = age / LIFE
|
||||
const dx = x + 0.5 - item.x - 0.5
|
||||
const dy = y * 2 + 1 - item.y * 2 - 1
|
||||
const dist = Math.hypot(dx, dy)
|
||||
const r = SPAN * (1 - (1 - p) ** EXPAND)
|
||||
const r = ctx.SPAN * (1 - (1 - p) ** EXPAND)
|
||||
if (dist > r) return 1
|
||||
return clamp((r - dist) / 1.35 < 1 ? 1 - (r - dist) / 1.35 : 0)
|
||||
}
|
||||
|
||||
function wave(x: number, y: number, frame: Frame, live: boolean) {
|
||||
function wave(x: number, y: number, frame: Frame, live: boolean, ctx: LogoContext) {
|
||||
return frame.list.reduce((sum, item) => {
|
||||
const age = frame.t - item.at
|
||||
if (age < 0 || age > LIFE) return sum
|
||||
|
|
@ -278,7 +308,7 @@ function wave(x: number, y: number, frame: Frame, live: boolean) {
|
|||
const dx = x + 0.5 - item.x
|
||||
const dy = y * 2 + 1 - item.y
|
||||
const dist = Math.hypot(dx, dy)
|
||||
const r = SPAN * (1 - (1 - p) ** EXPAND)
|
||||
const r = ctx.SPAN * (1 - (1 - p) ** EXPAND)
|
||||
const fade = (1 - p) ** 1.32
|
||||
const j = 1.02 + noise(x + item.x * 0.7, y + item.y * 0.7, item.at * 0.002 + age * 0.06) * 0.52
|
||||
const edge = Math.exp(-(((dist - r) / WIDTH) ** 2)) * GAIN * fade * item.force * j
|
||||
|
|
@ -292,7 +322,7 @@ function wave(x: number, y: number, frame: Frame, live: boolean) {
|
|||
}, 0)
|
||||
}
|
||||
|
||||
function field(x: number, y: number, frame: Frame) {
|
||||
function field(x: number, y: number, frame: Frame, ctx: LogoContext) {
|
||||
const held = frame.hold
|
||||
const rest = frame.release
|
||||
const item = held ?? rest
|
||||
|
|
@ -326,11 +356,11 @@ function field(x: number, y: number, frame: Frame) {
|
|||
Math.max(0, noise(item.x * 3.1, item.y * 2.7, frame.t * 1.7) - 0.72) *
|
||||
Math.exp(-(dist * dist) / 0.15) *
|
||||
lerp(0.08, 0.42, body)
|
||||
const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t) : 1
|
||||
const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t, ctx) : 1
|
||||
return (core + shell + ember + ring + fork + glitch + lash + flicker - dim) * fade
|
||||
}
|
||||
|
||||
function pick(x: number, y: number, frame: Frame) {
|
||||
function pick(x: number, y: number, frame: Frame, ctx: LogoContext) {
|
||||
const held = frame.hold
|
||||
const rest = frame.release
|
||||
const item = held ?? rest
|
||||
|
|
@ -339,26 +369,26 @@ function pick(x: number, y: number, frame: Frame) {
|
|||
const dx = x + 0.5 - item.x - 0.5
|
||||
const dy = y * 2 + 1 - item.y * 2 - 1
|
||||
const dist = Math.hypot(dx, dy)
|
||||
const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t) : 1
|
||||
const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t, ctx) : 1
|
||||
return Math.exp(-(dist * dist) / 1.7) * lerp(0.2, 0.96, rise) * fade
|
||||
}
|
||||
|
||||
function select(x: number, y: number) {
|
||||
const direct = MAP.glyph.get(key(x, y))
|
||||
function select(x: number, y: number, ctx: LogoContext) {
|
||||
const direct = ctx.MAP.glyph.get(key(x, y))
|
||||
if (direct !== undefined) return direct
|
||||
|
||||
const near = NEAR.map(([dx, dy]) => MAP.glyph.get(key(x + dx, y + dy))).find(
|
||||
const near = NEAR.map(([dx, dy]) => ctx.MAP.glyph.get(key(x + dx, y + dy))).find(
|
||||
(item): item is number => item !== undefined,
|
||||
)
|
||||
return near
|
||||
}
|
||||
|
||||
function trace(x: number, y: number, frame: Frame) {
|
||||
function trace(x: number, y: number, frame: Frame, ctx: LogoContext) {
|
||||
const held = frame.hold
|
||||
const rest = frame.release
|
||||
const item = held ?? rest
|
||||
if (!item || item.glyph === undefined) return 0
|
||||
const step = MAP.trace.get(key(x, y))
|
||||
const step = ctx.MAP.trace.get(key(x, y))
|
||||
if (!step || step.glyph !== item.glyph || step.l < 2) return 0
|
||||
const age = frame.t - item.at
|
||||
const rise = held ? ramp(age, HOLD, CHARGE) : rest!.rise
|
||||
|
|
@ -368,29 +398,70 @@ function trace(x: number, y: number, frame: Frame) {
|
|||
const dist = Math.min(Math.abs(step.i - head), step.l - Math.abs(step.i - head))
|
||||
const tail = (head - TAIL + step.l) % step.l
|
||||
const lag = Math.min(Math.abs(step.i - tail), step.l - Math.abs(step.i - tail))
|
||||
const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t) : 1
|
||||
const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t, ctx) : 1
|
||||
const core = Math.exp(-((dist / 1.05) ** 2)) * lerp(0.8, 2.35, rise)
|
||||
const glow = Math.exp(-((dist / 1.85) ** 2)) * lerp(0.08, 0.34, rise)
|
||||
const trail = Math.exp(-((lag / 1.45) ** 2)) * lerp(0.04, 0.42, rise)
|
||||
return (core + glow + trail) * appear * fade
|
||||
}
|
||||
|
||||
function bloom(x: number, y: number, frame: Frame) {
|
||||
function idle(x: number, y: number, frame: Frame, ctx: LogoContext): { glow: number; peak: number } {
|
||||
const w = ctx.FULL[0]?.length ?? 1
|
||||
const h = ctx.FULL.length * 2
|
||||
const reach = Math.hypot(w, h) + IDLE_TAIL * 2
|
||||
const dx = x + 0.5 - IDLE_ORIGIN.x
|
||||
const dy = y * 2 + 1 - IDLE_ORIGIN.y
|
||||
const dist = Math.hypot(dx, dy)
|
||||
const angle = Math.atan2(dy, dx)
|
||||
const wob1 = noise(x * 0.24, y * 0.38, frame.t * 0.0004) - 0.5
|
||||
const wob2 = noise(x * 0.08, y * 0.11, frame.t * 0.00015) - 0.5
|
||||
const ripple = Math.sin(angle * 4 + frame.t * 0.0015) * 0.35
|
||||
const jitter = (wob1 * 0.65 + wob2 * 0.25 + ripple * 0.1) * IDLE_NOISE * Math.min(dist, 7)
|
||||
const traveled = dist + jitter
|
||||
let glow = 0
|
||||
let peak = 0
|
||||
for (let i = 0; i < IDLE_RINGS; i++) {
|
||||
const offset = i / IDLE_RINGS
|
||||
const phase = (frame.t / IDLE_PERIOD + offset) % 1
|
||||
const envelope = Math.sin(phase * Math.PI)
|
||||
const eased = envelope * envelope * (3 - 2 * envelope)
|
||||
const head = phase * reach
|
||||
const delta = traveled - head
|
||||
const crestHalf = IDLE_WIDTH * 1.6
|
||||
const crest = Math.abs(delta) < crestHalf ? 0.5 + 0.5 * Math.cos((delta / crestHalf) * Math.PI) : 0
|
||||
const sharp = crest * crest
|
||||
const tailRange = IDLE_TAIL * 2.8
|
||||
const tail = delta < 0 && delta > -tailRange ? (1 + delta / tailRange) ** 2.2 : 0
|
||||
glow += (sharp * IDLE_AMP + tail * IDLE_TAIL_AMP) * eased
|
||||
peak += sharp * IDLE_PEAK * eased
|
||||
}
|
||||
const angular = 0.84 + 0.16 * Math.sin(angle * 1.6 + frame.t * 0.0005)
|
||||
const falloff = Math.max(0, 1 - dist / (reach * 0.95))
|
||||
const breath = (0.5 + 0.5 * Math.sin(frame.t * IDLE_PULSE)) * IDLE_PULSE_AMP
|
||||
const rings = IDLE_RINGS
|
||||
return {
|
||||
glow: (glow / rings) * falloff * angular + breath,
|
||||
peak: (peak / rings) * falloff,
|
||||
}
|
||||
}
|
||||
|
||||
function bloom(x: number, y: number, frame: Frame, ctx: LogoContext) {
|
||||
const item = frame.glow
|
||||
if (!item) return 0
|
||||
const glyph = MAP.glyph.get(key(x, y))
|
||||
const glyph = ctx.MAP.glyph.get(key(x, y))
|
||||
if (glyph !== item.glyph) return 0
|
||||
const age = frame.t - item.at
|
||||
if (age < 0 || age > GLOW_OUT) return 0
|
||||
const p = age / GLOW_OUT
|
||||
const flash = (1 - p) ** 2
|
||||
const dx = x + 0.5 - MAP.center.get(item.glyph)!.x
|
||||
const dy = y * 2 + 1 - MAP.center.get(item.glyph)!.y
|
||||
const dx = x + 0.5 - ctx.MAP.center.get(item.glyph)!.x
|
||||
const dy = y * 2 + 1 - ctx.MAP.center.get(item.glyph)!.y
|
||||
const bias = Math.exp(-((Math.hypot(dx, dy) / 2.8) ** 2))
|
||||
return lerp(item.force, item.force * 0.18, p) * lerp(0.72, 1.1, bias) * flash
|
||||
}
|
||||
|
||||
export function Logo() {
|
||||
export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } = {}) {
|
||||
const ctx = props.shape ? build(props.shape) : DEFAULT
|
||||
const { theme } = useTheme()
|
||||
const [rings, setRings] = createSignal<Ring[]>([])
|
||||
const [hold, setHold] = createSignal<Hold>()
|
||||
|
|
@ -430,6 +501,7 @@ export function Logo() {
|
|||
}
|
||||
if (!live) setRelease(undefined)
|
||||
if (live || hold() || release() || glow()) return
|
||||
if (props.idle) return
|
||||
stop()
|
||||
}
|
||||
|
||||
|
|
@ -438,8 +510,13 @@ export function Logo() {
|
|||
timer = setInterval(tick, 16)
|
||||
}
|
||||
|
||||
if (props.idle) {
|
||||
setNow(performance.now())
|
||||
start()
|
||||
}
|
||||
|
||||
const hit = (x: number, y: number) => {
|
||||
const char = FULL[y]?.[x]
|
||||
const char = ctx.FULL[y]?.[x]
|
||||
return char !== undefined && char !== " "
|
||||
}
|
||||
|
||||
|
|
@ -448,7 +525,7 @@ export function Logo() {
|
|||
if (last) burst(last.x, last.y)
|
||||
setNow(t)
|
||||
if (!last) setRelease(undefined)
|
||||
setHold({ x, y, at: t, glyph: select(x, y) })
|
||||
setHold({ x, y, at: t, glyph: select(x, y, ctx) })
|
||||
hum = false
|
||||
start()
|
||||
}
|
||||
|
|
@ -521,18 +598,21 @@ export function Logo() {
|
|||
const attrs = bold ? TextAttributes.BOLD : undefined
|
||||
|
||||
return Array.from(line).map((char, i) => {
|
||||
const h = field(off + i, y, frame)
|
||||
const n = wave(off + i, y, frame, lit(char)) + h
|
||||
const s = wave(off + i, y, dusk, false) + h
|
||||
const p = lit(char) ? pick(off + i, y, frame) : 0
|
||||
const e = lit(char) ? trace(off + i, y, frame) : 0
|
||||
const b = lit(char) ? bloom(off + i, y, frame) : 0
|
||||
const q = shimmer(off + i, y, frame)
|
||||
const h = field(off + i, y, frame, ctx)
|
||||
const pulse = props.idle ? idle(off + i, y, frame, ctx) : { glow: 0, peak: 0 }
|
||||
const peakMix = lit(char) ? Math.min(1, pulse.peak) : 0
|
||||
const inkTinted = peakMix > 0 ? tint(ink, PEAK, peakMix) : ink
|
||||
const n = wave(off + i, y, frame, lit(char), ctx) + h
|
||||
const s = wave(off + i, y, dusk, false, ctx) + h
|
||||
const p = lit(char) ? pick(off + i, y, frame, ctx) : 0
|
||||
const e = lit(char) ? trace(off + i, y, frame, ctx) : 0
|
||||
const b = lit(char) ? bloom(off + i, y, frame, ctx) : 0
|
||||
const q = shimmer(off + i, y, frame, ctx)
|
||||
|
||||
if (char === "_") {
|
||||
return (
|
||||
<text
|
||||
fg={shade(ink, theme, s * 0.08)}
|
||||
fg={shade(inkTinted, theme, s * 0.08)}
|
||||
bg={shade(shadow, theme, ghost(s, 0.24) + ghost(q, 0.06))}
|
||||
attributes={attrs}
|
||||
selectable={false}
|
||||
|
|
@ -545,7 +625,7 @@ export function Logo() {
|
|||
if (char === "^") {
|
||||
return (
|
||||
<text
|
||||
fg={shade(ink, theme, n + p + e + b)}
|
||||
fg={shade(inkTinted, theme, n + p + e + b)}
|
||||
bg={shade(shadow, theme, ghost(s, 0.18) + ghost(q, 0.05) + ghost(b, 0.08))}
|
||||
attributes={attrs}
|
||||
selectable={false}
|
||||
|
|
@ -563,16 +643,24 @@ export function Logo() {
|
|||
)
|
||||
}
|
||||
|
||||
if (char === ",") {
|
||||
return (
|
||||
<text fg={shade(shadow, theme, ghost(s, 0.22) + ghost(q, 0.05))} attributes={attrs} selectable={false}>
|
||||
▄
|
||||
</text>
|
||||
)
|
||||
}
|
||||
|
||||
if (char === " ") {
|
||||
return (
|
||||
<text fg={ink} attributes={attrs} selectable={false}>
|
||||
<text fg={inkTinted} attributes={attrs} selectable={false}>
|
||||
{char}
|
||||
</text>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<text fg={shade(ink, theme, n + p + e + b)} attributes={attrs} selectable={false}>
|
||||
<text fg={shade(inkTinted, theme, n + p + e + b)} attributes={attrs} selectable={false}>
|
||||
{char}
|
||||
</text>
|
||||
)
|
||||
|
|
@ -613,17 +701,27 @@ export function Logo() {
|
|||
position="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
width={FULL[0]?.length ?? 0}
|
||||
height={FULL.length}
|
||||
width={ctx.FULL[0]?.length ?? 0}
|
||||
height={ctx.FULL.length}
|
||||
zIndex={1}
|
||||
onMouse={mouse}
|
||||
/>
|
||||
<For each={logo.left}>
|
||||
<For each={ctx.shape.left}>
|
||||
{(line, index) => (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<box flexDirection="row">{renderLine(line, index(), theme.textMuted, false, 0, frame(), dusk())}</box>
|
||||
<box flexDirection="row">
|
||||
{renderLine(logo.right[index()], index(), theme.text, true, LEFT + GAP, frame(), dusk())}
|
||||
{renderLine(line, index(), props.ink ?? theme.textMuted, !!props.ink, 0, frame(), dusk())}
|
||||
</box>
|
||||
<box flexDirection="row">
|
||||
{renderLine(
|
||||
ctx.shape.right[index()],
|
||||
index(),
|
||||
props.ink ?? theme.text,
|
||||
true,
|
||||
ctx.LEFT + GAP,
|
||||
frame(),
|
||||
dusk(),
|
||||
)}
|
||||
</box>
|
||||
</box>
|
||||
)}
|
||||
|
|
@ -631,3 +729,9 @@ export function Logo() {
|
|||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
export function GoLogo() {
|
||||
const { theme } = useTheme()
|
||||
const base = tint(theme.background, theme.text, 0.82)
|
||||
return <Logo shape={go} ink={base} idle />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,4 +3,9 @@ export const logo = {
|
|||
right: [" ▄ ", "█▀▀▀ █▀▀█ █▀▀█ █▀▀█", "█___ █__█ █__█ █^^^", "▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀"],
|
||||
}
|
||||
|
||||
export const marks = "_^~"
|
||||
export const go = {
|
||||
left: [" ", "█▀▀▀", "█_^█", "▀▀▀▀"],
|
||||
right: [" ", "█▀▀█", "█__█", "▀▀▀▀"],
|
||||
}
|
||||
|
||||
export const marks = "_^~,"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue