From 01e20a9e6643bebee24b320bd7b20b19373de342 Mon Sep 17 00:00:00 2001 From: Omri Katz <9701896+omridevk@users.noreply.github.com> Date: Sat, 22 Aug 2026 17:11:42 +0300 Subject: [PATCH 1/6] feat(ui-kit-system): segment group with animated indicator, switch motion Adds a SegmentGroup primitive built on Ark's segment group. Its selected indicator is a real layout animation: zag publishes the selected item's rect as --left/--top/--width/--height and reads --transition-duration and --transition-timing-function back out, so the indicator slides between segments the same way the tabs indicator does. Under prefers-reduced-motion the duration collapses and the indicator snaps. The switch thumb moved with `translate`, which trans-btn never listed among its transitioned properties, so the thumb jumped. It now transitions `translate` and snaps under reduced motion. Refs #572 Co-Authored-By: Claude Fable 5 --- .changeset/wild-pandas-smile.md | 5 + packages/ui-kit-system/src/index.tsx | 1 + .../src/segment-group.stories.tsx | 118 ++++++++++++++++++ packages/ui-kit-system/src/segment-group.tsx | 32 +++++ packages/ui-kit-system/src/switch.tsx | 2 +- .../test/segment-group.browser.test.tsx | 94 ++++++++++++++ 6 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 .changeset/wild-pandas-smile.md create mode 100644 packages/ui-kit-system/src/segment-group.stories.tsx create mode 100644 packages/ui-kit-system/src/segment-group.tsx create mode 100644 packages/ui-kit-system/test/segment-group.browser.test.tsx diff --git a/.changeset/wild-pandas-smile.md b/.changeset/wild-pandas-smile.md new file mode 100644 index 000000000..597496934 --- /dev/null +++ b/.changeset/wild-pandas-smile.md @@ -0,0 +1,5 @@ +--- +'@conciv/ui-kit-system': patch +--- + +Add the SegmentGroup primitive: an Ark segment group whose selected indicator slides between segments through zag's indicator position variables, plus a switch thumb that now actually travels. Both snap instead of sliding under reduced motion. diff --git a/packages/ui-kit-system/src/index.tsx b/packages/ui-kit-system/src/index.tsx index 35bb8f1d5..b44812566 100644 --- a/packages/ui-kit-system/src/index.tsx +++ b/packages/ui-kit-system/src/index.tsx @@ -30,6 +30,7 @@ export {Toast, ToastGroup, createToaster, type ToasterInstance} from './toast.js export {Avatar} from './avatar.js' export {RelativeTime} from './relative-time.js' export {Tabs} from './tabs.js' +export {SegmentGroup} from './segment-group.js' export {Switch} from './switch.js' export {Swap} from './swap.js' export {Presence} from './presence.js' diff --git a/packages/ui-kit-system/src/segment-group.stories.tsx b/packages/ui-kit-system/src/segment-group.stories.tsx new file mode 100644 index 000000000..472d0a677 --- /dev/null +++ b/packages/ui-kit-system/src/segment-group.stories.tsx @@ -0,0 +1,118 @@ +import {createSignal, For} from 'solid-js' +import type {Meta, StoryObj} from 'storybook-solidjs-vite' +import {expect, within, userEvent, waitFor} from 'storybook/test' +import Monitor from 'lucide-solid/icons/monitor' +import Moon from 'lucide-solid/icons/moon' +import Sun from 'lucide-solid/icons/sun' +import {SegmentGroup} from './segment-group.js' + +const meta: Meta = {title: 'ui-kit-system/SegmentGroup'} +export default meta +type Story = StoryObj + +const DENSITIES = ['Comfortable', 'Cozy', 'Compact'] + +export const Default: Story = { + render: () => { + const [density, setDensity] = createSignal('Cozy') + return ( +
+ setDensity(details.value ?? 'Cozy')} + aria-label="Thread density" + > + + + {(option) => ( + + {option} + + + )} + + +
Density: {density()}
+
+ ) + }, + play: async ({canvasElement}) => { + const canvas = within(canvasElement) + await expect(canvas.getByText('Density: Cozy')).toBeVisible() + await userEvent.click(canvas.getByText('Compact')) + await waitFor(() => expect(canvas.getByText('Density: Compact')).toBeVisible()) + }, +} + +const SCHEMES = [ + {value: 'auto', label: 'Auto', icon: Monitor}, + {value: 'light', label: 'Light', icon: Sun}, + {value: 'dark', label: 'Dark', icon: Moon}, +] + +export const Scheme: Story = { + render: () => { + const [scheme, setScheme] = createSignal('auto') + return ( +
+ setScheme(details.value ?? 'auto')} + aria-label="Appearance" + > + + + {(option) => ( + + + + + + )} + + +
Scheme: {scheme()}
+
+ ) + }, + play: async ({canvasElement}) => { + const canvas = within(canvasElement) + await expect(canvas.getByText('Scheme: auto')).toBeVisible() + await userEvent.click(canvas.getByText('Dark')) + await waitFor(() => expect(canvas.getByText('Scheme: dark')).toBeVisible()) + }, +} + +export const Vertical: Story = { + render: () => ( + + + + {(section) => ( + + {section} + + + )} + + + ), +} + +export const Disabled: Story = { + render: () => ( + + + + {(option) => ( + + {option} + + + )} + + + ), +} diff --git a/packages/ui-kit-system/src/segment-group.tsx b/packages/ui-kit-system/src/segment-group.tsx new file mode 100644 index 000000000..1e69ec833 --- /dev/null +++ b/packages/ui-kit-system/src/segment-group.tsx @@ -0,0 +1,32 @@ +import {splitProps, type ComponentProps} from 'solid-js' +import {SegmentGroup as Ark} from '@ark-ui/solid/segment-group' + +const ROOT = + 'relative inline-flex items-center gap-0.5 p-0.5 rounded-chat-surface-sm bg-chat-fill-soft [border:1px_solid_var(--chat-line)] data-[orientation=vertical]:flex-col data-[orientation=vertical]:items-stretch data-[disabled]:opacity-50' +const INDICATOR = + 'rounded-chat-surface-sm bg-chat-fill-strong [border:1px_solid_var(--chat-line-2)] shadow-chat-sm [width:var(--width)] [height:var(--height)] [left:var(--left)] [top:var(--top)] [--transition-duration:200ms] [--transition-timing-function:var(--chat-ease-expo)] motion-reduce:[--transition-duration:0.01ms]' +const ITEM = + 'relative inline-flex items-center justify-center gap-1.5 min-h-8 py-1 px-3 rounded-chat-surface-sm text-[0.8125rem] font-chat text-chat-text-3 whitespace-nowrap cursor-pointer select-none trans-color-bg hover:text-chat-text-2 data-[state=checked]:text-chat-text-hi data-[focus-visible]:[outline:0.125rem_solid_var(--chat-accent)] data-[focus-visible]:[outline-offset:0.125rem] data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed' +const ITEM_TEXT = 'inline-flex items-center gap-1.5' + +function Root(props: ComponentProps) { + const [local, rest] = splitProps(props, ['class', 'orientation']) + return +} + +function Indicator(props: ComponentProps) { + const [local, rest] = splitProps(props, ['class']) + return +} + +function Item(props: ComponentProps) { + const [local, rest] = splitProps(props, ['class']) + return +} + +function ItemText(props: ComponentProps) { + const [local, rest] = splitProps(props, ['class']) + return +} + +export const SegmentGroup = Object.assign({}, Ark, {Root, Indicator, Item, ItemText}) diff --git a/packages/ui-kit-system/src/switch.tsx b/packages/ui-kit-system/src/switch.tsx index 015824b25..d4a444881 100644 --- a/packages/ui-kit-system/src/switch.tsx +++ b/packages/ui-kit-system/src/switch.tsx @@ -6,7 +6,7 @@ const ROOT = const CONTROL = 'relative inline-flex items-center w-8 h-4.5 rounded-chat-pill bg-chat-fill-strong [border:1px_solid_var(--chat-line)] trans-btn data-[state=checked]:bg-chat-accent data-[state=checked]:[border-color:var(--chat-accent)] data-[focus-visible]:[outline:0.125rem_solid_var(--chat-accent)] data-[focus-visible]:[outline-offset:0.125rem]' const THUMB = - 'size-3.5 rounded-chat-pill bg-chat-text translate-x-0.5 trans-btn data-[state=checked]:translate-x-3.5 data-[state=checked]:bg-chat-on-accent' + 'size-3.5 rounded-chat-pill bg-chat-text translate-x-0.5 [transition:translate_140ms_var(--chat-ease-expo),background-color_120ms_var(--chat-ease)] motion-reduce:[transition-property:background-color] data-[state=checked]:translate-x-3.5 data-[state=checked]:bg-chat-on-accent' const LABEL = 'text-[0.8125rem] font-chat text-chat-text-2' function Root(props: ComponentProps) { diff --git a/packages/ui-kit-system/test/segment-group.browser.test.tsx b/packages/ui-kit-system/test/segment-group.browser.test.tsx new file mode 100644 index 000000000..fea5cfbd6 --- /dev/null +++ b/packages/ui-kit-system/test/segment-group.browser.test.tsx @@ -0,0 +1,94 @@ +import 'virtual:uno.css' +import {render} from '@solidjs/testing-library' +import {page, userEvent} from 'vitest/browser' +import {createSignal, For} from 'solid-js' +import {expect, it} from 'vitest' +import {SegmentGroup} from '../src/segment-group.js' + +const SCHEMES = ['Auto', 'Light', 'Dark'] + +function mount(options: {defaultValue?: string; disabled?: string} = {}): () => string | null { + const [value, setValue] = createSignal(options.defaultValue ?? null) + render(() => ( + setValue(details.value)} + aria-label="Colour scheme" + > + + + {(scheme) => ( + + {scheme} + + + )} + + + )) + return value +} + +function segmentOf(name: string) { + return page.getByText(name, {exact: true}) +} + +function indicatorOf(): HTMLElement { + const indicator = document.querySelector('[data-scope="segment-group"][data-part="indicator"]') + if (!(indicator instanceof HTMLElement)) throw new Error('the segment group rendered no indicator') + return indicator +} + +it('exposes the segments as one radio group with an accessible name', async () => { + mount({defaultValue: 'Auto'}) + + await expect.element(page.getByRole('radiogroup', {name: 'Colour scheme'})).toBeVisible() + await expect.element(page.getByRole('radio', {name: 'Auto'})).toBeChecked() + await expect.element(page.getByRole('radio', {name: 'Dark'})).not.toBeChecked() +}) + +it('moves the selection to the segment the reader clicks', async () => { + const value = mount({defaultValue: 'Auto'}) + + await segmentOf('Dark').click() + + await expect.element(page.getByRole('radio', {name: 'Dark'})).toBeChecked() + await expect.element(page.getByRole('radio', {name: 'Auto'})).not.toBeChecked() + expect(value()).toBe('Dark') +}) + +it('walks the selection along the group with the arrow keys', async () => { + const value = mount({defaultValue: 'Auto'}) + + await userEvent.tab() + await expect.element(page.getByRole('radio', {name: 'Auto'})).toHaveFocus() + + await userEvent.keyboard('{ArrowRight}') + + await expect.element(page.getByRole('radio', {name: 'Light'})).toBeChecked() + await expect.element(page.getByRole('radio', {name: 'Light'})).toHaveFocus() + expect(value()).toBe('Light') +}) + +it('refuses a disabled segment', async () => { + const value = mount({defaultValue: 'Auto', disabled: 'Dark'}) + + await expect.element(page.getByRole('radio', {name: 'Dark'})).toBeDisabled() + + await segmentOf('Light').click() + + await expect.element(page.getByRole('radio', {name: 'Light'})).toBeChecked() + expect(value()).toBe('Light') +}) + +it('withholds the indicator until a segment is selected', async () => { + mount() + + await expect.element(page.getByRole('radiogroup', {name: 'Colour scheme'})).toBeVisible() + expect(indicatorOf().hidden).toBe(true) + + await segmentOf('Light').click() + + await expect.element(page.getByRole('radio', {name: 'Light'})).toBeChecked() + expect(indicatorOf().hidden).toBe(false) +}) From f66b0b35dd09b6f83386b4eb9a3a3262aebbbcb0 Mon Sep 17 00:00:00 2001 From: Omri Katz <9701896+omridevk@users.noreply.github.com> Date: Sat, 22 Aug 2026 23:18:27 +0300 Subject: [PATCH 2/6] feat(app): widget settings view - rail, scheme tiles, scope menu (#572 part 3) A nested settings route under the panel: a layout route with a trace-rail section nav plus an outlet, an appearance child, and an index that redirects to it. The session rail menu opens it; the settings header carries the two-line SETTINGS microlabel over the active section title with back and close. The appearance section renders the scheme setting as three SegmentGroup preview tiles that write through settings.set the moment they change, with no Save button. The provenance badge is the scope control: it opens an Ark menu with apply to all projects, use global value (only when a project override shadows a global one), and reset to default, and flashes a saved check as it flips to its new source. The resolved scheme now folds the stored preference over the host detection ladder, so auto keeps following the page live while light and dark pin the widget. A settings-changed notification on the session stream refetches the resolved settings, so a change made in another tab or by the agent repaints an open widget without a reload. The section rail reuses the trace-rail geometry with a complement clip: separate gray layers above and below the live band mean no pixel is ever painted by both colors, arms start at the spine stroke's outer edge, and centerlines snap to half-integers. A container query on the panel flips a custom property that switches the rail between the vertical spine and a horizontal underline, and every property that query re-decides lives in styles.css rather than as a utility class, since the generated utility layer is emitted last. Co-Authored-By: Claude Fable 5 --- .changeset/smart-mangos-shave.md | 8 + apps/conciv/package.json | 1 + apps/conciv/src/app/context.ts | 6 + apps/conciv/src/data/app-data.ts | 2 + apps/conciv/src/data/widget-settings.ts | 46 +++++ apps/conciv/src/routeTree.gen.ts | 71 +++++++ apps/conciv/src/routes/__root.tsx | 9 +- apps/conciv/src/routes/panel.$sessionId.tsx | 14 +- .../src/routes/panel.settings.appearance.tsx | 33 +++ .../src/routes/panel.settings.index.tsx | 8 + apps/conciv/src/routes/panel.settings.tsx | 74 +++++++ apps/conciv/src/settings/scheme-field.tsx | 95 +++++++++ apps/conciv/src/settings/scheme-writes.ts | 87 ++++++++ apps/conciv/src/settings/scope-badge.tsx | 83 ++++++++ apps/conciv/src/settings/settings-rail.tsx | 191 ++++++++++++++++++ apps/conciv/src/settings/settings-sections.ts | 15 ++ apps/conciv/src/styles.css | 187 +++++++++++++++++ apps/conciv/test/helpers/app-context-value.ts | 6 + packages/client/src/chat-connection.ts | 19 +- .../embed/tests/e2e/settings-panel.it.test.ts | 138 +++++++++++++ packages/embed/tests/helpers/boot.ts | 2 + packages/extension-testkit/src/core-kit.ts | 2 + pnpm-lock.yaml | 44 +--- 23 files changed, 1100 insertions(+), 41 deletions(-) create mode 100644 .changeset/smart-mangos-shave.md create mode 100644 apps/conciv/src/data/widget-settings.ts create mode 100644 apps/conciv/src/routes/panel.settings.appearance.tsx create mode 100644 apps/conciv/src/routes/panel.settings.index.tsx create mode 100644 apps/conciv/src/routes/panel.settings.tsx create mode 100644 apps/conciv/src/settings/scheme-field.tsx create mode 100644 apps/conciv/src/settings/scheme-writes.ts create mode 100644 apps/conciv/src/settings/scope-badge.tsx create mode 100644 apps/conciv/src/settings/settings-rail.tsx create mode 100644 apps/conciv/src/settings/settings-sections.ts create mode 100644 packages/embed/tests/e2e/settings-panel.it.test.ts diff --git a/.changeset/smart-mangos-shave.md b/.changeset/smart-mangos-shave.md new file mode 100644 index 000000000..ff6c1c7f1 --- /dev/null +++ b/.changeset/smart-mangos-shave.md @@ -0,0 +1,8 @@ +--- +'@conciv/app': patch +--- + +Widget settings view: a nested settings route under the panel with a trace-rail section nav, an +appearance section whose scheme preview tiles apply instantly, and a provenance badge that doubles as +the scope menu (apply to all projects, use global value, reset to default). Settings changes made +anywhere repaint the open widget through the settings-changed notification on the session stream. diff --git a/apps/conciv/package.json b/apps/conciv/package.json index 4785db9fb..b0f771a1e 100644 --- a/apps/conciv/package.json +++ b/apps/conciv/package.json @@ -38,6 +38,7 @@ "@conciv/ui-kit-tap": "workspace:^", "@solid-primitives/event-listener": "^2.4.6", "@solid-primitives/mutation-observer": "^1.2.4", + "@solid-primitives/resize-observer": "^2.2.0", "@solid-primitives/timer": "^1.4.4", "@tanstack/ai-client": "catalog:", "@tanstack/hotkeys": "^0.8.0", diff --git a/apps/conciv/src/app/context.ts b/apps/conciv/src/app/context.ts index e1c5331e3..d79d91399 100644 --- a/apps/conciv/src/app/context.ts +++ b/apps/conciv/src/app/context.ts @@ -11,6 +11,7 @@ import type {ExtensionInstance} from '../extension/extension-slots.js' import type {LiveSessions} from './live-sessions.js' import type {WarmSession} from './warm-session.js' import type {ColorScheme} from '../lib/color-scheme.js' +import type {WidgetSettings} from '../data/widget-settings.js' export type AppContextValue = { rpc: RpcClient @@ -35,6 +36,7 @@ export type AppContextValue = { apiBase: () => string notifyInteractive: () => void colorScheme: Accessor + widgetSettings: WidgetSettings } export const AppContext = createContext() @@ -128,3 +130,7 @@ export function useNotifyInteractive(): () => void { export function useColorScheme(): Accessor { return useAppScope('useColorScheme', (app) => app.colorScheme) } + +export function useWidgetSettings(): WidgetSettings { + return useAppScope('useWidgetSettings', (app) => app.widgetSettings) +} diff --git a/apps/conciv/src/data/app-data.ts b/apps/conciv/src/data/app-data.ts index 018a45749..4ba793d45 100644 --- a/apps/conciv/src/data/app-data.ts +++ b/apps/conciv/src/data/app-data.ts @@ -5,6 +5,7 @@ import type {RpcClient} from '@conciv/contract' export type AppData = { utils: QueryUtils invalidateSessions: () => void + invalidateSettings: () => void } export function makeAppData(rpc: RpcClient, queryClient: QueryClient): AppData { @@ -12,5 +13,6 @@ export function makeAppData(rpc: RpcClient, queryClient: QueryClient): AppData { return { utils, invalidateSessions: () => void queryClient.invalidateQueries({queryKey: utils.sessions.list.key()}), + invalidateSettings: () => void queryClient.invalidateQueries({queryKey: utils.settings.get.key()}), } } diff --git a/apps/conciv/src/data/widget-settings.ts b/apps/conciv/src/data/widget-settings.ts new file mode 100644 index 000000000..6396113c1 --- /dev/null +++ b/apps/conciv/src/data/widget-settings.ts @@ -0,0 +1,46 @@ +import {createMemo, type Accessor} from 'solid-js' +import {useQuery, type QueryClient} from '@tanstack/solid-query' +import { + SettingsSchemas, + type ResolvedSetting, + type SettingsSource, + type SettingsValueOf, +} from '@conciv/protocol/settings-types' +import type {AppData} from './app-data.js' + +export type SchemeValue = SettingsValueOf<'scheme'> + +export type SchemeSetting = {value: SchemeValue; source: SettingsSource} + +export const SCHEME_VALUES: SchemeValue[] = ['auto', 'light', 'dark'] + +const DEFAULT_SCHEME: SchemeSetting = {value: 'auto', source: 'default'} + +function schemeOf(rows: ResolvedSetting[]): SchemeSetting { + const row = rows.find((entry) => entry.key === 'scheme') + if (!row) return DEFAULT_SCHEME + const parsed = SettingsSchemas.scheme.safeParse(row.value) + if (!parsed.success) return DEFAULT_SCHEME + return {value: parsed.data, source: row.source} +} + +export type WidgetSettings = { + scheme: Accessor + isLoading: Accessor + isError: Accessor + retry: () => void +} + +export function createWidgetSettings(data: AppData, queryClient: QueryClient): WidgetSettings { + const query = useQuery( + () => data.utils.settings.get.queryOptions(), + () => queryClient, + ) + const scheme = createMemo(() => (query.isSuccess ? schemeOf(query.data ?? []) : DEFAULT_SCHEME)) + return { + scheme, + isLoading: () => query.isLoading, + isError: () => query.isError, + retry: () => void query.refetch(), + } +} diff --git a/apps/conciv/src/routeTree.gen.ts b/apps/conciv/src/routeTree.gen.ts index 530a855b4..3fed055df 100644 --- a/apps/conciv/src/routeTree.gen.ts +++ b/apps/conciv/src/routeTree.gen.ts @@ -13,10 +13,13 @@ import { Route as QuickRouteImport } from './routes/quick' import { Route as PanelRouteImport } from './routes/panel' import { Route as IndexRouteImport } from './routes/index' import { Route as PipSessionIdRouteImport } from './routes/pip.$sessionId' +import { Route as PanelSettingsRouteImport } from './routes/panel.settings' import { Route as PanelLatestRouteImport } from './routes/panel.latest' import { Route as PanelConnectRouteImport } from './routes/panel.connect' import { Route as PanelSessionIdRouteImport } from './routes/panel.$sessionId' +import { Route as PanelSettingsIndexRouteImport } from './routes/panel.settings.index' import { Route as PanelSessionIdIndexRouteImport } from './routes/panel.$sessionId.index' +import { Route as PanelSettingsAppearanceRouteImport } from './routes/panel.settings.appearance' import { Route as PanelSessionIdViewRouteImport } from './routes/panel.$sessionId.$view' const QuickRoute = QuickRouteImport.update({ @@ -39,6 +42,11 @@ const PipSessionIdRoute = PipSessionIdRouteImport.update({ path: '/pip/$sessionId', getParentRoute: () => rootRouteImport, } as any) +const PanelSettingsRoute = PanelSettingsRouteImport.update({ + id: '/settings', + path: '/settings', + getParentRoute: () => PanelRoute, +} as any) const PanelLatestRoute = PanelLatestRouteImport.update({ id: '/latest', path: '/latest', @@ -54,11 +62,21 @@ const PanelSessionIdRoute = PanelSessionIdRouteImport.update({ path: '/$sessionId', getParentRoute: () => PanelRoute, } as any) +const PanelSettingsIndexRoute = PanelSettingsIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => PanelSettingsRoute, +} as any) const PanelSessionIdIndexRoute = PanelSessionIdIndexRouteImport.update({ id: '/', path: '/', getParentRoute: () => PanelSessionIdRoute, } as any) +const PanelSettingsAppearanceRoute = PanelSettingsAppearanceRouteImport.update({ + id: '/appearance', + path: '/appearance', + getParentRoute: () => PanelSettingsRoute, +} as any) const PanelSessionIdViewRoute = PanelSessionIdViewRouteImport.update({ id: '/$view', path: '/$view', @@ -72,9 +90,12 @@ export interface FileRoutesByFullPath { '/panel/$sessionId': typeof PanelSessionIdRouteWithChildren '/panel/connect': typeof PanelConnectRoute '/panel/latest': typeof PanelLatestRoute + '/panel/settings': typeof PanelSettingsRouteWithChildren '/pip/$sessionId': typeof PipSessionIdRoute '/panel/$sessionId/$view': typeof PanelSessionIdViewRoute + '/panel/settings/appearance': typeof PanelSettingsAppearanceRoute '/panel/$sessionId/': typeof PanelSessionIdIndexRoute + '/panel/settings/': typeof PanelSettingsIndexRoute } export interface FileRoutesByTo { '/': typeof IndexRoute @@ -84,7 +105,9 @@ export interface FileRoutesByTo { '/panel/latest': typeof PanelLatestRoute '/pip/$sessionId': typeof PipSessionIdRoute '/panel/$sessionId/$view': typeof PanelSessionIdViewRoute + '/panel/settings/appearance': typeof PanelSettingsAppearanceRoute '/panel/$sessionId': typeof PanelSessionIdIndexRoute + '/panel/settings': typeof PanelSettingsIndexRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -94,9 +117,12 @@ export interface FileRoutesById { '/panel/$sessionId': typeof PanelSessionIdRouteWithChildren '/panel/connect': typeof PanelConnectRoute '/panel/latest': typeof PanelLatestRoute + '/panel/settings': typeof PanelSettingsRouteWithChildren '/pip/$sessionId': typeof PipSessionIdRoute '/panel/$sessionId/$view': typeof PanelSessionIdViewRoute + '/panel/settings/appearance': typeof PanelSettingsAppearanceRoute '/panel/$sessionId/': typeof PanelSessionIdIndexRoute + '/panel/settings/': typeof PanelSettingsIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -107,9 +133,12 @@ export interface FileRouteTypes { | '/panel/$sessionId' | '/panel/connect' | '/panel/latest' + | '/panel/settings' | '/pip/$sessionId' | '/panel/$sessionId/$view' + | '/panel/settings/appearance' | '/panel/$sessionId/' + | '/panel/settings/' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -119,7 +148,9 @@ export interface FileRouteTypes { | '/panel/latest' | '/pip/$sessionId' | '/panel/$sessionId/$view' + | '/panel/settings/appearance' | '/panel/$sessionId' + | '/panel/settings' id: | '__root__' | '/' @@ -128,9 +159,12 @@ export interface FileRouteTypes { | '/panel/$sessionId' | '/panel/connect' | '/panel/latest' + | '/panel/settings' | '/pip/$sessionId' | '/panel/$sessionId/$view' + | '/panel/settings/appearance' | '/panel/$sessionId/' + | '/panel/settings/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -170,6 +204,13 @@ declare module '@tanstack/solid-router' { preLoaderRoute: typeof PipSessionIdRouteImport parentRoute: typeof rootRouteImport } + '/panel/settings': { + id: '/panel/settings' + path: '/settings' + fullPath: '/panel/settings' + preLoaderRoute: typeof PanelSettingsRouteImport + parentRoute: typeof PanelRoute + } '/panel/latest': { id: '/panel/latest' path: '/latest' @@ -191,6 +232,13 @@ declare module '@tanstack/solid-router' { preLoaderRoute: typeof PanelSessionIdRouteImport parentRoute: typeof PanelRoute } + '/panel/settings/': { + id: '/panel/settings/' + path: '/' + fullPath: '/panel/settings/' + preLoaderRoute: typeof PanelSettingsIndexRouteImport + parentRoute: typeof PanelSettingsRoute + } '/panel/$sessionId/': { id: '/panel/$sessionId/' path: '/' @@ -198,6 +246,13 @@ declare module '@tanstack/solid-router' { preLoaderRoute: typeof PanelSessionIdIndexRouteImport parentRoute: typeof PanelSessionIdRoute } + '/panel/settings/appearance': { + id: '/panel/settings/appearance' + path: '/appearance' + fullPath: '/panel/settings/appearance' + preLoaderRoute: typeof PanelSettingsAppearanceRouteImport + parentRoute: typeof PanelSettingsRoute + } '/panel/$sessionId/$view': { id: '/panel/$sessionId/$view' path: '/$view' @@ -222,16 +277,32 @@ const PanelSessionIdRouteWithChildren = PanelSessionIdRoute._addFileChildren( PanelSessionIdRouteChildren, ) +interface PanelSettingsRouteChildren { + PanelSettingsAppearanceRoute: typeof PanelSettingsAppearanceRoute + PanelSettingsIndexRoute: typeof PanelSettingsIndexRoute +} + +const PanelSettingsRouteChildren: PanelSettingsRouteChildren = { + PanelSettingsAppearanceRoute: PanelSettingsAppearanceRoute, + PanelSettingsIndexRoute: PanelSettingsIndexRoute, +} + +const PanelSettingsRouteWithChildren = PanelSettingsRoute._addFileChildren( + PanelSettingsRouteChildren, +) + interface PanelRouteChildren { PanelSessionIdRoute: typeof PanelSessionIdRouteWithChildren PanelConnectRoute: typeof PanelConnectRoute PanelLatestRoute: typeof PanelLatestRoute + PanelSettingsRoute: typeof PanelSettingsRouteWithChildren } const PanelRouteChildren: PanelRouteChildren = { PanelSessionIdRoute: PanelSessionIdRouteWithChildren, PanelConnectRoute: PanelConnectRoute, PanelLatestRoute: PanelLatestRoute, + PanelSettingsRoute: PanelSettingsRouteWithChildren, } const PanelRouteWithChildren = PanelRoute._addFileChildren(PanelRouteChildren) diff --git a/apps/conciv/src/routes/__root.tsx b/apps/conciv/src/routes/__root.tsx index 5d2bf2ac9..e0b16f94c 100644 --- a/apps/conciv/src/routes/__root.tsx +++ b/apps/conciv/src/routes/__root.tsx @@ -52,6 +52,7 @@ import {setShutter} from '../lib/shutter.js' import {PanelChromeContext} from '../app/panel-chrome.js' import {createMediaQuery, PHONE_MEDIA_QUERY} from '../lib/media-query.js' import {applySchemeClass, createHostColorScheme} from '../lib/color-scheme.js' +import {createWidgetSettings} from '../data/widget-settings.js' import '../styles.css' const OPEN_DISMISSABLE_LAYER_SELECTOR = '[data-scope][data-part="content"][data-state="open"]' @@ -112,7 +113,12 @@ function RootComponent() { }) const liveSessions = makeLiveSessions() - const colorScheme = createHostColorScheme() + const hostScheme = createHostColorScheme() + const widgetSettings = createWidgetSettings(app.data, app.queryClient) + const colorScheme = createMemo(() => { + const preference = widgetSettings.scheme().value + return preference === 'auto' ? hostScheme() : preference + }) const value: AppContextValue = { rpc: app.rpc, @@ -137,6 +143,7 @@ function RootComponent() { apiBase: app.apiBase, notifyInteractive: app.notifyInteractive, colorScheme, + widgetSettings, } createEffect(() => { diff --git a/apps/conciv/src/routes/panel.$sessionId.tsx b/apps/conciv/src/routes/panel.$sessionId.tsx index 8b2c7c27c..ac46d783a 100644 --- a/apps/conciv/src/routes/panel.$sessionId.tsx +++ b/apps/conciv/src/routes/panel.$sessionId.tsx @@ -17,8 +17,10 @@ import Ellipsis from 'lucide-solid/icons/ellipsis' import PictureInPicture2 from 'lucide-solid/icons/picture-in-picture-2' import RefreshCw from 'lucide-solid/icons/refresh-cw' import Unplug from 'lucide-solid/icons/unplug' +import SlidersHorizontal from 'lucide-solid/icons/sliders-horizontal' import {Show, Suspense, createMemo, createSignal, type JSX} from 'solid-js' import {isSessionId} from '@conciv/protocol/chat-types' +import {SETTINGS_CHANGED_EVENT} from '@conciv/protocol/settings-types' import {useChatSession} from '@conciv/client' import { useAnnounce, @@ -137,7 +139,13 @@ function PanelSession(): JSX.Element { }) const chatKey = createMemo(() => ({sessionId: params().sessionId, generation: generation()})) - const chat = createMemo(() => useChatSession({rpc, sessionId: chatKey().sessionId})) + const chat = createMemo(() => + useChatSession({ + rpc, + sessionId: chatKey().sessionId, + connection: {onCustom: (name) => (name === SETTINGS_CHANGED_EVENT ? appData.invalidateSettings() : undefined)}, + }), + ) const turns = createMemo(() => coalesceTurns(chat().messages())) const latestRollup = createMemo(() => { @@ -242,6 +250,10 @@ function PanelSession(): JSX.Element {