From 074b49358bbfca896df2c74590793e908233b13c Mon Sep 17 00:00:00 2001 From: Pouzor Date: Mon, 20 Apr 2026 14:05:06 +0200 Subject: [PATCH] feat: add opacity slider to zone color pickers (fixes #72) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The native only supports 6-digit hex, stripping alpha and forcing background/border/text colors to be fully opaque on edit. Each color field now shows an opacity slider (0–100%) below the swatch. Values are stored as 8-digit hex (#rrggbbaa). Existing zones with 6-digit colors are handled transparently (alpha defaults to 100%). - colorUtils.ts: hexToRgba / rgbaToHex8 helpers - GroupRectModal: opacity sliders for all three color fields - 26 new tests across colorUtils and GroupRectModal --- .../src/components/modals/GroupRectModal.tsx | 43 +++++--- .../modals/__tests__/GroupRectModal.test.tsx | 56 +++++++++++ .../src/utils/__tests__/colorUtils.test.ts | 97 +++++++++++++++++++ frontend/src/utils/colorUtils.ts | 29 ++++++ 4 files changed, 210 insertions(+), 15 deletions(-) create mode 100644 frontend/src/utils/__tests__/colorUtils.test.ts create mode 100644 frontend/src/utils/colorUtils.ts 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}` +}