@ossy/app 1.40.3 → 3.0.2

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 (40) hide show
  1. package/README.md +6 -0
  2. package/cli/get-platform-files.task.js +4 -6
  3. package/cli/manifest-plugin.js +111 -36
  4. package/package.json +23 -14
  5. package/runtime/merge-shell-slots.js +148 -0
  6. package/runtime/page-runtime.js +32 -16
  7. package/runtime/resolve-app-slots.js +48 -11
  8. package/src/en.translations.json +8 -0
  9. package/src/manifest/build-actions-schema.js +1 -36
  10. package/src/manifest/build-capabilities.js +1 -190
  11. package/src/manifest/build-manifest-summary.js +1 -121
  12. package/src/manifest/discover-package-definitions.js +1 -100
  13. package/src/manifest/resolve-page-layout.js +51 -0
  14. package/src/manifest/serialize-package-definition.js +1 -30
  15. package/src/shell/App.jsx +13 -7
  16. package/src/shell/AppSettings.jsx +6 -0
  17. package/src/shell/DevPagesPanel.jsx +1 -1
  18. package/src/shell/HeaderAuthActions.jsx +49 -0
  19. package/src/shell/LanguageList.jsx +83 -0
  20. package/src/shell/LanguageSelect.jsx +73 -0
  21. package/src/shell/ThemeEditor.jsx +2 -2
  22. package/src/shell/ThemeSelect.jsx +101 -0
  23. package/src/shell/buildSidebarNav.js +86 -0
  24. package/src/shell/index.js +7 -0
  25. package/src/shell/languageCode.js +31 -0
  26. package/src/shell/resolvePackageHomePageId.js +14 -0
  27. package/src/shell/useCompactShellLayout.js +24 -0
  28. package/src/shell-registry/blank.layout.jsx +8 -0
  29. package/src/shell-registry/default.layout.jsx +101 -0
  30. package/src/shell-registry/footer-default.component.jsx +39 -0
  31. package/src/shell-registry/head-default.component.jsx +17 -0
  32. package/src/shell-registry/header-default.component.jsx +50 -0
  33. package/src/shell-registry/logo-logomark.component.jsx +8 -0
  34. package/src/shell-registry/logo-logotype.component.jsx +15 -0
  35. package/src/shell-registry/minimal.layout.jsx +58 -0
  36. package/src/shell-registry/sidebar-default.component.jsx +281 -0
  37. package/src/sv.translations.json +8 -0
  38. package/src/manifest/action-id-to-tool-name.js +0 -29
  39. package/src/manifest/build-action-input-schema.js +0 -220
  40. package/src/manifest/template-to-json-schema.js +0 -103
