ReviewOS

also looking at this

stacks/commshq

refactor(ui): adopt shared workspace badges

#17
Merged glennmichael123 wants to merge refactor/commshq-shared-controls into main
5 files +118 -9
config/crosswind.tsmodified+14-0
Changes to config/crosswind.ts
@@ -6,6 +6,20 @@ import type { CrosswindConfig, Theme } from '@cwcss/crosswind'
66 * Use theme entries rather than layered preflight overrides of library tokens.
77 */
88export default {
9 shortcuts: {
10 // Preserve the existing consent pill while Badge owns its markup. Explicit
11 // precedence is required: class attribute order does not decide CSS order.
12 'audience-consent-badge': 'sm:!inline !px-2.5 !py-1 !font-bold !text-[#466833] !bg-[#edf5e8] '
13 + 'dark:!text-[#acd294] dark:!bg-[#75a65a]/15',
14 // These were flex items. Block-level flex avoids an extra inline baseline
15 // inside the component scope wrapper, preserving the original row heights.
16 'workspace-change-badge': '!flex !px-2.5 !py-1 !font-bold !text-[11px] !leading-[inherit] '
17 + '!text-[#42652e] !bg-[#eef5e8] dark:!text-[#a8d28e] dark:!bg-[#75a65a]/15',
18 'workspace-record-badge': '!flex !py-1 !font-bold !text-[10px] !leading-[inherit] '
19 + '!text-[#466833] !bg-[#eef5e8] dark:!text-[#acd294] dark:!bg-[#75a65a]/15',
20 // The readiness panel is dark in both modes, so this treatment stays fixed.
21 'workspace-check-badge': '!flex !py-1 !font-bold !text-[10px] !leading-[inherit] !text-[#bfe2ab] !bg-white/10',
22 },
923 theme: {
1024 extend: {
1125 borderRadius: {
resources/components/CommsHQ/AudienceWorkspace.stxmodified+5-5
Changes to resources/components/CommsHQ/AudienceWorkspace.stx
@@ -11,7 +11,7 @@ const contacts = [
1111 <div class="mx-auto max-w-[1500px]">
1212 <header class="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between mb-7">
1313 <div>
14 <p class="font-bold text-[#b8402f] dark:text-[#ff8c79] text-[11px] tracking-[0.2em] uppercase">Audience</p>
14 <p class="font-bold text-[#b8402f] text-[11px] tracking-[0.2em] uppercase dark:text-[#ff8c79]">Audience</p>
1515 <h1 class="mt-2 font-semibold text-4xl tracking-[-0.04em]">People, not rows.</h1>
1616 <p class="mt-2 max-w-2xl text-[#666a63] text-sm dark:text-[#aaa9a3]">One profile across email, SMS, purchases, consent, and publication membership.</p>
1717 </div>
@@ -33,17 +33,17 @@ const contacts = [
3333 <label class="relative"><span class="sr-only">Search contacts</span><span aria-hidden="true" class="absolute left-3 top-3 h-4 w-4 text-[#5f635d] dark:text-[#bcbab5] i-hugeicons-search-01"></span><input type="search" class="pl-9 pr-4 min-h-11 w-full sm:w-auto text-sm bg-transparent border border-[#171916]/12 rounded-full dark:border-white/15 focus:border-[#bf4936] focus:outline-none" placeholder="Search people"></label>
3434 </div>
3535
36 <div class="sm:hidden divide-y divide-[#171916]/10 dark:divide-white/10">
36 <div class="sm:hidden divide-[#171916]/10 divide-y dark:divide-white/10">
3737 @foreach(contacts as contact)
3838 <article class="p-5">
3939 <div class="flex gap-3 items-start justify-between">
4040 <div class="min-w-0"><h3 class="font-bold text-sm">{{ contact.name }}</h3><p class="mt-0.5 break-all text-[#5f635d] text-xs dark:text-[#bcbab5]">{{ contact.address }}</p></div>
4141 <button type="button" aria-label="Open contact menu for " class="inline-flex items-center justify-center shrink-0 h-11 w-11 hover:bg-black/5 dark:hover:bg-white/10 rounded-full"><span aria-hidden="true" class="block h-4 w-4 i-hugeicons-more-horizontal"></span></button>
4242 </div>
43 <dl class="grid grid-cols-2 gap-3 mt-4 text-xs">
43 <dl class="grid gap-3 grid-cols-2 mt-4 text-xs">
4444 <div><dt class="text-[#5f635d] dark:text-[#bcbab5]">Source</dt><dd class="mt-1 font-medium">{{ contact.source }}</dd></div>
4545 <div><dt class="text-[#5f635d] dark:text-[#bcbab5]">Revenue</dt><dd class="mt-1 font-bold">{{ contact.value }}</dd></div>
46 <div class="col-span-2"><dt class="sr-only">Consent</dt><dd class="inline-flex px-2.5 py-1 font-bold text-[#466833] dark:text-[#acd294] bg-[#edf5e8] dark:bg-[#75a65a]/15 rounded-full">{{ contact.state }}</dd></div>
46 <div class="col-span-2"><dt class="sr-only">Consent</dt><dd><Badge size="sm" className="audience-consent-badge">{{ contact.state }}</Badge></dd></div>
4747 </dl>
4848 </article>
4949 @endforeach
@@ -57,7 +57,7 @@ const contacts = [
5757 <tr class="border-[#171916]/8 border-t dark:border-white/8">
5858 <td class="px-5 py-4"><p class="font-bold text-sm">{{ contact.name }}</p><p class="mt-0.5 text-[#5f635d] text-xs dark:text-[#bcbab5]">{{ contact.address }}</p></td>
5959 <td class="px-5 py-4 text-sm">{{ contact.source }}</td>
60 <td class="px-5 py-4"><span class="px-2.5 py-1 font-bold text-[#466833] text-xs dark:text-[#acd294] bg-[#edf5e8] dark:bg-[#75a65a]/15 rounded-full">{{ contact.state }}</span></td>
60 <td class="px-5 py-4"><Badge size="sm" className="audience-consent-badge">{{ contact.state }}</Badge></td>
6161 <td class="px-5 py-4 font-bold text-sm">{{ contact.value }}</td>
6262 <td class="px-5 py-4 text-right"><button type="button" aria-label="Open contact menu for " class="inline-flex items-center justify-center h-11 w-11 hover:bg-black/5 dark:hover:bg-white/10 rounded-full"><span aria-hidden="true" class="block h-4 w-4 i-hugeicons-more-horizontal"></span></button></td>
6363 </tr>
resources/components/CommsHQ/CommandCenter.stxmodified+2-2
Changes to resources/components/CommsHQ/CommandCenter.stx
@@ -18,7 +18,7 @@ const activity = [
1818 <div class="mx-auto max-w-[1500px]">
1919 <header class="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between pb-7 border-[#171916]/10 border-b dark:border-white/10">
2020 <div>
21 <p class="mb-2 font-bold text-[#bd3e2e] dark:text-[#ff8a78] text-[11px] tracking-[0.2em] uppercase">Sunday Paper Co.</p>
21 <p class="mb-2 font-bold text-[#bd3e2e] text-[11px] tracking-[0.2em] uppercase dark:text-[#ff8a78]">Sunday Paper Co.</p>
2222 <h1 class="max-w-3xl font-semibold text-3xl tracking-[-0.04em] sm:text-4xl">Your audience is growing. Three moments need your attention.</h1>
2323 </div>
2424 <div class="flex flex-wrap gap-2">
@@ -32,7 +32,7 @@ const activity = [
3232 <div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
3333 @foreach(performance as item)
3434 <article class="p-5 bg-white/75 dark:bg-white/[0.04] border border-[#171916]/10 rounded-[1.4rem] dark:border-white/10 shadow-[0_1px_0_rgba(23,25,22,0.04)] hover:shadow-[0_18px_45px_rgba(35,28,22,0.08)] transition group hover:-translate-y-0.5">
35 <div class="flex items-center justify-between mb-6"><span class="grid place-items-center h-9 w-9 text-[#bd3e2e] dark:text-[#ff8c79] bg-[#fce5df] dark:bg-[#e65743]/15 rounded-full"><span class="h-[18px] w-[18px] "></span></span><span class="px-2.5 py-1 font-bold text-[#42652e] text-[11px] dark:text-[#a8d28e] bg-[#eef5e8] dark:bg-[#75a65a]/15 rounded-full">{{ item.change }}</span></div>
35 <div class="flex items-center justify-between mb-6"><span class="grid place-items-center h-9 w-9 text-[#bd3e2e] dark:text-[#ff8c79] bg-[#fce5df] dark:bg-[#e65743]/15 rounded-full"><span class="h-[18px] w-[18px] "></span></span><Badge size="sm" className="workspace-change-badge">{{ item.change }}</Badge></div>
3636 <p class="font-semibold text-3xl tracking-[-0.04em]">{{ item.value }}</p><p class="mt-1 text-[#6c6f68] text-sm dark:text-[#aaa9a3]">{{ item.label }}</p>
3737 </article>
3838 @endforeach
resources/components/CommsHQ/WorkspaceSurface.stxmodified+2-2
Changes to resources/components/CommsHQ/WorkspaceSurface.stx
@@ -162,7 +162,7 @@ const viewState = state('populated')
162162 <div class="flex items-center justify-between px-5 py-4 border-[#171916]/10 border-b dark:border-white/10"><h2 id="records-title" class="font-bold text-sm">Active work</h2><button type="button" aria-label="Filter records" class="p-2 text-[#6c6f68] dark:text-[#aaa9a3] hover:bg-black/5 dark:hover:bg-white/10 rounded-full"><span aria-hidden="true" class="block h-4 w-4 i-hugeicons-filter-horizontal"></span></button></div>
163163 <div class="divide-[#171916]/8 divide-y dark:divide-white/8">
164164 @foreach(page().records as record)
165 <article class="grid gap-4 sm:grid-cols-[1fr_auto] sm:items-center px-5 py-5"><div><div class="flex flex-wrap gap-2 items-center"><h3 class="font-bold text-sm">{{ record.name }}</h3><span class="px-2 py-1 font-bold text-[#466833] text-[10px] dark:text-[#acd294] bg-[#eef5e8] dark:bg-[#75a65a]/15 rounded-full">{{ record.status }}</span></div><p class="mt-1 leading-5 text-[#6c6f68] text-xs dark:text-[#aaa9a3]">{{ record.detail }}</p></div><p class="font-bold text-sm">{{ record.value }}</p></article>
165 <article class="grid gap-4 sm:grid-cols-[1fr_auto] sm:items-center px-5 py-5"><div><div class="flex flex-wrap gap-2 items-center"><h3 class="font-bold text-sm">{{ record.name }}</h3><Badge size="sm" className="workspace-record-badge">{{ record.status }}</Badge></div><p class="mt-1 leading-5 text-[#6c6f68] text-xs dark:text-[#aaa9a3]">{{ record.detail }}</p></div><p class="font-bold text-sm">{{ record.value }}</p></article>
166166 @endforeach
167167 </div>
168168 </section>
@@ -171,7 +171,7 @@ const viewState = state('populated')
171171 <div class="flex items-center justify-between"><h2 id="checks-title" class="font-bold text-sm">Readiness checks</h2><span aria-hidden="true" class="h-5 w-5 text-[#ff8c79] i-hugeicons-shield-02"></span></div>
172172 <div class="mt-5 space-y-1">
173173 @foreach(page().checklist as check)
174 <div class="py-4 border-b border-white/10 last:border-0"><div class="flex gap-4 items-start justify-between"><p class="font-bold text-sm">{{ check.label }}</p><span class="px-2 py-1 font-bold text-[#bfe2ab] text-[10px] bg-white/10 rounded-full">{{ check.state }}</span></div><p class="mt-1 leading-5 text-white/55 text-xs">{{ check.detail }}</p></div>
174 <div class="py-4 border-b border-white/10 last:border-0"><div class="flex gap-4 items-start justify-between"><p class="font-bold text-sm">{{ check.label }}</p><Badge size="sm" className="workspace-check-badge">{{ check.state }}</Badge></div><p class="mt-1 leading-5 text-white/55 text-xs">{{ check.detail }}</p></div>
175175 @endforeach
176176 </div>
177177 </aside>
tests/Unit/shared-badges.test.tsadded+95-0
Changes to tests/Unit/shared-badges.test.ts
@@ -0,0 +1,95 @@
1import { describe, expect, test } from 'bun:test'
2import { readFileSync } from 'node:fs'
3import { resolve } from 'node:path'
4import theme from '../../config/crosswind'
5import { processComponents } from '../../node_modules/@stacksjs/stx/dist/component-renderer.js'
6import { loadCssEngine } from '../../node_modules/@stacksjs/stx/dist/dev-server/ts-css.js'
7import { mergeCssConfig } from '../../node_modules/@stacksjs/stx/dist/ts-css-config.js'
8
9const root = resolve(import.meta.dir, '../..')
10
11describe('shared workspace badges', () => {
12 for (const [component, samples] of [
13 ['CommandCenter', [{ text: '+18.4%', item: { change: '+18.4%' } }]],
14 ['WorkspaceSurface', [{ text: 'Complete', record: { status: 'Complete' } }, { text: 'On', check: { state: 'On' } }]],
15 ] as const) {
16 test(`${component} uses quiet shared badges and preserves its status text`, async () => {
17 const file = resolve(root, `resources/components/CommsHQ/${component}.stx`)
18 const badges = readFileSync(file, 'utf8').match(/<Badge\b[^]*?<\/Badge>/g) || []
19 expect(badges).toHaveLength(samples.length)
20 for (const [index, badge] of badges.entries()) {
21 const html = await processComponents(badge, {
22 ...samples[index],
23 __importedComponents: new Map([['badge', resolve(root, 'node_modules/@stacksjs/components/src/ui/badge/Badge.stx')]]),
24 }, file, { componentsDir: resolve(root, 'resources/components') }, new Set())
25 expect(html).toContain(samples[index].text)
26 expect(html).not.toContain('<Badge')
27 expect(html).not.toContain('[Component Error')
28 expect(html).not.toContain('aria-live=')
29 expect(html).not.toContain('x-cloak')
30 }
31 })
32 }
33
34 test('audience consent renders through Badge without becoming a live announcement', async () => {
35 const file = resolve(root, 'resources/components/CommsHQ/AudienceWorkspace.stx')
36 const source = readFileSync(file, 'utf8')
37 const badges = source.match(/<Badge\b[^]*?<\/Badge>/g) || []
38 expect(badges).toHaveLength(2)
39 for (const badge of badges) {
40 const dependencies = new Set<string>()
41 const html = await processComponents(badge, {
42 contact: { state: 'SMS consented' },
43 __importedComponents: new Map([['badge', resolve(root, 'node_modules/@stacksjs/components/src/ui/badge/Badge.stx')]]),
44 }, file, { componentsDir: resolve(root, 'resources/components') }, dependencies)
45 expect([...dependencies]).toContain(resolve(root, 'node_modules/@stacksjs/components/src/ui/badge/Badge.stx'))
46 expect(html).toContain('SMS consented')
47 expect(html).toContain('audience-consent-badge')
48 expect(html).not.toContain('<Badge')
49 expect(html).not.toContain('[Component Error')
50 expect(html).not.toContain('role="status"')
51 expect(html).not.toContain('aria-live=')
52 expect(html).not.toContain('<button')
53 }
54 expect(source).toMatch(/<dd>\s*<Badge[^]*?<\/Badge>\s*<\/dd>/)
55 })
56
57 test('audience badge utilities preserve the existing light and dark consent palette', async () => {
58 const engine = await loadCssEngine()
59 if (!engine) throw new Error('The installed STX CSS engine is required')
60 const generator = new engine.CSSGenerator(mergeCssConfig(engine.defaultConfig || engine.config, theme).config)
61 generator.generate('audience-consent-badge')
62 const css = generator.toCSS(false, false)
63 expect(css).toContain('.audience-consent-badge')
64 expect(css).toContain('font-weight: 700 !important')
65 expect(css).toContain('#466833 !important')
66 expect(css).toContain('#edf5e8 !important')
67 expect(css).toContain('#acd294 !important')
68 expect(css).toContain('0.625rem !important')
69 expect(css).toContain('0.25rem !important')
70 expect(css).toContain('.dark')
71 // Desktop table cells previously used inline pills, unlike mobile cards.
72 expect(css).toContain('display: inline !important')
73 expect(css).toContain('640px')
74 })
75
76 test('workspace badge utilities retain the compact typography and contextual colors', async () => {
77 const engine = await loadCssEngine()
78 if (!engine) throw new Error('The installed STX CSS engine is required')
79 for (const [shortcut, declarations] of [
80 ['workspace-change-badge', ['#42652e', '#a8d28e', '#eef5e8', '11px', '0.625rem']],
81 ['workspace-record-badge', ['#466833', '#acd294', '#eef5e8', '10px']],
82 ['workspace-check-badge', ['#bfe2ab', '10px']],
83 ] as const) {
84 const generator = new engine.CSSGenerator(mergeCssConfig(engine.defaultConfig || engine.config, theme).config)
85 generator.generate(shortcut)
86 const css = generator.toCSS(false, false)
87 expect(css).toContain(`.${shortcut}`)
88 for (const declaration of declarations) expect(css).toContain(`${declaration} !important`)
89 expect(css).toContain('font-weight: 700 !important')
90 expect(css).toContain('line-height: inherit !important')
91 expect(css).toContain('display: flex !important')
92 expect(css).toContain('0.25rem !important')
93 }
94 })
95})