@mebular/dsh-plugin 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.js ADDED
@@ -0,0 +1,521 @@
1
+ /* eslint-env browser */
2
+ // @mebular/dsh-plugin browser half.
3
+ //
4
+ // This is a hand-written DSH client bundle: no JSX, no build step, and exactly
5
+ // one module request (`react`). It contributes a sidebar icon and a root-scoped
6
+ // `main` panel. The panel does not reimplement Console; it embeds the shared
7
+ // Mebular Console served by the daemon at /console/?embed=dsh.
8
+ //
9
+ // Nothing here imports @mebular/*. State comes only from the host plugin's
10
+ // loopback-only GET /mebular/embed-info route.
11
+
12
+ window.__ModuleLoader__.load({
13
+ id: '@mebular/dsh-plugin',
14
+ factory: (require) => {
15
+ const React = require('react');
16
+ const h = React.createElement;
17
+
18
+ const EMBED_INFO_PATH = '/mebular/embed-info';
19
+ const DEFAULT_CONSOLE_URL = 'http://127.0.0.1:7331/console';
20
+ const POLL_MS = 2500;
21
+ const FETCH_TIMEOUT_MS = 4000;
22
+ const FRAME_TIMEOUT_MS = 12000;
23
+
24
+ const TEXT = {
25
+ en: {
26
+ title: 'Mebular',
27
+ connected: 'Connected',
28
+ checking: 'Connecting',
29
+ starting: 'Starting',
30
+ offline: 'Offline',
31
+ unavailable: 'Unavailable',
32
+ connectingTitle: 'Connecting to Mebular',
33
+ connectingBody: 'Waiting for the local Mebular daemon to answer.',
34
+ startingTitle: 'Starting Mebular',
35
+ startingBody: 'The DSH plugin is starting the local Mebular daemon. This usually takes a few seconds.',
36
+ offlineTitle: 'Mebular is not running',
37
+ cliMissingTitle: 'Mebular CLI not found',
38
+ incompatibleTitle: 'Mebular Console cannot be embedded',
39
+ frameFailedTitle: 'Mebular Console did not load',
40
+ openConsole: 'Open Console',
41
+ openInBrowser: 'Open Console in browser',
42
+ refresh: 'Refresh',
43
+ hostUnavailable: 'Panel route unavailable',
44
+ loadingFrame: 'Loading Console…',
45
+ error: 'The DSH host could not read Mebular status: {error}',
46
+ reasons: {
47
+ daemon_unreachable: 'No Mebular daemon answered on the configured loopback address. Start Mebular, then refresh this panel.',
48
+ daemon_unhealthy: 'Mebular answered, but its health check did not report ready. Check the Mebular logs, then refresh.',
49
+ embed_info_unavailable: 'Mebular is running but does not expose /app/embed/info. Update Mebular to a version that supports DSH embedding.',
50
+ embed_not_ok: 'Mebular refused the embed discovery request. Check the Mebular version and status.',
51
+ embed_protocol_unsupported: 'This Mebular Console uses an embed protocol this plugin does not support. Update the plugin or Mebular.',
52
+ embed_unsupported: 'This Mebular daemon does not offer Console embedding (loopback only).',
53
+ not_loopback: 'Embedding is disabled because Mebular or DSH is not on a loopback address. Open Console in a browser instead.',
54
+ embed_url_invalid: 'Mebular returned an embed URL that is not a loopback HTTP(S) URL. The panel will not load it.',
55
+ managed_cli_missing: 'Managed mode could not find the Mebular CLI. Install Mebular, or set MEBULAR_CLI or the plugin cliPath, then restart DSH.',
56
+ managed_start_failed: 'The plugin started Mebular but it did not become healthy. Check the Mebular logs, start Mebular manually, then refresh.',
57
+ frame_blocked: 'The Console iframe did not load, possibly blocked by CSP or cross-origin policy. Open it in a browser instead.',
58
+ unknown: 'The Mebular Console is unavailable. Open it in a browser to see the daemon status.',
59
+ },
60
+ },
61
+ zh: {
62
+ title: 'Mebular',
63
+ connected: '已连接',
64
+ checking: '连接中',
65
+ starting: '启动中',
66
+ offline: '未运行',
67
+ unavailable: '不可嵌入',
68
+ connectingTitle: '正在连接 Mebular',
69
+ connectingBody: '等待本地 Mebular daemon 响应。',
70
+ startingTitle: '正在启动 Mebular',
71
+ startingBody: 'DSH 插件正在启动本地 Mebular daemon,通常需要几秒钟。',
72
+ offlineTitle: 'Mebular 未运行',
73
+ cliMissingTitle: '未找到 Mebular CLI',
74
+ incompatibleTitle: 'Mebular Console 无法嵌入',
75
+ frameFailedTitle: 'Mebular Console 加载失败',
76
+ openConsole: '打开 Console',
77
+ openInBrowser: '在浏览器打开 Console',
78
+ refresh: '刷新',
79
+ hostUnavailable: '面板接口不可用',
80
+ loadingFrame: '正在加载 Console…',
81
+ error: 'DSH 主机无法读取 Mebular 状态:{error}',
82
+ reasons: {
83
+ daemon_unreachable: '配置的 loopback 地址上没有 Mebular daemon。请先启动 Mebular,再刷新此面板。',
84
+ daemon_unhealthy: 'Mebular 已响应,但健康检查未就绪。请检查 Mebular 日志后刷新。',
85
+ embed_info_unavailable: 'Mebular 正在运行,但没有提供 /app/embed/info。请升级到支持 DSH 嵌入的版本。',
86
+ embed_not_ok: 'Mebular 拒绝了嵌入发现请求。请检查 Mebular 版本与状态。',
87
+ embed_protocol_unsupported: '此 Mebular Console 的嵌入协议版本不受插件支持。请升级插件或 Mebular。',
88
+ embed_unsupported: '此 Mebular daemon 不允许嵌入 Console(仅限 loopback)。',
89
+ not_loopback: 'Mebular 或 DSH 不在 loopback 地址上,因此禁用嵌入。请在浏览器中打开 Console。',
90
+ embed_url_invalid: 'Mebular 返回了非 loopback HTTP(S) 的嵌入 URL,面板不会加载它。',
91
+ managed_cli_missing: 'managed 模式没有找到 Mebular CLI。请安装 Mebular,或设置 MEBULAR_CLI / 插件 cliPath,然后重启 DSH。',
92
+ managed_start_failed: '插件已启动 Mebular,但健康检查未通过。请检查 Mebular 日志,或手动启动 Mebular 后刷新。',
93
+ frame_blocked: 'Console iframe 没有加载成功,可能被 CSP 或跨源策略阻止。请在浏览器中打开 Console。',
94
+ unknown: 'Mebular Console 当前不可用。请在浏览器中打开以查看 daemon 状态。',
95
+ },
96
+ },
97
+ };
98
+
99
+ const styles = {
100
+ root: {
101
+ display: 'flex',
102
+ flexDirection: 'column',
103
+ minHeight: 0,
104
+ height: '100%',
105
+ background: 'var(--dsw-alias-bg-layer-1, transparent)',
106
+ color: 'var(--dsw-alias-label-primary, inherit)',
107
+ font: 'inherit',
108
+ },
109
+ header: {
110
+ display: 'flex',
111
+ alignItems: 'center',
112
+ gap: 8,
113
+ flex: 'none',
114
+ minHeight: 40,
115
+ padding: '0 12px',
116
+ borderBottom: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.25))',
117
+ background: 'var(--dsw-alias-bg-layer-2, transparent)',
118
+ },
119
+ dot: {
120
+ width: 8,
121
+ height: 8,
122
+ borderRadius: '50%',
123
+ flex: 'none',
124
+ background: 'var(--dsw-alias-label-tertiary, #888)',
125
+ },
126
+ title: {
127
+ fontSize: 13,
128
+ fontWeight: 600,
129
+ whiteSpace: 'nowrap',
130
+ },
131
+ version: {
132
+ fontSize: 11,
133
+ color: 'var(--dsw-alias-label-tertiary, #888)',
134
+ whiteSpace: 'nowrap',
135
+ },
136
+ spacer: {
137
+ flex: 1,
138
+ },
139
+ warn: {
140
+ fontSize: 11,
141
+ color: 'var(--dsw-alias-state-warn-primary, #d97706)',
142
+ whiteSpace: 'nowrap',
143
+ overflow: 'hidden',
144
+ textOverflow: 'ellipsis',
145
+ maxWidth: 180,
146
+ },
147
+ action: {
148
+ display: 'inline-flex',
149
+ alignItems: 'center',
150
+ justifyContent: 'center',
151
+ height: 26,
152
+ padding: '0 10px',
153
+ borderRadius: 6,
154
+ border: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.3))',
155
+ background: 'transparent',
156
+ color: 'inherit',
157
+ fontSize: 12,
158
+ lineHeight: '24px',
159
+ cursor: 'pointer',
160
+ textDecoration: 'none',
161
+ whiteSpace: 'nowrap',
162
+ font: 'inherit',
163
+ },
164
+ frameWrap: {
165
+ flex: 1,
166
+ minHeight: 0,
167
+ position: 'relative',
168
+ display: 'flex',
169
+ background: '#0a0a12',
170
+ },
171
+ frame: {
172
+ flex: 1,
173
+ width: '100%',
174
+ minHeight: 0,
175
+ border: 0,
176
+ display: 'block',
177
+ background: '#0a0a12',
178
+ },
179
+ frameOverlay: {
180
+ position: 'absolute',
181
+ inset: 0,
182
+ display: 'flex',
183
+ alignItems: 'center',
184
+ justifyContent: 'center',
185
+ gap: 10,
186
+ background: '#0a0a12',
187
+ color: 'var(--dsw-alias-label-secondary, #aaa)',
188
+ fontSize: 12,
189
+ pointerEvents: 'none',
190
+ },
191
+ placeholder: {
192
+ flex: 1,
193
+ minHeight: 0,
194
+ display: 'flex',
195
+ flexDirection: 'column',
196
+ alignItems: 'center',
197
+ justifyContent: 'center',
198
+ gap: 12,
199
+ padding: 24,
200
+ textAlign: 'center',
201
+ },
202
+ placeholderIcon: {
203
+ color: 'var(--dsw-alias-label-secondary, #aaa)',
204
+ opacity: 0.8,
205
+ },
206
+ heading: {
207
+ margin: 0,
208
+ fontSize: 15,
209
+ fontWeight: 600,
210
+ },
211
+ copy: {
212
+ margin: 0,
213
+ maxWidth: 460,
214
+ fontSize: 13,
215
+ lineHeight: 1.6,
216
+ color: 'var(--dsw-alias-label-secondary, #aaa)',
217
+ },
218
+ };
219
+
220
+ function currentText() {
221
+ try {
222
+ const lang = typeof document === 'undefined' ? '' : document.documentElement?.lang;
223
+ return String(lang ?? '').toLowerCase().startsWith('zh') ? TEXT.zh : TEXT.en;
224
+ } catch {
225
+ return TEXT.en;
226
+ }
227
+ }
228
+
229
+ function safeHttpUrl(value) {
230
+ if (typeof value !== 'string' || value.length === 0) return null;
231
+ try {
232
+ const url = new URL(value);
233
+ return url.protocol === 'http:' || url.protocol === 'https:' ? url.href : null;
234
+ } catch {
235
+ return null;
236
+ }
237
+ }
238
+
239
+ function isLoopbackIpv4(host) {
240
+ const parts = host.split('.');
241
+ if (parts.length !== 4) return false;
242
+ if (!parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255)) return false;
243
+ return parts[0] === '127';
244
+ }
245
+
246
+ function isLoopbackIpv6(host) {
247
+ if (host === '::1') return true;
248
+ if (!host.startsWith('::ffff:')) return false;
249
+ const tail = host.slice('::ffff:'.length);
250
+ if (isLoopbackIpv4(tail)) return true;
251
+ const groups = tail.split(':');
252
+ return groups.length === 2
253
+ && groups.every((group) => /^[0-9a-f]{1,4}$/i.test(group))
254
+ && (Number.parseInt(groups.map((group) => group.padStart(4, '0')).join(''), 16) >>> 24) === 127;
255
+ }
256
+
257
+ function isLoopbackHostname(hostname) {
258
+ if (typeof hostname !== 'string' || hostname.length === 0) return false;
259
+ const host = hostname.toLowerCase().replace(/^\[/, '').replace(/\]$/, '').replace(/\.$/, '');
260
+ if (host === 'localhost' || host.endsWith('.localhost')) return true;
261
+ if (host.includes(':')) return isLoopbackIpv6(host);
262
+ return isLoopbackIpv4(host);
263
+ }
264
+
265
+ function isLoopbackHttpUrl(value) {
266
+ const href = safeHttpUrl(value);
267
+ if (href === null) return false;
268
+ try {
269
+ return isLoopbackHostname(new URL(href).hostname);
270
+ } catch {
271
+ return false;
272
+ }
273
+ }
274
+
275
+ function statusOf(info, loading, ready, error) {
276
+ if (loading && info === null) return 'checking';
277
+ if (ready) return 'connected';
278
+ if (error !== null) return 'offline';
279
+ if (info?.status === 'starting') return 'starting';
280
+ return info?.status === 'up' ? 'unavailable' : 'offline';
281
+ }
282
+
283
+ function dotColor(status) {
284
+ if (status === 'connected') return 'var(--dsw-alias-state-success-primary, #22c55e)';
285
+ if (status === 'starting') return 'var(--dsw-alias-state-info-primary, #38bdf8)';
286
+ if (status === 'unavailable') return 'var(--dsw-alias-state-warn-primary, #d97706)';
287
+ if (status === 'offline') return 'var(--dsw-alias-state-error-primary, #ef4444)';
288
+ return 'var(--dsw-alias-label-tertiary, #888)';
289
+ }
290
+
291
+ function reasonMessage(t, info, error) {
292
+ if (typeof error === 'string' && error.length > 0) return t.error.replace('{error}', error);
293
+ const reason = typeof info?.reason === 'string' ? info.reason : 'unknown';
294
+ return t.reasons[reason] ?? t.reasons.unknown;
295
+ }
296
+
297
+ function MebularIcon(props) {
298
+ const size = Number.isFinite(props?.size) ? props.size : 18;
299
+ return h(
300
+ 'svg',
301
+ {
302
+ width: size,
303
+ height: size,
304
+ viewBox: '0 0 24 24',
305
+ fill: 'none',
306
+ stroke: 'currentColor',
307
+ strokeWidth: 1.5,
308
+ 'aria-hidden': 'true',
309
+ focusable: 'false',
310
+ style: { display: 'block' },
311
+ },
312
+ h('circle', { cx: 12, cy: 12, r: 2.4, fill: 'currentColor', stroke: 'none' }),
313
+ h('ellipse', { cx: 12, cy: 12, rx: 9, ry: 4.1, transform: 'rotate(-28 12 12)' }),
314
+ h('ellipse', { cx: 12, cy: 12, rx: 9, ry: 4.1, transform: 'rotate(28 12 12)' }),
315
+ );
316
+ }
317
+
318
+ function MebularPanel() {
319
+ const t = currentText();
320
+ const [info, setInfo] = React.useState(null);
321
+ const [error, setError] = React.useState(null);
322
+ const [loading, setLoading] = React.useState(true);
323
+ const [refreshToken, setRefreshToken] = React.useState(0);
324
+ const [frameState, setFrameState] = React.useState('idle');
325
+
326
+ React.useEffect(() => {
327
+ let active = true;
328
+ let timer = null;
329
+
330
+ const tick = async () => {
331
+ const signal =
332
+ typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
333
+ ? AbortSignal.timeout(FETCH_TIMEOUT_MS)
334
+ : undefined;
335
+ try {
336
+ const response = await fetch(
337
+ EMBED_INFO_PATH,
338
+ signal === undefined
339
+ ? { cache: 'no-store', headers: { accept: 'application/json' } }
340
+ : { cache: 'no-store', headers: { accept: 'application/json' }, signal },
341
+ );
342
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
343
+ const next = await response.json();
344
+ if (!active) return;
345
+ setInfo(next !== null && typeof next === 'object' ? next : null);
346
+ setError(null);
347
+ } catch (cause) {
348
+ if (!active) return;
349
+ setError(cause instanceof Error ? cause.message : String(cause));
350
+ } finally {
351
+ if (active) setLoading(false);
352
+ }
353
+ };
354
+
355
+ setLoading(true);
356
+ tick();
357
+ timer = window.setInterval(tick, POLL_MS);
358
+ return () => {
359
+ active = false;
360
+ if (timer !== null) window.clearInterval(timer);
361
+ };
362
+ }, [refreshToken]);
363
+
364
+ const embedUrl = safeHttpUrl(info?.embedUrl);
365
+ const ready = Boolean(
366
+ info?.ok === true &&
367
+ info?.status === 'up' &&
368
+ info?.loopback === true &&
369
+ info?.embedProtocol === 1 &&
370
+ embedUrl !== null &&
371
+ isLoopbackHttpUrl(embedUrl),
372
+ );
373
+ const frameFailed = frameState === 'failed';
374
+ const showFrame = ready && !frameFailed;
375
+ const consoleUrl = safeHttpUrl(info?.consoleUrl) ?? DEFAULT_CONSOLE_URL;
376
+ const version = typeof info?.version === 'string' && info.version.length > 0 ? info.version : null;
377
+ const status = statusOf(info, loading, ready, error);
378
+ const refresh = () => setRefreshToken((value) => value + 1);
379
+
380
+ React.useEffect(() => {
381
+ if (!ready) setFrameState('idle');
382
+ else setFrameState('loading');
383
+ }, [ready, embedUrl, refreshToken]);
384
+
385
+ React.useEffect(() => {
386
+ if (frameState !== 'loading' || !showFrame) return undefined;
387
+ const timer = window.setTimeout(() => setFrameState('failed'), FRAME_TIMEOUT_MS);
388
+ return () => window.clearTimeout(timer);
389
+ }, [frameState, showFrame]);
390
+
391
+ const header = h(
392
+ 'div',
393
+ { style: styles.header },
394
+ h('span', { style: { ...styles.dot, background: dotColor(status) }, 'aria-hidden': 'true' }),
395
+ h('span', { style: styles.title }, t.title),
396
+ version === null ? null : h('span', { style: styles.version }, `v${version.replace(/^v/, '')}`),
397
+ h('span', { style: styles.spacer }),
398
+ error !== null && !ready
399
+ ? h('span', { style: styles.warn, title: error }, t.hostUnavailable)
400
+ : h('span', { style: styles.version }, t[status]),
401
+ h(
402
+ 'a',
403
+ {
404
+ href: consoleUrl,
405
+ target: '_blank',
406
+ rel: 'noopener noreferrer',
407
+ style: styles.action,
408
+ },
409
+ t.openConsole,
410
+ ),
411
+ h('button', { type: 'button', onClick: refresh, style: styles.action }, t.refresh),
412
+ );
413
+
414
+ const starting = info?.status === 'starting';
415
+ const cliMissing = info?.reason === 'managed_cli_missing';
416
+ const heading = loading && info === null
417
+ ? t.connectingTitle
418
+ : starting
419
+ ? t.startingTitle
420
+ : cliMissing
421
+ ? t.cliMissingTitle
422
+ : frameFailed
423
+ ? t.frameFailedTitle
424
+ : info?.status === 'up' ? t.incompatibleTitle : t.offlineTitle;
425
+ const bodyText = loading && info === null
426
+ ? t.connectingBody
427
+ : starting
428
+ ? t.startingBody
429
+ : frameFailed ? t.reasons.frame_blocked : reasonMessage(t, info, error);
430
+
431
+ const body = showFrame
432
+ ? h(
433
+ 'div',
434
+ { style: styles.frameWrap },
435
+ h('iframe', {
436
+ key: `${refreshToken}:${embedUrl}`,
437
+ src: embedUrl,
438
+ title: 'Mebular Console',
439
+ sandbox: 'allow-scripts allow-same-origin allow-forms',
440
+ referrerPolicy: 'origin',
441
+ style: styles.frame,
442
+ onLoad: (event) => {
443
+ // A cross-origin Console throws on contentWindow.location, which
444
+ // proves a real document loaded. A readable about:blank usually
445
+ // means CSP blocked the frame; switch to the guidance page.
446
+ try {
447
+ const href = event?.currentTarget?.contentWindow?.location?.href;
448
+ setFrameState(href === 'about:blank' ? 'failed' : 'ready');
449
+ } catch {
450
+ setFrameState('ready');
451
+ }
452
+ },
453
+ onError: () => setFrameState('failed'),
454
+ }),
455
+ frameState === 'ready'
456
+ ? null
457
+ : h('div', { style: styles.frameOverlay }, h(MebularIcon, { size: 20 }), t.loadingFrame),
458
+ )
459
+ : h(
460
+ 'div',
461
+ { style: styles.placeholder },
462
+ h('div', { style: styles.placeholderIcon }, h(MebularIcon, { size: 48 })),
463
+ h('h2', { style: styles.heading }, heading),
464
+ h('p', { style: styles.copy }, bodyText),
465
+ h(
466
+ 'a',
467
+ {
468
+ href: consoleUrl,
469
+ target: '_blank',
470
+ rel: 'noopener noreferrer',
471
+ style: { ...styles.action, height: 30, padding: '0 14px', lineHeight: '28px' },
472
+ },
473
+ t.openInBrowser,
474
+ ),
475
+ );
476
+
477
+ return h('div', { style: styles.root }, header, body);
478
+ }
479
+
480
+ const inject = ['slots'];
481
+
482
+ // Host-injected through DSH webServer.tapIndex. Absent means panel:true;
483
+ // only an explicit `panel:false` disables both slots. MCP tools are mounted
484
+ // separately by the host plugin from the same discovered daemon URL and are
485
+ // unaffected by this switch.
486
+ function panelEnabled() {
487
+ try {
488
+ const config = typeof window === 'undefined' ? undefined : window.__MEBULAR_DSH__;
489
+ return !(config !== null && typeof config === 'object' && config.panel === false);
490
+ } catch {
491
+ return true;
492
+ }
493
+ }
494
+
495
+ function apply(ctx) {
496
+ if (!panelEnabled()) return;
497
+ ctx.slots.inject('sidebar.panellist', () =>
498
+ ctx.slots.register(
499
+ {
500
+ name: 'sidebar.panellist',
501
+ id: 'mebular',
502
+ order: 10,
503
+ label: 'Mebular',
504
+ },
505
+ MebularIcon,
506
+ ),
507
+ );
508
+ ctx.slots.inject('main', () =>
509
+ ctx.slots.register(
510
+ {
511
+ name: 'main',
512
+ key: 'mebular',
513
+ },
514
+ MebularPanel,
515
+ ),
516
+ );
517
+ }
518
+
519
+ return { apply, inject };
520
+ },
521
+ });
@@ -0,0 +1,16 @@
1
+ # @mebular/dsh-plugin bundle patch.
2
+ #
3
+ # This file is the only composition surface the plugin owns: one `mebular` host
4
+ # row (status route + client panel + official MCP bridge). The MCP client is
5
+ # mounted inside the host plugin from the same discovered daemon URL, so the
6
+ # panel and the MCP bridge cannot disagree about the port.
7
+ #
8
+ # daemonUrl is intentionally omitted: the host resolves it as
9
+ # explicit daemonUrl > MEBULAR_HTTP_PORT > <home>/runtime.json > 7331.
10
+ # Set daemonUrl here only to pin one instance explicitly.
11
+ - insert:
12
+ - id: mebular
13
+ name: '@mebular/dsh-plugin'
14
+ config:
15
+ lifecycle: attach
16
+ panel: true
package/icon.svg ADDED
@@ -0,0 +1,8 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64" role="img" aria-label="Mebular">
2
+ <rect width="64" height="64" rx="14" fill="#0b0b16"/>
3
+ <g fill="none" stroke="#8b7cff" stroke-width="3.5" stroke-linecap="round">
4
+ <ellipse cx="32" cy="32" rx="22" ry="9" transform="rotate(-28 32 32)"/>
5
+ <ellipse cx="32" cy="32" rx="22" ry="9" transform="rotate(28 32 32)"/>
6
+ </g>
7
+ <circle cx="32" cy="32" r="5" fill="#f5f3ff"/>
8
+ </svg>