diff --git a/frontend/src/components/modals/GroupRectModal.tsx b/frontend/src/components/modals/GroupRectModal.tsx
index b9a4027..e698ce2 100644
--- a/frontend/src/components/modals/GroupRectModal.tsx
+++ b/frontend/src/components/modals/GroupRectModal.tsx
@@ -5,6 +5,7 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import type { TextPosition } from '@/types'
+import { hexToRgba, rgbaToHex8 } from '@/utils/colorUtils'
export type BorderStyle = 'solid' | 'dashed' | 'dotted' | 'double' | 'none'
@@ -204,23 +205,35 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
- {colorFields.map(({ key, label }) => (
-
-
+ )
+ })}
diff --git a/frontend/src/components/modals/__tests__/GroupRectModal.test.tsx b/frontend/src/components/modals/__tests__/GroupRectModal.test.tsx
index 7c7233c..4190bca 100644
--- a/frontend/src/components/modals/__tests__/GroupRectModal.test.tsx
+++ b/frontend/src/components/modals/__tests__/GroupRectModal.test.tsx
@@ -251,4 +251,60 @@ describe('GroupRectModal', () => {
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
expect(submitted.border_style).toBe('solid')
})
+
+ it('shows opacity sliders for all three color fields', () => {
+ render()
+ const sliders = screen.getAllByRole('slider')
+ expect(sliders).toHaveLength(3)
+ })
+
+ it('default background_color is 8-digit hex with low alpha', () => {
+ const onSubmit = vi.fn()
+ render()
+ fireEvent.click(screen.getByText('Add'))
+ const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
+ expect(submitted.background_color).toBe('#00d4ff0d')
+ expect(submitted.background_color.length).toBe(9)
+ })
+
+ it('moving background opacity slider updates background_color alpha', () => {
+ const onSubmit = vi.fn()
+ render()
+ // background slider is the third one (Text, Border, Background)
+ const sliders = screen.getAllByRole('slider')
+ fireEvent.change(sliders[2], { target: { value: '50' } })
+ fireEvent.click(screen.getByText('Add'))
+ const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
+ // alpha 50% → 0x80 = 128
+ expect(submitted.background_color).toBe('#00d4ff80')
+ })
+
+ it('moving border opacity slider to 0 makes border fully transparent', () => {
+ const onSubmit = vi.fn()
+ render()
+ const sliders = screen.getAllByRole('slider')
+ fireEvent.change(sliders[1], { target: { value: '0' } })
+ fireEvent.click(screen.getByText('Add'))
+ const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
+ expect(submitted.border_color).toBe('#00d4ff00')
+ })
+
+ it('pre-fills opacity from 8-digit initial background_color', () => {
+ render(
+
+ )
+ const sliders = screen.getAllByRole('slider')
+ expect((sliders[2] as HTMLInputElement).value).toBe('50')
+ })
+
+ it('shows opacity percentage in label', () => {
+ render()
+ // Background default is 5% opacity
+ expect(screen.getByText(/Background 5%/)).toBeInTheDocument()
+ })
})
diff --git a/frontend/src/utils/__tests__/colorUtils.test.ts b/frontend/src/utils/__tests__/colorUtils.test.ts
new file mode 100644
index 0000000..bb2b98f
--- /dev/null
+++ b/frontend/src/utils/__tests__/colorUtils.test.ts
@@ -0,0 +1,97 @@
+import { describe, it, expect } from 'vitest'
+import { hexToRgba, rgbaToHex8 } from '../colorUtils'
+
+describe('hexToRgba', () => {
+ it('splits 8-digit hex into hex6 and alpha', () => {
+ const { hex6, alpha } = hexToRgba('#00d4ff0d')
+ expect(hex6).toBe('#00d4ff')
+ expect(alpha).toBe(5)
+ })
+
+ it('handles fully opaque 8-digit hex (ff)', () => {
+ const { hex6, alpha } = hexToRgba('#00d4ffff')
+ expect(hex6).toBe('#00d4ff')
+ expect(alpha).toBe(100)
+ })
+
+ it('handles fully transparent 8-digit hex (00)', () => {
+ const { hex6, alpha } = hexToRgba('#00d4ff00')
+ expect(hex6).toBe('#00d4ff')
+ expect(alpha).toBe(0)
+ })
+
+ it('defaults alpha to 100 for 6-digit hex', () => {
+ const { hex6, alpha } = hexToRgba('#00d4ff')
+ expect(hex6).toBe('#00d4ff')
+ expect(alpha).toBe(100)
+ })
+
+ it('handles 6-digit hex without leading #', () => {
+ const { hex6, alpha } = hexToRgba('ff6e00')
+ expect(hex6).toBe('#ff6e00')
+ expect(alpha).toBe(100)
+ })
+
+ it('handles 8-digit hex without leading #', () => {
+ const { hex6, alpha } = hexToRgba('ff6e0080')
+ expect(hex6).toBe('#ff6e00')
+ expect(alpha).toBe(50)
+ })
+
+ it('returns fallback for invalid input', () => {
+ const { hex6, alpha } = hexToRgba('invalid')
+ expect(hex6).toBe('#000000')
+ expect(alpha).toBe(100)
+ })
+
+ it('is case-insensitive', () => {
+ const { hex6 } = hexToRgba('#00D4FF0D')
+ expect(hex6).toBe('#00D4FF')
+ })
+})
+
+describe('rgbaToHex8', () => {
+ it('combines hex6 and alpha into 8-digit hex', () => {
+ expect(rgbaToHex8('#00d4ff', 5)).toBe('#00d4ff0d')
+ })
+
+ it('produces ff for alpha 100', () => {
+ expect(rgbaToHex8('#00d4ff', 100)).toBe('#00d4ffff')
+ })
+
+ it('produces 00 for alpha 0', () => {
+ expect(rgbaToHex8('#00d4ff', 0)).toBe('#00d4ff00')
+ })
+
+ it('produces 80 for alpha 50', () => {
+ expect(rgbaToHex8('#ff6e00', 50)).toBe('#ff6e0080')
+ })
+
+ it('clamps alpha below 0 to 0', () => {
+ expect(rgbaToHex8('#ffffff', -10)).toBe('#ffffff00')
+ })
+
+ it('clamps alpha above 100 to 100', () => {
+ expect(rgbaToHex8('#ffffff', 150)).toBe('#ffffffff')
+ })
+
+ it('pads single-digit alpha hex with leading zero', () => {
+ const result = rgbaToHex8('#000000', 1)
+ const alphaPart = result.slice(7)
+ expect(alphaPart.length).toBe(2)
+ })
+})
+
+describe('round-trip', () => {
+ it('hexToRgba → rgbaToHex8 round-trips correctly', () => {
+ const original = '#00d4ff0d'
+ const { hex6, alpha } = hexToRgba(original)
+ expect(rgbaToHex8(hex6, alpha)).toBe(original)
+ })
+
+ it('round-trips fully opaque color', () => {
+ const original = '#a855f7ff'
+ const { hex6, alpha } = hexToRgba(original)
+ expect(rgbaToHex8(hex6, alpha)).toBe(original)
+ })
+})
diff --git a/frontend/src/utils/colorUtils.ts b/frontend/src/utils/colorUtils.ts
new file mode 100644
index 0000000..0e62436
--- /dev/null
+++ b/frontend/src/utils/colorUtils.ts
@@ -0,0 +1,29 @@
+/**
+ * Split a 6- or 8-digit hex color into its RGB hex and alpha (0–100).
+ * 6-digit input returns alpha 100.
+ * Invalid input returns { hex6: '#000000', alpha: 100 }.
+ */
+export function hexToRgba(hex: string): { hex6: string; alpha: number } {
+ const clean = hex.replace('#', '')
+ if (clean.length === 8) {
+ const alphaByte = parseInt(clean.slice(6, 8), 16)
+ return {
+ hex6: `#${clean.slice(0, 6)}`,
+ alpha: Math.round((alphaByte / 255) * 100),
+ }
+ }
+ if (clean.length === 6) {
+ return { hex6: `#${clean}`, alpha: 100 }
+ }
+ return { hex6: '#000000', alpha: 100 }
+}
+
+/**
+ * Combine a 6-digit hex color and an alpha (0–100) into an 8-digit hex.
+ */
+export function rgbaToHex8(hex6: string, alpha: number): string {
+ const clamped = Math.max(0, Math.min(100, alpha))
+ const alphaByte = Math.round((clamped / 100) * 255)
+ const alphaHex = alphaByte.toString(16).padStart(2, '0')
+ return `${hex6}${alphaHex}`
+}