+
diff --git a/apps/storybook/src/components/components/callout/CalloutTitle.vue b/apps/storybook/src/components/components/callout/CalloutTitle.vue
index 817e5811..781ae914 100644
--- a/apps/storybook/src/components/components/callout/CalloutTitle.vue
+++ b/apps/storybook/src/components/components/callout/CalloutTitle.vue
@@ -1,5 +1,16 @@
+
+
-
+
diff --git a/apps/storybook/stories/components/callout.styleframe.ts b/apps/storybook/stories/components/callout.styleframe.ts
index 8eb03924..31bf730b 100644
--- a/apps/storybook/stories/components/callout.styleframe.ts
+++ b/apps/storybook/stories/components/callout.styleframe.ts
@@ -1,8 +1,10 @@
import {
useCalloutContentRecipe,
+ useCalloutDescriptionRecipe,
useCalloutDismissRecipe,
useCalloutIconRecipe,
useCalloutRecipe,
+ useCalloutTitleRecipe,
} from "@styleframe/theme";
import { styleframe } from "virtual:styleframe";
@@ -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
diff --git a/theme/src/recipes/callout/index.ts b/theme/src/recipes/callout/index.ts
index 864d21aa..ab63c631 100644
--- a/theme/src/recipes/callout/index.ts
+++ b/theme/src/recipes/callout/index.ts
@@ -1,4 +1,6 @@
export * from "./useCalloutRecipe";
export * from "./useCalloutIconRecipe";
export * from "./useCalloutContentRecipe";
+export * from "./useCalloutTitleRecipe";
+export * from "./useCalloutDescriptionRecipe";
export * from "./useCalloutDismissRecipe";
diff --git a/theme/src/recipes/callout/useCalloutDescriptionRecipe.test.ts b/theme/src/recipes/callout/useCalloutDescriptionRecipe.test.ts
new file mode 100644
index 00000000..f66f44cd
--- /dev/null
+++ b/theme/src/recipes/callout/useCalloutDescriptionRecipe.test.ts
@@ -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" });
+ });
+ });
+});
diff --git a/theme/src/recipes/callout/useCalloutDescriptionRecipe.ts b/theme/src/recipes/callout/useCalloutDescriptionRecipe.ts
new file mode 100644
index 00000000..85357c70
--- /dev/null
+++ b/theme/src/recipes/callout/useCalloutDescriptionRecipe.ts
@@ -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",
+ },
+ },
+);
diff --git a/theme/src/recipes/callout/useCalloutTitleRecipe.test.ts b/theme/src/recipes/callout/useCalloutTitleRecipe.test.ts
new file mode 100644
index 00000000..89ee8a96
--- /dev/null
+++ b/theme/src/recipes/callout/useCalloutTitleRecipe.test.ts
@@ -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" });
+ });
+ });
+});
diff --git a/theme/src/recipes/callout/useCalloutTitleRecipe.ts b/theme/src/recipes/callout/useCalloutTitleRecipe.ts
new file mode 100644
index 00000000..b2df7e73
--- /dev/null
+++ b/theme/src/recipes/callout/useCalloutTitleRecipe.ts
@@ -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",
+ },
+});
diff --git a/tooling/plugin/playground/recipes/callout.styleframe.ts b/tooling/plugin/playground/recipes/callout.styleframe.ts
index ead084e2..3df78ef0 100644
--- a/tooling/plugin/playground/recipes/callout.styleframe.ts
+++ b/tooling/plugin/playground/recipes/callout.styleframe.ts
@@ -1,8 +1,10 @@
import {
useCalloutContentRecipe,
+ useCalloutDescriptionRecipe,
useCalloutDismissRecipe,
useCalloutIconRecipe,
useCalloutRecipe,
+ useCalloutTitleRecipe,
} from "@styleframe/theme";
import { styleframe } from "virtual:styleframe";
@@ -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;