From 0b1852ca9c1a4446205e577863196a8234764db1 Mon Sep 17 00:00:00 2001 From: James Morton Date: Sat, 31 Oct 2026 06:51:30 +0000 Subject: [PATCH 1/2] feat(design): switch a portal with its own CSS, and back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A portal with its own CSS stays on its old theme until someone switches. Its old Settings › Portal page now offers the switch: today's portal beside the portal switched, a report on the CSS, and Switch, held while the CSS has refusals. History offers the way back. The switch publishes today's branding converted, with the theme variables the CSS sets on :root and .dark read in first, since the design sets those variables itself and a tenant-layer :root would stop applying. The CSS carries over unchanged without asking the plan, as it is the portal's already. Switching back unpublishes the design, so the old theme, never touched, shows again; the design's versions stay. Both are audited. The switched frame is drawn by the engine on the server: with ?design=switch the bootstrap draws the portal switched, for a dashboard session that can edit branding only. The CSS check now offers the post-card part for .post-card. --- .../__tests__/appearance-view.test.tsx | 6 + .../__tests__/design-switch.test.tsx | 151 +++++++++ .../appearance/design-switch-card.tsx | 128 ++++++++ .../settings/appearance/history-dialog.tsx | 41 ++- .../settings/branding/portal-preview.tsx | 5 +- .../settings/security/audit-log-page.tsx | 2 + .../src/lib/client/queries/portal-design.ts | 12 +- apps/web/src/lib/server/audit/log.ts | 2 + .../domains/design/__tests__/css-lint.test.ts | 10 + .../design/__tests__/switch.db.test.ts | 187 +++++++++++ .../src/lib/server/domains/design/css-lint.ts | 3 + .../server/domains/design/design.actions.ts | 4 +- .../server/domains/design/design.service.ts | 67 ++++ .../web/src/lib/server/domains/design/http.ts | 2 + .../src/lib/server/domains/design/switch.ts | 201 ++++++++++++ .../__tests__/bootstrap-client-safe.test.ts | 91 ++++- .../web/src/lib/server/functions/bootstrap.ts | 310 ++++++++++-------- .../src/lib/server/functions/portal-design.ts | 26 ++ .../lib/server/policy/authz-matrix/MATRIX.md | 7 +- .../shared/design/__tests__/convert.test.ts | 35 +- apps/web/src/lib/shared/design/convert.ts | 42 ++- apps/web/src/routes/__root.tsx | 12 +- apps/web/src/routes/_portal.tsx | 6 +- apps/web/src/routes/admin/settings.portal.tsx | 27 +- 24 files changed, 1209 insertions(+), 168 deletions(-) create mode 100644 apps/web/src/components/admin/settings/appearance/__tests__/design-switch.test.tsx create mode 100644 apps/web/src/components/admin/settings/appearance/design-switch-card.tsx create mode 100644 apps/web/src/lib/server/domains/design/__tests__/switch.db.test.ts create mode 100644 apps/web/src/lib/server/domains/design/switch.ts diff --git a/apps/web/src/components/admin/settings/appearance/__tests__/appearance-view.test.tsx b/apps/web/src/components/admin/settings/appearance/__tests__/appearance-view.test.tsx index c88f7cab1a..33bc7991e1 100644 --- a/apps/web/src/components/admin/settings/appearance/__tests__/appearance-view.test.tsx +++ b/apps/web/src/components/admin/settings/appearance/__tests__/appearance-view.test.tsx @@ -56,6 +56,12 @@ vi.mock('@/lib/server/functions/portal-design', () => ({ return { ok: true, revisionId: 'portal_design_revision_1', number: 1 } }, restorePortalDesignVersionFn: async () => ({ ok: true }), + getPortalDesignSwitchFn: async () => ({ + available: false, + canSwitchBack: false, + report: { errors: [], warnings: [], carried: [] }, + }), + switchBackPortalDesignFn: async () => ({ ok: true }), })) vi.mock('@/lib/client/analytics', () => ({ diff --git a/apps/web/src/components/admin/settings/appearance/__tests__/design-switch.test.tsx b/apps/web/src/components/admin/settings/appearance/__tests__/design-switch.test.tsx new file mode 100644 index 0000000000..112613a4fa --- /dev/null +++ b/apps/web/src/components/admin/settings/appearance/__tests__/design-switch.test.tsx @@ -0,0 +1,151 @@ +// @vitest-environment happy-dom +/** + * A portal with its own CSS is offered the switch on its old Settings › Portal + * page: the CSS report, today's portal beside the switched one, and Switch, + * held back while the CSS has refusals. Once switched, Versions offers the way + * back. + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import type { ReactNode } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' + +const server = vi.hoisted(() => ({ + invalidations: 0, + switches: 0, + switchesBack: 0, + outcome: { ok: true } as { ok: true } | { ok: false; code: string; message: string }, +})) + +vi.mock('@tanstack/react-router', () => ({ + useRouter: () => ({ + invalidate: async () => { + server.invalidations++ + }, + }), + ClientOnly: ({ children }: { children: ReactNode }) => <>{children}, +})) +vi.mock('@/components/admin/settings/branding/portal-preview', () => ({ + PortalPreview: ({ switched }: { switched: boolean }) => ( +
+ ), +})) +vi.mock('@/lib/server/functions/portal-design', () => ({ + getPortalDesignSwitchFn: async () => null, + listPortalDesignVersionsFn: async () => [], + switchPortalDesignFn: async () => { + server.switches++ + return server.outcome + }, + switchBackPortalDesignFn: async () => { + server.switchesBack++ + return server.outcome + }, +})) + +const { portalDesignQueries } = await import('@/lib/client/queries/portal-design') +const { DesignSwitchCard } = await import('../design-switch-card') +const { HistoryDialog } = await import('../history-dialog') + +const REPORT = { + errors: [] as Array<{ message: string; line?: number }>, + warnings: [{ message: '.portal-nav__item is internal and may change.', line: 3 }], + carried: [{ message: '--primary on :root becomes the accent.' }], +} + +function withClient(ui: ReactNode, state: unknown) { + const client = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity } } }) + client.setQueryData(portalDesignQueries.switch().queryKey, state) + client.setQueryData(portalDesignQueries.versions().queryKey, []) + render({ui}) +} + +const card = + +afterEach(() => { + cleanup() + server.invalidations = 0 + server.switches = 0 + server.switchesBack = 0 + server.outcome = { ok: true } +}) + +describe('the switch card', () => { + it('is absent when there is nothing to switch', () => { + withClient(card, { available: false, canSwitchBack: true, report: REPORT }) + expect(screen.queryByRole('button', { name: 'Switch' })).toBeNull() + }) + + it('reports the CSS and compares today with the portal switched', () => { + withClient(card, { available: true, canSwitchBack: false, report: REPORT }) + expect(screen.getByRole('list', { name: 'Moves into the design' }).textContent).toBe( + '--primary on :root becomes the accent.' + ) + expect(screen.getByRole('list', { name: 'Check after switching' }).textContent).toBe( + 'Line 3: .portal-nav__item is internal and may change.' + ) + expect(screen.queryAllByTestId('frame')).toEqual([]) + fireEvent.click(screen.getByRole('button', { name: 'Compare' })) + expect(screen.getAllByTestId('frame').map((frame) => frame.dataset.switched)).toEqual([ + 'false', + 'true', + ]) + }) + + it('switches and reloads the page as Appearance', async () => { + withClient(card, { available: true, canSwitchBack: false, report: REPORT }) + await act(async () => fireEvent.click(screen.getByRole('button', { name: 'Switch' }))) + expect(server.switches).toBe(1) + expect(server.invalidations).toBe(1) + }) + + it('holds the switch while the CSS has refusals, and shows a refusal', async () => { + withClient(card, { + available: true, + canSwitchBack: false, + report: { ...REPORT, errors: [{ message: '@import is not allowed.', line: 1 }] }, + }) + expect( + screen.getByRole('list', { name: 'Fix in Advanced CSS before switching' }).textContent + ).toBe('Line 1: @import is not allowed.') + expect(screen.getByRole('button', { name: 'Switch' }).hasAttribute('disabled')).toBe(true) + + cleanup() + server.outcome = { ok: false, code: 'DESIGN_CHANGED', message: 'Already switched.' } + withClient(card, { available: true, canSwitchBack: false, report: REPORT }) + await act(async () => fireEvent.click(screen.getByRole('button', { name: 'Switch' }))) + expect(screen.getByRole('alert').textContent).toBe('Already switched.') + expect(server.invalidations).toBe(0) + }) +}) + +describe('switching back', () => { + const dialog = ( + {}} + currentNumber={1} + onRestore={async () => ({ ok: true })} + /> + ) + + it('is offered once the portal has switched, and asks first', async () => { + withClient(dialog, { available: false, canSwitchBack: true, report: REPORT }) + fireEvent.click(await screen.findByRole('button', { name: 'Switch back' })) + expect(server.switchesBack).toBe(0) + const confirm = await screen.findByRole('alertdialog') + await act(async () => + fireEvent.click( + [...confirm.querySelectorAll('button')].find((b) => b.textContent === 'Switch back')! + ) + ) + expect(server.switchesBack).toBe(1) + await waitFor(() => expect(server.invalidations).toBe(1)) + }) + + it('is not offered to a portal that never switched', async () => { + withClient(dialog, { available: false, canSwitchBack: false, report: REPORT }) + await screen.findByRole('dialog') + expect(screen.queryByRole('button', { name: 'Switch back' })).toBeNull() + }) +}) diff --git a/apps/web/src/components/admin/settings/appearance/design-switch-card.tsx b/apps/web/src/components/admin/settings/appearance/design-switch-card.tsx new file mode 100644 index 0000000000..541a74c618 --- /dev/null +++ b/apps/web/src/components/admin/settings/appearance/design-switch-card.tsx @@ -0,0 +1,128 @@ +import { useState } from 'react' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { ClientOnly, useRouter } from '@tanstack/react-router' +import { Button } from '@/components/ui/button' +import { PortalPreview } from '@/components/admin/settings/branding/portal-preview' +import { portalDesignQueries } from '@/lib/client/queries/portal-design' +import { switchPortalDesignFn } from '@/lib/server/functions/portal-design' +import type { PortalPreviewDraft } from '@/components/public/preview-draft-context' + +interface Issue { + message: string + line?: number +} + +const NO_DRAFT: PortalPreviewDraft = {} + +function Issues({ label, issues }: { label: string; issues: readonly Issue[] }) { + if (issues.length === 0) return null + return ( +
+

{label}

+
    + {issues.map((issue, index) => ( +
  • + {issue.line ? `Line ${issue.line}: ` : ''} + {issue.message} +
  • + ))} +
+
+ ) +} + +/** + * A portal with its own CSS stays on its old theme until someone switches. + * The card compares the portal as it is with the portal switched, reports + * what becomes of the CSS, and switches. Refused CSS has to be fixed in + * Advanced CSS first. + */ +export function DesignSwitchCard({ + workspaceName, + faviconUrl, +}: { + workspaceName: string + faviconUrl: string | null +}) { + const state = useQuery(portalDesignQueries.switch()) + const router = useRouter() + const queryClient = useQueryClient() + const [comparing, setComparing] = useState(false) + const [switching, setSwitching] = useState(false) + const [refusal, setRefusal] = useState(null) + if (!state.data?.available) return null + const { report } = state.data + const blocked = report.errors.length > 0 + + async function switchNow() { + setSwitching(true) + setRefusal(null) + const outcome = await switchPortalDesignFn() + if (outcome.ok) { + await queryClient.invalidateQueries({ queryKey: ['settings'] }) + await router.invalidate() + } else { + setRefusal(outcome.message) + } + setSwitching(false) + } + + const frame = (switched: boolean) => ( + + + + ) + + return ( +
+
+

+ Your portal uses custom CSS. Switch to the new design system. +

+ + +
+ {refusal && ( +

+ {refusal} +

+ )} + + + + {comparing && ( +
+
+
Today
+ {frame(false)} +
+
+
+ After switching +
+ {frame(true)} +
+
+ )} +
+ ) +} diff --git a/apps/web/src/components/admin/settings/appearance/history-dialog.tsx b/apps/web/src/components/admin/settings/appearance/history-dialog.tsx index ac18d17ab9..9dbe0a0b0c 100644 --- a/apps/web/src/components/admin/settings/appearance/history-dialog.tsx +++ b/apps/web/src/components/admin/settings/appearance/history-dialog.tsx @@ -1,5 +1,6 @@ import { useState } from 'react' -import { useQuery } from '@tanstack/react-query' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { useRouter } from '@tanstack/react-router' import type { PortalDesignRevisionId } from '@quackback/ids' import { AlertDialog, @@ -21,6 +22,7 @@ import { } from '@/components/ui/dialog' import { TimeAgo } from '@/components/ui/time-ago' import { portalDesignQueries } from '@/lib/client/queries/portal-design' +import { switchBackPortalDesignFn } from '@/lib/server/functions/portal-design' const SOURCE: Record = { appearance: 'Appearance', @@ -52,6 +54,20 @@ export function HistoryDialog({ number: number } | null>(null) const [refusal, setRefusal] = useState(null) + // A portal that switched from its old theme can go back to it. + const switched = useQuery({ ...portalDesignQueries.switch(), enabled: open }) + const [confirmingBack, setConfirmingBack] = useState(false) + const router = useRouter() + const queryClient = useQueryClient() + + async function switchBack() { + const outcome = await switchBackPortalDesignFn() + setConfirmingBack(false) + if (!outcome.ok) return setRefusal(outcome.message) + onOpenChange(false) + await queryClient.invalidateQueries({ queryKey: ['settings'] }) + await router.invalidate() + } async function restore() { if (!confirming) return @@ -104,6 +120,14 @@ export function HistoryDialog({ ))} + {switched.data?.canSwitchBack && ( +
+ Switched from the old theme. + +
+ )} {refusal && (

{refusal} @@ -125,6 +149,21 @@ export function HistoryDialog({ + + + + Switch back to the old theme? + + Visitors see the old theme and its CSS straight away. The design and its versions + stay. + + + + Cancel + Switch back + + + ) } diff --git a/apps/web/src/components/admin/settings/branding/portal-preview.tsx b/apps/web/src/components/admin/settings/branding/portal-preview.tsx index cfa0c13598..ba89709927 100644 --- a/apps/web/src/components/admin/settings/branding/portal-preview.tsx +++ b/apps/web/src/components/admin/settings/branding/portal-preview.tsx @@ -48,6 +48,8 @@ interface PortalPreviewProps { } | null /** The page the frame shows: the root, Home or the feed at its own address. */ page?: PreviewPage + /** Draw the portal as it would be after switching to the design. */ + switched?: boolean } export type PreviewPage = 'root' | 'home' | 'feedback' @@ -90,6 +92,7 @@ export function PortalPreview({ scaleToDesktop = false, sort = 'trending', page = 'root', + switched = false, }: PortalPreviewProps) { const frameBox = useRef(null) const [boxWidth, setBoxWidth] = useState(0) @@ -189,7 +192,7 @@ export function PortalPreview({