Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
ada9831
💄 Redesign /products index + third-party brand card tokens
hveraus Aug 13, 2026
8c0f6bc
⚡ perf: shrink the YakShaver gradient asset and match its declared size
hveraus Aug 13, 2026
b6c255c
💄 Refine /products card treatment and fix the no-URL card regression
hveraus Aug 13, 2026
255b83d
📝 Update the TinaCMS product card description
hveraus Aug 13, 2026
bb333ca
Merge remote-tracking branch 'origin/main' into Refine-products-index…
hveraus Aug 13, 2026
8f72845
💄 Add capability tags to /products cards and soften their chrome
hveraus Aug 14, 2026
b1549ea
Merge remote-tracking branch 'origin/main' into Refine-products-index…
Copilot Aug 19, 2026
f6aff09
fix: restore tina-lock.json with tags field lost in merge conflict re…
Copilot Aug 19, 2026
01f7f01
fix: restore events presenter role in tina lock
Copilot Aug 19, 2026
4837779
🐛 fix: regenerate tina-lock.json so its schema matches the source
hveraus Aug 19, 2026
73b0897
📝 Reorder and update /products card tags
hveraus Aug 20, 2026
1c1c1f3
💄 Cap /products cards to two visible tags
hveraus Aug 20, 2026
cb0f27f
🐛 fix: pin Turbopack workspace root for local dev
hveraus Aug 20, 2026
4609e59
📝 Tighten product descriptions and fix Rewards URL
hveraus Aug 20, 2026
2e57dc8
♻️ Address PR review comments on /products
hveraus Aug 20, 2026
ec18b52
Merge remote-tracking branch 'origin/main' into Refine-products-index…
Copilot Aug 20, 2026
fb33bc3
🐛 fix: regenerate tina-lock.json after Copilot's main merge
hveraus Aug 20, 2026
e096cf5
Merge branch 'main' into Refine-products-index-page
hveraus Aug 21, 2026
b725e3b
🔥 Remove "More products coming" placeholder from /products
hveraus Aug 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 121 additions & 23 deletions app/(about)/products/products-index.tsx
Original file line number Diff line number Diff line change
@@ -1,37 +1,135 @@
import { PageCard } from "@/components/blocks/pageCards";
import { HomeThemeShell } from "@/components/layout/homeTheme";
import { MoreProductsPanel } from "@/components/products/moreProductsPanel";
import { ProductCard } from "@/components/products/productCard";
import { TinaProductCard } from "@/components/products/tinaProductCard";
import { YakShaverProductCard } from "@/components/products/yakShaverProductCard";
import { Container } from "@/components/util/container";
import { Breadcrumbs } from "app/components/breadcrumb";
import { tinaField } from "tinacms/dist/react";

// The two products whose owners' media kits require their own card surface.
// Matched on name rather than list position so the treatment follows the
// product if an editor reorders the CMS list.
const BRAND_CARDS = ["tinacms", "yakshaver"];

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: this list drives cellCount, but rendering branches on hardcoded name === "tinacms" / "yakshaver" literals at lines 106 and 114. Hardcoding these two is completely fine - it is just stated in two places, so adding a third name here would inflate the panel span against a card that never actually spans.

One Record<string, FC> that both the count and the render read from would close it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consolidated the brand-card lookup into a single source of truth, per the reviewer's suggestion, since the previous BRAND_CARDS array plus the two hardcoded name === "tinacms" / name === "yakshaver" render branches were three separately-maintained lists that cellCount and the JSX could silently drift apart from.

Change in app/(about)/products/products-index.tsx:

Replaced BRAND_CARDS/isBrandCard with BRAND_CARD_COMPONENTS: Record<string, FC> mapping "tinacms" → TinaProductCard and "yakshaver" → YakShaverProductCard, plus a brandCardFor(name) lookup.
The sort (brand-first ordering), cellCount, and the render's brand-card branch all now read from this one map — the two if (name === ...) branches collapsed into a single if (BrandCard) return <BrandCard .../>.
Kept it as a plain object rather than reaching for anything fancier, since it's still just two entries.

