@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,190 @@
1
+ import { actionIdToToolName, humanizeActionId } from './action-id-to-tool-name.js'
2
+ import { buildActionInputSchema } from './build-action-input-schema.js'
3
+ import { taskIdFromActionId } from '@ossy/schema'
4
+
5
+ const MCP_ACCESS = new Set(['workspace', 'public'])
6
+
7
+ /** MCP resource URI for read-only task topology (tasks + graph edges). */
8
+ export const TASK_TOPOLOGY_RESOURCE_URI = 'ossy://capabilities/task-topology'
9
+
10
+ /**
11
+ * @param {object} task
12
+ */
13
+ export function projectTaskForCapabilities (task) {
14
+ return {
15
+ id: task.id,
16
+ package: task.package ?? null,
17
+ moduleId: task.moduleId ?? null,
18
+ triggers: task.triggers ?? [],
19
+ schedule: task.schedule ?? null,
20
+ inputSchemaIds: task.inputSchemaIds ?? [],
21
+ actionIds: task.actionIds ?? [],
22
+ primaryActionId: task.primaryActionId ?? null,
23
+ outputs: task.outputs ?? [],
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Structured trigger → task edges for agent planning (ADR 0011).
29
+ *
30
+ * @param {object[]} taskCatalog
31
+ */
32
+ export function buildCapabilitiesGraph (taskCatalog = []) {
33
+ /** @type {Array<{ kind: string, from: object, to: { taskId: string }, label?: string }>} */
34
+ const edges = []
35
+
36
+ for (const task of taskCatalog) {
37
+ for (const trigger of task.triggers ?? []) {
38
+ if (trigger.kind === 'on_action' && trigger.action) {
39
+ edges.push({
40
+ kind: 'on_action',
41
+ from: { kind: 'action', actionId: trigger.action },
42
+ to: { taskId: task.id },
43
+ label: trigger.action,
44
+ })
45
+ }
46
+ if (trigger.kind === 'on_event' && trigger.type) {
47
+ edges.push({
48
+ kind: 'on_event',
49
+ from: {
50
+ kind: 'trigger',
51
+ type: trigger.type,
52
+ ...(trigger.event ? { event: trigger.event } : {}),
53
+ },
54
+ to: { taskId: task.id },
55
+ label: [trigger.type, trigger.event].filter(Boolean).join(' · '),
56
+ })
57
+ }
58
+ if (trigger.kind === 'on_schedule' && trigger.schedule) {
59
+ edges.push({
60
+ kind: 'on_schedule',
61
+ from: { kind: 'schedule', schedule: trigger.schedule },
62
+ to: { taskId: task.id },
63
+ label: trigger.schedule,
64
+ })
65
+ }
66
+ }
67
+ if (task.primaryActionId) {
68
+ edges.push({
69
+ kind: 'primary',
70
+ from: { kind: 'action', actionId: task.primaryActionId },
71
+ to: { taskId: task.id },
72
+ label: 'invoke',
73
+ })
74
+ }
75
+ }
76
+
77
+ return { edges }
78
+ }
79
+
80
+ /**
81
+ * Build agent capabilities (MCP tools + resource template catalog) from manifest data.
82
+ *
83
+ * @param {{
84
+ * actions?: Array<{ id: string, access?: string, package?: string }>,
85
+ * tasks?: Array<{ id: string }>,
86
+ * schemas?: Array<object>,
87
+ * taskCatalog?: object[],
88
+ * taskGraphEdges?: object[],
89
+ * }} manifest
90
+ * @param {{ generatedAt?: string }} [options]
91
+ */
92
+ export function buildCapabilities (manifest, options = {}) {
93
+ const actions = manifest.actions || []
94
+ const tasks = manifest.tasks || []
95
+ const schemas = manifest.schemas || []
96
+ const taskCatalog = manifest.taskCatalog || []
97
+ const taskIds = new Set(tasks.map(t => t.id))
98
+
99
+ const tools = actions
100
+ .filter(action => {
101
+ try {
102
+ return taskIds.has(taskIdFromActionId(action.id))
103
+ } catch {
104
+ return false
105
+ }
106
+ })
107
+ .filter(action => MCP_ACCESS.has(action.access || 'authenticated'))
108
+ .map(action => ({
109
+ actionId: action.id,
110
+ name: actionIdToToolName(action.id),
111
+ title: humanizeActionId(action.id),
112
+ description: `Invoke ${action.id}`,
113
+ access: action.access || 'authenticated',
114
+ package: action.package,
115
+ channels: ['sdk', 'api', 'mcp'],
116
+ inputSchema: buildActionInputSchema(action.id, { schemas }),
117
+ }))
118
+ .sort((a, b) => a.actionId.localeCompare(b.actionId))
119
+
120
+ const projectedTasks = taskCatalog.map(projectTaskForCapabilities)
121
+
122
+ return {
123
+ version: 2,
124
+ generatedAt: options.generatedAt || new Date().toISOString(),
125
+ tools,
126
+ schemas: schemas.map(t => ({
127
+ id: t.id,
128
+ name: t.name,
129
+ package: t.package,
130
+ fields: t.fields,
131
+ })),
132
+ tasks: projectedTasks,
133
+ graph: buildCapabilitiesGraph(taskCatalog),
134
+ /** Legacy string-edge projection kept for UI parity — prefer `graph.edges`. */
135
+ taskGraphEdges: manifest.taskGraphEdges ?? [],
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Payload exposed via MCP resource {@link TASK_TOPOLOGY_RESOURCE_URI}.
141
+ *
142
+ * @param {ReturnType<typeof buildCapabilities>} capabilities
143
+ */
144
+ export function capabilitiesTaskTopologyResource (capabilities) {
145
+ return {
146
+ version: capabilities.version,
147
+ generatedAt: capabilities.generatedAt,
148
+ tasks: capabilities.tasks ?? [],
149
+ graph: capabilities.graph ?? { edges: [] },
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Legacy actions.schema.json shape for package detail / docs.
155
+ *
156
+ * @param {ReturnType<typeof buildCapabilities>} capabilities
157
+ * @param {Array<{ id: string, access?: string, package?: string }>} [allActions]
158
+ */
159
+ export function capabilitiesToActionsSchema (capabilities, allActions) {
160
+ const toolById = Object.fromEntries(capabilities.tools.map(t => [t.actionId, t]))
161
+ const actions = (allActions || capabilities.tools.map(t => ({
162
+ id: t.actionId,
163
+ access: t.access,
164
+ package: t.package,
165
+ })))
166
+
167
+ return {
168
+ version: capabilities.version,
169
+ generatedAt: capabilities.generatedAt,
170
+ actions: actions.map(action => {
171
+ const tool = toolById[action.id]
172
+ const inputSchema = tool?.inputSchema
173
+ ?? buildActionInputSchema(action.id, {
174
+ schemas: capabilities.schemas,
175
+ })
176
+ const hasKnownEnvelope = inputSchema?.properties && !inputSchema.properties.payload
177
+
178
+ return {
179
+ id: action.id,
180
+ access: action.access ?? 'authenticated',
181
+ package: action.package,
182
+ channels: tool ? tool.channels : ['sdk', 'api'],
183
+ ...(tool?.title ? { title: tool.title } : {}),
184
+ ...(tool?.description ? { description: tool.description } : {}),
185
+ ...(tool?.name ? { mcpTool: tool.name } : {}),
186
+ ...(hasKnownEnvelope ? { inputSchema } : {}),
187
+ }
188
+ }).sort((a, b) => a.id.localeCompare(b.id)),
189
+ }
190
+ }
@@ -32,7 +32,7 @@ export function buildManifestSummary (manifest) {
32
32
  apis: [],
33
33
  actions: [],
34
34
  components: [],
35
- resourceTemplates: [],
35
+ schemas: [],
36
36
  tasks: [],
37
37
  integrations: [],
38
38
  emails: [],
@@ -69,15 +69,21 @@ export function buildManifestSummary (manifest) {
69
69
  add(component.package, 'components', { id: component.id })
70
70
  }
71
71
 
72
- for (const template of manifest.resourceTemplates || []) {
73
- add(template.package, 'resourceTemplates', {
72
+ for (const template of manifest.schemas || []) {
73
+ add(template.package, 'schemas', {
74
74
  id: template.id,
75
75
  ...(template.title ? { title: template.title } : {}),
76
76
  })
77
77
  }
78
78
 
79
- for (const task of manifest.tasks || []) {
80
- add(task.package, 'tasks', { id: task.id })
79
+ for (const task of manifest.taskCatalog || manifest.tasks || []) {
80
+ add(task.package, 'tasks', {
81
+ id: task.id,
82
+ ...(task.moduleId ? { moduleId: task.moduleId } : {}),
83
+ ...(task.triggers ? { triggers: task.triggers } : {}),
84
+ ...(task.inputSchemaIds ? { inputSchemaIds: task.inputSchemaIds } : {}),
85
+ ...(task.primaryActionId ? { primaryActionId: task.primaryActionId } : {}),
86
+ })
81
87
  }
82
88
 
83
89
  for (const integration of manifest.integrations || []) {
@@ -0,0 +1 @@
1
+ export { extractTaskCatalogEntry, buildTaskCatalogEdges } from '@ossy/schema'
@@ -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
+ }
@@ -21,9 +21,8 @@ export function serializePackageDefinition (definition) {
21
21
  out.navOrder = definition.navOrder
22
22
  }
23
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())
24
+ if (Array.isArray(definition.status) && definition.status.length) {
25
+ out.status = definition.status.filter((s) => typeof s === 'string' && s.trim()).map((s) => s.trim())
27
26
  }
28
27
 
29
28
  return Object.keys(out).length ? out : null
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Map resource template field types to JSON Schema (draft-07 subset).
3
+ *
4
+ * @param {{ name: string, type: string, options?: string[] }} field
5
+ * @returns {object}
6
+ */
7
+ export function fieldToJsonSchema (field) {
8
+ const type = field?.type?.trim?.() || 'text'
9
+
10
+ switch (type) {
11
+ case 'number':
12
+ case 'timestamp':
13
+ return { type: 'number', description: `${field.name} (Unix ms for timestamp)` }
14
+ case 'boolean':
15
+ return { type: 'boolean' }
16
+ case 'select':
17
+ return {
18
+ type: 'string',
19
+ ...(Array.isArray(field.options) && field.options.length
20
+ ? { enum: field.options }
21
+ : {}),
22
+ }
23
+ case 'multiselect':
24
+ return {
25
+ type: 'array',
26
+ items: {
27
+ type: 'string',
28
+ ...(Array.isArray(field.options) && field.options.length
29
+ ? { enum: field.options }
30
+ : {}),
31
+ },
32
+ }
33
+ case 'file':
34
+ case 'image':
35
+ return {
36
+ type: 'object',
37
+ properties: { resourceId: { type: 'string' } },
38
+ required: ['resourceId'],
39
+ description: 'Reference to an uploaded binary resource',
40
+ }
41
+ case 'date-range':
42
+ return {
43
+ type: 'object',
44
+ properties: {
45
+ start: { type: 'number' },
46
+ end: { type: 'number' },
47
+ },
48
+ required: ['start', 'end'],
49
+ }
50
+ case 'date-ranges':
51
+ return {
52
+ type: 'array',
53
+ items: {
54
+ type: 'object',
55
+ properties: {
56
+ start: { type: 'number' },
57
+ end: { type: 'number' },
58
+ },
59
+ required: ['start', 'end'],
60
+ },
61
+ }
62
+ case 'email':
63
+ return { type: 'string', format: 'email' }
64
+ case 'textarea':
65
+ case 'richtext':
66
+ case 'text':
67
+ case 'date':
68
+ default:
69
+ return { type: 'string' }
70
+ }
71
+ }
72
+
73
+ /**
74
+ * @param {{ id: string, name?: string, fields?: Array<object> }} template
75
+ * @returns {object}
76
+ */
77
+ export function templateToJsonSchema (template) {
78
+ const properties = {}
79
+ for (const field of template.fields || []) {
80
+ if (!field?.name) continue
81
+ properties[field.name] = fieldToJsonSchema(field)
82
+ }
83
+
84
+ return {
85
+ title: template.id,
86
+ type: 'object',
87
+ properties,
88
+ }
89
+ }
90
+
91
+ /**
92
+ * @param {Array<{ id: string, fields?: Array<object> }>} templates
93
+ * @returns {object | undefined}
94
+ */
95
+ export function templatesContentOneOf (templates) {
96
+ const branches = (templates || [])
97
+ .filter(t => t?.id && Array.isArray(t.fields))
98
+ .map(templateToJsonSchema)
99
+
100
+ if (!branches.length) return undefined
101
+
102
+ return { oneOf: branches }
103
+ }
package/src/shell/App.jsx CHANGED
@@ -1,22 +1,26 @@
1
1
  import React from 'react'
2
2
  import { SDK } from '@ossy/sdk'
3
3
  import { WorkspaceProvider } from '@ossy/sdk-react'
4
- import { Theme, ComponentSlotsProvider, LocaleProvider } from '@ossy/design-system'
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'
7
+ import { SchemasReadBootstrap } from '@ossy/workspaces/SchemasReadBootstrap'
5
8
  import { ThemeEditor } from './ThemeEditor.jsx'
9
+ import { WorkspaceAppSettingsSync } from './WorkspaceAppSettingsSync.jsx'
6
10
  import { defaultAppSettings } from './AppSettings.jsx'
7
11
  import { Router } from '@ossy/router-react'
8
12
  import { AppContext } from './AppContext.js'
9
13
 
10
- export const App = ({ children, language, messages, fallbackMessages, ..._appSettings }) => {
14
+ export const App = ({ children, language, messages, fallbackMessages, sdk: sdkOverride, ..._appSettings }) => {
11
15
  const appSettings = { ...defaultAppSettings(), ..._appSettings }
12
16
 
13
- // `components` holds resolved ComponentType values — not JSON-serializable,
14
- // so we keep them out of AppContext and provide them via ComponentSlotsProvider.
15
- const { components, ...contextSettings } = appSettings
17
+ // `components` and `sdk` are not JSON-serializable — keep them out of AppContext.
18
+ const { components, sdk: _sdkFromSettings, ...contextSettings } = appSettings
16
19
 
17
- const sdk = SDK.of({
20
+ const sdk = sdkOverride ?? SDK.of({
18
21
  apiUrl: appSettings.apiUrl,
19
- workspaceId: appSettings.workspaceId
22
+ workspaceId: appSettings.workspaceId,
23
+ actionRoutes: appSettings.actionRoutes,
20
24
  })
21
25
 
22
26
  return (
@@ -28,9 +32,14 @@ export const App = ({ children, language, messages, fallbackMessages, ..._appSet
28
32
  supportedLanguages={appSettings.supportedLanguages}
29
33
  >
30
34
  <AppContext.Provider value={contextSettings}>
31
- <ComponentSlotsProvider slots={components || {}}>
35
+ <ComponentSlotsProvider slots={{ ...DEFAULT_FORM_FIELD_SLOTS, ...DEFAULT_SCHEMA_VIEW_SLOTS, ...DEFAULT_SCHEMA_FORM_SLOTS, ...(components || {}) }}>
32
36
  <Theme theme={appSettings.theme} themes={appSettings.themes}>
33
- <WorkspaceProvider sdk={sdk}>
37
+ <WorkspaceProvider
38
+ sdk={sdk}
39
+ enablePushInvalidation={appSettings.enablePushInvalidation === true}
40
+ >
41
+ <SchemasReadBootstrap schemas={appSettings.schemas} />
42
+ <WorkspaceAppSettingsSync />
34
43
  <Router {...appSettings} pages={appSettings.pages || []}>
35
44
  {children}
36
45
  {appSettings.devMode && <ThemeEditor />}
@@ -25,7 +25,24 @@ export function defaultAppSettings() {
25
25
  faviconHref: undefined,
26
26
  /** When true, main app sidebar is collapsed to icons (from server cookie / app-settings). */
27
27
  sidebarPrimaryCollapsed: false,
28
+ /** Cached workspace shell snapshot (from user-app-settings cookie; refreshed client-side). */
29
+ workspaceName: undefined,
30
+ workspaceServices: undefined,
31
+ workspaces: undefined,
28
32
  /** Grouped manifest entries by npm package — for dev tooling. */
29
33
  manifestSummary: undefined,
34
+ /** Merged system + workspace schemas (SSR bootstrap for client validation). */
35
+ schemas: undefined,
36
+ /** Task catalog from `app build` (ADR 0011) — for automation topology UI. */
37
+ taskCatalog: undefined,
38
+ taskGraphEdges: undefined,
39
+ /** Action id → HTTP route map for sdk.invoke transport routing. */
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,
30
47
  }
31
48
  }
@@ -57,36 +57,40 @@ export const DevPagesPanel = () => {
57
57
 
58
58
  return (
59
59
  <View gap="m">
60
- <Text variant="title-tertiary">Dev</Text>
60
+ <Text variant="heading-tertiary" text="design-system.dev.pagesPanel.title" />
61
61
 
62
62
  <View gap="xs" style={currentPageStyles}>
63
- <Text variant="title-tertiary" style={{ marginBottom: 'var(--space-xs)' }}>
64
- Current page
65
- </Text>
63
+ <Text
64
+ variant="heading-tertiary"
65
+ text="design-system.dev.pagesPanel.currentPage"
66
+ style={{ marginBottom: 'var(--space-xs)' }}
67
+ />
66
68
  <Text style={monoStyle}>
67
- <strong>id:</strong> {app?.pageId || '—'}
69
+ <strong><Text as="span" text="design-system.dev.pagesPanel.id" />:</strong> {app?.pageId || '—'}
68
70
  </Text>
69
71
  <Text style={monoStyle}>
70
- <strong>path:</strong> {currentPathLabel}
72
+ <strong><Text as="span" text="design-system.dev.pagesPanel.path" />:</strong> {currentPathLabel}
71
73
  </Text>
72
74
  <Text style={monoStyle}>
73
- <strong>url:</strong> {currentUrl}
75
+ <strong><Text as="span" text="design-system.dev.pagesPanel.url" />:</strong> {currentUrl}
74
76
  </Text>
75
77
  {packageDetailHref && currentPage?.package && (
76
78
  <Button
77
79
  variant="link"
78
80
  href={packageDetailHref}
81
+ label="design-system.dev.pagesPanel.viewPackage"
82
+ params={{ package: currentPage.package }}
79
83
  style={{ alignSelf: 'flex-start', padding: 0, height: 'auto' }}
80
- >
81
- View package ({currentPage.package})
82
- </Button>
84
+ />
83
85
  )}
84
86
  </View>
85
87
 
86
88
  <View gap="s">
87
- <Text variant="title-tertiary">
88
- Pages ({pages.length})
89
- </Text>
89
+ <Text
90
+ variant="heading-tertiary"
91
+ text="design-system.dev.pagesPanel.pages"
92
+ params={{ count: pages.length }}
93
+ />
90
94
 
91
95
  {groups.map(({ key, label, pages: groupPages }) => (
92
96
  <View key={key} gap="xs">
@@ -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
+ }