@ossy/app 3.9.0 → 3.11.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.
package/README.md CHANGED
@@ -60,7 +60,7 @@ export const metadata = {
60
60
  }
61
61
  ```
62
62
 
63
- SSR emits `<meta name="description">`, `og:title`, `og:description`, `og:type=website`, and when `config.siteUrl` or `config.domain` is set, `og:url`. Root-relative `ogImage` values (e.g. `/og.png`) are absolutized against that origin. Prefer `{pageId}.metaDescription` in translation catalogs when the copy should be localized.
63
+ SSR emits `<meta name="theme-color">`, `<link rel="icon">` (default `/favicon.ico`, or `config.faviconHref`; set `faviconHref: null` to omit), `<meta name="description">`, `og:title`, `og:description`, `og:type=website`, and when `config.siteUrl` or `config.domain` is set, `og:url`. Root-relative `ogImage` values (e.g. `/og.png`) are absolutized against that origin. Prefer `{pageId}.metaDescription` in translation catalogs when the copy should be localized.
64
64
 
65
65
  **Props:** The full app config is passed as props to the page component — `url`, `theme`, `isAuthenticated`, `pages`, `workspaceId`, `apiUrl`, etc. You can also access config via `useApp()` / `useRouter()` hooks from `@ossy/connected-components` and `@ossy/router-react`.
66
66
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ossy/app",
3
- "version": "3.9.0",
3
+ "version": "3.11.0",
4
4
  "description": "",
5
5
  "source": "./src/index.js",
6
6
  "main": "./src/index.js",
@@ -49,21 +49,21 @@
49
49
  "@babel/eslint-parser": "^7.15.8",
50
50
  "@babel/preset-react": "^7.26.3",
51
51
  "@babel/register": "^7.25.9",
52
- "@ossy/authentication": "^3.5.1",
53
- "@ossy/design-system": "^3.9.0",
52
+ "@ossy/authentication": "^3.11.0",
53
+ "@ossy/design-system": "^3.11.0",
54
54
  "@ossy/locale": "^3.4.0",
55
55
  "@ossy/manifest": "^3.9.0",
56
- "@ossy/package-catalog": "^3.8.0",
56
+ "@ossy/package-catalog": "^3.11.0",
57
57
  "@ossy/pages": "^3.0.9",
58
- "@ossy/platform": "^3.9.0",
59
- "@ossy/resources": "^3.9.0",
58
+ "@ossy/platform": "^3.11.0",
59
+ "@ossy/resources": "^3.11.0",
60
60
  "@ossy/router": "^3.0.9",
61
- "@ossy/router-react": "^3.9.0",
61
+ "@ossy/router-react": "^3.11.0",
62
62
  "@ossy/schema": "^3.8.0",
63
63
  "@ossy/sdk": "^3.5.0",
64
- "@ossy/sdk-react": "^3.8.0",
64
+ "@ossy/sdk-react": "^3.11.0",
65
65
  "@ossy/themes": "^3.8.0",
66
- "@ossy/workspaces": "^3.9.0",
66
+ "@ossy/workspaces": "^3.11.0",
67
67
  "@rollup/plugin-alias": "^6.0.0",
68
68
  "@rollup/plugin-babel": "^7.1.0",
69
69
  "@rollup/plugin-commonjs": "^29.0.0",
@@ -97,5 +97,5 @@
97
97
  "README.md",
98
98
  "tsconfig.json"
99
99
  ],
100
- "gitHead": "f404be69becb27a1fd853a6ff1903554e76e7d17"
100
+ "gitHead": "53ff8456920e09151c9d88df4a6c3ee958d811eb"
101
101
  }
@@ -138,7 +138,37 @@ export function resolveOpenGraphImage (metadata = {}, props = {}) {
138
138
  }
139
139
 
140
140
  /**
141
- * Build `<head>` children for SSR: charset, viewport, title, description, Open Graph.
141
+ * Resolve favicon URL from app settings.
142
+ * Default `/favicon.ico`. Use `null` or `false` to omit the link tag.
143
+ *
144
+ * @param {{ faviconHref?: string | null | false }} props
145
+ * @returns {string | null}
146
+ */
147
+ export function resolveFaviconHref (props = {}) {
148
+ if (props.faviconHref === null || props.faviconHref === false) return null
149
+ if (typeof props.faviconHref === 'string') {
150
+ const trimmed = props.faviconHref.trim()
151
+ return trimmed || null
152
+ }
153
+ return '/favicon.ico'
154
+ }
155
+
156
+ /**
157
+ * Resolve `<meta name="theme-color">` content from app settings.
158
+ *
159
+ * @param {{ themeColor?: string }} props
160
+ * @returns {string}
161
+ */
162
+ export function resolveThemeColor (props = {}) {
163
+ if (typeof props.themeColor === 'string' && props.themeColor.trim()) {
164
+ return props.themeColor.trim()
165
+ }
166
+ return '#000000'
167
+ }
168
+
169
+ /**
170
+ * Build `<head>` children for SSR: charset, viewport, title, description, Open Graph,
171
+ * theme-color, and favicon.
142
172
  *
143
173
  * @param {{ metadata: object, props: object, viewportContent: string }} options
144
174
  * @returns {import('react').ReactNode[]}
@@ -148,14 +178,21 @@ export function buildDocumentHeadChildren ({ metadata, props, viewportContent })
148
178
  const description = resolveDocumentDescription(metadata, props)
149
179
  const ogUrl = resolveOpenGraphUrl(props)
150
180
  const ogImage = resolveOpenGraphImage(metadata, props)
181
+ const themeColor = resolveThemeColor(props)
182
+ const faviconHref = resolveFaviconHref(props)
151
183
 
152
184
  /** @type {import('react').ReactNode[]} */
153
185
  const children = [
154
186
  createElement('meta', { key: 'charset', charSet: 'utf-8' }),
155
187
  createElement('meta', { key: 'viewport', name: 'viewport', content: viewportContent }),
188
+ createElement('meta', { key: 'theme-color', name: 'theme-color', content: themeColor }),
156
189
  createElement('title', { key: 'title' }, title),
157
190
  ]
158
191
 
192
+ if (faviconHref) {
193
+ children.push(createElement('link', { key: 'favicon', rel: 'icon', href: faviconHref }))
194
+ }
195
+
159
196
  if (description) {
160
197
  children.push(createElement('meta', { key: 'description', name: 'description', content: description }))
161
198
  }
@@ -13,6 +13,8 @@ export {
13
13
  absolutizeSiteUrl,
14
14
  resolveOpenGraphUrl,
15
15
  resolveOpenGraphImage,
16
+ resolveFaviconHref,
17
+ resolveThemeColor,
16
18
  buildDocumentHeadChildren,
17
19
  } from './document-meta.js'
18
20
 
package/src/index.js CHANGED
@@ -3,5 +3,5 @@ export {
3
3
  PAGE_FILE_PATTERN,
4
4
  API_FILE_PATTERN,
5
5
  TASK_FILE_PATTERN,
6
- RESOURCE_FILE_PATTERN,
6
+ SCHEMA_FILE_PATTERN,
7
7
  } from '../cli/build.task.js'
@@ -9,6 +9,7 @@ import {
9
9
  import { useRouter } from '@ossy/router-react'
10
10
  import { languageCode } from './languageCode.js'
11
11
  import { compactShellControlStyle } from './mobileShellFocus.js'
12
+ import { metadata as SwitchLanguage } from '../shell-registry/switch-language.action.js'
12
13
 
13
14
  /**
14
15
  * Language switcher for app shell header — cycle when two locales, dropdown when more.
@@ -32,10 +33,14 @@ export function LanguageSelect ({ compact = false }) {
32
33
 
33
34
  return (
34
35
  <Button
36
+ {...SwitchLanguage}
35
37
  variant="link"
36
38
  prefix="select"
37
39
  href={getHref({ language: other })}
40
+ label={undefined}
38
41
  aria-label={pickerLabel}
42
+ data-language={other}
43
+ data-ossy-language={language}
39
44
  style={controlStyle}
40
45
  >
41
46
  {languageCode(language)}
@@ -47,9 +52,12 @@ export function LanguageSelect ({ compact = false }) {
47
52
  <Dropdown
48
53
  trigger={(
49
54
  <Button
55
+ {...SwitchLanguage}
50
56
  prefix="select"
51
57
  variant="link"
58
+ label={undefined}
52
59
  aria-label={pickerLabel}
60
+ data-ossy-language={language}
53
61
  style={controlStyle}
54
62
  >
55
63
  {languageCode(language)}
@@ -63,7 +71,10 @@ export function LanguageSelect ({ compact = false }) {
63
71
  return (
64
72
  <ContextMenu.Item
65
73
  key={lang}
74
+ {...SwitchLanguage}
66
75
  href={isActive ? undefined : getHref({ language: lang })}
76
+ label={undefined}
77
+ data-language={lang}
67
78
  aria-current={isActive ? 'true' : undefined}
68
79
  >
69
80
  {languageCode(lang)}
@@ -35,6 +35,7 @@ export {
35
35
  } from './mobileShellFocus.js'
36
36
  export { metadata as OpenMobileShellNav } from '../shell-registry/open-mobile-shell-nav.action.js'
37
37
  export { metadata as CloseMobileShellNav } from '../shell-registry/close-mobile-shell-nav.action.js'
38
+ export { metadata as SwitchLanguage } from '../shell-registry/switch-language.action.js'
38
39
  export { buildSidebarNav } from './buildSidebarNav.js'
39
40
  export { resolvePackageHomePageId } from './resolvePackageHomePageId.js'
40
41
  export { shellSlotUnset, shellSlotViewId, coerceShellSlotSpec } from '../../runtime/merge-shell-slots.js'
@@ -144,18 +144,26 @@ export const TAB_TRAP_FALLBACK = Object.freeze({ fallback: true })
144
144
  * List Tab-reachable elements inside a drawer root.
145
145
  * Skips `aria-hidden` nodes and elements that are not visible.
146
146
  *
147
+ * Do **not** pass `checkOpacity: true` to `checkVisibility`. Overlay fade-in
148
+ * animates ancestor opacity 0→1; during that window Chromium reports every
149
+ * control as not visible, so the Tab trap sees an empty list and falls back
150
+ * to re-focusing the close control (breaking Shift+Tab wrap in e2e).
151
+ *
147
152
  * @param {{ querySelectorAll?: Function } | null | undefined} root
148
153
  * @returns {Element[]}
149
154
  */
150
155
  export function listFocusable (root) {
151
156
  if (!root?.querySelectorAll) return []
157
+ const active = typeof document !== 'undefined' ? document.activeElement : null
152
158
  return [...root.querySelectorAll(FOCUSABLE_SELECTOR)].filter((el) => {
153
159
  if (el.getAttribute?.('aria-hidden') === 'true') return false
160
+ // Keep the focused control in the trap even if visibility APIs disagree
161
+ // (e.g. mid fade-in or inert/aria-hidden ancestors outside the drawer).
162
+ if (el === active) return true
154
163
  if (typeof el.checkVisibility === 'function') {
155
- return el.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })
164
+ return el.checkVisibility({ checkVisibilityCSS: true })
156
165
  }
157
- const active = typeof document !== 'undefined' ? document.activeElement : null
158
- return el.offsetParent !== null || el === active
166
+ return el.offsetParent !== null
159
167
  })
160
168
  }
161
169
 
@@ -7,6 +7,9 @@ import { useApp } from './AppContext.js'
7
7
  * Workspace list + active workspace for shell chrome.
8
8
  * SSR and the first client paint use the signed user-app-settings cookie
9
9
  * (merged into bootstrap props); live SDK reads refresh in the background.
10
+ *
11
+ * Shares the GetWorkspace / ListWorkspaces read-cache with feature pages —
12
+ * `invalidate(cacheKey(GetWorkspace))` / `refetch()` updates this hook.
10
13
  */
11
14
  export function useShellWorkspace () {
12
15
  const app = useApp()
@@ -0,0 +1,114 @@
1
+ import { metadata as OpenMobileShellNav } from './open-mobile-shell-nav.action.js'
2
+ import { metadata as CloseMobileShellNav } from './close-mobile-shell-nav.action.js'
3
+
4
+ export const metadata = {
5
+ id: '@ossy/app/flows/compact-mobile-shell',
6
+ feature: 'app-shell',
7
+ requires: ['server'],
8
+ }
9
+
10
+ export default {
11
+ title: 'Compact mobile shell navbar',
12
+ description:
13
+ 'At ≤900px the default layout hides the in-grid sidebar and opens primary nav from a header menu into a drawer (phone + iPad portrait); wider than 900px keeps desktop chrome (iPad landscape)',
14
+ steps: [
15
+ { page: '@home' },
16
+ { viewport: { width: 390, height: 844 } },
17
+ { result: { action: OpenMobileShellNav, timeout: 15000 } },
18
+ { action: OpenMobileShellNav },
19
+ { result: { selector: '[data-ossy-mobile-shell-nav]', timeout: 10000 } },
20
+ { result: { selector: '[data-ossy-sidebar-presentation="drawer"]', timeout: 5000 } },
21
+ // Open focuses the close control (SHELL-SPEC §4.5)
22
+ { result: { selector: '[data-ossy-mobile-shell-nav-close]:focus', timeout: 5000 } },
23
+ // Background inert + body scroll lock while open
24
+ { result: { selector: '[data-ossy-app-shell][aria-hidden="true"]', timeout: 5000 } },
25
+ { result: { selector: 'body[data-ossy-mobile-shell-scroll-lock]', timeout: 5000 } },
26
+ // Tab cycles inside the drawer (wrap: Shift+Tab from close → last nav; Tab → close)
27
+ { press: 'Shift+Tab' },
28
+ {
29
+ result: {
30
+ selector: '[data-ossy-mobile-shell-nav] [data-ossy-sidebar-nav-item]:focus',
31
+ timeout: 5000,
32
+ },
33
+ },
34
+ { press: 'Tab' },
35
+ { result: { selector: '[data-ossy-mobile-shell-nav-close]:focus', timeout: 5000 } },
36
+ // Escape closes the drawer (SHELL-SPEC §4.5)
37
+ { press: 'Escape' },
38
+ { result: { selector: '[data-ossy-mobile-shell-nav]', hidden: true, timeout: 5000 } },
39
+ { result: { selector: '[data-ossy-app-shell][aria-hidden="true"]', hidden: true, timeout: 5000 } },
40
+ { result: { selector: 'body[data-ossy-mobile-shell-scroll-lock]', hidden: true, timeout: 5000 } },
41
+ // Close restores focus to the menu trigger
42
+ { result: { selector: '[data-ossy-mobile-shell-nav-trigger]:focus', timeout: 5000 } },
43
+ { result: { action: OpenMobileShellNav, timeout: 10000 } },
44
+ { action: OpenMobileShellNav },
45
+ { result: { action: CloseMobileShellNav, timeout: 5000 } },
46
+ { action: CloseMobileShellNav },
47
+ { result: { action: OpenMobileShellNav, timeout: 10000 } },
48
+ // Overlay backdrop click closes the drawer (click right of the left panel)
49
+ { action: OpenMobileShellNav },
50
+ { result: { selector: '[data-ossy-mobile-shell-nav-overlay]', timeout: 10000 } },
51
+ {
52
+ click: {
53
+ selector: '[data-ossy-mobile-shell-nav-overlay]',
54
+ position: { x: 360, y: 200 },
55
+ },
56
+ },
57
+ { result: { selector: '[data-ossy-mobile-shell-nav]', hidden: true, timeout: 5000 } },
58
+ { result: { action: OpenMobileShellNav, timeout: 10000 } },
59
+ // Route change closes the drawer (SHELL-SPEC §4.5) — click a non-current sidebar link
60
+ { action: OpenMobileShellNav },
61
+ {
62
+ result: {
63
+ selector: '[data-ossy-mobile-shell-nav] [data-ossy-sidebar-nav-item]:not([aria-current="page"])',
64
+ timeout: 10000,
65
+ },
66
+ },
67
+ {
68
+ click: {
69
+ selector: '[data-ossy-mobile-shell-nav] [data-ossy-sidebar-nav-item]:not([aria-current="page"])',
70
+ },
71
+ },
72
+ { result: { selector: '[data-ossy-mobile-shell-nav]', hidden: true, timeout: 10000 } },
73
+ { result: { action: OpenMobileShellNav, timeout: 15000 } },
74
+ { page: '@home' },
75
+ { viewport: { width: 768, height: 1024 } },
76
+ { result: { action: OpenMobileShellNav, timeout: 15000 } },
77
+ { action: OpenMobileShellNav },
78
+ { result: { selector: '[data-ossy-mobile-shell-nav]', timeout: 10000 } },
79
+ { result: { selector: '[data-ossy-sidebar-presentation="drawer"]', timeout: 5000 } },
80
+ { result: { selector: '[data-ossy-app-shell][aria-hidden="true"]', timeout: 5000 } },
81
+ { result: { selector: 'body[data-ossy-mobile-shell-scroll-lock]', timeout: 5000 } },
82
+ { press: 'Escape' },
83
+ { result: { selector: '[data-ossy-mobile-shell-nav]', hidden: true, timeout: 5000 } },
84
+ { result: { action: OpenMobileShellNav, timeout: 10000 } },
85
+ { action: OpenMobileShellNav },
86
+ { result: { selector: '[data-ossy-mobile-shell-nav-overlay]', timeout: 10000 } },
87
+ {
88
+ click: {
89
+ selector: '[data-ossy-mobile-shell-nav-overlay]',
90
+ position: { x: 700, y: 200 },
91
+ },
92
+ },
93
+ { result: { selector: '[data-ossy-mobile-shell-nav]', hidden: true, timeout: 5000 } },
94
+ { result: { action: OpenMobileShellNav, timeout: 10000 } },
95
+ // iPad portrait: route change closes the drawer
96
+ { action: OpenMobileShellNav },
97
+ {
98
+ result: {
99
+ selector: '[data-ossy-mobile-shell-nav] [data-ossy-sidebar-nav-item]:not([aria-current="page"])',
100
+ timeout: 10000,
101
+ },
102
+ },
103
+ {
104
+ click: {
105
+ selector: '[data-ossy-mobile-shell-nav] [data-ossy-sidebar-nav-item]:not([aria-current="page"])',
106
+ },
107
+ },
108
+ { result: { selector: '[data-ossy-mobile-shell-nav]', hidden: true, timeout: 10000 } },
109
+ { result: { action: OpenMobileShellNav, timeout: 15000 } },
110
+ // iPad landscape (>900px): desktop chrome — no mobile menu control
111
+ { viewport: { width: 1024, height: 768 } },
112
+ { result: { action: OpenMobileShellNav, hidden: true, timeout: 10000 } },
113
+ ],
114
+ }
@@ -2,6 +2,8 @@ import React from 'react'
2
2
 
3
3
  /**
4
4
  * Default document head assets for platform shell layouts (React 19 hoisting).
5
+ * Favicon and theme-color live in SSR `buildDocumentHeadChildren` so every layout
6
+ * (including blank) gets them; this slot keeps PWA companion links only.
5
7
  */
6
8
  export function SiteDocumentHead () {
7
9
  return (
@@ -0,0 +1,7 @@
1
+ /** Client-only — navigate to the same page in another configured language. */
2
+ export const metadata = {
3
+ id: '@ossy/app/actions/switch-language',
4
+ access: 'public',
5
+ prefix: 'select',
6
+ label: 'app.shell.header.languagePicker',
7
+ }
@@ -0,0 +1,33 @@
1
+ import { metadata as SwitchLanguage } from './switch-language.action.js'
2
+
3
+ export const metadata = {
4
+ id: '@ossy/app/flows/switch-language',
5
+ feature: 'app-shell',
6
+ requires: ['server'],
7
+ }
8
+
9
+ /**
10
+ * Happy path for issue #5 — header language control switches the active page
11
+ * locale and preserves the current query string (#726).
12
+ */
13
+ export default {
14
+ title: 'Switch language',
15
+ description:
16
+ 'Visitor opens packages with a query string, switches EN→SV via the header control, then back to EN while keeping the query',
17
+ steps: [
18
+ { page: '@package-catalog/home', search: { q: 'e2e-lang' } },
19
+ { result: { url: /\/packages\/?(\?|#|$)/, timeout: 15000 } },
20
+ { result: { url: /[?&]q=e2e-lang/, timeout: 5000 } },
21
+ { result: { selector: '[data-ossy-language="en"]', timeout: 10000 } },
22
+ { result: { action: { ...SwitchLanguage, language: 'sv' }, timeout: 10000 } },
23
+ { action: { ...SwitchLanguage, language: 'sv' } },
24
+ { result: { selector: '[data-ossy-language="sv"]', timeout: 15000 } },
25
+ { result: { url: /\/paket\/?(\?|#|$)/, timeout: 15000 } },
26
+ { result: { url: /[?&]q=e2e-lang/, timeout: 5000 } },
27
+ { result: { action: { ...SwitchLanguage, language: 'en' }, timeout: 10000 } },
28
+ { action: { ...SwitchLanguage, language: 'en' } },
29
+ { result: { selector: '[data-ossy-language="en"]', timeout: 15000 } },
30
+ { result: { url: /\/packages\/?(\?|#|$)/, timeout: 15000 } },
31
+ { result: { url: /[?&]q=e2e-lang/, timeout: 5000 } },
32
+ ],
33
+ }