diff --git a/.changeset/loader-and-route-pending.md b/.changeset/loader-and-route-pending.md new file mode 100644 index 000000000..226475f59 --- /dev/null +++ b/.changeset/loader-and-route-pending.md @@ -0,0 +1,5 @@ +--- +'@conciv/ui-kit-system': patch +--- + +New `Loader` compound (`Loader.Root/Indicator/Text/Label/Description`) built on Ark's indeterminate Progress: a conic-gradient orb whose arcs animate registered `@property` angles rather than rotating a rasterized texture, drawn entirely in `currentColor` so it inherits any surface. Sizes ride a `--pw-loader-size` variable through `data-size`, and `Loader.Indicator` renders whatever children it is given, so a different visual replaces one part instead of the component. Styled entirely through the shared `@conciv/uno-preset` (keyframes, a `data-size` rule and shortcuts), like every other component in the package — no separate stylesheet to `@import`. diff --git a/apps/conciv/src/pane/session-captures.ts b/apps/conciv/src/pane/session-captures.ts index 02f9a294f..947d706f2 100644 --- a/apps/conciv/src/pane/session-captures.ts +++ b/apps/conciv/src/pane/session-captures.ts @@ -15,6 +15,7 @@ export function useSessionCaptures(sessionId: string): SessionCapturesView { staleTime: Infinity, })) const views = createMemo>(() => { + if (captures.isPending) return {} const data = captures.data return data === undefined ? {} : toolCaptureViews(data) }) diff --git a/apps/conciv/src/router.ts b/apps/conciv/src/router.ts index abb63adbb..950ecc963 100644 --- a/apps/conciv/src/router.ts +++ b/apps/conciv/src/router.ts @@ -9,6 +9,7 @@ import {makeAppData, type AppData} from './data/app-data.js' import type {ConcivSettings} from './data/settings.js' import type {ExtensionInstance} from './extension/extension-slots.js' import highlight from './extensions/highlight.js' +import {PendingPane} from './shell/pending.js' export type ConcivEnvironment = {rootNode: Node; document: Document} @@ -71,6 +72,9 @@ export function createConcivRouter(config: ConcivRouterConfig) { routeTree, history: config.history, scrollRestoration: () => false, + defaultPendingComponent: PendingPane, + defaultPendingMs: 300, + defaultPendingMinMs: 500, context: { rpc: config.rpc, environment: config.environment, diff --git a/apps/conciv/src/shell/pending.tsx b/apps/conciv/src/shell/pending.tsx index 18645fa99..65df4f120 100644 --- a/apps/conciv/src/shell/pending.tsx +++ b/apps/conciv/src/shell/pending.tsx @@ -1,7 +1,22 @@ import {type JSX} from 'solid-js' +import {Loader} from '@conciv/ui-kit-system' const SKEL = 'skel-bg [background-size:200%_100%] anim-skel' +const PENDING_PANE_LABEL = 'Loading conciv' + +export function PendingPane(): JSX.Element { + return ( + PENDING_PANE_LABEL}}> + + + {`${PENDING_PANE_LABEL}…`} + Reconnecting to your workspace. + + + ) +} + export function SessionPillPending(props: {variant: 'pill' | 'bar'}): JSX.Element { return (
diff --git a/apps/conciv/test/helpers/fake-core.ts b/apps/conciv/test/helpers/fake-core.ts index 5417755b8..0370ffc34 100644 --- a/apps/conciv/test/helpers/fake-core.ts +++ b/apps/conciv/test/helpers/fake-core.ts @@ -142,6 +142,7 @@ export function installFakeCore(config: FakeCoreConfig = {}): FakeCore { const routes: Record Response> = { '/rpc/sessions/list': () => reply(config.sessions ?? [sessionRow({id: 'conciv_1'})]), + '/rpc/sessions/resolve': () => reply({sessionId: config.sessions?.[0]?.id ?? 'conciv_1'}), '/rpc/sessions/create': () => reply({sessionId: 'conciv_2'}), '/rpc/sessions/compact': () => reply({ok: true}), '/rpc/drafts/get': () => reply(config.draft ?? null), diff --git a/apps/conciv/test/route-boundary.browser.test.tsx b/apps/conciv/test/route-boundary.browser.test.tsx new file mode 100644 index 000000000..c112209e5 --- /dev/null +++ b/apps/conciv/test/route-boundary.browser.test.tsx @@ -0,0 +1,73 @@ +import './helpers/utilities.css' +import {afterEach, expect, test} from 'vitest' +import {page} from 'vitest/browser' +import {render} from '@solidjs/testing-library' +import {RouterProvider, createMemoryHistory} from '@tanstack/solid-router' +import {makeRpcClient} from '@conciv/contract' +import {parseConcivSettings} from '../src/data/settings.js' +import {createConcivRouter, disposeConcivRouter} from '../src/router.js' +import {CORE_BASE, installFakeCore, sessionRow, type FakeCore} from './helpers/fake-core.js' + +const PANEL_SESSION = 'conciv_1' +const HELD_ROUTE_MS = 1500 +const WHILE_HELD = {timeout: 700} +const disposers: (() => void)[] = [] +let core: FakeCore | null = null + +afterEach(() => { + for (const dispose of disposers.splice(0)) dispose() + core?.restore() + core = null +}) + +const PANEL_ENTRY = `/panel/${PANEL_SESSION}?open=true` +const CLOSED_ENTRY = '/' + +function mountShell(entry: string, config: Parameters[0] = {}): void { + core = installFakeCore({sessions: [sessionRow({id: PANEL_SESSION})], ...config}) + const router = createConcivRouter({ + rpc: makeRpcClient(CORE_BASE), + history: createMemoryHistory({initialEntries: [entry]}), + environment: {rootNode: document, document}, + settings: parseConcivSettings(''), + }) + const mounted = render(() => ) + disposers.push(() => { + mounted.unmount() + disposeConcivRouter(router) + }) +} + +const editor = () => page.getByRole('textbox', {name: 'Message the conciv agent'}) +const launcher = () => page.getByRole('button', {name: 'Open conciv chat'}) +const routePending = () => page.getByRole('progressbar', {name: 'Loading conciv'}) + +test('the pane paints its composer while the element captures query is still in flight', async () => { + mountShell(PANEL_ENTRY, {delays: {'/rpc/captures/list': HELD_ROUTE_MS}}) + + await expect.element(editor(), WHILE_HELD).toBeVisible() + await expect.element(routePending(), WHILE_HELD).not.toBeInTheDocument() +}) + +test('the shell keeps its launcher while the session list query is still in flight', async () => { + mountShell(CLOSED_ENTRY, {delays: {'/rpc/sessions/list': HELD_ROUTE_MS}}) + + await expect.element(launcher(), WHILE_HELD).toBeVisible() + await expect.element(routePending(), WHILE_HELD).not.toBeInTheDocument() +}) + +test('a pane whose queries all answer immediately never shows the route pending loader', async () => { + mountShell(PANEL_ENTRY) + + await expect.element(editor(), WHILE_HELD).toBeVisible() + await core?.idle() + await expect.element(routePending(), WHILE_HELD).not.toBeInTheDocument() +}) + +test('a slow beforeLoad reveals the route pending loader, then hands off to the pane', async () => { + mountShell('/panel/latest?open=true', {delays: {'/rpc/sessions/resolve': 900}}) + + await expect.element(routePending()).toBeVisible() + await expect.element(editor(), {timeout: 2000}).toBeVisible() + await expect.element(routePending()).not.toBeInTheDocument() +}) diff --git a/packages/ui-kit-system/src/index.tsx b/packages/ui-kit-system/src/index.tsx index 3ba580337..35bb8f1d5 100644 --- a/packages/ui-kit-system/src/index.tsx +++ b/packages/ui-kit-system/src/index.tsx @@ -7,6 +7,7 @@ export {Select, createListCollection} from './select.js' export {Dialog, type DialogApi} from './dialog.js' export {TextField, TextArea, type TextAreaProps} from './text-field.js' export {Progress} from './progress.js' +export {Loader, type LoaderSize} from './loader.js' export {Tooltip} from './tooltip.js' export { TooltipIconButton, diff --git a/packages/ui-kit-system/src/loader.stories.tsx b/packages/ui-kit-system/src/loader.stories.tsx new file mode 100644 index 000000000..030f436d8 --- /dev/null +++ b/packages/ui-kit-system/src/loader.stories.tsx @@ -0,0 +1,78 @@ +import {For} from 'solid-js' +import type {Meta, StoryObj} from 'storybook-solidjs-vite' +import {Loader, type LoaderSize} from './loader.js' + +const meta: Meta = {title: 'ui-kit-system/Loader'} +export default meta +type Story = StoryObj + +const SIZES: LoaderSize[] = ['sm', 'md', 'lg'] + +export const Default: Story = { + render: () => ( + 'Loading conciv'}}> + + + Loading conciv… + Restoring your session and its transcript. + + + ), +} + +export const Sizes: Story = { + render: () => ( +
+ + {(size) => ( + `Loading ${size}`}}> + + + Loading conciv… + Size {size} + + + )} + +
+ ), +} + +export const TitleOnly: Story = { + render: () => ( + 'Loading conciv'}}> + + + Loading conciv… + + + ), +} + +export const OnAccent: Story = { + render: () => ( +
+ 'Loading conciv'}}> + + + Loading conciv… + The orb is drawn in currentColor, so it inherits any surface. + + +
+ ), +} + +export const SwappedIndicator: Story = { + render: () => ( + 'Loading conciv'}}> + + + + + Loading conciv… + Any children replace the default orb. + + + ), +} diff --git a/packages/ui-kit-system/src/loader.tsx b/packages/ui-kit-system/src/loader.tsx new file mode 100644 index 000000000..f68b18d73 --- /dev/null +++ b/packages/ui-kit-system/src/loader.tsx @@ -0,0 +1,66 @@ +import {Show, splitProps, type ComponentProps, type JSX} from 'solid-js' +import {Progress as Ark} from '@ark-ui/solid/progress' + +export type LoaderSize = 'sm' | 'md' | 'lg' + +const ROOT = 'loader-size flex flex-col items-center justify-center gap-8 p-8' +const ORB = 'loader-orb' +const TEXT = 'loader-text' +const LABEL = 'loader-label' +const DESCRIPTION = 'loader-description' + +const DEFAULT_TRANSLATIONS = {value: () => 'Loading'} + +function LoaderArcs(): JSX.Element { + return ( + <> + + + + + + ) +} + +function Root( + props: Omit, 'value' | 'defaultValue'> & {size?: LoaderSize}, +): JSX.Element { + const [local, rest] = splitProps(props, ['class', 'size']) + return ( + + ) +} + +function Indicator(props: ComponentProps): JSX.Element { + const [local, rest] = splitProps(props, ['class', 'children']) + return ( + + }> + {local.children} + + + ) +} + +function Text(props: ComponentProps<'div'>): JSX.Element { + const [local, rest] = splitProps(props, ['class']) + return
+} + +function Label(props: ComponentProps): JSX.Element { + const [local, rest] = splitProps(props, ['class']) + return +} + +function Description(props: ComponentProps<'p'>): JSX.Element { + const [local, rest] = splitProps(props, ['class']) + return

+} + +export const Loader = Object.assign({}, Ark, {Root, Indicator, Text, Label, Description}) diff --git a/packages/uno-preset/src/index.ts b/packages/uno-preset/src/index.ts index 6eec3af0e..1f204ddac 100644 --- a/packages/uno-preset/src/index.ts +++ b/packages/uno-preset/src/index.ts @@ -11,15 +11,17 @@ import {effects} from './effects.js' import {typography} from './typography.js' import {shortcuts} from './shortcuts.js' import {jsonTree} from './json-tree.js' +import {loaderShortcuts, loaderRules, loaderPreflight} from './loader.js' export function presetConciv(): Preset { return { name: '@conciv/uno-preset', presets: [presetWind4({preflights: {reset: false}, variablePrefix: 'unx-'}), typography], - rules: [jsonTree], + rules: [jsonTree, ...loaderRules], + preflights: [loaderPreflight], separators: [':'], theme: {colors, radius, font, ease, animation}, - shortcuts: {...shortcuts, ...motion, ...effects, ...shadows}, + shortcuts: {...shortcuts, ...motion, ...effects, ...shadows, ...loaderShortcuts}, } } diff --git a/packages/uno-preset/src/loader.ts b/packages/uno-preset/src/loader.ts new file mode 100644 index 000000000..8afebb8ff --- /dev/null +++ b/packages/uno-preset/src/loader.ts @@ -0,0 +1,290 @@ +import {toEscapedSelector, type Preflight, type Rule, type StaticShortcutMap} from 'unocss' + +export const loaderShortcuts = { + 'loader-arc': 'absolute inset-0 rounded-pw-pill', +} satisfies StaticShortcutMap + +function blockRule(name: string, css: (selector: string) => string): Rule { + return [new RegExp(`^${name}$`), (_match, {rawSelector}) => css(toEscapedSelector(rawSelector))] +} + +export const loaderRules: Rule[] = [ + blockRule( + 'loader-size', + (s) => ` +${s} { + --pw-loader-size: 8rem; + --pw-loader-label-size: 1rem; + --pw-loader-description-size: 0.875rem; +} +${s}[data-size='sm'] { + --pw-loader-size: 5rem; + --pw-loader-label-size: 0.875rem; + --pw-loader-description-size: 0.75rem; +} +${s}[data-size='lg'] { + --pw-loader-size: 10rem; + --pw-loader-label-size: 1.125rem; + --pw-loader-description-size: 1rem; +} +${s}[data-size='lg'] .loader-label { + font-weight: 600; +} +`, + ), + blockRule( + 'loader-orb', + (s) => ` +${s} { + position: relative; + inline-size: var(--pw-loader-size); + block-size: var(--pw-loader-size); + animation: pw-loader-breathe 4s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} +@media (prefers-reduced-motion: reduce) { + ${s} { + animation: none; + } +} +`, + ), + blockRule( + 'loader-arc-a', + (s) => ` +${s} { + opacity: 0.8; + background: conic-gradient(from var(--pw-loader-angle-a), transparent 0deg, currentColor 90deg, transparent 180deg); + mask: radial-gradient( + circle at 50% 50%, + transparent calc(36.5% - 2px), + black 36.5%, + black 38.5%, + transparent calc(38.5% + 2px) + ); + animation: pw-loader-sweep-a 3s linear infinite; +} +@media (prefers-reduced-motion: reduce) { + ${s} { + display: none; + } +} +`, + ), + blockRule( + 'loader-arc-b', + (s) => ` +${s} { + opacity: 0.9; + background: conic-gradient( + from var(--pw-loader-angle-b), + transparent 0deg, + currentColor 120deg, + color-mix(in srgb, currentColor 50%, transparent) 240deg, + transparent 360deg + ); + mask: radial-gradient( + circle at 50% 50%, + transparent calc(44% - 2px), + black 44%, + black 48%, + transparent calc(48% + 2px) + ); + animation: pw-loader-sweep-b 2.5s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} +@media (prefers-reduced-motion: reduce) { + ${s} { + opacity: 0.4; + animation: none; + } +} +`, + ), + blockRule( + 'loader-arc-c', + (s) => ` +${s} { + opacity: 0.35; + background: conic-gradient( + from var(--pw-loader-angle-c), + transparent 0deg, + color-mix(in srgb, currentColor 60%, transparent) 45deg, + transparent 90deg + ); + mask: radial-gradient( + circle at 50% 50%, + transparent calc(54% - 2px), + black 54%, + black 56%, + transparent calc(56% + 2px) + ); + animation: pw-loader-sweep-c 4s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} +@media (prefers-reduced-motion: reduce) { + ${s} { + display: none; + } +} +`, + ), + blockRule( + 'loader-arc-d', + (s) => ` +${s} { + opacity: 0.5; + background: conic-gradient( + from var(--pw-loader-angle-d), + transparent 0deg, + color-mix(in srgb, currentColor 40%, transparent) 20deg, + transparent 40deg + ); + mask: radial-gradient( + circle at 50% 50%, + transparent calc(62% - 1.5px), + black 62%, + black 63%, + transparent calc(63% + 1.5px) + ); + animation: pw-loader-sweep-d 3.5s linear infinite; +} +@media (prefers-reduced-motion: reduce) { + ${s} { + display: none; + } +} +`, + ), + blockRule( + 'loader-text', + (s) => ` +${s} { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; + text-align: center; + max-inline-size: 22ch; + animation: pw-fade-in-up 1s var(--pw-ease) 0.4s backwards; +} +@media (prefers-reduced-motion: reduce) { + ${s} { + animation: none; + } +} +`, + ), + blockRule( + 'loader-label', + (s) => ` +${s} { + font-weight: 500; + font-size: var(--pw-loader-label-size); + line-height: 1.15; + letter-spacing: -0.02em; + text-wrap: balance; + overflow-wrap: anywhere; + color: color-mix(in srgb, currentColor 90%, transparent); + animation: + pw-fade-in-up 0.8s var(--pw-ease) 0.6s backwards, + pw-loader-label-pulse 3s cubic-bezier(0.4, 0, 0.6, 1) 1.4s infinite; +} +@media (prefers-reduced-motion: reduce) { + ${s} { + animation: none; + } +} +`, + ), + blockRule( + 'loader-description', + (s) => ` +${s} { + font-size: var(--pw-loader-description-size); + line-height: 1.45; + letter-spacing: -0.01em; + text-wrap: pretty; + overflow-wrap: anywhere; + color: color-mix(in srgb, currentColor 60%, transparent); + animation: + pw-fade-in-up 0.8s var(--pw-ease) 0.8s backwards, + pw-loader-description-pulse 4s cubic-bezier(0.4, 0, 0.6, 1) 1.6s infinite; +} +@media (prefers-reduced-motion: reduce) { + ${s} { + animation: none; + } +} +`, + ), +] + +export const loaderPreflight: Preflight = { + getCSS: () => ` +@keyframes pw-loader-sweep-a { + to { + --pw-loader-angle-a: 360deg; + } +} +@keyframes pw-loader-sweep-b { + to { + --pw-loader-angle-b: 360deg; + } +} +@keyframes pw-loader-sweep-c { + to { + --pw-loader-angle-c: -180deg; + } +} +@keyframes pw-loader-sweep-d { + to { + --pw-loader-angle-d: 630deg; + } +} +@keyframes pw-loader-breathe { + 0%, + 100% { + transform: scale(1); + } + 50% { + transform: scale(1.02); + } +} +@keyframes pw-loader-label-pulse { + 0%, + 100% { + opacity: 0.9; + } + 50% { + opacity: 0.7; + } +} +@keyframes pw-loader-description-pulse { + 0%, + 100% { + opacity: 0.6; + } + 50% { + opacity: 0.4; + } +} +@property --pw-loader-angle-a { + syntax: ''; + inherits: false; + initial-value: 0deg; +} +@property --pw-loader-angle-b { + syntax: ''; + inherits: false; + initial-value: 0deg; +} +@property --pw-loader-angle-c { + syntax: ''; + inherits: false; + initial-value: 180deg; +} +@property --pw-loader-angle-d { + syntax: ''; + inherits: false; + initial-value: 270deg; +} +`, +}