@@ -0,0 +1,51 @@
1
+ import { mergeShellSlots } from '../../runtime/merge-shell-slots.js'
2
+
3
+ const LAYOUT_ID_PATTERN = /^@([^/]+)\/([^/]+)\/layout\/([^/]+)$/
4
+
5
+ /**
6
+ * @param {string} layoutId
7
+ * @returns {boolean}
8
+ */
9
+ export function isCanonicalLayoutId (layoutId) {
10
+ return typeof layoutId === 'string' && LAYOUT_ID_PATTERN.test(layoutId.trim())
11
+ }
12
+
13
+ /**
14
+ * Resolve layout id for a page at build time (ADR 0012).
15
+ *
16
+ * @param {{
17
+ * pageMetadata?: { layout?: string },
18
+ * appConfig?: { layout?: string },
19
+ * }} options
20
+ * @returns {string}
21
+ */
22
+ export function resolvePageLayoutId ({
23
+ pageMetadata = {},
24
+ appConfig = {},
25
+ } = {}) {
26
+ const pageLayout = pageMetadata?.layout
27
+ if (typeof pageLayout === 'string' && pageLayout.trim()) return pageLayout.trim()
28
+
29
+ const appLayout = appConfig?.layout
30
+ if (typeof appLayout === 'string' && appLayout.trim()) return appLayout.trim()
31
+
32
+ return '@ossy/app/layout/default'
33
+ }
34
+
35
+ /**
36
+ * Merge layout, app, and page slot maps for a single page render.
37
+ *
38
+ * @param {{
39
+ * layoutSlots?: Record<string, string>,
40
+ * appSlots?: Record<string, string | null>,
41
+ * pageSlots?: Record<string, string | null>,
42
+ * }} options
43
+ * @returns {Record<string, import('../../runtime/merge-shell-slots.js').ShellSlotSpec>}
44
+ */
45
+ export function resolvePageShellSlots ({
46
+ layoutSlots,
47
+ appSlots,
48
+ pageSlots,
49
+ } = {}) {
50
+ return mergeShellSlots(layoutSlots, appSlots, pageSlots)
51
+ }
@@ -1,30 +1 @@
1
- /**
2
- * Pick JSON-serializable Definition fields for manifest / SSR bootstrap.
3
- * Definition holds module presentation metadata only — capabilities live in manifestSummary.
4
- *
5
- * @param {object | null | undefined} definition
6
- * @returns {object | null}
7
- */
8
- export function serializePackageDefinition (definition) {
9
- if (!definition || typeof definition !== 'object' || Array.isArray(definition)) return null
10
-
11
- /** @type {Record<string, unknown>} */
12
- const out = {}
13
-
14
- if (typeof definition.id === 'string' && definition.id.trim()) out.id = definition.id.trim()
15
- if (typeof definition.title === 'string' && definition.title.trim()) out.title = definition.title.trim()
16
- if (typeof definition.description === 'string' && definition.description.trim()) {
17
- out.description = definition.description.trim()
18
- }
19
- if (typeof definition.icon === 'string' && definition.icon.trim()) out.icon = definition.icon.trim()
20
- if (typeof definition.navOrder === 'number' && Number.isFinite(definition.navOrder)) {
21
- out.navOrder = definition.navOrder
22
- }
23
- if (definition.entitlementRequired === false) out.entitlementRequired = false
24
- if (typeof definition.status === 'string' && definition.status.trim()) out.status = definition.status.trim()
25
- if (Array.isArray(definition.statuses) && definition.statuses.length) {
26
- out.statuses = definition.statuses.filter((s) => typeof s === 'string' && s.trim()).map((s) => s.trim())
27
- }
28
-
29
- return Object.keys(out).length ? out : null
30
- }
1
+ export * from '@ossy/manifest/serialize-package-definition'
package/src/shell/App.jsx CHANGED
@@ -2,20 +2,22 @@ import React from 'react'
2
2
  import { SDK } from '@ossy/sdk'
3
3
  import { WorkspaceProvider } from '@ossy/sdk-react'
4
4
  import { Theme, ComponentSlotsProvider, LocaleProvider, DEFAULT_FORM_FIELD_SLOTS } from '@ossy/design-system'
5
+ import { DEFAULT_SCHEMA_VIEW_SLOTS } from '@ossy/resources/schemaViewSlots.js'
6
+ import { DEFAULT_SCHEMA_FORM_SLOTS } from '@ossy/resources/schemaFormSlots.js'
5
7
  import { SchemasReadBootstrap } from '@ossy/workspaces/SchemasReadBootstrap'
6
8
  import { ThemeEditor } from './ThemeEditor.jsx'
9
+ import { WorkspaceAppSettingsSync } from './WorkspaceAppSettingsSync.jsx'
7
10
  import { defaultAppSettings } from './AppSettings.jsx'
8
11
  import { Router } from '@ossy/router-react'
9
12
  import { AppContext } from './AppContext.js'
10
13
 
