From f1cc39342980057a47a7256a4e421f4751dc2d0f Mon Sep 17 00:00:00 2001 From: James Morton Date: Sat, 31 Oct 2026 15:29:04 +0000 Subject: [PATCH 1/9] feat(portal): Home's blocks work as the pages they stand for - Share an idea is the feed's composer in the brand's composer shape. It opens in place with the board, details, sign-in and submit, posts to the first board the visitor can post on, and goes to the posted idea. - Ideas vote by the feed's rules: the board's, and the viewer's. - Help search searches the help centre as you type, with Ask AI where the help centre offers it; its field takes the design's composer corners. - A board default that arrives after the composer mounts is taken while none is chosen. --- .../help-center/help-center-search.tsx | 42 +++--- .../feedback/feedback-header-animated.tsx | 61 +++++++- .../public/home/__tests__/home-band.test.tsx | 26 +++- .../home/__tests__/home-composer.test.tsx | 122 +++++++++++++--- .../home/__tests__/home-ideas-vote.test.tsx | 136 ++++++++++++++++++ .../components/public/home/home-blocks.tsx | 123 ++++++---------- .../components/public/home/home-messages.ts | 11 -- .../components/public/home/home-prefetch.ts | 5 + apps/web/src/lib/client/queries/portal.ts | 11 ++ apps/web/src/locales/ar.json | 1 - apps/web/src/locales/de.json | 1 - apps/web/src/locales/en.json | 1 - apps/web/src/locales/es.json | 1 - apps/web/src/locales/fr.json | 1 - apps/web/src/locales/nl.json | 1 - apps/web/src/locales/pl.json | 1 - apps/web/src/locales/pt-br.json | 1 - apps/web/src/locales/ru.json | 1 - apps/web/src/locales/th.json | 1 - apps/web/src/locales/uk.json | 1 - apps/web/src/locales/zh-cn.json | 1 - apps/web/src/locales/zh-tw.json | 1 - 22 files changed, 399 insertions(+), 151 deletions(-) create mode 100644 apps/web/src/components/public/home/__tests__/home-ideas-vote.test.tsx diff --git a/apps/web/src/components/help-center/help-center-search.tsx b/apps/web/src/components/help-center/help-center-search.tsx index 2d2c57361c..3b14cfe45e 100644 --- a/apps/web/src/components/help-center/help-center-search.tsx +++ b/apps/web/src/components/help-center/help-center-search.tsx @@ -1,7 +1,7 @@ import { useState, useRef, useCallback, useEffect } from 'react' import { useIntl } from 'react-intl' import { MagnifyingGlassIcon, ArrowRightIcon } from '@heroicons/react/24/outline' -import { Button } from '@/components/ui/button' +import { cn } from '@/lib/shared/utils' import { contentPreview } from '@/lib/shared/utils/string' import { AskAiAnswerPanel, @@ -26,9 +26,15 @@ interface HelpCenterHeroSearchProps { * widget passes its own UI locale here; portal /hc routes pass the * route's locale param. */ locale?: string + /** `block` sits in a card, as on Home: shorter, and flat on it. */ + size?: 'hero' | 'block' } -export function HelpCenterHeroSearch({ askAiEnabled = false, locale }: HelpCenterHeroSearchProps) { +export function HelpCenterHeroSearch({ + askAiEnabled = false, + locale, + size = 'hero', +}: HelpCenterHeroSearchProps) { const intl = useIntl() const [query, setQuery] = useState('') const [showResults, setShowResults] = useState(false) @@ -123,8 +129,18 @@ export function HelpCenterHeroSearch({ askAiEnabled = false, locale }: HelpCente return (
-
- + {/* The field takes the design's composer corners, as the other places to type do. */} +
+
{/* Centred while it fits; wider than the screen, it scrolls from its start. */} -
+
{navigation}
-
+ ) } @@ -466,11 +465,11 @@ export function PortalHeader({
{/* Row 2: Navigation */} -
+
{navigation}
-
+ ) } diff --git a/apps/web/src/components/public/post-card.tsx b/apps/web/src/components/public/post-card.tsx index ff67993ac7..c840a05d7e 100644 --- a/apps/web/src/components/public/post-card.tsx +++ b/apps/web/src/components/public/post-card.tsx @@ -28,6 +28,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp import type { PostStatusEntity } from '@/lib/shared/db-types' import { usePostVote } from '@/lib/client/hooks/use-post-vote' import { cn } from '@/lib/shared/utils' +import { TAG_TINT, useTagStyle } from './tag-tint' import { useEnsureAnonSession } from '@/lib/client/hooks/use-ensure-anon-session' import { AuthorHoverCard } from '@/components/public/author-hover-card' import type { ChangelogId, PostId, PostStatusId, PrincipalId } from '@quackback/ids' @@ -151,6 +152,7 @@ export function PostCard({ }: PostCardProps): React.ReactElement { // Safe hook - returns null in admin context where AuthPopoverProvider isn't available const intl = useIntl() + const tagStyle = useTagStyle() const authPopover = useAuthPopoverSafe() // "Admin mode" here means the inbox list rendering: modal-style navigation // (onClick instead of a title link) plus the admin quick-actions overlay. It @@ -499,7 +501,10 @@ export function PostCard({ ) - const tagClassName = 'inline-flex items-center px-1.5 py-0 rounded text-[11px] font-medium' + const tagClassName = cn( + 'inline-flex items-center px-1.5 py-0 rounded text-[11px] font-medium', + TAG_TINT + ) const cardContent = (
@@ -549,12 +554,12 @@ export function PostCard({ {tags.length > 0 && (
{tags.slice(0, 3).map((tag) => { - // The tag's colour tints the chip; its text stays in the ink, which reads on it. - const style = { backgroundColor: tag.color + '20', color: 'var(--foreground)' } + const style = tagStyle(tag.color) return onTagClick ? ( ) : ( - + {tag.name} ) diff --git a/apps/web/src/components/public/post-detail/metadata-sidebar.tsx b/apps/web/src/components/public/post-detail/metadata-sidebar.tsx index 6a6f5b644d..16738566f2 100644 --- a/apps/web/src/components/public/post-detail/metadata-sidebar.tsx +++ b/apps/web/src/components/public/post-detail/metadata-sidebar.tsx @@ -37,6 +37,7 @@ import { type VotersQuerySource, } from '@/components/admin/feedback/voters-avatar-stack' import { cn, formatMonthYear } from '@/lib/shared/utils' +import { TAG_EDGE, TAG_TINT, useTagStyle } from '@/components/public/tag-tint' import type { PostStatusEntity } from '@/lib/shared/db-types' import type { OwnerRef } from '@/lib/server/functions/post-owner-context' import type { PostId, PostStatusId, PostTagId, BoardId, PrincipalId } from '@quackback/ids' @@ -389,6 +390,7 @@ export function MetadataSidebar({ onVotersInvalidate, manageActions, }: MetadataSidebarProps) { + const tagStyle = useTagStyle() const intl = useIntl() const [tagOpen, setTagOpen] = useState(false) const [boardOpen, setBoardOpen] = useState(false) @@ -809,15 +811,14 @@ export function MetadataSidebar({ className={cn( 'group inline-flex items-center gap-0.5 ps-1.5 pe-1 py-0.5', 'rounded-full text-[11px] font-medium border', + TAG_TINT, + TAG_EDGE, 'hover:opacity-80', 'transition-all duration-150', 'disabled:opacity-50 disabled:cursor-not-allowed' )} - style={{ - backgroundColor: tag.color + '20', - borderColor: tag.color + '40', - color: 'var(--foreground)', - }} + data-qb-part="tag" + style={tagStyle(tag.color)} > {tag.name} @@ -884,11 +885,12 @@ export function MetadataSidebar({ {tags.map((tag) => ( {tag.name} diff --git a/apps/web/src/components/public/roadmap-board.tsx b/apps/web/src/components/public/roadmap-board.tsx index 3f5b51449d..2225ec37ce 100644 --- a/apps/web/src/components/public/roadmap-board.tsx +++ b/apps/web/src/components/public/roadmap-board.tsx @@ -157,7 +157,8 @@ export function RoadmapBoard({ {columns.map((column, index) => (
+
-
+ {icon && ( diff --git a/apps/web/src/components/public/tag-tint.ts b/apps/web/src/components/public/tag-tint.ts new file mode 100644 index 0000000000..f5bd7bf81b --- /dev/null +++ b/apps/web/src/components/public/tag-tint.ts @@ -0,0 +1,21 @@ +import { useContext, type CSSProperties } from 'react' +import { StatusColourContext } from '@/components/ui/status-badge' + +/** + * A tag's chip, marked as the status markers are: tinted with its colour, in + * the brand's colours where the design maps them, and plain where the design + * draws statuses in the ink. Its text stays in the ink, which reads on it. + */ +export const TAG_TINT = + '[background:color-mix(in_oklch,var(--qb-tag)_14%,transparent)] text-foreground [[data-qb-status=ink]_&]:[background:var(--muted)]' + +/** The same chip's edge, where it has one. */ +export const TAG_EDGE = + '[border-color:color-mix(in_oklch,var(--qb-tag)_28%,transparent)] [[data-qb-status=ink]_&]:[border-color:var(--border)]' + +/** Each tag's colour as this page shows it, for its chip's style. */ +export function useTagStyle(): (colour: string | null | undefined) => CSSProperties { + const shown = useContext(StatusColourContext) + return (colour) => + ({ '--qb-tag': colour ? shown(colour) : 'var(--muted-foreground)' }) as CSSProperties +} diff --git a/apps/web/src/lib/shared/design/parts.ts b/apps/web/src/lib/shared/design/parts.ts index 69a8ebc2c4..58b1667dba 100644 --- a/apps/web/src/lib/shared/design/parts.ts +++ b/apps/web/src/lib/shared/design/parts.ts @@ -111,6 +111,18 @@ export const PORTAL_PARTS = [ tokens: ['--qb-vote-active', '--qb-radius-control'], files: ['components/public/post-card.tsx', 'components/public/vote-button.tsx'], }, + { + name: 'tag', + description: + "A post's or update's tag, tinted with its colour as status markers are, and plain when they are in the ink.", + states: [], + tokens: [], + files: [ + 'components/public/post-card.tsx', + 'components/public/post-detail/metadata-sidebar.tsx', + 'components/portal/changelog/changelog-entry-card.tsx', + ], + }, { name: 'search-field', description: 'Where a visitor searches the help centre, on its pages and on Home.', From b04430067d3ae6fed27ae8ec789c883b8058ab63 Mon Sep 17 00:00:00 2001 From: James Morton Date: Sat, 31 Oct 2026 16:02:56 +0000 Subject: [PATCH 5/9] feat(design): headlines can take their own face, and titles keep the band short - brand.fonts.display sets the page headlines in their own face, such as a condensed poster face, while section headings keep the heading font. Appearance offers it beside the heading and body fonts, Quackback AI can set it by name, and looks choose one when a brand's hero type differs. - A page title on the band keeps it short: no image beside or under the words, and less room around them. A page's own buttons there take the band's colours, so they read on it. - The roadmap's note on its height is a comment again, not page text. --- .../settings/appearance/fonts-section.tsx | 36 +++++++++++++++---- .../__tests__/portal-page-title.test.tsx | 21 ++++++++++- .../web/src/components/public/portal-band.tsx | 11 +++++- .../components/public/portal-page-title.tsx | 7 +++- .../domains/design/ai/design-ai-guide.ts | 2 +- .../domains/design/ai/design-directions.ts | 3 ++ .../server/domains/design/ai/design-looks.ts | 4 +++ .../design/__tests__/apply-changes.test.ts | 15 ++++++++ .../lib/shared/design/__tests__/fonts.test.ts | 6 ++++ .../shared/design/__tests__/resolve.test.ts | 14 +++++++- .../src/lib/shared/design/apply-changes.ts | 3 +- apps/web/src/lib/shared/design/components.ts | 1 + apps/web/src/lib/shared/design/fonts.ts | 9 ++--- apps/web/src/lib/shared/design/labels.ts | 1 + apps/web/src/lib/shared/design/resolve.ts | 2 +- apps/web/src/lib/shared/design/schema.ts | 2 ++ apps/web/src/routes/_portal/roadmap.index.tsx | 8 ++--- 17 files changed, 123 insertions(+), 22 deletions(-) diff --git a/apps/web/src/components/admin/settings/appearance/fonts-section.tsx b/apps/web/src/components/admin/settings/appearance/fonts-section.tsx index bb75d0d3ff..2ceaa83c75 100644 --- a/apps/web/src/components/admin/settings/appearance/fonts-section.tsx +++ b/apps/web/src/components/admin/settings/appearance/fonts-section.tsx @@ -80,20 +80,26 @@ export function guessFamily(name: string): string { return base.replace(/([a-z])([A-Z])/g, '$1 $2').trim() } +/** The choice that leaves an optional font to another. */ +const SAME = '__same__' + function FontSelect({ id, label, value, uploaded, onChange, + same, }: { id: string label: string - value: DesignFont + value: DesignFont | undefined uploaded: UploadedFont[] - onChange: (font: DesignFont) => void + onChange: (font: DesignFont | undefined) => void + /** Offers leaving it to another font, named so. */ + same?: string }) { - const current = value.kind === 'bundled' ? value.id : `${UPLOADED}${value.family}` + const current = !value ? SAME : value.kind === 'bundled' ? value.id : `${UPLOADED}${value.family}` return (