Make skeleton widths caller-controlled and sized

TextSkeleton and HeadingSkeleton baked w-full into their shared base.
Under tailwind-variants/lite (no tailwind-merge) that collided with the
caller's width class and won by stylesheet order, so every skeleton
ignored its width: bars stretched full in column contexts and collapsed
in flex rows.

Drop w-full from the base so the single caller width applies, matching
ButtonSkeleton/AvatarSkeleton. Size the compliance-portal TopBar and
Hero skeleton bars to roughly match their English copy, and add a rule
so skeleton widths are revisited when en-US strings change.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-06-28 15:01:04 +02:00
parent 1bf476c2d8
commit c004490b07
7 changed files with 59 additions and 13 deletions

View File

@@ -0,0 +1,31 @@
---
description: Keep skeleton placeholder widths in sync with English (en-US) copy
globs: "**/_locales/en-US.json"
alwaysApply: false
---
# Skeleton widths follow English copy
en-US is the reference locale for placeholder sizing. When you add or change a
user-facing string here, update the paired `*Skeleton` for the component that
renders it so its width still roughly matches the English text. Skeleton
primitives (`TextSkeleton`, `HeadingSkeleton`) take width via the caller's
`className` (e.g. `w-16`, `w-24`, `w-80`, or `w-full`); they do not default to a
width, so the placeholder must carry one.
Pairing convention: a component `Foo` has a sibling `FooSkeleton` that reuses the
same layout (see `contrib/claude/ui.md` and `contrib/claude/app-arborescence.md`).
```jsonc
// en-US.json — label lengthened from "Subprocessors"
"subprocessors": "Sub-processors & vendors"
```
```tsx
// TopBarSkeleton.tsx — bump the matching nav bar so it still mirrors the label
{ key: "subprocessors", width: "w-40" }, // was w-24
```
This is a best-effort visual convention, not a mechanically enforced link: match
the placeholder width to the English string length so loading states stay
harmonious. Other locales do not drive skeleton sizing.

View File

@@ -19,7 +19,13 @@ import { HeaderBand } from "#/components/HeaderBand/HeaderBand";
import { hero, organizationContactInfo } from "./variants";
const CONTACT_ITEM_KEYS = ["website", "email", "location"] as const;
// Width per contact item, roughly sized to its typical content (hostname /
// email / address). See .cursor/rules/skeleton-width-sync.mdc.
const CONTACT_ITEMS = [
{ key: "website", width: "w-28" },
{ key: "email", width: "w-40" },
{ key: "location", width: "w-36" },
] as const;
// Loading placeholder paired with Hero: reuses the same layout slots with
// skeleton primitives. Imports no Relay, so it renders instantly.
@@ -31,13 +37,13 @@ export function HeroSkeleton() {
<HeaderBand>
<div className={content()}>
<div className={section()}>
<HeadingSkeleton size={8} className="w-96" />
<HeadingSkeleton size={8} className="w-80" />
<TextSkeleton size={2} className="w-full max-w-2xl" />
</div>
<div className={root()}>
{CONTACT_ITEM_KEYS.map(key => (
<div key={key} className={item()}>
<TextSkeleton size={2} className="w-28" />
{CONTACT_ITEMS.map(contact => (
<div key={contact.key} className={item()}>
<TextSkeleton size={2} className={contact.width} />
</div>
))}
</div>

View File

@@ -18,7 +18,14 @@ import { TextSkeleton } from "@probo/ui/src/v2/typography/TextSkeleton";
import { topBar } from "./variants";
const NAV_ITEM_KEYS = ["documents", "subprocessors", "updates", "requests"] as const;
// Width per nav item, sized to its English label so the placeholder row reads
// like the real nav. See .cursor/rules/skeleton-width-sync.mdc.
const NAV_ITEMS = [
{ key: "documents", width: "w-16" },
{ key: "subprocessors", width: "w-24" },
{ key: "updates", width: "w-12" },
{ key: "requests", width: "w-14" },
] as const;
// Loading placeholder paired with TopBar: reuses the same layout slots with
// skeleton primitives. Imports no Relay / Base UI, so it renders instantly.
@@ -30,14 +37,14 @@ export function TopBarSkeleton() {
<div className={slots.inner()}>
<div className={slots.brand()}>
<AvatarSkeleton size={1} radius="small" />
<TextSkeleton size={2} className="w-20" />
<TextSkeleton size={2} className="w-24" />
</div>
<div className={slots.spacer()} />
<nav className={slots.nav()}>
{NAV_ITEM_KEYS.map(key => (
<TextSkeleton key={key} size={2} className="mx-3 w-16" />
{NAV_ITEMS.map(item => (
<TextSkeleton key={item.key} size={2} className={`mx-3 ${item.width}`} />
))}
<ButtonSkeleton size={2} />
</nav>

View File

@@ -20,7 +20,8 @@ import { headingSkeleton } from "./variants";
export type HeadingSkeletonProps = Omit<ComponentProps<"span">, "children"> & VariantProps<typeof headingSkeleton>;
// Loading placeholder paired with Heading: a pulse block matching a heading
// line at the given size. Defaults to full width; constrain with a width class.
// line at the given size. Supply a width class (e.g. w-80, or w-full for a full
// line); with none the block collapses to its content.
export function HeadingSkeleton(props: HeadingSkeletonProps) {
const { size, className, ...rest } = props;

View File

@@ -104,7 +104,7 @@ export const Skeleton: Story = {
render: () => (
<div className="flex w-96 flex-col gap-3">
<TextSkeleton size={6} className="w-1/2" />
<TextSkeleton size={3} />
<TextSkeleton size={3} className="w-full" />
<TextSkeleton size={3} className="w-2/3" />
</div>
),

View File

@@ -20,7 +20,8 @@ import { textSkeleton } from "./variants";
export type TextSkeletonProps = Omit<ComponentProps<"span">, "children"> & VariantProps<typeof textSkeleton>;
// Loading placeholder paired with Text: a pulse block matching a line of text
// at the given size. Defaults to full width; constrain with a width class.
// at the given size. Supply a width class (e.g. w-24, or w-full for a full
// line); with none the block collapses to its content.
export function TextSkeleton(props: TextSkeletonProps) {
const { size, className, ...rest } = props;

View File

@@ -97,7 +97,7 @@ export const heading = tv({
},
});
const skeletonBase = "inline-block w-full animate-pulse select-none rounded-2 bg-sand-3 text-transparent";
const skeletonBase = "inline-block animate-pulse select-none rounded-2 bg-sand-3 text-transparent";
export const textSkeleton = tv({
base: skeletonBase,