diff --git a/web/src/features/pause/PauseWidget.tsx b/web/src/features/pause/PauseWidget.tsx index 76b448d..758e19e 100644 --- a/web/src/features/pause/PauseWidget.tsx +++ b/web/src/features/pause/PauseWidget.tsx @@ -1,8 +1,10 @@ import { useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import * as stylex from "@stylexjs/stylex"; import { pauseMutation, pauseQuery } from "@/lib/queries"; import InlineError from "@/lib/InlineError"; -import { buttonClass, insetFocusRing } from "@/ui/classes"; +import { styles as shared } from "@/ui/styles"; +import { colors } from "@/ui/tokens.stylex"; const DURATIONS = [ { label: "60 seconds", seconds: 60 }, @@ -11,6 +13,72 @@ const DURATIONS = [ { label: "Indefinitely", seconds: null }, ] as const; +const styles = stylex.create({ + /** + * Disabled text darkens in light scheme and lightens in dark, the opposite + * direction from `textMuted`, so the token cannot express it. + */ + trigger: { + color: { + default: null, + ":disabled": "oklch(70.5% 0.015 286.067)", + "@media (prefers-color-scheme: dark)": { default: null, ":disabled": "oklch(44.2% 0.017 285.786)" }, + }, + }, + pausedRow: { + display: "flex", + flexDirection: "column", + alignItems: "flex-end", + }, + pausedControls: { + display: "flex", + alignItems: "center", + gap: "0.5rem", + }, + /** + * Amber as standalone text on the app ground, not inside a warning banner, so + * the `warn*` tokens — tuned against `warnSurface` — do not apply here. + */ + pausedLabel: { + fontSize: "0.875rem", + lineHeight: "1.25rem", + color: { + default: "oklch(55.5% 0.163 48.998)", + "@media (prefers-color-scheme: dark)": "oklch(82.8% 0.189 84.429)", + }, + }, + anchor: { + position: "relative", + }, + menu: { + position: "absolute", + right: 0, + top: "100%", + zIndex: 10, + marginTop: "0.25rem", + display: "flex", + width: "9rem", + flexDirection: "column", + borderRadius: "0.25rem", + borderWidth: 1, + borderStyle: "solid", + borderColor: colors.border, + backgroundColor: colors.surfaceRaised, + paddingBlock: "0.25rem", + boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)", + }, + menuItem: { + borderStyle: "none", + backgroundColor: { default: "transparent", ":hover": colors.surfaceHover }, + color: "inherit", + paddingInline: "0.75rem", + paddingBlock: "0.375rem", + textAlign: "left", + fontSize: "0.875rem", + lineHeight: "1.25rem", + }, +}); + export function formatRemaining(totalSeconds: number): string { const clamped = Math.max(0, totalSeconds); const hours = Math.floor(clamped / 3600); @@ -35,8 +103,6 @@ function useNowSeconds(active: boolean): number { return now; } -const triggerButtonClass = `${buttonClass} disabled:text-zinc-400 dark:disabled:text-zinc-600`; - export default function PauseWidget() { const queryClient = useQueryClient(); const { data } = useQuery({ @@ -52,7 +118,7 @@ export default function PauseWidget() { if (data === undefined) { return ( - ); @@ -60,16 +126,16 @@ export default function PauseWidget() { if (data.paused) { return ( -
-
- +
+
+ {data.until === null ? "Paused" : `Paused ${formatRemaining(data.until - now)}`} @@ -81,7 +147,7 @@ export default function PauseWidget() { return (
{ if (e.key === "Escape") setMenuOpen(false); }} @@ -92,15 +158,12 @@ export default function PauseWidget() { aria-controls="pause-menu" onClick={() => setMenuOpen((open) => !open)} disabled={mutation.isPending} - className={triggerButtonClass} + {...stylex.props(shared.button, styles.trigger, shared.focusRing)} > Pause {menuOpen && ( -
+
{DURATIONS.map(({ label, seconds }) => ( diff --git a/web/src/ui/styles.ts b/web/src/ui/styles.ts index aa49ffa..5e1b5c6 100644 --- a/web/src/ui/styles.ts +++ b/web/src/ui/styles.ts @@ -13,6 +13,11 @@ import { colors } from "./tokens.stylex"; const FOCUS = ":focus-visible"; const DISABLED = ":disabled"; +/** The half-fade loop a placeholder runs while its data is in flight. */ +const pulseFrames = stylex.keyframes({ + "50%": { opacity: 0.5 }, +}); + export const styles = stylex.create({ /** The ring drawn outside the element. */ focusRing: { @@ -151,6 +156,13 @@ export const styles = stylex.create({ color: colors.dangerText, }, + /** Marks an element as waiting on data. Carries no colour of its own. */ + pulse: { + animationName: pulseFrames, + animationDuration: "2s", + animationTimingFunction: "cubic-bezier(0.4, 0, 0.6, 1)", + animationIterationCount: "infinite", + }, /** Digits of equal width, so a column of counts does not jitter as it updates. */ tabularNums: { fontVariantNumeric: "tabular-nums", diff --git a/web/src/ui/tokens.stylex.ts b/web/src/ui/tokens.stylex.ts index cea67a7..a15361b 100644 --- a/web/src/ui/tokens.stylex.ts +++ b/web/src/ui/tokens.stylex.ts @@ -20,6 +20,8 @@ export const colors = stylex.defineVars({ surface: { default: "oklch(98.5% 0 none)", [DARK]: "oklch(14.1% 0.005 285.823)" }, /** Cards, dialogs and inputs sitting on top of the ground. */ surfaceRaised: { default: "#fff", [DARK]: "oklch(21% 0.006 285.885)" }, + /** The fill under a pointer on a nav item or a menu row. */ + surfaceHover: { default: "oklch(96.7% 0.001 286.375)", [DARK]: "oklch(27.4% 0.006 286.033)" }, /** Hairlines between rows and around cards. */ border: { default: "oklch(92% 0.004 286.32)", [DARK]: "oklch(27.4% 0.006 286.033)" }, /** Control outlines, which need more contrast than a row divider. */