@flamingo-stack/openframe-frontend-core 0.0.745 → 0.0.748

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 (99) hide show
  1. package/dist/build/export-map.json +1 -1
  2. package/dist/{chunk-7PQOZLVP.cjs → chunk-46EIXJFC.cjs} +20 -20
  3. package/dist/{chunk-FNIFUCXU.cjs → chunk-4AZHFLOM.cjs} +5 -5
  4. package/dist/{chunk-YJRMCFX3.cjs → chunk-4MGZYY6U.cjs} +7 -7
  5. package/dist/{chunk-QES5NV4T.cjs → chunk-6XP6RNFD.cjs} +1040 -1010
  6. package/dist/{chunk-75BF7YJR.cjs → chunk-7Y3VMMJB.cjs} +13 -13
  7. package/dist/{chunk-GAGNKWOC.cjs → chunk-BBM2ICOY.cjs} +2 -2
  8. package/dist/{chunk-DJO7YMT3.cjs → chunk-BBTSJYTR.cjs} +232 -227
  9. package/dist/{chunk-4N7KGOBS.cjs → chunk-BEC43GHL.cjs} +3 -3
  10. package/dist/{chunk-NFINQ7IZ.cjs → chunk-D4XZL27V.cjs} +6 -6
  11. package/dist/{chunk-PM5ELQBL.cjs → chunk-EH3LOWGY.cjs} +32 -32
  12. package/dist/{chunk-7KHQHYXL.cjs → chunk-G2ELX3DG.cjs} +18 -18
  13. package/dist/{chunk-M4FKV2U5.cjs → chunk-GE4PCDNZ.cjs} +12 -12
  14. package/dist/{chunk-GEEKJ7SH.cjs → chunk-H34A2765.cjs} +74 -74
  15. package/dist/{chunk-URVIPSAJ.cjs → chunk-HNLI4OU5.cjs} +14 -14
  16. package/dist/{chunk-ABLUMGCI.cjs → chunk-JHQNUWFM.cjs} +83 -33
  17. package/dist/{chunk-3LX5AGZO.cjs → chunk-JNBOQ7F2.cjs} +24 -24
  18. package/dist/{chunk-RIGSZEXS.cjs → chunk-JOPFX3PJ.cjs} +90 -90
  19. package/dist/{chunk-UY6A6TEY.cjs → chunk-L2IQESTQ.cjs} +2 -2
  20. package/dist/{chunk-HVR7DPAY.cjs → chunk-OZUJYBQ2.cjs} +4 -4
  21. package/dist/{chunk-LJ46XYYP.cjs → chunk-P7KTAXXE.cjs} +41 -27
  22. package/dist/{chunk-OWGLRE74.cjs → chunk-PSIKUDUV.cjs} +8 -8
  23. package/dist/{chunk-4A6OF4HI.cjs → chunk-PSSGIZXZ.cjs} +44 -44
  24. package/dist/{chunk-YIOWV7LS.cjs → chunk-QPSQ5WLG.cjs} +2 -2
  25. package/dist/{chunk-GQ2TRIOV.cjs → chunk-TRXCQADV.cjs} +35 -35
  26. package/dist/{chunk-U2O5YQRP.cjs → chunk-U6XWGK53.cjs} +33 -33
  27. package/dist/{chunk-HMSCUL4R.cjs → chunk-UNR74CAC.cjs} +4 -4
  28. package/dist/{chunk-JGZEHBBD.cjs → chunk-YIRX6C7V.cjs} +10 -10
  29. package/dist/{chunk-6IQDQQ56.cjs → chunk-YKJQM2O6.cjs} +38 -38
  30. package/dist/{chunk-5TCIRWHL.cjs → chunk-YRULLK7N.cjs} +5 -5
  31. package/dist/{chunk-GOTEXQZN.cjs → chunk-ZNUSYMH7.cjs} +80 -80
  32. package/dist/{cloud-tenants-DECPWIYQ.cjs → cloud-tenants-V5727B7F.cjs} +13 -13
  33. package/dist/components/case-studies/index.cjs +31 -29
  34. package/dist/components/case-studies/share-experience-section.d.ts.map +1 -1
  35. package/dist/components/case-studies/share-experience-section.js +6 -5
  36. package/dist/components/chat/index.cjs +9 -9
  37. package/dist/components/contact/index.cjs +14 -14
  38. package/dist/components/docs/index.cjs +17 -17
  39. package/dist/components/embeds/index.cjs +14 -14
  40. package/dist/components/faq/index.cjs +15 -15
  41. package/dist/components/features/index.cjs +13 -13
  42. package/dist/components/help-center-pages/index.cjs +116 -116
  43. package/dist/components/index.cjs +121 -125
  44. package/dist/components/layout/page-heading.d.ts +16 -0
  45. package/dist/components/layout/page-heading.d.ts.map +1 -1
  46. package/dist/components/layout/page-heading.js +16 -2
  47. package/dist/components/meeting-scheduler/index.cjs +63 -63
  48. package/dist/components/navigation/index.cjs +16 -16
  49. package/dist/components/onboarding-guides/index.cjs +16 -16
  50. package/dist/components/product-demo/index.cjs +9 -9
  51. package/dist/components/related-content/index.cjs +7 -7
  52. package/dist/components/tickets/index.cjs +19 -19
  53. package/dist/components/ui/filter-checkbox-item.d.ts +8 -2
  54. package/dist/components/ui/filter-checkbox-item.d.ts.map +1 -1
  55. package/dist/components/ui/filter-checkbox-item.js +14 -4
  56. package/dist/components/ui/index.cjs +15 -13
  57. package/dist/components/ui/layout-hold.d.ts +4 -3
  58. package/dist/components/ui/layout-hold.d.ts.map +1 -1
  59. package/dist/components/ui/layout-hold.js +29 -9
  60. package/dist/components/unified-filter-logic.js +1 -1
  61. package/dist/components/vendor-tag.d.ts +12 -0
  62. package/dist/components/vendor-tag.d.ts.map +1 -1
  63. package/dist/components/vendor-tag.js +50 -47
  64. package/dist/{devices-HJKDRWQX.cjs → devices-WWMXIIHI.cjs} +14 -14
  65. package/dist/{embeddable-chat-3SKFBO2P.cjs → embeddable-chat-IJHCGQ24.cjs} +80 -80
  66. package/dist/index.cjs +22 -18
  67. package/dist/{knowledge-NH2CGY2C.cjs → knowledge-JZP4RIXB.cjs} +14 -14
  68. package/dist/{logs-KZYIDYQK.cjs → logs-WIRYFNUC.cjs} +21 -21
  69. package/dist/{policies-UAVTPIKM.cjs → policies-X7OJYA7G.cjs} +17 -17
  70. package/dist/{remote-session-N4PJB5FW.cjs → remote-session-IYT4LWJC.cjs} +7 -7
  71. package/dist/{scripts-FRPY43KU.cjs → scripts-GRMT2NGW.cjs} +20 -20
  72. package/dist/{software-update-AEJJNLX5.cjs → software-update-G3QOB7RT.cjs} +17 -17
  73. package/dist/{tickets-board-O753S4ZB.cjs → tickets-board-4MT6ZB4F.cjs} +8 -8
  74. package/dist/utils/format.d.ts.map +1 -1
  75. package/dist/utils/format.js +2 -3
  76. package/dist/utils/index.cjs +123 -72
  77. package/dist/utils/index.d.ts +1 -1
  78. package/dist/utils/index.d.ts.map +1 -1
  79. package/dist/utils/index.js +8 -6
  80. package/dist/utils/markdown-to-plain.d.ts.map +1 -1
  81. package/dist/utils/markdown-to-plain.js +12 -7
  82. package/dist/utils/vendor-classification.d.ts +43 -17
  83. package/dist/utils/vendor-classification.d.ts.map +1 -1
  84. package/dist/utils/vendor-classification.js +73 -22
  85. package/package.json +7 -2
  86. package/src/components/case-studies/share-experience-section.tsx +9 -6
  87. package/src/components/layout/page-heading.tsx +52 -11
  88. package/src/components/ui/filter-checkbox-item.tsx +21 -5
  89. package/src/components/ui/layout-hold.tsx +38 -10
  90. package/src/components/unified-filter-logic.tsx +1 -1
  91. package/src/components/vendor-tag.tsx +73 -69
  92. package/src/utils/format.ts +2 -3
  93. package/src/utils/index.ts +7 -5
  94. package/src/utils/markdown-to-plain.ts +25 -16
  95. package/src/utils/vendor-classification.ts +113 -34
  96. package/dist/utils/format-text-stub.d.ts +0 -5
  97. package/dist/utils/format-text-stub.d.ts.map +0 -1
  98. package/dist/utils/format-text-stub.js +0 -20
  99. package/src/utils/format-text-stub.ts +0 -17
