@flamingo-stack/openframe-frontend-core 0.0.423 → 0.0.424-snapshot.20260714200421

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 (134) hide show
  1. package/dist/{chunk-MSZ4WBCD.cjs → chunk-5DAVSIXQ.cjs} +21 -21
  2. package/dist/{chunk-MSZ4WBCD.cjs.map → chunk-5DAVSIXQ.cjs.map} +1 -1
  3. package/dist/{chunk-WWS3R3UD.js → chunk-5RFLFMCS.js} +4 -4
  4. package/dist/{chunk-4XLJWX2N.js → chunk-7EUIW46X.js} +2 -2
  5. package/dist/{chunk-JPZE2P4Y.js → chunk-7OMNZR2A.js} +6 -6
  6. package/dist/{chunk-7W4474XS.js → chunk-7TA6WMMV.js} +5 -5
  7. package/dist/{chunk-KNO5CB3W.cjs → chunk-BESGMTIN.cjs} +7 -7
  8. package/dist/{chunk-KNO5CB3W.cjs.map → chunk-BESGMTIN.cjs.map} +1 -1
  9. package/dist/{chunk-YOEQWHUD.js → chunk-BY3NKTNZ.js} +2 -2
  10. package/dist/{chunk-VFKQMAUF.cjs → chunk-C5RS3QLV.cjs} +3 -3
  11. package/dist/{chunk-VFKQMAUF.cjs.map → chunk-C5RS3QLV.cjs.map} +1 -1
  12. package/dist/{chunk-WOHMJJKD.cjs → chunk-C7AXZBFC.cjs} +29 -29
  13. package/dist/{chunk-WOHMJJKD.cjs.map → chunk-C7AXZBFC.cjs.map} +1 -1
  14. package/dist/{chunk-LK3KXZIN.js → chunk-CRPQEP7D.js} +3 -3
  15. package/dist/{chunk-ZKIYCRVQ.cjs → chunk-EE5NH5SH.cjs} +3 -3
  16. package/dist/{chunk-ZKIYCRVQ.cjs.map → chunk-EE5NH5SH.cjs.map} +1 -1
  17. package/dist/{chunk-SVUDKNS7.js → chunk-FBCN76VE.js} +3 -3
  18. package/dist/{chunk-IRIPRXO5.cjs → chunk-GA6MWC56.cjs} +14 -14
  19. package/dist/{chunk-IRIPRXO5.cjs.map → chunk-GA6MWC56.cjs.map} +1 -1
  20. package/dist/{chunk-FVI2ESVQ.cjs → chunk-HNRGQCTE.cjs} +112 -87
  21. package/dist/chunk-HNRGQCTE.cjs.map +1 -0
  22. package/dist/{chunk-B2255TIZ.js → chunk-IQW3LEUV.js} +29 -4
  23. package/dist/chunk-IQW3LEUV.js.map +1 -0
  24. package/dist/{chunk-GR2CMONR.js → chunk-LHWLXA4X.js} +7 -7
  25. package/dist/{chunk-2Q2SKYCZ.js → chunk-LKMUH4IO.js} +6 -6
  26. package/dist/{chunk-WO2NOBF3.cjs → chunk-LRDVU7NB.cjs} +43 -43
  27. package/dist/{chunk-WO2NOBF3.cjs.map → chunk-LRDVU7NB.cjs.map} +1 -1
  28. package/dist/{chunk-SR6LEWCF.cjs → chunk-N2TJ3HAM.cjs} +10 -10
  29. package/dist/{chunk-SR6LEWCF.cjs.map → chunk-N2TJ3HAM.cjs.map} +1 -1
  30. package/dist/{chunk-FFCGB73K.js → chunk-QXYOGJQJ.js} +2 -2
  31. package/dist/{chunk-NNF5XOMH.js → chunk-RAGHFNE7.js} +2 -2
  32. package/dist/{chunk-OTNL36LC.cjs → chunk-RUPCSQLF.cjs} +40 -40
  33. package/dist/{chunk-OTNL36LC.cjs.map → chunk-RUPCSQLF.cjs.map} +1 -1
  34. package/dist/{chunk-OMUCATVA.js → chunk-RVWC4P3H.js} +2 -2
  35. package/dist/{chunk-TIFJIHV2.cjs → chunk-SHDV5QCI.cjs} +77 -77
  36. package/dist/{chunk-TIFJIHV2.cjs.map → chunk-SHDV5QCI.cjs.map} +1 -1
  37. package/dist/{chunk-CBY3VQKJ.cjs → chunk-SPR4MJX5.cjs} +86 -86
  38. package/dist/{chunk-CBY3VQKJ.cjs.map → chunk-SPR4MJX5.cjs.map} +1 -1
  39. package/dist/{chunk-G7UE6RKV.cjs → chunk-SSJNN5BS.cjs} +20 -3
  40. package/dist/chunk-SSJNN5BS.cjs.map +1 -0
  41. package/dist/{chunk-HGBEKYXL.cjs → chunk-SZWVVAZ6.cjs} +5 -5
  42. package/dist/{chunk-HGBEKYXL.cjs.map → chunk-SZWVVAZ6.cjs.map} +1 -1
  43. package/dist/{chunk-TMSP5FYV.cjs → chunk-THVQWRV6.cjs} +36 -36
  44. package/dist/{chunk-TMSP5FYV.cjs.map → chunk-THVQWRV6.cjs.map} +1 -1
  45. package/dist/{chunk-SMUKHJJM.cjs → chunk-UDRJ6KOT.cjs} +27 -27
  46. package/dist/{chunk-SMUKHJJM.cjs.map → chunk-UDRJ6KOT.cjs.map} +1 -1
  47. package/dist/{chunk-I4YIWY3O.js → chunk-VR3VNXUT.js} +7 -7
  48. package/dist/{chunk-MOBZMJAP.js → chunk-ZGE4KMI6.js} +3 -3
  49. package/dist/{chunk-PLJLE4A4.js → chunk-ZJJERZUA.js} +20 -3
  50. package/dist/chunk-ZJJERZUA.js.map +1 -0
  51. package/dist/components/case-studies/index.cjs +11 -11
  52. package/dist/components/case-studies/index.js +5 -5
  53. package/dist/components/chat/index.cjs +5 -5
  54. package/dist/components/chat/index.js +4 -4
  55. package/dist/components/contact/index.cjs +6 -6
  56. package/dist/components/contact/index.js +5 -5
  57. package/dist/components/docs/index.cjs +8 -8
  58. package/dist/components/docs/index.js +7 -7
  59. package/dist/components/embeds/index.cjs +6 -6
  60. package/dist/components/embeds/index.js +5 -5
  61. package/dist/components/faq/index.cjs +7 -7
  62. package/dist/components/faq/index.js +6 -6
  63. package/dist/components/features/index.cjs +5 -5
  64. package/dist/components/features/index.js +4 -4
  65. package/dist/components/features/seo-editor-preview.d.ts.map +1 -1
  66. package/dist/components/help-center-pages/index.cjs +24 -24
  67. package/dist/components/help-center-pages/index.js +15 -15
  68. package/dist/components/index.cjs +141 -141
  69. package/dist/components/index.js +13 -13
  70. package/dist/components/navigation/index.cjs +5 -5
  71. package/dist/components/navigation/index.js +4 -4
  72. package/dist/components/onboarding-guides/index.cjs +9 -9
  73. package/dist/components/onboarding-guides/index.js +8 -8
  74. package/dist/components/related-content/index.cjs +7 -7
  75. package/dist/components/related-content/index.js +6 -6
  76. package/dist/components/tickets/index.cjs +8 -8
  77. package/dist/components/tickets/index.js +7 -7
  78. package/dist/components/ui/index.cjs +5 -5
  79. package/dist/components/ui/index.js +4 -4
  80. package/dist/embed-shims/index.cjs +3 -3
  81. package/dist/embed-shims/index.js +2 -2
  82. package/dist/embed-shims/next-navigation.cjs +3 -3
  83. package/dist/embed-shims/next-navigation.d.ts.map +1 -1
  84. package/dist/embed-shims/next-navigation.js +1 -1
  85. package/dist/hooks/index.cjs +3 -3
  86. package/dist/hooks/index.js +2 -2
  87. package/dist/index.cjs +7 -5
  88. package/dist/index.cjs.map +1 -1
  89. package/dist/index.js +6 -4
  90. package/dist/utils/index.cjs +4 -0
  91. package/dist/utils/index.cjs.map +1 -1
  92. package/dist/utils/index.d.ts +1 -0
  93. package/dist/utils/index.d.ts.map +1 -1
  94. package/dist/utils/index.js +4 -1
  95. package/dist/utils/index.js.map +1 -1
  96. package/dist/utils/seo-description.d.ts +14 -0
  97. package/dist/utils/seo-description.d.ts.map +1 -0
  98. package/package.json +1 -1
  99. package/src/components/.tool-icon.md +3 -3
  100. package/src/components/features/seo-editor-preview.tsx +24 -1
  101. package/src/components/icons-v2-generated/brand-logos/.index.md +1 -2
  102. package/src/components/platform/.ShellTypeBadge.md +1 -1
  103. package/src/components/platform/.ToolBadge.md +2 -2
  104. package/src/embed-shims/__tests__/next-navigation.test.ts +165 -0
  105. package/src/embed-shims/next-navigation.tsx +46 -9
  106. package/src/stories/.OSTypeBadge.stories.md +1 -1
  107. package/src/stories/.ShellTypeBadge.stories.md +1 -1
  108. package/src/stories/.Toaster.stories.md +1 -1
  109. package/src/stories/.ToolBadge.stories.md +2 -2
  110. package/src/stories/.ToolExecutionDisplay.stories.md +4 -4
  111. package/src/types/.os.types.md +1 -1
  112. package/src/types/.shell.types.md +1 -1
  113. package/src/types/.tool.types.md +3 -3
  114. package/src/utils/.tool-utils.md +1 -3
  115. package/src/utils/index.ts +1 -0
  116. package/src/utils/seo-description.ts +13 -0
  117. package/dist/chunk-B2255TIZ.js.map +0 -1
  118. package/dist/chunk-FVI2ESVQ.cjs.map +0 -1
  119. package/dist/chunk-G7UE6RKV.cjs.map +0 -1
  120. package/dist/chunk-PLJLE4A4.js.map +0 -1
  121. /package/dist/{chunk-WWS3R3UD.js.map → chunk-5RFLFMCS.js.map} +0 -0
  122. /package/dist/{chunk-4XLJWX2N.js.map → chunk-7EUIW46X.js.map} +0 -0
  123. /package/dist/{chunk-JPZE2P4Y.js.map → chunk-7OMNZR2A.js.map} +0 -0
  124. /package/dist/{chunk-7W4474XS.js.map → chunk-7TA6WMMV.js.map} +0 -0
  125. /package/dist/{chunk-YOEQWHUD.js.map → chunk-BY3NKTNZ.js.map} +0 -0
  126. /package/dist/{chunk-LK3KXZIN.js.map → chunk-CRPQEP7D.js.map} +0 -0
  127. /package/dist/{chunk-SVUDKNS7.js.map → chunk-FBCN76VE.js.map} +0 -0
  128. /package/dist/{chunk-GR2CMONR.js.map → chunk-LHWLXA4X.js.map} +0 -0
  129. /package/dist/{chunk-2Q2SKYCZ.js.map → chunk-LKMUH4IO.js.map} +0 -0
  130. /package/dist/{chunk-FFCGB73K.js.map → chunk-QXYOGJQJ.js.map} +0 -0
  131. /package/dist/{chunk-NNF5XOMH.js.map → chunk-RAGHFNE7.js.map} +0 -0
  132. /package/dist/{chunk-OMUCATVA.js.map → chunk-RVWC4P3H.js.map} +0 -0
  133. /package/dist/{chunk-I4YIWY3O.js.map → chunk-VR3VNXUT.js.map} +0 -0
  134. /package/dist/{chunk-MOBZMJAP.js.map → chunk-ZGE4KMI6.js.map} +0 -0
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Single source of truth for the `seo_description` length budget.
3
+ *
4
+ * Unlike `seo_title` (soft search-engine guidance), this is a HARD cap: the
5
+ * `seo_description` DB columns are `varchar(160)` and Postgres rejects longer
6
+ * values outright ("value too long for type character varying(160)").
7
+ *
8
+ * This number lives in exactly ONE place. Consumers:
9
+ * - `SEOEditorPreview` — input `maxLength` + the too-long alert.
10
+ * - the hub — imports it (via `@flamingo-stack/openframe-frontend-core/utils`)
11
+ * for the AI generation prompt guidance and the rewrite-to-fit helper.
12
+ */
13
+ export declare const SEO_DESCRIPTION_MAX_LENGTH = 160;
14
+ //# sourceMappingURL=seo-description.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"seo-description.d.ts","sourceRoot":"","sources":["../../src/utils/seo-description.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,0BAA0B,MAAM,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flamingo-stack/openframe-frontend-core",
3
- "version": "0.0.423",
3
+ "version": "0.0.424-snapshot.20260714200421",
4
4
  "description": "Shared design system and components for all Flamingo platforms",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,5 +1,5 @@
