From c3c56177051ae06a4a4f5fdc5305b84cfb30de66 Mon Sep 17 00:00:00 2001 From: Omri Katz <9701896+omridevk@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:47:40 +0300 Subject: [PATCH 1/8] feat(ui-kit-system): Loader compound with a swappable indeterminate orb (#455) Loader.Root wraps Ark's indeterminate Progress; Loader.Indicator is the one visual slot and renders any children it is given, so a future orb replaces a single part rather than the component. The default orb animates registered @property gradient angles instead of rotating a rasterized texture, draws in currentColor so it inherits any surface, and collapses to a single static arc under prefers-reduced-motion. Sizes ride --pw-loader-size through data-size on Root. The stylesheet ships as @conciv/ui-kit-system/loader.css, matching how ui-kit-chat ships its theme layers. Co-Authored-By: Claude Fable 5 --- .changeset/loader-and-route-pending.md | 5 + apps/storybook/.storybook/storybook.css | 1 + packages/ui-kit-system/package.json | 8 +- packages/ui-kit-system/src/index.tsx | 1 + packages/ui-kit-system/src/loader.css | 280 ++++++++++++++++++ packages/ui-kit-system/src/loader.stories.tsx | 78 +++++ packages/ui-kit-system/src/loader.tsx | 65 ++++ 7 files changed, 435 insertions(+), 3 deletions(-) create mode 100644 .changeset/loader-and-route-pending.md create mode 100644 packages/ui-kit-system/src/loader.css create mode 100644 packages/ui-kit-system/src/loader.stories.tsx create mode 100644 packages/ui-kit-system/src/loader.tsx diff --git a/.changeset/loader-and-route-pending.md b/.changeset/loader-and-route-pending.md new file mode 100644 index 000000000..f6747658a --- /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. Ships its stylesheet as `@conciv/ui-kit-system/loader.css`. diff --git a/apps/storybook/.storybook/storybook.css b/apps/storybook/.storybook/storybook.css index 1a4431f60..6b5aa3278 100644 --- a/apps/storybook/.storybook/storybook.css +++ b/apps/storybook/.storybook/storybook.css @@ -1,5 +1,6 @@ @import '@unocss/reset/tailwind.css'; @import '@conciv/ui-kit-system/tokens.css'; +@import '@conciv/ui-kit-system/loader.css'; @import '@conciv/ui-kit-chat/theme/tokens.css'; @import '@conciv/ui-kit-chat/theme/conciv.css'; @import '@conciv/solid-streamdown/styles.css'; diff --git a/packages/ui-kit-system/package.json b/packages/ui-kit-system/package.json index 8bd28f4b3..7623f278f 100644 --- a/packages/ui-kit-system/package.json +++ b/packages/ui-kit-system/package.json @@ -19,7 +19,8 @@ }, "files": [ "dist", - "src/tokens.css" + "src/tokens.css", + "src/loader.css" ], "type": "module", "exports": { @@ -31,7 +32,8 @@ "types": "./dist/tokens.d.ts", "import": "./dist/tokens.js" }, - "./tokens.css": "./src/tokens.css" + "./tokens.css": "./src/tokens.css", + "./loader.css": "./src/loader.css" }, "publishConfig": { "access": "public" @@ -43,7 +45,7 @@ "lint": "oxlint", "test": "vitest run --passWithNoTests", "publint": "publint", - "attw": "attw --pack . --profile esm-only --exclude-entrypoints tokens.css" + "attw": "attw --pack . --profile esm-only --exclude-entrypoints tokens.css loader.css" }, "dependencies": { "@ark-ui/solid": "^5.37.1", 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.css b/packages/ui-kit-system/src/loader.css new file mode 100644 index 000000000..0ecd4725c --- /dev/null +++ b/packages/ui-kit-system/src/loader.css @@ -0,0 +1,280 @@ +/* Loader (ui-kit-system compound). Lives in a stylesheet rather than UnoCSS utilities because the orb + needs registered @property angles, four @keyframes and feathered radial-gradient masks — none of which + a utility class can express. Consumers @import '@conciv/ui-kit-system/loader.css', the same way they + already @import the tokens sheet. Everything is currentColor + --pw-* tokens, so the orb inherits the + surrounding text color and needs no theme wiring. + + The arcs animate their conic-gradient ANGLE, not `transform: rotate`. Rotating the element rasterizes + the gradient once and spins the texture, which visibly stair-steps on the feathered mask edge; animating + the registered angle re-renders the gradient each frame and stays clean. The @supports fallback below + covers engines without registered custom properties, where a rotated texture is the only option. */ + +@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; +} + +@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-rise { + from { + opacity: 0; + transform: translateY(0.75rem); + } + to { + opacity: 1; + transform: translateY(0); + } +} +@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; + } +} +@keyframes pw-loader-spin { + to { + transform: rotate(360deg); + } +} +@keyframes pw-loader-spin-reverse { + to { + transform: rotate(-360deg); + } +} + +.pw-loader { + --pw-loader-size: 8rem; + --pw-loader-label-size: 1rem; + --pw-loader-description-size: 0.875rem; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2rem; + padding: 2rem; +} + +.pw-loader[data-size='sm'] { + --pw-loader-size: 5rem; + --pw-loader-label-size: 0.875rem; + --pw-loader-description-size: 0.75rem; +} + +.pw-loader[data-size='lg'] { + --pw-loader-size: 10rem; + --pw-loader-label-size: 1.125rem; + --pw-loader-description-size: 1rem; +} + +.pw-loader-orb { + 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; +} + +.pw-loader-arc { + position: absolute; + inset: 0; + border-radius: var(--pw-r-pill); +} + +.pw-loader-arc-a { + 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) + ); + opacity: 0.8; + animation: pw-loader-sweep-a 3s linear infinite; +} + +.pw-loader-arc-b { + 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) + ); + opacity: 0.9; + animation: pw-loader-sweep-b 2.5s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} + +.pw-loader-arc-c { + 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) + ); + opacity: 0.35; + animation: pw-loader-sweep-c 4s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} + +.pw-loader-arc-d { + 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) + ); + opacity: 0.5; + animation: pw-loader-sweep-d 3.5s linear infinite; +} + +.pw-loader-text { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; + text-align: center; + max-inline-size: 22ch; + animation: pw-loader-rise 1s var(--pw-ease) 0.4s backwards; +} + +.pw-loader-label { + font-size: var(--pw-loader-label-size); + line-height: 1.15; + font-weight: 500; + letter-spacing: -0.02em; + text-wrap: balance; + overflow-wrap: anywhere; + color: color-mix(in srgb, currentColor 90%, transparent); + animation: + pw-loader-rise 0.8s var(--pw-ease) 0.6s backwards, + pw-loader-label-pulse 3s cubic-bezier(0.4, 0, 0.6, 1) 1.4s infinite; +} + +.pw-loader[data-size='lg'] .pw-loader-label { + font-weight: 600; +} + +.pw-loader-description { + 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-loader-rise 0.8s var(--pw-ease) 0.8s backwards, + pw-loader-description-pulse 4s cubic-bezier(0.4, 0, 0.6, 1) 1.6s infinite; +} + +@supports not (background: conic-gradient(from var(--pw-loader-angle-a), red, blue)) { + .pw-loader-arc-a { + background: conic-gradient(transparent 0deg, currentColor 90deg, transparent 180deg); + animation: pw-loader-spin 3s linear infinite; + } + .pw-loader-arc-b { + background: conic-gradient(transparent 0deg, currentColor 120deg, transparent 240deg); + animation: pw-loader-spin 2.5s cubic-bezier(0.4, 0, 0.6, 1) infinite; + } + .pw-loader-arc-c { + background: conic-gradient(transparent 0deg, currentColor 45deg, transparent 90deg); + animation: pw-loader-spin-reverse 4s cubic-bezier(0.4, 0, 0.6, 1) infinite; + } + .pw-loader-arc-d { + background: conic-gradient(transparent 0deg, currentColor 20deg, transparent 40deg); + animation: pw-loader-spin 3.5s linear infinite; + } +} + +@media (prefers-reduced-motion: reduce) { + .pw-loader-orb, + .pw-loader-arc, + .pw-loader-text, + .pw-loader-label, + .pw-loader-description { + animation: none; + } + .pw-loader-arc-a, + .pw-loader-arc-c, + .pw-loader-arc-d { + display: none; + } + .pw-loader-arc-b { + opacity: 0.4; + } +} 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..df7e2697a --- /dev/null +++ b/packages/ui-kit-system/src/loader.tsx @@ -0,0 +1,65 @@ +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 = 'pw-loader' +const ORB = 'pw-loader-orb' +const ARC = 'pw-loader-arc' +const TEXT = 'pw-loader-text' +const LABEL = 'pw-loader-label' +const DESCRIPTION = 'pw-loader-description' + +const DEFAULT_TRANSLATIONS = {value: () => 'Loading'} + +function LoaderArcs(): JSX.Element { + return ( + <> + + + + + + ) +} + +function Root(props: ComponentProps & {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}) From 70fc9901d06b0efff3dcf16de54daaa00a145d6c Mon Sep 17 00:00:00 2001 From: Omri Katz <9701896+omridevk@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:47:57 +0300 Subject: [PATCH 2/8] feat(app): route-level pending state renders the Loader (#455) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PendingPane is the single place the app decides what global pending looks like; createRouter wires it as defaultPendingComponent. defaultPendingMs 300 / defaultPendingMinMs 500 govern the loader-driven pending branch (router-core setupPendingTimeout only arms for routes with loader/beforeLoad — here /panel/$sessionId and /quick): nothing shows below 300ms, and once shown the pane stays at least 500ms, which Match.jsx also requires to be > 0 before it renders the fallback at all. Co-Authored-By: Claude Fable 5 --- apps/conciv/src/router.ts | 4 ++++ apps/conciv/src/shell/pending.tsx | 15 +++++++++++++++ apps/conciv/src/styles.css | 3 +++ 3 files changed, 22 insertions(+) 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/src/styles.css b/apps/conciv/src/styles.css index 78b0c0918..d58f1c0d0 100644 --- a/apps/conciv/src/styles.css +++ b/apps/conciv/src/styles.css @@ -9,6 +9,9 @@ copy. The ui-kit packages ship JS only, so the widget generates THEIR utilities too by scanning their source (see uno.config.ts), so there's no per-package CSS to @import here. */ @import '@conciv/ui-kit-system/tokens.css'; +/* Loader orb: registered @property angles, keyframes and feathered gradient masks that no utility class + can express, shipped as a stylesheet by the design system. */ +@import '@conciv/ui-kit-system/loader.css'; /* Neutral chat tokens (--chat-*) + the conciv theme layer that maps them onto the widget's --pw-*. @conciv/ui-kit-chat's styled set references only --chat-*; the shell applies `chat-theme-conciv` on its container so the chat renders in the dark/magenta look. */ From 3d8fba8b0412ac6d4d34a590cc5ef59eab86c583 Mon Sep 17 00:00:00 2001 From: Omri Katz <9701896+omridevk@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:48:15 +0300 Subject: [PATCH 3/8] fix(app): element captures no longer blank the whole pane while loading (#455) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useSessionCaptures built its view map in a body-scope createMemo that read captures.data unguarded. solid-query's data proxy falls through to the resource on the first-load undefined path, so that eager read suspended the route's own Match Suspense and took the entire pane subtree off-document for the duration of the query. Guarding on isPending — the pattern chat-pane already uses for the harness metadata — keeps the read off the resource until there is data. Co-Authored-By: Claude Fable 5 --- apps/conciv/src/pane/session-captures.ts | 1 + 1 file changed, 1 insertion(+) 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) }) From e2fe6b4c1800c8a890542216aa8f12cf3add6f55 Mon Sep 17 00:00:00 2001 From: Omri Katz <9701896+omridevk@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:48:15 +0300 Subject: [PATCH 4/8] test(app): guard the route Suspense boundary against query suspension (#455) Holds /rpc/captures/list and asserts the composer paints anyway with no route pending loader on screen; holds /rpc/sessions/list and asserts the shell keeps its launcher, which is what fab.tsx's local Suspense island already buys. A third case proves the fast path never flashes the loader at all. Co-Authored-By: Claude Fable 5 --- .../test/route-boundary.browser.test.tsx | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 apps/conciv/test/route-boundary.browser.test.tsx 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..aebe4202d --- /dev/null +++ b/apps/conciv/test/route-boundary.browser.test.tsx @@ -0,0 +1,65 @@ +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() +}) From c17bfd0b2a8aed05dab39b5fa9791504c3d90f69 Mon Sep 17 00:00:00 2001 From: Omri Katz <9701896+omridevk@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:51:00 +0300 Subject: [PATCH 5/8] refactor(ui-kit-system): loader styles through the uno preset, drop the shipped stylesheet (#455) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Folds the Loader compound's styling (keyframes, @property angle registrations plus the @supports transform fallback, conic-gradient arc masks, orb breathing, size variants, text rise/pulse) into @conciv/uno-preset as theme keyframes, a loader-size rule and loader-* shortcuts, matching how every other ui-kit-system component is styled. Deletes loader.css and its package export/attw exclusion and the two @import lines that pulled it into apps/conciv and storybook — this repo styles everything through the shared preset. Co-Authored-By: Claude Fable 5 --- .changeset/loader-and-route-pending.md | 2 +- apps/conciv/src/styles.css | 3 - apps/storybook/.storybook/storybook.css | 1 - packages/ui-kit-system/package.json | 8 +- packages/ui-kit-system/src/loader.css | 280 ------------------------ packages/ui-kit-system/src/loader.tsx | 19 +- packages/uno-preset/src/animation.ts | 10 + packages/uno-preset/src/index.ts | 6 +- packages/uno-preset/src/loader.ts | 55 +++++ 9 files changed, 82 insertions(+), 302 deletions(-) delete mode 100644 packages/ui-kit-system/src/loader.css create mode 100644 packages/uno-preset/src/loader.ts diff --git a/.changeset/loader-and-route-pending.md b/.changeset/loader-and-route-pending.md index f6747658a..226475f59 100644 --- a/.changeset/loader-and-route-pending.md +++ b/.changeset/loader-and-route-pending.md @@ -2,4 +2,4 @@ '@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. Ships its stylesheet as `@conciv/ui-kit-system/loader.css`. +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/styles.css b/apps/conciv/src/styles.css index d58f1c0d0..78b0c0918 100644 --- a/apps/conciv/src/styles.css +++ b/apps/conciv/src/styles.css @@ -9,9 +9,6 @@ copy. The ui-kit packages ship JS only, so the widget generates THEIR utilities too by scanning their source (see uno.config.ts), so there's no per-package CSS to @import here. */ @import '@conciv/ui-kit-system/tokens.css'; -/* Loader orb: registered @property angles, keyframes and feathered gradient masks that no utility class - can express, shipped as a stylesheet by the design system. */ -@import '@conciv/ui-kit-system/loader.css'; /* Neutral chat tokens (--chat-*) + the conciv theme layer that maps them onto the widget's --pw-*. @conciv/ui-kit-chat's styled set references only --chat-*; the shell applies `chat-theme-conciv` on its container so the chat renders in the dark/magenta look. */ diff --git a/apps/storybook/.storybook/storybook.css b/apps/storybook/.storybook/storybook.css index 6b5aa3278..1a4431f60 100644 --- a/apps/storybook/.storybook/storybook.css +++ b/apps/storybook/.storybook/storybook.css @@ -1,6 +1,5 @@ @import '@unocss/reset/tailwind.css'; @import '@conciv/ui-kit-system/tokens.css'; -@import '@conciv/ui-kit-system/loader.css'; @import '@conciv/ui-kit-chat/theme/tokens.css'; @import '@conciv/ui-kit-chat/theme/conciv.css'; @import '@conciv/solid-streamdown/styles.css'; diff --git a/packages/ui-kit-system/package.json b/packages/ui-kit-system/package.json index 7623f278f..8bd28f4b3 100644 --- a/packages/ui-kit-system/package.json +++ b/packages/ui-kit-system/package.json @@ -19,8 +19,7 @@ }, "files": [ "dist", - "src/tokens.css", - "src/loader.css" + "src/tokens.css" ], "type": "module", "exports": { @@ -32,8 +31,7 @@ "types": "./dist/tokens.d.ts", "import": "./dist/tokens.js" }, - "./tokens.css": "./src/tokens.css", - "./loader.css": "./src/loader.css" + "./tokens.css": "./src/tokens.css" }, "publishConfig": { "access": "public" @@ -45,7 +43,7 @@ "lint": "oxlint", "test": "vitest run --passWithNoTests", "publint": "publint", - "attw": "attw --pack . --profile esm-only --exclude-entrypoints tokens.css loader.css" + "attw": "attw --pack . --profile esm-only --exclude-entrypoints tokens.css" }, "dependencies": { "@ark-ui/solid": "^5.37.1", diff --git a/packages/ui-kit-system/src/loader.css b/packages/ui-kit-system/src/loader.css deleted file mode 100644 index 0ecd4725c..000000000 --- a/packages/ui-kit-system/src/loader.css +++ /dev/null @@ -1,280 +0,0 @@ -/* Loader (ui-kit-system compound). Lives in a stylesheet rather than UnoCSS utilities because the orb - needs registered @property angles, four @keyframes and feathered radial-gradient masks — none of which - a utility class can express. Consumers @import '@conciv/ui-kit-system/loader.css', the same way they - already @import the tokens sheet. Everything is currentColor + --pw-* tokens, so the orb inherits the - surrounding text color and needs no theme wiring. - - The arcs animate their conic-gradient ANGLE, not `transform: rotate`. Rotating the element rasterizes - the gradient once and spins the texture, which visibly stair-steps on the feathered mask edge; animating - the registered angle re-renders the gradient each frame and stays clean. The @supports fallback below - covers engines without registered custom properties, where a rotated texture is the only option. */ - -@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; -} - -@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-rise { - from { - opacity: 0; - transform: translateY(0.75rem); - } - to { - opacity: 1; - transform: translateY(0); - } -} -@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; - } -} -@keyframes pw-loader-spin { - to { - transform: rotate(360deg); - } -} -@keyframes pw-loader-spin-reverse { - to { - transform: rotate(-360deg); - } -} - -.pw-loader { - --pw-loader-size: 8rem; - --pw-loader-label-size: 1rem; - --pw-loader-description-size: 0.875rem; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 2rem; - padding: 2rem; -} - -.pw-loader[data-size='sm'] { - --pw-loader-size: 5rem; - --pw-loader-label-size: 0.875rem; - --pw-loader-description-size: 0.75rem; -} - -.pw-loader[data-size='lg'] { - --pw-loader-size: 10rem; - --pw-loader-label-size: 1.125rem; - --pw-loader-description-size: 1rem; -} - -.pw-loader-orb { - 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; -} - -.pw-loader-arc { - position: absolute; - inset: 0; - border-radius: var(--pw-r-pill); -} - -.pw-loader-arc-a { - 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) - ); - opacity: 0.8; - animation: pw-loader-sweep-a 3s linear infinite; -} - -.pw-loader-arc-b { - 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) - ); - opacity: 0.9; - animation: pw-loader-sweep-b 2.5s cubic-bezier(0.4, 0, 0.6, 1) infinite; -} - -.pw-loader-arc-c { - 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) - ); - opacity: 0.35; - animation: pw-loader-sweep-c 4s cubic-bezier(0.4, 0, 0.6, 1) infinite; -} - -.pw-loader-arc-d { - 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) - ); - opacity: 0.5; - animation: pw-loader-sweep-d 3.5s linear infinite; -} - -.pw-loader-text { - display: flex; - flex-direction: column; - align-items: center; - gap: 0.75rem; - text-align: center; - max-inline-size: 22ch; - animation: pw-loader-rise 1s var(--pw-ease) 0.4s backwards; -} - -.pw-loader-label { - font-size: var(--pw-loader-label-size); - line-height: 1.15; - font-weight: 500; - letter-spacing: -0.02em; - text-wrap: balance; - overflow-wrap: anywhere; - color: color-mix(in srgb, currentColor 90%, transparent); - animation: - pw-loader-rise 0.8s var(--pw-ease) 0.6s backwards, - pw-loader-label-pulse 3s cubic-bezier(0.4, 0, 0.6, 1) 1.4s infinite; -} - -.pw-loader[data-size='lg'] .pw-loader-label { - font-weight: 600; -} - -.pw-loader-description { - 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-loader-rise 0.8s var(--pw-ease) 0.8s backwards, - pw-loader-description-pulse 4s cubic-bezier(0.4, 0, 0.6, 1) 1.6s infinite; -} - -@supports not (background: conic-gradient(from var(--pw-loader-angle-a), red, blue)) { - .pw-loader-arc-a { - background: conic-gradient(transparent 0deg, currentColor 90deg, transparent 180deg); - animation: pw-loader-spin 3s linear infinite; - } - .pw-loader-arc-b { - background: conic-gradient(transparent 0deg, currentColor 120deg, transparent 240deg); - animation: pw-loader-spin 2.5s cubic-bezier(0.4, 0, 0.6, 1) infinite; - } - .pw-loader-arc-c { - background: conic-gradient(transparent 0deg, currentColor 45deg, transparent 90deg); - animation: pw-loader-spin-reverse 4s cubic-bezier(0.4, 0, 0.6, 1) infinite; - } - .pw-loader-arc-d { - background: conic-gradient(transparent 0deg, currentColor 20deg, transparent 40deg); - animation: pw-loader-spin 3.5s linear infinite; - } -} - -@media (prefers-reduced-motion: reduce) { - .pw-loader-orb, - .pw-loader-arc, - .pw-loader-text, - .pw-loader-label, - .pw-loader-description { - animation: none; - } - .pw-loader-arc-a, - .pw-loader-arc-c, - .pw-loader-arc-d { - display: none; - } - .pw-loader-arc-b { - opacity: 0.4; - } -} diff --git a/packages/ui-kit-system/src/loader.tsx b/packages/ui-kit-system/src/loader.tsx index df7e2697a..15d9e3955 100644 --- a/packages/ui-kit-system/src/loader.tsx +++ b/packages/ui-kit-system/src/loader.tsx @@ -3,22 +3,21 @@ import {Progress as Ark} from '@ark-ui/solid/progress' export type LoaderSize = 'sm' | 'md' | 'lg' -const ROOT = 'pw-loader' -const ORB = 'pw-loader-orb' -const ARC = 'pw-loader-arc' -const TEXT = 'pw-loader-text' -const LABEL = 'pw-loader-label' -const DESCRIPTION = 'pw-loader-description' +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 ( <> - - - - + + + + ) } diff --git a/packages/uno-preset/src/animation.ts b/packages/uno-preset/src/animation.ts index 116c3ee57..4a076e6b8 100644 --- a/packages/uno-preset/src/animation.ts +++ b/packages/uno-preset/src/animation.ts @@ -18,5 +18,15 @@ export const animation = { 'pw-fade-in-up': '{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}', 'pw-zoom-in': '{from{opacity:0;transform:scale(0.9)}to{opacity:1;transform:scale(1)}}', + + 'pw-loader-sweep-a': '{to{--pw-loader-angle-a:360deg}}', + 'pw-loader-sweep-b': '{to{--pw-loader-angle-b:360deg}}', + 'pw-loader-sweep-c': '{to{--pw-loader-angle-c:-180deg}}', + 'pw-loader-sweep-d': '{to{--pw-loader-angle-d:630deg}}', + 'pw-loader-breathe': '{0%,100%{transform:scale(1)}50%{transform:scale(1.02)}}', + 'pw-loader-spin': '{to{transform:rotate(360deg)}}', + 'pw-loader-spin-reverse': '{to{transform:rotate(-360deg)}}', + 'pw-loader-label-pulse': '{0%,100%{opacity:0.9}50%{opacity:0.7}}', + 'pw-loader-description-pulse': '{0%,100%{opacity:0.6}50%{opacity:0.4}}', }, } satisfies Theme['animation'] 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..d4d40d1d9 --- /dev/null +++ b/packages/uno-preset/src/loader.ts @@ -0,0 +1,55 @@ +import {toEscapedSelector, type Preflight, type Rule, type StaticShortcutMap} from 'unocss' + +export const loaderShortcuts = { + 'loader-orb': + '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] motion-reduce:[animation:none]', + + 'loader-arc': 'absolute inset-0 rounded-pw-pill', + + 'loader-arc-a': + 'opacity-80 [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] motion-reduce:hidden', + 'loader-arc-b': + 'opacity-90 [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] motion-reduce:opacity-40 motion-reduce:[animation:none]', + 'loader-arc-c': + 'opacity-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] motion-reduce:hidden', + 'loader-arc-d': + 'opacity-50 [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] motion-reduce:hidden', + + 'loader-text': + 'flex flex-col items-center gap-3 text-center [max-inline-size:22ch] [animation:pw-fade-in-up_1s_var(--pw-ease)_0.4s_backwards] motion-reduce:[animation:none]', + 'loader-label': + 'font-medium leading-[1.15] tracking-[-0.02em] text-balance [overflow-wrap:anywhere] [font-size:var(--pw-loader-label-size)] [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] motion-reduce:[animation:none]', + 'loader-description': + 'leading-[1.45] tracking-[-0.01em] text-pretty [overflow-wrap:anywhere] [font-size:var(--pw-loader-description-size)] [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] motion-reduce:[animation:none]', +} satisfies StaticShortcutMap + +export const loaderRules: Rule[] = [ + [ + /^loader-size$/, + (_match, {rawSelector}) => { + const s = toEscapedSelector(rawSelector) + return ` +${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} +` + }, + ], +] + +export const loaderPreflight: Preflight = { + getCSS: () => ` +@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} + +@supports not (background: conic-gradient(from var(--pw-loader-angle-a), red, blue)) { + .loader-arc-a{background:conic-gradient(transparent 0deg,currentColor 90deg,transparent 180deg);animation:pw-loader-spin 3s linear infinite} + .loader-arc-b{background:conic-gradient(transparent 0deg,currentColor 120deg,transparent 240deg);animation:pw-loader-spin 2.5s cubic-bezier(0.4,0,0.6,1) infinite} + .loader-arc-c{background:conic-gradient(transparent 0deg,currentColor 45deg,transparent 90deg);animation:pw-loader-spin-reverse 4s cubic-bezier(0.4,0,0.6,1) infinite} + .loader-arc-d{background:conic-gradient(transparent 0deg,currentColor 20deg,transparent 40deg);animation:pw-loader-spin 3.5s linear infinite} +} +`, +} From e23f53bf323998bd0dca57468512b7e158a2ace3 Mon Sep 17 00:00:00 2001 From: Omri Katz <9701896+omridevk@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:07:01 +0300 Subject: [PATCH 6/8] refactor(uno-preset): loader visuals as readable preset rules, not bracket-utility strings (#455) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shortcut map encoded every gradient, mask, and animation as underscore-escaped bracket declarations — unreadable CSS smuggled through utility syntax. Each visual class is now a preset Rule emitting a plain, formatted CSS block (blockRule + toEscapedSelector, the json-tree pattern), with its reduced-motion behavior in a nested @media block beside it; the @property preflight is formatted the same way. Only loader-arc remains a shortcut — it is genuinely just utilities. Generated CSS is unchanged: embed bundle grep shows the same selectors, registrations, and keyframes, and the Default story renders pixel-identical. Co-Authored-By: Claude Fable 5 --- packages/uno-preset/src/loader.ts | 286 +++++++++++++++++++++++++----- 1 file changed, 246 insertions(+), 40 deletions(-) diff --git a/packages/uno-preset/src/loader.ts b/packages/uno-preset/src/loader.ts index d4d40d1d9..ae190c0a5 100644 --- a/packages/uno-preset/src/loader.ts +++ b/packages/uno-preset/src/loader.ts @@ -1,55 +1,261 @@ import {toEscapedSelector, type Preflight, type Rule, type StaticShortcutMap} from 'unocss' export const loaderShortcuts = { - 'loader-orb': - '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] motion-reduce:[animation:none]', - 'loader-arc': 'absolute inset-0 rounded-pw-pill', - - 'loader-arc-a': - 'opacity-80 [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] motion-reduce:hidden', - 'loader-arc-b': - 'opacity-90 [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] motion-reduce:opacity-40 motion-reduce:[animation:none]', - 'loader-arc-c': - 'opacity-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] motion-reduce:hidden', - 'loader-arc-d': - 'opacity-50 [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] motion-reduce:hidden', - - 'loader-text': - 'flex flex-col items-center gap-3 text-center [max-inline-size:22ch] [animation:pw-fade-in-up_1s_var(--pw-ease)_0.4s_backwards] motion-reduce:[animation:none]', - 'loader-label': - 'font-medium leading-[1.15] tracking-[-0.02em] text-balance [overflow-wrap:anywhere] [font-size:var(--pw-loader-label-size)] [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] motion-reduce:[animation:none]', - 'loader-description': - 'leading-[1.45] tracking-[-0.01em] text-pretty [overflow-wrap:anywhere] [font-size:var(--pw-loader-description-size)] [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] motion-reduce:[animation:none]', } satisfies StaticShortcutMap +function blockRule(name: string, css: (selector: string) => string): Rule { + return [new RegExp(`^${name}$`), (_match, {rawSelector}) => css(toEscapedSelector(rawSelector))] +} + export const loaderRules: Rule[] = [ - [ - /^loader-size$/, - (_match, {rawSelector}) => { - const s = toEscapedSelector(rawSelector) - return ` -${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-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: () => ` -@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} - +@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; +} @supports not (background: conic-gradient(from var(--pw-loader-angle-a), red, blue)) { - .loader-arc-a{background:conic-gradient(transparent 0deg,currentColor 90deg,transparent 180deg);animation:pw-loader-spin 3s linear infinite} - .loader-arc-b{background:conic-gradient(transparent 0deg,currentColor 120deg,transparent 240deg);animation:pw-loader-spin 2.5s cubic-bezier(0.4,0,0.6,1) infinite} - .loader-arc-c{background:conic-gradient(transparent 0deg,currentColor 45deg,transparent 90deg);animation:pw-loader-spin-reverse 4s cubic-bezier(0.4,0,0.6,1) infinite} - .loader-arc-d{background:conic-gradient(transparent 0deg,currentColor 20deg,transparent 40deg);animation:pw-loader-spin 3.5s linear infinite} + .loader-arc-a { + background: conic-gradient(transparent 0deg, currentColor 90deg, transparent 180deg); + animation: pw-loader-spin 3s linear infinite; + } + .loader-arc-b { + background: conic-gradient(transparent 0deg, currentColor 120deg, transparent 240deg); + animation: pw-loader-spin 2.5s cubic-bezier(0.4, 0, 0.6, 1) infinite; + } + .loader-arc-c { + background: conic-gradient(transparent 0deg, currentColor 45deg, transparent 90deg); + animation: pw-loader-spin-reverse 4s cubic-bezier(0.4, 0, 0.6, 1) infinite; + } + .loader-arc-d { + background: conic-gradient(transparent 0deg, currentColor 20deg, transparent 40deg); + animation: pw-loader-spin 3.5s linear infinite; + } } `, } From 3dc8ab528002f42fbeb779c90a715e911083076f Mon Sep 17 00:00:00 2001 From: Omri Katz <9701896+omridevk@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:17:18 +0300 Subject: [PATCH 7/8] =?UTF-8?q?fix(uno-preset):=20emit=20loader=20keyframe?= =?UTF-8?q?s=20from=20the=20preflight=20=E2=80=94=20theme=20keyframes=20ar?= =?UTF-8?q?e=20shortcut-gated=20(#455)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The preset's own suite caught a real bug the visual checks could not: wind4 only emits a theme keyframe when an animate-* utility references it, and the loader rules reference their keyframes from raw animation declarations — so no @keyframes ever reached the bundle and the orb rendered frozen at its initial angles. The preset test invariant (every animation.ts keyframe reachable from a motion shortcut) exists precisely to prevent this. Loader keyframes now live as raw @keyframes in the loader preflight beside the @property registrations, guaranteed emitted; animation.ts returns to holding only shortcut-reachable keyframes. Verified: @keyframes pw-loader-* present in embed.css, computed animationName on a live arc is pw-loader-sweep-a at 3s, preset suite 180/180. Co-Authored-By: Claude Fable 5 --- packages/uno-preset/src/animation.ts | 10 ----- packages/uno-preset/src/loader.ts | 57 ++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 10 deletions(-) diff --git a/packages/uno-preset/src/animation.ts b/packages/uno-preset/src/animation.ts index 4a076e6b8..116c3ee57 100644 --- a/packages/uno-preset/src/animation.ts +++ b/packages/uno-preset/src/animation.ts @@ -18,15 +18,5 @@ export const animation = { 'pw-fade-in-up': '{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}', 'pw-zoom-in': '{from{opacity:0;transform:scale(0.9)}to{opacity:1;transform:scale(1)}}', - - 'pw-loader-sweep-a': '{to{--pw-loader-angle-a:360deg}}', - 'pw-loader-sweep-b': '{to{--pw-loader-angle-b:360deg}}', - 'pw-loader-sweep-c': '{to{--pw-loader-angle-c:-180deg}}', - 'pw-loader-sweep-d': '{to{--pw-loader-angle-d:630deg}}', - 'pw-loader-breathe': '{0%,100%{transform:scale(1)}50%{transform:scale(1.02)}}', - 'pw-loader-spin': '{to{transform:rotate(360deg)}}', - 'pw-loader-spin-reverse': '{to{transform:rotate(-360deg)}}', - 'pw-loader-label-pulse': '{0%,100%{opacity:0.9}50%{opacity:0.7}}', - 'pw-loader-description-pulse': '{0%,100%{opacity:0.6}50%{opacity:0.4}}', }, } satisfies Theme['animation'] diff --git a/packages/uno-preset/src/loader.ts b/packages/uno-preset/src/loader.ts index ae190c0a5..b13dff731 100644 --- a/packages/uno-preset/src/loader.ts +++ b/packages/uno-preset/src/loader.ts @@ -219,6 +219,63 @@ ${s} { 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-spin { + to { + transform: rotate(360deg); + } +} +@keyframes pw-loader-spin-reverse { + to { + transform: rotate(-360deg); + } +} +@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; From e8d79d1583eea32bcb9a5bd754bb73cf6599969f Mon Sep 17 00:00:00 2001 From: Omri Katz <9701896+omridevk@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:24:43 +0300 Subject: [PATCH 8/8] =?UTF-8?q?fix(ui-kit-system,app):=20review=20follow-u?= =?UTF-8?q?ps=20=E2=80=94=20enforce=20indeterminate=20contract,=20drop=20u?= =?UTF-8?q?ngatable=20fallback,=20prove=20pending=20positively=20(#455)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loader.Root now omits value/defaultValue from its props and pins value={null} after the spread, so a caller cannot flip the indeterminate loader into determinate ARIA state (Copilot). The @supports transform fallback is deleted rather than fixed: the query tested conic-gradient var() syntax, which cannot detect @property support, so the browsers it targeted would animate angles discretely instead of taking the fallback — an ungatable second motion system for a vanishing population (Copilot). The route-pending config gains its missing positive guard: fake-core learns sessions/resolve, and a new test drives a slow beforeLoad alias navigation, asserting the pending loader appears, hands off to the pane, and leaves — revert-checked red without defaultPendingComponent (Copilot + CodeRabbit, sans the suggested sleep, which the test laws ban). Co-Authored-By: Claude Fable 5 --- apps/conciv/test/helpers/fake-core.ts | 1 + .../test/route-boundary.browser.test.tsx | 8 ++++++ packages/ui-kit-system/src/loader.tsx | 14 ++++++---- packages/uno-preset/src/loader.ts | 28 ------------------- 4 files changed, 17 insertions(+), 34 deletions(-) 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 index aebe4202d..c112209e5 100644 --- a/apps/conciv/test/route-boundary.browser.test.tsx +++ b/apps/conciv/test/route-boundary.browser.test.tsx @@ -63,3 +63,11 @@ test('a pane whose queries all answer immediately never shows the route pending 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/loader.tsx b/packages/ui-kit-system/src/loader.tsx index 15d9e3955..f68b18d73 100644 --- a/packages/ui-kit-system/src/loader.tsx +++ b/packages/ui-kit-system/src/loader.tsx @@ -14,21 +14,23 @@ const DEFAULT_TRANSLATIONS = {value: () => 'Loading'} function LoaderArcs(): JSX.Element { return ( <> - - - - + + + + ) } -function Root(props: ComponentProps & {size?: LoaderSize}): JSX.Element { +function Root( + props: Omit, 'value' | 'defaultValue'> & {size?: LoaderSize}, +): JSX.Element { const [local, rest] = splitProps(props, ['class', 'size']) return ( diff --git a/packages/uno-preset/src/loader.ts b/packages/uno-preset/src/loader.ts index b13dff731..8afebb8ff 100644 --- a/packages/uno-preset/src/loader.ts +++ b/packages/uno-preset/src/loader.ts @@ -248,16 +248,6 @@ export const loaderPreflight: Preflight = { transform: scale(1.02); } } -@keyframes pw-loader-spin { - to { - transform: rotate(360deg); - } -} -@keyframes pw-loader-spin-reverse { - to { - transform: rotate(-360deg); - } -} @keyframes pw-loader-label-pulse { 0%, 100% { @@ -296,23 +286,5 @@ export const loaderPreflight: Preflight = { inherits: false; initial-value: 270deg; } -@supports not (background: conic-gradient(from var(--pw-loader-angle-a), red, blue)) { - .loader-arc-a { - background: conic-gradient(transparent 0deg, currentColor 90deg, transparent 180deg); - animation: pw-loader-spin 3s linear infinite; - } - .loader-arc-b { - background: conic-gradient(transparent 0deg, currentColor 120deg, transparent 240deg); - animation: pw-loader-spin 2.5s cubic-bezier(0.4, 0, 0.6, 1) infinite; - } - .loader-arc-c { - background: conic-gradient(transparent 0deg, currentColor 45deg, transparent 90deg); - animation: pw-loader-spin-reverse 4s cubic-bezier(0.4, 0, 0.6, 1) infinite; - } - .loader-arc-d { - background: conic-gradient(transparent 0deg, currentColor 20deg, transparent 40deg); - animation: pw-loader-spin 3.5s linear infinite; - } -} `, }