@ossy/app 1.40.2 → 1.40.3

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.
@@ -1,30 +1,9 @@
1
- import React, { useState, useMemo, useCallback, useEffect } from 'react'
1
+ import React, { useState, useCallback, useEffect } from 'react'
2
2
  import { updateResourceContent } from '@ossy/resources'
3
3
  import { useSdk } from '@ossy/sdk-react'
4
4
  import { Overlay, Button, useTheme, View, Text } from '@ossy/design-system'
5
5
  import { DevPagesPanel } from './DevPagesPanel.jsx'
6
-
7
- const fabStyles = {
8
- boxShadow: '2px 2px 5px hsla(0, 0%, 0%, .2)',
9
- borderRadius: '999px',
10
- position: 'fixed',
11
- right: 'var(--space-m)',
12
- bottom: 'var(--space-m)',
13
- cursor: 'pointer',
14
- transition: 'transform .5s',
15
- zIndex: 101,
16
- padding: 'var(--space-m)',
17
- }
18
-
19
- const modalPanelStyles = {
20
- width: 'min(720px, 80vw)',
21
- minWidth: 'min(480px, 100%)',
22
- maxHeight: '85vh',
23
- overflowY: 'auto',
24
- overflowX: 'hidden',
25
- margin: '0 auto',
26
- boxShadow: '2px 2px 5px hsla(0, 0%, 0%, .2)',
27
- }
6
+ import { themeEditorStyles } from './themeEditorStyles.js'
28
7
 
