Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/callout-title-description-size.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@styleframe/theme": minor
---

Add `useCalloutTitleRecipe` (`callout-title`) and `useCalloutDescriptionRecipe` (`callout-description`) sub-part recipes, giving the callout the same per-size type scheme the toast recipe lands with so the two siblings stay visually consistent. The title tracks the `size` axis on the literal token (`sm` → `sm`, `md` → `md`, `lg` → `lg`); the description sits one token below at every step (`sm` → `xs`, `md` → `sm`, `lg` → `md`), default `md`. Values are copied from the toast recipe's landed source so the two recipes can't drift. Purely additive.
30 changes: 19 additions & 11 deletions apps/docs/content/docs/05.components/04.feedback/01.callout.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,20 @@ Add the Callout recipe to a local Styleframe instance. The global `styleframe.co
import { styleframe } from 'virtual:styleframe';
import {
useCalloutContentRecipe,
useCalloutDescriptionRecipe,
useCalloutDismissRecipe,
useCalloutIconRecipe,
useCalloutRecipe,
useCalloutTitleRecipe,
} from '@styleframe/theme';

const s = styleframe();

const callout = useCalloutRecipe(s);
const calloutIcon = useCalloutIconRecipe(s);
const calloutContent = useCalloutContentRecipe(s);
const calloutTitle = useCalloutTitleRecipe(s);
const calloutDescription = useCalloutDescriptionRecipe(s);
const calloutDismiss = useCalloutDismissRecipe(s);

export default s;
Expand Down Expand Up @@ -75,7 +79,7 @@ Import the `callout` runtime function from the virtual module and pass variant p

```vue [src/components/Callout.vue]
<script setup lang="ts">
import { callout, calloutContent, calloutDismiss, calloutIcon, type CalloutProps } from "virtual:styleframe";
import { callout, calloutContent, calloutDescription, calloutDismiss, calloutIcon, calloutTitle, type CalloutProps } from "virtual:styleframe";