11
- export const App = ({ children, language, messages, fallbackMessages, ..._appSettings }) => {
14
+ export const App = ({ children, language, messages, fallbackMessages, sdk: sdkOverride, ..._appSettings }) => {
12
15
  const appSettings = { ...defaultAppSettings(), ..._appSettings }
13
16
 
14
- // `components` holds resolved ComponentType values — not JSON-serializable,
15
- // so we keep them out of AppContext and provide them via ComponentSlotsProvider.
16
- const { components, ...contextSettings } = appSettings
17
+ // `components` and `sdk` are not JSON-serializable — keep them out of AppContext.
18
+ const { components, sdk: _sdkFromSettings, ...contextSettings } = appSettings
17
19
 
18
- const sdk = SDK.of({
20
+ const sdk = sdkOverride ?? SDK.of({
19
21
  apiUrl: appSettings.apiUrl,
20
22
  workspaceId: appSettings.workspaceId,
21
23
  actionRoutes: appSettings.actionRoutes,
@@ -30,10 +32,14 @@ export const App = ({ children, language, messages, fallbackMessages, ..._appSet
30
32
  supportedLanguages={appSettings.supportedLanguages}
31
33
  >
32
34
  <AppContext.Provider value={contextSettings}>
33
- <ComponentSlotsProvider slots={{ ...DEFAULT_FORM_FIELD_SLOTS, ...(components || {}) }}>
35
+ <ComponentSlotsProvider slots={{ ...DEFAULT_FORM_FIELD_SLOTS, ...DEFAULT_SCHEMA_VIEW_SLOTS, ...DEFAULT_SCHEMA_FORM_SLOTS, ...(components || {}) }}>
34
36
  <Theme theme={appSettings.theme} themes={appSettings.themes}>
35
- <WorkspaceProvider sdk={sdk}>
37
+ <WorkspaceProvider
38
+ sdk={sdk}
39
+ enablePushInvalidation={appSettings.enablePushInvalidation === true}
40
+ >
36
41
  <SchemasReadBootstrap schemas={appSettings.schemas} />
42
+ <WorkspaceAppSettingsSync />
37
43
  <Router {...appSettings} pages={appSettings.pages || []}>
38
44
  {children}
39
45
  {appSettings.devMode && <ThemeEditor />}
@@ -38,5 +38,11 @@ export function defaultAppSettings() {
38
38
  taskGraphEdges: undefined,
39
39
  /** Action id → HTTP route map for sdk.invoke transport routing. */
40
40
  actionRoutes: undefined,
41
+ /**
42
+ * When true, the app shell opens SSE push invalidation for every page.
43
+ * Default false — mount `PushInvalidationSubscriber` only on pages that
44
+ * need live cross-tab cache updates (see @ossy/sdk-react README).
45
+ */
46
+ enablePushInvalidation: false,
41
47
  }
42
48
  }
@@ -3,7 +3,7 @@ import { Button, Text, View } from '@ossy/design-system'
3
3
  import { useRouter } from '@ossy/router-react'
4
4
  import { useApp } from './AppContext.js'
5
5
  import { formatPagePaths, groupPagesByFeature } from './devPagesUtils.js'
6
- import { packageNameToSlug } from '../manifest/build-manifest-summary.js'
6
+ import { packageNameToSlug } from '@ossy/manifest/build-manifest-summary'
7
7
 
8
8
  const monoStyle = {
9
9
  fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
@@ -0,0 +1,49 @@
1
+ import React from 'react'
2
+ import { Button, useLocale } from '@ossy/design-system'
3
+ import { useRouter } from '@ossy/router-react'
4
+ import { OpenSignIn, OpenSignUp } from '@ossy/authentication'
5
+ import { useApp } from './AppContext.js'
6
+
7
+ /**
8
+ * Profile link when authenticated; sign-in / sign-up when not.
9
+ */
10
+ export function HeaderAuthActions ({ compact = false }) {
11
+ const app = useApp()
12
+ const router = useRouter()
13
+ const { t } = useLocale()
14
+
15
+ if (app?.isAuthenticated) {
16
+ return (
17
+ <Button
18
+ variant="link"
19
+ suffix="profile"
20
+ href={router.getHref('@profile')}
21
+ aria-label={t('app.shell.header.profile') || 'Profile'}
22
+ style={{ flexShrink: 0 }}
23
+ >
24
+ {compact ? null : (t('app.shell.header.profile') || 'Profile')}
25
+ </Button>
26
+ )
27
+ }
28
+
29
+ return (
30
+ <>
31
+ <Button
32
+ variant="link"
33
+ suffix={OpenSignIn.suffix}
34
+ href={router.getHref('@sign-in')}
35
+ label={compact ? undefined : OpenSignIn.label}
36
+ aria-label={t(OpenSignIn.label)}
37
+ style={{ flexShrink: 0 }}
38
+ />
39
+ <Button
40
+ variant="cta"
41
+ suffix={OpenSignUp.suffix}
42
+ href={router.getHref('@sign-up')}
43
+ label={compact ? undefined : OpenSignUp.label}
44
+ aria-label={t(OpenSignUp.label)}
45
+ style={{ flexShrink: 0 }}
46
+ />
47
+ </>
48
+ )
49
+ }
@@ -0,0 +1,83 @@
1
+ import React, { useMemo } from 'react'
2
+ import { Text, View, useLocale } from '@ossy/design-system'
3
+ import { useRouter } from '@ossy/router-react'
4
+ import { languageDisplayName } from './languageCode.js'
5
+
6
+ const listItemStyle = {
7
+ display: 'block',
8
+ width: '100%',
9
+ textAlign: 'left',
10
+ }
11
+
12
+ /**
13
+ * Footer language column when multiple locales are configured.
14
+ */
15
+ export function LanguageList () {
16
+ const router = useRouter()
17
+ const { t } = useLocale()
18
+ const { language, supportedLanguages, getHref } = router
19
+
20
+ const displayNames = useMemo(
21
+ () => new Intl.DisplayNames([language], { type: 'language' }),
22
+ [language],
23
+ )
24
+
25
+ if (!supportedLanguages?.length || supportedLanguages.length <= 1) {
26
+ return null
27
+ }
28
+
29
+ const listLabel = t('app.shell.footer.languages') || 'Languages'
30
+
31
+ return (
32
+ <View layout="column" gap="s" alignItems="flex-start" style={{ minWidth: 0 }}>
33
+ <Text variant="heading-tertiary" as="h2" style={listItemStyle}>
34
+ {listLabel}
35
+ </Text>
36
+ <View
37
+ as="nav"
38
+ aria-label={listLabel}
39
+ style={{ width: '100%' }}
40
+ >
41
+ <View
42
+ as="ul"
43
+ layout="column"
44
+ gap="xs"
45
+ alignItems="flex-start"
46
+ style={{ listStyle: 'none', margin: 0, padding: 0, width: '100%' }}
47
+ >
48
+ {supportedLanguages.map((lang) => {
49
+ const isActive = lang === language
50
+ const label = displayNames.of(lang) ?? languageDisplayName(lang, language)
51
+
52
+ if (isActive) {
53
+ return (
54
+ <View as="li" key={lang} style={{ width: '100%' }}>
55
+ <Text variant="small" as="span" aria-current="true" style={listItemStyle}>
56
+ {label}
57
+ </Text>
58
+ </View>
59
+ )
60
+ }
61
+
62
+ return (
63
+ <View as="li" key={lang} style={{ width: '100%' }}>
64
+ <Text
65
+ variant="small"
66
+ as="a"
67
+ href={getHref({ language: lang })}
68
+ style={{
69
+ ...listItemStyle,
70
+ textDecoration: 'underline',
71
+ cursor: 'pointer',
72
+ }}
73
+ >
74
+ {label}
75
+ </Text>
76
+ </View>
77
+ )
78
+ })}
79
+ </View>
80
+ </View>
81
+ </View>
82
+ )
83
+ }
@@ -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,5 +1,5 @@
1
1
  import React, { useState, useCallback, useEffect } from 'react'
2
- import { updateResourceContent } from '@ossy/resources'
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'
@@ -68,7 +68,7 @@ export const ThemeEditor = () => {
68
68
  isEditorOpen && (
69
69
  <Overlay isVisible={true} onClose={onToggle}>
70
70
  <View layout="off-center" data-component="theme-editor-overlay">
71
- <View slot="content" data-component="theme-editor-panel">
71
+ <View data-region="content" data-component="theme-editor-panel">
72
72
  <View surface="primary" roundness="m" gap="m" inset="l">
73
73
  <DevPagesPanel />
74
74
  <View as="form" gap="s" onSubmit={onSaveTheme} onChange={onThemeChange}>
@@ -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,86 @@
1
+ import { slugToDisplayName } from '@ossy/package-catalog'
2
+ import { isServiceEntitled } from '@ossy/workspaces/entitlements'
3
+ import { resolveWorkspaceServices } from './resolveWorkspaceServices.js'
4
+ import { resolvePackageHomePageId } from './resolvePackageHomePageId.js'
5
+
6
+ export { resolvePackageHomePageId }
7
+
8
+ /**
9
+ * @typedef {object} SidebarShellItem
10
+ * @property {string} id
11
+ * @property {string} label
12
+ * @property {string} prefix
13
+ */
14
+
15
+ /**
16
+ * @typedef {object} SidebarPackageItem
17
+ * @property {string} slug
18
+ * @property {string} label
19
+ * @property {string} icon
20
+ * @property {string} pageId
21
+ * @property {number} order
22
+ */
23
+
24
+ /**
25
+ * Build entitlement-filtered sidebar navigation — one link per entitled package
26
+ * that registers a `{slug}/home` page.
27
+ *
28
+ * @param {{
29
+ * manifestSummary?: { packages?: Array<{ slug?: string, package?: string, pages?: Array<{ id: string, title?: string, path?: string | Record<string, string> }> }> }
30
+ * workspaceServices?: Record<string, { enabled?: boolean }>
31
+ * definitions?: Record<string, object>
32
+ * isAuthenticated?: boolean
33
+ * workspaceId?: string
34
+ * devMode?: boolean
35
+ * devEntitlements?: Record<string, { enabled?: boolean }>
36
+ * shellItems?: SidebarShellItem[]
37
+ * excludeSlugs?: string[] | Set<string>
38
+ * }} options
39
+ * @returns {{ shellItems: SidebarShellItem[], packageItems: SidebarPackageItem[], services: Record<string, { enabled?: boolean }> }}
40
+ */
41
+ export function buildSidebarNav ({
42
+ manifestSummary,
43
+ workspaceServices,
44
+ definitions = {},
45
+ isAuthenticated = false,
46
+ workspaceId,
47
+ devMode = false,
48
+ devEntitlements,
49
+ shellItems = [],
50
+ excludeSlugs = [],
51
+ }) {
52
+ const services = resolveWorkspaceServices({ devMode, devEntitlements, workspaceServices })
53
+ const excluded = excludeSlugs instanceof Set ? excludeSlugs : new Set(excludeSlugs)
54
+
55
+ const canShowProductGroups =
56
+ (isAuthenticated && workspaceId) ||
57
+ (devMode && devEntitlements && Object.keys(devEntitlements).length > 0)
58
+
59
+ const packageItems = !canShowProductGroups
60
+ ? []
61
+ : (manifestSummary?.packages || [])
62
+ .filter((pkg) => pkg.slug && !excluded.has(pkg.slug))
63
+ .filter((pkg) => !definitions[pkg.slug]?.status?.includes('hidden'))
64
+ .filter((pkg) => {
65
+ const def = definitions[pkg.slug]
66
+ const entitlementRequired = def?.entitlementRequired !== false
67
+ return !entitlementRequired || isServiceEntitled(services, pkg.package)
68
+ })
69
+ .map((pkg) => {
70
+ const slug = pkg.slug
71
+ const def = definitions[slug]
72
+ const pageId = resolvePackageHomePageId(pkg.pages, slug)
73
+ if (!pageId) return null
74
+ return {
75
+ slug,
76
+ label: def?.title || slugToDisplayName(slug),
77
+ icon: def?.icon || 'package',
78
+ order: def?.navOrder ?? 100,
79
+ pageId,
80
+ }
81
+ })
82
+ .filter(Boolean)
83
+ .sort((a, b) => a.order - b.order || a.label.localeCompare(b.label))
84
+
85
+ return { shellItems, packageItems, services }
86
+ }
@@ -7,3 +7,10 @@ export { useShellWorkspace } from './useShellWorkspace.js'
7
7
  export { WorkspaceAppSettingsSync } from './WorkspaceAppSettingsSync.jsx'
8
8
  export { resolveEndpoints } from './resolveEndpoints.js'
9
9
  export { resolveWorkspaceServices } from './resolveWorkspaceServices.js'
10
+ export { useCompactShellLayout } from './useCompactShellLayout.js'
11
+ export { buildSidebarNav } from './buildSidebarNav.js'
12
+ export { resolvePackageHomePageId } from './resolvePackageHomePageId.js'
13
+ export { shellSlotUnset, shellSlotViewId, coerceShellSlotSpec } from '../../runtime/merge-shell-slots.js'
14
+ export { ThemeSelect } from './ThemeSelect.jsx'
15
+ export { LanguageSelect } from './LanguageSelect.jsx'
16
+ 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,14 @@
1
+ /**
2
+ * Pick the package home page for sidebar nav.
3
+ * Only `{slug}/home` qualifies — no fallback to other pages.
4
+ *
5
+ * @param {Array<{ id?: string }> | undefined} pages
6
+ * @param {string} slug
7
+ * @returns {string | null}
8
+ */
9
+ export function resolvePackageHomePageId (pages, slug) {
10
+ if (!slug) return null
11
+ const homeId = `${slug}/home`
12
+ const match = (pages || []).find((page) => page?.id === homeId)
13
+ return match ? homeId : null
14
+ }
@@ -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,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
+ }