feat(tui): animated GO logo + radial pulse in free-limit upsell dialog (#22976)
This commit is contained in:
130
packages/opencode/src/cli/cmd/tui/component/bg-pulse.tsx
Normal file
130
packages/opencode/src/cli/cmd/tui/component/bg-pulse.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
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 = 3.8
|
||||||
|
const TAIL = 9.5
|
||||||
|
const AMP = 0.55
|
||||||
|
const TAIL_AMP = 0.16
|
||||||
|
const BREATH_AMP = 0.05
|
||||||
|
const BREATH_SPEED = 0.0008
|
||||||
|
// Offset so bg ring emits from GO center at the moment the logo pulse peaks.
|
||||||
|
const PHASE_OFFSET = 0.29
|
||||||
|
|
||||||
|
export type BgPulseMask = {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
pad?: number
|
||||||
|
strength?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BgPulse(props: { centerX?: number; centerY?: number; masks?: BgPulseMask[] }) {
|
||||||
|
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 ringStates = Array.from({ length: 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)
|
||||||
|
return {
|
||||||
|
head: phase * reach,
|
||||||
|
eased,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const normalizedMasks = props.masks?.map((m) => {
|
||||||
|
const pad = m.pad ?? 2
|
||||||
|
return {
|
||||||
|
left: m.x - pad,
|
||||||
|
right: m.x + m.width + pad,
|
||||||
|
top: m.y - pad,
|
||||||
|
bottom: m.y + m.height + pad,
|
||||||
|
pad,
|
||||||
|
strength: m.strength ?? 0.85,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
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 (const ring of ringStates) {
|
||||||
|
const delta = dist - ring.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) * ring.eased
|
||||||
|
}
|
||||||
|
const edgeFalloff = Math.max(0, 1 - (dist / (reach * 0.85)) ** 2)
|
||||||
|
const breath = (0.5 + 0.5 * Math.sin(t * BREATH_SPEED)) * BREATH_AMP
|
||||||
|
let maskAtten = 1
|
||||||
|
if (normalizedMasks) {
|
||||||
|
for (const m of normalizedMasks) {
|
||||||
|
if (x < m.left || x > m.right || y < m.top || y > m.bottom) continue
|
||||||
|
const inX = Math.min(x - m.left, m.right - x)
|
||||||
|
const inY = Math.min(y - m.top, m.bottom - y)
|
||||||
|
const edge = Math.min(inX / m.pad, inY / m.pad, 1)
|
||||||
|
const eased = edge * edge * (3 - 2 * edge)
|
||||||
|
const reduce = 1 - m.strength * eased
|
||||||
|
if (reduce < maskAtten) maskAtten = reduce
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const strength = Math.min(1, ((level / RINGS) * edgeFalloff + breath * edgeFalloff) * maskAtten)
|
||||||
|
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 { useKeyboard } from "@opentui/solid"
|
||||||
import open from "open"
|
import open from "open"
|
||||||
import { createSignal } from "solid-js"
|
import { createSignal, onCleanup, onMount } from "solid-js"
|
||||||
import { selectedForeground, useTheme } from "@tui/context/theme"
|
import { selectedForeground, useTheme } from "@tui/context/theme"
|
||||||
import { useDialog, type DialogContext } from "@tui/ui/dialog"
|
import { useDialog, type DialogContext } from "@tui/ui/dialog"
|
||||||
import { Link } from "@tui/ui/link"
|
import { Link } from "@tui/ui/link"
|
||||||
|
import { GoLogo } from "./logo"
|
||||||
|
import { BgPulse, type BgPulseMask } from "./bg-pulse"
|
||||||
|
|
||||||
const GO_URL = "https://opencode.ai/go"
|
const GO_URL = "https://opencode.ai/go"
|
||||||
|
const PAD_X = 3
|
||||||
|
const PAD_TOP_OUTER = 1
|
||||||
|
|
||||||
export type DialogGoUpsellProps = {
|
export type DialogGoUpsellProps = {
|
||||||
onClose?: (dontShowAgain?: boolean) => void
|
onClose?: (dontShowAgain?: boolean) => void
|
||||||
@@ -27,62 +31,116 @@ export function DialogGoUpsell(props: DialogGoUpsellProps) {
|
|||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const fg = selectedForeground(theme)
|
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>()
|
||||||
|
const [masks, setMasks] = createSignal<BgPulseMask[]>([])
|
||||||
|
let content: BoxRenderable | undefined
|
||||||
|
let logoBox: BoxRenderable | undefined
|
||||||
|
let headingBox: BoxRenderable | undefined
|
||||||
|
let descBox: BoxRenderable | undefined
|
||||||
|
let buttonsBox: 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 + PAD_TOP_OUTER,
|
||||||
|
})
|
||||||
|
const next: BgPulseMask[] = []
|
||||||
|
const baseY = PAD_TOP_OUTER
|
||||||
|
for (const b of [headingBox, descBox, buttonsBox]) {
|
||||||
|
if (!b) continue
|
||||||
|
next.push({
|
||||||
|
x: b.x - content.x,
|
||||||
|
y: b.y - content.y + baseY,
|
||||||
|
width: b.width,
|
||||||
|
height: b.height,
|
||||||
|
pad: 2,
|
||||||
|
strength: 0.78,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
setMasks(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
sync()
|
||||||
|
for (const b of [content, logoBox, headingBox, descBox, buttonsBox]) b?.on("resize", sync)
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
for (const b of [content, logoBox, headingBox, descBox, buttonsBox]) b?.off("resize", sync)
|
||||||
|
})
|
||||||
|
|
||||||
useKeyboard((evt) => {
|
useKeyboard((evt) => {
|
||||||
if (evt.name === "left" || evt.name === "right" || evt.name === "tab") {
|
if (evt.name === "left" || evt.name === "right" || evt.name === "tab") {
|
||||||
setSelected((s) => (s === 0 ? 1 : 0))
|
setSelected((s) => (s === "subscribe" ? "dismiss" : "subscribe"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (evt.name !== "return") return
|
if (evt.name === "return") {
|
||||||
if (selected() === 0) subscribe(props, dialog)
|
if (selected() === "subscribe") subscribe(props, dialog)
|
||||||
else dismiss(props, dialog)
|
else dismiss(props, dialog)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
<box ref={(item: BoxRenderable) => (content = item)}>
|
||||||
<box flexDirection="row" justifyContent="space-between">
|
<box position="absolute" top={-PAD_TOP_OUTER} left={0} right={0} bottom={0} zIndex={0}>
|
||||||
<text attributes={TextAttributes.BOLD} fg={theme.text}>
|
<BgPulse centerX={center()?.x} centerY={center()?.y} masks={masks()} />
|
||||||
Free limit reached
|
|
||||||
</text>
|
|
||||||
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
|
|
||||||
esc
|
|
||||||
</text>
|
|
||||||
</box>
|
</box>
|
||||||
<box gap={1} paddingBottom={1}>
|
<box paddingLeft={PAD_X} paddingRight={PAD_X} paddingBottom={1} gap={1}>
|
||||||
<text fg={theme.textMuted}>
|
<box ref={(item: BoxRenderable) => (headingBox = item)} flexDirection="row" justifyContent="space-between">
|
||||||
Subscribe to OpenCode Go to keep going with reliable access to the best open-source models, starting at
|
<text attributes={TextAttributes.BOLD} fg={theme.text}>
|
||||||
$5/month.
|
Free limit reached
|
||||||
</text>
|
</text>
|
||||||
<box flexDirection="row" gap={1}>
|
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
|
||||||
|
esc
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
<box ref={(item: BoxRenderable) => (descBox = item)} gap={0}>
|
||||||
|
<box flexDirection="row">
|
||||||
|
<text fg={theme.textMuted}>Subscribe to </text>
|
||||||
|
<text attributes={TextAttributes.BOLD} fg={theme.textMuted}>
|
||||||
|
OpenCode Go
|
||||||
|
</text>
|
||||||
|
<text fg={theme.textMuted}> for reliable access to the</text>
|
||||||
|
</box>
|
||||||
|
<text fg={theme.textMuted}>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} />
|
<Link href={GO_URL} fg={theme.primary} />
|
||||||
</box>
|
</box>
|
||||||
</box>
|
<box ref={(item: BoxRenderable) => (buttonsBox = item)} flexDirection="row" justifyContent="space-between">
|
||||||
<box flexDirection="row" justifyContent="flex-end" gap={1} paddingBottom={1}>
|
<box
|
||||||
<box
|
paddingLeft={2}
|
||||||
paddingLeft={3}
|
paddingRight={2}
|
||||||
paddingRight={3}
|
backgroundColor={selected() === "dismiss" ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
|
||||||
backgroundColor={selected() === 0 ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
|
onMouseOver={() => setSelected("dismiss")}
|
||||||
onMouseOver={() => setSelected(0)}
|
onMouseUp={() => dismiss(props, dialog)}
|
||||||
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}
|
|
||||||
>
|
>
|
||||||
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>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
import { BoxRenderable, MouseButton, MouseEvent, RGBA, TextAttributes } from "@opentui/core"
|
import { BoxRenderable, MouseButton, MouseEvent, RGBA, TextAttributes } from "@opentui/core"
|
||||||
import { For, createMemo, createSignal, onCleanup, type JSX } from "solid-js"
|
import { For, createMemo, createSignal, onCleanup, onMount, type JSX } from "solid-js"
|
||||||
import { useTheme, tint } from "@tui/context/theme"
|
import { useTheme, tint } from "@tui/context/theme"
|
||||||
import * as Sound from "@tui/util/sound"
|
import * as Sound from "@tui/util/sound"
|
||||||
import { logo } from "@/cli/logo"
|
import { go, logo } from "@/cli/logo"
|
||||||
|
import { shimmerConfig, type ShimmerConfig } from "./shimmer-config"
|
||||||
|
|
||||||
|
export type LogoShape = {
|
||||||
|
left: string[]
|
||||||
|
right: string[]
|
||||||
|
}
|
||||||
|
|
||||||
// Shadow markers (rendered chars in parens):
|
// Shadow markers (rendered chars in parens):
|
||||||
// _ = full shadow cell (space with bg=shadow)
|
// _ = full shadow cell (space with bg=shadow)
|
||||||
@@ -74,9 +80,6 @@ type Frame = {
|
|||||||
spark: number
|
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 = [
|
const NEAR = [
|
||||||
[1, 0],
|
[1, 0],
|
||||||
[1, 1],
|
[1, 1],
|
||||||
@@ -140,7 +143,7 @@ function noise(x: number, y: number, t: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function lit(char: string) {
|
function lit(char: string) {
|
||||||
return char !== " " && char !== "_" && char !== "~"
|
return char !== " " && char !== "_" && char !== "~" && char !== ","
|
||||||
}
|
}
|
||||||
|
|
||||||
function key(x: number, y: number) {
|
function key(x: number, y: number) {
|
||||||
@@ -188,12 +191,12 @@ function route(list: Array<{ x: number; y: number }>) {
|
|||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapGlyphs() {
|
function mapGlyphs(full: string[]) {
|
||||||
const cells = [] as Array<{ x: number; y: number }>
|
const cells = [] as Array<{ x: number; y: number }>
|
||||||
|
|
||||||
for (let y = 0; y < FULL.length; y++) {
|
for (let y = 0; y < full.length; y++) {
|
||||||
for (let x = 0; x < (FULL[y]?.length ?? 0); x++) {
|
for (let x = 0; x < (full[y]?.length ?? 0); x++) {
|
||||||
if (lit(FULL[y]?.[x] ?? " ")) cells.push({ x, y })
|
if (lit(full[y]?.[x] ?? " ")) cells.push({ x, y })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,9 +240,25 @@ function mapGlyphs() {
|
|||||||
return { glyph, trace, center }
|
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) => {
|
return frame.list.reduce((best, item) => {
|
||||||
const age = frame.t - item.at
|
const age = frame.t - item.at
|
||||||
if (age < SHIMMER_IN || age > LIFE) return best
|
if (age < SHIMMER_IN || age > LIFE) return best
|
||||||
@@ -247,7 +266,7 @@ function shimmer(x: number, y: number, frame: Frame) {
|
|||||||
const dy = y * 2 + 1 - item.y
|
const dy = y * 2 + 1 - item.y
|
||||||
const dist = Math.hypot(dx, dy)
|
const dist = Math.hypot(dx, dy)
|
||||||
const p = age / LIFE
|
const p = age / LIFE
|
||||||
const r = SPAN * (1 - (1 - p) ** EXPAND)
|
const r = ctx.SPAN * (1 - (1 - p) ** EXPAND)
|
||||||
const lag = r - dist
|
const lag = r - dist
|
||||||
if (lag < 0.18 || lag > SHIMMER_OUT) return best
|
if (lag < 0.18 || lag > SHIMMER_OUT) return best
|
||||||
const band = Math.exp(-(((lag - 1.05) / 0.68) ** 2))
|
const band = Math.exp(-(((lag - 1.05) / 0.68) ** 2))
|
||||||
@@ -258,19 +277,19 @@ function shimmer(x: number, y: number, frame: Frame) {
|
|||||||
}, 0)
|
}, 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
|
const age = t - item.at
|
||||||
if (age < 0 || age > LIFE) return 0
|
if (age < 0 || age > LIFE) return 0
|
||||||
const p = age / LIFE
|
const p = age / LIFE
|
||||||
const dx = x + 0.5 - item.x - 0.5
|
const dx = x + 0.5 - item.x - 0.5
|
||||||
const dy = y * 2 + 1 - item.y * 2 - 1
|
const dy = y * 2 + 1 - item.y * 2 - 1
|
||||||
const dist = Math.hypot(dx, dy)
|
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
|
if (dist > r) return 1
|
||||||
return clamp((r - dist) / 1.35 < 1 ? 1 - (r - dist) / 1.35 : 0)
|
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) => {
|
return frame.list.reduce((sum, item) => {
|
||||||
const age = frame.t - item.at
|
const age = frame.t - item.at
|
||||||
if (age < 0 || age > LIFE) return sum
|
if (age < 0 || age > LIFE) return sum
|
||||||
@@ -278,7 +297,7 @@ function wave(x: number, y: number, frame: Frame, live: boolean) {
|
|||||||
const dx = x + 0.5 - item.x
|
const dx = x + 0.5 - item.x
|
||||||
const dy = y * 2 + 1 - item.y
|
const dy = y * 2 + 1 - item.y
|
||||||
const dist = Math.hypot(dx, dy)
|
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 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 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
|
const edge = Math.exp(-(((dist - r) / WIDTH) ** 2)) * GAIN * fade * item.force * j
|
||||||
@@ -292,7 +311,7 @@ function wave(x: number, y: number, frame: Frame, live: boolean) {
|
|||||||
}, 0)
|
}, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
function field(x: number, y: number, frame: Frame) {
|
function field(x: number, y: number, frame: Frame, ctx: LogoContext) {
|
||||||
const held = frame.hold
|
const held = frame.hold
|
||||||
const rest = frame.release
|
const rest = frame.release
|
||||||
const item = held ?? rest
|
const item = held ?? rest
|
||||||
@@ -326,11 +345,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.max(0, noise(item.x * 3.1, item.y * 2.7, frame.t * 1.7) - 0.72) *
|
||||||
Math.exp(-(dist * dist) / 0.15) *
|
Math.exp(-(dist * dist) / 0.15) *
|
||||||
lerp(0.08, 0.42, body)
|
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
|
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 held = frame.hold
|
||||||
const rest = frame.release
|
const rest = frame.release
|
||||||
const item = held ?? rest
|
const item = held ?? rest
|
||||||
@@ -339,26 +358,26 @@ function pick(x: number, y: number, frame: Frame) {
|
|||||||
const dx = x + 0.5 - item.x - 0.5
|
const dx = x + 0.5 - item.x - 0.5
|
||||||
const dy = y * 2 + 1 - item.y * 2 - 1
|
const dy = y * 2 + 1 - item.y * 2 - 1
|
||||||
const dist = Math.hypot(dx, dy)
|
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
|
return Math.exp(-(dist * dist) / 1.7) * lerp(0.2, 0.96, rise) * fade
|
||||||
}
|
}
|
||||||
|
|
||||||
function select(x: number, y: number) {
|
function select(x: number, y: number, ctx: LogoContext) {
|
||||||
const direct = MAP.glyph.get(key(x, y))
|
const direct = ctx.MAP.glyph.get(key(x, y))
|
||||||
if (direct !== undefined) return direct
|
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,
|
(item): item is number => item !== undefined,
|
||||||
)
|
)
|
||||||
return near
|
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 held = frame.hold
|
||||||
const rest = frame.release
|
const rest = frame.release
|
||||||
const item = held ?? rest
|
const item = held ?? rest
|
||||||
if (!item || item.glyph === undefined) return 0
|
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
|
if (!step || step.glyph !== item.glyph || step.l < 2) return 0
|
||||||
const age = frame.t - item.at
|
const age = frame.t - item.at
|
||||||
const rise = held ? ramp(age, HOLD, CHARGE) : rest!.rise
|
const rise = held ? ramp(age, HOLD, CHARGE) : rest!.rise
|
||||||
@@ -368,29 +387,125 @@ 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 dist = Math.min(Math.abs(step.i - head), step.l - Math.abs(step.i - head))
|
||||||
const tail = (head - TAIL + step.l) % step.l
|
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 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 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 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)
|
const trail = Math.exp(-((lag / 1.45) ** 2)) * lerp(0.04, 0.42, rise)
|
||||||
return (core + glow + trail) * appear * fade
|
return (core + glow + trail) * appear * fade
|
||||||
}
|
}
|
||||||
|
|
||||||
function bloom(x: number, y: number, frame: Frame) {
|
function idle(
|
||||||
|
x: number,
|
||||||
|
pixelY: number,
|
||||||
|
frame: Frame,
|
||||||
|
ctx: LogoContext,
|
||||||
|
state: IdleState,
|
||||||
|
): { glow: number; peak: number; primary: number } {
|
||||||
|
const cfg = state.cfg
|
||||||
|
const dx = x + 0.5 - cfg.originX
|
||||||
|
const dy = pixelY - cfg.originY
|
||||||
|
const dist = Math.hypot(dx, dy)
|
||||||
|
const angle = Math.atan2(dy, dx)
|
||||||
|
const wob1 = noise(x * 0.32, pixelY * 0.25, frame.t * 0.0005) - 0.5
|
||||||
|
const wob2 = noise(x * 0.12, pixelY * 0.08, frame.t * 0.00022) - 0.5
|
||||||
|
const ripple = Math.sin(angle * 3 + frame.t * 0.0012) * 0.3
|
||||||
|
const jitter = (wob1 * 0.55 + wob2 * 0.32 + ripple * 0.18) * cfg.noise
|
||||||
|
const traveled = dist + jitter
|
||||||
|
let glow = 0
|
||||||
|
let peak = 0
|
||||||
|
let halo = 0
|
||||||
|
let primary = 0
|
||||||
|
let ambient = 0
|
||||||
|
for (const active of state.active) {
|
||||||
|
const head = active.head
|
||||||
|
const eased = active.eased
|
||||||
|
const delta = traveled - head
|
||||||
|
// Use shallower exponent (1.6 vs 2) for softer edges on the Gaussians
|
||||||
|
// so adjacent pixels have smaller brightness deltas
|
||||||
|
const core = Math.exp(-(Math.abs(delta / cfg.coreWidth) ** 1.8))
|
||||||
|
const soft = Math.exp(-(Math.abs(delta / cfg.softWidth) ** 1.6))
|
||||||
|
const tailRange = cfg.tail * 2.6
|
||||||
|
const tail = delta < 0 && delta > -tailRange ? (1 + delta / tailRange) ** 2.6 : 0
|
||||||
|
const haloDelta = delta + cfg.haloOffset
|
||||||
|
const haloBand = Math.exp(-(Math.abs(haloDelta / cfg.haloWidth) ** 1.6))
|
||||||
|
glow += (soft * cfg.softAmp + tail * cfg.tailAmp) * eased
|
||||||
|
peak += core * cfg.coreAmp * eased
|
||||||
|
halo += haloBand * cfg.haloAmp * eased
|
||||||
|
// Primary-tinted fringe follows the halo (which trails behind the core) and the tail
|
||||||
|
primary += (haloBand + tail * 0.6) * eased
|
||||||
|
ambient += active.ambient
|
||||||
|
}
|
||||||
|
ambient /= state.rings
|
||||||
|
return {
|
||||||
|
glow: glow / state.rings,
|
||||||
|
peak: cfg.breathBase + ambient + (peak + halo) / state.rings,
|
||||||
|
primary: (primary / state.rings) * cfg.primaryMix,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bloom(x: number, y: number, frame: Frame, ctx: LogoContext) {
|
||||||
const item = frame.glow
|
const item = frame.glow
|
||||||
if (!item) return 0
|
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
|
if (glyph !== item.glyph) return 0
|
||||||
const age = frame.t - item.at
|
const age = frame.t - item.at
|
||||||
if (age < 0 || age > GLOW_OUT) return 0
|
if (age < 0 || age > GLOW_OUT) return 0
|
||||||
const p = age / GLOW_OUT
|
const p = age / GLOW_OUT
|
||||||
const flash = (1 - p) ** 2
|
const flash = (1 - p) ** 2
|
||||||
const dx = x + 0.5 - MAP.center.get(item.glyph)!.x
|
const dx = x + 0.5 - ctx.MAP.center.get(item.glyph)!.x
|
||||||
const dy = y * 2 + 1 - MAP.center.get(item.glyph)!.y
|
const dy = y * 2 + 1 - ctx.MAP.center.get(item.glyph)!.y
|
||||||
const bias = Math.exp(-((Math.hypot(dx, dy) / 2.8) ** 2))
|
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
|
return lerp(item.force, item.force * 0.18, p) * lerp(0.72, 1.1, bias) * flash
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Logo() {
|
type IdleState = {
|
||||||
|
cfg: ShimmerConfig
|
||||||
|
reach: number
|
||||||
|
rings: number
|
||||||
|
active: Array<{
|
||||||
|
head: number
|
||||||
|
eased: number
|
||||||
|
ambient: number
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildIdleState(t: number, ctx: LogoContext): IdleState {
|
||||||
|
const cfg = shimmerConfig
|
||||||
|
const w = ctx.FULL[0]?.length ?? 1
|
||||||
|
const h = ctx.FULL.length * 2
|
||||||
|
const corners: [number, number][] = [
|
||||||
|
[0, 0],
|
||||||
|
[w, 0],
|
||||||
|
[0, h],
|
||||||
|
[w, h],
|
||||||
|
]
|
||||||
|
let maxCorner = 0
|
||||||
|
for (const [cx, cy] of corners) {
|
||||||
|
const d = Math.hypot(cx - cfg.originX, cy - cfg.originY)
|
||||||
|
if (d > maxCorner) maxCorner = d
|
||||||
|
}
|
||||||
|
const reach = maxCorner + cfg.tail * 2
|
||||||
|
const rings = Math.max(1, Math.floor(cfg.rings))
|
||||||
|
const active = [] as IdleState["active"]
|
||||||
|
for (let i = 0; i < rings; i++) {
|
||||||
|
const offset = i / rings
|
||||||
|
const cyclePhase = (t / cfg.period + offset) % 1
|
||||||
|
if (cyclePhase >= cfg.sweepFraction) continue
|
||||||
|
const phase = cyclePhase / cfg.sweepFraction
|
||||||
|
const envelope = Math.sin(phase * Math.PI)
|
||||||
|
const eased = envelope * envelope * (3 - 2 * envelope)
|
||||||
|
const d = (phase - cfg.ambientCenter) / cfg.ambientWidth
|
||||||
|
active.push({
|
||||||
|
head: phase * reach,
|
||||||
|
eased,
|
||||||
|
ambient: Math.abs(d) < 1 ? (1 - d * d) ** 2 * cfg.ambientAmp : 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return { cfg, reach, rings, active }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } = {}) {
|
||||||
|
const ctx = props.shape ? build(props.shape) : DEFAULT
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const [rings, setRings] = createSignal<Ring[]>([])
|
const [rings, setRings] = createSignal<Ring[]>([])
|
||||||
const [hold, setHold] = createSignal<Hold>()
|
const [hold, setHold] = createSignal<Hold>()
|
||||||
@@ -430,6 +545,7 @@ export function Logo() {
|
|||||||
}
|
}
|
||||||
if (!live) setRelease(undefined)
|
if (!live) setRelease(undefined)
|
||||||
if (live || hold() || release() || glow()) return
|
if (live || hold() || release() || glow()) return
|
||||||
|
if (props.idle) return
|
||||||
stop()
|
stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -438,8 +554,20 @@ export function Logo() {
|
|||||||
timer = setInterval(tick, 16)
|
timer = setInterval(tick, 16)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
stop()
|
||||||
|
hum = false
|
||||||
|
Sound.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (!props.idle) return
|
||||||
|
setNow(performance.now())
|
||||||
|
start()
|
||||||
|
})
|
||||||
|
|
||||||
const hit = (x: number, y: number) => {
|
const hit = (x: number, y: number) => {
|
||||||
const char = FULL[y]?.[x]
|
const char = ctx.FULL[y]?.[x]
|
||||||
return char !== undefined && char !== " "
|
return char !== undefined && char !== " "
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,7 +576,7 @@ export function Logo() {
|
|||||||
if (last) burst(last.x, last.y)
|
if (last) burst(last.x, last.y)
|
||||||
setNow(t)
|
setNow(t)
|
||||||
if (!last) setRelease(undefined)
|
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
|
hum = false
|
||||||
start()
|
start()
|
||||||
}
|
}
|
||||||
@@ -508,6 +636,8 @@ export function Logo() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const idleState = createMemo(() => (props.idle ? buildIdleState(frame().t, ctx) : undefined))
|
||||||
|
|
||||||
const renderLine = (
|
const renderLine = (
|
||||||
line: string,
|
line: string,
|
||||||
y: number,
|
y: number,
|
||||||
@@ -516,24 +646,64 @@ export function Logo() {
|
|||||||
off: number,
|
off: number,
|
||||||
frame: Frame,
|
frame: Frame,
|
||||||
dusk: Frame,
|
dusk: Frame,
|
||||||
|
state: IdleState | undefined,
|
||||||
): JSX.Element[] => {
|
): JSX.Element[] => {
|
||||||
const shadow = tint(theme.background, ink, 0.25)
|
const shadow = tint(theme.background, ink, 0.25)
|
||||||
const attrs = bold ? TextAttributes.BOLD : undefined
|
const attrs = bold ? TextAttributes.BOLD : undefined
|
||||||
|
|
||||||
return Array.from(line).map((char, i) => {
|
return Array.from(line).map((char, i) => {
|
||||||
const h = field(off + i, y, frame)
|
if (char === " ") {
|
||||||
const n = wave(off + i, y, frame, lit(char)) + h
|
return (
|
||||||
const s = wave(off + i, y, dusk, false) + h
|
<text fg={ink} attributes={attrs} selectable={false}>
|
||||||
const p = lit(char) ? pick(off + i, y, frame) : 0
|
{char}
|
||||||
const e = lit(char) ? trace(off + i, y, frame) : 0
|
</text>
|
||||||
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 charLit = lit(char)
|
||||||
|
// Sub-pixel sampling: cells are 2 pixels tall. Sample at top (y*2) and bottom (y*2+1) pixel rows.
|
||||||
|
const pulseTop = state ? idle(off + i, y * 2, frame, ctx, state) : { glow: 0, peak: 0, primary: 0 }
|
||||||
|
const pulseBot = state ? idle(off + i, y * 2 + 1, frame, ctx, state) : { glow: 0, peak: 0, primary: 0 }
|
||||||
|
const peakMixTop = charLit ? Math.min(1, pulseTop.peak) : 0
|
||||||
|
const peakMixBot = charLit ? Math.min(1, pulseBot.peak) : 0
|
||||||
|
const primaryMixTop = charLit ? Math.min(1, pulseTop.primary) : 0
|
||||||
|
const primaryMixBot = charLit ? Math.min(1, pulseBot.primary) : 0
|
||||||
|
// Layer primary tint first, then white peak on top — so the halo/tail pulls toward primary,
|
||||||
|
// while the bright core stays pure white
|
||||||
|
const inkTopTint = primaryMixTop > 0 ? tint(ink, theme.primary, primaryMixTop) : ink
|
||||||
|
const inkBotTint = primaryMixBot > 0 ? tint(ink, theme.primary, primaryMixBot) : ink
|
||||||
|
const inkTop = peakMixTop > 0 ? tint(inkTopTint, PEAK, peakMixTop) : inkTopTint
|
||||||
|
const inkBot = peakMixBot > 0 ? tint(inkBotTint, PEAK, peakMixBot) : inkBotTint
|
||||||
|
// For the non-peak-aware brightness channels, use the average of top/bot
|
||||||
|
const pulse = {
|
||||||
|
glow: (pulseTop.glow + pulseBot.glow) / 2,
|
||||||
|
peak: (pulseTop.peak + pulseBot.peak) / 2,
|
||||||
|
primary: (pulseTop.primary + pulseBot.primary) / 2,
|
||||||
|
}
|
||||||
|
const peakMix = charLit ? Math.min(1, pulse.peak) : 0
|
||||||
|
const primaryMix = charLit ? Math.min(1, pulse.primary) : 0
|
||||||
|
const inkPrimary = primaryMix > 0 ? tint(ink, theme.primary, primaryMix) : ink
|
||||||
|
const inkTinted = peakMix > 0 ? tint(inkPrimary, PEAK, peakMix) : inkPrimary
|
||||||
|
const shadowMixCfg = state?.cfg.shadowMix ?? shimmerConfig.shadowMix
|
||||||
|
const shadowMixTop = Math.min(1, pulseTop.peak * shadowMixCfg)
|
||||||
|
const shadowMixBot = Math.min(1, pulseBot.peak * shadowMixCfg)
|
||||||
|
const shadowTop = shadowMixTop > 0 ? tint(shadow, PEAK, shadowMixTop) : shadow
|
||||||
|
const shadowBot = shadowMixBot > 0 ? tint(shadow, PEAK, shadowMixBot) : shadow
|
||||||
|
const shadowMix = Math.min(1, pulse.peak * shadowMixCfg)
|
||||||
|
const shadowTinted = shadowMix > 0 ? tint(shadow, PEAK, shadowMix) : shadow
|
||||||
|
const n = wave(off + i, y, frame, charLit, ctx) + h
|
||||||
|
const s = wave(off + i, y, dusk, false, ctx) + h
|
||||||
|
const p = charLit ? pick(off + i, y, frame, ctx) : 0
|
||||||
|
const e = charLit ? trace(off + i, y, frame, ctx) : 0
|
||||||
|
const b = charLit ? bloom(off + i, y, frame, ctx) : 0
|
||||||
|
const q = shimmer(off + i, y, frame, ctx)
|
||||||
|
|
||||||
if (char === "_") {
|
if (char === "_") {
|
||||||
return (
|
return (
|
||||||
<text
|
<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))}
|
bg={shade(shadowTinted, theme, ghost(s, 0.24) + ghost(q, 0.06))}
|
||||||
attributes={attrs}
|
attributes={attrs}
|
||||||
selectable={false}
|
selectable={false}
|
||||||
>
|
>
|
||||||
@@ -545,8 +715,8 @@ export function Logo() {
|
|||||||
if (char === "^") {
|
if (char === "^") {
|
||||||
return (
|
return (
|
||||||
<text
|
<text
|
||||||
fg={shade(ink, theme, n + p + e + b)}
|
fg={shade(inkTop, theme, n + p + e + b)}
|
||||||
bg={shade(shadow, theme, ghost(s, 0.18) + ghost(q, 0.05) + ghost(b, 0.08))}
|
bg={shade(shadowBot, theme, ghost(s, 0.18) + ghost(q, 0.05) + ghost(b, 0.08))}
|
||||||
attributes={attrs}
|
attributes={attrs}
|
||||||
selectable={false}
|
selectable={false}
|
||||||
>
|
>
|
||||||
@@ -557,34 +727,60 @@ export function Logo() {
|
|||||||
|
|
||||||
if (char === "~") {
|
if (char === "~") {
|
||||||
return (
|
return (
|
||||||
<text fg={shade(shadow, theme, ghost(s, 0.22) + ghost(q, 0.05))} attributes={attrs} selectable={false}>
|
<text fg={shade(shadowTop, theme, ghost(s, 0.22) + ghost(q, 0.05))} attributes={attrs} selectable={false}>
|
||||||
▀
|
▀
|
||||||
</text>
|
</text>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (char === " ") {
|
if (char === ",") {
|
||||||
return (
|
return (
|
||||||
<text fg={ink} attributes={attrs} selectable={false}>
|
<text fg={shade(shadowBot, theme, ghost(s, 0.22) + ghost(q, 0.05))} attributes={attrs} selectable={false}>
|
||||||
{char}
|
▄
|
||||||
|
</text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solid █: render as ▀ so the top pixel (fg) and bottom pixel (bg) can carry independent shimmer values
|
||||||
|
if (char === "█") {
|
||||||
|
return (
|
||||||
|
<text
|
||||||
|
fg={shade(inkTop, theme, n + p + e + b)}
|
||||||
|
bg={shade(inkBot, theme, n + p + e + b)}
|
||||||
|
attributes={attrs}
|
||||||
|
selectable={false}
|
||||||
|
>
|
||||||
|
▀
|
||||||
|
</text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ▀ top-half-lit: fg uses top-pixel sample, bg stays transparent/panel
|
||||||
|
if (char === "▀") {
|
||||||
|
return (
|
||||||
|
<text fg={shade(inkTop, theme, n + p + e + b)} attributes={attrs} selectable={false}>
|
||||||
|
▀
|
||||||
|
</text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ▄ bottom-half-lit: fg uses bottom-pixel sample
|
||||||
|
if (char === "▄") {
|
||||||
|
return (
|
||||||
|
<text fg={shade(inkBot, theme, n + p + e + b)} attributes={attrs} selectable={false}>
|
||||||
|
▄
|
||||||
</text>
|
</text>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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}
|
{char}
|
||||||
</text>
|
</text>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
stop()
|
|
||||||
hum = false
|
|
||||||
Sound.dispose()
|
|
||||||
})
|
|
||||||
|
|
||||||
const mouse = (evt: MouseEvent) => {
|
const mouse = (evt: MouseEvent) => {
|
||||||
if (!box) return
|
if (!box) return
|
||||||
if ((evt.type === "down" || evt.type === "drag") && evt.button === MouseButton.LEFT) {
|
if ((evt.type === "down" || evt.type === "drag") && evt.button === MouseButton.LEFT) {
|
||||||
@@ -613,17 +809,28 @@ export function Logo() {
|
|||||||
position="absolute"
|
position="absolute"
|
||||||
top={0}
|
top={0}
|
||||||
left={0}
|
left={0}
|
||||||
width={FULL[0]?.length ?? 0}
|
width={ctx.FULL[0]?.length ?? 0}
|
||||||
height={FULL.length}
|
height={ctx.FULL.length}
|
||||||
zIndex={1}
|
zIndex={1}
|
||||||
onMouse={mouse}
|
onMouse={mouse}
|
||||||
/>
|
/>
|
||||||
<For each={logo.left}>
|
<For each={ctx.shape.left}>
|
||||||
{(line, index) => (
|
{(line, index) => (
|
||||||
<box flexDirection="row" gap={1}>
|
<box flexDirection="row" gap={1}>
|
||||||
<box flexDirection="row">{renderLine(line, index(), theme.textMuted, false, 0, frame(), dusk())}</box>
|
|
||||||
<box flexDirection="row">
|
<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(), idleState())}
|
||||||
|
</box>
|
||||||
|
<box flexDirection="row">
|
||||||
|
{renderLine(
|
||||||
|
ctx.shape.right[index()],
|
||||||
|
index(),
|
||||||
|
props.ink ?? theme.text,
|
||||||
|
true,
|
||||||
|
ctx.LEFT + GAP,
|
||||||
|
frame(),
|
||||||
|
dusk(),
|
||||||
|
idleState(),
|
||||||
|
)}
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
)}
|
)}
|
||||||
@@ -631,3 +838,9 @@ export function Logo() {
|
|||||||
</box>
|
</box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GoLogo() {
|
||||||
|
const { theme } = useTheme()
|
||||||
|
const base = tint(theme.background, theme.text, 0.62)
|
||||||
|
return <Logo shape={go} ink={base} idle />
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
export type ShimmerConfig = {
|
||||||
|
period: number
|
||||||
|
rings: number
|
||||||
|
sweepFraction: number
|
||||||
|
coreWidth: number
|
||||||
|
coreAmp: number
|
||||||
|
softWidth: number
|
||||||
|
softAmp: number
|
||||||
|
tail: number
|
||||||
|
tailAmp: number
|
||||||
|
haloWidth: number
|
||||||
|
haloOffset: number
|
||||||
|
haloAmp: number
|
||||||
|
breathBase: number
|
||||||
|
noise: number
|
||||||
|
ambientAmp: number
|
||||||
|
ambientCenter: number
|
||||||
|
ambientWidth: number
|
||||||
|
shadowMix: number
|
||||||
|
primaryMix: number
|
||||||
|
originX: number
|
||||||
|
originY: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const shimmerDefaults: ShimmerConfig = {
|
||||||
|
period: 4600,
|
||||||
|
rings: 2,
|
||||||
|
sweepFraction: 1,
|
||||||
|
coreWidth: 1.2,
|
||||||
|
coreAmp: 1.9,
|
||||||
|
softWidth: 10,
|
||||||
|
softAmp: 1.6,
|
||||||
|
tail: 5,
|
||||||
|
tailAmp: 0.64,
|
||||||
|
haloWidth: 4.3,
|
||||||
|
haloOffset: 0.6,
|
||||||
|
haloAmp: 0.16,
|
||||||
|
breathBase: 0.04,
|
||||||
|
noise: 0.1,
|
||||||
|
ambientAmp: 0.36,
|
||||||
|
ambientCenter: 0.5,
|
||||||
|
ambientWidth: 0.34,
|
||||||
|
shadowMix: 0.1,
|
||||||
|
primaryMix: 0.3,
|
||||||
|
originX: 4.5,
|
||||||
|
originY: 13.5,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const shimmerConfig: ShimmerConfig = { ...shimmerDefaults }
|
||||||
@@ -3,4 +3,9 @@ export const logo = {
|
|||||||
right: [" ▄ ", "█▀▀▀ █▀▀█ █▀▀█ █▀▀█", "█___ █__█ █__█ █^^^", "▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀"],
|
right: [" ▄ ", "█▀▀▀ █▀▀█ █▀▀█ █▀▀█", "█___ █__█ █__█ █^^^", "▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀"],
|
||||||
}
|
}
|
||||||
|
|
||||||
export const marks = "_^~"
|
export const go = {
|
||||||
|
left: [" ", "█▀▀▀", "█_^█", "▀▀▀▀"],
|
||||||
|
right: [" ", "█▀▀█", "█__█", "▀▀▀▀"],
|
||||||
|
}
|
||||||
|
|
||||||
|
export const marks = "_^~,"
|
||||||
|
|||||||
Reference in New Issue
Block a user