Skip to content

Commit 513c0c8

Browse files
committed
refactor(design): unify the full-panel connection state
The full-screen "not connected" state was reimplemented with a near-identical copy map in four surfaces (git, inspect, messages, terminals). Move the copy (icon/title/body/reloadable/spin per status) and the layout class builders (connectionPanel/Glyph/Title/Body/Detail) into design/design.ts, and have every surface render from them, so the disconnected/error/unauthorized/connecting panels look identical across React, Vue and Svelte.
1 parent 0a2d7f5 commit 513c0c8

6 files changed

Lines changed: 163 additions & 115 deletions

File tree

design/design.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,82 @@ export function connectionIndicator(status: ConnectionStatus, extra?: string): C
123123
}
124124
}
125125

126+
export interface ConnectionStateCopy {
127+
/** Phosphor icon for the state glyph. */
128+
icon: string
129+
/** Short heading, e.g. `Disconnected`. */
130+
title: string
131+
/** One-line explanation of the state and how to recover. */
132+
body: string
133+
/** Whether to offer the reload recovery button (every state but `connecting`). */
134+
reloadable: boolean
135+
/** Whether the glyph should animate while the handshake is in flight. */
136+
spin: boolean
137+
}
138+
139+
const CONNECTION_STATE: Record<Exclude<ConnectionStatus, 'connected'>, ConnectionStateCopy> = {
140+
connecting: {
141+
icon: 'i-ph-plugs-connected-duotone',
142+
title: 'Connecting…',
143+
body: 'Establishing a connection to the devframe server.',
144+
reloadable: false,
145+
spin: true,
146+
},
147+
disconnected: {
148+
icon: 'i-ph-plugs-duotone',
149+
title: 'Disconnected',
150+
body: 'Lost the connection to the devframe server. Reload once it is back up.',
151+
reloadable: true,
152+
spin: false,
153+
},
154+
unauthorized: {
155+
icon: 'i-ph-lock-key-duotone',
156+
title: 'Not authorized',
157+
body: 'This client isn’t authorized. Reopen the link printed by your dev server, then reload.',
158+
reloadable: true,
159+
spin: false,
160+
},
161+
error: {
162+
icon: 'i-ph-warning-octagon-duotone',
163+
title: 'Connection failed',
164+
body: 'Could not reach the devframe server.',
165+
reloadable: true,
166+
spin: false,
167+
},
168+
}
169+
170+
// The shared full-panel connection state copy: shown whenever the client isn't
171+
// `connected`, so a surface never sits on an infinite spinner without saying
172+
// why. Returns `null` when connected. Pair with the `connection*` class builders
173+
// below so every surface renders the identical centered glyph + title + body.
174+
export function connectionState(status: ConnectionStatus): ConnectionStateCopy | null {
175+
if (status === 'connected')
176+
return null
177+
return CONNECTION_STATE[status]
178+
}
179+
180+
// Centered fill for the full-panel state; each surface adds its own fill
181+
// strategy (`h-full`, `h-svh w-full`, `absolute inset-0`, …) via `extra`.
182+
export function connectionPanel(extra?: string): string {
183+
return cx('flex flex-col items-center justify-center gap-4 bg-base p-8 text-center', extra)
184+
}
185+
186+
export function connectionGlyph(spin = false, extra?: string): string {
187+
return cx('text-4xl color-active', spin && 'animate-pulse', extra)
188+
}
189+
190+
export function connectionTitle(extra?: string): string {
191+
return cx('text-lg font-medium color-base', extra)
192+
}
193+
194+
export function connectionBody(extra?: string): string {
195+
return cx('max-w-sm text-sm color-muted', extra)
196+
}
197+
198+
export function connectionDetail(extra?: string): string {
199+
return cx('mt-1 max-w-sm break-words font-mono text-xs color-faint', extra)
200+
}
201+
126202
export function toolbar(extra?: string): string {
127203
return cx('flex items-center gap-2 shrink-0 h-8 px-2.5 border-b border-base bg-secondary text-sm', extra)
128204
}

plugins/git/src/client/components/connection-state.tsx

Lines changed: 13 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,30 @@
11
'use client'
22

33
import type { DevframeConnectionStatus } from 'devframe/client'
4-
import { button } from '../lib/design'
4+
import { button, connectionBody, connectionDetail, connectionGlyph, connectionPanel, connectionState, connectionTitle } from '../lib/design'
55
import { Icon } from './ui/icon'
66

