@byline/host-tanstack-start 6.7.2 → 6.9.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.
Files changed (74) hide show
  1. package/dist/admin-shell/admin-roles/container_module.css +1 -1
  2. package/dist/admin-shell/admin-roles/delete.js +1 -1
  3. package/dist/admin-shell/admin-users/container.js +181 -159
  4. package/dist/admin-shell/admin-users/container.module.js +2 -1
  5. package/dist/admin-shell/admin-users/container_module.css +5 -1
  6. package/dist/admin-shell/admin-users/delete.js +1 -1
  7. package/dist/admin-shell/chrome/admin-app-bar.d.ts +0 -7
  8. package/dist/admin-shell/collections/api.d.ts +6 -2
  9. package/dist/admin-shell/collections/api.js +109 -3
  10. package/dist/admin-shell/collections/api.test.d.ts +8 -0
  11. package/dist/admin-shell/collections/edit.js +2 -0
  12. package/dist/admin-shell/collections/preview-link.js +17 -13
  13. package/dist/admin-shell/collections/view-menu.d.ts +8 -1
  14. package/dist/admin-shell/collections/view-menu.js +124 -107
  15. package/dist/admin-shell/collections/view-menu.module.js +1 -0
  16. package/dist/admin-shell/collections/view-menu_module.css +15 -1
  17. package/dist/admin-shell/scheduled-publications/list.js +2 -2
  18. package/dist/admin-shell/singletons/preview-link.js +17 -13
  19. package/dist/admin-shell/singletons/view-menu.js +78 -63
  20. package/dist/admin-shell/singletons/view.js +2 -0
  21. package/dist/integrations/byline-admin-services.js +2 -0
  22. package/dist/integrations/passkey-admin-services.d.ts +9 -0
  23. package/dist/integrations/passkey-admin-services.js +53 -0
  24. package/dist/integrations/session-coordination.d.ts +3 -1
  25. package/dist/integrations/session-coordination.js +3 -1
  26. package/dist/integrations/session-middleware.d.ts +3 -1
  27. package/dist/integrations/session-middleware.js +87 -79
  28. package/dist/integrations/sign-in-body.d.ts +3 -0
  29. package/dist/integrations/sign-in-body.js +19 -4
  30. package/dist/integrations/sign-in-middleware.js +30 -5
  31. package/dist/server-fns/auth/passkey-admission.d.ts +24 -0
  32. package/dist/server-fns/auth/passkey-admission.js +88 -0
  33. package/dist/server-fns/auth/passkeys.d.ts +96 -0
  34. package/dist/server-fns/auth/passkeys.js +238 -0
  35. package/dist/server-fns/auth/reconcile-locale.d.ts +20 -0
  36. package/dist/server-fns/auth/reconcile-locale.js +16 -0
  37. package/dist/server-fns/auth/sign-in.js +1 -15
  38. package/dist/server-fns/collections/get.d.ts +1 -0
  39. package/dist/server-fns/collections/get.js +4 -0
  40. package/package.json +11 -11
  41. package/src/admin-shell/admin-roles/container.module.css +1 -1
  42. package/src/admin-shell/admin-roles/delete.tsx +1 -1
  43. package/src/admin-shell/admin-users/container.module.css +7 -1
  44. package/src/admin-shell/admin-users/container.tsx +22 -2
  45. package/src/admin-shell/admin-users/delete.tsx +1 -1
  46. package/src/admin-shell/chrome/admin-app-bar.tsx +2 -2
  47. package/src/admin-shell/chrome/dashboard.test.tsx +2 -2
  48. package/src/admin-shell/collections/api.test.tsx +223 -0
  49. package/src/admin-shell/collections/api.tsx +98 -3
  50. package/src/admin-shell/collections/edit.tsx +9 -1
  51. package/src/admin-shell/collections/preview-link.tsx +13 -12
  52. package/src/admin-shell/collections/view-menu.module.css +29 -3
  53. package/src/admin-shell/collections/view-menu.tsx +114 -96
  54. package/src/admin-shell/scheduled-publications/list.tsx +3 -4
  55. package/src/admin-shell/singletons/history.test.tsx +1 -1
  56. package/src/admin-shell/singletons/preview-link.tsx +13 -12
  57. package/src/admin-shell/singletons/view-menu.tsx +65 -59
  58. package/src/admin-shell/singletons/view.test.tsx +4 -4
  59. package/src/admin-shell/singletons/view.tsx +3 -1
  60. package/src/integrations/byline-admin-services.ts +3 -0
  61. package/src/integrations/passkey-admin-services.ts +85 -0
  62. package/src/integrations/session-client.test.node.ts +73 -0
  63. package/src/integrations/session-coordination.ts +3 -1
  64. package/src/integrations/session-middleware.ts +86 -72
  65. package/src/integrations/sign-in-body.test.node.ts +31 -1
  66. package/src/integrations/sign-in-body.ts +27 -3
  67. package/src/integrations/sign-in-middleware.ts +42 -9
  68. package/src/server-fns/auth/passkey-admission.test.node.ts +173 -0
  69. package/src/server-fns/auth/passkey-admission.ts +125 -0
  70. package/src/server-fns/auth/passkeys.test.node.ts +247 -0
  71. package/src/server-fns/auth/passkeys.ts +252 -0
  72. package/src/server-fns/auth/reconcile-locale.ts +40 -0
  73. package/src/server-fns/auth/sign-in.ts +1 -32
  74. package/src/server-fns/collections/get.ts +3 -0
