Add on-page editor for the go-graph-cms
Injectable React editor that is side-loaded onto the statically rendered page and edits through the CMS HTTP API (POST /api/edit). - Shadow-root UI over server HTML; patches morphed in, undo/redo from server inverses (reuses @k6n/cms-editor EditClient/morph/markdown editor) - Sign-in via go-auth device grant as the CMS client (aud matches), token refresh + 401 retry; live preview swapped in on load and on tab focus - Schema-driven property form (arrays, maps, variants, $ref, validation) - Every string property editable where it is rendered (value-bound inline fields and attribute chips), markdown WYSIWYG for marked fields - Block palette + drag & drop with ghost drop targets, insertion slots, sort snapping between siblings (Alt to nest) - x-style annotations: on-page padding/margin/gap handles, Style panel, wrap-in-layout; x-graph label/group for titles and grouping - Dev workflow: Vite proxy to the live site (token injected server-side) or an offline in-memory mock CMS; jsdom test suite Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
1af9ec3172
67 files changed
+12322
No files matched your search
@@ -0,0 +1,178 @@
|
||||
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([]) // spacing is not a breakpoint override (the template has no classes for it)
|
||||
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')
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user