@@ -1,36 +1,87 @@
1
- const VENDOR_CLASSIFICATIONS = [
2
- "open_source",
3
- "commercial",
4
- "openframe_selected",
5
- "openframe_shipped",
6
- "openframe_connect"
7
- ];
8
- const OPENFRAME_CLASSIFICATIONS = {
1
+ const VENDOR_CLASSIFICATION_COPY = {
9
2
  openframe_selected: {
10
- word: "Selected",
11
3
  label: "OpenFrame Selected",
12
- description: "OpenFrame is a unified platform that integrates multiple open-source IT and security tools into a single dashboard for MSPs. This filter shows vendors selected by our team for their excellence, community support, and MSP-specific value."
4
+ word: "Selected",
5
+ description: "OpenFrame is a unified platform that integrates multiple open-source IT and security tools into a single dashboard for MSPs. This filter shows vendors selected by our team for their excellence, community support, and MSP-specific value.",
6
+ group: "openframe",
7
+ icon: { name: "openframe-logo", tone: "accent" }
8
+ },
9
+ openframe_connect: {
10
+ label: "OpenFrame Connect",
11
+ word: "Connect",
12
+ description: "Services OpenFrame connects to and manages for every client, such as Microsoft 365 and Google Workspace, beside devices and tickets.",
13
+ group: "openframe",
14
+ icon: { name: "openframe-logo", tone: "accent" }
13
15
  },
14
16
  openframe_shipped: {
15
- word: "Shipped",
16
17
  label: "OpenFrame Shipped",
17
- description: "Tools and OpenFrame products that are in the released OpenFrame product today, not on the roadmap."
18
+ word: "Shipped",
19
+ description: "Tools and OpenFrame products that are in the released OpenFrame product today, not on the roadmap.",
20
+ group: "openframe",
21
+ icon: { name: "openframe-logo", tone: "accent" }
18
22
  },
19
- openframe_connect: {
20
- word: "Connect",
21
- label: "OpenFrame Connect",
22
- description: "Services OpenFrame connects to and manages for every client, such as Microsoft 365 and Google Workspace, beside devices and tickets."
23
+ open_source: {
24
+ label: "Open Source",
25
+ description: "Released under an OSI-approved open-source license.",
26
+ group: "license",
27
+ icon: { name: "code", tone: "accent" }
28
+ },
29
+ source_available: {
30
+ label: "Source-available",
31
+ description: "The source code is public, but the license restricts how it may be used (for example SSPL, BSL, the Elastic License, or limits on company size or on offering it as a service).",
32
+ group: "license",
33
+ icon: { name: "file-code", tone: "neutral" }
34
+ },
35
+ commercial: {
36
+ label: "Commercial",
37
+ description: "Proprietary software sold by the vendor.",
38
+ group: "license",
39
+ icon: { name: "dollar", tone: "neutral" }
40
+ },
41
+ end_of_life: {
42
+ label: "End of life",
43
+ description: "The vendor has discontinued or retired the product. No new releases.",
44
+ group: "lifecycle",
45
+ icon: { name: "alert-triangle", tone: "warning" }
46
+ },
47
+ end_of_support: {
48
+ label: "End of support",
49
+ description: "The vendor no longer provides support or security fixes, though the product can still be obtained.",
50
+ group: "lifecycle",
51
+ icon: { name: "clock", tone: "warning" }
52
+ },
53
+ unmaintained: {
54
+ label: "Unmaintained",
55
+ description: "No release for years and no official end-of-life notice.",
56
+ group: "lifecycle",
57
+ icon: { name: "pause-circle", tone: "warning" }
23
58
  }
24
59
  };
25
- function isVendorClassification(value) {
26
- return VENDOR_CLASSIFICATIONS.includes(value);
60
+ function vendorClassificationCopy(value) {
61
+ if (!value || !Object.prototype.hasOwnProperty.call(VENDOR_CLASSIFICATION_COPY, value)) return void 0;
62
+ return VENDOR_CLASSIFICATION_COPY[value];
63
+ }
64
+ function vendorClassificationGroup(value) {
65
+ return vendorClassificationCopy(value)?.group;
27
66
  }
28
67
  function isOpenFrameClassification(value) {
29
- return !!value && Object.prototype.hasOwnProperty.call(OPENFRAME_CLASSIFICATIONS, value);
68
+ return vendorClassificationGroup(value) === "openframe";
69
+ }
70
+ const DISPLAY_ORDER = Object.keys(VENDOR_CLASSIFICATION_COPY);
71
+ function compareVendorClassifications(a, b) {
72
+ const ia = DISPLAY_ORDER.indexOf(a);
73
+ const ib = DISPLAY_ORDER.indexOf(b);
74
+ if (ia !== -1 || ib !== -1) {
75
+ if (ia === -1) return 1;
76
+ if (ib === -1) return -1;
77
+ return ia - ib;
78
+ }
79
+ return a.localeCompare(b);
30
80
  }
31
81
  export {
32
- OPENFRAME_CLASSIFICATIONS,
33
- VENDOR_CLASSIFICATIONS,
82
+ VENDOR_CLASSIFICATION_COPY,
83
+ compareVendorClassifications,
34
84
  isOpenFrameClassification,
35
- isVendorClassification
85
+ vendorClassificationCopy,
86
+ vendorClassificationGroup
36
87
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flamingo-stack/openframe-frontend-core",
3
- "version": "0.0.745",
3
+ "version": "0.0.748",
4
4
  "description": "Shared design system and components for all Flamingo platforms",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -480,10 +480,16 @@
480
480
  "framer-motion": "^12.23.12",
481
481
  "globals": "^16.4.0",
482
482
  "graphql": "^16.12.0",
483
+ "hast-util-raw": "^9.1.0",
483
484
  "hast-util-to-text": "^4.0.2",
484
485
  "libphonenumber-js": "^1.12.40",
485
486
  "lucide-react": "^0.454.0",
487
+ "mdast-util-from-markdown": "^2.0.2",
488
+ "mdast-util-gfm": "^3.1.0",
489
+ "mdast-util-newline-to-break": "^2.0.0",
490
+ "mdast-util-to-hast": "^13.2.1",
486
491
  "mermaid": "^11.14.0",
492
+ "micromark-extension-gfm": "^3.0.0",
487
493
  "nats.ws": "^1.30.3",
488
494
  "next-themes": "^0.2.1",
489
495
  "prettier-plugin-tailwindcss": "^0.8.1",
@@ -505,7 +511,6 @@
505
511
  "remark": "^15.0.1",
506
512
  "remark-breaks": "^4.0.0",
507
513
  "remark-gfm": "^4.0.1",
508
- "remark-rehype": "^11.1.2",
509
514
  "sonner": "^1.2.4",
510
515
  "tailwind-merge": "^3.4.0",
511
516
  "tailwindcss-animate": "^1.0.7",
@@ -7,7 +7,7 @@ import { CapterraIcon } from '../icons-v2-generated/brand-logos/capterra-icon';
7
7
  import { G2Icon } from '../icons-v2-generated/brand-logos/g2-icon';
8
8
  import { GetappIcon } from '../icons-v2-generated/brand-logos/getapp-icon';
9
9
  import { TrustpilotIcon } from '../icons-v2-generated/brand-logos/trustpilot-icon';
10
- import { accentSentenceMarks } from '../layout/page-heading';
10
+ import { accentSentenceMarks, HeadingLines } from '../layout/page-heading';
11
11
  import { BenefitCard, BenefitCardGrid } from '../ui';
12
12
 
13
13
  /**
@@ -56,11 +56,14 @@ export interface ShareExperienceSectionProps {
56
56
  }
57
57
 
58
58
  const DEFAULT_TITLE: React.ReactNode = (
59
- <>
60
- Share Your Experience
61
- <br />
62
- with Fellow MSPs<span className="text-ods-accent">:</span>
63
- </>
59
+ <HeadingLines>
60
+ {[
61
+ 'Share Your Experience',
62
+ <>
63
+ with Fellow MSPs<span className="text-ods-accent">:</span>
64
+ </>,
65
+ ]}
66
+ </HeadingLines>
64
67
  );
65
68
 
66
69
  const DEFAULT_SUBTITLE: React.ReactNode = (
@@ -68,6 +68,41 @@ export function PageHeading({
68
68
  );
69
69
  }
70
70
 
71
+ /**
72
+ * THE way a heading is drawn on more than one line: each line on its own line,
73
+ * with a space before every break. The space is what keeps the heading's TEXT
74
+ * (what search engines, screen readers and link previews read) in words:
75
+ * "own. Approved", never "own.Approved". It sits at the end of a line, where it
76
+ * draws nothing. An empty line is a blank line.
77
+ *
78
+ * Every multi-line title goes through this; never write a bare `<br />` or one
79
+ * block `<span>` per line in a heading. The lines are its children, so anything
80
+ * that walks a title's children (the accent marks) reaches every line.
81
+ */
82
+ export interface HeadingLinesProps {
83
+ /** The title's lines, top to bottom: one child per line. */
84
+ children: ReactNode;
85
+ }
86
+
87
+ export function HeadingLines({ children }: HeadingLinesProps) {
88
+ return (
89
+ <>
90
+ {Children.toArray(children).map((line, index) => (
91
+ // biome-ignore lint/suspicious/noArrayIndexKey: a title's lines never reorder
92
+ <Fragment key={index}>
93
+ {index > 0 ? (
94
+ <>
95
+ {' '}
96
+ <br />
97
+ </>
98
+ ) : null}
99
+ {line}
100
+ </Fragment>
101
+ ))}
102
+ </>
103
+ );
104
+ }
105
+
71
106
  /** A sentence mark inside a title: `.`, `:`, `?` or `!` that ends a sentence (followed by a space or the end). */
72
107
  const SENTENCE_MARK = /([.:?!]+)(?=\s|$)/g;
73
108
  const ONLY_MARKS = /^\s*[.:?!]+\s*$/;
@@ -116,17 +151,22 @@ function accentMarks(node: ReactNode, accentClassName: string): ReactNode {
116
151
  if (ONLY_MARKS.test(node)) return node;
117
152
  const parts = node.split(SENTENCE_MARK);
118
153
  if (parts.length === 1) return node;
119
- return parts.map((part, index) =>
120
- // `split` with one capture group alternates text, mark, text, mark...
121
- index % 2 === 1 ? (
122
- // biome-ignore lint/suspicious/noArrayIndexKey: the parts of one string never reorder
123
- <span key={index} className={accentClassName}>
124
- {part}
125
- </span>
126
- ) : (
127
- // biome-ignore lint/suspicious/noArrayIndexKey: the parts of one string never reorder
128
- <Fragment key={index}>{part}</Fragment>
129
- ),
154
+ // One node for one string, so a list of children keeps its length (each of `HeadingLines`' lines stays one line).
155
+ return (
156
+ <>
157
+ {parts.map((part, index) =>
158
+ // `split` with one capture group alternates text, mark, text, mark...
159
+ index % 2 === 1 ? (
160
+ // biome-ignore lint/suspicious/noArrayIndexKey: the parts of one string never reorder
161
+ <span key={index} className={accentClassName}>
162
+ {part}
163
+ </span>
164
+ ) : (
165
+ // biome-ignore lint/suspicious/noArrayIndexKey: the parts of one string never reorder
166
+ <Fragment key={index}>{part}</Fragment>
167
+ ),
168
+ )}
169
+ </>
130
170
  );
131
171
  }
132
172
  if (Array.isArray(node)) {
@@ -225,6 +265,7 @@ export function SectionHeading({
225
265
  // `contents`: the element names the heading and draws no box, so the stack lays its two children out as before.
226
266
  <Tag className="contents">
227
267
  {eyebrowNode}
268
+ {/* A space between the eyebrow and the title, so the heading's text reads "Eyebrow Title", not "EyebrowTitle". */}{' '}
228
269
  <span className={`block ${layout.heading}`} data-section-heading>
229
270
  {headingText}
230
271
  </span>
@@ -1,30 +1,46 @@
1
1
  'use client';
2
2
 
3
3
  import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
4
+ import type { ReactNode } from 'react';
4
5
  import { cn } from '../../utils/cn';
5
6
  import { CheckboxCheckmarkIcon } from '../icons-v2-generated/signs-and-symbols/checkbox-checkmark-icon';
6
7
 
7
8
  export interface FilterCheckboxItemProps {
8
- label: string;
9
+ /** Text, or a richer label (an icon with a word and a tooltip). */
10
+ label: ReactNode;
11
+ /** The accessible name when `label` is not plain text. */
12
+ ariaLabel?: string;
9
13
  checked: boolean;
10
14
  onChange: (checked: boolean) => void;
11
15
  count?: number;
16
+ /** Ignore clicks (e.g. while a navigation started by this filter is pending). */
17
+ disabled?: boolean;
12
18
  className?: string;
13
19
  }
14
20
 
15
- export function FilterCheckboxItem({ label, checked, onChange, count, className }: FilterCheckboxItemProps) {
21
+ export function FilterCheckboxItem({
22
+ label,
23
+ ariaLabel,
24
+ checked,
25
+ onChange,
26
+ count,
27
+ disabled,
28
+ className,
29
+ }: FilterCheckboxItemProps) {
16
30
  return (
17
31
  <div
18
32
  className={cn(
19
33
  'flex items-center gap-3 border-b border-ods-border bg-ods-card last:border-b-0',
20
- 'cursor-pointer transition-colors hover:bg-ods-bg-hover',
34
+ disabled ? 'cursor-wait opacity-70' : 'cursor-pointer transition-colors hover:bg-ods-bg-hover',
21
35
  count !== undefined ? 'px-4 py-3' : 'p-3',
22
36
  className,
23
37
  )}
24
- onClick={() => onChange(!checked)}
38
+ onClick={() => !disabled && onChange(!checked)}
25
39
  >
26
40
  <CheckboxPrimitive.Root
27
41
  checked={checked}
42
+ disabled={disabled}
43
+ aria-label={ariaLabel ?? (typeof label === 'string' ? label : undefined)}
28
44
  onCheckedChange={c => onChange(c === true)}
29
45
  className={cn(
30
46
  'h-6 w-6 shrink-0 rounded-[6px] border-2',
@@ -37,7 +53,7 @@ export function FilterCheckboxItem({ label, checked, onChange, count, className
37
53
  <CheckboxCheckmarkIcon size={10} />
38
54
  </CheckboxPrimitive.Indicator>
39
55
  </CheckboxPrimitive.Root>
40
- <span className="flex-1 text-ods-text-primary text-h4">{label}</span>
56
+ <span className="min-w-0 flex-1 text-ods-text-primary text-h4">{label}</span>
41
57
  {count !== undefined && (
42
58
  <span className="shrink-0 text-ods-text-secondary text-h5">{count.toLocaleString()}</span>
43
59
  )}
@@ -1,5 +1,8 @@
1
- import type { ReactNode } from 'react';
1
+ 'use client';
2
+
3
+ import { type ReactNode, type RefObject, useLayoutEffect, useRef, useState } from 'react';
2
4
  import { cn } from '../../utils/cn';
5
+ import { SkeletonText } from './skeleton';
3
6
 
4
7
  /**
5
8
  * Holding a layout while its content is read, the two ways that are right at
@@ -10,7 +13,8 @@ import { cn } from '../../utils/cn';
10
13
  *
11
14
  * - `TextHold`: an inline stand-in exactly as wide as the words it is given
12
15
  * (same font, same letter spacing, because it IS those words, not shown).
13
- * `bar` draws it as a pulsing grey bar; `blank` only keeps its space.
16
+ * `bar` draws a `SkeletonText` of as many lines as the words take; `blank`
17
+ * only keeps their space.
14
18
  * - `LayoutHold`: a block as tall as the real layout rendered inside it, kept
15
19
  * invisible and inert: the layout's own height, at any width.
16
20
  *
@@ -19,27 +23,51 @@ import { cn } from '../../utils/cn';
19
23
  export interface TextHoldProps {
20
24
  /** The words whose width is held (the content's own, or a stand-in of its length). */
21
25
  children: ReactNode;
22
- /** `bar`: a pulsing grey bar of that width. `blank`: the width only. */
26
+ /** `bar`: a `SkeletonText` of as many lines as the words take. `blank`: the space only. */
23
27
  variant?: 'bar' | 'blank';
24
28
  className?: string;
25
29
  }
26
30
 
27
31
  export function TextHold({ children, variant = 'bar', className }: TextHoldProps) {
32
+ const hold = useRef<HTMLSpanElement>(null);
33
+ const words = useRef<HTMLSpanElement>(null);
34
+ const lines = useLineCount(hold, words, variant === 'bar');
28
35
  return (
29
36
  <span
37
+ ref={hold}
30
38
  aria-hidden
31
- className={cn(
32
- variant === 'bar' ? 'animate-pulse rounded bg-ods-border text-transparent' : 'invisible',
33
- // A hold is never selectable or clickable, whatever it wraps.
34
- 'pointer-events-none select-none',
35
- className,
36
- )}
39
+ className={cn('pointer-events-none select-none', variant === 'bar' && 'relative inline-block', className)}
37
40
  >
38
- {children}
41
+ <span ref={words} className="invisible">
42
+ {children}
43
+ </span>
44
+ {lines > 0 ? (
45
+ // `justify-around`: each `Skeleton` sits in the middle of the line it stands for.
46
+ <SkeletonText lines={lines} className="absolute inset-0 flex flex-col justify-around space-y-0" />
47
+ ) : null}
39
48
  </span>
40
49
  );
41
50
  }
42
51
 
52
+ /** How many lines the words take (one box per line), kept current as the width changes. */
53
+ function useLineCount(
54
+ hold: RefObject<HTMLSpanElement | null>,
55
+ words: RefObject<HTMLSpanElement | null>,
56
+ enabled: boolean,
57
+ ): number {
58
+ const [lines, setLines] = useState(0);
59
+ useLayoutEffect(() => {
60
+ const box = hold.current;
61
+ if (!enabled || !box) return undefined;
62
+ const measure = () => setLines(words.current?.getClientRects().length ?? 0);
63
+ measure();
64
+ const observer = new ResizeObserver(measure);
65
+ observer.observe(box);
66
+ return () => observer.disconnect();
67
+ }, [hold, words, enabled]);
68
+ return lines;
69
+ }
70
+
43
71
  export interface LayoutHoldProps {
44
72
  /** The real layout, rendered with the data at hand. */
45
73
  children: ReactNode;
@@ -397,7 +397,7 @@ export function useUnifiedFiltering(config: FilterConfig) {
397
397
  };
398
398
  }
399
399
 
400
- import { formatClassification, formatPricingModel } from '../utils/format-text-stub';
400
+ import { formatClassification, formatPricingModel } from '../utils/format';
401
401
 
402
402
  // Helper functions for filter labels
403
403
  function getFilterLabel(filterKey: string): string {
@@ -1,13 +1,11 @@
1
1
  'use client';
2
2
 
3
- import { Boxes, Code, Database, DollarSign, Hand, Plug, ShieldCheck, Sparkles } from 'lucide-react';
3
+ import { Boxes, Database, DollarSign, Hand, Plug, ShieldCheck, Sparkles } from 'lucide-react';
4
+ import { createElement, type ReactNode } from 'react';
4
5
  import { cn } from '../utils/cn';
5
- import {
6
- OPENFRAME_CLASSIFICATIONS,
7
- isOpenFrameClassification,
8
- type OpenFrameClassification,
9
- } from '../utils/vendor-classification';
10
- import { OpenframeLogoIcon } from './icons-v2-generated/logos/openframe-logo-icon';
6
+ import { formatClassification } from '../utils/format';
7
+ import { type VendorClassificationIconConfig, vendorClassificationCopy } from '../utils/vendor-classification';
8
+ import { resolveIcon } from './chat/utils/icon-library';
11
9
 
12
10
  export interface VendorTagProps {
13
11
  type:
@@ -36,12 +34,69 @@ export interface VendorTagProps {
36
34
  accentColor?: string;
37
35
  }
38
36
 
39
- /** An OpenFrame classification: the OpenFrame mark and its one word ("Selected", "Connect"). */
40
- function openFrameTag(classification: OpenFrameClassification, text?: string) {
37
+ const TONE_CLASS: Readonly<Record<VendorClassificationIconConfig['tone'], string>> = {
38
+ accent: 'text-ods-text-on-accent',
39
+ neutral: 'text-ods-text-secondary',
40
+ warning: 'text-ods-warning',
41
+ };
42
+
43
+ /**
44
+ * A classification's icon, from its config (`VENDOR_CLASSIFICATION_COPY[value].icon`): the
45
+ * icon is resolved by NAME through the shared resolver, never imported here. A license icon
46
+ * sits on a small tile (the accent tile for an `accent` tone); an OpenFrame mark and a
47
+ * lifecycle icon are drawn bare in their tone. A value with no icon draws nothing.
48
+ */
49
+ export function VendorClassificationIcon({
50
+ classification,
51
+ accentColor,
52
+ className,
53
+ }: {
54
+ classification: string;
55
+ /** The accent tile's colour, for a host that tints its hover accent (a vendor card). */
56
+ accentColor?: string;
57
+ className?: string;
58
+ }) {
59
+ const copy = vendorClassificationCopy(classification);
60
+ if (!copy?.icon) return null;
61
+ // The resolver returns one component per name, loaded on demand (drawn through `createElement`, as `NamedIcon` does).
62
+ const icon = resolveIcon(copy.icon.name);
63
+ if (copy.group !== 'license') {
64
+ const bareClass = copy.icon.tone === 'accent' ? 'text-ods-accent' : TONE_CLASS[copy.icon.tone];
65
+ return createElement(icon, {
66
+ size: copy.group === 'openframe' ? 16 : 12,
67
+ className: cn('shrink-0', bareClass, className),
68
+ });
69
+ }
70
+ const accent = copy.icon.tone === 'accent';
71
+ return (
72
+ <div
73
+ className={cn(
74
+ 'flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-sm',
75
+ accent ? 'bg-ods-accent' : 'bg-ods-border',
76
+ className,
77
+ )}
78
+ style={accent && accentColor ? { backgroundColor: accentColor } : undefined}
79
+ >
80
+ {createElement(icon, { size: 10, className: TONE_CLASS[copy.icon.tone] })}
81
+ </div>
82
+ );
83
+ }
84
+
85
+ /**
86
+ * A classification tag, derived from the value the server sends and its config: the label
87
+ * (an OpenFrame value's one word), the configured icon, and the warning text colour for a
88
+ * lifecycle value. Any other value is its title-cased label with no icon.
89
+ */
90
+ function classificationTag(
91
+ value: string,
92
+ text?: string,
93
+ accentColor?: string,
94
+ ): { text: string; icon: ReactNode; textColor?: string } {
95
+ const copy = vendorClassificationCopy(value);
41
96
  return {
42
- text: text || OPENFRAME_CLASSIFICATIONS[classification].word,
43
- textColor: undefined,
44
- icon: <OpenframeLogoIcon secondaryColor="currentColor" color="currentColor" className="h-4 w-4 text-ods-accent" />,
97
+ text: text || (copy?.group === 'openframe' ? copy.word : undefined) || copy?.label || formatClassification(value),
98
+ textColor: copy?.group === 'lifecycle' ? 'text-ods-warning' : undefined,
99
+ icon: <VendorClassificationIcon classification={value} accentColor={accentColor} />,
45
100
  };
46
101
  }
47
102
 
@@ -53,7 +108,7 @@ export function VendorTag({ type, text, className = '', hidden = false, size = '
53
108
  );
54
109
 
55
110
  // Get display text and styling based on type
56
- const getTagContent = () => {
111
+ const getTagContent = (): { text: string; icon: ReactNode; textColor?: string } => {
57
112
  switch (type) {
58
113
  case 'placeholder':
59
114
  return {
@@ -86,26 +141,9 @@ export function VendorTag({ type, text, className = '', hidden = false, size = '
86
141
  ),
87
142
  };
88
143
  case 'open-source':
89
- return {
90
- text: text || 'Open Source',
91
- icon: (
92
- <div
93
- className="flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-sm bg-ods-accent"
94
- style={accentColor ? { backgroundColor: accentColor } : undefined}
95
- >
96
- <Code width={10} height={10} className="text-ods-text-on-accent" />
97
- </div>
98
- ),
99
- };
144
+ return classificationTag('open_source', text, accentColor);
100
145
  case 'commercial':
101
- return {
102
- text: text || 'Commercial Vendor',
103
- icon: (
104
- <div className="flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-sm bg-ods-border">
105
- <DollarSign width={10} height={10} className="text-ods-text-secondary" />
106
- </div>
107
- ),
108
- };
146
+ return classificationTag('commercial', text);
109
147
  // platform-capability tags — same neutral icon-box chrome as 'commercial'
110
148
  case 'api':
111
149
  return {
@@ -188,46 +226,12 @@ export function VendorTag({ type, text, className = '', hidden = false, size = '
188
226
  </div>
189
227
  ),
190
228
  };
191
- case 'classification': {
192
- // Handle specific classification types based on the text value
193
- const classificationType = text?.toLowerCase();
194
-
195
- if (classificationType === 'open_source') {
196
- return {
197
- text: 'Open Source',
198
- icon: (
199
- <div className="flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-sm bg-ods-accent">
200
- <Code width={10} height={10} className="text-ods-text-on-accent" />
201
- </div>
202
- ),
203
- };
204
- } else if (classificationType === 'commercial') {
205
- return {
206
- text: 'Commercial Vendor',
207
- icon: (
208
- <div className="flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-sm bg-ods-border">
209
- <DollarSign width={10} height={10} className="text-ods-text-secondary" />
210
- </div>
211
- ),
212
- };
213
- } else if (isOpenFrameClassification(classificationType)) {
214
- return openFrameTag(classificationType);
215
- } else {
216
- // Fallback for unknown classification types
217
- return {
218
- text: text || 'Classification',
219
- icon: (
220
- <div className="flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-sm bg-ods-accent">
221
- <span className="text-[8px] font-bold text-ods-text-on-accent">C</span>
222
- </div>
223
- ),
224
- };
225
- }
226
- }
229
+ case 'classification':
230
+ return classificationTag((text ?? '').toLowerCase(), undefined, accentColor);
227
231
  case 'openframe_selected':
228
232
  case 'openframe_connect':
229
233
  case 'openframe_shipped':
230
- return openFrameTag(type, text);
234
+ return classificationTag(type, text);
231
235
  default:
232
236
  return {
233
237
  text: text || type,
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import type { ProgramInstant } from './program-instant';
6
- import { OPENFRAME_CLASSIFICATIONS, isOpenFrameClassification } from './vendor-classification';
6
+ import { vendorClassificationCopy } from './vendor-classification';
7
7
 
8
8
  /**
9
9
  * Format a date to a human-readable string
@@ -941,8 +941,7 @@ export function stripHtml(html: string): string {
941
941
  * curated mapping.
942
942
  */
943
943
  export function formatClassification(classification: string): string {
944
- if (isOpenFrameClassification(classification)) return OPENFRAME_CLASSIFICATIONS[classification].label;
945
- return formatUnderscoreText(classification);
944
+ return vendorClassificationCopy(classification)?.label ?? formatUnderscoreText(classification);
946
945
  }
947
946
 
948
947
  /**
@@ -21,13 +21,15 @@ export * from './ai-providers';
21
21
  export * from './model-token-rates';
22
22
  export { TRIAL_TERMS, trialLabels, trialLengthLabel } from './trial';
23
23
  export {
24
- VENDOR_CLASSIFICATIONS,
25
- OPENFRAME_CLASSIFICATIONS,
26
- isVendorClassification,
24
+ VENDOR_CLASSIFICATION_COPY,
25
+ vendorClassificationCopy,
26
+ vendorClassificationGroup,
27
+ compareVendorClassifications,
27
28
  isOpenFrameClassification,
28
29
  type VendorClassificationValue,
29
- type OpenFrameClassification,
30
- type OpenFrameClassificationCopy,
30
+ type VendorClassificationGroup,
31
+ type VendorClassificationCopy,
32
+ type VendorClassificationIconConfig,
31
33
  } from './vendor-classification';
32
34
  // SVG path constants — re-exported here (server-safe) because icons-v2 has "use client"
33
35
  export { PLAY_ICON_PATH } from '../components/icons-v2-generated/media-playback/play-icon';