tiptapify 0.3.3 → 0.3.5

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.
Files changed (37) hide show
  1. package/README.md +1 -0
  2. package/dist/tiptapify.css +1 -1
  3. package/dist/tiptapify.mjs +15909 -15553
  4. package/dist/tiptapify.umd.js +55 -55
  5. package/index.d.ts +95 -0
  6. package/package.json +2 -1
  7. package/src/components/ImageBubbleMenu.vue +16 -7
  8. package/src/components/MenuBubble.vue +45 -7
  9. package/src/components/SkinScope.test.ts +74 -0
  10. package/src/components/SkinScope.vue +18 -0
  11. package/src/components/Tiptapify.skin.test.ts +186 -0
  12. package/src/components/Tiptapify.vue +115 -21
  13. package/src/components/Toolbar/Index.vue +43 -62
  14. package/src/components/Toolbar/Items.test.ts +58 -2
  15. package/src/components/Toolbar/Items.vue +93 -7
  16. package/src/components/UI/TiptapifyDialog.test.ts +132 -0
  17. package/src/components/UI/TiptapifyDialog.vue +90 -40
  18. package/src/composables/useTiptapifyConfig.test.ts +25 -2
  19. package/src/composables/useTiptapifyConfig.ts +21 -2
  20. package/src/constants/defaults.ts +2 -1
  21. package/src/extensions/components/alignment/menu/Button.vue +21 -17
  22. package/src/extensions/components/list/bullet/Button.vue +20 -16
  23. package/src/extensions/components/list/styles/ListStyleButton.vue +25 -25
  24. package/src/extensions/components/style/heading/Button.vue +33 -29
  25. package/src/index.test.ts +88 -0
  26. package/src/index.ts +15 -0
  27. package/src/skins/__fixtures__/skin-baseline.json +805 -0
  28. package/src/skins/builtins.ts +81 -0
  29. package/src/skins/menu.css +87 -0
  30. package/src/skins/menu.ts +10 -0
  31. package/src/skins/registry.test.ts +180 -0
  32. package/src/skins/registry.ts +132 -0
  33. package/src/skins/tokens.reachability.test.ts +43 -0
  34. package/src/skins/tokens.ts +92 -0
  35. package/src/types/editor.ts +99 -0
  36. package/src/utils/validateHref.test.ts +25 -0
  37. package/src/utils/validateHref.ts +17 -13
package/index.d.ts CHANGED
@@ -67,6 +67,101 @@ import * as mdi from '@mdi/js'
67
67
 
68
68
  export type TiptapifyFooterAlignment = 'start' | 'center' | 'end'
69
69
 
