@snap-rail/framework-shadcn 0.0.0-stage → 0.2.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.
Files changed (97) hide show
  1. package/README.md +20 -2
  2. package/docs/framework-authoring.md +59 -0
  3. package/lib/fallback.js +307 -0
  4. package/lib/index.js +3036 -0
  5. package/lib/runtime.js +2827 -0
  6. package/lib/semantic.js +96 -0
  7. package/lib/session.js +48 -0
  8. package/lib/settings-pages.js +68 -0
  9. package/lib/slots.js +95 -0
  10. package/lib/types/Shell.d.ts +13 -0
  11. package/lib/types/Shell.js +17 -0
  12. package/lib/types/client.d.ts +41 -0
  13. package/lib/types/client.js +39 -0
  14. package/lib/types/fallback.d.ts +15 -0
  15. package/lib/types/fallback.js +52 -0
  16. package/lib/types/index.d.ts +17 -0
  17. package/lib/types/index.js +17 -0
  18. package/lib/types/runtime.d.ts +53 -0
  19. package/lib/types/runtime.js +75 -0
  20. package/lib/types/semantic.d.ts +55 -0
  21. package/lib/types/semantic.js +63 -0
  22. package/lib/types/session.d.ts +43 -0
  23. package/lib/types/session.js +76 -0
  24. package/lib/types/settings-dialog.d.ts +17 -0
  25. package/lib/types/settings-dialog.js +138 -0
  26. package/lib/types/settings-pages.d.ts +68 -0
  27. package/lib/types/settings-pages.js +85 -0
  28. package/lib/types/settings-plugins-page.d.ts +29 -0
  29. package/lib/types/settings-plugins-page.js +466 -0
  30. package/lib/types/settings-update-page.d.ts +17 -0
  31. package/lib/types/settings-update-page.js +124 -0
  32. package/lib/types/slots.d.ts +114 -0
  33. package/lib/types/slots.js +119 -0
  34. package/lib/types/ui/badge.d.ts +17 -0
  35. package/lib/types/ui/badge.js +29 -0
  36. package/lib/types/ui/button.d.ts +20 -0
  37. package/lib/types/ui/button.js +39 -0
  38. package/lib/types/ui/card.d.ts +14 -0
  39. package/lib/types/ui/card.js +18 -0
  40. package/lib/types/ui/checkbox.d.ts +10 -0
  41. package/lib/types/ui/checkbox.js +13 -0
  42. package/lib/types/ui/collapsible.d.ts +24 -0
  43. package/lib/types/ui/collapsible.js +31 -0
  44. package/lib/types/ui/dialog.d.ts +22 -0
  45. package/lib/types/ui/dialog.js +29 -0
  46. package/lib/types/ui/drag-scroll.d.ts +13 -0
  47. package/lib/types/ui/drag-scroll.js +85 -0
  48. package/lib/types/ui/hooks.d.ts +16 -0
  49. package/lib/types/ui/hooks.js +26 -0
  50. package/lib/types/ui/icons.d.ts +21 -0
  51. package/lib/types/ui/icons.js +18 -0
  52. package/lib/types/ui/index.d.ts +32 -0
  53. package/lib/types/ui/index.js +32 -0
  54. package/lib/types/ui/input.d.ts +8 -0
  55. package/lib/types/ui/input.js +6 -0
  56. package/lib/types/ui/label.d.ts +9 -0
  57. package/lib/types/ui/label.js +12 -0
  58. package/lib/types/ui/led.d.ts +13 -0
  59. package/lib/types/ui/led.js +11 -0
  60. package/lib/types/ui/number-input.d.ts +35 -0
  61. package/lib/types/ui/number-input.js +48 -0
  62. package/lib/types/ui/progress.d.ts +10 -0
  63. package/lib/types/ui/progress.js +14 -0
  64. package/lib/types/ui/radio-group.d.ts +12 -0
  65. package/lib/types/ui/radio-group.js +17 -0
  66. package/lib/types/ui/schema-form.d.ts +51 -0
  67. package/lib/types/ui/schema-form.js +124 -0
  68. package/lib/types/ui/scroll-area.d.ts +10 -0
  69. package/lib/types/ui/scroll-area.js +13 -0
  70. package/lib/types/ui/switch.d.ts +10 -0
  71. package/lib/types/ui/switch.js +13 -0
  72. package/lib/types/ui/table.d.ts +20 -0
  73. package/lib/types/ui/table.js +28 -0
  74. package/lib/types/ui/tabs.d.ts +16 -0
  75. package/lib/types/ui/tabs.js +23 -0
  76. package/lib/types/ui/textarea.d.ts +10 -0
  77. package/lib/types/ui/textarea.js +6 -0
  78. package/lib/types/ui/theme.d.ts +30 -0
  79. package/lib/types/ui/theme.js +33 -0
  80. package/lib/types/ui/tooltip.d.ts +23 -0
  81. package/lib/types/ui/tooltip.js +15 -0
  82. package/lib/types/ui/touch-select.d.ts +29 -0
  83. package/lib/types/ui/touch-select.js +37 -0
  84. package/lib/types/ui/utils.d.ts +13 -0
  85. package/lib/types/ui/utils.js +16 -0
  86. package/lib/types/variables.d.ts +168 -0
  87. package/lib/types/variables.js +282 -0
  88. package/lib/types/workflows.d.ts +98 -0
  89. package/lib/types/workflows.js +95 -0
  90. package/lib/ui/index.js +1101 -0
  91. package/lib/ui/theme.css +271 -0
  92. package/lib/variables.js +260 -0
  93. package/lib/workflows.js +85 -0
  94. package/package.json +138 -3
  95. package/src/ui/theme.css +271 -0
  96. package/templates/ui-extension/README.md +17 -0
  97. package/templates/ui-view/README.md +17 -0