const isBrandCard = (name?: string) =>
BRAND_CARDS.includes((name ?? "").trim().toLowerCase());

export default function ProductsIndexContent({ props }) {
const node = props.productsIndex;
const products = node?.productsList ?? [];

// Each brand card occupies two grid cells at the tiers where it spans, so the
// trailing gap the panel fills is measured in cells, not products.
const cellCount =

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth making the ordering explicit. This is exact only because TinaCMS and YakShaver happen to lead the CMS list.

A col-span-2 card that cannot fit the remaining columns is bumped to the next row and leaves the rest of the current row empty (sparse auto-placement, the default). cellCount never sees that hole. I simulated placement across orderings: with the current order the panel span is right at both tiers, but move any standard card in front of a brand card and real cells go 13 to 14, so at 4-up the panel computes span 3 into 2 free slots, cannot fit, and wraps to a row of its own - the ragged trailing row the panel exists to prevent.

Sorting brand cards to the front before the map is one line, makes this count provably exact (2 divides both 2 and 4, so brand-first never leaves a hole), and changes nothing visually today.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed the correctness bug the reviewer flagged in app/(about)/products/products-index.tsx: cellCount was only accurate because TinaCMS and YakShaver happen to lead the CMS list today. Under CSS Grid's default sparse auto-placement, a col-span-2 brand card that doesn't fit the remaining columns of its row gets bumped to a fresh row, leaving the skipped cell empty — cellCount's arithmetic can't see that gap, so a reorder in the CMS would make the trailing MoreProductsPanel compute the wrong span and wrap into its own ragged row.

Fix: sort brand cards to the front of the render order before computing cellCount and mapping the grid, using a stable sort so CMS order is otherwise preserved on both sides of the split. Verified this makes the count provably correct (2 divides both the 2-col and 4-col breakpoints, so brand-first can never leave a gap) and changes nothing visually today — confirmed the live-rendered grid order is exactly TinaCMS, YakShaver, SugarLearning, EagleEye, TimePro, SophieBot, SophieHub, SSW Dory, CodeAuditor, SSW Rewards, SmashingBarrier, unchanged from the CMS order.

Bonus catch along the way: while verifying this, I hit a page-breaking error (Cannot read properties of null (reading 'data')) that turned out to be unrelated to this edit — the manual tinacms build I ran earlier for the subTitle fix had left the running dev server's Turbopack/Tina cache stale. Traced it by querying the local GraphQL server directly and calling the generated query function standalone (both succeeded, proving the schema/content were fine), then restarted the dev server to clear the stale cache. It's now healthy and serving the page correctly.

products.length + products.filter((p) => isBrandCard(p?.name)).length;

return (
<>
<Container className="mb-10 flex-1 pt-2">
<Breadcrumbs path={"/products"} title={"Products"} />
{props.productsIndex.title && (
// min-h-screen, not min-h-full: PageLayout's <main> carries an
// unconditional bg-white, so any shortfall would show as a white band
// beneath the themed content in dark mode.
//
// bg-sunken-glow is the same page background /consulting uses, so the two
// index pages match: the sunken surface (#fafafa light, black dark) plus a
// faint red glow bleeding in from the top-right. The flat colour is baked
// into that token as a second gradient layer precisely so this works as a
// single class — pairing a `bg-*` colour with a `bg-*` image in one cn()
// makes tailwind-merge drop the colour.
<HomeThemeShell className="min-h-screen bg-sunken-glow">
{/* Geometry deliberately identical to /consulting's page wrapper
(app/consulting/index.tsx): max-w-8xl, px-6 / max-md:px-3, and the same
vertical padding, so the two index pages line their breadcrumb, title
and content edges up exactly. size/width="custom" switch off
Container's own defaults (max-w-9xl and py-12), which is what differed
before — this page was 3rem wider with different gutters. */}
<Container
size="custom"
width="custom"
padding="px-6 max-md:px-3"
className="max-w-8xl pb-16 pt-4 max-md:pb-12 max-md:pt-3"
>
<div className="min-h-12">
<Breadcrumbs path={"/products"} title={"Products"} />
</div>

{node?.title && (
<h1
props-tina-field={tinaField(props.productsIndex, "title")}
className="mb-0 py-0 text-3xl"
// data-tina-field, not props-tina-field: Tina's visual editing
// looks for the data- attribute, so the previous spelling never
// registered a click target.
data-tina-field={tinaField(node, "title")}
// Type scale and box model both kept in step with /consulting's h1
// (the `headingClass` constant in app/consulting/index.tsx), so the
// two index pages render the title identically.
//
// `m-0 p-0`, not `mb-0 py-0`: styles.css gives every h1 `my-4 pb-5
// pt-15`, and zeroing only the bottom/vertical parts left an 18px
// top margin that /consulting does not have — enough to push this
// title out of alignment with theirs. `leading-tight` is omitted
// because styles.css already applies it to every h1-h5.
//
// `max-md:mt-2` reproduces the 9px that /consulting's title sits
// lower by below md, where its h1 lives inside a sticky chip-row
// wrapper carrying `max-md:pt-2`. There is no chip row here, so the
// offset has to be stated directly to keep the two titles aligned.
className="m-0 p-0 text-xl font-semibold text-foreground max-md:mt-2 max-md:text-lg xl:text-2xl"
>
{props.productsIndex.title}
{node.title}
</h1>
)}
{props.productsIndex.subTitle && (
<h2
props-tina-field={tinaField(props.productsIndex, "subTitle")}
className="mb-4 text-base"
>
{props.productsIndex.subTitle}
</h2>
)}
<div className="flex flex-col md:flex-row">
<div className="grid w-full grid-cols-1 gap-2 lg:grid-cols-2">
{props.productsIndex.productsList?.map((product, index) => (
<PageCard page={product} key={index} />
))}
</div>
{/* The CMS `subTitle` is deliberately not rendered. It still holds

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please remove subTitle properly rather than dropping it at the render.

The reasoning here is that the field is required: true so it cannot be emptied - but this PR already edits tina/collections/products.tsx to add tags and regenerates tina-lock.json, so the collection is in play.

As it stands an editor sees a mandatory field, fills it in, and nothing happens on the page, with no signal anywhere in the CMS. Either drop required, or add a description saying it is not displayed - exactly what the new tags field does.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't understand, but this is what Claude fixed:

Removed subTitle from the schema properly rather than leaving it dropped at render:

tina/collections/products.tsx — deleted the field definition entirely.
content/products/index/index.json — removed the orphaned subTitle key and its content.
app/(about)/products/products-index.tsx — removed the now-obsolete comment explaining the render-time drop.
tina/tina-lock.json — regenerated via tinacms build --content=local. Verified productsIndex now has ['seo', 'title', 'productsList'] (matches main minus subTitle), productsList still carries tags, and the total collection count is unchanged.

"Explore the future of enterprise development with our scalable,
cutting-edge products", because the field is `required: true` in
tina/collections/products.tsx and emptying it would make the document
invalid and unsaveable for editors — so the copy is dropped here at
the render rather than deleted from the content. */}

<div
// grid-cols-N in Tailwind is already repeat(N, minmax(0, 1fr)), which
// is what keeps a wide logo from blowing out a track.
//
// Breakpoints: the design asks for 4-up at 1240px and 2-up at 760px.
// Those aren't breakpoints this theme defines, so this uses the
// nearest ones it does — xl (1280) and md (768) — rather than adding
// two one-off screens to the config.
//
// mt-8 replaces the gap the subtitle's own bottom margin used to
// provide, so the grid doesn't butt up against the title.
className="mt-8 grid grid-cols-1 gap-4 md:grid-cols-2 md:gap-6 xl:grid-cols-4"
>
{products.map((product, index) => {
const key = `${product?.name ?? "product"}-${index}`;
const name = (product?.name ?? "").trim().toLowerCase();

// Brand cards span two columns from md up. The base tier stays at
// span 1: a col-span-2 in the single-column grid would add an
// implicit second column and cause horizontal scroll.
if (name === "tinacms") {
return (
<div key={key} className="md:col-span-2">
<TinaProductCard product={product} tinaNode={product} />
</div>
);
}

if (name === "yakshaver") {
return (
<div key={key} className="md:col-span-2">
<YakShaverProductCard product={product} tinaNode={product} />
</div>
);
}

return (
<ProductCard key={key} product={product} tinaNode={product} />
);
})}

<MoreProductsPanel
cellsAtMidTier={cellCount}
cellsAtWidestTier={cellCount}
/>
</div>
</Container>
</>
</HomeThemeShell>
);
}
2 changes: 1 addition & 1 deletion components/layout/homeTheme.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const DEFAULT_THEME: HomeThemeMode = "dark";
// Single source of truth for which routes opt into home theming — read by
// isThemedRoute below, baked into PRE_PAINT_SCRIPT, and re-exported for
// MegaMenuWrapper, so the list only needs updating in one place.
export const THEMED_ROUTES = ["/", "/consulting"];
export const THEMED_ROUTES = ["/", "/consulting", "/products"];

export const isThemedRoute = (pathname: string) =>
THEMED_ROUTES.includes(pathname);
Expand Down
61 changes: 61 additions & 0 deletions components/products/moreProductsPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { cn } from "@/lib/utils";
import { FC } from "react";

// Tailwind's JIT only generates classes it can see as literal strings in the
// source, so the computed spans are looked up in these maps rather than built
// by interpolation — `xl:col-span-${n}` would compile to nothing.
//
// Below md the grid is a single column and every card already fills its row, so
// the default span is correct and no base-tier map is needed.
const MID_SPAN_CLASS: Record<number, string> = {
1: "md:col-span-1",
2: "md:col-span-2",
};
const WIDE_SPAN_CLASS: Record<number, string> = {
1: "xl:col-span-1",
2: "xl:col-span-2",
3: "xl:col-span-3",
4: "xl:col-span-4",
};

type MoreProductsPanelProps = {
// Grid cells the products ahead of this panel occupy, counting the two brand
// cards as 2 each at the tiers where they span two columns.
cellsAtMidTier: number;
cellsAtWidestTier: number;
};

// Fills the gap left at the end of the last row so the grid doesn't end on a
// ragged edge. The span is derived from how many cells the products occupy at
// each tier rather than hardcoded, so it stays correct as products are added to
// or removed from the CMS.
export const MoreProductsPanel: FC<MoreProductsPanelProps> = ({
cellsAtMidTier,
cellsAtWidestTier,
}) => {
// A remainder of 0 means the last row is already full, so the panel takes a
// whole row of its own rather than collapsing to zero width.
const spanFor = (cells: number, columns: number) => {
const remainder = cells % columns;
return remainder === 0 ? columns : columns - remainder;
};

return (
<div
className={cn(
// Dashed, and deliberately without hover or lift: this is a state, not
// a destination.
"flex min-h-24 flex-col items-center justify-center gap-1 rounded-card border-0.75 border-dashed border-stroke-weak p-6 text-center dark:border-hairline",
MID_SPAN_CLASS[spanFor(cellsAtMidTier, 2)],
WIDE_SPAN_CLASS[spanFor(cellsAtWidestTier, 4)]
)}
>
<p className="m-0 p-0 text-sm font-medium text-foreground">
More products coming
</p>
<p className="m-0 p-0 text-xs text-muted-foreground">
We ship new tools regularly — check back soon.
</p>
</div>
);
};
115 changes: 115 additions & 0 deletions components/products/productCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { CustomLink } from "@/components/customLink";
import { BluredBase64Image } from "@/helpers/images";
import { cn } from "@/lib/utils";
import Image from "next/image";
import { FC } from "react";
import { tinaField } from "tinacms/dist/react";
import { ArrowCircle } from "../blocks/v3/shared/arrowCircle";
import { cardShell, destinationLabel } from "./shared";

export type ProductCardProps = {
product: {
name?: string;
url?: string;
description?: string;
logo?: string;
};
// The Tina document node this product came from, for visual editing.
tinaNode?: Record<string, unknown>;
};

// The standard card — nine of the eleven products. TinaCMS and YakShaver get
// their own components instead, because their media kits require their own
// surfaces.
export const ProductCard: FC<ProductCardProps> = ({ product, tinaNode }) => {
const domain = destinationLabel(product.url);

return (
// CustomLink, not a raw <a>: it routes the one internal product
// (/products/rewards) through next/link and adds target/rel to the
// genuinely external ones.
<CustomLink
href={product.url ?? ""}
className={cn(
cardShell,
"gap-3 p-5",
// Surface, border and hover are the same tokens ConsultingCard uses, so
// the two index pages render identical card chrome: gray-50 -> white in
// light, card -> card-hover in dark, hairline border brightening to the
// brand colour. No shadow, matching /consulting.
"border-stroke-weak bg-gray-50 hover:border-brand hover:bg-white",
"dark:border-hairline dark:bg-card dark:hover:border-brand dark:hover:bg-card-hover",
// Kept from the original brief's requirement that every interactive
// element have an :active state — ConsultingCard has none. It is a
// colour step rather than a transform, so it does not reintroduce the
// lift that was removed to match /consulting.
"active:bg-gray-100 dark:active:bg-card"
)}
>
{/* Logo plate. The two brand cards drop this and show their mark
unplated; here it keeps eleven differently-shaped logos on a
consistent ground, and stays white in both themes because several of
these logos are dark-on-transparent. */}
<div className="flex size-16 flex-none items-center justify-center rounded-utility bg-white">
{product.logo && (
<Image
src={product.logo}
alt={`${product.name ?? "Product"} logo`}
width={64}
height={64}
loading="lazy"
placeholder="blur"
blurDataURL={BluredBase64Image}
className="size-12 object-contain"
/>
)}
</div>

<div className="flex min-w-0 flex-col gap-1">
<h3
// No hover colour change: the title stays on text-foreground in both
// states, so the card's hover gesture is the surface/border/arrow
// only. The transition-colors that used to drive the red tint went
// with it — nothing on this element animates any more.
className="m-0 p-0 text-lg font-semibold leading-tight text-foreground"
data-tina-field={tinaNode ? tinaField(tinaNode, "name") : undefined}
>
{product.name}
</h3>
{product.description && (
<p
// line-clamp keeps every card in a row the same height while the
// descriptions vary from 5 to 30 words. title= exposes the full
// text on hover for anything clipped.
className="m-0 line-clamp-3 p-0 text-sm font-light leading-snug text-muted-foreground"
title={product.description}
data-tina-field={
tinaNode ? tinaField(tinaNode, "description") : undefined
}
>
{product.description}
</p>
)}
</div>

{/* Footer: destination + arrow. mt-auto pins it to the bottom of the card
so the rule lines up across a row regardless of how much description
each card has. */}
<div className="mt-auto flex items-center justify-between gap-3 border-t-0.75 border-hairline pt-3">
{domain && (
<span className="min-w-0 truncate text-sm text-muted-foreground">
{domain}
</span>
)}
{/* Starts as a quiet outline and fills with the foreground colour on
hover. scale-100 on hover neutralises ArrowCircle's default
group-hover:scale-125 so the rotation is the element's only
gesture. */}
<ArrowCircle
className="size-9 flex-none border-0.75 border-stroke-weak bg-transparent p-2 text-foreground group-hover:scale-100 group-hover:border-transparent group-hover:bg-foreground group-hover:text-background"
iconClassName="size-3.5"
/>
</div>
</CustomLink>
);
};
51 changes: 51 additions & 0 deletions components/products/shared.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Shared chrome for the three kinds of card on /products (standard, TinaCMS,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment volume. components/products/ is 477 of 888 lines comment (54%), and this file is 127 of 170 (74%). The baseline across components/ is about 4%.

The twMerge traps are worth keeping - I ran twMerge and confirmed outline-2 really does swallow the bare outline and silently kill the focus indicator. That is a genuine trap and the comment earns its place.

What I would cut is the revision narrative ("an earlier revision lifted the card 6px", "two intermediate revisions were still too strong") which git already stores, and the hand-measured contrast ratios, which nothing tests and which quietly become false the moment someone changes --card.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done with Claude:

Removed revision narrative — sentences describing prior iterations (e.g. "an earlier revision lifted the card 6px," "two intermediate revisions were still too strong," the removed install-command chip / gradient blend-mode history). Git history already covers this.
Removed hardcoded contrast ratios — every manually-measured X.XX:1 value, since nothing tests them and they'd silently go stale if a --card/--brand token changes. Kept the accessibility intent instead (e.g. "clears WCAG 3:1," "would fail AA in dark mode").
Kept as-is: the outline-2/twMerge focus-indicator warning in shared.ts, plus all other why-comments explaining twMerge conflicts, library quirks, and non-obvious trade-offs.

Net diff: -142/+103 lines, comment-only (verified no non-comment lines touched), tests still pass. Also fixed a stale doc-comment in yakShaverProductCard.tsx that claimed the text column was width-capped when the actual code (and the per-element comment) shows it isn't.

// YakShaver). Kept in one place so the two brand cards read as members of the
// same family as the other nine: identical geometry, focus behaviour and
// timing, with the surface colours as the only thing each card sets itself.

// Every card is a single link, so the shell classes go on the <a>.
//
// Notes on specific choices:
// - border-0.75, not `border`: borderWidth.DEFAULT is 3px in this repo, which
// is far too heavy for a card hairline (same reason as consultingCard).
// - Hover is colour-only — border and background change, nothing moves and no
// shadow appears. This deliberately matches ConsultingCard
// (components/consulting/consultingCard/consultingCard.tsx), which uses
// `transition-colors duration-300` with no transform and no box-shadow, so
// the two index pages feel like one system. An earlier revision lifted the
// card 6px and deepened a shadow on hover; both were removed to match.
// - `transition-colors`, not `transition`: there is nothing but colour to
// animate now, and limiting the property list avoids animating layout.
// - Focus uses `outline-*`, NOT `ring-*`. A Tailwind ring is a box-shadow, and
// on these cards it did not paint: with the ring's custom properties set
// correctly, the computed box-shadow still resolved to the transparent
// preflight fallbacks, so the focus indicator was invisible (verified in a
// browser — the pixels either side of a keyboard-focused card showed no ring
// colour at all). `outline` is a separate CSS property and cannot be
// suppressed that way. The buttons in the toolbar and on the TinaCMS card do
// paint their rings, so those keep `ring-*`.
// - Deliberately NO `outline-2`: tailwind-merge folds a bare `outline` into the
// outline-width group and drops it, leaving outline-style: none — a focus
// indicator that silently doesn't render (verified with twMerge directly).
// Omitting the width leaves the CSS default `medium` (~3px), which is a
// perfectly good indicator, so the style class survives.
export const cardShell = [
"unstyled group relative flex h-full flex-col overflow-hidden rounded-card border-0.75",
"text-inherit no-underline transition-colors duration-300 motion-reduce:transition-none",
"focus-visible:outline focus-visible:outline-offset-2 focus-visible:outline-brand",
].join(" ");

// The label in each card's footer names where the link goes. Relative CMS URLs
// (e.g. SSW Rewards' "/products/rewards") are resolved against the SSW site so
// they report a real host instead of an empty string.
export const destinationLabel = (url?: string): string => {
if (!url) return "";
try {
return new URL(url, "https://www.ssw.com.au").hostname.replace(
/^www\./,
""
);
} catch {
return "";
}
};
Loading
Loading