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/recipe-spacing-rhythm.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@forumone/throughline-design-system': minor
---

`lintRecipe` adds a `spacing.rhythm` rule. A layout nested in one of the same kind (a Stack in a Stack, a Cluster in a Cluster) must use a smaller gap than the one around it, or nothing in it reads as a group. `compose_section` refuses a recipe that breaks the rule. The publish gate re-lints recipes, so an approved recipe that breaks it stops publishing until it's fixed (forumone-2026#847).
42 changes: 42 additions & 0 deletions packages/design-system/src/recipes/lint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,3 +246,45 @@ describe('a bad recipe fails, saying where and why', () => {
expect(errors(recipe).map((issue) => issue.rule)).toContain('tree.depth')
})
})

/*
Things that belong together sit closer than the things around them
(forumone-2026#847): a Stack inside a Stack spaces its children more tightly
than the one around it, or nothing in it reads as a group.
*/
describe('spacing rhythm', () => {
/** The good recipe's stack spaced `outer`, its heading and text in a nested stack spaced `inner`. */
function nested(outer: string, inner: string | undefined): Recipe {
return variant((r) => {
const stack = stackOf(r)
stack.props = { gap: outer }
const children = stack.slots!['children']!
stack.slots!['children'] = [
{
primitive: 'Stack',
...(inner ? { props: { gap: inner } } : {}),
slots: { children: [children[0]!, children[1]!] },
},
children[2]!,
]
})
}

it('passes a group spaced more tightly than the gaps around it', () => {
expect(errors(nested('spacing-8', 'spacing-2'))).toEqual([])
})

it.each([
['as wide', 'spacing-4', 'spacing-4'],
['wider', 'spacing-2', 'spacing-8'],
['as wide, by default', 'spacing-2', undefined],
])('refuses an inner group spaced %s', (_label, outer, inner) => {
expect(errors(nested(outer, inner))).toEqual([
expect.objectContaining({
rule: 'spacing.rhythm',
path: 'tree.slots.children[0].slots.start[0].slots.children[0].props.gap',
message: expect.stringContaining(`smaller than ${outer}`),
}),
])
})
})
59 changes: 59 additions & 0 deletions packages/design-system/src/recipes/lint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,8 @@ export function lintRecipe(input: unknown, manifest: Manifest): RecipeIssue[] {
error(walk, 'tree', 'tree.size', `${walk.nodes} nodes; a recipe has at most ${MAX_NODES}.`)
}

lintRhythm(recipe.tree, 'tree', new Map(), walk)

let previous = 1
for (const heading of walk.headings) {
if (heading.level > previous + 1) {
Expand Down Expand Up @@ -374,3 +376,60 @@ function bound(name: string, path: string, walk: Walk): ContentField | undefined
walk.used.add(name)
return field
}

/*
Things that belong together sit closer than the things around them.

A Stack inside a Stack is a group inside a larger arrangement: a column's
heading and text, inside a section's heading, columns and button. If the inner
gap is as wide as the outer one, the eye cannot tell which text belongs to
which heading, and the section reads as an even list of lines. That is the
failure in forumone-2026#847, where it was the margins rather than the gaps,
and it is the one an agent choosing gaps one Stack at a time can make on its
own. A layout nested in one of the same kind, spaced along the same axis, must
use a smaller gap than the one around it.

Only the same kind is compared: a Cluster's gap runs across, a Stack's down,
and a Grid's is the site's gutter. Gaps are compared by their step on the
spacing scale, the number at the end of the token's name; a token without one
is not compared.
*/
function gapStep(value: unknown): number | undefined {
if (typeof value !== 'string') return undefined
const match = /-(\d+)$/.exec(value)
return match ? Number(match[1]) : undefined
}

function lintRhythm(
node: RecipeNode,
path: string,
around: ReadonlyMap<string, { gap: string; step: number; path: string }>,
walk: Walk,
): void {
if (isComponentNode(node)) return
const primitive = walk.manifest.primitives?.[node.primitive]
if (!primitive) return

let inner = around
const gapProp = primitive.props['gap']
if (gapProp && gapProp.type === 'token') {
const gap = String(node.props?.['gap'] ?? gapProp.default ?? '')
const step = gapStep(gap)
if (step !== undefined) {
const outer = around.get(primitive.name)
if (outer && step >= outer.step) {
error(
walk,
`${path}.props.gap`,
'spacing.rhythm',
`This ${primitive.name}'s ${gap} is as wide as or wider than the ${outer.gap} of the ${primitive.name} around it, so nothing in it reads as belonging together. Make it smaller than ${outer.gap}: a heading and its text are usually spacing-2.`,
)
}
inner = new Map(around).set(primitive.name, { gap, step, path })
}
}

for (const [name, children] of Object.entries(node.slots ?? {})) {
children.forEach((child, index) => lintRhythm(child, `${path}.slots.${name}[${index}]`, inner, walk))
}
}
Loading