@@ -0,0 +1,55 @@
1
+ /** Framework-neutral semantic UI contributions.
2
+ *
3
+ * Layouts own the visual arrangement. Components publish views, navigation
4
+ * entries and actions against stable semantic slots, so the same component can
5
+ * move between layouts without importing a layout package.
6
+ *
7
+ * @module @snap-rail/framework-shadcn/semantic
8
+ */
9
+ import { Context, type Plugin } from '@snap-rail/cordis';
10
+ import type { ReactNode } from 'react';
11
+ import type { SlotId, SlotOccupant } from './slots.tsx';
12
+ /** A view contributed by a UI component plugin. */
13
+ export interface UiViewContribution extends SlotOccupant {
14
+ /** Human-facing label for menus and accessibility surfaces. */
15
+ title: string;
16
+ /** The semantic slot where the view is rendered. */
17
+ slot: SlotId;
18
+ }
19
+ /** A layout contribution that consumes semantic slots. */
20
+ export interface UiLayoutContribution extends SlotOccupant {
21
+ /** Slots this layout defines and renders. */
22
+ slots: readonly SlotId[];
23
+ }
24
+ /** A navigation entry that targets a contributed view id. */
25
+ export interface UiNavigationContribution {
26
+ id: string;
27
+ label: string;
28
+ viewId: string;
29
+ order: number;
30
+ /** Optional leading visual for layouts that render navigation items —
31
+ * the component renders its own SVG/asset, the layout only places it. */
32
+ icon?: ReactNode;
33
+ }
34
+ /** The shared UI contract service exposed to all client plugins. */
35
+ export interface UiContractService {
36
+ registerView(caller: Context, contribution: UiViewContribution): () => void;
37
+ registerLayout(caller: Context, contribution: UiLayoutContribution): () => void;
38
+ registerNavigation(caller: Context, contribution: UiNavigationContribution): () => void;
39
+ listViews(): readonly UiViewContribution[];
40
+ listLayouts(): readonly UiLayoutContribution[];
41
+ listNavigation(): readonly UiNavigationContribution[];
42
+ }
43
+ declare module '@snap-rail/cordis' {
44
+ interface Context {
45
+ uiContract: UiContractService;
46
+ }
47
+ interface Events {
48
+ 'ui/semantic-changed'(kind: 'view' | 'layout' | 'navigation'): void;
49
+ /** A contribution used an unknown or malformed semantic slot. */
50
+ 'ui/semantic-invalid'(kind: 'view' | 'layout', id: string, slot: string): void;
51
+ }
52
+ }
53
+ /** Mount the semantic contribution registry on top of the slot registry. */
54
+ declare const semanticPlugin: Plugin.Object<void>;
55
+ export default semanticPlugin;
@@ -0,0 +1,63 @@
1
+ /** Framework-neutral semantic UI contributions.
2
+ *
3
+ * Layouts own the visual arrangement. Components publish views, navigation
4
+ * entries and actions against stable semantic slots, so the same component can
5
+ * move between layouts without importing a layout package.
6
+ *
7
+ * @module @snap-rail/framework-shadcn/semantic
8
+ */
9
+ import { Context } from '@snap-rail/cordis';
10
+ import { isValidUiSlotId, UI_SLOT_IDS } from "./slots.js";
11
+ /** Mount the semantic contribution registry on top of the slot registry. */
12
+ const semanticPlugin = {
13
+ name: 'framework semantic ui contract',
14
+ inject: ['uiSlots'],
15
+ apply(ctx) {
16
+ const slots = ctx.uiSlots;
17
+ const views = new Map();
18
+ const layouts = new Map();
19
+ const navigation = new Map();
20
+ let sequence = 0;
21
+ const ordered = (values) => [...values].sort((a, b) => a.value.order - b.value.order || a.seq - b.seq).map(item => item.value);
22
+ const register = (table, caller, id, value, disposeSlot) => {
23
+ table.set(id, { value, seq: sequence++ });
24
+ const kind = table === views ? 'view' : table === layouts ? 'layout' : 'navigation';
25
+ ctx.emit('ui/semantic-changed', kind);
26
+ const remove = () => {
27
+ // Disposal is by contribution id: an earlier handle still removes the
28
+ // entry after a same-id replacement (fiber unload drops the current
29
+ // contribution), and repeat calls are no-ops.
30
+ if (!table.delete(id))
31
+ return;
32
+ disposeSlot?.();
33
+ ctx.emit('ui/semantic-changed', kind);
34
+ };
35
+ caller.effect(() => remove);
36
+ return remove;
37
+ };
38
+ const validateSlot = (kind, id, slot) => {
39
+ if (!isValidUiSlotId(slot))
40
+ ctx.emit('ui/semantic-invalid', kind, id, slot);
41
+ };
42
+ ctx.provide('uiContract', {
43
+ registerView(caller, contribution) {
44
+ validateSlot('view', contribution.id, contribution.slot);
45
+ const disposeSlot = slots.register(caller, contribution.slot, contribution);
46
+ return register(views, caller, contribution.id, contribution, disposeSlot);
47
+ },
48
+ registerLayout(caller, contribution) {
49
+ for (const slot of contribution.slots)
50
+ validateSlot('layout', contribution.id, slot);
51
+ const disposeSlot = slots.register(caller, UI_SLOT_IDS.layout, contribution);
52
+ return register(layouts, caller, contribution.id, contribution, disposeSlot);
53
+ },
54
+ registerNavigation(caller, contribution) {
55
+ return register(navigation, caller, contribution.id, contribution);
56
+ },
57
+ listViews: () => ordered(views.values()),
58
+ listLayouts: () => ordered(layouts.values()),
59
+ listNavigation: () => ordered(navigation.values()),
60
+ });
61
+ },
62
+ };
63
+ export default semanticPlugin;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The operator session seam: `ctx.session` mirrors the host-side session
3
+ * (persisted to settings, so a restart keeps the operator signed on) and
4
+ * re-emits `session/changed` on every transition — its own writes and any
5
+ * host-side change that rides the `settings/changed` frame. The login page
6
+ * writes it; every workflow consumer reads it for gating and audit
7
+ * attribution.
8
+ *
9
+ * @module @snap-rail/framework-shadcn/session
10
+ */
11
+ import '@snap-rail/station-rpc/contract';
12
+ import { type Plugin } from '@snap-rail/cordis';
13
+ declare module '@snap-rail/cordis' {
14
+ interface Context {
15
+ session: SessionService;
16
+ }
17
+ interface Events {
18
+ /** The signed-on operator changed (login, logout, or boot restore).
19
+ * @param operator - the new operator id, or `null` when signed off.
20
+ */
21
+ 'session/changed'(operator: string | null): void;
22
+ /** The boot-time restore probe settled (success or failure) — the one
23
+ * tick where `current()` first reflects the persisted operator. */
24
+ 'session/restored'(): void;
25
+ }
26
+ }
27
+ /** Operator session exposed on `ctx.session`. */
28
+ export interface SessionService {
29
+ /** The signed-on operator id, or `null` while the login page shows. */
30
+ current(): string | null;
31
+ /** Whether the boot-time restore probe has settled. Until it does,
32
+ * `current()` is not yet the truth (a restart may still be about to
33
+ * restore a persisted operator) — consumers must not render the login
34
+ * page or gate workflows on `current()` before this holds. */
35
+ restored(): boolean;
36
+ /** Sign an operator on; trims the id and rejects empty input. */
37
+ login(operator: string): Promise<void>;
38
+ /** Sign the current operator off (back to the login page). */
39
+ logout(): Promise<void>;
40
+ }
41
+ /** Mounts the `ctx.session` mirror; state lives in the apply closure. */
42
+ declare const sessionPlugin: Plugin.Object<void>;
43
+ export default sessionPlugin;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * The operator session seam: `ctx.session` mirrors the host-side session
3
+ * (persisted to settings, so a restart keeps the operator signed on) and
4
+ * re-emits `session/changed` on every transition — its own writes and any
5
+ * host-side change that rides the `settings/changed` frame. The login page
6
+ * writes it; every workflow consumer reads it for gating and audit
7
+ * attribution.
8
+ *
9
+ * @module @snap-rail/framework-shadcn/session
10
+ */
11
+ // Wire rows for the station-domain methods this resident calls.
12
+ import '@snap-rail/station-rpc/contract';
13
+ import { Context } from '@snap-rail/cordis';
14
+ import { subscribeFrame } from '@snap-rail/connection';
15
+ import { SESSION_OPERATOR_KEY, settingsChangedSchema } from '@snap-rail/station-rpc/contract';
16
+ /** Mounts the `ctx.session` mirror; state lives in the apply closure. */
17
+ const sessionPlugin = {
18
+ name: 'client-session',
19
+ inject: ['client'],
20
+ apply(ctx) {
21
+ const { link } = ctx.client;
22
+ let operator = null;
23
+ let restored = false;
24
+ const sync = (next) => {
25
+ operator = next;
26
+ ctx.emit('session/changed', operator);
27
+ };
28
+ // Restore the persisted operator; a failed probe leaves the login page
29
+ // up. The probe races the first rendered frame, so its settlement is
30
+ // announced on its own event — the login surface holds off until then
31
+ // instead of flashing the keypad before a restart's operator lands.
32
+ void link.call('session.current', {})
33
+ .then(result => {
34
+ if (result.ok)
35
+ sync(result.value.operator);
36
+ })
37
+ .catch(() => { })
38
+ .finally(() => {
39
+ restored = true;
40
+ ctx.emit('session/restored');
41
+ });
42
+ // Hot-follow host-side session changes (another surface signed in or
43
+ // out — the titlebar's 换人, or the host restarting with a different
44
+ // persisted operator): re-pull the truth and re-emit on any drift.
45
+ ctx.effect(() => subscribeFrame(link, 'settings/changed', settingsChangedSchema, change => {
46
+ if (change.key !== SESSION_OPERATOR_KEY)
47
+ return;
48
+ void link.call('session.current', {})
49
+ .then(result => {
50
+ if (result.ok && result.value.operator !== operator)
51
+ sync(result.value.operator);
52
+ })
53
+ .catch(() => { });
54
+ }));
55
+ ctx.provide('session', {
56
+ current: () => operator,
57
+ restored: () => restored,
58
+ async login(raw) {
59
+ const trimmed = raw.trim();
60
+ if (trimmed === '')
61
+ throw new Error('工号不能为空');
62
+ const result = await link.call('session.login', { operator: trimmed });
63
+ if (!result.ok)
64
+ throw new Error(`登录失败(${result.error.code})`);
65
+ sync(trimmed);
66
+ },
67
+ async logout() {
68
+ const result = await link.call('session.logout', {});
69
+ if (!result.ok)
70
+ throw new Error(`退出失败(${result.error.code})`);
71
+ sync(null);
72
+ },
73
+ });
74
+ },
75
+ };
76
+ export default sessionPlugin;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The settings dialog shell: a layout occupant that renders nothing until
3
+ * `ctx.settingsPages` is open, then shows the registered pages as a menu on
4
+ * the left and the active page's content on the right. Pages come from any
5
+ * plugin via the settings seam (equipment-station contributes the 设备管理
6
+ * page that way); the shell itself contributes the first-party plugin
7
+ * management, theme, and app-update pages — living in the framework bundle
8
+ * means a framework-only install already manages its own plugins.
9
+ *
10
+ * @module @snap-rail/framework-shadcn/settings-dialog
11
+ */
12
+ import { type Plugin } from '@snap-rail/cordis';
13
+ import './settings-pages.ts';
14
+ import './semantic.tsx';
15
+ /** The settings dialog occupant. */
16
+ declare const settingsDialogPlugin: Plugin.Object<void>;
17
+ export default settingsDialogPlugin;
@@ -0,0 +1,138 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * The settings dialog shell: a layout occupant that renders nothing until
4
+ * `ctx.settingsPages` is open, then shows the registered pages as a menu on
5
+ * the left and the active page's content on the right. Pages come from any
6
+ * plugin via the settings seam (equipment-station contributes the 设备管理
7
+ * page that way); the shell itself contributes the first-party plugin
8
+ * management, theme, and app-update pages — living in the framework bundle
9
+ * means a framework-only install already manages its own plugins.
10
+ *
11
+ * @module @snap-rail/framework-shadcn/settings-dialog
12
+ */
13
+ import { Context } from '@snap-rail/cordis';
14
+ import "./settings-pages.js";
15
+ import { UI_SLOT_IDS } from "./slots.js";
16
+ import "./semantic.js";
17
+ import { subscribeFrame } from '@snap-rail/connection';
18
+ import { settingsChangedSchema } from '@snap-rail/station-rpc/contract';
19
+ import { Button, DragScroll, Tabs, TabsList, TabsTrigger, useRefresh, applyTheme, DEFAULT_THEME, themeSettingsSchema, THEME_SETTINGS_KEY, } from "./ui/index.js";
20
+ import { X } from 'lucide-react';
21
+ import { useEffect, useState } from 'react';
22
+ import { PluginsPage } from "./settings-plugins-page.js";
23
+ import { UpdatePage } from "./settings-update-page.js";
24
+ /** The 主题 settings page: one mode choice (跟随系统/白天/黑夜). The write
25
+ * hot-applies everywhere via the `settings/changed` frame — no restart. */
26
+ function ThemePage(props) {
27
+ const [mode, setMode] = useState(props.initial.mode);
28
+ return (_jsxs("div", { "data-region": "theme-page", className: "flex max-w-xl flex-col gap-4", children: [_jsx("p", { className: "text-sm text-muted-foreground", children: "\u6A21\u5F0F\u7ACB\u5373\u751F\u6548\u5E76\u5199\u5165\u914D\u7F6E\u6587\u4EF6\uFF1B\u8DDF\u968F\u7CFB\u7EDF\u65F6\u968F\u64CD\u4F5C\u7CFB\u7EDF\u7684\u65E5\u591C\u95F4\u8BBE\u7F6E\u81EA\u52A8\u5207\u6362\u3002" }), _jsx(Tabs, { value: mode, onValueChange: next => {
29
+ const choice = next;
30
+ setMode(choice);
31
+ props.onChoose({ mode: choice });
32
+ }, children: _jsxs(TabsList, { "aria-label": "\u4E3B\u9898\u6A21\u5F0F", children: [_jsx(TabsTrigger, { value: "system", "data-mode-option": "system", children: "\u8DDF\u968F\u7CFB\u7EDF" }), _jsx(TabsTrigger, { value: "light", "data-mode-option": "light", children: "\u767D\u5929" }), _jsx(TabsTrigger, { value: "dark", "data-mode-option": "dark", children: "\u9ED1\u591C" })] }) })] }));
33
+ }
34
+ /** The settings shell: a fullscreen surface that renders the registered
35
+ * pages as a menu on the left and the active page's content on the right.
36
+ * Pages come from any plugin via the settings seam; the shell itself
37
+ * contributes the first-party plugin page (management and market fused)
38
+ * plus theme and app-update pages. */
39
+ function SettingsDialog({ ctx }) {
40
+ useRefresh(ctx, ['settings/open-changed', 'settings/pages-changed']);
41
+ const open = ctx.settingsPages.isOpen();
42
+ const pages = ctx.settingsPages.list();
43
+ const activeId = ctx.settingsPages.active();
44
+ const active = pages.find(page => page.id === activeId);
45
+ // The shell fills the whole viewport: settings is a surface an operator
46
+ // lives in, not a modal peek. Escape keeps the dialog-era close gesture.
47
+ useEffect(() => {
48
+ if (!open)
49
+ return;
50
+ const onKey = (event) => {
51
+ if (event.key === 'Escape')
52
+ ctx.settingsPages.close();
53
+ };
54
+ window.addEventListener('keydown', onKey);
55
+ return () => { window.removeEventListener('keydown', onKey); };
56
+ // eslint-disable-next-line react-hooks/exhaustive-deps
57
+ }, [open]);
58
+ if (!open)
59
+ return null;
60
+ return (_jsxs("div", { "data-region": "settings-shell", "data-active-page": activeId, className: "fixed inset-0 z-50 flex flex-col bg-background", children: [_jsxs("div", { className: "flex items-center justify-between border-b border-border px-5 py-3", children: [_jsx("span", { className: "text-lg font-medium", children: "\u8BBE\u7F6E" }), _jsx(Button, { variant: "ghost", size: "icon", "aria-label": "\u5173\u95ED\u8BBE\u7F6E", onClick: () => ctx.settingsPages.close(), children: _jsx(X, { className: "h-5 w-5" }) })] }), _jsxs("div", { className: "flex min-h-0 flex-1", children: [_jsx(DragScroll, { "data-region": "settings-menu", className: "flex w-60 shrink-0 flex-col gap-1 border-r border-border p-2", children: pages.map(page => (_jsx("button", { type: "button", "data-settings-page": page.id, "aria-current": page.active || undefined, className: `flex h-12 w-full items-center rounded-md px-4 text-left text-base transition-colors hover:bg-accent ${page.active ? 'bg-accent font-medium text-accent-foreground channel-keyline' : 'text-muted-foreground'}`, onClick: () => ctx.settingsPages.open(page.id), children: page.title }, page.id))) }), _jsx("div", { "data-region": "settings-content", "data-active-page": activeId, className: "flex min-w-0 flex-1 flex-col overflow-auto p-6", children: active?.render() })] })] }));
61
+ }
62
+ /** The settings dialog occupant. */
63
+ const settingsDialogPlugin = {
64
+ name: 'framework settings',
65
+ inject: ['uiContract', 'settingsPages'],
66
+ apply(ctx) {
67
+ const client = ctx.get('client');
68
+ // Theme: apply the stored row (settings.json `ui.theme`) to <html> right
69
+ // away, keep every surface hot-synced through the settings/changed
70
+ // frame, and follow the OS preference while in system mode. The page
71
+ // below is the only writer; its write re-enters through the frame.
72
+ let theme = DEFAULT_THEME;
73
+ const adoptTheme = (value) => {
74
+ const parsed = themeSettingsSchema.safeParse(value);
75
+ if (!parsed.success)
76
+ return;
77
+ theme = parsed.data;
78
+ applyTheme(document, theme);
79
+ };
80
+ applyTheme(document, theme);
81
+ void client.link.call('settings.get', { key: THEME_SETTINGS_KEY })
82
+ .then(result => { if (result.ok)
83
+ adoptTheme(result.value.value); })
84
+ .catch(() => { });
85
+ ctx.effect(() => subscribeFrame(client.link, 'settings/changed', settingsChangedSchema, change => {
86
+ if (change.key === THEME_SETTINGS_KEY)
87
+ adoptTheme(change.value);
88
+ }));
89
+ const osPreference = window.matchMedia('(prefers-color-scheme: light)');
90
+ ctx.effect(() => {
91
+ const sync = () => { if (theme.mode === 'system')
92
+ applyTheme(document, theme); };
93
+ osPreference.addEventListener('change', sync);
94
+ return () => { osPreference.removeEventListener('change', sync); };
95
+ });
96
+ const chooseTheme = (next) => {
97
+ theme = next;
98
+ applyTheme(document, next);
99
+ void client.link.call('settings.set', { key: THEME_SETTINGS_KEY, value: next });
100
+ };
101
+ ctx.settingsPages.register(ctx, {
102
+ id: 'plugins',
103
+ title: '插件',
104
+ order: 0,
105
+ render() {
106
+ return _jsx(PluginsPage, { ctx: ctx });
107
+ },
108
+ });
109
+ ctx.settingsPages.register(ctx, {
110
+ id: 'theme',
111
+ title: '主题',
112
+ order: 1,
113
+ render() {
114
+ return _jsx(ThemePage, { initial: theme, onChoose: chooseTheme });
115
+ },
116
+ });
117
+ ctx.settingsPages.register(ctx, {
118
+ id: 'update',
119
+ title: '软件更新',
120
+ order: 2,
121
+ render() {
122
+ return _jsx(UpdatePage, { ctx: ctx });
123
+ },
124
+ });
125
+ ctx.uiContract.registerLayout(ctx, {
126
+ // The id is a cross-face contract: the fallback shell excludes
127
+ // exactly this occupant when deciding whether it is the only
128
+ // layout — renaming it blanks the fallback.
129
+ id: 'settings-dialog',
130
+ order: 100,
131
+ slots: [UI_SLOT_IDS.overlay],
132
+ render() {
133
+ return _jsx(SettingsDialog, { ctx: ctx });
134
+ },
135
+ });
136
+ },
137
+ };
138
+ export default settingsDialogPlugin;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * The settings seam: `ctx.settingsPages` is a registry of settings-dialog
3
+ * pages plus the dialog's open state. It knows the mechanism — registration,
4
+ * ordering, which page is shown, whether the dialog is open — and nothing
5
+ * about any concrete page. The dialog shell (settings-station) renders the
6
+ * registry; the chrome's gear button only calls `open()`.
7
+ *
8
+ * The service mirrors `ctx.workflows` structurally: register + list plus
9
+ * change events, with state in construction-time closures.
10
+ *
11
+ * @module @snap-rail/framework-shadcn/settings-pages
12
+ */
13
+ import { Context, type Plugin } from '@snap-rail/cordis';
14
+ import type { ReactNode } from 'react';
15
+ /** What one plugin contributes to the settings dialog. */
16
+ export interface SettingsPageDef {
17
+ /** Stable page id (the menu and content key). */
18
+ id: string;
19
+ /** Menu label. */
20
+ title: string;
21
+ /** Menu ordering; ties break by registration time. */
22
+ order: number;
23
+ render(): ReactNode;
24
+ }
25
+ /** A settings page as the dialog sees it, with derived state. */
26
+ export interface SettingsPage extends SettingsPageDef {
27
+ /** Whether this page is the one currently shown. */
28
+ active: boolean;
29
+ }
30
+ /** Settings registry and dialog state exposed as `ctx.settingsPages`. */
31
+ export interface SettingsPagesService {
32
+ /** Registered pages in menu order with the active flag. */
33
+ list(): readonly SettingsPage[];
34
+ /** Register one page; disposal (caller unload) removes it.
35
+ * @param caller - owning context; unload drops the page.
36
+ * @param def - the contribution; same-id re-registration replaces.
37
+ */
38
+ register(caller: Context, def: SettingsPageDef): () => void;
39
+ /** Open the dialog; `id` additionally makes that page active. */
40
+ open(id?: string): void;
41
+ /** Close the dialog (the active-page choice is kept). */
42
+ close(): void;
43
+ /** Open when closed, close when open. */
44
+ toggle(): void;
45
+ /** Whether the dialog is currently open. */
46
+ isOpen(): boolean;
47
+ /** The active page id while open — the requested one when registered, else the first menu page; `undefined` while closed. */
48
+ active(): string | undefined;
49
+ }
50
+ declare module '@snap-rail/cordis' {
51
+ interface Context {
52
+ settingsPages: SettingsPagesService;
53
+ }
54
+ interface Events {
55
+ /** The page registry changed (any add, replace, or removal). */
56
+ 'settings/pages-changed'(): void;
57
+ /** The dialog opened, closed, or switched pages. */
58
+ 'settings/open-changed'(): void;
59
+ }
60
+ }
61
+ /**
62
+ * Mounts the `ctx.settingsPages` registry. All state lives in
63
+ * construction-time closures: traceable context proxies rebind `this` on
64
+ * every method access, so `this`-reached state cannot back a service here
65
+ * (the uiSlots lesson).
66
+ */
67
+ declare const settingsPlugin: Plugin.Object<void>;
68
+ export default settingsPlugin;
@@ -0,0 +1,85 @@
1
+ /**
2
+ * The settings seam: `ctx.settingsPages` is a registry of settings-dialog
3
+ * pages plus the dialog's open state. It knows the mechanism — registration,
4
+ * ordering, which page is shown, whether the dialog is open — and nothing
5
+ * about any concrete page. The dialog shell (settings-station) renders the
6
+ * registry; the chrome's gear button only calls `open()`.
7
+ *
8
+ * The service mirrors `ctx.workflows` structurally: register + list plus
9
+ * change events, with state in construction-time closures.
10
+ *
11
+ * @module @snap-rail/framework-shadcn/settings-pages
12
+ */
13
+ import { Context } from '@snap-rail/cordis';
14
+ /**
15
+ * Mounts the `ctx.settingsPages` registry. All state lives in
16
+ * construction-time closures: traceable context proxies rebind `this` on
17
+ * every method access, so `this`-reached state cannot back a service here
18
+ * (the uiSlots lesson).
19
+ */
20
+ const settingsPlugin = {
21
+ name: 'client-settings',
22
+ apply(ctx) {
23
+ const pages = new Map();
24
+ let nextSeq = 0;
25
+ let open = false;
26
+ let requestedId;
27
+ const firstId = () => [...pages.values()]
28
+ .sort((a, b) => a.def.order - b.def.order || a.seq - b.seq)[0]?.def.id;
29
+ const activeId = () => {
30
+ if (!open)
31
+ return undefined;
32
+ if (requestedId !== undefined && pages.has(requestedId))
33
+ return requestedId;
34
+ return firstId();
35
+ };
36
+ const openDialog = (id) => {
37
+ if (id !== undefined)
38
+ requestedId = id;
39
+ if (open && id === undefined)
40
+ return;
41
+ open = true;
42
+ ctx.emit('settings/open-changed');
43
+ };
44
+ const closeDialog = () => {
45
+ if (!open)
46
+ return;
47
+ open = false;
48
+ ctx.emit('settings/open-changed');
49
+ };
50
+ ctx.provide('settingsPages', {
51
+ list() {
52
+ const current = activeId();
53
+ return [...pages.values()]
54
+ .sort((a, b) => a.def.order - b.def.order || a.seq - b.seq)
55
+ .map(({ def }) => ({ ...def, active: def.id === current }));
56
+ },
57
+ register(caller, def) {
58
+ const replaced = pages.has(def.id);
59
+ pages.set(def.id, { def, seq: nextSeq++ });
60
+ ctx.emit(replaced ? 'settings/open-changed' : 'settings/pages-changed');
61
+ const remove = () => {
62
+ if (pages.delete(def.id))
63
+ ctx.emit('settings/pages-changed');
64
+ };
65
+ // Effects take a body producing the disposer; passing `remove`
66
+ // itself would run it as setup.
67
+ caller.effect(() => remove);
68
+ return remove;
69
+ },
70
+ open: openDialog,
71
+ close: closeDialog,
72
+ toggle() {
73
+ if (open)
74
+ closeDialog();
75
+ else
76
+ openDialog();
77
+ },
78
+ isOpen() {
79
+ return open;
80
+ },
81
+ active: activeId,
82
+ });
83
+ },
84
+ };
85
+ export default settingsPlugin;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The 插件 page — plugin management and discovery fused into one surface. The
3
+ * top row is a single tri-form input: a local path or an http(s) address
4
+ * inspects that zip (a plugin, or an offline combo package with `recipe.yml`
5
+ * at its root; both preview → confirm → install), a dropped file does the
6
+ * same, anything else searches every configured plugin feed (merged, rows
7
+ * carry their source). Below, every installed (non-builtin) row lists in
8
+ * one table — 名称/标识 split into columns, the version cell doubling as
9
+ * the update affordance (a muted pill normally; a success-colored pill with
10
+ * a refresh glyph and the feed version when an update is pending — clicking
11
+ * it updates directly, behind a key-change confirmation when the feed entry
12
+ * is signed by a different key than the installed copy). The preview shows
13
+ * the three-state signature verdict per plugin (a combo shows each member's
14
+ * own verdict — the archive itself is unsigned); an unverified member takes
15
+ * one extra explicit risk step before it installs. The feed sources hide
16
+ * behind a small icon button before the input; they default from the app's
17
+ * shipped `defaults.json` until the user writes their own list. Every write
18
+ * lands in `plugins.yml`/`settings.json` through the plugins and settings
19
+ * domains; the files stay the single interface.
20
+ *
21
+ * @module @snap-rail/framework-shadcn/settings-plugins-page
22
+ */
23
+ import '@snap-rail/app-boot/contract';
24
+ import type { Context } from '@snap-rail/cordis';
25
+ import { type ReactNode } from 'react';
26
+ /** The plugins page. */
27
+ export declare function PluginsPage({ ctx }: {
28
+ ctx: Context;
29
+ }): ReactNode;