tiptapify 0.3.3 → 0.3.4

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 (35) hide show
  1. package/README.md +1 -0
  2. package/dist/tiptapify.css +1 -1
  3. package/dist/tiptapify.mjs +15750 -15398
  4. package/dist/tiptapify.umd.js +64 -64
  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
@@ -0,0 +1,81 @@
1
+ import { registerSkin } from './registry'
2
+
3
+ /*
4
+ * The built-in skins are registered skins, not special cases: the custom-skin
5
+ * path resolves through exactly the same chain (spec: anti-goals).
6
+ * 'default' carries the classic shape and the base tokens, so it emits none.
7
+ */
8
+
9
+ export function registerBuiltins(): void {
10
+ registerSkin('default', {})
11
+
12
+ registerSkin('pastel', {
13
+ toolbar: 'flat',
14
+ groups: 'flat',
15
+ buttons: 'quiet',
16
+ bubble: 'flat',
17
+ dialog: 'soft',
18
+ menu: 'soft',
19
+ colors: {
20
+ toolbarBg: 'rgba(var(--v-theme-on-surface), 0.03)',
21
+ toolbarBorderBottom: '1px solid rgba(var(--v-border-color), var(--v-border-opacity))',
22
+ toolbarFg: 'rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity))',
23
+ groupBg: 'transparent',
24
+ groupShadow: 'none',
25
+ dividerColor: 'rgba(var(--v-border-color), var(--v-border-opacity))',
26
+ buttonFg: 'rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity))',
27
+ buttonHoverBg: 'rgba(var(--v-theme-on-surface), 0.07)',
28
+ buttonHoverFg: 'rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity))',
29
+ buttonActiveBg: 'rgba(var(--v-theme-on-surface), 0.1)',
30
+ buttonActiveHoverBg: 'rgba(var(--v-theme-on-surface), 0.14)',
31
+ bubbleBorder: '1px solid rgba(var(--v-border-color), var(--v-border-opacity))',
32
+ bubbleShadow: '0 6px 20px rgba(0, 0, 0, 0.12)',
33
+ menuBorder: '1px solid rgba(var(--v-border-color), var(--v-border-opacity))',
34
+ menuShadow: '0 4px 16px rgba(0, 0, 0, 0.08)',
35
+ menuItemActiveBg: 'rgba(var(--v-theme-on-surface), 0.06)',
36
+ dialogShadow: '0 20px 48px rgba(0, 0, 0, 0.16), 0 2px 6px rgba(0, 0, 0, 0.06)',
37
+ frameBg: 'rgb(var(--v-theme-surface))',
38
+ frameShadow: '0 1px 2px rgba(0, 0, 0, 0.05)',
39
+ imageBg: 'rgba(var(--v-theme-on-surface), 0.04)',
40
+ imageOutline: '2px solid rgb(var(--v-theme-primary))',
41
+ imageOutlineHover: 'rgba(var(--v-theme-primary), 0.35)',
42
+ handleBg: 'rgb(var(--v-theme-primary))',
43
+ handleBgHover: 'rgb(var(--v-theme-primary))',
44
+ handleBorder: '2px solid rgb(var(--v-theme-surface))',
45
+ handleShadow: '0 0 0 1px rgba(0, 0, 0, 0.15)',
46
+ },
47
+ metrics: {
48
+ itemsPadding: '6px 8px',
49
+ itemsGap: '6px',
50
+ itemsRowGap: '4px',
51
+ groupGap: '2px',
52
+ groupHeight: 'auto',
53
+ groupRadius: '0px',
54
+ dividerWidth: '1px',
55
+ dividerHeight: '20px',
56
+ buttonRadius: '6px',
57
+ bubbleRadius: '10px',
58
+ bubblePadding: '4px',
59
+ bubbleGap: '2px',
60
+ menuListPadding: '4px',
61
+ menuRadius: '8px',
62
+ menuItemRadius: '6px',
63
+ menuItemPadding: '4px 8px',
64
+ menuItemMinHeight: '34px',
65
+ dialogRadius: '12px',
66
+ dialogHeaderPadding: '16px 12px 12px 20px',
67
+ dialogContentPadding: '4px 20px',
68
+ dialogActionsPadding: '16px 20px 20px',
69
+ dialogTitleSize: '1.125rem',
70
+ dialogTitleWeight: '600',
71
+ dialogFieldRadius: '8px',
72
+ dialogActionBtnMinHeight: '38px',
73
+ dialogActionBtnRadius: '8px',
74
+ dialogGridGap: '16px',
75
+ imageRadius: '4px',
76
+ handleRadius: '50%',
77
+ },
78
+ })
79
+ }
80
+
81
+ registerBuiltins()
@@ -0,0 +1,87 @@
1
+ /*
2
+ * Menu skin rules (spec 2026-10-18-skins-design.md, "Menu CSS strategy").
3
+ * Each tiptapify menu wraps its content in SkinScope: the scope div carries
4
+ * the skin tokens, so the menu rules hang off the content-class element
5
+ * through it. The soft block rides the `tiptapify-menu--soft` carrier, which
6
+ * the menu shape flag drives (`shape.menu === 'soft'`); tokens paint it, the
7
+ * fallbacks are the soft shape's base (the 0.3.3 look). Doubled classes: the
8
+ * admin theme pads menu lists and items with its own !important rules (the
9
+ * defense of the base commit).
10
+ *
11
+ * The five remaining !importants are the admin-theme defense set, kept
12
+ * deliberately (spec: "no new !important beyond what exists today"):
13
+ * - list `padding`, item `margin`, item `padding`: the consumer admin theme
14
+ * pads Vuetify lists with its own !important rules (documented in the
15
+ * Toolbar/Index.vue comment); token-reading does not beat !important,
16
+ * only !important does.
17
+ * - spacer `width`: the admin theme sizes list spacers; the soft menu owns
18
+ * this pixel (icon-to-label gap).
19
+ * - active item `color`: the admin theme repaints active list items
20
+ * (`.v-list-item--active { color: ... !important }`-style rules); the
21
+ * soft menu's high-emphasis text is part of the skin contract.
22
+ * Everything else (border, radius, shadow, min-height, active background) is
23
+ * plain specificity: the doubled classes + scope segment (0-5-0+) outrank
24
+ * Vuetify's component rules and need no !important.
25
+ */
26
+
27
+ /*
28
+ * Classic skin: Vuetify's own `.v-menu > .v-overlay__content > .v-list` radius/elevation
29
+ * is a child rule (0-3-0), and the scope div inserts itself between them, so the child
30
+ * combinator no longer matches. This restoration rule re-arms those two pixels for the
31
+ * default menu (0-4-0 beats what the content class alone gets), reading the menu tokens
32
+ * with the fixture-canonical classic fallbacks. Under the soft shape the block below
33
+ * paints the same properties from the shape's base (equal specificity, later source order).
34
+ * The classic menu list is therefore a skin rule today: the reachability note "classic
35
+ * menu metrics = native Vuetify" holds for the item pixels only.
36
+ */
37
+ .tiptapify-menu > .tiptapify-skin-scope > .v-list.v-list {
38
+ border-radius: var(--tf-menu-radius, 4px);
39
+ box-shadow: var(--tf-menu-shadow, 0 5px 5px -3px rgba(0, 0, 0, 0.2), 0 8px 10px 1px rgba(0, 0, 0, 0.14), 0 3px 14px 2px rgba(0, 0, 0, 0.12));
40
+ }
41
+
42
+ .tiptapify-menu--soft > .tiptapify-skin-scope > .v-list.v-list {
43
+ padding: var(--tf-menu-list-padding, 4px) !important;
44
+ border: var(--tf-menu-border, 1px solid rgba(var(--v-border-color), var(--v-border-opacity)));
45
+ border-radius: var(--tf-menu-radius, 8px);
46
+ box-shadow: var(--tf-menu-shadow, 0 4px 16px rgba(0, 0, 0, 0.08));
47
+ }
48
+
49
+ .tiptapify-menu--soft > .tiptapify-skin-scope > .v-list.v-list > .v-list-item.v-list-item {
50
+ min-height: var(--tf-menu-item-min-height, 34px);
51
+ margin: 0 !important;
52
+ padding: var(--tf-menu-item-padding, 4px 8px) !important;
53
+ border-radius: var(--tf-menu-item-radius, 6px);
54
+ color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
55
+ }
56
+
57
+ .tiptapify-menu--soft > .tiptapify-skin-scope > .v-list.v-list > .v-list-item.v-list-item .v-list-item__prepend > .v-icon {
58
+ margin-inline-end: 0;
59
+ opacity: 0.6;
60
+ }
61
+
62
+ .tiptapify-menu--soft > .tiptapify-skin-scope > .v-list.v-list > .v-list-item.v-list-item .v-list-item__spacer {
63
+ width: 10px !important;
64
+ }
65
+
66
+ .tiptapify-menu--soft > .tiptapify-skin-scope > .v-list.v-list > .v-list-item.v-list-item .v-list-item-title {
67
+ font-size: 14px;
68
+ font-weight: 500;
69
+ }
70
+
71
+ .tiptapify-menu--soft > .tiptapify-skin-scope > .v-list.v-list > .v-list-item.v-list-item.v-list-item--active {
72
+ background: var(--tf-menu-item-active-bg, rgba(var(--v-theme-on-surface), 0.06));
73
+ color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity)) !important;
74
+ }
75
+
76
+ .tiptapify-menu--soft > .tiptapify-skin-scope > .v-list.v-list > .v-list-item.v-list-item.v-list-item--active > .v-list-item__overlay {
77
+ opacity: 0;
78
+ }
79
+
80
+ /* The list-style grid inside the soft menu; the feature layout stays in ListStyleButton.vue */
81
+ .tiptapify-menu--soft > .tiptapify-skin-scope > .tiptapify-list-style-grid {
82
+ padding: 6px;
83
+ border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
84
+ border-radius: 8px;
85
+ background: rgb(var(--v-theme-surface));
86
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
87
+ }
@@ -0,0 +1,10 @@
1
+ /*
2
+ * The shared `content-class` of the four tiptapify menus (heading, align,
3
+ * bullet, list-styles): the base class plus the soft menu carrier, which
4
+ * anchors the soft menu rules in src/skins/menu.css through the SkinScope
5
+ * wrapper. Driven by the menu shape flag (`shape.menu === 'soft'`), not the
6
+ * skin name: a custom skin extending pastel keeps the carrier.
7
+ */
8
+ export function menuContentClass(menuSoft: boolean): string {
9
+ return menuSoft ? 'tiptapify-menu tiptapify-menu--soft' : 'tiptapify-menu'
10
+ }
@@ -0,0 +1,180 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { registerSkin, defineSkin, resolveSkin, resetSkins, registeredSkins, CLASSIC_SHAPE } from '@tiptapify/skins/registry'
3
+ import { baseColors, baseMetrics, skinColorKeys, skinMetricKeys, cssVarName } from '@tiptapify/skins/tokens'
4
+ import { registerBuiltins } from '@tiptapify/skins/builtins'
5
+
6
+ describe('skin registry', () => {
7
+ beforeEach(() => {
8
+ resetSkins()
9
+ registerBuiltins()
10
+ })
11
+
12
+ afterEach(() => {
13
+ vi.restoreAllMocks()
14
+ })
15
+
16
+ it('resolves the default skin to the classic shape and emits no tokens', () => {
17
+ const skin = resolveSkin('default')
18
+
19
+ expect(skin.name).toBe('default')
20
+ expect(skin.className).toBe('tiptapify-skin-default')
21
+ expect(skin.shape).toEqual(CLASSIC_SHAPE)
22
+ expect(skin.tokens).toEqual({})
23
+ })
24
+
25
+ it('resolves the pastel skin to the quiet shape with its tokens', () => {
26
+ const skin = resolveSkin('pastel')
27
+
28
+ expect(skin.shape).toEqual({
29
+ toolbar: 'flat', groups: 'flat', buttons: 'quiet',
30
+ bubble: 'flat', dialog: 'soft', menu: 'soft',
31
+ })
32
+ expect(skin.tokens['--tf-toolbar-bg']).toBe('rgba(var(--v-theme-on-surface), 0.03)')
33
+ expect(skin.tokens['--tf-button-radius']).toBe('6px')
34
+ expect(skin.tokens['--tf-dialog-grid-gap']).toBe('16px')
35
+ })
36
+
37
+ it('falls back to default for an unregistered name with a dev warning', () => {
38
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
39
+
40
+ const skin = resolveSkin('nope')
41
+
42
+ expect(skin.name).toBe('default')
43
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('nope'))
44
+ })
45
+
46
+ it('merges an inline skin over the default base', () => {
47
+ const skin = resolveSkin({ colors: { toolbarBg: 'rebeccapurple' }, metrics: { groupRadius: '10px' } })
48
+
49
+ expect(skin.className).toBe('tiptapify-skin-inline')
50
+ expect(skin.tokens['--tf-toolbar-bg']).toBe('rebeccapurple')
51
+ expect(skin.tokens['--tf-group-radius']).toBe('10px')
52
+ expect(skin.shape).toEqual(CLASSIC_SHAPE)
53
+ })
54
+
55
+ it('drops an override that equals the base value (emit-skip-equal-to-base)', () => {
56
+ // a skin that re-states the classic values is a twin of default: nothing to emit
57
+ registerSkin('twin', {
58
+ colors: { toolbarBg: baseColors.toolbarBg, handleBg: baseColors.handleBg },
59
+ metrics: { itemsGap: baseMetrics.itemsGap, buttonRadius: '9px' },
60
+ })
61
+
62
+ const skin = resolveSkin('twin')
63
+
64
+ // the base-valued keys are excluded; the one real override rides through
65
+ expect(skin.tokens).toEqual({ '--tf-button-radius': '9px' })
66
+ })
67
+
68
+ it('inherits shape and tokens through extends, own members win', () => {
69
+ registerSkin('mint', {
70
+ extends: 'pastel',
71
+ colors: { toolbarBg: 'rgba(0, 128, 0, 0.05)', handleBg: 'rgb(0, 100, 0)' },
72
+ metrics: { dialogRadius: '14px' },
73
+ })
74
+
75
+ const skin = resolveSkin('mint')
76
+
77
+ expect(skin.shape).toEqual(resolveSkin('pastel').shape)
78
+ expect(skin.tokens['--tf-toolbar-bg']).toBe('rgba(0, 128, 0, 0.05)')
79
+ expect(skin.tokens['--tf-handle-bg']).toBe('rgb(0, 100, 0)')
80
+ expect(skin.tokens['--tf-dialog-radius']).toBe('14px')
81
+ // untouched pastel tokens are inherited
82
+ expect(skin.tokens['--tf-button-radius']).toBe('6px')
83
+ })
84
+
85
+ it('an inline object may extend a registered skin', () => {
86
+ const skin = resolveSkin({ extends: 'pastel', metrics: { bubbleRadius: '2px' } })
87
+
88
+ expect(skin.className).toBe('tiptapify-skin-inline')
89
+ expect(skin.shape.bubble).toBe('flat')
90
+ expect(skin.tokens['--tf-bubble-radius']).toBe('2px')
91
+ expect(skin.tokens['--tf-bubble-padding']).toBe('4px')
92
+ })
93
+
94
+ it('ignores unknown token keys in dev with a warning', () => {
95
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
96
+
97
+ const skin = resolveSkin({ colors: { notAToken: 'red' } as never })
98
+
99
+ expect(skin.tokens['--tf-not-a-token']).toBeUndefined()
100
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('notAToken'))
101
+ })
102
+
103
+ it('resolves a missing extends to default with a dev warning', () => {
104
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
105
+
106
+ const skin = resolveSkin({ extends: 'ghost' })
107
+
108
+ expect(skin.shape).toEqual(CLASSIC_SHAPE)
109
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('ghost'))
110
+ })
111
+
112
+ it('resolves a circular extends chain to default with a dev warning', () => {
113
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
114
+ registerSkin('a', { extends: 'b' })
115
+ registerSkin('b', { extends: 'a' })
116
+
117
+ const skin = resolveSkin('a')
118
+
119
+ expect(skin.name).toBe('a')
120
+ expect(skin.shape).toEqual(CLASSIC_SHAPE)
121
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('a'))
122
+ })
123
+
124
+ it('inline skins never enter the global registry', () => {
125
+ resolveSkin({ name: 'mint', colors: { toolbarBg: 'green' } })
126
+
127
+ expect(registeredSkins()).not.toContain('mint')
128
+ const registered = registerSkin('mint', { colors: { toolbarBg: 'lime' } })
129
+ expect(registered).toBeUndefined()
130
+ expect(registeredSkins()).toContain('mint')
131
+ })
132
+
133
+ it('defineSkin is identity typed', () => {
134
+ const cfg = defineSkin({ extends: 'pastel', metrics: { itemsPadding: '1px 2px' } })
135
+
136
+ expect(cfg).toEqual({ extends: 'pastel', metrics: { itemsPadding: '1px 2px' } })
137
+ })
138
+
139
+ it('emits css variables only for keys present in the typed catalogs', () => {
140
+ expect(cssVarName('toolbarBg')).toBe('--tf-toolbar-bg')
141
+ expect(cssVarName('itemsRowGap')).toBe('--tf-items-row-gap')
142
+ expect(skinColorKeys.length).toBeGreaterThan(10)
143
+ expect(skinMetricKeys.length).toBeGreaterThan(10)
144
+ // every catalog key has a base value
145
+ for (const key of skinColorKeys) expect(typeof baseColors[key]).toBe('string')
146
+ for (const key of skinMetricKeys) expect(typeof baseMetrics[key]).toBe('string')
147
+ })
148
+ })
149
+
150
+ describe('skin registry without builtins', () => {
151
+ // The registry used standalone (no builtins.ts load): 'default' is the implicit
152
+ // root of every extends chain, so walking to it must not warn about a missing link
153
+ beforeEach(() => {
154
+ resetSkins()
155
+ })
156
+
157
+ afterEach(() => {
158
+ vi.restoreAllMocks()
159
+ })
160
+
161
+ it('treats the implicit default root as present, no dev warning', () => {
162
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
163
+
164
+ const skin = resolveSkin({ extends: 'default', colors: { toolbarBg: 'red' } })
165
+ const inline = resolveSkin({ colors: { toolbarBg: 'blue' } })
166
+
167
+ expect(skin.shape).toEqual(CLASSIC_SHAPE)
168
+ expect(skin.tokens['--tf-toolbar-bg']).toBe('red')
169
+ expect(inline.tokens['--tf-toolbar-bg']).toBe('blue')
170
+ expect(warn).not.toHaveBeenCalled()
171
+ })
172
+
173
+ it('still warns for a genuinely missing extends link', () => {
174
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
175
+
176
+ resolveSkin({ extends: 'ghost' })
177
+
178
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('ghost'))
179
+ })
180
+ })
@@ -0,0 +1,132 @@
1
+ import { TiptapifyShape, TiptapifySkinConfig, TiptapifySkinInput } from '@tiptapify/types/editor'
2
+ import { baseColors, baseMetrics, skinColorKeys, skinMetricKeys, cssVarName } from './tokens'
3
+
4
+ export type ResolvedSkin = {
5
+ name: string
6
+ // root class for the escape hatch: `tiptapify-skin-{name}`
7
+ className: string
8
+ shape: Required<TiptapifyShape>
9
+ // css variable name -> value; only overrides, base values are the css fallbacks
10
+ tokens: Record<string, string>
11
+ }
12
+
13
+ // The classic structure: every skin resolves over it (base tokens → extends chain → own)
14
+ export const CLASSIC_SHAPE: Required<TiptapifyShape> = {
15
+ toolbar: 'raised',
16
+ groups: 'box',
17
+ buttons: 'vuetify',
18
+ bubble: 'card',
19
+ dialog: 'classic',
20
+ menu: 'vuetify',
21
+ }
22
+
23
+ const registry = new Map<string, TiptapifySkinConfig>()
24
+
25
+ export function registerSkin(name: string, skin: TiptapifySkinConfig): void {
26
+ registry.set(name, { ...skin, name })
27
+ }
28
+
29
+ // Identity for typed const definitions; the object never enters the registry
30
+ export function defineSkin(skin: TiptapifySkinConfig): TiptapifySkinConfig {
31
+ return skin
32
+ }
33
+
34
+ export function registeredSkins(): string[] {
35
+ return [...registry.keys()]
36
+ }
37
+
38
+ // Dev/test isolation: drop all registrations
39
+ export function resetSkins(): void {
40
+ registry.clear()
41
+ }
42
+
43
+ const devWarn = (msg: string) => {
44
+ if (import.meta.env.DEV) console.warn(`[tiptapify] ${msg}`)
45
+ }
46
+
47
+ const isConfig = (skin: TiptapifySkinInput): skin is TiptapifySkinConfig => typeof skin === 'object' && skin !== null
48
+
49
+ const SHAPE_KEYS = ['toolbar', 'groups', 'buttons', 'bubble', 'dialog', 'menu'] as const
50
+
51
+ // Walk the extends chain root-first: [default, base…, self]. A missing link
52
+ // resolves to the accumulated chain (warn); a cycle resolves to classic (warn).
53
+ // 'default' is the implicit root of every chain: a bare registry without the
54
+ // builtins must not warn about walking to it.
55
+ function chainOf(config: TiptapifySkinConfig): { chain: TiptapifySkinConfig[], cyclic: boolean } {
56
+ const chain: TiptapifySkinConfig[] = []
57
+ const visited = new Set<string>()
58
+ let link: string | undefined = config.extends ?? 'default'
59
+ while (link) {
60
+ if (visited.has(link)) return { chain, cyclic: true }
61
+ visited.add(link)
62
+ const registered = registry.get(link)
63
+ if (!registered) {
64
+ if (link !== 'default') devWarn(`skin extends "${link}" is not registered; resolving without it`)
65
+ break
66
+ }
67
+ chain.unshift(registered)
68
+ link = registered.extends ?? 'default'
69
+ if (link === 'default' && registered.name === 'default') break
70
+ }
71
+ return { chain, cyclic: false }
72
+ }
73
+
74
+ export function resolveSkin(skin?: TiptapifySkinInput): ResolvedSkin {
75
+ let config: TiptapifySkinConfig
76
+ let name: string
77
+
78
+ if (skin === undefined || skin === null || skin === '') {
79
+ config = {}
80
+ name = 'default'
81
+ } else if (typeof skin === 'string') {
82
+ const registered = registry.get(skin)
83
+ if (!registered) {
84
+ devWarn(`unknown skin "${skin}"; falling back to "default"`)
85
+ config = {}
86
+ name = 'default'
87
+ } else {
88
+ config = registered
89
+ name = skin
90
+ }
91
+ } else {
92
+ config = skin
93
+ name = config.name ?? 'inline'
94
+ }
95
+
96
+ const { chain, cyclic } = chainOf(config)
97
+ if (cyclic) devWarn(`circular "extends" in skin "${name}"; falling back to classic`)
98
+
99
+ // base: classic shape + classic tokens (not emitted — they are the css fallbacks)
100
+ const shape: Required<TiptapifyShape> = { ...CLASSIC_SHAPE }
101
+ const colors: Record<string, string> = {}
102
+ const metrics: Record<string, string> = {}
103
+ const sources = cyclic ? [] : [...chain, config]
104
+ for (const source of sources) {
105
+ for (const key of SHAPE_KEYS) {
106
+ const value = source[key]
107
+ if (value) shape[key] = value as never
108
+ }
109
+ for (const [key, value] of Object.entries(source.colors ?? {})) {
110
+ if (!skinColorKeys.includes(key as never)) {
111
+ devWarn(`unknown skin color token "${key}" on skin "${name}"; ignored`)
112
+ continue
113
+ }
114
+ if (value !== undefined) colors[key] = value
115
+ }
116
+ for (const [key, value] of Object.entries(source.metrics ?? {})) {
117
+ if (!skinMetricKeys.includes(key as never)) {
118
+ devWarn(`unknown skin metric token "${key}" on skin "${name}"; ignored`)
119
+ continue
120
+ }
121
+ if (value !== undefined) metrics[key] = value
122
+ }
123
+ }
124
+
125
+ const tokens: Record<string, string> = {}
126
+ for (const [key, value] of Object.entries({ ...colors, ...metrics })) {
127
+ const base = (baseColors as Record<string, string>)[key] ?? (baseMetrics as Record<string, string>)[key]
128
+ if (value !== base) tokens[cssVarName(key)] = value
129
+ }
130
+
131
+ return { name, className: `tiptapify-skin-${name}`, shape, tokens }
132
+ }
@@ -0,0 +1,43 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { skinColorKeys, skinMetricKeys, cssVarName } from '@tiptapify/skins/tokens'
4
+
5
+ /*
6
+ * Reachability contract guard (spec 2026-10-18-skins-design.md, task 7): every catalog
7
+ * token must have at least one CSS consumer in the skin layer. Gating is per shape:
8
+ * quiet-button tokens are consumed under the quiet gates, menu-soft tokens under the
9
+ * `--soft` menu rules, dialog-soft tokens under the `--soft` dialog rules, and the
10
+ * default-path tokens in the base rules — this guard checks consumption, the fixture
11
+ * gate (skin-baseline.json) checks the painted result.
12
+ *
13
+ * Parsing the SOURCE files (not dist): the source is where a token can be dropped;
14
+ * dist adds noise (bundling, minification) and is produced by the build anyway.
15
+ */
16
+ const SOURCES = [
17
+ '../components/Tiptapify.vue',
18
+ '../components/Toolbar/Index.vue',
19
+ '../components/Toolbar/Items.vue',
20
+ '../components/MenuBubble.vue',
21
+ '../components/ImageBubbleMenu.vue',
22
+ '../components/UI/TiptapifyDialog.vue',
23
+ './menu.css',
24
+ '../extensions/components/list/styles/ListStyleButton.vue',
25
+ ]
26
+
27
+ const css = SOURCES.map((file) => readFileSync(new URL(file, import.meta.url), 'utf8')).join('\n')
28
+
29
+ describe('skin token reachability', () => {
30
+ for (const key of [...skinColorKeys, ...skinMetricKeys]) {
31
+ it(`consumes ${cssVarName(key)} (from ${key})`, () => {
32
+ expect(css).toContain(`var(${cssVarName(key)}`)
33
+ })
34
+ }
35
+
36
+ it('has no library rule keyed on a built-in skin name (escape hatch has no consumers)', () => {
37
+ // `.tiptapify-skin-{name}` is the documented escape hatch for consumer CSS; the
38
+ // library itself gates on shape classes and tokens only (spec: Layered model).
39
+ const rule = css.match(/\.tiptapify-skin-(pastel|default)\b[^{]*\{/g)
40
+
41
+ expect(rule).toBeNull()
42
+ })
43
+ })
@@ -0,0 +1,92 @@
1
+ import { TiptapifySkinColors, TiptapifySkinMetrics } from '@tiptapify/types/editor'
2
+
3
+ /*
4
+ * The typed token catalog (public contract; spec 2026-10-18-skins-design.md).
5
+ * Every skin rule consumes its tokens as CSS custom properties with a fallback
6
+ * equal to the base (classic) value: the values below. Cells of the spec table
7
+ * reading "Vuetify" / "VCard elevation" are filled by the committed baseline
8
+ * fixture (src/skins/__fixtures__/skin-baseline.json) — its measured value
9
+ * at that selector is canonical and is the fallback stored here.
10
+ *
11
+ * Key naming: the camelCase key is the CSS suffix — `groupShadow` ↔
12
+ * `--tf-group-shadow`. Composite values split by nature: `--tf-items-gap`
13
+ * expands to `itemsGap` (column) + `itemsRowGap` (the classic row gap).
14
+ * Colors are paint (including full border shorthands and shadows); lengths,
15
+ * paddings, gaps, radii, min-heights are metrics.
16
+ */
17
+
18
+ // Base values = the classic look. Button tokens paint only when the shape
19
+ // activates the quiet buttons; the classic values below are the native vuetify
20
+ // rendering the fixture measures (reachability contract: inert under
21
+ // buttons:'vuetify'). The quiet/pastel values ship with the pastel skin.
22
+ export const baseColors: TiptapifySkinColors = {
23
+ toolbarBg: 'rgb(var(--v-theme-surface))',
24
+ toolbarBorderBottom: 'none',
25
+ toolbarFg: 'inherit',
26
+ groupBg: 'rgb(var(--v-theme-surface))',
27
+ groupShadow: '0 3px 1px -2px var(--v-shadow-key-umbra-opacity, rgba(0, 0, 0, 0.2)), 0 2px 2px 0 var(--v-shadow-key-penumbra-opacity, rgba(0, 0, 0, 0.14)), 0 1px 5px 0 var(--v-shadow-key-ambient-opacity, rgba(0, 0, 0, 0.12))',
28
+ dividerColor: 'transparent',
29
+ buttonFg: 'inherit',
30
+ buttonHoverBg: 'rgba(var(--v-theme-on-surface), var(--v-hover-opacity))',
31
+ buttonHoverFg: 'inherit',
32
+ buttonActiveBg: 'rgb(var(--v-theme-primary))',
33
+ buttonActiveHoverBg: 'rgb(var(--v-theme-primary))',
34
+ bubbleBg: 'rgb(var(--v-theme-surface))',
35
+ bubbleBorder: 'none',
36
+ bubbleShadow: '4px 4px 20px var(--dark-gray)',
37
+ menuBorder: 'none',
38
+ menuShadow: '0 5px 5px -3px rgba(0, 0, 0, 0.2), 0 8px 10px 1px rgba(0, 0, 0, 0.14), 0 3px 14px 2px rgba(0, 0, 0, 0.12)',
39
+ menuItemActiveBg: 'rgba(0, 0, 0, 0)',
40
+ dialogShadow: '0 11px 15px -7px rgba(0, 0, 0, 0.2), 0 24px 38px 3px rgba(0, 0, 0, 0.14), 0 9px 46px 8px rgba(0, 0, 0, 0.12)',
41
+ frameBg: 'transparent',
42
+ frameShadow: 'none',
43
+ // the classic frame: a hairline around the editor, the toolbar edge on top of it (border-top removed by the rule)
44
+ frameBorder: '1px solid rgba(var(--v-border-color), var(--v-border-opacity))',
45
+ imageBg: 'transparent',
46
+ imageOutline: '1px solid rgba(0, 0, 0, 0.25)',
47
+ imageOutlineHover: 'transparent',
48
+ handleBg: 'rgba(0, 0, 0, 0.5)',
49
+ handleBgHover: 'rgba(0, 0, 0, 0.8)',
50
+ handleBorder: 'none',
51
+ handleShadow: 'none',
52
+ }
53
+
54
+ export const baseMetrics: TiptapifySkinMetrics = {
55
+ itemsPadding: '12px 16px',
56
+ itemsGap: '0px',
57
+ itemsRowGap: '5px',
58
+ groupGap: '0px',
59
+ groupHeight: '32px',
60
+ groupRadius: '4px',
61
+ dividerWidth: '0px',
62
+ dividerHeight: '0px',
63
+ buttonRadius: '4px',
64
+ bubbleRadius: '6px',
65
+ bubblePadding: '0px',
66
+ bubbleGap: '0px',
67
+ menuListPadding: '8px 0px',
68
+ menuRadius: '4px',
69
+ menuItemRadius: '0px',
70
+ menuItemPadding: '4px 16px',
71
+ menuItemMinHeight: '40px',
72
+ dialogRadius: '4px',
73
+ dialogHeaderPadding: '8px 16px',
74
+ dialogContentPadding: '16px 24px 24px',
75
+ dialogActionsPadding: '8px',
76
+ dialogTitleSize: '1.25rem',
77
+ dialogTitleWeight: '400',
78
+ dialogFieldRadius: '4px',
79
+ dialogActionBtnMinHeight: 'auto',
80
+ dialogActionBtnRadius: '4px',
81
+ dialogGridGap: '24px',
82
+ imageRadius: '0.125rem',
83
+ handleRadius: '2px',
84
+ }
85
+
86
+ export const skinColorKeys = Object.keys(baseColors) as (keyof TiptapifySkinColors)[]
87
+ export const skinMetricKeys = Object.keys(baseMetrics) as (keyof TiptapifySkinMetrics)[]
88
+
89
+ // 'toolbarBg' -> '--tf-toolbar-bg', 'itemsRowGap' -> '--tf-items-row-gap'
90
+ export function cssVarName(key: string): string {
91
+ return '--tf-' + key.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase())
92
+ }