@ossy/app 1.40.2 → 3.0.1

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 (47) hide show
  1. package/README.md +49 -12
  2. package/cli/build.task.js +49 -10
  3. package/cli/get-platform-files.task.js +21 -15
  4. package/cli/manifest-plugin.js +340 -48
  5. package/package.json +25 -13
  6. package/runtime/merge-shell-slots.js +148 -0
  7. package/runtime/page-runtime.js +36 -18
  8. package/runtime/resolve-app-slots.js +126 -0
  9. package/src/en.translations.json +8 -0
  10. package/src/manifest/action-id-to-tool-name.js +29 -0
  11. package/src/manifest/build-action-input-schema.js +220 -0
  12. package/src/manifest/build-actions-schema.js +36 -0
  13. package/src/manifest/build-capabilities.js +190 -0
  14. package/src/manifest/build-manifest-summary.js +11 -5
  15. package/src/manifest/extract-task-catalog.js +1 -0
  16. package/src/manifest/resolve-page-layout.js +51 -0
  17. package/src/manifest/serialize-package-definition.js +2 -3
  18. package/src/manifest/template-to-json-schema.js +103 -0
  19. package/src/shell/App.jsx +18 -9
  20. package/src/shell/AppSettings.jsx +17 -0
  21. package/src/shell/DevPagesPanel.jsx +17 -13
  22. package/src/shell/HeaderAuthActions.jsx +49 -0
  23. package/src/shell/LanguageList.jsx +83 -0
  24. package/src/shell/LanguageSelect.jsx +73 -0
  25. package/src/shell/ThemeEditor.jsx +19 -34
  26. package/src/shell/ThemeSelect.jsx +101 -0
  27. package/src/shell/WorkspaceAppSettingsSync.jsx +39 -0
  28. package/src/shell/buildSidebarNav.js +113 -0
  29. package/src/shell/index.js +11 -0
  30. package/src/shell/languageCode.js +31 -0
  31. package/src/shell/patchUserAppSettings.js +10 -0
  32. package/src/shell/resolveEndpoints.js +43 -0
  33. package/src/shell/resolveWorkspaceServices.js +20 -0
  34. package/src/shell/themeEditorStyles.js +49 -0
  35. package/src/shell/useCompactShellLayout.js +24 -0
  36. package/src/shell/useShellWorkspace.js +40 -0
  37. package/src/shell-registry/blank.layout.jsx +8 -0
  38. package/src/shell-registry/default.layout.jsx +101 -0
  39. package/src/shell-registry/footer-default.component.jsx +39 -0
  40. package/src/shell-registry/head-default.component.jsx +17 -0
  41. package/src/shell-registry/header-default.component.jsx +50 -0
  42. package/src/shell-registry/logo-logomark.component.jsx +8 -0
  43. package/src/shell-registry/logo-logotype.component.jsx +15 -0
  44. package/src/shell-registry/minimal.layout.jsx +58 -0
  45. package/src/shell-registry/sidebar-default.component.jsx +281 -0
  46. package/src/sv.translations.json +8 -0
  47. package/runtime/resolve-shell-slots.js +0 -112
