Merge remote-tracking branch 'origin/main'

This commit is contained in:
2026-10-15 08:20:11 +02:00
3 changed files with 114 additions and 91 deletions
@@ -400,6 +400,7 @@ export function DashboardView({
size={canvasOf(dashboard).width}
onDashboard={onDashboard}
around={around}
editing={editing}
/>
) : (
<WidgetGrid
+108 -87
View File
@@ -22,7 +22,12 @@ import { resolveIcon } from "./icons"
import { usePublish } from "./publish"
import { dashboardQueryOptions } from "./queries"
import { CentreReading, FaceTitle, hasGapLine, Ring } from "./RingWidget"
import { useLocked, usePalette } from "./settings"
import {
LockedProvider,
PaletteProvider,
useLocked,
usePalette,
} from "./settings"
import { useUi } from "./ui"
import { arcPath } from "./ui/core/arc"
import {
@@ -206,47 +211,54 @@ function usePageDrag(
/**
* A panel's round dashboards, one at a time, paged up and down.
*
* Swiped, the dashboard follows the finger and the next one's name comes up
* from below it (the previous one's down from above); let go far enough and
* the turn completes, and the dashboard itself takes its name's place once it
* has loaded. Shift+PageUp/PageDown turn the same way. One opened by its
* address replaces the last at once.
* The dashboards either side are drawn a screen above and below, ahead of
* time and live, each on the face it would open on, so a swipe up or down
* drags the next one in exactly as a sideways swipe drags the next face. Let
* go far enough and the turn completes; Shift+PageUp/PageDown turn the same
* way. One opened by its address replaces the last at once.
*/
export function RoundPanel({
dashboard,
size,
onDashboard,
around,
editing,
}: {
dashboard: Dashboard
size: number
onDashboard?: (by: number) => void
/** The panel's dashboards before and after this one. */
around?: [string | undefined, string | undefined]
/** Being arranged, so nothing is locked. */
editing?: boolean
}) {
const { look } = useLook()
const still = useReducedMotion()
const spring = LOOK[look].spring
const y = useMotionValue(0)
// The dashboard held at the origin while a turn is on its way to the next,
// the side the next one's name is on, whether the turn has landed, and the
// last dashboard a turn brought, which has been named already.
// where that one is and what it is called, and whether the turn has landed.
const [held, setHeld] = useState<Dashboard | null>(null)
const [beside, setBeside] = useState<{ by: 1 | -1; name: string } | null>(
const [turning, setTurning] = useState<{ by: 1 | -1; name: string } | null>(
null,
)
const [landed, setLanded] = useState(false)
const [reached, setReached] = useState<string | null>(null)
const shown = held ?? dashboard
const name = (by: number) => around?.[by > 0 ? 1 : 0]
const read = (of: string | undefined) => ({
...dashboardQueryOptions(of ?? ""),
enabled: Boolean(of),
})
const before = useQuery(read(around?.[0])).data
const after = useQuery(read(around?.[1])).data
// By the name it had when the turn began: the panel's neighbours are
// counted from the new dashboard as soon as it is asked for.
const target = useQuery(read(turning?.name)).data
const turn = (by: 1 | -1, speed = 0) => {
const next = name(by)
if (!next || !onDashboard || held) return
setHeld(dashboard)
// Its name as it was when the turn began: the panel's neighbours are
// counted from the new dashboard as soon as it is asked for.
setBeside({ by, name: next })
setTurning({ by, name: next })
setLanded(false)
onDashboard(by)
if (still) {
@@ -265,90 +277,100 @@ export function RoundPanel({
}).then(() => setLanded(true))
}
// Landed, and the dashboard it went to has loaded: that one takes its
// name's place, which it was drawn as, so nothing moves.
// Landed, and the panel has moved on to it: it becomes the one at the
// origin, which it is already drawn as, so nothing moves.
useLayoutEffect(() => {
if (!held || !landed || dashboard.name === held.name) return
setHeld(null)
setBeside(null)
setTurning(null)
setLanded(false)
setReached(dashboard.name)
y.jump(0)
}, [held, landed, dashboard.name, y])
const rise: Axis = {
open: (by) => Boolean(name(by) && onDashboard) && !held,
drag: (offset, by) => {
if (held) return
const next = onDashboard ? name(by) : undefined
if (next !== beside?.name) setBeside(next ? { by, name: next } : null)
y.set(offset)
drag: (offset) => {
if (!held) y.set(offset)
},
release: (by, turnIt, speed) => {
if (held) return
if (turnIt) {
turn(by, speed)
return
}
if (still) {
y.jump(0)
setBeside(null)
return
}
animate(y, 0, { ...spring, velocity: speed }).then(() => setBeside(null))
if (turnIt) turn(by, speed)
else if (still) y.jump(0)
else animate(y, 0, { ...spring, velocity: speed })
},
}
// On the track by place: the one shown and its neighbours a screen either
// side; during a turn, the one it left and the one it is going to.
const pages: [Dashboard | undefined, number][] =
held && turning
? [
[held, 0],
[target, turning.by],
]
: [
[before, -1],
[dashboard, 0],
[after, 1],
]
return (
<motion.div className="absolute inset-0" style={{ y }}>
<RoundView
// Its own face memory per dashboard, so a panel switching to another
// starts that one where it was left.
key={shown.name}
dashboard={shown}
size={size}
onDashboard={onDashboard && ((by) => turn(by > 0 ? 1 : -1))}
rise={rise}
named={reached === shown.name}
/>
{beside ? (
<div
className="pointer-events-none absolute inset-x-0 flex items-center justify-center"
style={{ top: beside.by * size, height: size }}
>
<DashboardName name={beside.name} />
</div>
) : null}
{pages.map(([page, at]) => {
if (!page) return null
const view = (
<RoundView
dashboard={page}
size={size}
peek={at !== 0}
onDashboard={
at === 0 && onDashboard
? (by) => turn(by > 0 ? 1 : -1)
: undefined
}
rise={at === 0 ? rise : undefined}
/>
)
return (
// Keyed by name, so a neighbour becomes the one shown, and back,
// without being drawn afresh: its own face memory and all.
<div
key={page.name}
className="absolute inset-0"
style={{ translate: `0 ${at * size}px` }}
inert={at !== 0}
>
<PaletteProvider dashboard={page}>
{editing ? (
view
) : (
<LockedProvider dashboard={page}>{view}</LockedProvider>
)}
</PaletteProvider>
</div>
)
})}
</motion.div>
)
}
/** A dashboard's icon and title, on the frosted surface: what a panel shows
* of a dashboard it is turning to, and of one it has just landed on. */
function NamePill({ dashboard }: { dashboard: Dashboard | undefined }) {
const Glyph = resolveIcon(dashboard?.icon ?? "")
/** A dashboard's icon and title: what a panel shows for a moment of a
* dashboard it has just landed on. Opaque, so the face under it does not
* read through the name. */
function NamePill({ dashboard }: { dashboard: Dashboard }) {
const Glyph = resolveIcon(dashboard.icon ?? "")
return (
<output
data-testid="dashboard-announce"
className="flex h-9 max-w-[220px] items-center gap-2 rounded-full border border-border bg-card/80 px-4 font-medium shadow-e2 backdrop-blur-md"
style={{ fontSize: 15 }}
className="flex h-14 max-w-[280px] items-center gap-2.5 rounded-full border border-border bg-card px-5 font-medium text-card-foreground shadow-e2"
style={{ fontSize: 24 }}
>
{Glyph ? <Glyph className="size-4 shrink-0" aria-hidden /> : null}
<span className="truncate">{dashboard?.title || dashboard?.name}</span>
{Glyph ? <Glyph className="size-6 shrink-0" aria-hidden /> : null}
<span className="truncate">{dashboard.title || dashboard.name}</span>
</output>
)
}
/** The name of a dashboard not drawn yet, read for it — which is also what
* has it loaded by the time a turn lands on it. */
function DashboardName({ name }: { name: string }) {
const { data } = useQuery({
...dashboardQueryOptions(name),
enabled: Boolean(name),
})
return <NamePill dashboard={data ?? ({ name } as Dashboard)} />
}
/**
* A round dashboard: one widget at a time, each drawn as the whole face.
*
@@ -358,7 +380,8 @@ function DashboardName({ name }: { name: string }) {
* the panel's other dashboards (`rise`, `onDashboard`: a swipe or
* Shift+PageUp/PageDown), and landing on one names it for a moment. Each
* dashboard comes back to the face it was left on. The arrow keys stay the
* ring's.
* ring's. A `peek` is a neighbour drawn beside the one shown: it answers to
* nothing until it becomes the one shown.
*
* Arranging, the editor says which face is shown (`face`, `onFace`) and a
* press on a face that is not one of its controls picks it (`onPick`).
@@ -372,7 +395,7 @@ export function RoundView({
onPick,
onDashboard,
rise,
named,
peek,
}: {
dashboard: Dashboard
/** The face's diameter: the canvas width. */
@@ -385,9 +408,7 @@ export function RoundView({
onDashboard?: (by: number) => void
/** Swiping up and down, to the panel's other dashboards. */
rise?: Axis
/** Turned to, its name already shown on the way: it stays without
* fading in again. */
named?: boolean
peek?: boolean
}) {
const widgets = dashboard.widgets ?? []
const { look } = useLook()
@@ -408,16 +429,15 @@ export function RoundView({
// whether a drag has the track.
const fling = useRef(0)
const dragging = useRef(false)
const [announce, setAnnounce] = useState(
() =>
Boolean(onDashboard) &&
lastShown !== null &&
lastShown !== dashboard.name,
)
const [announce, setAnnounce] = useState(false)
// Named for a moment when a panel lands on it from another — once it is
// the one shown, not while it waits beside it.
useEffect(() => {
if (onDashboard) lastShown = dashboard.name
}, [onDashboard, dashboard.name])
if (!onDashboard || peek) return
if (lastShown !== null && lastShown !== dashboard.name) setAnnounce(true)
lastShown = dashboard.name
}, [onDashboard, peek, dashboard.name])
useEffect(() => {
if (!announce) return
@@ -477,11 +497,12 @@ export function RoundView({
onFace?.(next)
}
const keys = !editing && !peek
useShortcuts({
pagedown: () => (editing ? false : page(index + 1)),
pageup: () => (editing ? false : page(index - 1)),
"shift+pagedown": () => (onDashboard && !editing ? onDashboard(1) : false),
"shift+pageup": () => (onDashboard && !editing ? onDashboard(-1) : false),
pagedown: () => (keys ? page(index + 1) : false),
pageup: () => (keys ? page(index - 1) : false),
"shift+pagedown": () => (onDashboard && keys ? onDashboard(1) : false),
"shift+pageup": () => (onDashboard && keys ? onDashboard(-1) : false),
})
const sideways: Axis = {
@@ -629,7 +650,7 @@ export function RoundView({
</nav>
) : null}
<AnimatePresence initial={!named}>
<AnimatePresence>
{announce ? (
<motion.div
key="announce"