7-
interface StateCopy {
8-
icon: string
9-
title: string
10-
body: string
11-
spin?: boolean
12-
}
13-
14-
const COPY: Record<Exclude<DevframeConnectionStatus, 'connected'>, StateCopy> = {
15-
connecting: {
16-
icon: 'i-ph-plugs-connected-duotone',
17-
title: 'Connecting…',
18-
body: 'Establishing a connection to the devframe server.',
19-
spin: true,
20-
},
21-
disconnected: {
22-
icon: 'i-ph-plugs-duotone',
23-
title: 'Disconnected',
24-
body: 'Lost the connection to the devframe server. Reload once it is back up.',
25-
},
26-
unauthorized: {
27-
icon: 'i-ph-lock-key-duotone',
28-
title: 'Not authorized',
29-
body: 'This client isn’t authorized. Reopen the link printed by your dev server, then reload.',
30-
},
31-
error: {
32-
icon: 'i-ph-warning-octagon-duotone',
33-
title: 'Connection failed',
34-
body: 'Could not reach the devframe server.',
35-
},
36-
}
37-
387
/**
39-
* Full-panel connection state — shown whenever the client isn't `connected`,
40-
* so the UI never sits on an infinite spinner without saying why. Reload is the
41-
* recovery path (the client doesn't auto-reconnect).
8+
* Full-panel connection state — shown whenever the client isn't `connected`, so
9+
* the UI never sits on an infinite spinner without saying why. Copy and layout
10+
* come from the shared `design/design.ts` so every surface looks identical;
11+
* reload is the recovery path (the client doesn't auto-reconnect).
4212
*/
4313
export function ConnectionState({ status, error }: { status: DevframeConnectionStatus, error?: string | null }) {
44-
if (status === 'connected')
14+
const copy = connectionState(status)
15+
if (!copy)
4516
return null
46-
const copy = COPY[status]
4717
return (
48-
<div className="bg-base flex h-svh w-full flex-col items-center justify-center gap-4 p-8 text-center">
49-
<Icon
50-
name={copy.icon}
51-
className={`color-active size-10 ${copy.spin ? 'animate-pulse' : ''}`}
52-
/>
18+
<div className={connectionPanel('h-svh w-full')}>
19+
<Icon name={copy.icon} className={connectionGlyph(copy.spin)} />
5320
<div className="flex flex-col gap-1">
54-
<p className="color-base text-lg font-medium">{copy.title}</p>
55-
<p className="color-muted max-w-sm text-sm">{copy.body}</p>
21+
<p className={connectionTitle()}>{copy.title}</p>
22+
<p className={connectionBody()}>{copy.body}</p>
5623
{error && status === 'error' && (
57-
<p className="color-faint mt-1 max-w-sm break-words font-mono text-xs">{error}</p>
24+
<p className={connectionDetail()}>{error}</p>
5825
)}
5926
</div>
60-
{status !== 'connecting' && (
27+
{copy.reloadable && (
6128
<button type="button" className={button({ variant: 'primary', size: 'sm' })} onClick={() => location.reload()}>
6229
<Icon name="i-ph-arrow-clockwise" className="size-4" />
6330
Reload

plugins/inspect/src/spa/App.vue

Lines changed: 29 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,16 @@ import ActionIconButton from '@antfu/design/components/Action/ActionIconButton.v
33
import LayoutTabs from '@antfu/design/components/Layout/LayoutTabs.vue'
44
import LayoutToolbar from '@antfu/design/components/Layout/LayoutToolbar.vue'
55
import { computed, onMounted, ref } from 'vue'
6-
import { connectionIndicator } from '../../../../design/design'
6+
import {
7+
button,
8+
connectionBody,
9+
connectionDetail,
10+
connectionGlyph,
11+
connectionIndicator,
12+
connectionPanel,
13+
connectionState,
14+
connectionTitle,
15+
} from '../../../../design/design'
716
import AgentSmart from './components/AgentSmart.vue'
817
import FunctionsSmart from './components/FunctionsSmart.vue'
918
import HistorySmart from './components/HistorySmart.vue'
@@ -20,6 +29,10 @@ const { refresh, loading } = useRefresh()
2029
// connection is not live; when connected it renders nothing.
2130
const conn = computed(() => connectionIndicator(connection.status))
2231
32+
// The shared full-panel connection state takes over the body until the client
33+
// is connected.
34+
const connState = computed(() => connectionState(connection.status))
35+
2336
const tabs: { value: Tab, label: string, icon: string }[] = [
2437
{ value: 'functions', label: 'Functions', icon: 'i-ph-function-duotone' },
2538
{ value: 'state', label: 'State', icon: 'i-ph-database-duotone' },
@@ -67,32 +80,24 @@ function reload(): void {
6780
</LayoutToolbar>
6881

6982
<main class="app-body">
70-
<div v-if="connection.status === 'error'" class="center error">
71-
<span class="i-ph-warning-octagon-duotone state-glyph" />
72-
<div>Failed to connect to the devframe backend.</div>
73-
<code v-if="connection.error">{{ connection.error }}</code>
74-
<button type="button" class="btn-action" @click="reload">
75-
Reload
76-
</button>
77-
</div>
78-
<div v-else-if="connection.status === 'disconnected'" class="center error">
79-
<span class="i-ph-plugs-duotone state-glyph" />
80-
<div>Disconnected from the devframe backend.</div>
81-
<button type="button" class="btn-action" @click="reload">
83+
<div v-if="connState" :class="connectionPanel('h-full')">
84+
<span :class="[connState.icon, connectionGlyph(connState.spin)]" />
85+
<div class="flex flex-col gap-1">
86+
<p :class="connectionTitle()">
87+
{{ connState.title }}
88+
</p>
89+
<p :class="connectionBody()">
90+
{{ connState.body }}
91+
</p>
92+
<p v-if="connection.error && connection.status === 'error'" :class="connectionDetail()">
93+
{{ connection.error }}
94+
</p>
95+
</div>
96+
<button v-if="connState.reloadable" type="button" :class="button({ variant: 'primary', size: 'sm' })" @click="reload">
97+
<span class="i-ph-arrow-clockwise" />
8298
Reload
8399
</button>
84100
</div>
85-
<div v-else-if="connection.status === 'unauthorized'" class="center error">
86-
<span class="i-ph-lock-key-duotone state-glyph" />
87-
<div>Not authorized. Reopen the link printed by your dev server, then reload.</div>
88-
<button type="button" class="btn-action" @click="reload">
89-
Reload
90-
</button>
91-
</div>
92-
<div v-else-if="!connection.connected" class="center">
93-
<span class="i-ph-plugs-connected-duotone state-glyph" />
94-
Connecting to devframe…
95-
</div>
96101
<template v-else>
97102
<FunctionsSmart v-if="tab === 'functions'" />
98103
<StateSmart v-else-if="tab === 'state'" />

plugins/inspect/src/spa/style.css

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -118,15 +118,6 @@ textarea {
118118
text-align: center;
119119
}
120120

121-
.center.error {
122-
color: var(--df-danger);
123-
}
124-
125-
.center .state-glyph {
126-
font-size: 32px;
127-
opacity: 0.85;
128-
}
129-
130121
.pane {
131122
height: 100%;
132123
overflow: auto;

plugins/messages/src/client/App.vue

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,15 @@ import type { DevframeConnectionStatus, DevframeRpcClient } from 'devframe/clien
33
import type { DevframeMessageEntry } from '../types'
44
import LayoutToolbar from '@antfu/design/components/Layout/LayoutToolbar.vue'
55
import { computed, onBeforeUnmount, ref } from 'vue'
6-
import { connectionIndicator } from '../../../../design/design'
6+
import {
7+
button,
8+
connectionBody,
9+
connectionGlyph,
10+
connectionIndicator,
11+
connectionPanel,
12+
connectionState,
13+
connectionTitle,
14+
} from '../../../../design/design'
715
import MessagesView from './components/MessagesView.vue'
816
import { useMessages } from './state/messages'
917
@@ -22,18 +30,13 @@ const offStatus = props.rpc.events.on('connection:status', (next) => {
2230
})
2331
onBeforeUnmount(offStatus)
2432
25-
const CONNECTION_COPY: Record<Exclude<DevframeConnectionStatus, 'connected'>, { icon: string, title: string, body: string }> = {
26-
connecting: { icon: 'i-ph-plugs-connected-duotone', title: 'Connecting…', body: 'Establishing a connection to the devframe server.' },
27-
disconnected: { icon: 'i-ph-plugs-duotone', title: 'Disconnected', body: 'Lost the connection to the devframe server. Reload once it is back up.' },
28-
unauthorized: { icon: 'i-ph-lock-key-duotone', title: 'Not authorized', body: 'Reopen the link printed by your dev server, then reload.' },
29-
error: { icon: 'i-ph-warning-octagon-duotone', title: 'Connection failed', body: 'Could not reach the devframe server.' },
30-
}
31-
const connectionCopy = computed(() => status.value === 'connected' ? null : CONNECTION_COPY[status.value])
32-
3333
// The shared top-nav connection indicator (dot + label), shown only while the
3434
// connection is not live.
3535
const conn = computed(() => connectionIndicator(status.value))
3636
37+
// The shared full-panel connection state takes over the body until connected.
38+
const connState = computed(() => connectionState(status.value))
39+
3740
function reload(): void {
3841
location.reload()
3942
}
@@ -97,23 +100,20 @@ async function onOpenFile(entry: DevframeMessageEntry): Promise<void> {
97100
</LayoutToolbar>
98101

99102
<div class="min-h-0">
100-
<div
101-
v-if="connectionCopy"
102-
class="h-full flex flex-col items-center justify-center gap-4 p-8 text-center"
103-
>
104-
<span :class="connectionCopy.icon" class="text-4xl color-active" />
103+
<div v-if="connState" :class="connectionPanel('h-full')">
104+
<span :class="[connState.icon, connectionGlyph(connState.spin)]" />
105105
<div class="flex flex-col gap-1">
106-
<p class="text-lg font-medium">
107-
{{ connectionCopy.title }}
106+
<p :class="connectionTitle()">
107+
{{ connState.title }}
108108
</p>
109-
<p class="text-sm color-muted max-w-sm">
110-
{{ connectionCopy.body }}
109+
<p :class="connectionBody()">
110+
{{ connState.body }}
111111
</p>
112112
</div>
113113
<button
114-
v-if="status !== 'connecting'"
114+
v-if="connState.reloadable"
115115
type="button"
116-
class="btn-primary text-sm px-2.5! py-1!"
116+
:class="button({ variant: 'primary', size: 'sm' })"
117117
@click="reload"
118118
>
119119
<span class="i-ph-arrow-clockwise" />

plugins/terminals/src/client/App.svelte

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,21 @@
22
import type { DevframeConnectionStatus, DevframeRpcClient } from 'devframe/client'
33
import type { TerminalPreset, TerminalSessionInfo } from '../types'
44
import type { DotState } from './design'
5-
import { button, dot, iconButton, nav, navBrand, navTab, tag, toolbar } from './design'
5+
import {
6+
button,
7+
connectionBody,
8+
connectionGlyph,
9+
connectionPanel,
10+
connectionState,
11+
connectionTitle,
12+
dot,
13+
iconButton,
14+
nav,
15+
navBrand,
16+
navTab,
17+
tag,
18+
toolbar,
19+
} from './design'
620
import { onMount } from 'svelte'
721
import { DOCKS_ACTIVE_STATE_KEY, PLUGIN_ID, PRESETS_STATE_KEY, SESSIONS_STATE_KEY } from '../constants'
822
import TerminalView from './TerminalView.svelte'
@@ -15,14 +29,10 @@
1529
let connectionStatus = $state<DevframeConnectionStatus>(rpc.status)
1630
1731
// Terminals ride live PTY streams, so a dropped socket or refused auth makes
18-
// the whole surface useless — swap it for a clear state instead of a frozen
19-
// terminal. The client doesn't auto-reconnect; a reload re-runs the handshake.
20-
const CONNECTION_COPY: Record<Exclude<DevframeConnectionStatus, 'connected'>, { icon: string, title: string, body: string }> = {
21-
connecting: { icon: 'i-ph-plugs-connected-duotone', title: 'Connecting…', body: 'Establishing a connection to the devframe server.' },
22-
disconnected: { icon: 'i-ph-plugs-duotone', title: 'Disconnected', body: 'Lost the connection to the devframe server. Reload once it is back up.' },
23-
unauthorized: { icon: 'i-ph-lock-key-duotone', title: 'Not authorized', body: 'Reopen the link printed by your dev server, then reload.' },
24-
error: { icon: 'i-ph-warning-octagon-duotone', title: 'Connection failed', body: 'Could not reach the devframe server.' },
25-
}
32+
// the whole surface useless — swap it for the shared connection state instead
33+
// of a frozen terminal. The client doesn't auto-reconnect; a reload re-runs
34+
// the handshake.
35+
const connCopy = $derived(connectionState(connectionStatus))
2636
2737
let isDark = $state(true)
2838
let sessions = $state<TerminalSessionInfo[]>([])
@@ -229,15 +239,14 @@
229239
}
230240
</script>
231241

232-
{#if connectionStatus !== 'connected'}
233-
{@const copy = CONNECTION_COPY[connectionStatus]}
234-
<div class="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-base color-base font-sans p-8 text-center">
235-
<div class="{copy.icon} text-4xl color-active"></div>
242+
{#if connCopy}
243+
<div class={connectionPanel('absolute inset-0 color-base font-sans')}>
244+
<div class="{connCopy.icon} {connectionGlyph(connCopy.spin)}"></div>
236245
<div class="flex flex-col gap-1">
237-
<p class="text-lg font-medium">{copy.title}</p>
238-
<p class="text-sm op-mute max-w-sm">{copy.body}</p>
246+
<p class={connectionTitle()}>{connCopy.title}</p>
247+
<p class={connectionBody()}>{connCopy.body}</p>
239248
</div>
240-
{#if connectionStatus !== 'connecting'}
249+
{#if connCopy.reloadable}
241250
<button type="button" class={button({ variant: 'primary', size: 'sm' })} onclick={() => location.reload()}>
242251
<div class="i-ph-arrow-clockwise"></div>
243252
Reload

0 commit comments

Comments
 (0)