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
+5 -4
View File
@@ -301,7 +301,7 @@ look's muted text colour.
| Not wired | the problem, at 17, muted, in the middle; a ring type draws no ring |
| Any other type | its rectangular body, without a card, from y = c − 64 to c + S/(2√2), x = c ± S/(2√2) (Knob: 53–307 × 116–307) |
| Page dots | one per face, from two faces: dots 6 across on targets 16 apart (closer when more than 128 wide would be needed), centred at y = S − 12; the current one in the text colour, the others muted at half opacity |
| Dashboard name | a pill centred at (c, c), 36 high: the icon 16 and the title at 15, medium, at most 220 wide, on the frosted surface; for 1000 ms |
| Dashboard name | a pill centred at (c, c), 56 high and fully round, 20 padding either side: the icon 24, 10, and the title at 24, medium, cut with an ellipsis; at most 280 wide, on the look's card colour, opaque, with a hairline border in `--border` (no blur), the face visible around it; for 1000 ms |
| Paging | faces slide sideways, dashboards up and down; see [Motion](#motion) |
| Locked | controls do nothing and read as disabled; the ring at 0.45 opacity. **Read-only** in the gap line's place (centred at y = S − 28): a lock glyph 15 and the word at 15, 6 apart, muted. A face whose gap line is in use leads that line with the lock glyph instead. Nothing is drawn outside the circle |
@@ -330,9 +330,9 @@ dashboard's look's.
| What | Moves | From → to | Timing | Input during it |
|---|---|---|---|---|
| Face paging (round) | the faces, side by side on a track | the track slides one face width S, the new face coming in from the side it lies on (the next from the right). A dot that skips faces brings its face in beside the one shown, so a turn is always one width | spring, from where the track is (a swipe's release speed carried on) | a new turn while one runs: the running one lands at once and the next starts |
| Dashboard paging (round panel) | the dashboard, and beside it the next one's name | slides S up (the next, from below) or down (the previous, from above), the name pill centred on an otherwise empty face; once it has landed and the dashboard has loaded, the dashboard replaces its name in place. One opened by its address replaces the last at once | spring | further paging waits for the turn to land |
| Dashboard paging (round panel) | the dashboard, and beside it the next one | slides S up (the next, from below) or down (the previous, from above), the next one drawn on the face it opens on; once landed, it is the dashboard shown, and its name pill shows for a moment. One opened by its address replaces the last at once | spring | further paging waits for the turn to land |
| Page dot | the current dot and the last | muted at 0.5 opacity ↔ text colour at 1 | 200 ms, cubic-bezier(0.4, 0, 0.2, 1) | retargets |
| Dashboard name | the pill | opacity 0 → 1, held 1000 ms, 1 → 0; on a dashboard a turn brought, where the name came in already, it starts at 1 | in 200 ms, out 150 ms, cubic-bezier(0.4, 0, 0.2, 1) | — |
| Dashboard name | the pill, on landing on another dashboard | opacity 0 → 1, held 1000 ms, 1 → 0 | in 200 ms, out 150 ms, cubic-bezier(0.4, 0, 0.2, 1) | — |
| Ring value set by a notch, a key or the engine | the value arc's end, the handle (slider, selector, colour) | from where it is drawn to the new fraction of the sweep | spring | each notch retargets from where it is, at the speed it has |
| Ring value set by a drag | the same | follows the pointer exactly | none | — |
| Hue across red | the colour handle | a change of more than half the sweep jumps to the other end | none | — |
@@ -363,7 +363,8 @@ under the finger, as a tile view moves.
|---|---|
| Start | a press anywhere on the face but the ring band, which turns the ring instead |
| Axis | none until the drag has gone 8 from where it began along either axis; then the longer of the two, kept until release |
| Follow | the page moves with the drag one to one, and the page that way is drawn beside it, edge to edge, S away: the next face, or the next dashboard's name pill on an otherwise empty face |
| Follow | the page moves with the drag one to one, and the page that way is drawn beside it, edge to edge, S away: the next face, or the next dashboard on the face it would open on (the one last shown there, else its first), with its live values |
| Neighbours | the dashboards before and after the one shown are built in advance — loaded, drawn and fed live values — so the one a swipe reaches is complete from the first move. They take no input until they are the one shown |
| Past the last | nothing beside it; the page moves 0.3 of the drag, at most 48 |
| Release | turns if the drag went at least S/4 (90 on the Knob), or went at least 8 and was moving at least 500 a second towards the next page over its last 80 ms; otherwise goes back |
| Turn, go back | the look's spring from where the page is, carrying the release speed; a device may use the look's cubic-bezier over its duration from where it is |
@@ -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"