29
8
  const ThemeSwitcher = () => {
30
9
  const { activeTheme, setTheme, themes } = useTheme()
@@ -47,10 +26,6 @@ export const ThemeEditor = () => {
47
26
  // const [theme, temporarilyUpdateTheme] = useTheme()
48
27
  const theme = {}
49
28
  const temporarilyUpdateTheme = () => {}
50
-
51
- const toggleStyles = useMemo(() => !isEditorOpen
52
- ? fabStyles
53
- : { ...fabStyles, transform: 'rotate(-45deg)' }, [isEditorOpen])
54
29
 
55
30
  const onToggle = useCallback(() => {
56
31
  setIsEditorOpen(!isEditorOpen)
@@ -85,18 +60,22 @@ export const ThemeEditor = () => {
85
60
 
86
61
  return (
87
62
  <>
63
+ <style href="@ossy/app/theme-editor" precedence="high">
64
+ {themeEditorStyles}
65
+ </style>
66
+
88
67
  {
89
68
  isEditorOpen && (
90
69
  <Overlay isVisible={true} onClose={onToggle}>
91
- <View layout="off-center" style={{ height: '100%', width: '100%' }}>
92
- <View slot="content" style={modalPanelStyles}>
70
+ <View layout="off-center" data-component="theme-editor-overlay">
71
+ <View slot="content" data-component="theme-editor-panel">
93
72
  <View surface="primary" roundness="m" gap="m" inset="l">
94
73
  <DevPagesPanel />
95
74
  <View as="form" gap="s" onSubmit={onSaveTheme} onChange={onThemeChange}>
96
75
  {Object.entries(theme).map(([name, value]) => (
97
- <div style={{ marginBottom: '16px' }}>
98
- <label style={{ display: 'block', fontFamily: 'sans-serif', marginBottom: '4px', fontWeight: 'bold' }}>{name}</label>
99
- <input value={value} data-name={name} style={{ width: '100%', padding: '4px' }}/>
76
+ <div key={name} data-component="theme-editor-field">
77
+ <label>{name}</label>
78
+ <input value={value} data-name={name} />
100
79
  </div>
101
80
  ))}
102
81
  <Button type="submit" variant="cta">
@@ -112,7 +91,13 @@ export const ThemeEditor = () => {
112
91
  )
113
92
  }
114
93
 
115
- <Button variant="cta" prefix="math-plus" style={toggleStyles} onClick={onToggle} />
94
+ <Button
95
+ variant="cta"
96
+ prefix="math-plus"
97
+ data-component="theme-editor-fab"
98
+ data-open={isEditorOpen ? 'true' : undefined}
99
+ onClick={onToggle}
100
+ />
116
101
  </>
117
102
  )
118
103
  }
@@ -0,0 +1,39 @@
1
+ import { useEffect, useRef } from 'react'
2
+ import { GetWorkspace, ListWorkspaces } from '@ossy/workspaces'
3
+ import { useSdk } from '@ossy/sdk-react'
4
+ import { patchUserAppSettings } from './patchUserAppSettings.js'
5
+
6
+ function buildPatch (workspace, workspaces) {
7
+ const patch = {}
8
+ if (workspace?.name) patch.workspaceName = workspace.name
9
+ if (workspace?.services) patch.workspaceServices = workspace.services
10
+ if (workspaces?.length) {
11
+ patch.workspaces = workspaces.map(({ id, name }) => ({ id, name }))
12
+ }
13
+ return Object.keys(patch).length ? patch : null
14
+ }
15
+
16
+ /**
17
+ * Persists workspace shell snapshot to the signed user-app-settings cookie
18
+ * (via ProxyInternal PATCH /@ossy/users/me/app-settings) so SSR bootstrap
19
+ * props stay stable across full-page navigations.
20
+ */
21
+ export function WorkspaceAppSettingsSync () {
22
+ const sdk = useSdk()
23
+ const { data: workspace } = sdk.read(GetWorkspace)
24
+ const { data: workspaces } = sdk.read(ListWorkspaces)
25
+ const lastPatchRef = useRef('')
26
+
27
+ useEffect(() => {
28
+ const patch = buildPatch(workspace, workspaces)
29
+ if (!patch) return
30
+
31
+ const serialized = JSON.stringify(patch)
32
+ if (serialized === lastPatchRef.current) return
33
+ lastPatchRef.current = serialized
34
+
35
+ patchUserAppSettings(patch).catch(() => {})
36
+ }, [workspace, workspaces])
37
+
38
+ return null
39
+ }
@@ -2,3 +2,8 @@ export * from './App.jsx'
2
2
  export * from './AppSettings.jsx'
3
3
  export { useApp, useAppSettings, AppContext } from './AppContext.js'
4
4
  export * from './ThemeEditor.jsx'
5
+ export { patchUserAppSettings } from './patchUserAppSettings.js'
6
+ export { useShellWorkspace } from './useShellWorkspace.js'
7
+ export { WorkspaceAppSettingsSync } from './WorkspaceAppSettingsSync.jsx'
8
+ export { resolveEndpoints } from './resolveEndpoints.js'
9
+ export { resolveWorkspaceServices } from './resolveWorkspaceServices.js'
@@ -0,0 +1,10 @@
1
+ export function patchUserAppSettings (partial) {
2
+ return fetch('/@ossy/users/me/app-settings', {
3
+ method: 'PATCH',
4
+ body: JSON.stringify(partial),
5
+ headers: {
6
+ 'Content-Type': 'application/json',
7
+ },
8
+ credentials: 'same-origin',
9
+ })
10
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Resolve integration endpoint URLs from app context and browser origin.
3
+ *
4
+ * @param {{ apiUrl?: string, workspaceId?: string } | null | undefined} app
5
+ * @returns {{
6
+ * apiUrl: string,
7
+ * appOrigin: string,
8
+ * actionsUrl: string,
9
+ * mcpUrl: string,
10
+ * capabilitiesUrl: string,
11
+ * workspaceId: string | undefined,
12
+ * }}
13
+ */
14
+ export function resolveEndpoints (app) {
15
+ const apiUrl = app?.apiUrl || ''
16
+ const appOrigin =
17
+ typeof window !== 'undefined' && window.location?.origin
18
+ ? window.location.origin
19
+ : ''
20
+
21
+ const normalizedApi = apiUrl.replace(/\/$/, '')
22
+ let actionsUrl = '/actions'
23
+
24
+ if (normalizedApi.startsWith('http://') || normalizedApi.startsWith('https://')) {
25
+ actionsUrl = `${normalizedApi}/actions`
26
+ } else if (normalizedApi.startsWith('/')) {
27
+ actionsUrl = appOrigin ? `${appOrigin}${normalizedApi}/actions` : `${normalizedApi}/actions`
28
+ } else if (appOrigin) {
29
+ actionsUrl = `${appOrigin}/actions`
30
+ }
31
+
32
+ const mcpUrl = appOrigin ? `${appOrigin}/mcp` : '/mcp'
33
+ const capabilitiesUrl = appOrigin ? `${appOrigin}/capabilities.json` : '/capabilities.json'
34
+
35
+ return {
36
+ apiUrl: normalizedApi,
37
+ appOrigin,
38
+ actionsUrl,
39
+ mcpUrl,
40
+ capabilitiesUrl,
41
+ workspaceId: app?.workspaceId,
42
+ }
43
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Merge live workspace services with dev-mode entitlement overrides.
3
+ *
4
+ * @param {{
5
+ * devMode?: boolean
6
+ * devEntitlements?: Record<string, { enabled?: boolean }>
7
+ * workspaceServices?: Record<string, { enabled?: boolean }>
8
+ * }} options
9
+ * @returns {Record<string, { enabled?: boolean }>}
10
+ */
11
+ export function resolveWorkspaceServices ({
12
+ devMode = false,
13
+ devEntitlements,
14
+ workspaceServices,
15
+ }) {
16
+ if (devMode && devEntitlements) {
17
+ return { ...(workspaceServices || {}), ...devEntitlements }
18
+ }
19
+ return workspaceServices || {}
20
+ }
@@ -0,0 +1,49 @@
1
+ /** Dev theme editor — FAB and modal panel using theme tokens. */
2
+ export const themeEditorStyles = `
3
+ [data-component="theme-editor-fab"] {
4
+ box-shadow: 2px 2px 5px color-mix(in srgb, var(--foreground) 20%, transparent);
5
+ border-radius: var(--space-xl);
6
+ position: fixed;
7
+ right: var(--space-m);
8
+ bottom: var(--space-m);
9
+ cursor: pointer;
10
+ transition: transform 0.5s;
11
+ z-index: 101;
12
+ padding: var(--space-m);
13
+ }
14
+
15
+ [data-component="theme-editor-fab"][data-open="true"] {
16
+ transform: rotate(-45deg);
17
+ }
18
+
19
+ [data-component="theme-editor-panel"] {
20
+ width: min(720px, 80vw);
21
+ min-width: min(480px, 100%);
22
+ max-height: 85vh;
23
+ overflow-y: auto;
24
+ overflow-x: hidden;
25
+ margin: 0 auto;
26
+ box-shadow: 2px 2px 5px color-mix(in srgb, var(--foreground) 20%, transparent);
27
+ }
28
+
29
+ [data-component="theme-editor-overlay"] {
30
+ height: 100%;
31
+ width: 100%;
32
+ }
33
+
34
+ [data-component="theme-editor-field"] {
35
+ margin-bottom: var(--space-m);
36
+ }
37
+
38
+ [data-component="theme-editor-field"] label {
39
+ display: block;
40
+ font-family: var(--text-default-font-family, sans-serif);
41
+ margin-bottom: var(--space-xs);
42
+ font-weight: 700;
43
+ }
44
+
45
+ [data-component="theme-editor-field"] input {
46
+ width: 100%;
47
+ padding: var(--space-xs);
48
+ }
49
+ `
@@ -0,0 +1,40 @@
1
+ import { useMemo } from 'react'
2
+ import { GetWorkspace, ListWorkspaces } from '@ossy/workspaces'
3
+ import { useSdk } from '@ossy/sdk-react'
4
+ import { useApp } from './AppContext.js'
5
+
6
+ /**
7
+ * Workspace list + active workspace for shell chrome.
8
+ * SSR and the first client paint use the signed user-app-settings cookie
9
+ * (merged into bootstrap props); live SDK reads refresh in the background.
10
+ */
11
+ export function useShellWorkspace () {
12
+ const app = useApp()
13
+ const sdk = useSdk()
14
+ const { data: workspaceFromSdk } = sdk.read(GetWorkspace)
15
+ const { data: workspacesFromSdk } = sdk.read(ListWorkspaces)
16
+
17
+ const workspace = useMemo(() => {
18
+ if (workspaceFromSdk) return workspaceFromSdk
19
+ if (app?.workspaceName || app?.workspaceServices || app?.workspaceId) {
20
+ return {
21
+ id: app.workspaceId,
22
+ name: app.workspaceName,
23
+ services: app.workspaceServices,
24
+ }
25
+ }
26
+ return undefined
27
+ }, [
28
+ workspaceFromSdk,
29
+ app?.workspaceId,
30
+ app?.workspaceName,
31
+ app?.workspaceServices,
32
+ ])
33
+
34
+ const workspaces = useMemo(() => {
35
+ if (workspacesFromSdk?.length) return workspacesFromSdk
36
+ return app?.workspaces ?? []
37
+ }, [workspacesFromSdk, app?.workspaces])
38
+
39
+ return { workspace, workspaces }
40
+ }
@@ -1,112 +0,0 @@
1
- /** Canonical shell slot names (namespaced). App layout maps chrome only — not content. */
2
- export const SHELL_SLOT_NAMES = [
3
- 'shell:header',
4
- 'shell:sidebar',
5
- 'shell:toolbar',
6
- 'shell:notifications',
7
- 'shell:system-messages',
8
- ]
9
-
10
- /** Platform-owned slot filled with the current route page component. */
11
- export const CONTENT_SLOT_NAME = 'shell:content'
12
-
13
- /** Bare shell region → namespaced key (legacy fallback for app components only). */
14
- const BARE_SHELL_REGION = {
15
- header: 'shell:header',
16
- sidebar: 'shell:sidebar',
17
- toolbar: 'shell:toolbar',
18
- notifications: 'shell:notifications',
19
- 'system-messages': 'shell:system-messages',
20
- content: CONTENT_SLOT_NAME,
21
- }
22
-
23
- /**
24
- * Normalize a slot map key to the canonical namespaced form when it is a bare shell region.
25
- *
26
- * @param {string} slotName
27
- * @returns {string}
28
- */
29
- export function normalizeShellSlotName (slotName) {
30
- const key = typeof slotName === 'string' ? slotName.trim() : ''
31
- return BARE_SHELL_REGION[key] ?? key
32
- }
33
-
34
- /**
35
- * Build `Record<slotName, Component>` from the app layout's static `slots` map
36
- * (`slotName → componentId`) and components loaded by `metadata.id`.
37
- *
38
- * One component per slot. Optional fallback (app components only): if the layout
39
- * map omits a slot, use a component whose id equals the slot name (e.g. `shell:header`)
40
- * or the bare region name (e.g. `header`).
41
- *
42
- * Does not resolve `shell:content` — use {@link resolvePageSlots}.
43
- *
44
- * @param {Record<string, string> | null | undefined} layoutSlotsMap
45
- * @param {Record<string, import('react').ComponentType>} componentsById
46
- * @returns {Record<string, import('react').ComponentType>}
47
- */
48
- export function resolveShellSlots (layoutSlotsMap, componentsById) {
49
- /** @type {Record<string, import('react').ComponentType>} */
50
- const resolved = {}
51
-
52
- const map = layoutSlotsMap && typeof layoutSlotsMap === 'object' ? layoutSlotsMap : {}
53
- for (const [rawSlot, componentId] of Object.entries(map)) {
54
- const slotName = normalizeShellSlotName(rawSlot)
55
- const id = typeof componentId === 'string' ? componentId.trim() : ''
56
- if (!slotName || !id || slotName === CONTENT_SLOT_NAME) continue
57
- const Component = componentsById[id]
58
- if (Component) resolved[slotName] = Component
59
- }
60
-
61
- for (const slotName of SHELL_SLOT_NAMES) {
62
- if (resolved[slotName]) continue
63
- if (componentsById[slotName]) {
64
- resolved[slotName] = componentsById[slotName]
65
- continue
66
- }
67
- const bare = slotName.slice('shell:'.length)
68
- if (componentsById[bare]) resolved[slotName] = componentsById[bare]
69
- }
70
-
71
- return resolved
72
- }
73
-
74
- const RESOURCE_SLOT_PREFIX = 'resource:'
75
-
76
- /**
77
- * Map manifest components whose `metadata.id` is a `resource:{type}/{view}` key.
78
- *
79
- * @param {Record<string, import('react').ComponentType>} componentsById
80
- * @returns {Record<string, import('react').ComponentType>}
81
- */
82
- export function resolveResourceSlots (componentsById) {
83
- /** @type {Record<string, import('react').ComponentType>} */
84
- const resolved = {}
85
- for (const [id, Component] of Object.entries(componentsById || {})) {
86
- if (typeof id === 'string' && id.startsWith(RESOURCE_SLOT_PREFIX) && Component) {
87
- resolved[id] = Component
88
- }
89
- }
90
- return resolved
91
- }
92
-
93
- /**
94
- * Full provider slot map for a page request: shell chrome, resource views, and page content.
95
- *
96
- * @param {{
97
- * layoutSlots?: Record<string, string> | null,
98
- * componentsById?: Record<string, import('react').ComponentType>,
99
- * pageComponent?: import('react').ComponentType | null,
100
- * }} options
101
- * @returns {Record<string, import('react').ComponentType>}
102
- */
103
- export function resolvePageSlots ({ layoutSlots, componentsById = {}, pageComponent = null } = {}) {
104
- const shellSlots = resolveShellSlots(layoutSlots, componentsById)
105
- const resourceSlots = resolveResourceSlots(componentsById)
106
- /** @type {Record<string, import('react').ComponentType>} */
107
- const resolved = { ...componentsById, ...shellSlots, ...resourceSlots }
108
- if (pageComponent) {
109
- resolved[CONTENT_SLOT_NAME] = pageComponent
110
- }
111
- return resolved
112
- }