@@ -0,0 +1,73 @@
1
+ import React from 'react'
2
+ import {
3
+ Button,
4
+ ContextMenu,
5
+ Dropdown,
6
+ View,
7
+ useLocale,
8
+ } from '@ossy/design-system'
9
+ import { useRouter } from '@ossy/router-react'
10
+ import { languageCode } from './languageCode.js'
11
+
12
+ /**
13
+ * Language switcher for app shell header — cycle when two locales, dropdown when more.
14
+ */
15
+ export function LanguageSelect () {
16
+ const router = useRouter()
17
+ const { t } = useLocale()
18
+ const { language, supportedLanguages, getHref } = router
19
+
20
+ if (!supportedLanguages?.length || supportedLanguages.length <= 1) {
21
+ return null
22
+ }
23
+
24
+ const pickerLabel = t('app.shell.header.languagePicker') || 'Language'
25
+
26
+ if (supportedLanguages.length === 2) {
27
+ const other = supportedLanguages.find((lang) => lang !== language) ?? supportedLanguages[0]
28
+
29
+ return (
30
+ <Button
31
+ variant="link"
32
+ prefix="select"
33
+ href={getHref({ language: other })}
34
+ aria-label={pickerLabel}
35
+ style={{ flexShrink: 0 }}
36
+ >
37
+ {languageCode(language)}
38
+ </Button>
39
+ )
40
+ }
41
+
42
+ return (
43
+ <Dropdown
44
+ trigger={(
45
+ <Button
46
+ prefix="select"
47
+ variant="link"
48
+ aria-label={pickerLabel}
49
+ style={{ flexShrink: 0 }}
50
+ >
51
+ {languageCode(language)}
52
+ </Button>
53
+ )}
54
+ >
55
+ <View inset="xs" surface="primary" roundness="s">
56
+ <ContextMenu roundness="s" surface="primary">
57
+ {supportedLanguages.map((lang) => {
58
+ const isActive = lang === language
59
+ return (
60
+ <ContextMenu.Item
61
+ key={lang}
62
+ href={isActive ? undefined : getHref({ language: lang })}
63
+ aria-current={isActive ? 'true' : undefined}
64
+ >
65
+ {languageCode(lang)}
66
+ </ContextMenu.Item>
67
+ )
68
+ })}
69
+ </ContextMenu>
70
+ </View>
71
+ </Dropdown>
72
+ )
73
+ }
@@ -1,30 +1,9 @@
1
- import React, { useState, useMemo, useCallback, useEffect } from 'react'
2
- import { updateResourceContent } from '@ossy/resources'
1
+ import React, { useState, useCallback, useEffect } from 'react'
2
+ import { updateResourceContent } from '@ossy/resources/resource.helpers.js'
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 data-region="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,101 @@
1
+ import React, { useCallback } from 'react'
2
+ import {
3
+ Button,
4
+ ContextMenu,
5
+ Dropdown,
6
+ View,
7
+ useLocale,
8
+ useTheme,
9
+ } from '@ossy/design-system'
10
+ import { patchUserAppSettings } from './patchUserAppSettings.js'
11
+
12
+ const THEME_ICON_BY_NAME = {
13
+ light: 'sun',
14
+ dark: 'moon',
15
+ 'cloud-light': 'sun',
16
+ 'cloud-dark': 'moon',
17
+ }
18
+
19
+ function themeLabel (name) {
20
+ if (typeof name !== 'string' || !name) {
21
+ return 'Theme'
22
+ }
23
+ return name
24
+ .split('-')
25
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
26
+ .join(' ')
27
+ }
28
+
29
+ function themeIcon (name) {
30
+ return THEME_ICON_BY_NAME[name] || 'dark-mode'
31
+ }
32
+
33
+ /**
34
+ * Theme switcher for app shell header — cycle when two themes, dropdown when more.
35
+ */
36
+ export function ThemeSelect ({ compact = false }) {
37
+ const { themes, activeTheme, setTheme } = useTheme()
38
+ const { t } = useLocale()
39
+
40
+ const saveTheme = useCallback((themeName) => {
41
+ setTheme(themeName)
42
+ patchUserAppSettings({ theme: themeName }).catch(() => {})
43
+ }, [setTheme])
44
+
45
+ if (!themes?.length || themes.length <= 1) {
46
+ return null
47
+ }
48
+
49
+ const activeLabel = themeLabel(activeTheme)
50
+ const ariaLabel =
51
+ t('app.shell.header.themeSwitch', { theme: activeLabel })
52
+ || `${activeLabel} theme. Switch theme.`
53
+
54
+ if (themes.length === 2) {
55
+ const cycleTheme = () => {
56
+ const index = Math.max(0, themes.indexOf(activeTheme))
57
+ saveTheme(themes[(index + 1) % themes.length])
58
+ }
59
+
60
+ return (
61
+ <Button
62
+ variant="link"
63
+ prefix={themeIcon(activeTheme)}
64
+ onClick={cycleTheme}
65
+ aria-label={ariaLabel}
66
+ style={{ flexShrink: 0 }}
67
+ >
68
+ {compact ? null : activeLabel}
69
+ </Button>
70
+ )
71
+ }
72
+
73
+ return (
74
+ <Dropdown
75
+ trigger={(
76
+ <Button
77
+ prefix={compact ? themeIcon(activeTheme) : 'select'}
78
+ variant="link"
79
+ aria-label={ariaLabel}
80
+ style={{ flexShrink: 0 }}
81
+ >
82
+ {compact ? null : activeLabel}
83
+ </Button>
84
+ )}
85
+ >
86
+ <View inset="xs" surface="primary" roundness="s">
87
+ <ContextMenu roundness="s" surface="primary">
88
+ {themes.map((name) => (
89
+ <ContextMenu.Item
90
+ key={name}
91
+ onClick={() => saveTheme(name)}
92
+ aria-current={name === activeTheme ? 'true' : undefined}
93
+ >
94
+ {themeLabel(name)}
95
+ </ContextMenu.Item>
96
+ ))}
97
+ </ContextMenu>
98
+ </View>
99
+ </Dropdown>
100
+ )
101
+ }
@@ -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
+ }
@@ -0,0 +1,113 @@
1
+ import { slugToDisplayName } from '@ossy/package-catalog'
2
+ import { isServiceEntitled } from '@ossy/workspaces/entitlements'
3
+ import { resolveWorkspaceServices } from './resolveWorkspaceServices.js'
4
+
5
+ /**
6
+ * @typedef {object} SidebarShellItem
7
+ * @property {string} id
8
+ * @property {string} label
9
+ * @property {string} prefix
10
+ */
11
+
12
+ /**
13
+ * @typedef {object} SidebarPackageItem
14
+ * @property {string} slug
15
+ * @property {string} label
16
+ * @property {string} icon
17
+ * @property {string} pageId
18
+ * @property {number} order
19
+ */
20
+
21
+ /**
22
+ * Pick the package "home" page from manifest pages.
23
+ * Convention: slug plus "/home", then any page id ending in "/home", then shortest route path.
24
+ *
25
+ * @param {Array<{ id?: string, path?: string | Record<string, string> }>} pages
26
+ * @param {string} slug
27
+ * @returns {string | null}
28
+ */
29
+ export function resolvePackageHomePageId (pages, slug) {
30
+ const list = (pages || []).filter((page) => page?.id)
31
+ if (!list.length) return null
32
+
33
+ const conventional = list.find((page) => page.id === `${slug}/home`)
34
+ if (conventional) return conventional.id
35
+
36
+ const anyHome = list.find((page) => page.id === 'home' || page.id.endsWith('/home'))
37
+ if (anyHome) return anyHome.id
38
+
39
+ const pathLength = (page) => {
40
+ const path = page.path
41
+ if (typeof path === 'string') return path.length
42
+ if (path && typeof path === 'object') {
43
+ return Math.min(...Object.values(path).map((p) => (typeof p === 'string' ? p.length : 999)))
44
+ }
45
+ return 999
46
+ }
47
+
48
+ const byPath = [...list].sort((a, b) => pathLength(a) - pathLength(b) || a.id.localeCompare(b.id))
49
+ return byPath[0].id
50
+ }
51
+
52
+ /**
53
+ * Build entitlement-filtered sidebar navigation — one link per entitled package home.
54
+ *
55
+ * @param {{
56
+ * manifestSummary?: { packages?: Array<{ slug?: string, package?: string, pages?: Array<{ id: string, title?: string, path?: string | Record<string, string> }> }> }
57
+ * workspaceServices?: Record<string, { enabled?: boolean }>
58
+ * definitions?: Record<string, object>
59
+ * isAuthenticated?: boolean
60
+ * workspaceId?: string
61
+ * devMode?: boolean
62
+ * devEntitlements?: Record<string, { enabled?: boolean }>
63
+ * shellItems?: SidebarShellItem[]
64
+ * excludeSlugs?: string[] | Set<string>
65
+ * }} options
66
+ * @returns {{ shellItems: SidebarShellItem[], packageItems: SidebarPackageItem[], services: Record<string, { enabled?: boolean }> }}
67
+ */
68
+ export function buildSidebarNav ({
69
+ manifestSummary,
70
+ workspaceServices,
71
+ definitions = {},
72
+ isAuthenticated = false,
73
+ workspaceId,
74
+ devMode = false,
75
+ devEntitlements,
76
+ shellItems = [],
77
+ excludeSlugs = [],
78
+ }) {
79
+ const services = resolveWorkspaceServices({ devMode, devEntitlements, workspaceServices })
80
+ const excluded = excludeSlugs instanceof Set ? excludeSlugs : new Set(excludeSlugs)
81
+
82
+ const canShowProductGroups =
83
+ (isAuthenticated && workspaceId) ||
84
+ (devMode && devEntitlements && Object.keys(devEntitlements).length > 0)
85
+
86
+ const packageItems = !canShowProductGroups
87
+ ? []
88
+ : (manifestSummary?.packages || [])
89
+ .filter((pkg) => pkg.slug && !excluded.has(pkg.slug))
90
+ .filter((pkg) => !definitions[pkg.slug]?.status?.includes('hidden'))
91
+ .filter((pkg) => {
92
+ const def = definitions[pkg.slug]
93
+ const entitlementRequired = def?.entitlementRequired !== false
94
+ return !entitlementRequired || isServiceEntitled(services, pkg.package)
95
+ })
96
+ .map((pkg) => {
97
+ const slug = pkg.slug
98
+ const def = definitions[slug]
99
+ const pageId = resolvePackageHomePageId(pkg.pages, slug)
100
+ if (!pageId) return null
101
+ return {
102
+ slug,
103
+ label: def?.title || slugToDisplayName(slug),
104
+ icon: def?.icon || 'package',
105
+ order: def?.navOrder ?? 100,
106
+ pageId,
107
+ }
108
+ })
109
+ .filter(Boolean)
110
+ .sort((a, b) => a.order - b.order || a.label.localeCompare(b.label))
111
+
112
+ return { shellItems, packageItems, services }
113
+ }
@@ -2,3 +2,14 @@ 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'
10
+ export { useCompactShellLayout } from './useCompactShellLayout.js'
11
+ export { buildSidebarNav, resolvePackageHomePageId } from './buildSidebarNav.js'
12
+ export { shellSlotUnset, shellSlotViewId, coerceShellSlotSpec } from '../../runtime/merge-shell-slots.js'
13
+ export { ThemeSelect } from './ThemeSelect.jsx'
14
+ export { LanguageSelect } from './LanguageSelect.jsx'
15
+ export { HeaderAuthActions } from './HeaderAuthActions.jsx'
@@ -0,0 +1,31 @@
1
+ /**
2
+ * ISO 639-1 shorthand from a BCP 47 language tag (e.g. `en` → `EN`, `sv-SE` → `SV`).
3
+ *
4
+ * @param {string} tag
5
+ * @returns {string}
6
+ */
7
+ export function languageCode (tag) {
8
+ try {
9
+ return new Intl.Locale(tag).language.toUpperCase()
10
+ } catch {
11
+ const [primary] = String(tag).split('-')
12
+ return (primary || tag).toUpperCase()
13
+ }
14
+ }
15
+
16
+ /**
17
+ * Localized language name via `Intl.DisplayNames` (e.g. `en` in UI `sv` → `engelska`).
18
+ *
19
+ * @param {string} tag BCP 47 language tag to describe
20
+ * @param {string} [displayLocale] UI locale used for the label
21
+ * @returns {string}
22
+ */
23
+ export function languageDisplayName (tag, displayLocale) {
24
+ if (!tag) return ''
25
+ try {
26
+ const locale = displayLocale || tag
27
+ return new Intl.DisplayNames([locale], { type: 'language' }).of(tag) ?? tag
28
+ } catch {
29
+ return tag
30
+ }
31
+ }
@@ -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,24 @@
1
+ import { useSyncExternalStore } from 'react'
2
+
3
+ /** Viewports at or below this width use compact chrome (icon rail, tighter padding). */
4
+ const COMPACT_MEDIA_QUERY = '(max-width: 900px)'
5
+
6
+ function subscribe (onStoreChange) {
7
+ if (typeof window === 'undefined') return () => {}
8
+ const mq = window.matchMedia(COMPACT_MEDIA_QUERY)
9
+ mq.addEventListener('change', onStoreChange)
10
+ return () => mq.removeEventListener('change', onStoreChange)
11
+ }
12
+
13
+ function getSnapshot () {
14
+ if (typeof window === 'undefined') return false
15
+ return window.matchMedia(COMPACT_MEDIA_QUERY).matches
16
+ }
17
+
18
+ function getServerSnapshot () {
19
+ return false
20
+ }
21
+
22
+ export function useCompactShellLayout () {
23
+ return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
24
+ }
@@ -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
+ }
@@ -0,0 +1,8 @@
1
+ import React from 'react'
2
+ import { Slot } from '@ossy/design-system'
3
+
4
+ export const metadata = { id: '@ossy/app/layout/blank' }
5
+
6
+ export default function BlankLayout () {
7
+ return <Slot view="app:content" />
8
+ }