1
1
  <!-- source-hash: 2db0b6673e5d8f02f2a3713fb6528d5d -->
2
- Renders the appropriate logo icon for a given MSP tool type (Fleet MDM, MeshCentral, TacticalRMM, Authentik, Osquery, or OpenFrame variants).
2
+ Renders the appropriate logo icon for a given MSP tool type (Fleet MDM, MeshCentral, Authentik, Osquery, or OpenFrame variants).
3
3
 
4
4
  ## Key Components
5
5
 
@@ -25,9 +25,9 @@ import { ToolTypeValues } from "../types/tool.types";
25
25
  // Render a Fleet MDM icon at default size (16px)
26
26
  <ToolIcon toolType={ToolTypeValues.FLEET_MDM} />
27
27
 
28
- // Render a TacticalRMM icon at 24px with a custom class
28
+ // Render a MeshCentral icon at 24px with a custom class
29
29
  <ToolIcon
30
- toolType={ToolTypeValues.TACTICAL_RMM}
30
+ toolType={ToolTypeValues.MESHCENTRAL}
31
31
  size={24}
32
32
  className="h-6 w-6 text-accent"
33
33
  />
@@ -10,6 +10,7 @@ import Image from '../../embed-shims/next-image';
10
10
  // SSOT for the field cap (server-safe constant). The seo_title renders as the