@@ -0,0 +1,223 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import type { ComponentProps, ReactNode } from 'react'
10
+ import { act } from 'react'
11
+
12
+ import { adminTranslations } from '@byline/i18n/admin'
13
+ import { I18nProvider } from '@byline/i18n/react'
14
+ import { createRoot } from 'react-dom/client'
15
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
16
+
17
+ const mocks = vi.hoisted(() => ({ toast: vi.fn(), writeText: vi.fn() }))
18
+ vi.mock('@byline/ui/react', () => ({
19
+ Container: ({ children }: { children: ReactNode }) => <div>{children}</div>,
20
+ Section: ({ children }: { children: ReactNode }) => <section>{children}</section>,
21
+ IconButton: ({
22
+ size: _size,
23
+ variant: _variant,
24
+ ...props
25
+ }: ComponentProps<'button'> & {
26
+ size?: string
27
+ variant?: string
28
+ }) => <button {...props} />,
29
+ Tooltip: ({ children }: { children: ReactNode }) => children,
30
+ CopyIcon: () => null,
31
+ DownloadIcon: () => null,
32
+ useToastManager: () => ({ add: mocks.toast }),
33
+ }))
34
+ vi.mock('./view-menu.js', () => ({
35
+ ViewMenu: ({ dataActions }: { dataActions?: ReactNode }) => <div>{dataActions}</div>,
36
+ }))
37
+
38
+ import { ApiView } from './api.js'
39
+
40
+ const payload = {
41
+ id: 'document-1',
42
+ fields: {
43
+ title: 'Sample document',
44
+ content: [{ _type: 'text', children: [{ text: 'Hidden nested content' }] }],
45
+ related: { fields: { title: 'Populated relation' } },
46
+ },
47
+ } as ComponentProps<typeof ApiView>['initialData']
48
+
49
+ let container: HTMLDivElement
50
+ let root: ReturnType<typeof createRoot>
51
+ const originalURL = globalThis.URL
52
+ const createObjectURL = vi.fn((_blob: Blob) => 'blob:test-json')
53
+ const revokeObjectURL = vi.fn()
54
+
55
+ // `locale: null` renders as a route without a locale search param.
56
+ function render(data = payload, locale: string | null = 'en', depth = 0) {
57
+ act(() =>
58
+ root.render(
59
+ <I18nProvider
60
+ bundle={adminTranslations({ locales: ['en'] })}
61
+ activeLocale="en"
62
+ defaultLocale="en"
63
+ localeDefinitions={[{ code: 'en', nativeName: 'English' }]}
64
+ >
65
+ <ApiView
66
+ collectionDefinition={{
67
+ path: 'docs',
68
+ labels: { singular: 'Doc', plural: 'Docs' },
69
+ fields: [],
70
+ }}
71
+ initialData={data}
72
+ locale={locale ?? undefined}
73
+ depth={depth}
74
+ contentLocales={[
75
+ { code: 'en', label: 'English' },
76
+ { code: 'fr', label: 'French' },
77
+ ]}
78
+ defaultContentLocale="fr"
79
+ />
80
+ </I18nProvider>
81
+ )
82
+ )
83
+ }
84
+
85
+ async function click(label: string) {
86
+ const button = container.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`)
87
+ if (!button) throw new Error(`Button not found: ${label}`)
88
+ await act(async () => button.click())
89
+ }
90
+
91
+ function readBlob(blob: Blob): Promise<string> {
92
+ return new Promise((resolve, reject) => {
93
+ const reader = new FileReader()
94
+ reader.onload = () => resolve(String(reader.result))
95
+ reader.onerror = () => reject(reader.error)
96
+ reader.readAsText(blob)
97
+ })
98
+ }
99
+
100
+ beforeEach(() => {
101
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
102
+ vi.stubGlobal(
103
+ 'URL',
104
+ Object.assign(class extends originalURL {}, { createObjectURL, revokeObjectURL })
105
+ )
106
+ vi.stubGlobal('navigator', { clipboard: { writeText: mocks.writeText } })
107
+ mocks.writeText.mockResolvedValue(undefined)
108
+ container = document.createElement('div')
109
+ document.body.append(container)
110
+ root = createRoot(container)
111
+ })
112
+
113
+ afterEach(() => {
114
+ act(() => root.unmount())
115
+ container.remove()
116
+ vi.useRealTimers()
117
+ vi.restoreAllMocks()
118
+ vi.unstubAllGlobals()
119
+ vi.clearAllMocks()
120
+ })
121
+
122
+ describe('API document exports', () => {
123
+ it('copies the complete payload without expanding or mounting nested content', async () => {
124
+ render()
125
+ expect(container.textContent).not.toContain('Hidden nested content')
126
+ await click('Copy JSON')
127
+ expect(JSON.parse(mocks.writeText.mock.calls[0]?.[0])).toEqual(payload)
128
+ expect(container.textContent).not.toContain('Hidden nested content')
129
+ expect(mocks.toast).toHaveBeenCalledWith(expect.objectContaining({ title: 'JSON copied' }))
130
+ })
131
+
132
+ it('copies new data after a locale and population-depth reload', async () => {
133
+ render()
134
+ const reloaded = {
135
+ ...payload,
136
+ fields: { title: 'Français', related: { title: 'Relation française' } },
137
+ }
138
+ render(reloaded, 'fr', 2)
139
+ await click('Copy JSON')
140
+ expect(JSON.parse(mocks.writeText.mock.calls[0]?.[0])).toEqual(reloaded)
141
+ })
142
+
143
+ it('reports clipboard rejection without claiming success', async () => {
144
+ mocks.writeText.mockRejectedValue(new Error('Permission denied'))
145
+ render()
146
+ await click('Copy JSON')
147
+ expect(mocks.toast).toHaveBeenCalledTimes(1)
148
+ expect(mocks.toast).toHaveBeenCalledWith(
149
+ expect.objectContaining({
150
+ title: 'Could not copy JSON. Try Download JSON.',
151
+ })
152
+ )
153
+ expect(container.querySelector('button')?.disabled).toBe(false)
154
+ })
155
+
156
+ it('offers download when the clipboard API is unavailable', async () => {
157
+ vi.stubGlobal('navigator', {})
158
+ render()
159
+ await click('Copy JSON')
160
+ expect(mocks.toast).toHaveBeenCalledWith(
161
+ expect.objectContaining({
162
+ title: 'Could not copy JSON. Try Download JSON.',
163
+ })
164
+ )
165
+ expect(mocks.writeText).not.toHaveBeenCalled()
166
+ })
167
+
168
+ it('names a download after the default locale when the route has none', async () => {
169
+ vi.useFakeTimers()
170
+ let filename = ''
171
+ vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (
172
+ this: HTMLAnchorElement
173
+ ) {
174
+ filename = this.download
175
+ })
176
+ render(payload, null)
177
+ await click('Download JSON')
178
+ expect(filename).toBe('docs-document-1-fr.json')
179
+ })
180
+
181
+ it('reports a download failure and releases the allocated URL', async () => {
182
+ vi.useFakeTimers()
183
+ vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {
184
+ throw new Error('Download unavailable')
185
+ })
186
+ render()
187
+ await click('Download JSON')
188
+ expect(mocks.toast).toHaveBeenCalledWith(
189
+ expect.objectContaining({
190
+ title: 'Could not download JSON.',
191
+ })
192
+ )
193
+ expect(document.querySelector('a[download]')).toBeNull()
194
+ vi.advanceTimersByTime(1000)
195
+ expect(revokeObjectURL).toHaveBeenCalledWith('blob:test-json')
196
+ })
197
+
198
+ it('downloads complete current JSON and releases the temporary link and URL', async () => {
199
+ vi.useFakeTimers()
200
+ let filename = ''
201
+ vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (
202
+ this: HTMLAnchorElement
203
+ ) {
204
+ filename = this.download
205
+ expect(this.isConnected).toBe(true)
206
+ expect(this.href).toBe('blob:test-json')
207
+ })
208
+ render()
209
+ const reloaded = { ...payload, fields: { title: 'Français', content: payload.fields.content } }
210
+ render(reloaded, 'fr', 2)
211
+ await click('Download JSON')
212
+ const blob = createObjectURL.mock.calls[0]?.[0] as Blob
213
+ expect(blob.type).toBe('application/json;charset=utf-8')
214
+ expect(filename).toBe('docs-document-1-fr-depth2.json')
215
+ expect(document.querySelector('a[download]')).toBeNull()
216
+ expect(revokeObjectURL).not.toHaveBeenCalled()
217
+ vi.advanceTimersByTime(1000)
218
+ expect(revokeObjectURL).toHaveBeenCalledWith('blob:test-json')
219
+ vi.useRealTimers()
220
+ expect(JSON.parse(await readBlob(blob))).toEqual(reloaded)
221
+ expect(container.textContent).not.toContain('Hidden nested content')
222
+ })
223
+ })
@@ -6,10 +6,20 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
 
9
+ import { useState } from 'react'
10
+
9
11
  import type { MultiCollectionDefinition } from '@byline/core'
10
12
  import type { AnyCollectionSchemaTypes } from '@byline/core/zod-schemas'
11
13
  import { useTranslation } from '@byline/i18n/react'
12
- import { Container, Section } from '@byline/ui/react'
14
+ import {
15
+ Container,
16
+ CopyIcon,
17
+ DownloadIcon,
18
+ IconButton,
19
+ Section,
20
+ Tooltip,
21
+ useToastManager,
22
+ } from '@byline/ui/react'
13
23
  import cx from 'clsx'
14
24
  import { darkStyles, JsonView } from 'react-json-view-lite'
15
25
 
@@ -36,8 +46,12 @@ const expandToFirstLevel = (level: number): boolean => level < 2
36
46
  /**
37
47
  * JSON inspector view for a collection document.
38
48
  *
49
+ * Copy JSON and Download JSON render in the view menu's data group, beside the
50
+ * locale and depth controls whose selection they export.
51
+ *
39
52
  * Stable override handles: `.byline-api-section`, `.byline-api-container`,
40
- * `.byline-api-head`, `.byline-api-title`, `.byline-api-viewer`.
53
+ * `.byline-api-head`, `.byline-api-title`, `.byline-api-action`,
54
+ * `.byline-api-viewer`.
41
55
  */
42
56
  export const ApiView = ({
43
57
  collectionDefinition,
@@ -49,7 +63,7 @@ export const ApiView = ({
49
63
  }: {
50
64
  collectionDefinition: MultiCollectionDefinition
51
65
  initialData: AnyCollectionSchemaTypes['UpdateType']
52
- /** Currently active locale from the route search param (undefined → all). */
66
+ /** Locale from the route search param (undefined → the default content locale). */
53
67
  locale?: string
54
68
  /** Populate depth from the route search param (undefined → 0, no populate). */
55
69
  depth?: number
@@ -58,6 +72,58 @@ export const ApiView = ({
58
72
  }) => {
59
73
  const { labels, path } = collectionDefinition
60
74
  const { t } = useTranslation('byline-admin')
75
+ const toastManager = useToastManager()
76
+ const [copyPending, setCopyPending] = useState(false)
77
+
78
+ // Serialize only on demand, from the complete loaded payload rather than
79
+ // the mounted tree. Collapsed branches remain part of both exports.
80
+ const copyJson = async () => {
81
+ setCopyPending(true)
82
+ try {
83
+ await navigator.clipboard.writeText(JSON.stringify(initialData, null, 2))
84
+ toastManager.add({
85
+ title: t('collections.api.copied'),
86
+ data: { intent: 'success', iconType: 'success', icon: true, close: true },
87
+ })
88
+ } catch {
89
+ toastManager.add({
90
+ title: t('collections.api.copyFailed'),
91
+ data: { intent: 'danger', iconType: 'danger', icon: true, close: true },
92
+ })
93
+ } finally {
94
+ setCopyPending(false)
95
+ }
96
+ }
97
+
98
+ const downloadJson = () => {
99
+ try {
100
+ const blob = new Blob([JSON.stringify(initialData, null, 2)], {
101
+ type: 'application/json;charset=utf-8',
102
+ })
103
+ const url = URL.createObjectURL(blob)
104
+ const link = document.createElement('a')
105
+ try {
106
+ link.href = url
107
+ // Name the selection, so exports of one document in different locales
108
+ // or population depths do not collide.
109
+ const selection = [path, String(initialData.id), locale ?? defaultContentLocale]
110
+ if (depth != null && depth > 0) selection.push(`depth${depth}`)
111
+ const filename = selection.join('-').replace(/[^a-zA-Z0-9_-]/g, '-')
112
+ link.download = `${filename}.json`
113
+ document.body.appendChild(link)
114
+ link.click()
115
+ } finally {
116
+ link.remove()
117
+ // Let the browser acquire the download before releasing its URL.
118
+ setTimeout(() => URL.revokeObjectURL(url), 1000)
119
+ }
120
+ } catch {
121
+ toastManager.add({
122
+ title: t('collections.api.downloadFailed'),
123
+ data: { intent: 'danger', iconType: 'danger', icon: true, close: true },
124
+ })
125
+ }
126
+ }
61
127
 
62
128
  return (
63
129
  <Section className={cx('byline-api-section', styles.section)}>
@@ -74,6 +140,35 @@ export const ApiView = ({
74
140
  depth={depth}
75
141
  contentLocales={contentLocales}
76
142
  defaultContentLocale={defaultContentLocale}
143
+ dataActions={
144
+ <>
145
+ <Tooltip text={t('collections.api.copyJson')} side="bottom" delay={0}>
146
+ <IconButton
147
+ type="button"
148
+ size="xs"
149
+ variant="text"
150
+ className="byline-view-menu-icon-button byline-api-action"
151
+ aria-label={t('collections.api.copyJson')}
152
+ disabled={copyPending}
153
+ onClick={copyJson}
154
+ >
155
+ <CopyIcon width="18px" height="18px" />
156
+ </IconButton>
157
+ </Tooltip>
158
+ <Tooltip text={t('collections.api.downloadJson')} side="bottom" delay={0}>
159
+ <IconButton
160
+ type="button"
161
+ size="xs"
162
+ variant="text"
163
+ className="byline-view-menu-icon-button byline-api-action"
164
+ aria-label={t('collections.api.downloadJson')}
165
+ onClick={downloadJson}
166
+ >
167
+ <DownloadIcon width="20px" height="20px" />
168
+ </IconButton>
169
+ </Tooltip>
170
+ </>
171
+ }
77
172
  />
78
173
  </div>
79
174
  <div className={cx('byline-api-viewer', styles.viewer)}>
@@ -9,7 +9,11 @@
9
9
  import { useState } from 'react'
10
10
 
11
11
  import { FormRenderer } from '@byline/admin/react'
12
- import type { CollectionAdminConfig, MultiCollectionDefinition } from '@byline/core'
12
+ import type {
13
+ CollectionAdminConfig,
14
+ MultiCollectionDefinition,
15
+ UnavailableBlock,
16
+ } from '@byline/core'
13
17
  import {
14
18
  getDefaultStatus,
15
19
  getWorkflow,
@@ -321,6 +325,9 @@ export const EditView = ({
321
325
  // orphan row was skipped because the collection schema has moved on
322
326
  // since the document was written.
323
327
  // biome-ignore lint/suspicious/noExplicitAny: storage shape
328
+ const unavailableBlocks = (initialData as any)?._unavailableBlocks as
329
+ | UnavailableBlock[]
330
+ | undefined
324
331
  const restoreWarnings = (initialData as any)?._restoreWarnings as string[] | undefined
325
332
 
326
333
  const handleUnpublish = async () => {
@@ -854,6 +861,7 @@ export const EditView = ({
854
861
  contentLocales={contentLocales}
855
862
  publishedVersion={publishedVersion}
856
863
  restoreWarnings={restoreWarnings}
864
+ unavailableBlocks={unavailableBlocks}
857
865
  nextStatus={nextStatus}
858
866
  workflowStatuses={workflowStatuses}
859
867
  onCancel={() => {
@@ -47,7 +47,7 @@ import { useState } from 'react'
47
47
 
48
48
  import type { CollectionAdminConfig, PreviewDocument } from '@byline/core'
49
49
  import { useTranslation } from '@byline/i18n/react'
50
- import { ExternalLinkIcon, IconButton, useToastManager } from '@byline/ui/react'
50
+ import { ExternalLinkIcon, IconButton, Tooltip, useToastManager } from '@byline/ui/react'
51
51
  import cx from 'clsx'
52
52
 
53
53
  import { enablePreviewModeFn } from '../../server-fns/preview/index.js'
@@ -110,16 +110,17 @@ export const PreviewLink = ({
110
110
  }
111
111
 
112
112
  return (
113
- <IconButton
114
- className={cx('byline-preview-link', className)}
115
- size="xs"
116
- variant="text"
117
- disabled={busy}
118
- onClick={handleClick}
119
- aria-label={t('collections.preview.openAriaLabel')}
120
- title={t('collections.preview.title')}
121
- >
122
- <ExternalLinkIcon width="20px" height="20px" className="byline-preview-link-icon" />
123
- </IconButton>
113
+ <Tooltip text={t('collections.preview.title')} side="bottom" delay={0}>
114
+ <IconButton
115
+ className={cx('byline-preview-link', className)}
116
+ size="xs"
117
+ variant="text"
118
+ disabled={busy}
119
+ onClick={handleClick}
120
+ aria-label={t('collections.preview.openAriaLabel')}
121
+ >
122
+ <ExternalLinkIcon width="20px" height="20px" className="byline-preview-link-icon" />
123
+ </IconButton>
124
+ </Tooltip>
124
125
  )
125
126
  }
@@ -2,21 +2,47 @@
2
2
  * ViewMenu — mini-nav bar for document-level views (Edit, History, API).
3
3
  *
4
4
  * Override handles:
5
- * .byline-view-menu — outer flex row
5
+ * .byline-view-menu — outer row; wraps between its two groups
6
+ * .byline-view-menu-data — locale/depth selects and the data actions that
7
+ * act on them (API view: Copy / Download JSON)
8
+ * .byline-view-menu-nav — preview link and view navigation
6
9
  * .byline-view-menu-label — locale/depth label (lg-only)
7
10
  * .byline-view-menu-button — primary view buttons (Edit/API)
8
- * .byline-view-menu-icon-button — history icon button
11
+ * .byline-view-menu-icon-button — preview, history, and data-action icon buttons
9
12
  */
10
13
 
14
+ /* Groups wrap as units, so a narrow viewport breaks the row between "what
15
+ * you are looking at" and "where you can go" rather than mid-group. The
16
+ * wider column gap marks the boundary when both groups share a line. */
11
17
  .root,
12
18
  :global(.byline-view-menu) {
13
19
  display: flex;
20
+ flex-wrap: wrap;
14
21
  align-items: center;
15
- gap: 0.5rem;
22
+ gap: 0.5rem 1.25rem;
16
23
  margin-top: 0.5rem;
17
24
  margin-bottom: 0.5rem;
18
25
  }
19
26
 
27
+ /* Beside the heading (every host switches to a row at 40rem), keep a wrapped
28
+ * second line flush with the right edge rather than stranded under the
29
+ * heading's side of the menu. */
30
+ @media (min-width: 40rem) {
31
+ .root,
32
+ :global(.byline-view-menu) {
33
+ justify-content: flex-end;
34
+ }
35
+ }
36
+
37
+ .group,
38
+ :global(.byline-view-menu-data),
39
+ :global(.byline-view-menu-nav) {
40
+ display: flex;
41
+ flex-wrap: wrap;
42
+ align-items: center;
43
+ gap: 0.5rem;
44
+ }
45
+
20
46
  .label,
21
47
  :global(.byline-view-menu-label) {
22
48
  display: none;