Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db9cfb7abf | ||
|
|
f82f2b589d |
@@ -88,13 +88,18 @@ export function PetalCompanion({
|
|||||||
const [pickerOpen, setPickerOpen] = useState(false)
|
const [pickerOpen, setPickerOpen] = useState(false)
|
||||||
const rootRef = useRef<HTMLDivElement>(null)
|
const rootRef = useRef<HTMLDivElement>(null)
|
||||||
const badgeRef = useRef<HTMLButtonElement>(null)
|
const badgeRef = useRef<HTMLButtonElement>(null)
|
||||||
|
// Stand-in for the badge's corner, used only for measuring — see
|
||||||
|
// useCardOverlap. It sits exactly where the badge sits but never bobs, shrinks
|
||||||
|
// or hovers, so what the mascot yields to can't depend on whether it is
|
||||||
|
// currently yielding.
|
||||||
|
const probeRef = useRef<HTMLSpanElement>(null)
|
||||||
|
|
||||||
// When suggestion cards stack down into the corner, the kitten fades to
|
// When suggestion cards stack down into the corner, the kitten fades to
|
||||||
// translucent and shrinks a step so the card stays readable and clickable.
|
// translucent and shrinks a step so the card stays readable and clickable.
|
||||||
// It wakes back up whenever it has something to say (bubble) or is being
|
// It wakes back up whenever it has something to say (bubble) or is being
|
||||||
// interacted with (picker open) — except under an open History or Garden
|
// interacted with (picker open) — except under an open History or Garden
|
||||||
// panel, where even a cheer would cover the controls she just reached for.
|
// panel, where even a cheer would cover the controls she just reached for.
|
||||||
const crowded = useCardOverlap(badgeRef)
|
const crowded = useCardOverlap(probeRef)
|
||||||
const faded = crowded.modal || (crowded.cards && !pickerOpen && !bubble)
|
const faded = crowded.modal || (crowded.cards && !pickerOpen && !bubble)
|
||||||
|
|
||||||
// Awake companions (no sleeping clip) don't visibly nap — when the engine
|
// Awake companions (no sleeping clip) don't visibly nap — when the engine
|
||||||
@@ -135,7 +140,7 @@ export function PetalCompanion({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={rootRef}
|
ref={rootRef}
|
||||||
className="pointer-events-none fixed bottom-4 right-4 z-40 flex flex-col items-end gap-2"
|
className="petal-corner pointer-events-none fixed bottom-4 right-4 z-40 flex flex-col items-end gap-2"
|
||||||
>
|
>
|
||||||
{pickerOpen && (
|
{pickerOpen && (
|
||||||
<div
|
<div
|
||||||
@@ -251,6 +256,16 @@ export function PetalCompanion({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<span
|
||||||
|
ref={probeRef}
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute bottom-0 right-0"
|
||||||
|
style={{
|
||||||
|
width: 'var(--petal-companion-size, 9rem)',
|
||||||
|
height: 'var(--petal-companion-size, 9rem)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
ref={badgeRef}
|
ref={badgeRef}
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState, type RefObject } from 'react'
|
import { useEffect, useRef, useState, type RefObject } from 'react'
|
||||||
|
|
||||||
// How often to re-measure outside of scroll/resize events. Cards re-pack when
|
// How often to re-measure outside of scroll/resize events. Cards re-pack when
|
||||||
// suggestions arrive, expand, or get accepted — none of which fire an event we
|
// suggestions arrive, expand, or get accepted — none of which fire an event we
|
||||||
@@ -11,6 +11,11 @@ const POLL_MS = 500
|
|||||||
// means a future drawer is covered the day it's written, without a list to keep
|
// means a future drawer is covered the day it's written, without a list to keep
|
||||||
// in sync. Anything that doesn't actually reach the corner still won't trip the
|
// in sync. Anything that doesn't actually reach the corner still won't trip the
|
||||||
// rect test below.
|
// rect test below.
|
||||||
|
// How far a card must retreat before an already-yielded mascot comes back. Wide
|
||||||
|
// enough to cover a re-pack or a browser bar appearing, small enough that a card
|
||||||
|
// genuinely scrolled away still wakes it.
|
||||||
|
const HOLD_PX = 24
|
||||||
|
|
||||||
const CARD = '.petal-rail-card'
|
const CARD = '.petal-rail-card'
|
||||||
const MODAL = '[role="dialog"][aria-modal="true"]'
|
const MODAL = '[role="dialog"][aria-modal="true"]'
|
||||||
|
|
||||||
@@ -24,27 +29,22 @@ export interface CardOverlap {
|
|||||||
modal: boolean
|
modal: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// The mascot shrinks a step when it yields (.petal-companion-faded), and
|
|
||||||
// getBoundingClientRect reports the *scaled* box. Measuring that lets the
|
|
||||||
// mascot shrink out of its own overlap test: it yields, stops overlapping,
|
|
||||||
// wakes back up, overlaps again — pulsing forever against a card that sits just
|
|
||||||
// at its edge. The layout box doesn't move when it yields, so measure that. The
|
|
||||||
// transform origin is the centre, so it's the visual centre ± the layout size.
|
|
||||||
function layoutRect(el: HTMLElement) {
|
|
||||||
const r = el.getBoundingClientRect()
|
|
||||||
const halfW = el.offsetWidth / 2
|
|
||||||
const halfH = el.offsetHeight / 2
|
|
||||||
const cx = (r.left + r.right) / 2
|
|
||||||
const cy = (r.top + r.bottom) / 2
|
|
||||||
return { left: cx - halfW, right: cx + halfW, top: cy - halfH, bottom: cy + halfH }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reports what, if anything, the mascot should yield to at the given element.
|
// Reports what, if anything, the mascot should yield to at the given element.
|
||||||
|
//
|
||||||
|
// Pass the probe span, never the badge itself. The badge shrinks when it yields
|
||||||
|
// (.petal-companion-faded) and bobs continuously (petal-bob), and
|
||||||
|
// getBoundingClientRect reports the *transformed* box — so measuring the badge
|
||||||
|
// lets it shrink out of its own overlap test, wake up, overlap again, and pulse
|
||||||
|
// forever against a card resting at its edge. The probe holds the corner box
|
||||||
|
// still whatever the mascot is doing, which is what makes the test settle.
|
||||||
// Used to fade the corner mascot out of the way when a card or panel reaches
|
// Used to fade the corner mascot out of the way when a card or panel reaches
|
||||||
// into its corner, so nothing is ever hidden (or made unclickable) by the
|
// into its corner, so nothing is ever hidden (or made unclickable) by the
|
||||||
// kitten.
|
// kitten.
|
||||||
export function useCardOverlap(ref: RefObject<HTMLElement | null>): CardOverlap {
|
export function useCardOverlap(ref: RefObject<HTMLElement | null>): CardOverlap {
|
||||||
const [overlap, setOverlap] = useState<CardOverlap>({ cards: false, modal: false })
|
const [overlap, setOverlap] = useState<CardOverlap>({ cards: false, modal: false })
|
||||||
|
// check() runs on a timer and reads the previous answer, which state alone
|
||||||
|
// wouldn't hand it without re-subscribing every render.
|
||||||
|
const current = useRef(overlap)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let raf = 0
|
let raf = 0
|
||||||
@@ -52,17 +52,29 @@ export function useCardOverlap(ref: RefObject<HTMLElement | null>): CardOverlap
|
|||||||
const check = () => {
|
const check = () => {
|
||||||
const el = ref.current
|
const el = ref.current
|
||||||
if (!el) return
|
if (!el) return
|
||||||
const r = layoutRect(el)
|
const r = el.getBoundingClientRect()
|
||||||
const hits = (selector: string) => {
|
// Cards settle a pixel or two from the corner all the time — the rail
|
||||||
|
// re-packs, the window resizes, a browser bar appears. Yielding is a
|
||||||
|
// visible move, so once yielded, hold it until the card has clearly gone:
|
||||||
|
// decide on a box grown by HOLD_PX rather than flipping on the exact edge.
|
||||||
|
const hits = (selector: string, held: boolean) => {
|
||||||
|
const pad = held ? HOLD_PX : 0
|
||||||
for (const other of document.querySelectorAll(selector)) {
|
for (const other of document.querySelectorAll(selector)) {
|
||||||
const b = other.getBoundingClientRect()
|
const b = other.getBoundingClientRect()
|
||||||
if (b.left < r.right && b.right > r.left && b.top < r.bottom && b.bottom > r.top) {
|
if (
|
||||||
|
b.left < r.right + pad &&
|
||||||
|
b.right > r.left - pad &&
|
||||||
|
b.top < r.bottom + pad &&
|
||||||
|
b.bottom > r.top - pad
|
||||||
|
) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
const next = { cards: hits(CARD), modal: hits(MODAL) }
|
const prev = current.current
|
||||||
|
const next = { cards: hits(CARD, prev.cards), modal: hits(MODAL, prev.modal) }
|
||||||
|
current.current = next
|
||||||
// Same-value object identity would re-render on every poll tick.
|
// Same-value object identity would re-render on every poll tick.
|
||||||
setOverlap((prev) =>
|
setOverlap((prev) =>
|
||||||
prev.cards === next.cards && prev.modal === next.modal ? prev : next,
|
prev.cards === next.cards && prev.modal === next.modal ? prev : next,
|
||||||
|
|||||||
+6
-1
@@ -422,10 +422,15 @@ button, a, input {
|
|||||||
/* --- Companion kitten -------------------------------------------------------
|
/* --- Companion kitten -------------------------------------------------------
|
||||||
The cozy corner mascot. Gently bobs while awake, settles and sways slowly
|
The cozy corner mascot. Gently bobs while awake, settles and sways slowly
|
||||||
while napping; its speech bubble pops in; little zzz drift up when asleep. */
|
while napping; its speech bubble pops in; little zzz drift up when asleep. */
|
||||||
.petal-companion {
|
/* The whole corner, badge and bubble and measuring probe alike. The size lives
|
||||||
|
here rather than on the badge so the probe — a sibling, not a child — is sized
|
||||||
|
from the same number the mascot is. */
|
||||||
|
.petal-corner {
|
||||||
/* Mascot size scales with the viewport width: ~original on a laptop, up to
|
/* Mascot size scales with the viewport width: ~original on a laptop, up to
|
||||||
~2× on a large desktop. Tune the middle (vw) term to taste. */
|
~2× on a large desktop. Tune the middle (vw) term to taste. */
|
||||||
--petal-companion-size: clamp(9rem, 15.3vw, 18rem);
|
--petal-companion-size: clamp(9rem, 15.3vw, 18rem);
|
||||||
|
}
|
||||||
|
.petal-companion {
|
||||||
animation: petal-bob 3.2s ease-in-out infinite;
|
animation: petal-bob 3.2s ease-in-out infinite;
|
||||||
/* Shrink toward its corner when fading out of a card's way. `scale` is a
|
/* Shrink toward its corner when fading out of a card's way. `scale` is a
|
||||||
separate property from `transform` so it composes with the bob keyframes. */
|
separate property from `transform` so it composes with the bob keyframes. */
|
||||||
|
|||||||
Reference in New Issue
Block a user