Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/loader-and-route-pending.md
Original file line number Diff line number Diff line change
@@ -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`.
1 change: 1 addition & 0 deletions apps/conciv/src/pane/session-captures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export function useSessionCaptures(sessionId: string): SessionCapturesView {
staleTime: Infinity,
}))
const views = createMemo<Record<string, ToolCaptureView>>(() => {
if (captures.isPending) return {}
const data = captures.data
return data === undefined ? {} : toolCaptureViews(data)
})
Expand Down
4 changes: 4 additions & 0 deletions apps/conciv/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}

Expand Down Expand Up @@ -71,6 +72,9 @@ export function createConcivRouter(config: ConcivRouterConfig) {
routeTree,
history: config.history,
scrollRestoration: () => false,
defaultPendingComponent: PendingPane,
defaultPendingMs: 300,
defaultPendingMinMs: 500,
Comment on lines +75 to +77

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in e8d79d1: fake-core gained a sessions/resolve route, and a new test drives an alias navigation whose beforeLoad is held 900ms — asserting the pending loader appears, hands off to the pane, and leaves. Revert-checked: with defaultPendingComponent removed the test fails on the pending-visible assertion.

context: {
rpc: config.rpc,
environment: config.environment,
Expand Down
15 changes: 15 additions & 0 deletions apps/conciv/src/shell/pending.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Loader.Root class="h-full w-full text-pw-text" translations={{value: () => PENDING_PANE_LABEL}}>
<Loader.Indicator />
<Loader.Text>
<Loader.Label>{`${PENDING_PANE_LABEL}…`}</Loader.Label>
<Loader.Description>Reconnecting to your workspace.</Loader.Description>
</Loader.Text>
</Loader.Root>
)
}

export function SessionPillPending(props: {variant: 'pill' | 'bar'}): JSX.Element {
return (
<div class="inline-flex min-w-0 items-center" role="status">
Expand Down
1 change: 1 addition & 0 deletions apps/conciv/test/helpers/fake-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ export function installFakeCore(config: FakeCoreConfig = {}): FakeCore {

const routes: Record<string, (body: unknown, signal: AbortSignal) => 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),
Expand Down
73 changes: 73 additions & 0 deletions apps/conciv/test/route-boundary.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof installFakeCore>[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(() => <RouterProvider router={router} />)
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()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})

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()
})
1 change: 1 addition & 0 deletions packages/ui-kit-system/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
78 changes: 78 additions & 0 deletions packages/ui-kit-system/src/loader.stories.tsx
Original file line number Diff line number Diff line change
@@ -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: () => (
<Loader.Root translations={{value: () => 'Loading conciv'}}>
<Loader.Indicator />
<Loader.Text>
<Loader.Label>Loading conciv…</Loader.Label>
<Loader.Description>Restoring your session and its transcript.</Loader.Description>
</Loader.Text>
</Loader.Root>
),
}

export const Sizes: Story = {
render: () => (
<div class="flex flex-wrap gap-4 items-start">
<For each={SIZES}>
{(size) => (
<Loader.Root size={size} translations={{value: () => `Loading ${size}`}}>
<Loader.Indicator />
<Loader.Text>
<Loader.Label>Loading conciv…</Loader.Label>
<Loader.Description>Size {size}</Loader.Description>
</Loader.Text>
</Loader.Root>
)}
</For>
</div>
),
}

export const TitleOnly: Story = {
render: () => (
<Loader.Root size="sm" translations={{value: () => 'Loading conciv'}}>
<Loader.Indicator />
<Loader.Text>
<Loader.Label>Loading conciv…</Loader.Label>
</Loader.Text>
</Loader.Root>
),
}

export const OnAccent: Story = {
render: () => (
<div class="text-pw-on-accent rounded-pw-lg bg-pw-accent">
<Loader.Root translations={{value: () => 'Loading conciv'}}>
<Loader.Indicator />
<Loader.Text>
<Loader.Label>Loading conciv…</Loader.Label>
<Loader.Description>The orb is drawn in currentColor, so it inherits any surface.</Loader.Description>
</Loader.Text>
</Loader.Root>
</div>
),
}

export const SwappedIndicator: Story = {
render: () => (
<Loader.Root translations={{value: () => 'Loading conciv'}}>
<Loader.Indicator class="grid place-items-center">
<span class="border-2 border-pw-line border-t-pw-accent rounded-pw-pill size-6 anim-compact" />
</Loader.Indicator>
<Loader.Text>
<Loader.Label>Loading conciv…</Loader.Label>
<Loader.Description>Any children replace the default orb.</Loader.Description>
</Loader.Text>
</Loader.Root>
),
}
66 changes: 66 additions & 0 deletions packages/ui-kit-system/src/loader.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<span class="loader-arc-a loader-arc" />
<span class="loader-arc-b loader-arc" />
<span class="loader-arc-c loader-arc" />
<span class="loader-arc-d loader-arc" />
</>
)
}

function Root(
props: Omit<ComponentProps<typeof Ark.Root>, 'value' | 'defaultValue'> & {size?: LoaderSize},
): JSX.Element {
const [local, rest] = splitProps(props, ['class', 'size'])
return (
<Ark.Root
translations={DEFAULT_TRANSLATIONS}
{...rest}
value={null}
class={`${ROOT} ${local.class ?? ''}`}
data-size={local.size ?? 'md'}
/>
)
}

function Indicator(props: ComponentProps<typeof Ark.Track>): JSX.Element {
const [local, rest] = splitProps(props, ['class', 'children'])
return (
<Ark.Track {...rest} class={`${ORB} ${local.class ?? ''}`}>
<Show when={local.children !== undefined} fallback={<LoaderArcs />}>
{local.children}
</Show>
</Ark.Track>
)
}

function Text(props: ComponentProps<'div'>): JSX.Element {
const [local, rest] = splitProps(props, ['class'])
return <div {...rest} class={`${TEXT} ${local.class ?? ''}`} />
}

function Label(props: ComponentProps<typeof Ark.Label>): JSX.Element {
const [local, rest] = splitProps(props, ['class'])
return <Ark.Label {...rest} class={`${LABEL} ${local.class ?? ''}`} />
}

function Description(props: ComponentProps<'p'>): JSX.Element {
const [local, rest] = splitProps(props, ['class'])
return <p {...rest} class={`${DESCRIPTION} ${local.class ?? ''}`} />
}

export const Loader = Object.assign({}, Ark, {Root, Indicator, Text, Label, Description})
6 changes: 4 additions & 2 deletions packages/uno-preset/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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},
}
}
Loading
Loading