70
+ export type TiptapifySkin = 'default' | 'pastel'
71
+
72
+ // Structural variants a token cannot express (component markup, vuetify prop bindings)
73
+ export interface TiptapifyShape {
74
+ toolbar?: 'raised' | 'flat'
75
+ groups?: 'box' | 'flat'
76
+ buttons?: 'vuetify' | 'quiet'
77
+ bubble?: 'card' | 'flat'
78
+ dialog?: 'classic' | 'soft'
79
+ menu?: 'vuetify' | 'soft'
80
+ }
81
+
82
+ // The shape after resolution: every key set (missing keys fall back to classic)
83
+ export type ResolvedShape = Required<TiptapifyShape>
84
+
85
+ export interface TiptapifySkinColors {
86
+ toolbarBg: string
87
+ toolbarBorderBottom: string
88
+ toolbarFg: string
89
+ groupBg: string
90
+ groupShadow: string
91
+ dividerColor: string
92
+ buttonFg: string
93
+ buttonHoverBg: string
94
+ buttonHoverFg: string
95
+ buttonActiveBg: string
96
+ buttonActiveHoverBg: string
97
+ bubbleBg: string
98
+ bubbleBorder: string
99
+ bubbleShadow: string
100
+ menuBorder: string
101
+ menuShadow: string
102
+ menuItemActiveBg: string
103
+ dialogShadow: string
104
+ frameBg: string
105
+ frameShadow: string
106
+ frameBorder: string
107
+ imageBg: string
108
+ imageOutline: string
109
+ imageOutlineHover: string
110
+ handleBg: string
111
+ handleBgHover: string
112
+ handleBorder: string
113
+ handleShadow: string
114
+ }
115
+
116
+ export interface TiptapifySkinMetrics {
117
+ itemsPadding: string
118
+ itemsGap: string
119
+ itemsRowGap: string
120
+ groupGap: string
121
+ groupHeight: string
122
+ groupRadius: string
123
+ dividerWidth: string
124
+ dividerHeight: string
125
+ buttonRadius: string
126
+ bubbleRadius: string
127
+ bubblePadding: string
128
+ bubbleGap: string
129
+ menuListPadding: string
130
+ menuRadius: string
131
+ menuItemRadius: string
132
+ menuItemPadding: string
133
+ menuItemMinHeight: string
134
+ dialogRadius: string
135
+ dialogHeaderPadding: string
136
+ dialogContentPadding: string
137
+ dialogActionsPadding: string
138
+ dialogTitleSize: string
139
+ dialogTitleWeight: string
140
+ dialogFieldRadius: string
141
+ dialogActionBtnMinHeight: string
142
+ dialogActionBtnRadius: string
143
+ dialogGridGap: string
144
+ imageRadius: string
145
+ handleRadius: string
146
+ }
147
+
148
+ // A skin as data: token overrides + shape flags, inheriting from a registered skin
149
+ export type TiptapifySkinConfig = TiptapifyShape & {
150
+ name?: string
151
+ // registered skin name; implicit 'default'
152
+ extends?: string
153
+ colors?: Partial<TiptapifySkinColors>
154
+ metrics?: Partial<TiptapifySkinMetrics>
155
+ }
156
+
157
+ // The `skin` prop: a registered name (string) or an inline object
158
+ export type TiptapifySkinInput = string | TiptapifySkinConfig
159
+
160
+ export declare function registerSkin(name: string, skin: TiptapifySkinConfig): void
161
+
162
+ // Identity for typed const definitions; the object never enters the registry
163
+ export declare function defineSkin(skin: TiptapifySkinConfig): TiptapifySkinConfig
164
+
70
165
  export interface TiptapifyOptions {
71
166
  i18n?: string
72
167
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "tiptapify",
3
3
  "types": "./index.d.ts",
4
- "version": "0.3.3",
4
+ "version": "0.3.5",
5
5
  "description": "Tiptap3 editor with Vuetify3 menu implementation",
6
6
  "exports": {
7
7
  ".": {
@@ -34,6 +34,7 @@
34
34
  "test:watch": "vitest",
35
35
  "build:charmap": "tsx build-charmap.ts",
36
36
  "build:emojis": "tsx build-emojis.ts",
37
+ "record:skin-baseline": "tsx scripts/record-skin-baseline.ts",
37
38
  "docs:dev": "pnpm --filter tiptapify-docs docs:dev",
38
39
  "docs:build": "pnpm --filter tiptapify-docs docs:build",
39
40
  "docs:preview": "pnpm --filter tiptapify-docs docs:preview"
@@ -8,10 +8,14 @@ import Tooltip from '@tiptapify/components/UI/Tooltip.vue'
8
8
  import { ImageAlign } from '@tiptapify/extensions/components/media/image/caption'
9
9
  import { inject, Ref, ref } from 'vue'
10
10
  import { ComposerTranslation } from 'vue-i18n'
11
+ import { useTiptapifyConfig } from '@tiptapify/composables/useTiptapifyConfig'
11
12
 
12
13
  // The toolbar on a selected image: float toggles (or, for a slide, its place in the slider), download, edit, removal
13
14
  const editor = inject('tiptapifyEditor') as Ref<Editor>
14
15
 
16
+ // The image menu follows the buttons shape flag: a quiet carrier in the quiet shape
17
+ const { shape } = useTiptapifyConfig()
18
+
15
19
  // The menu follows its text or image when the editor scrolls inside its own box; it is created once the
16
20
  // box is there, as the menu reads its options only when it is created
17
21
  const scrollTarget = inject<Ref<HTMLElement | null>>('tiptapifyScrollContainer', ref(null))
@@ -62,7 +66,7 @@ function download() {
62
66
  :options="{ placement: 'top', offset: -52, flip: false, scrollTarget }"
63
67
  :should-show="isImageSelected"
64
68
  >
65
- <div class="tiptapify-image-menu tiptapify-quiet-buttons">
69
+ <div class="tiptapify-image-menu" :class="{ 'tiptapify-quiet-buttons': shape.buttons === 'quiet' }">
66
70
  <template v-if="isSlide()">
67
71
  <VBtn size="32" variant="text" color="" :disabled="!editor.can().moveImage(-1)" @click="editor.chain().focus().moveImage(-1).run()">
68
72
  <Tooltip :label="t('media.image_toolbar.move_back')" />
@@ -110,15 +114,20 @@ function download() {
110
114
  </template>
111
115
 
112
116
  <style scoped lang="scss">
117
+ /*
118
+ * The image menu follows the bubble tokens: one rule, the classic shape's base as fallback;
119
+ * the quiet shape paints its border/radius/shadow from the same tokens.
120
+ * The internal divider is this menu's own layout, not the toolbar divider surface.
121
+ */
113
122
  .tiptapify-image-menu {
114
123
  display: flex;
115
124
  align-items: center;
116
- gap: 2px;
117
- padding: 4px;
118
- border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
119
- border-radius: 10px;
120
- background: rgb(var(--v-theme-surface));
121
- box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12);
125
+ gap: var(--tf-bubble-gap, 2px);
126
+ padding: var(--tf-bubble-padding, 4px);
127
+ border: var(--tf-bubble-border, none);
128
+ border-radius: var(--tf-bubble-radius, 6px);
129
+ background: var(--tf-bubble-bg, rgb(var(--v-theme-surface)));
130
+ box-shadow: var(--tf-bubble-shadow, 4px 4px 20px var(--dark-gray));
122
131
  }
123
132
 
124
133
  .tiptapify-image-menu__divider {
@@ -4,6 +4,7 @@ import { computed, inject, Ref, ref } from 'vue'
4
4
  import { BubbleMenu } from '@tiptap/vue-3/menus'
5
5
  import { NodeSelection } from '@tiptap/pm/state'
6
6
  import * as mdi from '@mdi/js'
7
+ import { useTiptapifyConfig } from '@tiptapify/composables/useTiptapifyConfig'
7
8
 
8
9
  defineProps({
9
10
  variant: { type: String, default () { return 'flat' } },
@@ -12,6 +13,10 @@ defineProps({
12
13
 
13
14
  const editor = inject('tiptapifyEditor') as Ref<Editor>
14
15
 
16
+ // The bubble shape picks the markup: a flat row of buttons vs the classic card; the quiet
17
+ // buttons carrier rides the buttons shape flag
18
+ const { shape } = useTiptapifyConfig()
19
+
15
20
  // The menu follows its text or image when the editor scrolls inside its own box; it is created once the
16
21
  // box is there, as the menu reads its options only when it is created
17
22
  const scrollTarget = inject<Ref<HTMLElement | null>>('tiptapifyScrollContainer', ref(null))
@@ -109,7 +114,11 @@ const items = ref([
109
114
  return (from !== to) && !isAllSelected
110
115
  }"
111
116
  >
112
- <div class="tiptapify-bubble-menu tiptapify-quiet-buttons">
117
+ <div
118
+ v-if="shape.bubble === 'flat'"
119
+ class="tiptapify-bubble-menu"
120
+ :class="{ 'tiptapify-quiet-buttons': shape.buttons === 'quiet' }"
121
+ >
113
122
  <VBtn
114
123
  v-for="(item, key) in items"
115
124
  :key="key"
@@ -123,18 +132,47 @@ const items = ref([
123
132
  <VIcon :icon="item.icon" size="20" />
124
133
  </VBtn>
125
134
  </div>
135
+
136
+ <!-- The classic bubble menu: a small card with a divided group of compact buttons -->
137
+ <div v-else class="bubble-menu">
138
+ <VCard>
139
+ <VCardText class="pa-0">
140
+ <VToolbar :theme="theme" density="compact" height="auto" class="p-0">
141
+ <VToolbarItems>
142
+ <VBtnGroup divided density="compact">
143
+ <VBtn v-for="(item, key) in items" :key="key" v-bind="item.props" size="x-small" @click="item.click">
144
+ <VIcon :icon="item.icon" size="20" />
145
+ </VBtn>
146
+ </VBtnGroup>
147
+ </VToolbarItems>
148
+ </VToolbar>
149
+ </VCardText>
150
+ </VCard>
151
+ </div>
126
152
  </BubbleMenu>
127
153
  </template>
128
154
 
129
155
  <style scoped lang="scss">
156
+ /*
157
+ * Bubble skin rules: one rule per shape branch, reading `var(--tf-*)`.
158
+ * The template branch is the shape gate (`bubble: card` renders the classic card,
159
+ * `bubble: flat` the quiet row), so each rule carries its shape's base as fallback.
160
+ * The classic card's background/border/padding/gap are VCard/VToolbar layout (not
161
+ * token dimensions); the flat row paints them from tokens.
162
+ */
163
+ .bubble-menu {
164
+ border-radius: var(--tf-bubble-radius, 6px);
165
+ box-shadow: var(--tf-bubble-shadow, 4px 4px 20px var(--dark-gray));
166
+ }
167
+
130
168
  .tiptapify-bubble-menu {
131
169
  display: flex;
132
170
  align-items: center;
133
- gap: 2px;
134
- padding: 4px;
135
- border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
136
- border-radius: 10px;
137
- background: rgb(var(--v-theme-surface));
138
- box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12);
171
+ gap: var(--tf-bubble-gap, 2px);
172
+ padding: var(--tf-bubble-padding, 4px);
173
+ border: var(--tf-bubble-border, 1px solid rgba(var(--v-border-color), var(--v-border-opacity)));
174
+ border-radius: var(--tf-bubble-radius, 10px);
175
+ background: var(--tf-bubble-bg, rgb(var(--v-theme-surface)));
176
+ box-shadow: var(--tf-bubble-shadow, 0 6px 20px rgba(0, 0, 0, 0.12));
139
177
  }
140
178
  </style>
@@ -0,0 +1,74 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { computed, createSSRApp, h, provide, ref } from 'vue'
3
+ import { renderToString } from 'vue/server-renderer'
4
+ import { createVuetify } from 'vuetify'
5
+ import SkinScope from '@tiptapify/components/SkinScope.vue'
6
+ import { menuContentClass } from '@tiptapify/skins/menu'
7
+ import { TIPTAPIFY_CONFIG_KEY } from '@tiptapify/composables/useTiptapifyConfig'
8
+ import { resolveSkin } from '@tiptapify/skins/registry'
9
+
10
+ // Menus teleport out of the editor DOM, so SkinScope re-anchors the skin tokens
11
+ // on the menu content: a plain div with the provided `--tf-*` record inline.
12
+ async function renderScope (tokens: Record<string, string> | null) {
13
+ const app = createSSRApp({
14
+ setup () {
15
+ if (tokens) {
16
+ provide(TIPTAPIFY_CONFIG_KEY, {
17
+ variantBtn: ref('flat'),
18
+ variantField: ref('outlined'),
19
+ skin: ref('pastel'),
20
+ skinName: computed(() => 'pastel'),
21
+ tokens: computed(() => tokens),
22
+ shape: computed(() => resolveSkin('pastel').shape),
23
+ })
24
+ }
25
+ return () => h(SkinScope, null, { default: () => h('div', { id: 'slot-child' }, 'content') })
26
+ },
27
+ })
28
+ app.use(createVuetify())
29
+
30
+ return renderToString(app)
31
+ }
32
+
33
+ describe('SkinScope', () => {
34
+ it('renders div.tiptapify-skin-scope with the provided --tf-* tokens inline', async () => {
35
+ const html = await renderScope({ '--tf-menu-list-padding': '4px', '--tf-menu-radius': '8px' })
36
+
37
+ expect(html).toContain('class="tiptapify-skin-scope"')
38
+ expect(html).toContain('--tf-menu-list-padding:4px')
39
+ expect(html).toContain('--tf-menu-radius:8px')
40
+ expect(html).toContain('id="slot-child"')
41
+ })
42
+
43
+ it('renders the scope div through the full pastel resolution, slot content inside', async () => {
44
+ const html = await renderScope(resolveSkin('pastel').tokens)
45
+
46
+ expect(html).toMatch(/^<div class="tiptapify-skin-scope" style="--tf-/)
47
+ expect(html).toContain('--tf-menu-list-padding:4px')
48
+ expect(html).toContain('--tf-dialog-radius:12px')
49
+ // The slot renders inside the scope (SSR wraps slot output in hydration comments)
50
+ expect(html).toContain('<div id="slot-child">content</div><!--]--></div>')
51
+ })
52
+
53
+ it('emits no inline vars without a provider (default skin emits none, css fallbacks carry the base)', async () => {
54
+ const html = await renderScope(null)
55
+
56
+ expect(html).toContain('class="tiptapify-skin-scope"')
57
+ expect(html).not.toContain('--tf-')
58
+ expect(html).toContain('id="slot-child"')
59
+ })
60
+ })
61
+
62
+ describe('menuContentClass', () => {
63
+ it('carries the soft menu class for the soft menu shape and the plain base otherwise', () => {
64
+ expect(menuContentClass(true)).toBe('tiptapify-menu tiptapify-menu--soft')
65
+ expect(menuContentClass(false)).toBe('tiptapify-menu')
66
+ })
67
+
68
+ it('the soft carrier rides the menu shape flag, not the skin name: a custom skin extending pastel keeps it', () => {
69
+ expect(menuContentClass(resolveSkin('pastel').shape.menu === 'soft')).toBe('tiptapify-menu tiptapify-menu--soft')
70
+ expect(menuContentClass(resolveSkin({ extends: 'pastel' }).shape.menu === 'soft')).toBe('tiptapify-menu tiptapify-menu--soft')
71
+ expect(menuContentClass(resolveSkin('default').shape.menu === 'soft')).toBe('tiptapify-menu')
72
+ expect(menuContentClass(resolveSkin({ menu: 'soft' }).shape.menu === 'soft')).toBe('tiptapify-menu tiptapify-menu--soft')
73
+ })
74
+ })
@@ -0,0 +1,18 @@
1
+ <script lang="ts" setup>
2
+
3
+ import { useTiptapifyConfig } from '@tiptapify/composables/useTiptapifyConfig'
4
+
5
+ /*
6
+ * Menus teleport out of the editor DOM, so `:style` cannot ride along with a
7
+ * VMenu `content-class`: every tiptapify menu wraps its content in this scope
8
+ * and the skin tokens land on it (spec: Token delivery). The CSS that skins
9
+ * the menus hangs off `.tiptapify-skin-scope` (src/skins/menu.css).
10
+ */
11
+ const { tokens } = useTiptapifyConfig()
12
+ </script>
13
+
14
+ <template>
15
+ <div class="tiptapify-skin-scope" :style="tokens">
16
+ <slot />
17
+ </div>
18
+ </template>
@@ -0,0 +1,186 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+ import { createApp, createSSRApp, h, nextTick, ref, Ref } from 'vue'
3
+ import { renderToString } from 'vue/server-renderer'
4
+ import { createVuetify } from 'vuetify'
5
+ import { createI18n } from 'vue-i18n'
6
+ import Tiptapify from '@tiptapify/components/Tiptapify.vue'
7
+ import { messages } from '@tiptapify/i18n'
8
+
9
+ function withPlugins (app: { use: (plugin: never) => unknown }) {
10
+ app.use(createVuetify() as never)
11
+ app.use(createI18n({ legacy: false, locale: 'en', fallbackLocale: 'en', messages }) as never)
12
+ }
13
+
14
+ async function renderShell (skin: unknown): Promise<string> {
15
+ const app = createSSRApp({ setup: () => () => h(Tiptapify, { skin }) })
16
+ withPlugins(app)
17
+
18
+ return renderToString(app)
19
+ }
20
+
21
+ // The opening tag of the editor root div, so assertions see the skin class and the token style
22
+ function rootTag (html: string): string {
23
+ const start = html.indexOf('tiptapify-editor-')
24
+
25
+ return html.slice(0, html.indexOf('>', start) + 1)
26
+ }
27
+
28
+ async function mountEditor (skinRef: Ref<unknown>) {
29
+ const app = createApp({ setup: () => () => h(Tiptapify, { skin: skinRef.value }) })
30
+ withPlugins(app)
31
+ const host = document.createElement('div')
32
+ document.body.appendChild(host)
33
+ app.mount(host)
34
+ await nextTick()
35
+ await nextTick()
36
+
37
+ return { app, host }
38
+ }
39
+
40
+ describe('Tiptapify skin resolution', () => {
41
+ afterEach(() => {
42
+ vi.restoreAllMocks()
43
+ })
44
+
45
+ it('renders the default skin class and emits no token vars', async () => {
46
+ const html = await renderShell('default')
47
+
48
+ expect(rootTag(html)).toContain('tiptapify-skin-default')
49
+ expect(rootTag(html)).not.toContain('--tf-')
50
+ })
51
+
52
+ it('renders the pastel skin class and emits its token overrides', async () => {
53
+ const html = await renderShell('pastel')
54
+
55
+ expect(rootTag(html)).toContain('tiptapify-skin-pastel')
56
+ expect(rootTag(html)).toContain('--tf-toolbar-bg:rgba(var(--v-theme-on-surface), 0.03)')
57
+ expect(rootTag(html)).toContain('--tf-group-shadow:none')
58
+ expect(rootTag(html)).toContain('--tf-button-radius:6px')
59
+ })
60
+
61
+ it('renders an inline skin object under tiptapify-skin-inline with merged tokens', async () => {
62
+ const html = await renderShell({ colors: { toolbarBg: 'rgb(1, 2, 3)' }, metrics: { buttonRadius: '9px' } })
63
+
64
+ expect(rootTag(html)).toContain('tiptapify-skin-inline')
65
+ expect(rootTag(html)).toContain('--tf-toolbar-bg:rgb(1, 2, 3)')
66
+ expect(rootTag(html)).toContain('--tf-button-radius:9px')
67
+ })
68
+
69
+ it('a named inline object renders under tiptapify-skin-{name}, not the inline name', async () => {
70
+ const html = await renderShell({
71
+ name: 'mint',
72
+ extends: 'pastel',
73
+ colors: { toolbarBg: 'rgba(0, 128, 0, 0.05)' },
74
+ })
75
+
76
+ expect(rootTag(html)).toContain('tiptapify-skin-mint')
77
+ expect(rootTag(html)).not.toContain('tiptapify-skin-inline')
78
+ expect(rootTag(html)).toContain('--tf-toolbar-bg:rgba(0, 128, 0, 0.05)')
79
+ })
80
+
81
+ it('falls back to the default skin for an unknown name', async () => {
82
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
83
+ const html = await renderShell('nope')
84
+
85
+ expect(rootTag(html)).toContain('tiptapify-skin-default')
86
+ expect(rootTag(html)).not.toContain('--tf-')
87
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('nope'))
88
+ })
89
+
90
+ it('re-resolves the skin when the prop changes, without re-rendering the editor', async () => {
91
+ const skin = ref<unknown>('default')
92
+ const { app, host } = await mountEditor(skin)
93
+
94
+ const root = host.querySelector('[id^=tiptapify-editor-]') as HTMLElement
95
+ expect(root.className).toContain('tiptapify-skin-default')
96
+
97
+ const proseMirror = host.querySelector('.ProseMirror')
98
+ expect(proseMirror).toBeTruthy()
99
+
100
+ skin.value = { colors: { toolbarBg: 'rgb(9, 9, 9)' } }
101
+ await nextTick()
102
+
103
+ expect(root.className).toContain('tiptapify-skin-inline')
104
+ expect(root.style.getPropertyValue('--tf-toolbar-bg')).toBe('rgb(9, 9, 9)')
105
+ // The editor DOM is the same node: a skin change never recreates the editor
106
+ expect(host.querySelector('.ProseMirror')).toBe(proseMirror)
107
+
108
+ app.unmount()
109
+ host.remove()
110
+ })
111
+
112
+ it('keeps the pastel toolbar quiet through the resolved skin name', async () => {
113
+ const skin = ref<unknown>('pastel')
114
+ const { app, host } = await mountEditor(skin)
115
+
116
+ const toolbar = host.querySelector('.v-toolbar')
117
+ expect(toolbar?.classList.contains('v-toolbar--flat')).toBe(true)
118
+
119
+ app.unmount()
120
+ host.remove()
121
+ })
122
+
123
+ it('the root carries all six shape classes: classic values for the default skin', async () => {
124
+ const tag = rootTag(await renderShell('default'))
125
+
126
+ expect(tag).toContain('tiptapify-shape-toolbar-raised')
127
+ expect(tag).toContain('tiptapify-shape-groups-box')
128
+ expect(tag).toContain('tiptapify-shape-buttons-vuetify')
129
+ expect(tag).toContain('tiptapify-shape-bubble-card')
130
+ expect(tag).toContain('tiptapify-shape-dialog-classic')
131
+ expect(tag).toContain('tiptapify-shape-menu-vuetify')
132
+ })
133
+
134
+ it('the pastel root carries the quiet shape classes and keeps the skin name class', async () => {
135
+ const tag = rootTag(await renderShell('pastel'))
136
+
137
+ expect(tag).toContain('tiptapify-skin-pastel')
138
+ expect(tag).toContain('tiptapify-shape-toolbar-flat')
139
+ expect(tag).toContain('tiptapify-shape-groups-flat')
140
+ expect(tag).toContain('tiptapify-shape-buttons-quiet')
141
+ expect(tag).toContain('tiptapify-shape-bubble-flat')
142
+ expect(tag).toContain('tiptapify-shape-dialog-soft')
143
+ expect(tag).toContain('tiptapify-shape-menu-soft')
144
+ expect(tag).not.toContain('tiptapify-shape-dialog-classic')
145
+ expect(tag).not.toContain('tiptapify-shape-buttons-vuetify')
146
+ })
147
+
148
+ it('an inline shape object lands on the root classes; the flags it omits stay classic', async () => {
149
+ const tag = rootTag(await renderShell({ buttons: 'quiet', dialog: 'soft' }))
150
+
151
+ expect(tag).toContain('tiptapify-skin-inline')
152
+ expect(tag).toContain('tiptapify-shape-buttons-quiet')
153
+ expect(tag).toContain('tiptapify-shape-dialog-soft')
154
+ expect(tag).toContain('tiptapify-shape-toolbar-raised')
155
+ expect(tag).toContain('tiptapify-shape-groups-box')
156
+ expect(tag).toContain('tiptapify-shape-bubble-card')
157
+ expect(tag).toContain('tiptapify-shape-menu-vuetify')
158
+ })
159
+
160
+ it('a custom skin extending pastel keeps the pastel shape classes under the inline name', async () => {
161
+ const tag = rootTag(await renderShell({ extends: 'pastel' }))
162
+
163
+ expect(tag).toContain('tiptapify-skin-inline')
164
+ expect(tag).toContain('tiptapify-shape-toolbar-flat')
165
+ expect(tag).toContain('tiptapify-shape-groups-flat')
166
+ expect(tag).toContain('tiptapify-shape-buttons-quiet')
167
+ expect(tag).toContain('tiptapify-shape-bubble-flat')
168
+ expect(tag).toContain('tiptapify-shape-dialog-soft')
169
+ expect(tag).toContain('tiptapify-shape-menu-soft')
170
+ })
171
+
172
+ it('the toolbar elevation follows the toolbar shape flag, not the skin name', async () => {
173
+ const skin = ref<unknown>({ toolbar: 'flat' })
174
+ const { app, host } = await mountEditor(skin)
175
+
176
+ expect(host.querySelector('.v-toolbar')?.classList.contains('v-toolbar--flat')).toBe(true)
177
+
178
+ skin.value = { toolbar: 'raised' }
179
+ await nextTick()
180
+
181
+ expect(host.querySelector('.v-toolbar')?.classList.contains('v-toolbar--flat')).toBe(false)
182
+
183
+ app.unmount()
184
+ host.remove()
185
+ })
186
+ })