11
11
  // page <title> verbatim (no brand suffix), so this is the full ~60-char budget.
12
12
  import { SEO_TITLE_MAX_LENGTH } from '../../utils/seo-title';
13
+ import { SEO_DESCRIPTION_MAX_LENGTH } from '../../utils/seo-description';
13
14
 
14
15
  export interface SEOEditorPreviewProps {
15
16
  // SEO fields - must be strings (not undefined)
@@ -76,6 +77,11 @@ export function SEOEditorPreview({
76
77
  const seoTitleLength = (seoTitle || '').length;
77
78
  const seoTitleTooLong = seoTitleLength > SEO_TITLE_MAX_LENGTH;
78
79
 
80
+ // SEO description length state — the column is varchar(160); flag over-length so
81
+ // the editor (or a programmatically-set value) can be brought within budget.
82
+ const seoDescriptionLength = (seoDescription || '').length;
83
+ const seoDescriptionTooLong = seoDescriptionLength > SEO_DESCRIPTION_MAX_LENGTH;
84
+
79
85
  // Use fallback values if OG fields are empty
80
86
  const displayTitle = seoTitle.trim() || title || 'Untitled';
81
87
  const displayDescription = seoDescription.trim() || summary || 'No description';
@@ -137,7 +143,7 @@ export function SEOEditorPreview({
137
143
  <div className="flex items-center justify-between gap-2">
138
144
  <span className="text-[11px] text-ods-error font-['DM_Sans']">
139
145
  {seoTitleTooLong
140
- ? `Too long search engines may truncate this title (keep it ${SEO_TITLE_MAX_LENGTH})`
146
+ ? `Too long: search engines may truncate this title (keep it under ${SEO_TITLE_MAX_LENGTH})`
141
147
  : ''}
142
148
  </span>
143
149
  <span
@@ -206,10 +212,27 @@ export function SEOEditorPreview({
206
212
  value={seoDescription || ''}
207
213
  onChange={(e) => onSeoDescriptionChange(e.target.value)}
208
214
  disabled={disabled}
215
+ maxLength={SEO_DESCRIPTION_MAX_LENGTH}
216
+ invalid={seoDescriptionTooLong}
209
217
  placeholder="Enter SEO meta description..."
210
218
  className="bg-ods-bg border-ods-border text-ods-text-primary flex-1 resize-none"
211
219
  rows={6}
212
220
  />
221
+ <div className="flex items-center justify-between gap-2">
222
+ <span className="text-[11px] text-ods-error font-['DM_Sans']">
223
+ {seoDescriptionTooLong
224
+ ? `Too long: search engines may truncate this description (keep it under ${SEO_DESCRIPTION_MAX_LENGTH})`
225
+ : ''}
226
+ </span>
227
+ <span
228
+ className={cn(
229
+ "text-[11px] font-['DM_Sans'] tabular-nums shrink-0",
230
+ seoDescriptionTooLong ? 'text-ods-error font-semibold' : 'text-ods-text-secondary'
231
+ )}
232
+ >
233
+ {seoDescriptionLength}/{SEO_DESCRIPTION_MAX_LENGTH}
234
+ </span>
235
+ </div>
213
236
  {!seoDescription && summary && (
214
237
  <p className="text-[11px] text-ods-accent font-['DM_Sans']">
215
238
  Auto-populated from summary
@@ -13,7 +13,7 @@ A flat collection of named icon exports organized by brand/platform category:
13
13
  | **Microsoft** | `MicrosoftLogoIcon`, `MicrosoftLogoGreyIcon`, `Office365LogoIcon`, `WindowsIcon`, `WindowsLogoIcon` |
14
14
  | **AI Platforms** | `AnthropicLogoIcon`, `GeminiLogoIcon`, `OpenaiLogoIcon` (+ grey variants) |
15
15
  | **Social** | `FacebookIcon`, `InstagramIcon`, `LinkedinIcon`, `TwitterIcon`, `TwitterXIcon`, `TiktokIcon`, `YoutubeIcon` |
16
- | **MSP/IT Tools** | `TacticalRmmLogoIcon`, `MeshcentralLogoIcon`, `FleetMdmLogoIcon`, `OsqueryLogoIcon`, `PowershellLogoIcon` |
16
+ | **MSP/IT Tools** | `MeshcentralLogoIcon`, `FleetMdmLogoIcon`, `OsqueryLogoIcon`, `PowershellLogoIcon` |
17
17
  | **Dev Tools** | `GithubIcon`, `FigmaIcon`, `FramerIcon`, `TrelloIcon`, `ClickupLogoIcon` |
18
18
 
19
19
  > Many brands include both a standard and a `Grey` variant (e.g., `SlackLogoIcon` / `SlackLogoGreyIcon`), useful for active/inactive states.
@@ -26,7 +26,6 @@ import {
26
26
  GithubIcon,
27
27
  SlackLogoIcon,
28
28
  SlackLogoGreyIcon,
29
- TacticalRmmLogoIcon,
30
29
  OpenaiLogoIcon,
31
30
  } from '@/components/icons/brands';
32
31
 
@@ -1,5 +1,5 @@
1
1
  <!-- source-hash: f614dd9faeadf368597d094822bcd3c7 -->
2
- Displays a badge combining an icon and label for a given shell/script type, supporting all Tactical RMM shell types (PowerShell, CMD, Bash, Python, Nushell, Deno, Shell).
2
+ Displays a badge combining an icon and label for a given shell/script type, supporting all OpenFrame RMM shell types (PowerShell, CMD, Bash, Python, Nushell, Deno, Shell).
3
3
 
4
4
  ## Key Components
5
5
 
@@ -1,5 +1,5 @@
1
1
  <!-- source-hash: 5668aa064d8c050beff751a96d6469be -->
2
- A React component that renders a visual badge combining a tool icon and label for OpenFrame-integrated MSP tools (e.g., Tactical RMM, Fleet MDM, MeshCentral).
2
+ A React component that renders a visual badge combining a tool icon and label for OpenFrame-integrated MSP tools (e.g., Fleet MDM, MeshCentral).
3
3
 
4
4
  ## Key Components
5
5
 
@@ -15,7 +15,7 @@ A React component that renders a visual badge combining a tool icon and label fo
15
15
  import { ToolBadge } from './components/ToolBadge'
16
16
 
17
17
  // Basic usage in a table cell
18
- <ToolBadge toolType="TACTICAL" />
18
+ <ToolBadge toolType="OPENFRAME_RMM" />
19
19
 
20
20
  // With custom styling
21
21
  <ToolBadge
@@ -0,0 +1,165 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ /**
4
+ * Regression tests for the `next/navigation` shim fallback (unregistered host).
5
+ *
6
+ * The guarantee under test: an unregistered `useRouter().push/replace` must
7
+ * perform a same-origin SPA navigation via the History API and NEVER hard-load
8
+ * the document — a `?search=` write from `useApiParams` used to hard-reload via
9
+ * `window.location.replace`. The registered path must delegate to the host
10
+ * router untouched.
11
+ *
12
+ * `impl` is module-level singleton state, so each test re-imports the module
13
+ * fresh via `vi.resetModules()` to isolate registration state. We also stub
14
+ * `window.location` with a spy object so any hard navigation (assign/replace/
15
+ * reload) is observable — jsdom leaves those methods unimplemented anyway.
16
+ */
17
+
18
+ const ORIGIN = 'http://localhost:3000'
19
+
20
+ async function freshModule() {
21
+ vi.resetModules()
22
+ return import('../next-navigation')
23
+ }
24
+
25
+ let assignSpy: ReturnType<typeof vi.fn>
26
+ let replaceSpy: ReturnType<typeof vi.fn>
27
+ let reloadSpy: ReturnType<typeof vi.fn>
28
+ let pushStateSpy: ReturnType<typeof vi.spyOn>
29
+ let replaceStateSpy: ReturnType<typeof vi.spyOn>
30
+ let originalLocation: Location
31
+
32
+ beforeEach(() => {
33
+ originalLocation = window.location
34
+ assignSpy = vi.fn()
35
+ replaceSpy = vi.fn()
36
+ reloadSpy = vi.fn()
37
+ Object.defineProperty(window, 'location', {
38
+ configurable: true,
39
+ value: {
40
+ href: `${ORIGIN}/`,
41
+ origin: ORIGIN,
42
+ pathname: '/',
43
+ search: '',
44
+ assign: assignSpy,
45
+ replace: replaceSpy,
46
+ reload: reloadSpy,
47
+ },
48
+ })
49
+ // Real jsdom history — spy without changing behavior so we can assert which
50
+ // History API the fallback used (push vs replace encodes the nav semantics).
51
+ pushStateSpy = vi.spyOn(window.history, 'pushState')
52
+ replaceStateSpy = vi.spyOn(window.history, 'replaceState')
53
+ })
54
+
55
+ afterEach(() => {
56
+ Object.defineProperty(window, 'location', { configurable: true, value: originalLocation })
57
+ vi.restoreAllMocks()
58
+ })
59
+
60
+ describe('fallback useRouter (unregistered host)', () => {
61
+ it('replace() writes the URL via history.replaceState — never a hard navigation', async () => {
62
+ const { useRouter } = await freshModule()
63
+ const popstate = vi.fn()
64
+ window.addEventListener('popstate', popstate)
65
+
66
+ useRouter().replace('?search=laptop')
67
+
68
+ expect(replaceStateSpy).toHaveBeenCalledWith(null, '', `${ORIGIN}/?search=laptop`)
69
+ expect(pushStateSpy).not.toHaveBeenCalled()
70
+ // No full-document navigation of any kind — this is the reported bug.
71
+ expect(assignSpy).not.toHaveBeenCalled()
72
+ expect(replaceSpy).not.toHaveBeenCalled()
73
+ expect(reloadSpy).not.toHaveBeenCalled()
74
+ // Subscribers (usePathname/useSearchParams) get a re-render signal.
75
+ expect(popstate).toHaveBeenCalledTimes(1)
76
+
77
+ window.removeEventListener('popstate', popstate)
78
+ })
79
+
80
+ it('push() uses history.pushState (new entry) — never a hard navigation', async () => {
81
+ const { useRouter } = await freshModule()
82
+ useRouter().push('/scripts?tab=list')
83
+
84
+ expect(pushStateSpy).toHaveBeenCalledWith(null, '', `${ORIGIN}/scripts?tab=list`)
85
+ expect(replaceStateSpy).not.toHaveBeenCalled()
86
+ expect(assignSpy).not.toHaveBeenCalled()
87
+ expect(reloadSpy).not.toHaveBeenCalled()
88
+ })
89
+
90
+ it('falls back to a real navigation for cross-origin targets, honoring replace semantics', async () => {
91
+ const { useRouter } = await freshModule()
92
+ useRouter().replace('https://example.com/evil')
93
+
94
+ // replace() must use location.replace (no back-button entry), not assign.
95
+ expect(replaceSpy).toHaveBeenCalledWith('https://example.com/evil')
96
+ expect(assignSpy).not.toHaveBeenCalled()
97
+ expect(replaceStateSpy).not.toHaveBeenCalled()
98
+ expect(pushStateSpy).not.toHaveBeenCalled()
99
+ })
100
+
101
+ it('uses location.assign for a cross-origin push (new history entry)', async () => {
102
+ const { useRouter } = await freshModule()
103
+ useRouter().push('https://example.com/evil')
104
+
105
+ expect(assignSpy).toHaveBeenCalledWith('https://example.com/evil')
106
+ expect(replaceSpy).not.toHaveBeenCalled()
107
+ expect(pushStateSpy).not.toHaveBeenCalled()
108
+ })
109
+
110
+ it('falls back to a real navigation for a malformed href (push → assign)', async () => {
111
+ const { useRouter } = await freshModule()
112
+ useRouter().push('http://[invalid')
113
+
114
+ expect(assignSpy).toHaveBeenCalledWith('http://[invalid')
115
+ expect(replaceSpy).not.toHaveBeenCalled()
116
+ expect(pushStateSpy).not.toHaveBeenCalled()
117
+ })
118
+
119
+ it('falls back to a real navigation for a malformed href (replace → replace)', async () => {
120
+ const { useRouter } = await freshModule()
121
+ useRouter().replace('http://[invalid')
122
+
123
+ expect(replaceSpy).toHaveBeenCalledWith('http://[invalid')
124
+ expect(assignSpy).not.toHaveBeenCalled()
125
+ expect(replaceStateSpy).not.toHaveBeenCalled()
126
+ })
127
+
128
+ it('back/forward remain history-based (unchanged)', async () => {
129
+ const { useRouter } = await freshModule()
130
+ const backSpy = vi.spyOn(window.history, 'back').mockImplementation(() => {})
131
+ const forwardSpy = vi.spyOn(window.history, 'forward').mockImplementation(() => {})
132
+ useRouter().back()
133
+ useRouter().forward()
134
+ expect(backSpy).toHaveBeenCalledTimes(1)
135
+ expect(forwardSpy).toHaveBeenCalledTimes(1)
136
+ })
137
+ })
138
+
139
+ describe('registered useRouter (Next host)', () => {
140
+ it('delegates to the host router and never touches window.location or history', async () => {
141
+ const { useRouter, registerNavigation } = await freshModule()
142
+ const hostRouter = {
143
+ push: vi.fn(),
144
+ replace: vi.fn(),
145
+ back: vi.fn(),
146
+ forward: vi.fn(),
147
+ refresh: vi.fn(),
148
+ prefetch: vi.fn(),
149
+ }
150
+ registerNavigation({ useRouter: () => hostRouter })
151
+
152
+ const r = useRouter()
153
+ r.replace('?search=laptop', { scroll: false })
154
+ r.push('/scripts')
155
+
156
+ expect(hostRouter.replace).toHaveBeenCalledWith('?search=laptop', { scroll: false })
157
+ expect(hostRouter.push).toHaveBeenCalledWith('/scripts')
158
+ // The fallback path is completely bypassed — no navigation side effects.
159
+ expect(assignSpy).not.toHaveBeenCalled()
160
+ expect(replaceSpy).not.toHaveBeenCalled()
161
+ expect(reloadSpy).not.toHaveBeenCalled()
162
+ expect(pushStateSpy).not.toHaveBeenCalled()
163
+ expect(replaceStateSpy).not.toHaveBeenCalled()
164
+ })
165
+ })
@@ -22,9 +22,11 @@
22
22
  * After registration, every exported hook in this file delegates to
23
23
  * the real implementation. Without registration, the stubs run:
24
24
  *
25
- * - `useRouter()` → push/replace use `window.location.assign/replace`;
26
- * back/forward use `history.back/forward`; refresh reloads; prefetch
27
- * is a no-op.
25
+ * - `useRouter()` → push/replace do a same-origin SPA navigation via the
26
+ * History API (`pushState`/`replaceState` + a synthetic `popstate`), so a
27
+ * query-param write never reloads the document; cross-origin/malformed
28
+ * hrefs fall back to `window.location`. back/forward use
29
+ * `history.back/forward`; refresh reloads; prefetch is a no-op.
28
30
  * - `usePathname()` → returns `window.location.pathname` (popstate-
29
31
  * subscribed so back/forward re-renders).
30
32
  * - `useSearchParams()` → returns `URLSearchParams` view of
@@ -34,10 +36,12 @@
34
36
  * - `notFound()` / `redirect()` / `permanentRedirect()` —
35
37
  * best-effort equivalents using `window.location`.
36
38
  *
37
- * The fallback path subscribes to `popstate` only. Programmatic
38
- * `pushState`/`replaceState` does NOT fire popstate, so the fallback
39
- * does not auto-re-render on programmatic navigation. Embedders that
40
- * need that should register a real router or supply their own.
39
+ * The fallback path subscribes to `popstate`. Native `pushState`/
40
+ * `replaceState` do NOT fire popstate, but the fallback `useRouter`'s
41
+ * push/replace dispatch a synthetic `popstate` so this file's own
42
+ * `usePathname`/`useSearchParams` subscribers re-render. A URL mutated
43
+ * through some OTHER API still won't auto-re-render here — embedders
44
+ * that need that should register a real router or supply their own.
41
45
  */
42
46
  'use client'
43
47
  import { useEffect, useState } from 'react'
@@ -95,11 +99,44 @@ const noopRouter: RouterStub = {
95
99
  prefetch: () => {},
96
100
  }
97
101
 
102
+ /**
103
+ * SPA navigation for the unregistered fallback. Uses the History API instead of
104
+ * `window.location.assign/replace` so a same-origin URL change (e.g. a table's
105
+ * `?search=` write via `useApiParams`) never triggers a full document reload —
106
+ * even if a real router was never registered, or the registration landed on a
107
+ * DIFFERENT module instance than this one (duplicate lib copy / ESM-CJS dual
108
+ * package). A synthetic `popstate` is dispatched so this file's own
109
+ * `usePathname`/`useSearchParams` subscribers re-render in response.
110
+ * Cross-origin or malformed hrefs still fall back to a real `window.location`
111
+ * navigation, which is the only correct behavior there.
112
+ */
113
+ function softNavigate(href: string, mode: 'push' | 'replace'): void {
114
+ if (typeof window === 'undefined') return
115
+ try {
116
+ const url = new URL(href, window.location.href)
117
+ if (url.origin !== window.location.origin) {
118
+ // Preserve the caller's history semantics: replace() must not leave a
119
+ // back-button entry, so it uses `location.replace`, not `location.assign`.
120
+ if (mode === 'push') window.location.assign(url.href)
121
+ else window.location.replace(url.href)
122
+ return
123
+ }
124
+ if (mode === 'push') window.history.pushState(null, '', url.href)
125
+ else window.history.replaceState(null, '', url.href)
126
+ window.dispatchEvent(new PopStateEvent('popstate'))
127
+ } catch {
128
+ // Malformed href — a real navigation is safer than swallowing it, but still
129
+ // honor push vs replace history semantics.
130
+ if (mode === 'push') window.location.assign(href)
131
+ else window.location.replace(href)
132
+ }
133
+ }
134
+
98
135
  function fallbackUseRouter(): RouterStub {
99
136
  if (typeof window === 'undefined') return noopRouter
100
137
  return {
101
- push: (href: string) => window.location.assign(href),
102
- replace: (href: string) => window.location.replace(href),
138
+ push: (href: string) => softNavigate(href, 'push'),
139
+ replace: (href: string) => softNavigate(href, 'replace'),
103
140
  back: () => window.history.back(),
104
141
  forward: () => window.history.forward(),
105
142
  refresh: () => window.location.reload(),
@@ -31,4 +31,4 @@ export const AllOSTypes: Story = {
31
31
  };
32
32
  ```
33
33
 
34
- The stories demonstrate OS type normalization from various MDM sources (Fleet, Tactical RMM) with support for aliases like "Darwin" → "macOS" and "win32" → "Windows". Includes examples for icon-only, label-only, and combined display modes with customizable sizing options.
34
+ The stories demonstrate OS type normalization from various MDM sources (Fleet) with support for aliases like "Darwin" → "macOS" and "win32" → "Windows". Includes examples for icon-only, label-only, and combined display modes with customizable sizing options.
@@ -1,5 +1,5 @@
1
1
  <!-- source-hash: fe5ee5e74229fbc813833a5d6c81b725 -->
2
- Storybook configuration for the ShellTypeBadge component, defining visual documentation and interactive examples for all supported Tactical RMM shell types including PowerShell, CMD, Bash, Python, Nushell, Deno, and generic Shell.
2
+ Storybook configuration for the ShellTypeBadge component, defining visual documentation and interactive examples for all supported OpenFrame RMM shell types including PowerShell, CMD, Bash, Python, Nushell, Deno, and generic Shell.
3
3
 
4
4
  ## Key Components
5
5
 
@@ -9,7 +9,7 @@ Storybook stories for the `Toaster` / `ToastCard` component system, covering sta
9
9
  | `Playground` | Story | Arg-driven interactive card — use Storybook Controls to tweak `variant`, `title`, `description`, `duration`, and `dismissible` |
10
10
  | `CommandApprovalCollapsed` | Story | `CommandApprovalToast` in its default collapsed state |
11
11
  | `CommandApprovalExpanded` | Story | `CommandApprovalToast` with `defaultExpanded` showing the command preview and Approve/Reject actions |
12
- | `CommandApprovalPerTool` | Story | One expanded approval toast per supported tool type (`FLEET_MDM`, `TACTICAL_RMM`, `MESHCENTRAL`, `AUTHENTIK`, `OSQUERY`, `OPENFRAME`) to verify icon mapping |
12
+ | `CommandApprovalPerTool` | Story | One expanded approval toast per supported tool type (`FLEET_MDM`, `MESHCENTRAL`, `AUTHENTIK`, `OSQUERY`, `OPENFRAME`) to verify icon mapping |
13
13
  | `CommandApprovalLive` | Story | Fires real Sonner toasts via `showCommandApprovalToast()` with `onApprove`/`onReject` callbacks |
14
14
  | `LiveTriggers` | Story | Button panel that fires every variant as a live Sonner toast using both `toast()` and `showToast()` |
15
15
 
@@ -4,7 +4,7 @@ Storybook configuration file for the ToolBadge component, providing interactive
4
4
  ## Key Components
5
5
 
6
6
  - **meta**: Main Storybook configuration with component metadata, controls, and documentation
7
- - **Story exports**: Individual stories showcasing different tool types (Tactical RMM, Fleet MDM, MeshCentral, etc.)
7
+ - **Story exports**: Individual stories showcasing different tool types (Fleet MDM, MeshCentral, etc.)
8
8
  - **AllTools story**: Comparison view showing multiple tool badges together
9
9
  - **argTypes**: Interactive controls for `toolType` selection and `className` customization
10
10
 
@@ -14,7 +14,7 @@ Storybook configuration file for the ToolBadge component, providing interactive
14
14
  // Individual tool badge stories
15
15
  export const Default: Story = {
16
16
  args: {
17
- toolType: 'TACTICAL_RMM',
17
+ toolType: 'OPENFRAME_RMM',
18
18
  },
19
19
  };
20
20
 
@@ -5,7 +5,7 @@ Storybook stories for the `ToolExecutionDisplay` chat component, covering execut
5
5
 
6
6
  | Export | Description |
7
7
  |--------|-------------|
8
- | `CollapsedExecuting` | Spinner state while a tool (e.g. `run_script` via Tactical RMM) is in progress |
8
+ | `CollapsedExecuting` | Spinner state while a tool (e.g. `run_script` via OpenFrame RMM) is in progress |
9
9
  | `CollapsedSuccess` | Green check state after successful tool execution, with result output |
10
10
  | `CollapsedFailure` | Red error icon state after a failed tool execution (e.g. Fleet MDM profile deploy) |
11
11
  | `constrainedDecorator` | Helper decorator that wraps stories in a `max-width` container (default 400px) |
@@ -18,7 +18,7 @@ import type { ToolExecutionData } from "../components/chat/types";
18
18
 
19
19
  const message: ToolExecutionData = {
20
20
  type: "EXECUTED_TOOL",
21
- integratedToolType: "TACTICAL_RMM",
21
+ integratedToolType: "OPENFRAME_RMM",
22
22
  toolFunction: "run_script",
23
23
  parameters: { RunAsUser: "False", timeoutSeconds: 60 },
24
24
  result: "Script output here...",
@@ -34,8 +34,8 @@ Three fixture payloads cover the core display states:
34
34
 
35
35
  | Fixture | `type` | `integratedToolType` | `success` |
36
36
  |---------|--------|----------------------|-----------|
37
- | `executingMessage` | `EXECUTING_TOOL` | `TACTICAL_RMM` | — |
38
- | `executedSuccessMessage` | `EXECUTED_TOOL` | `TACTICAL_RMM` | `true` |
37
+ | `executingMessage` | `EXECUTING_TOOL` | `OPENFRAME_RMM` | — |
38
+ | `executedSuccessMessage` | `EXECUTED_TOOL` | `OPENFRAME_RMM` | `true` |
39
39
  | `executedFailureMessage` | `EXECUTED_TOOL` | `FLEET_MDM` | `false` |
40
40
 
41
41
  The component renders a collapsed card by default; clicking toggles full details including parameters and result output.
@@ -1,5 +1,5 @@
1
1
  <!-- source-hash: 6581d93b3edee981ded786a177e99959 -->
2
- Defines the centralized source of truth for operating system type metadata across the Flamingo platform, normalizing OS values from Fleet MDM, Tactical RMM, and GraphQL into a unified structure.
2
+ Defines the centralized source of truth for operating system type metadata across the Flamingo platform, normalizing OS values from Fleet MDM and GraphQL into a unified structure.
3
3
 
4
4
  ## Key Components
5
5
 
@@ -1,5 +1,5 @@
1
1
  <!-- source-hash: b8477e619839e162b7edcfaaf835462d -->
2
- Centralized type definitions for shell/script types supported across the Flamingo platform. Provides a single source of truth for shell metadata, icons, and labels based on Tactical RMM supported types.
2
+ Centralized type definitions for shell/script types supported across the Flamingo platform. Provides a single source of truth for shell metadata, icons, and labels based on OpenFrame RMM supported types.
3
3
 
4
4
  ## Key Components
5
5
 
@@ -3,7 +3,7 @@ Centralized type definitions for MSP tool types used throughout the Flamingo/Ope
3
3
 
4
4
  ## Key Components
5
5
 
6
- - **`ToolTypeValues`** - Const assertion object containing all available tool type constants (TACTICAL_RMM, FLEET_MDM, MESHCENTRAL, etc.)
6
+ - **`ToolTypeValues`** - Const assertion object containing all available tool type constants (FLEET_MDM, MESHCENTRAL, etc.)
7
7
  - **`ToolType`** - Union type derived from ToolTypeValues for type-safe tool type references
8
8
  - **`toolLabels`** - Record mapping tool types to human-readable display names
9
9
 
@@ -13,10 +13,10 @@ Centralized type definitions for MSP tool types used throughout the Flamingo/Ope
13
13
  import { ToolType, ToolTypeValues, toolLabels } from './tool.types'
14
14
 
15
15
  // Type-safe tool type usage
16
- const currentTool: ToolType = ToolTypeValues.TACTICAL_RMM
16
+ const currentTool: ToolType = ToolTypeValues.MESHCENTRAL
17
17
 
18
18
  // Get display label for UI
19
- const displayName = toolLabels[currentTool] // "Tactical"
19
+ const displayName = toolLabels[currentTool] // "MeshCentral"
20
20
 
21
21
  // Use in component props
22
22
  interface ToolBadgeProps {
@@ -22,7 +22,6 @@ import {
22
22
  } from './tool-utils'
23
23
 
24
24
  // Normalize various input formats
25
- normalizeToolType('tactical') // => 'TACTICAL_RMM'
26
25
  normalizeToolType('FLEET-MDM') // => 'FLEET_MDM'
27
26
  normalizeToolType('unknown') // => undefined
28
27
 
@@ -31,7 +30,6 @@ normalizeToolTypeWithFallback('mesh') // => 'MESHCENTRAL'
31
30
  normalizeToolTypeWithFallback('invalid') // => 'SYSTEM'
32
31
 
33
32
  // Convert to display labels
34
- toToolLabel('tactical') // => 'Tactical'
35
33
  toToolLabel('fleet_mdm') // => 'Fleet'
36
34
 
37
35
  // Validation
@@ -39,4 +37,4 @@ isValidToolType('authentik') // => true
39
37
  isValidToolType('invalid') // => false
40
38
  ```
41
39
 
42
- The utility supports all major MSP tools including Tactical RMM, Fleet MDM, MeshCentral, Authentik, and OpenFrame components with comprehensive alias mapping for different naming conventions.
40
+ The utility supports all major MSP tools including Fleet MDM, MeshCentral, Authentik, and OpenFrame components with comprehensive alias mapping for different naming conventions.
@@ -29,6 +29,7 @@ export { getBaseUrl } from '../utils/cn'
29
29
  // SEO title length budget — server-safe constant (SSOT). Consumed by the hub
30
30
  // (prompt guidance + DB check value) and by SEOEditorPreview (input maxLength).
31
31
  export { SEO_TITLE_MAX_LENGTH } from './seo-title'
32
+ export { SEO_DESCRIPTION_MAX_LENGTH } from './seo-description'
32
33
 
33
34
  // Brand silhouette SVG registry (CSS mask-image tinting) — centralized so
34
35
  // consumers (e.g. company-hub deck) don't ship their own copies of the marks.
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Single source of truth for the `seo_description` length budget.
3
+ *
4
+ * Unlike `seo_title` (soft search-engine guidance), this is a HARD cap: the
5
+ * `seo_description` DB columns are `varchar(160)` and Postgres rejects longer
6
+ * values outright ("value too long for type character varying(160)").
7
+ *
8
+ * This number lives in exactly ONE place. Consumers:
9
+ * - `SEOEditorPreview` — input `maxLength` + the too-long alert.
10
+ * - the hub — imports it (via `@flamingo-stack/openframe-frontend-core/utils`)
11
+ * for the AI generation prompt guidance and the rewrite-to-fit helper.
12
+ */
13
+ export const SEO_DESCRIPTION_MAX_LENGTH = 160;