const {
color = "neutral",
Expand All @@ -98,8 +102,8 @@ const emit = defineEmits<{
<slot name="icon" />
</span>
<div :class="calloutContent()">
<strong v-if="title">{{ title }}</strong>
<p v-if="description">{{ description }}</p>
<strong v-if="title" :class="calloutTitle({ size })">{{ title }}</strong>
<p v-if="description" :class="calloutDescription({ size })">{{ description }}</p>
<slot />
</div>
<button v-if="dismissible" type="button" :class="calloutDismiss()" aria-label="Dismiss" @click="emit('dismiss')">
Expand All @@ -112,7 +116,7 @@ const emit = defineEmits<{
#react

```ts [src/components/Callout.tsx]
import { callout, calloutContent, calloutDismiss, calloutIcon, type CalloutProps } from "virtual:styleframe";
import { callout, calloutContent, calloutDescription, calloutDismiss, calloutIcon, calloutTitle, type CalloutProps } from "virtual:styleframe";

interface CalloutComponentProps extends CalloutProps {
title?: string;
Expand Down Expand Up @@ -141,8 +145,8 @@ export function Callout({
<div className={classes} role="alert">
{icon && <span className={calloutIcon()} aria-hidden="true">{icon}</span>}
<div className={calloutContent()}>
{title && <strong>{title}</strong>}
{description && <p>{description}</p>}
{title && <strong className={calloutTitle({ size })}>{title}</strong>}
{description && <p className={calloutDescription({ size })}>{description}</p>}
{children}
</div>
{dismissible && (
Expand Down Expand Up @@ -294,11 +298,13 @@ story: theme-recipes-feedback-callout--all-sizes
---
::

| Size | Font Size | Padding (V / H) | Gap |
|------|-----------|------------------|-----|
| `sm` | `@font-size.xs` | `@0.5` / `@0.75` | `@0.5` |
| `md` | `@font-size.sm` | `@0.75` / `@1` | `@0.75` |
| `lg` | `@font-size.md` | `@1` / `@1.25` | `@1` |
| Size | Title | Description | Padding (V / H) | Gap |
|------|-------|-------------|------------------|-----|
| `sm` | `@font-size.sm` | `@font-size.xs` | `@0.5` / `@0.75` | `@0.5` |
| `md` | `@font-size.md` | `@font-size.sm` | `@0.75` / `@1` | `@0.75` |
| `lg` | `@font-size.lg` | `@font-size.md` | `@1` / `@1.25` | `@1` |

The title tracks the `size` axis on the literal token (`sm` → `sm`, `md` → `md`, `lg` → `lg`); the description sits one token below it at every step, so the title/description hierarchy reads by both size and weight (`semibold` vs the description's `normal`). This mirrors the toast recipe's scheme so the two siblings stay in step.

## Orientation

Expand Down Expand Up @@ -472,6 +478,8 @@ The callout's parts are styled by base-only recipes that accept the same `(s, op
|-----------|-------|---------|
| `useCalloutIconRecipe()` | `callout-icon` | Non-shrinking inline-flex wrapper for the leading icon |
| `useCalloutContentRecipe()` | `callout-content` | Main text area, fills the available space |
| `useCalloutTitleRecipe()` | `callout-title` | Headline line — semibold and snug, sits above the description |
| `useCalloutDescriptionRecipe()` | `callout-description` | Supporting line — one font-size token below the title, a step down in weight |
| `useCalloutDismissRecipe()` | `callout-dismiss` | Dismiss button reset with hover and focus-visible states |

[Learn more about recipes &rarr;](/docs/api/recipes)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,12 @@ const dismissClasses = computed(() => calloutDismiss());
<slot name="icon">{{ props.icon }}</slot>
</span>
<CalloutContent>
<CalloutTitle v-if="props.title">{{ props.title }}</CalloutTitle>
<CalloutTitle v-if="props.title" :size="props.size">{{
props.title
}}</CalloutTitle>
<CalloutDescription
v-if="props.description"
:size="props.size"
>{{ props.description }}</CalloutDescription
>
<slot />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
<script setup lang="ts">
import { computed } from "vue";
import { calloutDescription } from "virtual:styleframe";

const props = defineProps<{
size?: "sm" | "md" | "lg";
}>();

const classes = computed(() => calloutDescription({ size: props.size }));
</script>

<template>
<div class="callout-description">
<div :class="classes">
<slot />
</div>
</template>
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
<script setup lang="ts">
import { computed } from "vue";
import { calloutTitle } from "virtual:styleframe";

const props = defineProps<{
size?: "sm" | "md" | "lg";
}>();

const classes = computed(() => calloutTitle({ size: props.size }));
</script>

<template>
<strong class="callout-title">
<strong :class="classes">
<slot />
</strong>
</template>
4 changes: 4 additions & 0 deletions apps/storybook/stories/components/callout.styleframe.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import {
useCalloutContentRecipe,
useCalloutDescriptionRecipe,
useCalloutDismissRecipe,
useCalloutIconRecipe,
useCalloutRecipe,
useCalloutTitleRecipe,
} from "@styleframe/theme";
import { styleframe } from "virtual:styleframe";

Expand All @@ -13,6 +15,8 @@ const { selector } = s;
export const callout = useCalloutRecipe(s);
export const calloutIcon = useCalloutIconRecipe(s);
export const calloutContent = useCalloutContentRecipe(s);
export const calloutTitle = useCalloutTitleRecipe(s);
export const calloutDescription = useCalloutDescriptionRecipe(s);
export const calloutDismiss = useCalloutDismissRecipe(s);

// Container styles for story layout
Expand Down
2 changes: 2 additions & 0 deletions theme/src/recipes/callout/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export * from "./useCalloutRecipe";
export * from "./useCalloutIconRecipe";
export * from "./useCalloutContentRecipe";
export * from "./useCalloutTitleRecipe";
export * from "./useCalloutDescriptionRecipe";
export * from "./useCalloutDismissRecipe";
68 changes: 68 additions & 0 deletions theme/src/recipes/callout/useCalloutDescriptionRecipe.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { styleframe } from "@styleframe/core";
import { useCalloutDescriptionRecipe } from "./useCalloutDescriptionRecipe";

function createInstance() {
const s = styleframe();
for (const name of [
"display",
"marginTop",
"fontSize",
"fontWeight",
"lineHeight",
]) {
s.utility(name, ({ value }) => ({ [name]: value }));
}
return s;
}

describe("useCalloutDescriptionRecipe", () => {
it("should create a recipe with correct metadata", () => {
const s = createInstance();
const recipe = useCalloutDescriptionRecipe(s);

expect(recipe.type).toBe("recipe");
expect(recipe.name).toBe("callout-description");
});

it("should step down from the title in weight, with the size on the axis", () => {
const s = createInstance();
const recipe = useCalloutDescriptionRecipe(s);

expect(recipe.base).toEqual({
display: "block",
marginTop: "@0.25",
fontWeight: "@font-weight.normal",
lineHeight: "@line-height.normal",
});
});

it("should not set an explicit colour so it inherits the variant", () => {
const s = createInstance();
const recipe = useCalloutDescriptionRecipe(s);

expect(recipe.base).not.toHaveProperty("color");
});

describe("size", () => {
it("should track the size axis one token below the title at every step", () => {
const s = createInstance();
const recipe = useCalloutDescriptionRecipe(s);

// The title is sm → sm, md → md, lg → lg; the description sits one
// token below at each size. Mirrors useToastDescriptionRecipe
// exactly so the two siblings can't drift.
expect(recipe.variants!.size).toEqual({
sm: { fontSize: "@font-size.xs" },
md: { fontSize: "@font-size.sm" },
lg: { fontSize: "@font-size.md" },
});
});

it("should default to the md size", () => {
const s = createInstance();
const recipe = useCalloutDescriptionRecipe(s);

expect(recipe.defaultVariants).toEqual({ size: "md" });
});
});
});
36 changes: 36 additions & 0 deletions theme/src/recipes/callout/useCalloutDescriptionRecipe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { createUseRecipe } from "../../utils/createUseRecipe";

/**
* Callout description recipe — the supporting line below the title. A step down
* in both size and weight from the title, with a small top margin to separate
* the two lines. Colour is inherited (`currentColor`) — the callout's variant
* foreground, never a tint of its own — so the lower-contrast reading relative
* to the title is colour-agnostic and driftless.
*
* Font size follows the `size` axis one token below `useCalloutTitleRecipe` at
* every step — the title is sm → sm, md → md, lg → lg, so the description is
* sm → xs, md → sm, lg → md — keeping the description one step under the title
* at each size. Mirrors `useToastDescriptionRecipe` exactly so the two siblings
* can't drift. Defaults to `md` to match the callout.
*/
export const useCalloutDescriptionRecipe = createUseRecipe(
"callout-description",
{
base: {
display: "block",
marginTop: "@0.25",
fontWeight: "@font-weight.normal",
lineHeight: "@line-height.normal",
},
variants: {
size: {
sm: { fontSize: "@font-size.xs" },
md: { fontSize: "@font-size.sm" },
lg: { fontSize: "@font-size.md" },
},
},
defaultVariants: {
size: "md",
},
},
);
61 changes: 61 additions & 0 deletions theme/src/recipes/callout/useCalloutTitleRecipe.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { styleframe } from "@styleframe/core";
import { useCalloutTitleRecipe } from "./useCalloutTitleRecipe";

function createInstance() {
const s = styleframe();
for (const name of ["display", "fontWeight", "lineHeight", "fontSize"]) {
s.utility(name, ({ value }) => ({ [name]: value }));
}
return s;
}

describe("useCalloutTitleRecipe", () => {
it("should create a recipe with correct metadata", () => {
const s = createInstance();
const recipe = useCalloutTitleRecipe(s);

expect(recipe.type).toBe("recipe");
expect(recipe.name).toBe("callout-title");
});

it("should render a semibold headline that inherits colour", () => {
const s = createInstance();
const recipe = useCalloutTitleRecipe(s);

expect(recipe.base).toEqual({
display: "block",
fontWeight: "@font-weight.semibold",
lineHeight: "@line-height.snug",
});
});

it("should not set an explicit colour so it inherits the variant", () => {
const s = createInstance();
const recipe = useCalloutTitleRecipe(s);

expect(recipe.base).not.toHaveProperty("color");
});

describe("size", () => {
it("should track the size axis on the literal token", () => {
const s = createInstance();
const recipe = useCalloutTitleRecipe(s);

// Mirrors useToastTitleRecipe exactly — sm → sm, md → md, lg → lg —
// so the two siblings can't drift; the description sits one token
// below at every step.
expect(recipe.variants!.size).toEqual({
sm: { fontSize: "@font-size.sm" },
md: { fontSize: "@font-size.md" },
lg: { fontSize: "@font-size.lg" },
});
});

it("should default to the md size", () => {
const s = createInstance();
const recipe = useCalloutTitleRecipe(s);

expect(recipe.defaultVariants).toEqual({ size: "md" });
});
});
});
32 changes: 32 additions & 0 deletions theme/src/recipes/callout/useCalloutTitleRecipe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { createUseRecipe } from "../../utils/createUseRecipe";

/**
* Callout title recipe — the headline line of the callout. Semibold and snug so
* it reads as the dominant element above the description. Colour is inherited
* (`currentColor`), so the title takes the callout's variant foreground rather
* than a tint of its own — the hierarchy against the description is carried by
* both size and weight, so it holds across every colour and variant.
*
* Font size follows the `size` axis on the literal token — sm → sm, md → md,
* lg → lg — mirroring `useToastTitleRecipe` exactly so the two siblings can't
* drift. The description carries the same axis one token below at every step,
* keeping the title dominant by size and weight together. Defaults to `md` to
* match the callout.
*/
export const useCalloutTitleRecipe = createUseRecipe("callout-title", {
base: {
display: "block",
fontWeight: "@font-weight.semibold",
lineHeight: "@line-height.snug",
},
variants: {
size: {
sm: { fontSize: "@font-size.sm" },
md: { fontSize: "@font-size.md" },
lg: { fontSize: "@font-size.lg" },
},
},
defaultVariants: {
size: "md",
},
});
4 changes: 4 additions & 0 deletions tooling/plugin/playground/recipes/callout.styleframe.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import {
useCalloutContentRecipe,
useCalloutDescriptionRecipe,
useCalloutDismissRecipe,
useCalloutIconRecipe,
useCalloutRecipe,
useCalloutTitleRecipe,
} from "@styleframe/theme";
import { styleframe } from "virtual:styleframe";

Expand All @@ -11,6 +13,8 @@ const s = styleframe();
export const callout = useCalloutRecipe(s);
export const calloutIcon = useCalloutIconRecipe(s);
export const calloutContent = useCalloutContentRecipe(s);
export const calloutTitle = useCalloutTitleRecipe(s);
export const calloutDescription = useCalloutDescriptionRecipe(s);
export const calloutDismiss = useCalloutDismissRecipe(s);

export default s;
Loading