Files
matsandClaude Sonnet 5.5 ec4212c113 Device preview + responsive spacing in the Style panel
Style panel gets a real-viewport iframe preview (390/820/1280px) tied to the
breakpoint tab, reloaded after each edit, and spacing/gap controls per
breakpoint. The editor does not boot inside the preview frame. Mock and
tests mirror the new k6n layout template.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 11:02:20 +01:00

180 lines
10 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { existsSync, readFileSync } from 'node:fs'
import { schemas } from '../mock/fixtures'
import { describeStyle, effective, fieldForSide, isWrapperSchema, patchFor } from '../src/style/model'
import { discoverSpacingVar, measureScale, snap } from '../src/style/tokens'
import type { Scale } from '../src/style/tokens'
import { computeHandles, dragPx, gapsBetween, previewProperty } from '../src/style/handles'
const layout = schemas.layout
const fields = describeStyle(layout)
const byName = (n: string) => fields.find((f) => f.name === n)!
/** The same schema with every x-style removed: what an un-annotated deployment serves. */
const stripped = () => {
const s = structuredClone(layout)
for (const p of Object.values<any>(s.properties)) delete p['x-style']
for (const p of Object.values<any>(s.$defs.breakpoint.properties)) delete p['x-style']
delete s['x-style']
return s
}
describe('describeStyle', () => {
it('reads x-style annotations: kinds, spacing box/side/overrides, token, breakpoints', () => {
expect(fields.map((f) => f.name)).toEqual(['flow', 'columns', 'gap', 'padding', 'paddingTop', 'paddingBottom', 'margin', 'marginTop', 'marginBottom', 'align', 'justify', 'tone', 'border'])
expect(byName('paddingTop')).toMatchObject({ kind: 'spacing', box: 'padding', side: 'top', overrides: 'padding', token: '--k6n-sp-{value}', inferred: false })
expect(byName('gap')).toMatchObject({ kind: 'gap', inferred: false })
expect(byName('flow').responsive).toEqual(['mobile', 'tablet', 'desktop'])
expect(byName('gap').responsive).toEqual(['mobile', 'tablet', 'desktop'])
expect(byName('padding').responsive).toEqual([]) // only declared per-side in the breakpoint def
expect(isWrapperSchema(layout)).toBe(true)
})
it('infers the same fields from names + enums when the schema is not annotated (and says so)', () => {
const inferred = describeStyle(stripped())
expect(inferred.map((f) => [f.name, f.kind, f.box, f.side])).toEqual(fields.map((f) => [f.name, f.kind, f.box, f.side]))
expect(inferred.every((f) => f.inferred)).toBe(true)
expect(inferred.find((f) => f.name === 'marginBottom')).toMatchObject({ overrides: 'margin' })
expect(isWrapperSchema(stripped())).toBe(false) // the wrapper tag is never guessed
})
it('does not mistake ordinary fields for style (enum without a scale, unrelated names)', () => {
const s = { type: 'object', properties: { padding: { type: 'string' }, gap: { type: 'string', enum: ['a', 'b'] }, title: { type: 'string' }, variant: { enum: ['x', 'y'] } } }
expect(describeStyle(s)).toEqual([])
})
it('an annotation wins over naming (any property can be tagged)', () => {
const s = { type: 'object', properties: { inset: { type: 'string', enum: ['none', 'xs', 'sm', 'md'], 'x-style': { kind: 'spacing', box: 'padding', side: 'x' } } } }
expect(describeStyle(s)).toMatchObject([{ name: 'inset', kind: 'spacing', box: 'padding', side: 'x', inferred: false }])
})
const real = '/home/mats/git.k6n.net/ecommerce/data/k6n/schemas/layout.json'
it.skipIf(!existsSync(real))('the real k6n layout schema is annotated and consistent with the mock', () => {
const s = JSON.parse(readFileSync(real, 'utf8'))
const f = describeStyle(s)
expect(f.map((x) => x.name)).toEqual(expect.arrayContaining(fields.map((x) => x.name)))
expect(f.map((x) => x.name)).toContain('reveal')
expect(f.filter((x) => x.inferred)).toEqual([]) // fully annotated
expect(isWrapperSchema(s)).toBe(true)
expect(f.find((x) => x.name === 'paddingTop')).toMatchObject({ overrides: 'padding', token: '--k6n-sp-{value}' })
})
})
describe('values', () => {
it('effective() falls back from an override to its base field', () => {
const d = { padding: 'sm' }
expect(effective(d, fields, byName('paddingTop'))).toMatchObject({ value: 'sm', from: { name: 'padding' } })
expect(effective({ padding: 'sm', paddingTop: 'xl' }, fields, byName('paddingTop'))).toMatchObject({ value: 'xl', from: { name: 'paddingTop' } })
expect(effective({}, fields, byName('paddingTop')).value).toBeUndefined()
})
it('patchFor: base is flat (undefined clears), breakpoints rebuild responsive and prune empties', () => {
expect(patchFor({}, byName('gap'), 'lg')).toEqual({ gap: 'lg' })
expect(patchFor({}, byName('gap'), undefined)).toEqual({ gap: null })
const d = { responsive: { tablet: { columns: 2 }, mobile: { flow: 'stack' } } }
expect(patchFor(d, byName('columns'), 3, 'tablet')).toEqual({ responsive: { tablet: { columns: 3 }, mobile: { flow: 'stack' } } })
expect(patchFor(d, byName('columns'), undefined, 'tablet')).toEqual({ responsive: { mobile: { flow: 'stack' } } })
expect(patchFor({ responsive: { tablet: { columns: 2 } } }, byName('columns'), undefined, 'tablet')).toEqual({ responsive: null })
expect(d.responsive.tablet.columns).toBe(2) // input untouched
})
it('fieldForSide: exact side, then axis, then all', () => {
expect(fieldForSide(fields, 'padding', 'top')!.name).toBe('paddingTop')
expect(fieldForSide(fields, 'padding', 'left')!.name).toBe('padding')
expect(fieldForSide(fields, 'margin', 'bottom')!.name).toBe('marginBottom')
expect(fieldForSide(describeStyle({ type: 'object', properties: { gap: layout.properties.gap } }), 'padding', 'top')).toBeUndefined()
})
})
describe('token scales', () => {
const scale: Scale = { values: ['none', 'xs', 'sm', 'md', 'lg', 'xl'], px: [0, 8, 16, 32, 64, 96] }
it('snaps to the nearest measured size', () => {
expect(snap(scale, 'md', 32, 0)).toBe('md')
expect(snap(scale, 'md', 32, 12)).toBe('md') // 44 -> md(32) vs lg(64): md
expect(snap(scale, 'md', 32, 20)).toBe('lg') // 52 -> lg
expect(snap(scale, 'md', 32, -100)).toBe('none') // clamps at 0
expect(snap(scale, undefined, 0, 500)).toBe('xl')
})
it('steps ordinally when sizes cannot be measured', () => {
const blind: Scale = { values: scale.values, px: scale.values.map(() => null) }
expect(snap(blind, 'sm', 0, 0)).toBe('sm')
expect(snap(blind, 'sm', 0, 60)).toBe('lg') // 2 steps of 28px
expect(snap(blind, 'sm', 0, -500)).toBe('none')
})
it('measureScale resolves each value through the field token template', () => {
const asked: string[] = []
const sc = measureScale(byName('gap'), (v) => (asked.push(v), asked.length * 10))
expect(asked[0]).toBe('--k6n-sp-none')
expect(asked).toContain('--k6n-sp-md')
expect(sc.px).toEqual([10, 20, 30, 40, 50, 60])
})
it('discovers the spacing variable prefix from the page stylesheets', () => {
const style = { length: 2, 0: '--acme-sp-sm', 1: '--acme-sp-md' }
const doc = { styleSheets: [{ cssRules: [{ style }] }] } as unknown as Document
expect(discoverSpacingVar(doc)).toBe('--acme-sp-{value}')
expect(discoverSpacingVar({ styleSheets: [] } as unknown as Document)).toBeUndefined()
})
})
describe('handles', () => {
const rect = { x: 100, y: 200, w: 400, h: 300 }
const box = (t: number, r: number, b: number, l: number) => ({ top: t, right: r, bottom: b, left: l })
const base = { rect, padding: box(40, 20, 10, 20), margin: box(16, 0, 16, 0), children: [], fields }
it('creates a handle per side the schema can express, with the right field, axis and sign', () => {
const hs = computeHandles(base)
const get = (id: string) => hs.find((h) => h.id === id)!
expect(hs.filter((h) => h.kind === 'padding')).toHaveLength(4)
expect(get('padding-top')).toMatchObject({ axis: 'y', sign: 1, px: 40, shared: false, field: { name: 'paddingTop' } })
expect(get('padding-bottom')).toMatchObject({ axis: 'y', sign: -1, field: { name: 'paddingBottom' } })
expect(get('padding-left')).toMatchObject({ axis: 'x', sign: 1, shared: true, field: { name: 'padding' } }) // only "all" covers left/right
expect(get('padding-right')).toMatchObject({ axis: 'x', sign: -1, shared: true })
expect(get('margin-top')).toMatchObject({ axis: 'y', sign: -1 }) // margin grows outwards
expect(get('margin-bottom')).toMatchObject({ axis: 'y', sign: 1 })
})
it('places bands inside the box for padding and outside for margin, grips on the free edge', () => {
const hs = computeHandles(base)
const pt = hs.find((h) => h.id === 'padding-top')!
expect(pt.band).toEqual({ x: 100, y: 200, w: 400, h: 40 })
expect(pt.grip.y + pt.grip.h / 2).toBe(240) // centred on where the 40px ends
const mt = hs.find((h) => h.id === 'margin-top')!
expect(mt.band).toEqual({ x: 100, y: 184, w: 400, h: 16 })
expect(mt.grip.y + mt.grip.h / 2).toBe(184)
})
it('a zero-size value still gets a grabbable grip', () => {
const h = computeHandles({ ...base, padding: box(0, 0, 0, 0) }).find((x) => x.id === 'padding-top')!
expect(h.band.h).toBe(0)
expect(h.grip.h).toBeGreaterThanOrEqual(14)
})
it('no spacing fields -> no handles', () => {
expect(computeHandles({ ...base, fields: fields.filter((f) => f.kind === 'flow' || f.kind === 'columns') })).toEqual([])
})
it('gap bands come from the space between neighbouring children (rows and columns)', () => {
const c = (x: number, y: number) => ({ x, y, w: 100, h: 50 })
// 2x2 grid with a 20px column gap and a 10px row gap
const g = gapsBetween([c(0, 0), c(120, 0), c(0, 60), c(120, 60)])
expect(g.filter((x) => x.axis === 'x').map((x) => x.rect.w)).toEqual([20, 20])
expect(g.filter((x) => x.axis === 'y').map((x) => x.rect.h)).toEqual([10, 10])
expect(gapsBetween([c(0, 0)])).toEqual([])
const hs = computeHandles({ ...base, children: [c(0, 0), c(120, 0)] })
expect(hs.filter((h) => h.kind === 'gap')).toMatchObject([{ axis: 'x', px: 20, field: { name: 'gap' } }])
})
it('dragPx follows the sign and never goes negative', () => {
expect(dragPx({ axis: 'y', sign: 1, px: 40 }, 0, 25)).toBe(65)
expect(dragPx({ axis: 'y', sign: -1, px: 40 }, 0, 25)).toBe(15)
expect(dragPx({ axis: 'x', sign: 1, px: 10 }, -50, 99)).toBe(0)
})
it('previewProperty maps a field to the CSS property to preview', () => {
expect(previewProperty(byName('paddingTop'), 'top')).toBe('padding-top')
expect(previewProperty(byName('margin'), 'left')).toBe('margin')
expect(previewProperty(byName('gap'), 'gap')).toBe('gap')
expect(previewProperty({ ...byName('padding'), side: 'x' }, 'left')).toBe('padding-inline')
})
})