From 3720e8c3d3b65e0176096e173b5a60f6e0f91cb1 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Sat, 16 May 2026 19:48:50 +0200 Subject: [PATCH] feat(gui-v2): port WorkspaceSwitcher to TypeScript Ports crewli-starter WorkspaceSwitcher into the Crewli SPA as production TypeScript: PrimeVue Popover replaces the manual click-outside listener, data is derived from useAuthStore/useOrganisationStore (no new store), gradient pairs are deterministic via a new pure util with full Vitest coverage. Co-Authored-By: Claude Opus 4.7 --- .../layout/WorkspaceSwitcher.vue | 256 ++++++++++++++++++ .../src/utils/v2/__tests__/gradient.spec.ts | 65 +++++ apps/app/src/utils/v2/gradient.ts | 39 +++ 3 files changed, 360 insertions(+) create mode 100644 apps/app/src/components-v2/layout/WorkspaceSwitcher.vue create mode 100644 apps/app/src/utils/v2/__tests__/gradient.spec.ts create mode 100644 apps/app/src/utils/v2/gradient.ts diff --git a/apps/app/src/components-v2/layout/WorkspaceSwitcher.vue b/apps/app/src/components-v2/layout/WorkspaceSwitcher.vue new file mode 100644 index 00000000..a5831dfe --- /dev/null +++ b/apps/app/src/components-v2/layout/WorkspaceSwitcher.vue @@ -0,0 +1,256 @@ + + + + + diff --git a/apps/app/src/utils/v2/__tests__/gradient.spec.ts b/apps/app/src/utils/v2/__tests__/gradient.spec.ts new file mode 100644 index 00000000..8a7d3de4 --- /dev/null +++ b/apps/app/src/utils/v2/__tests__/gradient.spec.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import { GRADIENT_PALETTE, computeOrgGradient } from '../gradient' + +/** + * TDD spec for computeOrgGradient. + * Written before the implementation — expected to fail until gradient.ts is created. + */ +describe('computeOrgGradient', () => { + it('is deterministic: same id always returns the identical pair', () => { + const id = '01jv1a2b3c4d5e6f7g8h9i0j' + const first = computeOrgGradient(id) + const second = computeOrgGradient(id) + + expect(first).toEqual(second) + }) + + it('returns a 2-tuple [string, string]', () => { + const pair = computeOrgGradient('some-org-id') + + expect(pair).toHaveLength(2) + expect(typeof pair[0]).toBe('string') + expect(typeof pair[1]).toBe('string') + }) + + it('returns hex strings (starts with # and has 7 chars)', () => { + const [a, b] = computeOrgGradient('abc') + + expect(a).toMatch(/^#[0-9a-f]{6}$/i) + expect(b).toMatch(/^#[0-9a-f]{6}$/i) + }) + + it('returned colors are drawn from the declared palette', () => { + const flatPalette = GRADIENT_PALETTE.flat() + const [a, b] = computeOrgGradient('test-id') + + expect(flatPalette).toContain(a) + expect(flatPalette).toContain(b) + }) + + it('different ids generally yield different gradient pairs', () => { + // Test a sample of 5 representative ids — at least 2 distinct pairs expected + const ids = ['org-1', 'org-2', 'org-3', 'org-4', 'org-5'] + const pairs = ids.map(id => computeOrgGradient(id).join(',')) + const unique = new Set(pairs) + + expect(unique.size).toBeGreaterThan(1) + }) + + it('handles empty string without throwing', () => { + expect(() => computeOrgGradient('')).not.toThrow() + + const pair = computeOrgGradient('') + + expect(pair).toHaveLength(2) + }) + + it('the returned pair consists of the two values at the chosen palette entry', () => { + // Verify internal structure: both values come from the same palette entry + const id = 'crewli-org-ulid' + const [a, b] = computeOrgGradient(id) + const matchingEntry = GRADIENT_PALETTE.find(([p0, p1]) => p0 === a && p1 === b) + + expect(matchingEntry).toBeDefined() + }) +}) diff --git a/apps/app/src/utils/v2/gradient.ts b/apps/app/src/utils/v2/gradient.ts new file mode 100644 index 00000000..391b6748 --- /dev/null +++ b/apps/app/src/utils/v2/gradient.ts @@ -0,0 +1,39 @@ +/** + * computeOrgGradient — deterministic gradient pair for an organisation. + * + * Maps an org `id` string to one of 8 teal-adjacent colour pairs via a + * simple djb2-style character-code hash. Same id always returns the + * same pair; no external dependencies. + * + * Palette rationale: teal / cyan / emerald / sea-green family to stay + * on-brand with Crewli's teal primary. Each tuple is [from, to] for a + * 135° linear gradient (darker "to" keeps depth on the logo square). + */ + +/** Exported so tests can assert membership without hard-coding values. */ +export const GRADIENT_PALETTE: [string, string][] = [ + ['#0d9488', '#0f766e'], // teal-600 → teal-700 + ['#0891b2', '#0e7490'], // cyan-600 → cyan-700 + ['#059669', '#047857'], // emerald-600 → emerald-700 + ['#10b981', '#059669'], // emerald-500 → emerald-600 + ['#0284c7', '#0369a1'], // sky-600 → sky-700 + ['#14b8a6', '#0d9488'], // teal-500 → teal-600 + ['#06b6d4', '#0891b2'], // cyan-500 → cyan-600 + ['#34d399', '#10b981'], // emerald-400 → emerald-500 +] + +/** + * Returns a `[fromHex, toHex]` colour pair deterministically derived + * from `id`. Handles empty string (hash stays 0; maps to palette[0]). + */ +export function computeOrgGradient(id: string): [string, string] { + // djb2-style hash: accumulate across char codes + let hash = 5381 + for (let i = 0; i < id.length; i++) { + // Equivalent to hash * 33 ^ charCode, kept 32-bit safe via >>> 0 + hash = ((hash << 5) + hash + id.charCodeAt(i)) >>> 0 + } + const index = hash % GRADIENT_PALETTE.length + + return GRADIENT_PALETTE[index] +}