@poveste/app 0.5.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app/style/font.css +78 -0
- package/dist/app/style/main.pcss +4 -1
- package/dist/app/style/sandbox.css +3 -0
- package/dist/bundle-main-dev.js +1 -0
- package/dist/bundle-main.js +3 -0
- package/dist/bundled/app.css +28 -20
- package/dist/bundled/components/app/AppActions.vue_vue_type_script_setup_true_lang.js +2 -2
- package/dist/bundled/components/base/BaseSelect.vue_vue_type_script_setup_true_lang.js +1 -1
- package/dist/bundled/components/layout/LayoutModal.vue_vue_type_script_setup_true_lang.js +2 -2
- package/dist/bundled/components/layout/LayoutPane.vue_vue_type_script_setup_true_lang.js +4 -4
- package/dist/bundled/components/panel/StatePresets.vue_vue_type_script_setup_true_lang.js +5 -5
- package/dist/bundled/components/panel/StoryControls.vue_vue_type_script_setup_true_lang.js +1 -1
- package/dist/bundled/components/panel/StoryDocs.vue_vue_type_script_setup_true_lang.js +1 -1
- package/dist/bundled/components/panel/StoryEvent.vue_vue_type_script_setup_true_lang.js +1 -1
- package/dist/bundled/components/panel/StorySidePanel.vue_vue_type_script_setup_true_lang.js +1 -1
- package/dist/bundled/components/panel/StorySourceCode.js +1 -1
- package/dist/bundled/components/panel/StorySourceCode.vue_vue_type_script_setup_true_lang.js +2 -2
- package/dist/bundled/components/search/SearchItem.js +1 -1
- package/dist/bundled/components/search/SearchItem.vue_vue_type_script_setup_true_lang.js +2 -2
- package/dist/bundled/components/search/SearchModal.vue_vue_type_script_setup_true_lang.js +1 -1
- package/dist/bundled/components/search/SearchPane.vue_vue_type_script_setup_true_lang.js +1 -0
- package/dist/bundled/components/story/StoryResponsivePreview.js +1 -1
- package/dist/bundled/components/story/StoryResponsivePreview.vue_vue_type_script_setup_true_lang.js +69 -64
- package/dist/bundled/components/story/StoryVariantGrid.js +3 -1
- package/dist/bundled/components/story/StoryVariantGrid.vue_vue_type_script_setup_true_lang.js +82 -35
- package/dist/bundled/components/story/StoryVariantGridItem.js +1 -1
- package/dist/bundled/components/story/StoryVariantGridItem.vue_vue_type_script_setup_true_lang.js +22 -14
- package/dist/bundled/components/story/StoryVariantListItem.js +1 -1
- package/dist/bundled/components/story/StoryVariantListItem.vue_vue_type_script_setup_true_lang.js +2 -2
- package/dist/bundled/components/story/StoryVariantSinglePreviewNative.vue_vue_type_script_setup_true_lang.js +3 -6
- package/dist/bundled/components/story/StoryVariantSinglePreviewRemote.vue_vue_type_script_setup_true_lang.js +101 -33
- package/dist/bundled/components/story/StoryVariantSingleView.vue_vue_type_script_setup_true_lang.js +1 -1
- package/dist/bundled/components/toolbar/ToolbarBackground.js +1 -1
- package/dist/bundled/components/toolbar/ToolbarBackground.vue_vue_type_script_setup_true_lang.js +7 -7
- package/dist/bundled/components/tree/StoryGroup.vue_vue_type_script_setup_true_lang.js +1 -1
- package/dist/bundled/components/tree/StoryListFolder.js +1 -1
- package/dist/bundled/components/tree/StoryListFolder.vue_vue_type_script_setup_true_lang.js +2 -2
- package/dist/bundled/components/tree/StoryListItem.js +1 -1
- package/dist/bundled/components/tree/StoryListItem.vue_vue_type_script_setup_true_lang.js +3 -3
- package/dist/bundled/index.js +3 -0
- package/dist/bundled/sandbox.js +57 -29
- package/dist/bundled/util/color-scheme.js +11 -1
- package/dist/bundled/util/const.js +3 -1
- package/dist/bundled/util/grid-cell-height.js +18 -0
- package/dist/bundled/util/grid-slots.js +64 -0
- package/dist/bundled/util/icons.generated.js +104 -0
- package/dist/bundled/util/state-bridge.js +75 -0
- package/dist/bundled/util/state.js +19 -1
- package/dist/style.css +7 -2
- package/package.json +12 -5
- package/src/__tests__/color-scheme.spec.ts +55 -0
- package/src/__tests__/font.spec.ts +30 -0
- package/src/__tests__/grid-slots.spec.ts +84 -0
- package/src/__tests__/icons.spec.ts +26 -0
- package/src/__tests__/state-bridge.spec.ts +155 -0
- package/src/__tests__/state-clone.spec.ts +66 -0
- package/src/app/components/app/AppActions.vue +2 -2
- package/src/app/components/layout/LayoutModal.vue +2 -2
- package/src/app/components/layout/LayoutPane.vue +4 -4
- package/src/app/components/panel/StatePresets.vue +5 -5
- package/src/app/components/panel/StoryControls.vue +1 -1
- package/src/app/components/panel/StoryDocs.vue +1 -1
- package/src/app/components/panel/StoryEvent.vue +1 -1
- package/src/app/components/panel/StorySidePanel.vue +1 -1
- package/src/app/components/panel/StorySourceCode.vue +2 -2
- package/src/app/components/search/SearchItem.vue +1 -1
- package/src/app/components/search/SearchModal.vue +1 -1
- package/src/app/components/search/SearchPane.vue +8 -0
- package/src/app/components/story/StoryResponsivePreview.vue +85 -50
- package/src/app/components/story/StoryVariantGrid.vue +126 -49
- package/src/app/components/story/StoryVariantGridItem.vue +37 -14
- package/src/app/components/story/StoryVariantListItem.vue +1 -1
- package/src/app/components/story/StoryVariantSinglePreviewNative.vue +3 -6
- package/src/app/components/story/StoryVariantSinglePreviewRemote.vue +169 -36
- package/src/app/components/story/StoryVariantSingleView.vue +1 -1
- package/src/app/components/toolbar/ToolbarBackground.vue +4 -4
- package/src/app/components/tree/StoryGroup.vue +1 -1
- package/src/app/components/tree/StoryListFolder.vue +1 -1
- package/src/app/components/tree/StoryListItem.vue +1 -1
- package/src/app/index.ts +10 -0
- package/src/app/sandbox.ts +108 -40
- package/src/app/style/font.css +78 -0
- package/src/app/style/main.pcss +4 -1
- package/src/app/style/sandbox.css +3 -0
- package/src/app/util/color-scheme.ts +13 -0
- package/src/app/util/const.ts +2 -0
- package/src/app/util/grid-cell-height.ts +16 -0
- package/src/app/util/grid-slots.ts +72 -0
- package/src/app/util/icons.generated.ts +9 -0
- package/src/app/util/state-bridge.ts +95 -0
- package/src/app/util/state.ts +19 -0
- package/src/bundle-main-dev.js +1 -0
- package/src/bundle-main.js +3 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
// `util/config` re-exports a virtual module Vite generates from the book's own
|
|
4
|
+
// config, so the tests supply it directly.
|
|
5
|
+
const config: { theme: { darkClass?: string }, sandboxDarkClass?: string } = { theme: {} }
|
|
6
|
+
|
|
7
|
+
vi.mock('../app/util/config', () => ({
|
|
8
|
+
get povesteConfig() {
|
|
9
|
+
return config
|
|
10
|
+
},
|
|
11
|
+
}))
|
|
12
|
+
|
|
13
|
+
// Touches browser storage on import; unrelated to the pure function under test.
|
|
14
|
+
vi.mock('../app/util/dark', () => ({ isDark: { value: false } }))
|
|
15
|
+
|
|
16
|
+
const { previewDarkClasses } = await import('../app/util/color-scheme')
|
|
17
|
+
|
|
18
|
+
function darkClassesFor({ darkClass = 'dark', sandboxDarkClass }: { darkClass?: string, sandboxDarkClass?: string }) {
|
|
19
|
+
config.theme = { darkClass }
|
|
20
|
+
config.sandboxDarkClass = sandboxDarkClass
|
|
21
|
+
return previewDarkClasses()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('previewDarkClasses', () => {
|
|
25
|
+
it('returns the theme class for a book that configures neither', () => {
|
|
26
|
+
expect(darkClassesFor({})).toEqual(['dark'])
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
// #126: the deprecated option used to default to `dark` and be emitted here
|
|
30
|
+
// too, which is what made the three render paths disagree.
|
|
31
|
+
it('returns the theme class alone when only it is configured', () => {
|
|
32
|
+
expect(darkClassesFor({ darkClass: 'my-dark' })).toEqual(['my-dark'])
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('appends sandboxDarkClass when a book still sets it', () => {
|
|
36
|
+
expect(darkClassesFor({ darkClass: 'my-dark', sandboxDarkClass: 'legacy' })).toEqual(['my-dark', 'legacy'])
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('returns one class when both names are the same', () => {
|
|
40
|
+
expect(darkClassesFor({ darkClass: 'dark', sandboxDarkClass: 'dark' })).toEqual(['dark'])
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
// `classList.toggle('')` throws, and the sandbox runs it during boot, so an
|
|
44
|
+
// empty entry leaves every preview blank rather than merely unstyled. Only
|
|
45
|
+
// the empty string reaches here — defu fills an undefined class.
|
|
46
|
+
describe('when a configured class name is empty', () => {
|
|
47
|
+
it('drops an empty theme class', () => {
|
|
48
|
+
expect(darkClassesFor({ darkClass: '' })).toEqual([])
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('drops an empty sandboxDarkClass', () => {
|
|
52
|
+
expect(darkClassesFor({ sandboxDarkClass: '' })).toEqual(['dark'])
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
})
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
// @ts-expect-error plain script, no types
|
|
5
|
+
import { FONT_PACKAGE, generateFontCss } from '../../scripts/font.mjs'
|
|
6
|
+
|
|
7
|
+
// Vitest runs from the package root, and stubs `?raw` CSS imports to empty.
|
|
8
|
+
// Newlines are normalised: the shipped sheet is checked in, so a Windows
|
|
9
|
+
// checkout hands it back with CRLF, and this compares content against the
|
|
10
|
+
// LF-newline installed package, not line endings (#156).
|
|
11
|
+
const read = (path: string) => readFileSync(join(process.cwd(), path), 'utf8').replace(/\r\n/g, '\n')
|
|
12
|
+
|
|
13
|
+
const shipped = read('src/app/style/font.css')
|
|
14
|
+
const upstream = read(join('node_modules', FONT_PACKAGE, 'index.css'))
|
|
15
|
+
|
|
16
|
+
describe('the bundled app face', () => {
|
|
17
|
+
it('matches the installed package, so a bump cannot go unnoticed', () => {
|
|
18
|
+
expect(shipped).toBe(generateFontCss(upstream))
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('asks for no format the woff2 already covers', () => {
|
|
22
|
+
expect(shipped).not.toContain('format(\'woff\')')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('keeps every subset upstream ships, so non-latin text stays on the face', () => {
|
|
26
|
+
const subsets = (css: string) => (css.match(/unicode-range: [^;]+/g) ?? []).sort()
|
|
27
|
+
|
|
28
|
+
expect(subsets(shipped)).toEqual(subsets(upstream))
|
|
29
|
+
})
|
|
30
|
+
})
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { Variant } from '../app/types'
|
|
2
|
+
import type { Slot } from '../app/util/grid-slots.js'
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
import { assignSlots, trimSlots } from '../app/util/grid-slots.js'
|
|
5
|
+
|
|
6
|
+
function variants(ids: string[]): Variant[] {
|
|
7
|
+
return ids.map(id => ({ id }) as Variant)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function shown(slots: Slot[]) {
|
|
11
|
+
return slots.map(s => (s.visible ? `${s.storyId}:${s.variant.id}@${s.order}` : `(${s.storyId}:${s.variant.id})`))
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('assignSlots', () => {
|
|
15
|
+
it('opens a slot per visible variant in screen order', () => {
|
|
16
|
+
const slots = assignSlots([], 'a', variants(['v1', 'v2', 'v3']))
|
|
17
|
+
|
|
18
|
+
expect(shown(slots)).toEqual(['a:v1@0', 'a:v2@1', 'a:v3@2'])
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('keeps a still-visible variant in its slot when the window moves', () => {
|
|
22
|
+
const first = assignSlots([], 'a', variants(['v1', 'v2', 'v3']))
|
|
23
|
+
|
|
24
|
+
const next = assignSlots(first, 'a', variants(['v2', 'v3', 'v4']))
|
|
25
|
+
|
|
26
|
+
expect(shown(next)).toEqual(['a:v4@2', 'a:v2@0', 'a:v3@1'])
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('hides a slot whose variant left rather than dropping it', () => {
|
|
30
|
+
const first = assignSlots([], 'a', variants(['v1', 'v2', 'v3']))
|
|
31
|
+
|
|
32
|
+
const next = assignSlots(first, 'a', variants(['v2', 'v3']))
|
|
33
|
+
|
|
34
|
+
expect(shown(next)).toEqual(['(a:v1)', 'a:v2@0', 'a:v3@1'])
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('hands a hidden slot to a variant coming in before opening a new one', () => {
|
|
38
|
+
const first = assignSlots([], 'a', variants(['v1', 'v2', 'v3']))
|
|
39
|
+
const hidden = assignSlots(first, 'a', variants(['v2', 'v3']))
|
|
40
|
+
|
|
41
|
+
const next = assignSlots(hidden, 'a', variants(['v2', 'v3', 'v4']))
|
|
42
|
+
|
|
43
|
+
expect(shown(next)).toEqual(['a:v4@2', 'a:v2@0', 'a:v3@1'])
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('does not match a variant of another story by id', () => {
|
|
47
|
+
const first = assignSlots([], 'a', variants(['v1', 'v2']))
|
|
48
|
+
|
|
49
|
+
const next = assignSlots(first, 'b', variants(['v1', 'v2']))
|
|
50
|
+
|
|
51
|
+
expect(next.map(s => s.storyId)).toEqual(['b', 'b'])
|
|
52
|
+
expect(next.every(s => s.visible)).toBe(true)
|
|
53
|
+
expect(next[0].variant).not.toBe(first[0].variant)
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe('trimSlots', () => {
|
|
58
|
+
it('leaves a pool with nothing hidden alone', () => {
|
|
59
|
+
const slots = assignSlots([], 'a', variants(['v1', 'v2']))
|
|
60
|
+
|
|
61
|
+
expect(trimSlots(slots)).toBe(slots)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('drops trailing hidden slots and keeps interior ones as spares', () => {
|
|
65
|
+
const first = assignSlots([], 'a', variants(['v1', 'v2', 'v3', 'v4']))
|
|
66
|
+
const moved = assignSlots(first, 'a', variants(['v1', 'v3', 'v5']))
|
|
67
|
+
expect(shown(moved)).toEqual(['a:v1@0', 'a:v5@2', 'a:v3@1', '(a:v4)'])
|
|
68
|
+
|
|
69
|
+
const next = trimSlots(moved)
|
|
70
|
+
|
|
71
|
+
expect(shown(next)).toEqual(['a:v1@0', 'a:v5@2', 'a:v3@1'])
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('compacts the pool when hidden slots outnumber visible ones', () => {
|
|
75
|
+
const big = assignSlots([], 'a', variants(['v1', 'v2', 'v3', 'v4', 'v5', 'v6']))
|
|
76
|
+
const small = assignSlots(big, 'a', variants(['v2', 'v6']))
|
|
77
|
+
expect(shown(small)).toEqual(['(a:v1)', 'a:v2@0', '(a:v3)', '(a:v4)', '(a:v5)', 'a:v6@1'])
|
|
78
|
+
|
|
79
|
+
const next = trimSlots(small)
|
|
80
|
+
|
|
81
|
+
// v2 stays where it is; v6 moves down into the first free slot.
|
|
82
|
+
expect(shown(next)).toEqual(['a:v6@1', 'a:v2@0'])
|
|
83
|
+
})
|
|
84
|
+
})
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import { resolve } from 'node:path'
|
|
4
|
+
import { describe, expect, it } from 'vitest'
|
|
5
|
+
|
|
6
|
+
const packageRoot = resolve(__dirname, '../..')
|
|
7
|
+
const script = resolve(packageRoot, 'scripts/icons.mjs')
|
|
8
|
+
const generated = resolve(packageRoot, 'src/app/util/icons.generated.ts')
|
|
9
|
+
|
|
10
|
+
describe('bundled chrome icons', () => {
|
|
11
|
+
// Run as a child process rather than imported: the generator is plain ESM
|
|
12
|
+
// with `import.meta.url`, which vitest's transform does not leave intact.
|
|
13
|
+
it('icons.generated.ts matches the icons the sources use', () => {
|
|
14
|
+
// A stale file means an Icon the chrome renders is missing from the bundle
|
|
15
|
+
// and falls back to a CDN fetch at runtime — exactly what bundling is for.
|
|
16
|
+
expect(() => execFileSync('node', [script, '--check'], { stdio: 'pipe' })).not.toThrow()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('carries the icons the chrome is known to use', () => {
|
|
20
|
+
const source = readFileSync(generated, 'utf8')
|
|
21
|
+
expect(source).toContain('"prefix":"carbon"')
|
|
22
|
+
expect(source).toContain('"cube":')
|
|
23
|
+
expect(source).toContain('"search":')
|
|
24
|
+
expect(source).toContain('"subtract-line":')
|
|
25
|
+
})
|
|
26
|
+
})
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { createStateBridge } from '../app/util/state-bridge.js'
|
|
3
|
+
|
|
4
|
+
// A stand-in for `postMessage`: messages queue rather than being delivered, so a
|
|
5
|
+
// test can put two of them in flight at once — which is the whole point. Real
|
|
6
|
+
// `postMessage` gives the same shape, one task apart, which is exactly how long
|
|
7
|
+
// it takes for both sides to have written before either has heard.
|
|
8
|
+
function link() {
|
|
9
|
+
const queues = { host: [] as any[], sandbox: [] as any[] }
|
|
10
|
+
const state = { host: {} as any, sandbox: {} as any }
|
|
11
|
+
|
|
12
|
+
const bridges = {
|
|
13
|
+
host: createStateBridge(changes => queues.sandbox.push(changes)),
|
|
14
|
+
sandbox: createStateBridge(changes => queues.host.push(changes)),
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
state,
|
|
19
|
+
/** The side's state changed; it composes and sends. */
|
|
20
|
+
send(side: 'host' | 'sandbox') {
|
|
21
|
+
bridges[side].send(structuredClone(state[side]))
|
|
22
|
+
},
|
|
23
|
+
/** Deliver everything currently in flight to the given side. */
|
|
24
|
+
deliver(side: 'host' | 'sandbox') {
|
|
25
|
+
const inbox = queues[side].splice(0)
|
|
26
|
+
for (const changes of inbox) {
|
|
27
|
+
bridges[side].receive(state[side], changes)
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
settle() {
|
|
31
|
+
for (let i = 0; i < 6; i++) {
|
|
32
|
+
this.deliver('host')
|
|
33
|
+
this.send('host')
|
|
34
|
+
this.deliver('sandbox')
|
|
35
|
+
this.send('sandbox')
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('createStateBridge', () => {
|
|
42
|
+
it('carries an edit in each direction', () => {
|
|
43
|
+
const bridge = link()
|
|
44
|
+
bridge.state.host = { a: 0, b: 0 }
|
|
45
|
+
bridge.state.sandbox = { a: 0, b: 0 }
|
|
46
|
+
bridge.settle()
|
|
47
|
+
|
|
48
|
+
bridge.state.host.a = 1
|
|
49
|
+
bridge.send('host')
|
|
50
|
+
bridge.deliver('sandbox')
|
|
51
|
+
expect(bridge.state.sandbox.a).toBe(1)
|
|
52
|
+
|
|
53
|
+
bridge.state.sandbox.b = 2
|
|
54
|
+
bridge.send('sandbox')
|
|
55
|
+
bridge.deliver('host')
|
|
56
|
+
expect(bridge.state.host.b).toBe(2)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('does not echo what it was just sent', () => {
|
|
60
|
+
const bridge = link()
|
|
61
|
+
bridge.state.host = { a: 0 }
|
|
62
|
+
bridge.state.sandbox = { a: 0 }
|
|
63
|
+
bridge.settle()
|
|
64
|
+
|
|
65
|
+
bridge.state.host.a = 1
|
|
66
|
+
bridge.send('host')
|
|
67
|
+
bridge.deliver('sandbox')
|
|
68
|
+
|
|
69
|
+
// The sandbox's own watcher fires on the write it just applied. It has
|
|
70
|
+
// nothing of its own to report, so nothing goes back.
|
|
71
|
+
bridge.send('sandbox')
|
|
72
|
+
expect(bridge.state.host.a).toBe(1)
|
|
73
|
+
bridge.deliver('host')
|
|
74
|
+
expect(bridge.state.host.a).toBe(1)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('keeps both edits when the two sides write before either has heard', () => {
|
|
78
|
+
// The race, exactly: both write, both compose, then both deliver. Sending
|
|
79
|
+
// the whole state here meant each message carried its sender's stale copy of
|
|
80
|
+
// the other's key, and each end applied that over its own fresh value — so
|
|
81
|
+
// the two sides ended up disagreeing with nothing left to settle it.
|
|
82
|
+
const bridge = link()
|
|
83
|
+
bridge.state.host = { label: 'start', bumps: 0 }
|
|
84
|
+
bridge.state.sandbox = { label: 'start', bumps: 0 }
|
|
85
|
+
bridge.settle()
|
|
86
|
+
|
|
87
|
+
bridge.state.host.label = 'raced'
|
|
88
|
+
bridge.state.sandbox.bumps = 1
|
|
89
|
+
bridge.send('host')
|
|
90
|
+
bridge.send('sandbox')
|
|
91
|
+
|
|
92
|
+
bridge.deliver('host')
|
|
93
|
+
bridge.deliver('sandbox')
|
|
94
|
+
bridge.settle()
|
|
95
|
+
|
|
96
|
+
expect(bridge.state.host).toEqual({ label: 'raced', bumps: 1 })
|
|
97
|
+
expect(bridge.state.sandbox).toEqual({ label: 'raced', bumps: 1 })
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('does not revert the sender when the receiver refuses the write', () => {
|
|
101
|
+
// `applyState` swallows a write a setter refuses — a read-only `computed` in
|
|
102
|
+
// state is the case that occurs — and reports it as written anyway. Agreeing
|
|
103
|
+
// to it regardless is worse than losing it: the receiver still holds the old
|
|
104
|
+
// value, its next firing diffs that against a baseline claiming the new one,
|
|
105
|
+
// and it comes back as a fresh edit that reverts the sender. The reader's
|
|
106
|
+
// control edit undoes itself with nothing to show for it.
|
|
107
|
+
const queues: any = { host: [], sandbox: [] }
|
|
108
|
+
const host: any = { a: 0 }
|
|
109
|
+
const sandbox: any = {}
|
|
110
|
+
Object.defineProperty(sandbox, 'a', { get: () => 0, enumerable: true, configurable: true })
|
|
111
|
+
|
|
112
|
+
const hostBridge = createStateBridge(changes => queues.sandbox.push(changes))
|
|
113
|
+
const sandboxBridge = createStateBridge(changes => queues.host.push(changes))
|
|
114
|
+
|
|
115
|
+
hostBridge.send({ ...host })
|
|
116
|
+
for (const changes of queues.sandbox.splice(0)) sandboxBridge.receive(sandbox, changes)
|
|
117
|
+
|
|
118
|
+
host.a = 1
|
|
119
|
+
hostBridge.send({ a: 1 })
|
|
120
|
+
for (const changes of queues.sandbox.splice(0)) sandboxBridge.receive(sandbox, changes)
|
|
121
|
+
|
|
122
|
+
// The sandbox refused it, so its own next firing still reads 0.
|
|
123
|
+
sandboxBridge.send({ a: sandbox.a })
|
|
124
|
+
for (const changes of queues.host.splice(0)) hostBridge.receive(host, changes)
|
|
125
|
+
|
|
126
|
+
expect(host.a).toBe(1)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('leaves a simultaneous write to the same key unresolved, as it always did', () => {
|
|
130
|
+
// Not something a diff can fix, and not something this changed. Both ends
|
|
131
|
+
// accept what the other sent, so the two values swap. Landing them on one
|
|
132
|
+
// value needs each end to know which of its own writes the other has seen
|
|
133
|
+
// yet — acknowledgements, a real protocol — and picking a winner without
|
|
134
|
+
// that would mean dropping legitimate later edits from the losing side.
|
|
135
|
+
//
|
|
136
|
+
// Recorded here so the limit is a decision rather than a surprise. Writing
|
|
137
|
+
// the same key from the story and the panel in one task is a genuine
|
|
138
|
+
// conflict; writing *different* keys, which is the case above, is not.
|
|
139
|
+
const bridge = link()
|
|
140
|
+
bridge.state.host = { a: 0 }
|
|
141
|
+
bridge.state.sandbox = { a: 0 }
|
|
142
|
+
bridge.settle()
|
|
143
|
+
|
|
144
|
+
bridge.state.host.a = 1
|
|
145
|
+
bridge.state.sandbox.a = 2
|
|
146
|
+
bridge.send('host')
|
|
147
|
+
bridge.send('sandbox')
|
|
148
|
+
bridge.deliver('host')
|
|
149
|
+
bridge.deliver('sandbox')
|
|
150
|
+
bridge.settle()
|
|
151
|
+
|
|
152
|
+
expect(bridge.state.host.a).toBe(2)
|
|
153
|
+
expect(bridge.state.sandbox.a).toBe(1)
|
|
154
|
+
})
|
|
155
|
+
})
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { toPresetState } from '../app/util/state'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What the state-presets panel does to a variant's state before storing it.
|
|
6
|
+
* `ComplexParameter.story.vue` builds both shapes this has to survive: a prop
|
|
7
|
+
* holding a callback, and a pair of objects that reference each other.
|
|
8
|
+
*/
|
|
9
|
+
const asPreset = (state: Record<string, any>) => toPresetState(state)
|
|
10
|
+
|
|
11
|
+
function stateWithACallbackAndACycle() {
|
|
12
|
+
const parent: Record<string, any> = { name: 'hello' }
|
|
13
|
+
const child: Record<string, any> = {}
|
|
14
|
+
parent.child = child
|
|
15
|
+
child.parent = parent
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
complexParameter: [{ action: () => {} }],
|
|
19
|
+
recursiveParameter: parent,
|
|
20
|
+
count: 1,
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('a variant\'s state as a preset', () => {
|
|
25
|
+
it('is detached from the state it was taken from', () => {
|
|
26
|
+
const state = stateWithACallbackAndACycle()
|
|
27
|
+
|
|
28
|
+
const preset = asPreset(state)
|
|
29
|
+
state.recursiveParameter.name = 'edited'
|
|
30
|
+
|
|
31
|
+
expect(preset.recursiveParameter.name).toBe('hello')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('keeps a self-referential prop as a cycle, which is what it is', () => {
|
|
35
|
+
const preset = asPreset(stateWithACallbackAndACycle())
|
|
36
|
+
|
|
37
|
+
expect(preset.recursiveParameter.child.parent).toBe(preset.recursiveParameter)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('drops the callback, which no stored preset could carry anyway', () => {
|
|
41
|
+
const preset = asPreset(stateWithACallbackAndACycle())
|
|
42
|
+
|
|
43
|
+
expect(preset.complexParameter).toEqual([{}])
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
// The whole of the defect: the copy came out correct either way, but a story
|
|
47
|
+
// with a callback in its props logged a warning and an error on every mount.
|
|
48
|
+
it('does so without falling back, so nothing is logged at the reader', () => {
|
|
49
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
50
|
+
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
51
|
+
|
|
52
|
+
asPreset(stateWithACallbackAndACycle())
|
|
53
|
+
|
|
54
|
+
expect(warn).not.toHaveBeenCalled()
|
|
55
|
+
expect(error).not.toHaveBeenCalled()
|
|
56
|
+
warn.mockRestore()
|
|
57
|
+
error.mockRestore()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('still copies the ordinary values beside them', () => {
|
|
61
|
+
const preset = asPreset(stateWithACallbackAndACycle())
|
|
62
|
+
|
|
63
|
+
expect(preset.count).toBe(1)
|
|
64
|
+
expect(preset.recursiveParameter.name).toBe('hello')
|
|
65
|
+
})
|
|
66
|
+
})
|
|
@@ -24,7 +24,7 @@ const themeIcon = computed(() => {
|
|
|
24
24
|
v-if="!isMobile"
|
|
25
25
|
v-tooltip="makeTooltip('Layout', ({ isMac }) => isMac ? 'meta+shift+l' : 'ctrl+shift+l')"
|
|
26
26
|
aria-label="Open layout customization"
|
|
27
|
-
data-
|
|
27
|
+
data-testid="layout-btn"
|
|
28
28
|
@click="$emit('layout')"
|
|
29
29
|
>
|
|
30
30
|
<Icon
|
|
@@ -35,7 +35,7 @@ const themeIcon = computed(() => {
|
|
|
35
35
|
|
|
36
36
|
<TopBarChipItem
|
|
37
37
|
v-tooltip="makeTooltip('Search', ({ isMac }) => isMac ? 'meta+k' : 'ctrl+k')"
|
|
38
|
-
data-
|
|
38
|
+
data-testid="search-btn"
|
|
39
39
|
@click="$emit('search')"
|
|
40
40
|
>
|
|
41
41
|
<Icon
|
|
@@ -29,7 +29,7 @@ onKeyboardShortcut(['escape'], () => {
|
|
|
29
29
|
<div
|
|
30
30
|
v-show="shown"
|
|
31
31
|
class="poveste-layout-modal fixed inset-0 bg-white/80 dark:bg-gray-900/80 z-20"
|
|
32
|
-
data-
|
|
32
|
+
data-testid="layout-modal"
|
|
33
33
|
>
|
|
34
34
|
<div
|
|
35
35
|
class="absolute inset-0"
|
|
@@ -44,7 +44,7 @@ onKeyboardShortcut(['escape'], () => {
|
|
|
44
44
|
type="button"
|
|
45
45
|
class="flex items-center justify-center p-1 bg-transparent border-0 text-gray-500 dark:text-gray-400 hover:text-primary-500 dark:hover:text-primary-400 cursor-pointer"
|
|
46
46
|
aria-label="Close layout customization"
|
|
47
|
-
data-
|
|
47
|
+
data-testid="layout-modal-close"
|
|
48
48
|
@click="close()"
|
|
49
49
|
>
|
|
50
50
|
<Icon
|
|
@@ -13,14 +13,14 @@ const settings = useLayoutStore().settings
|
|
|
13
13
|
title="Story List"
|
|
14
14
|
icon="carbon:open-panel-filled-left"
|
|
15
15
|
:state-icon="settings.storyListVisible ? 'carbon:view' : 'carbon:view-off'"
|
|
16
|
-
data-
|
|
16
|
+
data-testid="layout-toggle-story-list"
|
|
17
17
|
@click="settings.storyListVisible = !settings.storyListVisible"
|
|
18
18
|
/>
|
|
19
19
|
<LayoutItem
|
|
20
20
|
title="Story Options"
|
|
21
21
|
icon="carbon:open-panel-filled-right"
|
|
22
22
|
:state-icon="settings.storyOptionsVisible ? 'carbon:view' : 'carbon:view-off'"
|
|
23
|
-
data-
|
|
23
|
+
data-testid="layout-toggle-story-options"
|
|
24
24
|
@click="settings.storyOptionsVisible = !settings.storyOptionsVisible"
|
|
25
25
|
/>
|
|
26
26
|
</LayoutItemGroup>
|
|
@@ -30,14 +30,14 @@ const settings = useLayoutStore().settings
|
|
|
30
30
|
title="Right"
|
|
31
31
|
icon="carbon:open-panel-filled-right"
|
|
32
32
|
:is-active="settings.storyOptionsPlacement === 'right'"
|
|
33
|
-
data-
|
|
33
|
+
data-testid="layout-placement-right"
|
|
34
34
|
@click="settings.storyOptionsPlacement = 'right'"
|
|
35
35
|
/>
|
|
36
36
|
<LayoutItem
|
|
37
37
|
title="Bottom"
|
|
38
38
|
icon="carbon:open-panel-filled-bottom"
|
|
39
39
|
:is-active="settings.storyOptionsPlacement === 'bottom'"
|
|
40
|
-
data-
|
|
40
|
+
data-testid="layout-placement-bottom"
|
|
41
41
|
@click="settings.storyOptionsPlacement = 'bottom'"
|
|
42
42
|
/>
|
|
43
43
|
</LayoutItemGroup>
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
<script lang="ts" setup>
|
|
2
2
|
import type { Story, Variant } from '../../types'
|
|
3
3
|
import { Icon } from '@iconify/vue'
|
|
4
|
-
import { applyState, clone
|
|
4
|
+
import { applyState, clone } from '@poveste/shared'
|
|
5
5
|
import { onClickOutside, useStorage, useTimeoutFn } from '@vueuse/core'
|
|
6
6
|
import { computed, nextTick, onMounted, ref } from 'vue'
|
|
7
|
-
import { toRawDeep } from '../../util/state'
|
|
7
|
+
import { toPresetState, toRawDeep } from '../../util/state'
|
|
8
8
|
import BaseSelect from '../base/BaseSelect.vue'
|
|
9
9
|
|
|
10
10
|
const props = defineProps<{
|
|
@@ -18,7 +18,7 @@ const saveId = computed(() => `${props.story.id}:${props.variant.id}`)
|
|
|
18
18
|
|
|
19
19
|
const omitKeys = ['_hPropDefs']
|
|
20
20
|
|
|
21
|
-
const defaultState =
|
|
21
|
+
const defaultState = toPresetState(props.variant.state, omitKeys)
|
|
22
22
|
|
|
23
23
|
const selectedOption = useStorage<string>(
|
|
24
24
|
`_poveste-presets/${saveId.value}/selected`,
|
|
@@ -66,7 +66,7 @@ const isEditing = ref(false)
|
|
|
66
66
|
async function createPreset() {
|
|
67
67
|
const id = Date.now().toString()
|
|
68
68
|
|
|
69
|
-
presetStates.value.set(id, { state:
|
|
69
|
+
presetStates.value.set(id, { state: toPresetState(props.variant.state, omitKeys), label: 'New preset' })
|
|
70
70
|
selectedOption.value = id
|
|
71
71
|
isEditing.value = true
|
|
72
72
|
await nextTick()
|
|
@@ -82,7 +82,7 @@ async function savePreset() {
|
|
|
82
82
|
if (!canEdit.value) return
|
|
83
83
|
|
|
84
84
|
const preset = presetStates.value.get(selectedOption.value)
|
|
85
|
-
preset.state =
|
|
85
|
+
preset.state = toPresetState(props.variant.state, omitKeys)
|
|
86
86
|
savedNotif.value = true
|
|
87
87
|
savedTimeout.start()
|
|
88
88
|
}
|
|
@@ -195,7 +195,7 @@ const filePath = computed(() => story.value.file?.docsFilePath ?? (props.standal
|
|
|
195
195
|
v-else
|
|
196
196
|
ref="renderedEl"
|
|
197
197
|
class="prose dark:prose-invert p-4 max-w-none"
|
|
198
|
-
data-
|
|
198
|
+
data-testid="story-docs"
|
|
199
199
|
v-html="renderedDoc"
|
|
200
200
|
/>
|
|
201
201
|
</div>
|
|
@@ -55,7 +55,7 @@ const panelContentComponent = computed(() => {
|
|
|
55
55
|
:save-id="`story-sidepane-${innerOrientation}`"
|
|
56
56
|
:orientation="innerOrientation"
|
|
57
57
|
class="poveste-story-side-panel poveste-loaded h-full"
|
|
58
|
-
data-
|
|
58
|
+
data-testid="story-side-panel"
|
|
59
59
|
>
|
|
60
60
|
<template #first>
|
|
61
61
|
<div class="flex flex-col h-full">
|
|
@@ -218,7 +218,7 @@ watch(sourceHtml, async () => {
|
|
|
218
218
|
class="__poveste-code-placeholder w-full h-full p-4 outline-none bg-transparent resize-none m-0"
|
|
219
219
|
:value="displayedSourceCode"
|
|
220
220
|
readonly
|
|
221
|
-
data-
|
|
221
|
+
data-testid="story-source-code"
|
|
222
222
|
@scroll="onScroll"
|
|
223
223
|
/>
|
|
224
224
|
<!-- eslint-disable vue/no-v-html -->
|
|
@@ -226,7 +226,7 @@ watch(sourceHtml, async () => {
|
|
|
226
226
|
v-else
|
|
227
227
|
ref="scroller"
|
|
228
228
|
class="w-full h-full overflow-auto"
|
|
229
|
-
data-
|
|
229
|
+
data-testid="story-source-code"
|
|
230
230
|
@scroll="onScroll"
|
|
231
231
|
>
|
|
232
232
|
<div
|
|
@@ -284,6 +284,14 @@ const {
|
|
|
284
284
|
const router = useRouter()
|
|
285
285
|
|
|
286
286
|
onKeyboardShortcut(['enter'], () => {
|
|
287
|
+
// The modal hides with `v-show`, so this pane stays mounted and the listener
|
|
288
|
+
// stays live once search has been opened — without this guard, Enter anywhere
|
|
289
|
+
// in the app navigates to the last selected result (#114). `LayoutModal`'s
|
|
290
|
+
// escape handler guards on `shown` the same way.
|
|
291
|
+
if (!props.shown) {
|
|
292
|
+
return
|
|
293
|
+
}
|
|
294
|
+
|
|
287
295
|
const result = results.value[selectedIndex.value]
|
|
288
296
|
|
|
289
297
|
if (!result) {
|