diff --git a/.changeset/callout-title-description-size.md b/.changeset/callout-title-description-size.md new file mode 100644 index 00000000..73283e21 --- /dev/null +++ b/.changeset/callout-title-description-size.md @@ -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. diff --git a/apps/docs/content/docs/05.components/04.feedback/01.callout.md b/apps/docs/content/docs/05.components/04.feedback/01.callout.md index a73afe38..91300613 100644 --- a/apps/docs/content/docs/05.components/04.feedback/01.callout.md +++ b/apps/docs/content/docs/05.components/04.feedback/01.callout.md @@ -36,9 +36,11 @@ 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(); @@ -46,6 +48,8 @@ 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; @@ -75,7 +79,7 @@ Import the `callout` runtime function from the virtual module and pass variant p ```vue [src/components/Callout.vue] + 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;