@flamingo-stack/openframe-frontend-core 0.0.442 → 0.0.443-snapshot.20260717175152

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 (112) hide show
  1. package/dist/{chunk-J5NO7PAG.cjs → chunk-2CJ734N2.cjs} +3 -3
  2. package/dist/{chunk-J5NO7PAG.cjs.map → chunk-2CJ734N2.cjs.map} +1 -1
  3. package/dist/{chunk-KUXGQ2FQ.cjs → chunk-2PLNDTRG.cjs} +32 -32
  4. package/dist/{chunk-KUXGQ2FQ.cjs.map → chunk-2PLNDTRG.cjs.map} +1 -1
  5. package/dist/{chunk-Y3NFB6HR.js → chunk-37ZZQXHK.js} +4 -4
  6. package/dist/{chunk-YZJUHEPZ.js → chunk-3SCNEGL2.js} +5 -5
  7. package/dist/{chunk-DUQQEQ7G.cjs → chunk-4R3SVOKH.cjs} +190 -156
  8. package/dist/chunk-4R3SVOKH.cjs.map +1 -0
  9. package/dist/{chunk-QO6MCQYE.cjs → chunk-7CZ4MJSI.cjs} +13 -10
  10. package/dist/chunk-7CZ4MJSI.cjs.map +1 -0
  11. package/dist/{chunk-W3JZXLJ2.js → chunk-ABIRMYG5.js} +6 -6
  12. package/dist/{chunk-TAQMYVO6.cjs → chunk-BYTJNRJW.cjs} +40 -40
  13. package/dist/{chunk-TAQMYVO6.cjs.map → chunk-BYTJNRJW.cjs.map} +1 -1
  14. package/dist/{chunk-IAZ5AN2E.js → chunk-CE3AITV2.js} +6 -6
  15. package/dist/{chunk-FPE3SQ5D.js → chunk-DCDB3SM4.js} +3 -3
  16. package/dist/{chunk-LFWPWIAC.cjs → chunk-DSW6FIEF.cjs} +7 -7
  17. package/dist/{chunk-LFWPWIAC.cjs.map → chunk-DSW6FIEF.cjs.map} +1 -1
  18. package/dist/{chunk-JYI4R25Q.js → chunk-DTMJWBDQ.js} +3 -3
  19. package/dist/{chunk-SM26C2L7.js → chunk-F2XHBAHT.js} +2 -2
  20. package/dist/{chunk-JNC4LVD6.cjs → chunk-F6NMMPDL.cjs} +29 -29
  21. package/dist/{chunk-JNC4LVD6.cjs.map → chunk-F6NMMPDL.cjs.map} +1 -1
  22. package/dist/{chunk-7QP65IE4.cjs → chunk-FO4CI4R5.cjs} +5 -5
  23. package/dist/{chunk-7QP65IE4.cjs.map → chunk-FO4CI4R5.cjs.map} +1 -1
  24. package/dist/{chunk-7KDR5P23.js → chunk-FQASXKBK.js} +2 -2
  25. package/dist/{chunk-7KNOHGMI.js → chunk-H7H3AFLF.js} +2 -2
  26. package/dist/{chunk-QAG357PE.js → chunk-LCJGOV6P.js} +2 -2
  27. package/dist/{chunk-2BTDPDME.js → chunk-MG4AMIFX.js} +4 -1
  28. package/dist/chunk-MG4AMIFX.js.map +1 -0
  29. package/dist/{chunk-ZQYFDYWV.js → chunk-NDWIFCLS.js} +120 -86
  30. package/dist/chunk-NDWIFCLS.js.map +1 -0
  31. package/dist/{chunk-UFBUQO7Z.cjs → chunk-OXZLBIR7.cjs} +18 -18
  32. package/dist/{chunk-UFBUQO7Z.cjs.map → chunk-OXZLBIR7.cjs.map} +1 -1
  33. package/dist/{chunk-63TSTDME.js → chunk-PMTR5W5G.js} +5 -5
  34. package/dist/{chunk-XMCVUYKC.cjs → chunk-Q6XOEVPH.cjs} +12 -12
  35. package/dist/{chunk-XMCVUYKC.cjs.map → chunk-Q6XOEVPH.cjs.map} +1 -1
  36. package/dist/{chunk-IZ6A6K6N.cjs → chunk-RALJEQWM.cjs} +15 -15
  37. package/dist/{chunk-IZ6A6K6N.cjs.map → chunk-RALJEQWM.cjs.map} +1 -1
  38. package/dist/{chunk-AGV26PUR.js → chunk-U7U2CZBZ.js} +3 -3
  39. package/dist/{chunk-LUKRPHEW.cjs → chunk-VI2QQIGR.cjs} +73 -73
  40. package/dist/{chunk-LUKRPHEW.cjs.map → chunk-VI2QQIGR.cjs.map} +1 -1
  41. package/dist/{chunk-JOH5D76X.cjs → chunk-WXU6A6CA.cjs} +73 -73
  42. package/dist/{chunk-JOH5D76X.cjs.map → chunk-WXU6A6CA.cjs.map} +1 -1
  43. package/dist/{chunk-IPEIOR77.cjs → chunk-YP7VJPCJ.cjs} +40 -40
  44. package/dist/{chunk-IPEIOR77.cjs.map → chunk-YP7VJPCJ.cjs.map} +1 -1
  45. package/dist/components/case-studies/index.cjs +9 -9
  46. package/dist/components/case-studies/index.js +3 -3
  47. package/dist/components/chat/index.cjs +3 -3
  48. package/dist/components/chat/index.js +2 -2
  49. package/dist/components/contact/index.cjs +4 -4
  50. package/dist/components/contact/index.js +3 -3
  51. package/dist/components/docs/index.cjs +6 -6
  52. package/dist/components/docs/index.js +5 -5
  53. package/dist/components/embeds/index.cjs +4 -4
  54. package/dist/components/embeds/index.js +3 -3
  55. package/dist/components/faq/index.cjs +5 -5
  56. package/dist/components/faq/index.js +4 -4
  57. package/dist/components/features/index.cjs +3 -3
  58. package/dist/components/features/index.js +2 -2
  59. package/dist/components/features/policy-configuration-panel.d.ts +11 -0
  60. package/dist/components/features/policy-configuration-panel.d.ts.map +1 -1
  61. package/dist/components/help-center-pages/index.cjs +22 -22
  62. package/dist/components/help-center-pages/index.js +13 -13
  63. package/dist/components/index.cjs +135 -135
  64. package/dist/components/index.js +11 -11
  65. package/dist/components/navigation/index.cjs +3 -3
  66. package/dist/components/navigation/index.js +2 -2
  67. package/dist/components/onboarding-guides/index.cjs +7 -7
  68. package/dist/components/onboarding-guides/index.js +6 -6
  69. package/dist/components/related-content/index.cjs +5 -5
  70. package/dist/components/related-content/index.js +4 -4
  71. package/dist/components/tickets/index.cjs +6 -6
  72. package/dist/components/tickets/index.js +5 -5
  73. package/dist/components/ui/checkbox-block.d.ts +6 -0
  74. package/dist/components/ui/checkbox-block.d.ts.map +1 -1
  75. package/dist/components/ui/index.cjs +3 -3
  76. package/dist/components/ui/index.js +2 -2
  77. package/dist/hooks/index.cjs +2 -2
  78. package/dist/hooks/index.js +1 -1
  79. package/dist/index.cjs +3 -3
  80. package/dist/index.js +2 -2
  81. package/dist/types/permissions.d.ts +9 -6
  82. package/dist/types/permissions.d.ts.map +1 -1
  83. package/dist/utils/embed-authed-fetch.d.ts +19 -3
  84. package/dist/utils/embed-authed-fetch.d.ts.map +1 -1
  85. package/dist/utils/index.cjs +3 -0
  86. package/dist/utils/index.cjs.map +1 -1
  87. package/dist/utils/index.js +3 -0
  88. package/dist/utils/index.js.map +1 -1
  89. package/package.json +1 -1
  90. package/src/components/features/policy-configuration-panel.tsx +182 -122
  91. package/src/components/ui/checkbox-block.tsx +12 -1
  92. package/src/stories/CheckboxBlock.stories.tsx +18 -0
  93. package/src/stories/PolicyConfigurationPanel.stories.tsx +254 -0
  94. package/src/types/permissions.ts +10 -7
  95. package/src/utils/__tests__/embed-authed-fetch.test.ts +39 -0
  96. package/src/utils/embed-authed-fetch.ts +27 -3
  97. package/dist/chunk-2BTDPDME.js.map +0 -1
  98. package/dist/chunk-DUQQEQ7G.cjs.map +0 -1
  99. package/dist/chunk-QO6MCQYE.cjs.map +0 -1
  100. package/dist/chunk-ZQYFDYWV.js.map +0 -1
  101. /package/dist/{chunk-Y3NFB6HR.js.map → chunk-37ZZQXHK.js.map} +0 -0
  102. /package/dist/{chunk-YZJUHEPZ.js.map → chunk-3SCNEGL2.js.map} +0 -0
  103. /package/dist/{chunk-W3JZXLJ2.js.map → chunk-ABIRMYG5.js.map} +0 -0
  104. /package/dist/{chunk-IAZ5AN2E.js.map → chunk-CE3AITV2.js.map} +0 -0
  105. /package/dist/{chunk-FPE3SQ5D.js.map → chunk-DCDB3SM4.js.map} +0 -0
  106. /package/dist/{chunk-JYI4R25Q.js.map → chunk-DTMJWBDQ.js.map} +0 -0
  107. /package/dist/{chunk-SM26C2L7.js.map → chunk-F2XHBAHT.js.map} +0 -0
  108. /package/dist/{chunk-7KDR5P23.js.map → chunk-FQASXKBK.js.map} +0 -0
  109. /package/dist/{chunk-7KNOHGMI.js.map → chunk-H7H3AFLF.js.map} +0 -0
  110. /package/dist/{chunk-QAG357PE.js.map → chunk-LCJGOV6P.js.map} +0 -0
  111. /package/dist/{chunk-63TSTDME.js.map → chunk-PMTR5W5G.js.map} +0 -0
  112. /package/dist/{chunk-AGV26PUR.js.map → chunk-U7U2CZBZ.js.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flamingo-stack/openframe-frontend-core",
3
- "version": "0.0.442",
3
+ "version": "0.0.443-snapshot.20260717175152",
4
4
  "description": "Shared design system and components for all Flamingo platforms",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -2,7 +2,7 @@
2
2
 
3
3
  import * as React from 'react'
4
4
  import { useRef, useEffect, useState } from 'react'
5
- import { ChevronDown } from 'lucide-react'
5
+ import { ChevronDown, Shield } from 'lucide-react'
6
6
  import { cn } from '../../utils/cn'
7
7
  import { ToolIcon } from '../tool-icon'
8
8
  import { Button } from '../ui/button'
@@ -34,62 +34,107 @@ const getApprovalLevelLabel = (level: ApprovalLevel | undefined, editMode: boole
34
34
  return option?.label || level
35
35
  }
36
36
 
37
+ const ApprovalLevelDropdown: React.FC<{
38
+ value: ApprovalLevel | undefined;
39
+ onChange: (level: ApprovalLevel | undefined) => void;
40
+ /** Adds the "Clear Global Permission" item (category bulk dropdown). */
41
+ allowClear?: boolean;
42
+ triggerClassName?: string;
43
+ contentClassName?: string;
44
+ }> = ({ value, onChange, allowClear = false, triggerClassName, contentClassName }) => (
45
+ <DropdownMenu>
46
+ <DropdownMenuTrigger asChild>
47
+ <Button
48
+ variant="outline"
49
+ rightIcon={<ChevronDown className="h-6 w-6 text-ods-text-secondary" />}
50
+ className={cn(
51
+ "!text-h6 bg-ods-card border border-ods-border rounded-md flex gap-[var(--spacing-system-xsf)] items-center justify-between !px-[var(--spacing-system-xsf)] py-[var(--spacing-system-xsf)] hover:bg-ods-bg-hover transition-colors h-auto",
52
+ value ? "text-ods-text-primary" : "text-ods-text-secondary",
53
+ triggerClassName
54
+ )}
55
+ >
56
+ {getApprovalLevelLabel(value, true)}
57
+ </Button>
58
+ </DropdownMenuTrigger>
59
+ <DropdownMenuContent align="end" className={contentClassName}>
60
+ {allowClear && (
61
+ <DropdownMenuItem onClick={() => onChange(undefined)} className="text-h6">
62
+ Clear Global Permission
63
+ </DropdownMenuItem>
64
+ )}
65
+ {approvalLevelOptions.map(option => (
66
+ <DropdownMenuItem
67
+ key={option.value}
68
+ onClick={() => onChange(option.value)}
69
+ className="text-h6"
70
+ >
71
+ {option.label}
72
+ </DropdownMenuItem>
73
+ ))}
74
+ </DropdownMenuContent>
75
+ </DropdownMenu>
76
+ )
77
+
37
78
  const PolicyRow: React.FC<{
38
79
  policy: PermissionPolicy;
39
80
  categoryId: string;
40
81
  editMode: boolean;
41
82
  onPermissionChange: (categoryId: string, policyId: string, level: ApprovalLevel) => void;
42
83
  }> = ({ policy, categoryId, editMode, onPermissionChange }) => {
84
+ const handleChange = (level: ApprovalLevel | undefined) => {
85
+ if (level) onPermissionChange(categoryId, policy.id, level)
86
+ }
87
+
43
88
  return (
44
- <div className="bg-ods-bg border-b border-ods-border flex gap-4 items-center px-4 py-3">
89
+ <div className="bg-ods-bg border-b last:border-b-0 border-ods-border flex gap-[var(--spacing-system-m)] items-start md:items-center px-[var(--spacing-system-m)] py-[var(--spacing-system-sf)]">
45
90
  {/* Tool Icon */}
46
- <div className="bg-ods-bg border border-ods-border rounded-md flex items-center justify-center w-8 h-8">
91
+ <div className="bg-ods-bg border border-ods-border rounded-md flex items-center justify-center w-8 h-8 shrink-0">
47
92
  <ToolIcon toolType={policy.toolName} size={16} />
48
93
  </div>
49
94
 
50
- {/* Policy Info */}
95
+ {/* Policy Info — on mobile the approval level stacks below the pattern */}
51
96
  <div className="flex-1 flex flex-col min-w-0">
52
- <p className="text-h6 text-ods-text-primary truncate" title={policy.name}>
97
+ <p className="text-h4 text-ods-text-primary truncate" title={policy.name}>
53
98
  {policy.name}
54
99
  </p>
55
- <p className="text-code text-ods-text-secondary break-all">
100
+ <p className="text-h6 text-ods-text-secondary break-all">
56
101
  {policy.commandPattern}
57
102
  </p>
103
+ {editMode ? (
104
+ <div className="md:hidden mt-[var(--spacing-system-xsf)]">
105
+ <ApprovalLevelDropdown
106
+ value={policy.approvalLevel}
107
+ onChange={handleChange}
108
+ triggerClassName="w-full"
109
+ contentClassName="w-[180px]"
110
+ />
111
+ </div>
112
+ ) : policy.approvalLevel ? (
113
+ <span className="md:hidden text-h4 text-ods-text-primary">
114
+ {getApprovalLevelLabel(policy.approvalLevel)}
115
+ </span>
116
+ ) : null}
58
117
  </div>
59
118
 
60
- {/* Approval Level Dropdown */}
61
- {editMode ? (
62
- <DropdownMenu>
63
- <DropdownMenuTrigger asChild>
64
- <Button
65
- variant="outline"
66
- rightIcon={<ChevronDown className="h-6 w-6 text-ods-text-secondary" />}
67
- className="md:!text-sm bg-ods-card border border-ods-border rounded-md flex gap-2 items-center justify-between !px-2 py-2 w-[180px] hover:bg-ods-bg-hover transition-colors text-ods-text-primary h-auto"
68
- >
69
- {getApprovalLevelLabel(policy.approvalLevel, editMode)}
70
- </Button>
71
- </DropdownMenuTrigger>
72
- <DropdownMenuContent align="end" className="w-[180px]">
73
- {approvalLevelOptions.map(option => (
74
- <DropdownMenuItem
75
- key={option.value}
76
- onClick={() => onPermissionChange(categoryId, policy.id, option.value)}
77
- className="text-h6"
78
- >
79
- {option.label}
80
- </DropdownMenuItem>
81
- ))}
82
- </DropdownMenuContent>
83
- </DropdownMenu>
84
- ) : policy.approvalLevel ? (
85
- <div className="px-3 py-2 w-[180px]">
86
- <span className="text-h6 text-ods-text-primary">
87
- {getApprovalLevelLabel(policy.approvalLevel, editMode)}
88
- </span>
89
- </div>
90
- ) : (
91
- <div className="px-3 py-2 w-[180px]" />
92
- )}
119
+ {/* Approval Level column (desktop only) */}
120
+ <div className="hidden md:block shrink-0">
121
+ {editMode ? (
122
+ <ApprovalLevelDropdown
123
+ value={policy.approvalLevel}
124
+ onChange={handleChange}
125
+ triggerClassName="w-[180px]"
126
+ contentClassName="w-[180px]"
127
+ />
128
+ ) : (
129
+ <div className="px-[var(--spacing-system-sf)] py-[var(--spacing-system-xsf)] w-[180px]">
130
+ {policy.approvalLevel && (
131
+ <span className="text-h4 text-ods-text-primary">
132
+ {getApprovalLevelLabel(policy.approvalLevel)}
133
+ </span>
134
+ )}
135
+ </div>
136
+ )}
137
+ </div>
93
138
  </div>
94
139
  )
95
140
  }
@@ -97,7 +142,7 @@ const PolicyRow: React.FC<{
97
142
  const useAnimatedHeight = (isExpanded: boolean) => {
98
143
  const [height, setHeight] = useState<number | 'auto'>(0);
99
144
  const contentRef = useRef<HTMLDivElement>(null);
100
-
145
+
101
146
  useEffect(() => {
102
147
  if (contentRef.current) {
103
148
  const contentHeight = contentRef.current.scrollHeight;
@@ -111,93 +156,73 @@ const useAnimatedHeight = (isExpanded: boolean) => {
111
156
  const CategorySection: React.FC<{
112
157
  category: PermissionCategory;
113
158
  editMode: boolean;
114
- onCategoryToggle: (categoryId: string) => void;
115
- onGlobalPermissionChange: (categoryId: string, level: ApprovalLevel | undefined) => void;
159
+ isExpanded: boolean;
160
+ /** Last bulk level applied to this category (display-only, edit mode). */
161
+ bulkLevel: ApprovalLevel | undefined;
162
+ onToggle: (categoryId: string) => void;
163
+ onBulkLevelChange: (categoryId: string, level: ApprovalLevel | undefined) => void;
116
164
  onPolicyPermissionChange: (categoryId: string, policyId: string, level: ApprovalLevel) => void;
117
- }> = ({
118
- category,
119
- editMode,
120
- onCategoryToggle,
121
- onGlobalPermissionChange,
122
- onPolicyPermissionChange
165
+ }> = ({
166
+ category,
167
+ editMode,
168
+ isExpanded,
169
+ bulkLevel,
170
+ onToggle,
171
+ onBulkLevelChange,
172
+ onPolicyPermissionChange
123
173
  }) => {
124
- const { contentRef, height } = useAnimatedHeight(category.isExpanded);
125
-
174
+ const { contentRef, height } = useAnimatedHeight(isExpanded);
175
+ const handleBulkChange = (level: ApprovalLevel | undefined) => onBulkLevelChange(category.id, level)
176
+
126
177
  return (
127
178
  <>
128
179
  {/* Category Header */}
129
- <div
130
- className="bg-ods-card border-t border-ods-border flex gap-4 items-center pl-4 pr-2 py-3 cursor-pointer hover:bg-ods-bg-hover transition-colors"
131
- onClick={() => onCategoryToggle(category.id)}
180
+ <div
181
+ className="bg-ods-card border-t first:border-t-0 border-ods-border flex gap-[var(--spacing-system-m)] items-center pl-[var(--spacing-system-m)] pr-[var(--spacing-system-xsf)] py-[var(--spacing-system-s)] cursor-pointer hover:bg-ods-bg-hover transition-colors"
182
+ onClick={() => onToggle(category.id)}
132
183
  >
133
184
  {/* Category Icon */}
134
- <div className="bg-ods-bg border border-ods-border rounded-md flex items-center justify-center w-8 h-8">
185
+ <div className="bg-ods-bg border border-ods-border rounded-md flex items-center justify-center w-8 h-8 shrink-0">
135
186
  <div className="text-ods-text-secondary">
136
- {category.icon}
187
+ {category.icon ?? <Shield className="w-4 h-4" />}
137
188
  </div>
138
189
  </div>
139
190
 
140
- {/* Category Info */}
141
- <div className="flex-1 flex flex-col">
142
- <p className="md:!text-sm font-medium text-ods-text-primary">
191
+ {/* Category Info — on mobile the bulk dropdown stacks below the count */}
192
+ <div className="flex-1 flex flex-col min-w-0">
193
+ <p className="text-h4 text-ods-text-primary truncate" title={category.name}>
143
194
  {category.name}
144
195
  </p>
145
196
  <p className="text-h6 text-ods-text-secondary">
146
- {category.configurationsCount} Configurations
197
+ {category.policies.length} Configurations
147
198
  </p>
199
+ {editMode && (
200
+ <div className="md:hidden mt-[var(--spacing-system-xsf)]" onClick={(e) => e.stopPropagation()}>
201
+ <ApprovalLevelDropdown
202
+ value={bulkLevel}
203
+ onChange={handleBulkChange}
204
+ allowClear
205
+ triggerClassName="w-full"
206
+ contentClassName="w-[256px]"
207
+ />
208
+ </div>
209
+ )}
148
210
  </div>
149
211
 
150
- {/* Global Permission Dropdown */}
212
+ {/* Global Permission column (desktop only) */}
151
213
  {editMode ? (
152
- <div onClick={(e) => e.stopPropagation()}>
153
- <DropdownMenu>
154
- <DropdownMenuTrigger asChild>
155
- <Button
156
- variant="outline"
157
- rightIcon={<ChevronDown className="h-6 w-6 text-ods-text-secondary" />}
158
- className="md:!text-sm bg-ods-card border border-ods-border rounded-md flex gap-2 items-center justify-between !px-2 py-2 w-[256px] hover:bg-ods-bg-hover transition-colors h-auto"
159
- >
160
- <span className={cn(
161
- "md:!text-sm font-medium",
162
- category.globalPermission
163
- ? "text-ods-text-primary"
164
- : "text-ods-text-secondary"
165
- )}>
166
- {getApprovalLevelLabel(category.globalPermission, editMode)}
167
- </span>
168
- </Button>
169
- </DropdownMenuTrigger>
170
- <DropdownMenuContent align="end" className="w-[256px]">
171
- <DropdownMenuItem
172
- onClick={() => onGlobalPermissionChange(category.id, undefined)}
173
- className="md:!text-sm"
174
- >
175
- Clear Global Permission
176
- </DropdownMenuItem>
177
- {approvalLevelOptions.map(option => (
178
- <DropdownMenuItem
179
- key={option.value}
180
- onClick={() => onGlobalPermissionChange(category.id, option.value)}
181
- className="md:!text-sm"
182
- >
183
- {option.label}
184
- </DropdownMenuItem>
185
- ))}
186
- </DropdownMenuContent>
187
- </DropdownMenu>
188
- </div>
189
- ) : category.globalPermission ? (
190
- <div
191
- className="px-3 py-2 w-[256px]"
192
- onClick={(e) => e.stopPropagation()}
193
- >
194
- <span className="md:!text-sm font-medium text-ods-text-primary">
195
- {getApprovalLevelLabel(category.globalPermission, editMode)}
196
- </span>
214
+ <div className="hidden md:block shrink-0" onClick={(e) => e.stopPropagation()}>
215
+ <ApprovalLevelDropdown
216
+ value={bulkLevel}
217
+ onChange={handleBulkChange}
218
+ allowClear
219
+ triggerClassName="w-[256px]"
220
+ contentClassName="w-[256px]"
221
+ />
197
222
  </div>
198
223
  ) : (
199
- <div
200
- className="px-3 py-2 w-[256px]"
224
+ <div
225
+ className="hidden md:block px-[var(--spacing-system-sf)] py-[var(--spacing-system-xsf)] w-[256px] shrink-0"
201
226
  onClick={(e) => e.stopPropagation()}
202
227
  />
203
228
  )}
@@ -208,32 +233,32 @@ const CategorySection: React.FC<{
208
233
  variant="transparent"
209
234
  onClick={(e) => {
210
235
  e.stopPropagation();
211
- onCategoryToggle(category.id);
236
+ onToggle(category.id);
212
237
  }}
213
- leftIcon={<ChevronDown
238
+ leftIcon={<ChevronDown
214
239
  className={cn(
215
240
  "h-6 w-6 text-ods-text-primary transition-transform duration-300",
216
- category.isExpanded && "rotate-180"
217
- )}
241
+ isExpanded && "rotate-180"
242
+ )}
218
243
  />}
219
- className="rounded-md h-auto w-auto"
220
- aria-label={category.isExpanded ? "Collapse" : "Expand"}
244
+ className="rounded-md h-auto w-auto shrink-0"
245
+ aria-label={isExpanded ? "Collapse" : "Expand"}
221
246
  />
222
247
  </div>
223
248
 
224
249
  {/* Policies List */}
225
- <div
250
+ <div
226
251
  ref={contentRef}
227
- style={{
252
+ style={{
228
253
  height: height === 'auto' ? 'auto' : `${height}px`,
229
254
  transition: 'height 300ms ease-in-out, opacity 300ms ease-in-out'
230
255
  }}
231
256
  className={cn(
232
257
  "overflow-hidden",
233
- category.isExpanded ? "opacity-100" : "opacity-0"
258
+ isExpanded ? "opacity-100" : "opacity-0"
234
259
  )}
235
260
  >
236
- <div className="px-4 pb-4 pt-0">
261
+ <div className="px-[var(--spacing-system-mf)] pb-[var(--spacing-system-mf)] pt-0">
237
262
  <div className="border border-ods-border rounded-md overflow-hidden">
238
263
  {category.policies.map((policy) => (
239
264
  <PolicyRow
@@ -251,14 +276,47 @@ const CategorySection: React.FC<{
251
276
  )
252
277
  }
253
278
 
279
+ /**
280
+ * Grouped list of permission categories with per-policy approval dropdowns.
281
+ * Presentation state — which categories are expanded and the last bulk level
282
+ * picked per category — lives inside the panel, so callers pass pure data and
283
+ * background data refreshes never collapse the user's view. Bulk selections
284
+ * reset when `editMode` changes.
285
+ *
286
+ * Responsive: below `md` the approval level / dropdowns stack inside the text
287
+ * column (per the ODS mobile design); at `md+` they render as fixed-width
288
+ * right-hand columns.
289
+ */
254
290
  export const PolicyConfigurationPanel: React.FC<PolicyConfigurationPanelProps> = ({
255
291
  categories,
256
292
  editMode,
257
- onCategoryToggle,
258
- onGlobalPermissionChange,
259
293
  onPolicyPermissionChange,
294
+ onCategoryPermissionChange,
260
295
  className
261
296
  }) => {
297
+ const [expandedIds, setExpandedIds] = useState<ReadonlySet<string>>(new Set())
298
+ const [bulkLevels, setBulkLevels] = useState<Record<string, ApprovalLevel | undefined>>({})
299
+
300
+ useEffect(() => {
301
+ setBulkLevels({})
302
+ }, [editMode])
303
+
304
+ const handleToggle = (categoryId: string) => {
305
+ setExpandedIds(prev => {
306
+ const next = new Set(prev)
307
+ if (next.has(categoryId)) next.delete(categoryId)
308
+ else next.add(categoryId)
309
+ return next
310
+ })
311
+ }
312
+
313
+ const handleBulkLevelChange = (categoryId: string, level: ApprovalLevel | undefined) => {
314
+ setBulkLevels(prev => ({ ...prev, [categoryId]: level }))
315
+ if (level) {
316
+ onCategoryPermissionChange(categoryId, level)
317
+ }
318
+ }
319
+
262
320
  return (
263
321
  <div className={cn(
264
322
  "bg-ods-card border border-ods-border rounded-md overflow-hidden",
@@ -269,8 +327,10 @@ export const PolicyConfigurationPanel: React.FC<PolicyConfigurationPanelProps> =
269
327
  key={category.id}
270
328
  category={category}
271
329
  editMode={editMode}
272
- onCategoryToggle={onCategoryToggle}
273
- onGlobalPermissionChange={onGlobalPermissionChange}
330
+ isExpanded={expandedIds.has(category.id)}
331
+ bulkLevel={bulkLevels[category.id]}
332
+ onToggle={handleToggle}
333
+ onBulkLevelChange={handleBulkLevelChange}
274
334
  onPolicyPermissionChange={onPolicyPermissionChange}
275
335
  />
276
336
  ))}
@@ -278,4 +338,4 @@ export const PolicyConfigurationPanel: React.FC<PolicyConfigurationPanelProps> =
278
338
  )
279
339
  }
280
340
 
281
- PolicyConfigurationPanel.displayName = 'PolicyConfigurationPanel'
341
+ PolicyConfigurationPanel.displayName = 'PolicyConfigurationPanel'
@@ -17,6 +17,12 @@ interface CheckboxBlockProps {
17
17
  description?: React.ReactNode
18
18
  /** Keep the label on one line and ellipsize on overflow instead of wrapping */
19
19
  truncateLabel?: boolean
20
+ /**
21
+ * Optional trailing slot rendered at the end of the row (e.g. an action
22
+ * button). Interactive content must call `preventDefault` on click to stop
23
+ * the wrapping label from toggling the checkbox.
24
+ */
25
+ trailing?: React.ReactNode
20
26
  disabled?: boolean
21
27
  /** Error message displayed below the block (also triggers red border) */
22
28
  error?: string
@@ -26,12 +32,14 @@ interface CheckboxBlockProps {
26
32
  const CheckboxBlock = React.forwardRef<
27
33
  React.ComponentRef<typeof CheckboxPrimitive.Root>,
28
34
  CheckboxBlockProps
29
- >(({ id, checked, defaultChecked, onCheckedChange, label, description, truncateLabel, disabled, error, className }, ref) => (
35
+ >(({ id, checked, defaultChecked, onCheckedChange, label, description, truncateLabel, trailing, disabled, error, className }, ref) => (
30
36
  <div className={cn("relative flex w-full flex-col", className)}>
31
37
  <label
32
38
  htmlFor={id}
33
39
  className={cn(
34
40
  "flex items-center gap-[var(--spacing-system-s)] rounded-md ring-1 ring-inset w-full",
41
+ // Trailing content stacks full-width below the text on mobile.
42
+ trailing && "flex-wrap md:flex-nowrap",
35
43
  "p-[var(--spacing-system-sf)]",
36
44
  !description && "min-h-[44px] md:min-h-[48px]",
37
45
  description && "min-h-[60px] md:min-h-[64px]",
@@ -82,6 +90,9 @@ const CheckboxBlock = React.forwardRef<
82
90
  </span>
83
91
  )}
84
92
  </div>
93
+ {trailing && (
94
+ <div className="flex w-full shrink-0 items-center md:ml-auto md:w-auto">{trailing}</div>
95
+ )}
85
96
  </label>
86
97
  {error && (
87
98
  <p className="absolute bottom-0 left-0 right-0 translate-y-full text-h6 truncate text-ods-error" title={error}>
@@ -1,5 +1,6 @@
1
1
  import type { Meta, StoryObj } from '@storybook/nextjs-vite';
2
2
  import { useState } from 'react';
3
+ import { Button } from '../components/ui/button';
3
4
  import { CheckboxBlock } from '../components/ui/checkbox-block';
4
5
 
5
6
  const meta = {
@@ -142,6 +143,23 @@ export const WithDescription: Story = {
142
143
  },
143
144
  };
144
145
 
146
+ /**
147
+ * Checkbox block with a trailing action. Interactive trailing content must
148
+ * call `preventDefault` on click so the wrapping label doesn't toggle the
149
+ * checkbox (see the OpenFrame customer guardrails tab for a real usage).
150
+ */
151
+ export const WithTrailingAction: Story = {
152
+ args: {
153
+ label: 'Use the default guardrails settings',
154
+ description: 'This customer follows guardrails defaults.',
155
+ trailing: (
156
+ <Button variant="outline" onClick={e => e.preventDefault()}>
157
+ Edit Default Guardrails
158
+ </Button>
159
+ ),
160
+ },
161
+ };
162
+
145
163
  /**
146
164
  * Checkbox block with description, checked.
147
165
  */