@railway/inkwell 1.1.1 → 1.3.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/index.cjs CHANGED
@@ -1377,6 +1377,7 @@ var InkwellEditorClient = react.forwardRef(
1377
1377
  );
1378
1378
  const overLimit = characterLimit !== void 0 && characterCount > characterLimit;
1379
1379
  const hasCharacterLimit = characterLimit !== void 0;
1380
+ const showCharacterCount = characterLimit !== void 0 && characterCount >= characterLimit * 0.8;
1380
1381
  const getEditorState = react.useCallback(() => {
1381
1382
  const content2 = serializeContent();
1382
1383
  return {
@@ -2026,7 +2027,7 @@ var InkwellEditorClient = react.forwardRef(
2026
2027
  className: `inkwell-editor-wrapper${hasCharacterLimit ? " inkwell-editor-has-character-limit" : ""}${overLimit ? " inkwell-editor-over-limit" : ""}${className ? ` ${className}` : ""}${classNames?.root ? ` ${classNames.root}` : ""}`,
2027
2028
  style: styles?.root,
2028
2029
  children: [
2029
- hasCharacterLimit && /* @__PURE__ */ jsxRuntime.jsx(
2030
+ showCharacterCount && /* @__PURE__ */ jsxRuntime.jsx(
2030
2031
  CharacterCount,
2031
2032
  {
2032
2033
  count: characterCount,
@@ -2185,16 +2186,41 @@ var insertUploadedAttachment = (file, options) => {
2185
2186
  options.onError?.(err, file);
2186
2187
  });
2187
2188
  };
2188
- function createAttachmentsPlugin(options) {
2189
+ function routeFiles(editor, files, options) {
2189
2190
  const { accept } = options;
2191
+ const matching = accept ? files.filter((f) => mimeMatches(f.type, accept)) : files;
2192
+ const handled = matching.filter(
2193
+ (f) => isImageFile(f) || options.onAttachmentAdd !== void 0
2194
+ );
2195
+ for (const file of handled) {
2196
+ if (isImageFile(file)) {
2197
+ insertUploadedImage(editor, file, options);
2198
+ } else {
2199
+ insertUploadedAttachment(file, options);
2200
+ }
2201
+ }
2202
+ const skipped = files.filter((f) => !handled.includes(f));
2203
+ return { handled, skipped };
2204
+ }
2205
+ function createAttachmentsPlugin(options) {
2190
2206
  return {
2191
2207
  name: "attachments",
2208
+ setup(editor) {
2209
+ if (!options.ref) return;
2210
+ const writableRef = options.ref;
2211
+ writableRef.current = {
2212
+ upload: (files) => {
2213
+ if (files.length === 0) return;
2214
+ routeFiles(editor, files, options);
2215
+ }
2216
+ };
2217
+ return () => {
2218
+ writableRef.current = null;
2219
+ };
2220
+ },
2192
2221
  onInsertData(data, { editor, insertData }) {
2193
2222
  const files = extractFiles(data);
2194
- const matching = accept ? files.filter((f) => mimeMatches(f.type, accept)) : files;
2195
- const handled = matching.filter(
2196
- (f) => isImageFile(f) || options.onAttachmentAdd !== void 0
2197
- );
2223
+ const { handled, skipped } = routeFiles(editor, files, options);
2198
2224
  if (handled.length === 0) {
2199
2225
  const htmlImages = extractHtmlImages(data);
2200
2226
  if (htmlImages.length === 0) return false;
@@ -2203,16 +2229,8 @@ function createAttachmentsPlugin(options) {
2203
2229
  }
2204
2230
  return true;
2205
2231
  }
2206
- const unhandled = files.filter((f) => !handled.includes(f));
2207
- if (unhandled.length > 0) {
2208
- insertData(filesOnlyDataTransfer(unhandled));
2209
- }
2210
- for (const file of handled) {
2211
- if (isImageFile(file)) {
2212
- insertUploadedImage(editor, file, options);
2213
- } else {
2214
- insertUploadedAttachment(file, options);
2215
- }
2232
+ if (skipped.length > 0) {
2233
+ insertData(filesOnlyDataTransfer(skipped));
2216
2234
  }
2217
2235
  return true;
2218
2236
  }
package/dist/index.d.cts CHANGED
@@ -280,6 +280,9 @@ interface Attachment {
280
280
  */
281
281
  [key: string]: unknown;
282
282
  }
283
+ interface AttachmentsHandle {
284
+ upload: (files: File[]) => void;
285
+ }
283
286
  interface AttachmentsPluginOptions {
284
287
  /**
285
288
  * Upload a single file and resolve to the public URL, or an object
@@ -292,6 +295,8 @@ interface AttachmentsPluginOptions {
292
295
  * (`image/*`). Files that don't match pass through untouched.
293
296
  */
294
297
  accept?: string;
298
+ /** Populated on editor mount, nulled on unmount. */
299
+ ref?: RefObject<AttachmentsHandle | null>;
295
300
  /**
296
301
  * Placeholder alt text shown on the inserted image element while an
297
302
  * image upload is in flight. Defaults to `"Uploading…"`.
@@ -484,4 +489,4 @@ declare function InkwellRenderer({ content, className, components, rehypePlugins
484
489
  */
485
490
  declare function parseMarkdown(content: string, options?: ParseMarkdownOptions): ReactNode;
486
491
 
487
- export { type Attachment, type AttachmentUploadResult, type AttachmentsPluginOptions, type BubbleMenuItem, type BubbleMenuItemProps, type BubbleMenuOptions, type CompletionsPluginOptions, type EmojiItem, type EmojiPluginOptions, type InkwellComponents, InkwellEditor, type InkwellEditorClassNames, type InkwellEditorFocusOptions, type InkwellEditorHandle, type InkwellEditorProps, type InkwellEditorState, type InkwellEditorStyles, type InkwellFeatures, type InkwellPlugin, type InkwellPluginActivation, type InkwellPluginEditor, type InkwellPluginPlaceholder, InkwellRenderer, type InkwellRendererProps, type MentionItem, type MentionRenderer, type MentionsPluginOptions, type ParseMarkdownOptions, type PluginInsertDataContext, type PluginKeyDownContext, type PluginRenderProps, type RehypePluginConfig, type SlashCommandArg, type SlashCommandChoice, type SlashCommandExecution, type SlashCommandItem, type SlashCommandsPluginOptions, type Snippet, type SnippetsPluginOptions, type SubscribeForwardedKey, createAttachmentsPlugin, createBubbleMenuPlugin, createCompletionsPlugin, createEmojiPlugin, createMentionsPlugin, createSlashCommandsPlugin, createSnippetsPlugin, defaultBubbleMenuItems, defaultEmojis, htmlToMarkdown, parseMarkdown };
492
+ export { type Attachment, type AttachmentUploadResult, type AttachmentsHandle, type AttachmentsPluginOptions, type BubbleMenuItem, type BubbleMenuItemProps, type BubbleMenuOptions, type CompletionsPluginOptions, type EmojiItem, type EmojiPluginOptions, type InkwellComponents, InkwellEditor, type InkwellEditorClassNames, type InkwellEditorFocusOptions, type InkwellEditorHandle, type InkwellEditorProps, type InkwellEditorState, type InkwellEditorStyles, type InkwellFeatures, type InkwellPlugin, type InkwellPluginActivation, type InkwellPluginEditor, type InkwellPluginPlaceholder, InkwellRenderer, type InkwellRendererProps, type MentionItem, type MentionRenderer, type MentionsPluginOptions, type ParseMarkdownOptions, type PluginInsertDataContext, type PluginKeyDownContext, type PluginRenderProps, type RehypePluginConfig, type SlashCommandArg, type SlashCommandChoice, type SlashCommandExecution, type SlashCommandItem, type SlashCommandsPluginOptions, type Snippet, type SnippetsPluginOptions, type SubscribeForwardedKey, createAttachmentsPlugin, createBubbleMenuPlugin, createCompletionsPlugin, createEmojiPlugin, createMentionsPlugin, createSlashCommandsPlugin, createSnippetsPlugin, defaultBubbleMenuItems, defaultEmojis, htmlToMarkdown, parseMarkdown };
package/dist/index.d.ts CHANGED
@@ -280,6 +280,9 @@ interface Attachment {
280
280
  */
281
281
  [key: string]: unknown;
282
282
  }
283
+ interface AttachmentsHandle {
284
+ upload: (files: File[]) => void;
285
+ }
283
286
  interface AttachmentsPluginOptions {
284
287
  /**
285
288
  * Upload a single file and resolve to the public URL, or an object
@@ -292,6 +295,8 @@ interface AttachmentsPluginOptions {
292
295
  * (`image/*`). Files that don't match pass through untouched.
293
296
  */
294
297
  accept?: string;
298
+ /** Populated on editor mount, nulled on unmount. */
299
+ ref?: RefObject<AttachmentsHandle | null>;
295
300
  /**
296
301
  * Placeholder alt text shown on the inserted image element while an
297
302
  * image upload is in flight. Defaults to `"Uploading…"`.
@@ -484,4 +489,4 @@ declare function InkwellRenderer({ content, className, components, rehypePlugins
484
489
  */
485
490
  declare function parseMarkdown(content: string, options?: ParseMarkdownOptions): ReactNode;
486
491
 
487
- export { type Attachment, type AttachmentUploadResult, type AttachmentsPluginOptions, type BubbleMenuItem, type BubbleMenuItemProps, type BubbleMenuOptions, type CompletionsPluginOptions, type EmojiItem, type EmojiPluginOptions, type InkwellComponents, InkwellEditor, type InkwellEditorClassNames, type InkwellEditorFocusOptions, type InkwellEditorHandle, type InkwellEditorProps, type InkwellEditorState, type InkwellEditorStyles, type InkwellFeatures, type InkwellPlugin, type InkwellPluginActivation, type InkwellPluginEditor, type InkwellPluginPlaceholder, InkwellRenderer, type InkwellRendererProps, type MentionItem, type MentionRenderer, type MentionsPluginOptions, type ParseMarkdownOptions, type PluginInsertDataContext, type PluginKeyDownContext, type PluginRenderProps, type RehypePluginConfig, type SlashCommandArg, type SlashCommandChoice, type SlashCommandExecution, type SlashCommandItem, type SlashCommandsPluginOptions, type Snippet, type SnippetsPluginOptions, type SubscribeForwardedKey, createAttachmentsPlugin, createBubbleMenuPlugin, createCompletionsPlugin, createEmojiPlugin, createMentionsPlugin, createSlashCommandsPlugin, createSnippetsPlugin, defaultBubbleMenuItems, defaultEmojis, htmlToMarkdown, parseMarkdown };
492
+ export { type Attachment, type AttachmentUploadResult, type AttachmentsHandle, type AttachmentsPluginOptions, type BubbleMenuItem, type BubbleMenuItemProps, type BubbleMenuOptions, type CompletionsPluginOptions, type EmojiItem, type EmojiPluginOptions, type InkwellComponents, InkwellEditor, type InkwellEditorClassNames, type InkwellEditorFocusOptions, type InkwellEditorHandle, type InkwellEditorProps, type InkwellEditorState, type InkwellEditorStyles, type InkwellFeatures, type InkwellPlugin, type InkwellPluginActivation, type InkwellPluginEditor, type InkwellPluginPlaceholder, InkwellRenderer, type InkwellRendererProps, type MentionItem, type MentionRenderer, type MentionsPluginOptions, type ParseMarkdownOptions, type PluginInsertDataContext, type PluginKeyDownContext, type PluginRenderProps, type RehypePluginConfig, type SlashCommandArg, type SlashCommandChoice, type SlashCommandExecution, type SlashCommandItem, type SlashCommandsPluginOptions, type Snippet, type SnippetsPluginOptions, type SubscribeForwardedKey, createAttachmentsPlugin, createBubbleMenuPlugin, createCompletionsPlugin, createEmojiPlugin, createMentionsPlugin, createSlashCommandsPlugin, createSnippetsPlugin, defaultBubbleMenuItems, defaultEmojis, htmlToMarkdown, parseMarkdown };
package/dist/index.js CHANGED
@@ -1362,6 +1362,7 @@ var InkwellEditorClient = forwardRef(
1362
1362
  );
1363
1363
  const overLimit = characterLimit !== void 0 && characterCount > characterLimit;
1364
1364
  const hasCharacterLimit = characterLimit !== void 0;
1365
+ const showCharacterCount = characterLimit !== void 0 && characterCount >= characterLimit * 0.8;
1365
1366
  const getEditorState = useCallback(() => {
1366
1367
  const content2 = serializeContent();
1367
1368
  return {
@@ -2011,7 +2012,7 @@ var InkwellEditorClient = forwardRef(
2011
2012
  className: `inkwell-editor-wrapper${hasCharacterLimit ? " inkwell-editor-has-character-limit" : ""}${overLimit ? " inkwell-editor-over-limit" : ""}${className ? ` ${className}` : ""}${classNames?.root ? ` ${classNames.root}` : ""}`,
2012
2013
  style: styles?.root,
2013
2014
  children: [
2014
- hasCharacterLimit && /* @__PURE__ */ jsx(
2015
+ showCharacterCount && /* @__PURE__ */ jsx(
2015
2016
  CharacterCount,
2016
2017
  {
2017
2018
  count: characterCount,
@@ -2170,16 +2171,41 @@ var insertUploadedAttachment = (file, options) => {
2170
2171
  options.onError?.(err, file);
2171
2172
  });
2172
2173
  };
2173
- function createAttachmentsPlugin(options) {
2174
+ function routeFiles(editor, files, options) {
2174
2175
  const { accept } = options;
2176
+ const matching = accept ? files.filter((f) => mimeMatches(f.type, accept)) : files;
2177
+ const handled = matching.filter(
2178
+ (f) => isImageFile(f) || options.onAttachmentAdd !== void 0
2179
+ );
2180
+ for (const file of handled) {
2181
+ if (isImageFile(file)) {
2182
+ insertUploadedImage(editor, file, options);
2183
+ } else {
2184
+ insertUploadedAttachment(file, options);
2185
+ }
2186
+ }
2187
+ const skipped = files.filter((f) => !handled.includes(f));
2188
+ return { handled, skipped };
2189
+ }
2190
+ function createAttachmentsPlugin(options) {
2175
2191
  return {
2176
2192
  name: "attachments",
2193
+ setup(editor) {
2194
+ if (!options.ref) return;
2195
+ const writableRef = options.ref;
2196
+ writableRef.current = {
2197
+ upload: (files) => {
2198
+ if (files.length === 0) return;
2199
+ routeFiles(editor, files, options);
2200
+ }
2201
+ };
2202
+ return () => {
2203
+ writableRef.current = null;
2204
+ };
2205
+ },
2177
2206
  onInsertData(data, { editor, insertData }) {
2178
2207
  const files = extractFiles(data);
2179
- const matching = accept ? files.filter((f) => mimeMatches(f.type, accept)) : files;
2180
- const handled = matching.filter(
2181
- (f) => isImageFile(f) || options.onAttachmentAdd !== void 0
2182
- );
2208
+ const { handled, skipped } = routeFiles(editor, files, options);
2183
2209
  if (handled.length === 0) {
2184
2210
  const htmlImages = extractHtmlImages(data);
2185
2211
  if (htmlImages.length === 0) return false;
@@ -2188,16 +2214,8 @@ function createAttachmentsPlugin(options) {
2188
2214
  }
2189
2215
  return true;
2190
2216
  }
2191
- const unhandled = files.filter((f) => !handled.includes(f));
2192
- if (unhandled.length > 0) {
2193
- insertData(filesOnlyDataTransfer(unhandled));
2194
- }
2195
- for (const file of handled) {
2196
- if (isImageFile(file)) {
2197
- insertUploadedImage(editor, file, options);
2198
- } else {
2199
- insertUploadedAttachment(file, options);
2200
- }
2217
+ if (skipped.length > 0) {
2218
+ insertData(filesOnlyDataTransfer(skipped));
2201
2219
  }
2202
2220
  return true;
2203
2221
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@railway/inkwell",
3
- "version": "1.1.1",
3
+ "version": "1.3.0",
4
4
  "description": "Inkwell is a Markdown editor and renderer for React with an extensible plugin system.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
package/src/styles.css CHANGED
@@ -4,6 +4,19 @@
4
4
  * consumer can drop in and either keep or override. Light by default,
5
5
  * dark via `prefers-color-scheme: dark`. No saturated brand colors.
6
6
  *
7
+ * Specificity strategy:
8
+ * Visual-chrome defaults (color, background, border, padding, typography)
9
+ * are wrapped in `:where()` so they carry 0,0,0 specificity. Any single-class
10
+ * consumer rule — Tailwind utilities, `classNames` slot styling, `components`
11
+ * overrides on the renderer — wins automatically by specificity tie-break.
12
+ * No `!important`, no descendant scoping, no inline-style hacks needed.
13
+ *
14
+ * Layout-critical rules (position, z-index, structural width/height/overflow,
15
+ * picker flip math) deliberately stay at normal specificity. Consumers should
16
+ * not silently break editor or picker geometry by adding a class — when they
17
+ * want to override layout, they can with descendant or `!important` rules
18
+ * applied intentionally.
19
+ *
7
20
  * Import with: `import "@railway/inkwell/styles.css";`
8
21
  */
9
22
 
@@ -31,6 +44,7 @@
31
44
  --inkwell-accent: hsl(217, 91%, 50%);
32
45
  --inkwell-accent-soft: hsla(217, 91%, 50%, 0.12);
33
46
  --inkwell-danger: hsl(0, 72%, 51%);
47
+ --inkwell-danger-soft: hsla(0, 72%, 51%, 0.14);
34
48
 
35
49
  /* Inline code */
36
50
  --inkwell-code-bg: hsl(220, 14%, 94%);
@@ -61,6 +75,8 @@
61
75
 
62
76
  --inkwell-accent: hsl(217, 91%, 65%);
63
77
  --inkwell-accent-soft: hsla(217, 91%, 65%, 0.16);
78
+ --inkwell-danger: hsl(0, 70%, 65%);
79
+ --inkwell-danger-soft: hsla(0, 70%, 65%, 0.18);
64
80
 
65
81
  --inkwell-code-bg: hsl(220, 13%, 18%);
66
82
  --inkwell-code-fg: hsl(340, 70%, 75%);
@@ -69,14 +85,16 @@
69
85
 
70
86
  /* ── Editor surface ──────────────────────────────────────────────── */
71
87
 
88
+ /* Wrapper position is structural — plugin pickers, bubble menus, and the
89
+ character-count overlay all absolutely position against it. Stays at
90
+ normal specificity. */
72
91
  .inkwell-editor-wrapper {
73
92
  position: relative;
74
93
  }
75
94
 
76
- /* Visual-chrome defaults (padding, border, background, type) are wrapped in
77
- `:where()` so they carry 0,0,0 specificity. Any single-class consumer rule
78
- on `.inkwell-editor` (Tailwind utilities, `classNames.editor`, etc.) wins
79
- automatically — no `!important` or selector gymnastics needed.
95
+ /* Visual-chrome defaults on the editable surface live inside `:where()`
96
+ so a single-class consumer rule (Tailwind utilities, `classNames.editor`,
97
+ etc.) wins automatically.
80
98
 
81
99
  Container size (min-height, max-height, height) is deliberately NOT
82
100
  defaulted here. Inkwell sits inside the consumer's layout and the right
@@ -85,10 +103,12 @@
85
103
  container. Set it on the editor via `styles.editor`, `classNames.editor`,
86
104
  or your own CSS. */
87
105
  :where(.inkwell-editor) {
106
+ outline: none;
88
107
  padding: 1rem 1.25rem;
89
108
  border: 1px solid var(--inkwell-border);
90
109
  border-radius: var(--inkwell-radius);
91
110
  background: var(--inkwell-bg);
111
+ color: var(--inkwell-text);
92
112
  line-height: 1.6;
93
113
  font-size: 0.95rem;
94
114
  transition: border-color 0.15s ease;
@@ -96,27 +116,28 @@
96
116
  :where(.inkwell-editor:focus-within) {
97
117
  border-color: var(--inkwell-border-strong);
98
118
  }
99
- .inkwell-editor {
100
- outline: none;
101
- color: var(--inkwell-text);
102
- }
103
119
 
120
+ /* `position: relative` on paragraphs is structural — Slate decorations and
121
+ inline children position against it. Margin is theming and goes through
122
+ `:where()` so a consumer paragraph-spacing utility wins. */
104
123
  .inkwell-editor p {
105
- margin: 0;
106
124
  position: relative;
107
125
  }
108
- .inkwell-editor strong {
126
+ :where(.inkwell-editor p) {
127
+ margin: 0;
128
+ }
129
+ :where(.inkwell-editor strong) {
109
130
  font-weight: 600;
110
131
  color: var(--inkwell-text);
111
132
  }
112
- .inkwell-editor em {
133
+ :where(.inkwell-editor em) {
113
134
  font-style: italic;
114
135
  }
115
- .inkwell-editor del {
136
+ :where(.inkwell-editor del) {
116
137
  text-decoration: line-through;
117
138
  color: var(--inkwell-text-muted);
118
139
  }
119
- .inkwell-editor code {
140
+ :where(.inkwell-editor code) {
120
141
  background: var(--inkwell-code-bg);
121
142
  color: var(--inkwell-code-fg);
122
143
  padding: 0.1em 0.35em;
@@ -125,38 +146,38 @@
125
146
  font-size: 0.85em;
126
147
  }
127
148
 
128
- .inkwell-editor-blockquote {
149
+ :where(.inkwell-editor-blockquote) {
129
150
  border-left: 3px solid var(--inkwell-border-strong);
130
151
  padding-left: 0.85em;
131
152
  margin: 0.5em 0;
132
153
  color: var(--inkwell-text-muted);
133
154
  }
134
155
 
135
- .inkwell-editor-heading {
156
+ :where(.inkwell-editor-heading) {
136
157
  font-weight: 600;
137
158
  line-height: 1.3;
138
159
  color: var(--inkwell-text);
139
160
  }
140
- .inkwell-editor-heading-1 {
161
+ :where(.inkwell-editor-heading-1) {
141
162
  font-size: 1.75em;
142
163
  }
143
- .inkwell-editor-heading-2 {
164
+ :where(.inkwell-editor-heading-2) {
144
165
  font-size: 1.4em;
145
166
  }
146
- .inkwell-editor-heading-3 {
167
+ :where(.inkwell-editor-heading-3) {
147
168
  font-size: 1.2em;
148
169
  }
149
- .inkwell-editor-heading-4 {
170
+ :where(.inkwell-editor-heading-4) {
150
171
  font-size: 1em;
151
172
  }
152
- .inkwell-editor-heading-5 {
173
+ :where(.inkwell-editor-heading-5) {
153
174
  font-size: 0.9em;
154
175
  }
155
- .inkwell-editor-heading-6 {
176
+ :where(.inkwell-editor-heading-6) {
156
177
  font-size: 0.8em;
157
178
  }
158
179
 
159
- .inkwell-editor-image {
180
+ :where(.inkwell-editor-image) {
160
181
  margin: 0.75em 0;
161
182
  border-radius: var(--inkwell-radius);
162
183
  overflow: hidden;
@@ -165,62 +186,74 @@
165
186
  border-color 0.15s ease,
166
187
  box-shadow 0.15s ease;
167
188
  }
168
- .inkwell-editor-image[data-selected] {
189
+ :where(.inkwell-editor-image[data-selected]) {
169
190
  border-color: var(--inkwell-accent);
170
191
  box-shadow: 0 0 0 3px var(--inkwell-accent-soft);
171
192
  }
193
+ /* Image fitting stays structural — `max-width: 100%` and `height: auto`
194
+ are the only sensible defaults for keeping aspect ratio inside a
195
+ variable-width editor. Consumers who want a different sizing model
196
+ should opt in with a descendant or higher-specificity rule. */
172
197
  .inkwell-editor-image img {
173
198
  display: block;
174
199
  max-width: 100%;
175
200
  height: auto;
176
201
  }
177
202
 
178
- /* Built-in character count. Anchored to the bottom-right of the editor
179
- wrapper. `characterLimit` is a soft hint typing past the limit is
180
- allowed; the count turns red and the wrapper picks up
181
- `.inkwell-editor-over-limit`, which paints a red ring on the editor
182
- surface so it's visually obvious the document is over budget. */
203
+ /* Built-in character count. Overlays the top-right corner of the editor
204
+ wrapper so it never shifts content, and sits on a solid surface tint
205
+ so it visually layers above any text that wraps underneath. Only
206
+ rendered once typing reaches 80% of `characterLimit`, since the
207
+ limit is a soft hint typing past it is allowed; the count then
208
+ turns red and the wrapper picks up `.inkwell-editor-over-limit`,
209
+ which paints a soft red halo on the editor surface so it's visually
210
+ obvious the document is over budget.
211
+
212
+ Positioning stays at normal specificity — the overlay only works
213
+ from the top-right corner of the wrapper. Chrome (color, font-weight,
214
+ etc.) lives below in `:where()`. */
183
215
  .inkwell-editor-character-count {
184
216
  position: absolute;
185
- right: 0.7rem;
186
- bottom: 0.7rem;
217
+ top: 0.5rem;
218
+ right: 0.5rem;
187
219
  z-index: 10;
188
- padding: 0.15rem 0.45rem;
220
+ pointer-events: none;
221
+ user-select: none;
222
+ }
223
+ :where(.inkwell-editor-character-count) {
224
+ padding: 0.1rem 0.4rem;
189
225
  font-size: 0.72rem;
190
226
  font-variant-numeric: tabular-nums;
191
227
  color: var(--inkwell-text-dim);
192
- pointer-events: none;
193
- user-select: none;
228
+ background: var(--inkwell-bg);
229
+ border-radius: calc(var(--inkwell-radius) - 2px);
194
230
  }
195
- .inkwell-editor-character-count-over {
196
- color: hsl(0, 75%, 55%);
231
+ :where(.inkwell-editor-character-count-over) {
232
+ color: var(--inkwell-danger);
197
233
  font-weight: 500;
198
234
  }
199
- :where(
200
- .inkwell-editor-wrapper.inkwell-editor-has-character-limit .inkwell-editor
201
- ) {
202
- padding-right: 5.25rem;
203
- padding-bottom: 2.25rem;
204
- }
205
- .inkwell-editor-wrapper.inkwell-editor-over-limit .inkwell-editor {
206
- border-color: hsl(0, 75%, 55%);
207
- box-shadow: 0 0 0 1px hsl(0, 75%, 55%);
235
+ :where(.inkwell-editor-wrapper.inkwell-editor-over-limit .inkwell-editor) {
236
+ border-color: var(--inkwell-danger-soft);
237
+ box-shadow: 0 0 0 3px var(--inkwell-danger-soft);
208
238
  }
209
239
 
210
- .inkwell-editor-backtick,
211
- .inkwell-editor-marker {
240
+ :where(.inkwell-editor-backtick),
241
+ :where(.inkwell-editor-marker) {
212
242
  color: var(--inkwell-text-dim);
213
243
  }
214
- .inkwell-editor .inkwell-editor-code-fence {
244
+ :where(.inkwell-editor .inkwell-editor-code-fence) {
215
245
  color: var(--inkwell-text-dim);
216
246
  }
217
- .inkwell-editor .inkwell-editor-code-line,
218
- .inkwell-editor .inkwell-editor-code-fence,
219
- .inkwell-renderer pre code {
247
+ :where(.inkwell-editor .inkwell-editor-code-line),
248
+ :where(.inkwell-editor .inkwell-editor-code-fence),
249
+ :where(.inkwell-renderer pre code) {
220
250
  font-family: var(--inkwell-font-mono);
221
251
  font-size: 0.85em;
222
252
  line-height: 1.55;
223
253
  }
254
+ /* Wrapping behavior for code lines stays structural — Slate emits one
255
+ contenteditable line per node and depends on `white-space: pre-wrap`
256
+ for correct caret placement. */
224
257
  .inkwell-editor .inkwell-editor-code-line {
225
258
  white-space: pre-wrap;
226
259
  word-wrap: break-word;
@@ -239,11 +272,13 @@
239
272
  }
240
273
  }
241
274
 
275
+ /* Container positioning is layout-critical — the bubble menu is JS-
276
+ positioned against the selection. Keep at normal specificity. */
242
277
  .inkwell-plugin-bubble-menu-container {
243
278
  position: absolute;
244
279
  z-index: 9999;
245
280
  }
246
- .inkwell-plugin-bubble-menu-inner {
281
+ :where(.inkwell-plugin-bubble-menu-inner) {
247
282
  display: flex;
248
283
  gap: 2px;
249
284
  background: var(--inkwell-bg-elevated);
@@ -253,7 +288,7 @@
253
288
  box-shadow: 0 6px 20px hsla(220, 20%, 10%, 0.12);
254
289
  animation: inkwell-fade-in 0.12s ease-out;
255
290
  }
256
- .inkwell-plugin-bubble-menu-btn {
291
+ :where(.inkwell-plugin-bubble-menu-btn) {
257
292
  display: flex;
258
293
  align-items: center;
259
294
  justify-content: center;
@@ -268,35 +303,38 @@
268
303
  background 0.1s ease,
269
304
  color 0.1s ease;
270
305
  }
271
- .inkwell-plugin-bubble-menu-btn:hover {
306
+ :where(.inkwell-plugin-bubble-menu-btn:hover) {
272
307
  background: var(--inkwell-bg-subtle);
273
308
  color: var(--inkwell-text);
274
309
  }
275
- .inkwell-plugin-bubble-menu-btn-active {
310
+ :where(.inkwell-plugin-bubble-menu-btn-active) {
276
311
  background: var(--inkwell-bg-subtle);
277
312
  color: var(--inkwell-text);
278
313
  }
279
- .inkwell-plugin-bubble-menu-item-bold {
314
+ :where(.inkwell-plugin-bubble-menu-item-bold) {
280
315
  font-weight: 700;
281
316
  font-size: 14px;
282
317
  }
283
- .inkwell-plugin-bubble-menu-item-italic {
318
+ :where(.inkwell-plugin-bubble-menu-item-italic) {
284
319
  font-style: italic;
285
320
  font-size: 14px;
286
321
  font-family: Georgia, "Times New Roman", serif;
287
322
  }
288
- .inkwell-plugin-bubble-menu-item-strike {
323
+ :where(.inkwell-plugin-bubble-menu-item-strike) {
289
324
  text-decoration: line-through;
290
325
  font-size: 14px;
291
326
  }
292
327
 
293
328
  /* ── Shared plugin picker (snippets, mentions, etc.) ─────────────── */
294
329
 
330
+ /* Popup positioning is layout-critical — the picker reads
331
+ `popupEl.offsetParent` for flip math, so it must remain absolutely
332
+ positioned inside the editor wrapper. */
295
333
  .inkwell-plugin-picker-popup {
296
334
  position: absolute;
297
335
  z-index: 1001;
298
336
  }
299
- .inkwell-plugin-picker {
337
+ :where(.inkwell-plugin-picker) {
300
338
  background: var(--inkwell-bg-elevated);
301
339
  border: 1px solid var(--inkwell-border);
302
340
  border-radius: var(--inkwell-radius);
@@ -305,7 +343,7 @@
305
343
  max-width: 320px;
306
344
  box-shadow: 0 6px 24px hsla(220, 20%, 10%, 0.14);
307
345
  }
308
- .inkwell-plugin-picker-search {
346
+ :where(.inkwell-plugin-picker-search) {
309
347
  width: 100%;
310
348
  padding: 7px 10px;
311
349
  background: var(--inkwell-bg);
@@ -315,9 +353,12 @@
315
353
  font-size: 0.85rem;
316
354
  outline: none;
317
355
  }
318
- .inkwell-plugin-picker-search::placeholder {
356
+ :where(.inkwell-plugin-picker-search::placeholder) {
319
357
  color: var(--inkwell-text-dim);
320
358
  }
359
+ /* List `max-height` is structural — prevents the picker from blowing out
360
+ the viewport with long item lists. Scroll behavior and scrollbar
361
+ styling stay alongside it for a single source of truth. */
321
362
  .inkwell-plugin-picker-list {
322
363
  max-height: 240px;
323
364
  overflow-y: auto;
@@ -340,27 +381,27 @@
340
381
  .inkwell-plugin-picker-list::-webkit-scrollbar-thumb:hover {
341
382
  background-color: var(--inkwell-text-dim);
342
383
  }
343
- .inkwell-plugin-picker-item {
384
+ :where(.inkwell-plugin-picker-item) {
344
385
  padding: 7px 10px;
345
386
  cursor: pointer;
346
387
  transition: background 0.1s ease;
347
388
  }
348
- .inkwell-plugin-picker-item:hover,
349
- .inkwell-plugin-picker-item-active {
389
+ :where(.inkwell-plugin-picker-item:hover),
390
+ :where(.inkwell-plugin-picker-item-active) {
350
391
  background: var(--inkwell-bg-subtle);
351
392
  }
352
- .inkwell-plugin-picker-title {
393
+ :where(.inkwell-plugin-picker-title) {
353
394
  font-size: 0.85rem;
354
395
  font-weight: 500;
355
396
  color: var(--inkwell-text);
356
397
  margin-right: 0.5rem;
357
398
  }
358
- .inkwell-plugin-picker-subtitle {
399
+ :where(.inkwell-plugin-picker-subtitle) {
359
400
  font-size: 0.75rem;
360
401
  color: var(--inkwell-text-muted);
361
402
  margin-top: 2px;
362
403
  }
363
- .inkwell-plugin-picker-preview {
404
+ :where(.inkwell-plugin-picker-preview) {
364
405
  font-size: 0.75rem;
365
406
  color: var(--inkwell-text-muted);
366
407
  margin-top: 2px;
@@ -368,14 +409,14 @@
368
409
  text-overflow: ellipsis;
369
410
  white-space: nowrap;
370
411
  }
371
- .inkwell-plugin-picker-empty {
412
+ :where(.inkwell-plugin-picker-empty) {
372
413
  padding: 12px;
373
414
  text-align: center;
374
415
  color: var(--inkwell-text-dim);
375
416
  font-size: 0.85rem;
376
417
  }
377
418
 
378
- .inkwell-plugin-slash-commands-execute {
419
+ :where(.inkwell-plugin-slash-commands-execute) {
379
420
  padding: 8px 10px;
380
421
  color: var(--inkwell-text);
381
422
  font-size: 0.85rem;
@@ -385,57 +426,58 @@
385
426
 
386
427
  /* ── Renderer ────────────────────────────────────────────────────── */
387
428
 
388
- /* Layout defaults on the renderer follow the same low-specificity pattern
389
- as the editor a single-class consumer rule wins automatically. */
429
+ /* Every rule below targets a rendered HTML element (`a`, `h1`, `p`, ...)
430
+ that consumers customize through the `components` prop on
431
+ `<InkwellRenderer />` or by adding a class on the rendered element. All
432
+ chrome lives inside `:where()` so those overrides win without
433
+ `!important`. */
390
434
  :where(.inkwell-renderer) {
435
+ color: var(--inkwell-text);
391
436
  line-height: 1.65;
392
437
  font-size: 0.95rem;
393
438
  }
394
- .inkwell-renderer {
395
- color: var(--inkwell-text);
396
- }
397
- .inkwell-renderer :first-child {
439
+ :where(.inkwell-renderer :first-child) {
398
440
  margin-top: 0;
399
441
  }
400
- .inkwell-renderer h1 {
442
+ :where(.inkwell-renderer h1) {
401
443
  font-size: 1.75em;
402
444
  font-weight: 600;
403
445
  margin: 0.67em 0;
404
446
  }
405
- .inkwell-renderer h2 {
447
+ :where(.inkwell-renderer h2) {
406
448
  font-size: 1.4em;
407
449
  font-weight: 600;
408
450
  margin: 0.75em 0;
409
451
  }
410
- .inkwell-renderer h3 {
452
+ :where(.inkwell-renderer h3) {
411
453
  font-size: 1.2em;
412
454
  font-weight: 600;
413
455
  margin: 0.8em 0;
414
456
  }
415
- .inkwell-renderer p {
457
+ :where(.inkwell-renderer p) {
416
458
  margin: 0.5em 0;
417
459
  }
418
- .inkwell-renderer blockquote {
460
+ :where(.inkwell-renderer blockquote) {
419
461
  border-left: 3px solid var(--inkwell-border-strong);
420
462
  padding-left: 0.85em;
421
463
  margin: 1em 0;
422
464
  color: var(--inkwell-text-muted);
423
465
  }
424
- .inkwell-renderer ul,
425
- .inkwell-renderer ol {
466
+ :where(.inkwell-renderer ul),
467
+ :where(.inkwell-renderer ol) {
426
468
  padding-left: 1.5em;
427
469
  margin: 1em 0;
428
470
  }
429
- .inkwell-renderer ul {
471
+ :where(.inkwell-renderer ul) {
430
472
  list-style: disc;
431
473
  }
432
- .inkwell-renderer ol {
474
+ :where(.inkwell-renderer ol) {
433
475
  list-style: decimal;
434
476
  }
435
- .inkwell-renderer li {
477
+ :where(.inkwell-renderer li) {
436
478
  margin: 0.25em 0;
437
479
  }
438
- .inkwell-renderer code {
480
+ :where(.inkwell-renderer code) {
439
481
  background: var(--inkwell-code-bg);
440
482
  color: var(--inkwell-code-fg);
441
483
  padding: 0.1em 0.35em;
@@ -443,13 +485,21 @@
443
485
  font-family: var(--inkwell-font-mono);
444
486
  font-size: 0.85em;
445
487
  }
488
+ /* Code-block wrapper position is structural — the copy button absolutely
489
+ positions inside it. */
446
490
  .inkwell-renderer-code-block {
447
491
  position: relative;
448
492
  }
493
+ /* Copy button positioning is layout-critical (top-right inside the code
494
+ block); its chrome lives in `:where()` so consumers can restyle without
495
+ breaking placement. */
449
496
  .inkwell-renderer-copy-btn {
450
497
  position: absolute;
451
498
  top: 0.5rem;
452
499
  right: 0.5rem;
500
+ z-index: 1;
501
+ }
502
+ :where(.inkwell-renderer-copy-btn) {
453
503
  display: flex;
454
504
  align-items: center;
455
505
  justify-content: center;
@@ -465,49 +515,48 @@
465
515
  opacity 0.15s ease,
466
516
  color 0.15s ease,
467
517
  background 0.15s ease;
468
- z-index: 1;
469
518
  }
470
- .inkwell-renderer-code-block:hover .inkwell-renderer-copy-btn {
519
+ :where(.inkwell-renderer-code-block:hover .inkwell-renderer-copy-btn) {
471
520
  opacity: 1;
472
521
  }
473
- .inkwell-renderer-copy-btn:hover {
522
+ :where(.inkwell-renderer-copy-btn:hover) {
474
523
  background: var(--inkwell-bg-subtle);
475
524
  color: var(--inkwell-text);
476
525
  }
477
- .inkwell-renderer pre {
526
+ :where(.inkwell-renderer pre) {
478
527
  margin: 1em 0;
479
528
  border-radius: var(--inkwell-radius);
480
529
  overflow: auto;
481
530
  border: 1px solid var(--inkwell-border);
482
531
  background: var(--inkwell-bg-subtle);
483
532
  }
484
- .inkwell-renderer pre code {
533
+ :where(.inkwell-renderer pre code) {
485
534
  display: block;
486
535
  padding: 0.85em 1em;
487
536
  background: transparent;
488
537
  color: var(--inkwell-text);
489
538
  font-size: 0.82em;
490
539
  }
491
- .inkwell-renderer a {
540
+ :where(.inkwell-renderer a) {
492
541
  color: var(--inkwell-accent);
493
542
  text-decoration: underline;
494
543
  text-underline-offset: 2px;
495
544
  }
496
- .inkwell-renderer hr {
545
+ :where(.inkwell-renderer hr) {
497
546
  border: none;
498
547
  border-top: 1px solid var(--inkwell-border);
499
548
  margin: 2em 0;
500
549
  }
501
- .inkwell-renderer strong {
550
+ :where(.inkwell-renderer strong) {
502
551
  font-weight: 600;
503
552
  }
504
- .inkwell-renderer em {
553
+ :where(.inkwell-renderer em) {
505
554
  font-style: italic;
506
555
  }
507
- .inkwell-renderer del {
556
+ :where(.inkwell-renderer del) {
508
557
  text-decoration: line-through;
509
558
  }
510
- .inkwell-renderer img {
559
+ :where(.inkwell-renderer img) {
511
560
  max-width: 100%;
512
561
  height: auto;
513
562
  border-radius: var(--inkwell-radius);