From d4783daefc81e938dce71ecccd59c8e84ea3ac47 Mon Sep 17 00:00:00 2001 From: Brian Graves Date: Sun, 4 Oct 2026 21:55:21 -0500 Subject: [PATCH] feat(recipes): a group inside a group is spaced more tightly than the gaps around it forumone-2026#847. A composed section whose column heading-to-text gap is as wide as the gaps between the columns reads as an even list of lines. lintRecipe now refuses a Stack in a Stack (or a Cluster in a Cluster) whose gap is not smaller than the one around it, naming the gap to make smaller. Gaps compare by their step on the spacing scale; only the same kind is compared, since a Cluster's gap runs across and a Stack's down. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Sh7Rh2zPq3etuniocxscF5 --- .changeset/recipe-spacing-rhythm.md | 5 ++ .../design-system/src/recipes/lint.test.ts | 42 +++++++++++++ packages/design-system/src/recipes/lint.ts | 59 +++++++++++++++++++ 3 files changed, 106 insertions(+) create mode 100644 .changeset/recipe-spacing-rhythm.md diff --git a/.changeset/recipe-spacing-rhythm.md b/.changeset/recipe-spacing-rhythm.md new file mode 100644 index 00000000..fec92c8f --- /dev/null +++ b/.changeset/recipe-spacing-rhythm.md @@ -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). diff --git a/packages/design-system/src/recipes/lint.test.ts b/packages/design-system/src/recipes/lint.test.ts index b87c73c5..deb56338 100644 --- a/packages/design-system/src/recipes/lint.test.ts +++ b/packages/design-system/src/recipes/lint.test.ts @@ -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}`), + }), + ]) + }) +}) diff --git a/packages/design-system/src/recipes/lint.ts b/packages/design-system/src/recipes/lint.ts index 96745b8e..d4a53fa0 100644 --- a/packages/design-system/src/recipes/lint.ts +++ b/packages/design-system/src/recipes/lint.ts @@ -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) { @@ -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, + 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)) + } +}