diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d068c1a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +## 0.4.93 + +- Test and suite header is a card that folds open: `Test` / `Suite` label, id, first field and `+N` when shut, `No details` when empty, `+` adds a field. It is read-only when the editor is not editable. +- `TestMetaCard` is exported for read-only views. +- New `--tme-*` theme tokens for every color, font and size. Defaults keep the Testomat.io look. Most `html.dark` overrides moved into the tokens. +- New headless entry `testomatio-editor-blocks/markdown`: `parseTestDocument`, `metaFieldValue`, `scanMarkdownBlocks`, `splitTags` plus the converters, with no React or BlockNote at runtime. +- Header markup changed: `.bn-testmeta__summary` is a span inside `.bn-testmeta__trigger`; `.bn-testmeta--expanded/--collapsed` became `.bn-testmeta--open`; `.bn-testmeta__toggle` became `.bn-testmeta__chevron`; `.bn-testmeta__key--input` was folded into `.bn-testmeta__key`. +- Dark mode: a few near-identical greys were merged (step preview text, snippet chevron, link tooltip url). diff --git a/README.md b/README.md index fc29569..cd5f126 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,68 @@ return ( Step and snippet fields inherit the browser's `spellcheck` setting from the editor container, so it is enabled by default. To turn it off for the whole editor (paragraphs and steps), set `spellcheck="false"` on any wrapper around `BlockNoteView`. +## Test and Suite Header + +`` and `` comments render as a card that folds open into their fields. Shut, it shows the id, the first filled field and how many more. The `+` button adds a field from `getMetaFieldSuggestions`. The card is read-only when the editor is not editable. + +The same card is exported for read-only views outside the editor: + +```tsx +import { TestMetaCard } from "testomatio-editor-blocks"; + + {}} readOnly /> +``` + +## Theming + +Every color, font and size comes from a `--tme-*` token. Defaults are the Testomat.io look. Set the tokens on any ancestor of the editor (or `:root`) to restyle it. Dark values switch on `html.dark`. + +| Token | Default (light) | Used for | +| --- | --- | --- | +| `--tme-font-sans` / `--tme-font-mono` | Inter / system mono | Text / ids | +| `--tme-text-size` / `--tme-leading` | 14px / 20px | Step fields | +| `--tme-h1` … `--tme-h6` | 22 / 20 / 18 / 16 / 14 / 14px | Headings | +| `--tme-fg` | #262626 | Text | +| `--tme-fg-strong` / `--tme-fg-soft` | #0a0a0a / #404040 | Icons / step buttons | +| `--tme-muted-fg` / `--tme-placeholder` | #737373 / #a1a1a1 | Labels / placeholders | +| `--tme-border` / `--tme-border-strong` | #e5e5e5 / #d4d4d4 | Borders | +| `--tme-surface` / `--tme-surface-hover` | #fff / #fafafa | Popovers, menus | +| `--tme-muted` | slate 5% | Soft fills | +| `--tme-primary` / `--tme-ring` | #4f46e5 / its 25% | Focus, open card | +| `--tme-link` / `--tme-code` / `--tme-danger` | indigo / amber / red | Links / code / remove | +| `--tme-tooltip-bg` / `--tme-tooltip-fg` | #0a0a0a / #fafafa | Tooltips | +| `--tme-shadow` / `--tme-radius` | 12% black / 8px | Popovers / corners | +| `--tme-meta-bg` / `--tme-meta-border` / `--tme-meta-border-open` | muted / border / primary 50% | Test and suite header | +| `--tme-meta-gap` | 2rem | Space above each test | + +Step and snippet blocks also keep their older `--step-*` and `--snippet-*` variables. + +```css +.my-editor { + --tme-font-sans: var(--font-sans); + --tme-fg: var(--foreground); + --tme-primary: var(--primary); +} +``` + +## Headless Parser + +`testomatio-editor-blocks/markdown` holds the Markdown parser and serializer without React, BlockNote or a DOM, so a server can read test files with the same rules as the editor. + +```ts +import { parseTestDocument, metaFieldValue, splitTags } from "testomatio-editor-blocks/markdown"; + +for (const section of parseTestDocument(markdown)) { + // section.meta: { kind: "test" | "suite", fields, inline } | null + // section.start / bodyStart / end: 0-based lines, end exclusive + // section.heading: the first # or ## heading; section.steps: { title, data, expected } + const id = metaFieldValue(section.meta, "id"); + const { title, tags } = splitTags(section.heading?.text ?? ""); +} +``` + +It also exports `markdownToBlocks`, `blocksToMarkdown`, `scanMarkdownBlocks` (blocks with their source lines), `isStepsHeading` and `getMetaFieldSuggestions`. + ## Step Autocomplete & Image Upload Hooks Configure everything via JS—no React providers required: diff --git a/package.json b/package.json index 2214381..634ce4c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "testomatio-editor-blocks", - "version": "0.4.82", + "version": "0.4.93", "description": "Custom BlockNote schema, markdown conversion helpers, and UI for Testomatio-style test cases and steps.", "type": "module", "main": "./package/index.js", @@ -15,6 +15,10 @@ "types": "./package/snippets.d.ts", "import": "./package/snippets.js" }, + "./markdown": { + "types": "./package/markdown.d.ts", + "import": "./package/markdown.js" + }, "./styles.css": "./package/styles.css", "./package.json": "./package.json" }, diff --git a/src/App.css b/src/App.css index 672868c..7f6c032 100644 --- a/src/App.css +++ b/src/App.css @@ -16,16 +16,21 @@ .app__header { display: flex; + flex-wrap: wrap; justify-content: space-between; align-items: flex-start; gap: 1.5rem; } +.app__intro { + flex: 1 1 32rem; +} + .app__actions { display: flex; + flex-wrap: wrap; align-items: center; gap: 0.75rem; - flex-shrink: 0; } .app__header h1 { @@ -350,3 +355,17 @@ html.dark .app__copy-status { grid-template-columns: 1fr; } } + +/* A sample host theme: the editor restyled only through --tme-* tokens. */ +.app__editor--custom-theme { + --tme-font-sans: Georgia, serif; + --tme-fg: #1e1b4b; + --tme-muted-fg: #6d28d9; + --tme-primary: #7c3aed; + --tme-link: #7c3aed; + --tme-meta-bg: #f5f3ff; + --tme-meta-border: #ddd6fe; + --tme-radius: 12px; + --tme-h1: 28px; + --tme-h2: 24px; +} diff --git a/src/App.tsx b/src/App.tsx index 89308f2..bd464d8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -523,6 +523,7 @@ function App() { "idle", ); const [darkMode, setDarkMode] = useState(false); + const [customTheme, setCustomTheme] = useState(false); const [suggestWhileTyping, setSuggestWhileTyping] = useState(true); const [markdownInput, setMarkdownInput] = useState(""); const [inputError, setInputError] = useState(null); @@ -748,7 +749,7 @@ function App() { return (
-
+

BlockNote Custom Block Playground

Explore custom blocks for documenting test cases and actionable steps, then convert the document to Markdown using a bespoke serializer. @@ -779,6 +780,13 @@ function App() { > Suggest while typing: {suggestWhileTyping ? "on" : "off"} + + {!readOnly && ( + field.key)} onPick={add} /> + )} +

+ {open && ( +
+ {rows.length === 0 &&

No details yet.

} + {rows.map(({ field, index }) => ( +
+ update(index, { key: event.target.value })} + /> + update(index, { value: event.target.value })} + /> + {!readOnly && ( + + )} +
+ ))} +
+ )} +
+ ); +} function AddFieldMenu({ kind, usedKeys, onPick }: AddFieldMenuProps) { const [isOpen, setIsOpen] = useState(false); @@ -115,63 +206,70 @@ function AddFieldMenu({ kind, usedKeys, onPick }: AddFieldMenuProps) { useEffect(() => { if (!isOpen) return; const handleMouseDown = (event: MouseEvent) => { - if (containerRef.current && !containerRef.current.contains(event.target as Node)) { - setIsOpen(false); - } + if (containerRef.current?.contains(event.target as Node)) return; + setIsOpen(false); + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setIsOpen(false); }; document.addEventListener("mousedown", handleMouseDown); - return () => document.removeEventListener("mousedown", handleMouseDown); + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("mousedown", handleMouseDown); + document.removeEventListener("keydown", handleKeyDown); + }; }, [isOpen]); - const available = useMemo(() => { - const used = new Set(usedKeys.map((k) => k.trim().toLowerCase())); - return getMetaFieldSuggestions(kind).filter((s) => !used.has(s.key.trim().toLowerCase())); - }, [kind, usedKeys]); - - const pick = useCallback( - (key: string) => { - onPick(key); - setIsOpen(false); - }, - [onPick], + const taken = new Set(usedKeys.map((key) => key.trim().toLowerCase())); + const options = getMetaFieldSuggestions(kind).filter( + (option) => !taken.has(option.key.trim().toLowerCase()), ); + const pick = (key: string) => { + onPick(key); + setIsOpen(false); + }; return ( -
+
{isOpen && ( -
- {available.map((suggestion) => ( +
+ {options.map((option) => ( ))} + {options.length > 0 &&
}
)} @@ -199,158 +297,46 @@ export const testMetaBlock = createReactBlockSpec( }, }, { - render: ({ block, editor }) => { - const kind = (block.props.metaKind as string) === "suite" ? "suite" : "test"; - const fields = useMemo( - () => parseMetaFields(block.props.metaFields), - [block.props.metaFields], - ); - - const commitFields = useCallback( - (next: MetaField[]) => { - editor.updateBlock(block.id, { - props: { metaFields: serializeMetaFields(next) } as any, - }); - }, - [block.id, editor], - ); - - const handleValueChange = useCallback( - (index: number, value: string) => { - const next = fields.map((field, i) => - i === index ? { ...field, value } : field, - ); - commitFields(next); - }, - [fields, commitFields], - ); - - const handleKeyChange = useCallback( - (index: number, key: string) => { - const next = fields.map((field, i) => - i === index ? { ...field, key } : field, - ); - commitFields(next); - }, - [fields, commitFields], - ); - - const handleRemove = useCallback( - (index: number) => { - commitFields(fields.filter((_, i) => i !== index)); - }, - [fields, commitFields], - ); - - const handleAddField = useCallback( - (key: string) => { - commitFields([...fields, { key, value: "" }]); - }, - [fields, commitFields], - ); + render: ({ block, editor }) => ( +
+ + editor.updateBlock(block.id, { + props: { metaFields: serializeMetaFields(next) } as any, + }) + } + /> +
+ ), + }, +); - const usedKeys = useMemo(() => fields.map((f) => f.key), [fields]); +function isIdField(field: MetaField) { + return ID_KEYS.has(field.key.trim().toLowerCase()); +} - // The read-only `id` is shown inline on the header line next to the kind - // label; every other field renders as an editable row below. - const idField = fields.find((f) => ID_KEYS.has(f.key.trim().toLowerCase())); - const editableFields = fields - .map((field, index) => ({ field, index })) - .filter(({ field }) => !ID_KEYS.has(field.key.trim().toLowerCase())); +const KIND_LABEL: Record = { test: "Test", suite: "Suite" }; - // Compact (reading) view: collapse the rows into a single truncated line - // built only from fields that actually have a value — keys without values - // are an editing-only concern and never appear in the compact summary (the - // expanded rows still show them so they can be filled in). The - // expand/collapse toggle pinned to the far right reveals the full rows. - const summaryText = editableFields - .filter(({ field }) => field.key.trim().length > 0 && field.value.trim().length > 0) - .map(({ field }) => `${field.key}: ${field.value}`) - .join(" · "); - // Default to the compact (collapsed) view whenever the block carries any - // field — this keeps suite blocks compact too, even when their fields have - // no value yet (e.g. a seeded `emoji:` row) and so produce an empty - // summary. Only a genuinely empty block (no fields at all) starts expanded - // so it's immediately editable. `expanded` is UI-only state — never - // serialized. - const [expanded, setExpanded] = useState(() => fields.length === 0); +type MetaKind = "test" | "suite"; - return ( -
-
- - {kind === "test" && !idField?.value ? "NEW TEST" : kind.toUpperCase()} - - {idField?.value && {idField.value}} - {!expanded && ( - - )} -
- {expanded && } - -
-
+export type TestMetaCardProps = { + kind: MetaKind; + fields: MetaField[]; + onChange: (fields: MetaField[]) => void; + readOnly?: boolean; +}; - {expanded && editableFields.length > 0 && ( -
- {editableFields.map(({ field, index }) => ( -
- ) => handleKeyChange(index, e.target.value)} - /> - ) => handleValueChange(index, e.target.value)} - /> - -
- ))} -
- )} -
- ); - }, - }, -); +type AddFieldMenuProps = { + kind: MetaKind; + usedKeys: string[]; + onPick: (key: string) => void; +}; diff --git a/src/editor/customMarkdownConverter.ts b/src/editor/customMarkdownConverter.ts index bab6e5c..0678085 100644 --- a/src/editor/customMarkdownConverter.ts +++ b/src/editor/customMarkdownConverter.ts @@ -1,7 +1,3 @@ -import { - isLinkInlineContent, - isStyledTextInlineContent, -} from "@blocknote/core"; import type { Block, InlineContent, @@ -10,7 +6,7 @@ import type { } from "@blocknote/core"; import { resolveFileDisplayUrl } from "./fileDisplayUrl"; import type { customSchema } from "./customSchema"; -import { isStepsHeading } from "./blocks/step"; +import { isStepsHeading } from "./stepsHeading"; import { codeBlockFence, indexOfOutsideCode, @@ -316,6 +312,16 @@ function applyTextStyles(text: string, styles: EditorStyles | undefined): string return wrapped.join("\n"); } +// Local copies of BlockNote's guards keep this module free of runtime imports, +// so `testomatio-editor-blocks/markdown` loads without BlockNote or a DOM. +function isStyledTextInlineContent(item: unknown): item is { type: "text"; text: string; styles: EditorStyles } { + return typeof item === "object" && item !== null && (item as { type?: string }).type === "text"; +} + +function isLinkInlineContent(item: unknown): item is { type: "link"; href: string; content: any } { + return typeof item === "object" && item !== null && (item as { type?: string }).type === "link"; +} + function inlineToMarkdown(content: CustomEditorBlock["content"]): string { if (!content || !Array.isArray(content)) { return ""; @@ -1845,19 +1851,52 @@ export interface MarkdownToBlocksOptions { } export function markdownToBlocks(markdown: string, _options?: MarkdownToBlocksOptions): CustomPartialBlock[] { + const blocks = scanMarkdownBlocks(markdown).map((located) => located.block); + + // Drop trailing empty paragraphs so a trailing blank line in the source + // doesn't leave a ghost empty block at the end of the document. + while ( + blocks.length > 0 && + blocks[blocks.length - 1].type === "paragraph" && + (!blocks[blocks.length - 1].content || + (blocks[blocks.length - 1].content as any[]).length === 0) + ) { + blocks.pop(); + } + + return fixMalformedImageBlocks(blocks); +} + +/** A parsed block with the source lines it came from: `start` inclusive, `end` exclusive. */ +export type LocatedBlock = { block: CustomPartialBlock; start: number; end: number }; + +/** The parser behind `markdownToBlocks`, keeping each block's source line range. */ +export function scanMarkdownBlocks(markdown: string): LocatedBlock[] { const normalized = markdown.replace(/\r\n/g, "\n"); const lines = normalized.split("\n"); - const blocks: CustomPartialBlock[] = []; + const located: LocatedBlock[] = []; + const emit = (...blocks: CustomPartialBlock[]) => { + for (const block of blocks) located.push({ block, start: blockStart, end: blockStart + 1 }); + }; let index = 0; + let blockStart = 0; let stepsHeadingLevel: number | null = null; + const settle = () => { + for (let i = located.length - 1; i >= 0 && located[i].start === blockStart; i--) { + located[i].end = Math.max(index, blockStart + 1); + } + }; + while (index < lines.length) { + settle(); + blockStart = index; const line = lines[index]; if (!line.trim()) { // Drop blank lines until we've emitted at least one block, so leading // blanks don't produce a ghost empty paragraph at the top of the doc. - if (blocks.length > 0) { - blocks.push({ type: "paragraph", content: [], children: [] } as CustomPartialBlock); + if (located.length > 0) { + emit({ type: "paragraph", content: [], children: [] } as CustomPartialBlock); } index += 1; continue; @@ -1867,7 +1906,7 @@ export function markdownToBlocks(markdown: string, _options?: MarkdownToBlocksOp // a document or right after a heading), so this runs ungated. const metaComment = parseMetaComment(lines, index); if (metaComment) { - blocks.push(metaComment.block); + emit(metaComment.block); index = metaComment.nextIndex; continue; } @@ -1876,7 +1915,7 @@ export function markdownToBlocks(markdown: string, _options?: MarkdownToBlocksOp // an "examples" panel instead of raw comment text. const exampleMarker = parseExampleMarker(lines, index); if (exampleMarker) { - blocks.push(exampleMarker.block); + emit(exampleMarker.block); index = exampleMarker.nextIndex; continue; } @@ -1885,7 +1924,7 @@ export function markdownToBlocks(markdown: string, _options?: MarkdownToBlocksOp ? parseSnippetWrapper(lines, index) : null; if (snippetWrapper) { - blocks.push(snippetWrapper.block); + emit(snippetWrapper.block); index = snippetWrapper.nextIndex; continue; } @@ -1894,14 +1933,14 @@ export function markdownToBlocks(markdown: string, _options?: MarkdownToBlocksOp ? parseTestStep(lines, index, true) : null; if (stepLikeBlock) { - blocks.push(stepLikeBlock.block); + emit(stepLikeBlock.block); index = stepLikeBlock.nextIndex; continue; } const table = parseTable(lines, index); if (table) { - blocks.push(table.block); + emit(table.block); index = table.nextIndex; continue; } @@ -1923,21 +1962,21 @@ export function markdownToBlocks(markdown: string, _options?: MarkdownToBlocksOp stepsHeadingLevel = null; } - blocks.push(headingBlock); + emit(headingBlock); index = heading.nextIndex; continue; } const code = parseCodeBlock(lines, index); if (code) { - blocks.push(code.block); + emit(code.block); index = code.nextIndex; continue; } const quote = parseQuote(lines, index); if (quote) { - blocks.push(quote.block); + emit(quote.block); index = quote.nextIndex; continue; } @@ -1951,14 +1990,14 @@ export function markdownToBlocks(markdown: string, _options?: MarkdownToBlocksOp 0, stepsHeadingLevel !== null, ); - blocks.push(...items); + emit(...items); index = nextIndex; continue; } const fileMatch = line.trim().match(/^\[!\[([^\]]*)\]\(([^)]*)\)\]\(([^)]+)\)$/); if (fileMatch) { - blocks.push({ + emit({ type: "file", props: { name: fileMatch[1] || "", @@ -1974,7 +2013,7 @@ export function markdownToBlocks(markdown: string, _options?: MarkdownToBlocksOp const imageMatch = line.trim().match(/^!\[([^\]]*)\]\(([^)]+?)(?:\s+=(\d+)x(\d+|\*))?\)$/); if (imageMatch) { const width = imageMatch[3] ? parseInt(imageMatch[3], 10) : undefined; - blocks.push({ + emit({ type: "image", props: { url: imageMatch[2], @@ -1989,22 +2028,12 @@ export function markdownToBlocks(markdown: string, _options?: MarkdownToBlocksOp } const paragraph = parseParagraph(lines, index); - blocks.push(paragraph.block); + emit(paragraph.block); index = paragraph.nextIndex; } + settle(); - // Drop trailing empty paragraphs so a trailing blank line in the source - // doesn't leave a ghost empty block at the end of the document. - while ( - blocks.length > 0 && - blocks[blocks.length - 1].type === "paragraph" && - (!blocks[blocks.length - 1].content || - (blocks[blocks.length - 1].content as any[]).length === 0) - ) { - blocks.pop(); - } - - return fixMalformedImageBlocks(blocks); + return located; } function splitTableRow(line: string): string[] { diff --git a/src/editor/stepsHeading.ts b/src/editor/stepsHeading.ts new file mode 100644 index 0000000..316ccf7 --- /dev/null +++ b/src/editor/stepsHeading.ts @@ -0,0 +1,8 @@ +/** + * Returns true when a normalised (lowercased, trailing-punctuation-stripped) + * heading text looks like a "Steps" heading. + * Accepted forms: steps, step, step(s), test steps, test step, test step(s). + */ +export function isStepsHeading(text: string): boolean { + return /^(test\s+)?step(s|\(s\))?$/.test(text); +} diff --git a/src/editor/styles.css b/src/editor/styles.css index 48b5d66..d8f4404 100644 --- a/src/editor/styles.css +++ b/src/editor/styles.css @@ -1,3 +1,67 @@ +/* ============================================ + THEME TOKENS + A host app restyles the editor by setting these on any ancestor + (or :root). Defaults are the Testomat.io look. + ============================================ */ +:root { + --tme-font-sans: "Inter", system-ui, -apple-system, sans-serif; + --tme-font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + /* Step field text. */ + --tme-text-size: 14px; + --tme-leading: 20px; + --tme-h1: 22px; + --tme-h2: 20px; + --tme-h3: 18px; + --tme-h4: 16px; + --tme-h5: 14px; + --tme-h6: 14px; + + --tme-fg: #262626; + --tme-fg-strong: #0a0a0a; + --tme-fg-soft: #404040; + --tme-muted-fg: #737373; + --tme-placeholder: #a1a1a1; + --tme-border: #e5e5e5; + --tme-border-strong: #d4d4d4; + --tme-surface: #ffffff; + --tme-surface-hover: #fafafa; + --tme-muted: rgba(15, 23, 42, 0.05); + --tme-primary: #4f46e5; + --tme-ring: rgba(79, 70, 229, 0.25); + --tme-link: #4f46e5; + --tme-code: rgb(146, 64, 14); + --tme-danger: #ef4444; + --tme-tooltip-bg: #0a0a0a; + --tme-tooltip-fg: #fafafa; + --tme-shadow: rgba(0, 0, 0, 0.12); + --tme-radius: 8px; + + /* Test and suite header. */ + --tme-meta-bg: var(--tme-muted); + --tme-meta-border: var(--tme-border); + --tme-meta-border-open: color-mix(in srgb, var(--tme-primary) 50%, transparent); + --tme-meta-gap: 2rem; +} + +html.dark { + --tme-fg: #fafafa; + --tme-fg-strong: #d4d4d4; + --tme-fg-soft: #a3a3a3; + --tme-muted-fg: #737373; + --tme-placeholder: #525252; + --tme-border: #404040; + --tme-border-strong: #404040; + --tme-surface: #262626; + --tme-surface-hover: #333333; + --tme-muted: rgba(255, 255, 255, 0.05); + --tme-primary: #6366f1; + --tme-ring: rgba(99, 102, 241, 0.25); + --tme-link: rgb(129, 140, 248); + --tme-code: rgb(251, 191, 36); + --tme-tooltip-bg: #262626; + --tme-shadow: rgba(0, 0, 0, 0.3); +} + /* ============================================ COLOR PALETTE Adjust these variables to change the theme @@ -47,8 +111,6 @@ --step-card-bg: #f7f7f8; --step-card-border: #e5e7eb; --step-card-shadow: rgba(15, 23, 42, 0.06); - --step-input-border: #d1d5db; - --step-input-border-focus: #4f46e5; --step-input-shadow: rgba(79, 70, 229, 0.25); --step-text: #1f2937; --step-muted: #6b7280; @@ -159,129 +221,22 @@ html.dark { --status-blocked-bg: rgba(239, 68, 68, 0.08); } -/* Dark mode hardcoded color overrides */ -html.dark .bn-step-action-btn { - color: #a3a3a3; -} - -html.dark .bn-step-toolbar__button { - color: #d4d4d4; -} - -html.dark .bn-step-toolbar__divider { - background: #404040; -} - -html.dark .bn-teststep__view-toggle svg { - color: #d4d4d4; -} - -html.dark [data-tooltip]::after { - background: #262626; -} - -html.dark .bn-step-field__dismiss { - color: #737373; -} - -html.dark .bn-step-editor { - color: #fafafa; -} - -html.dark .bn-step-editor .overtype-wrapper .overtype-input { - caret-color: #fafafa !important; -} - -html.dark .bn-step-editor .overtype-wrapper .overtype-preview { - color: #fafafa !important; -} - -html.dark .bn-step-editor .overtype-wrapper textarea.overtype-input::placeholder { - color: #525252 !important; -} - -html.dark .bn-step-editor .overtype-wrapper textarea.overtype-input::-webkit-input-placeholder { - color: #525252 !important; -} - -html.dark .bn-step-custom-caret { - background: #fafafa; -} - -html.dark .bn-step-link-tooltip { - background: #262626; - border-color: #404040; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); -} - -html.dark .bn-step-link-tooltip__url { - color: #737373; -} - -html.dark .bn-snippet-dropdown__panel { - background: #262626; - border-color: #404040; - box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3); -} - -html.dark .bn-snippet-dropdown__search-wrapper { - border-bottom-color: #404040; -} - -html.dark .bn-snippet-dropdown__search { - border-color: #404040; -} - -html.dark .bn-snippet-dropdown__search-icon { - color: #525252; -} - -html.dark .bn-snippet-dropdown__search-input { - color: #fafafa; -} - -html.dark .bn-snippet-dropdown__search-input::placeholder { - color: #525252; -} - -html.dark .bn-snippet-dropdown__item--selected { - background: #333333; -} - -html.dark .bn-snippet-dropdown__item-check { - color: #fafafa; -} - -html.dark .bn-snippet-dropdown__chevron { - color: #525252; -} - -html.dark .bn-step-image-preview__content { - background: #262626; -} +/* Dark mode overrides for BlockNote's own file block */ html.dark [data-file-block] .bn-add-file-button { - background-color: #262626; - color: #a3a3a3; + background-color: var(--tme-surface); + color: var(--tme-fg-soft); } html.dark .bn-editor[contenteditable="true"] [data-file-block] .bn-add-file-button:hover, html.dark [data-file-block] .bn-file-name-with-icon:hover, html.dark .ProseMirror-selectednode .bn-file-name-with-icon { - background-color: #333333; + background-color: var(--tme-surface-hover); } -html.dark .bn-step-editor .overtype-wrapper .overtype-preview code.step-preview-code { - color: rgba(251, 191, 36, 1) !important; -} - -html.dark .bn-step-editor .overtype-wrapper .overtype-preview a.step-preview-link, -html.dark .bn-step-editor .overtype-wrapper .overtype-preview a.step-preview-link::selection { - color: rgba(129, 140, 248, 1) !important; -} - -.testomatio-editor { - font-family: "Inter", system-ui, -apple-system, sans-serif; +.testomatio-editor, +.testomatio-editor .bn-default-styles { + font-family: var(--tme-font-sans); } .bn-testcase { @@ -337,7 +292,7 @@ html.dark .bn-step-editor .overtype-wrapper .overtype-preview a.step-preview-lin } .bn-testcase__reference:focus-visible { - outline: 2px solid var(--step-focus-outline); + outline: 2px solid var(--tme-primary); outline-offset: 2px; } @@ -354,7 +309,7 @@ html.dark .bn-step-editor .overtype-wrapper .overtype-preview a.step-preview-lin border-radius: 0.5rem; border: 1px solid var(--border-default); padding: 0.35rem 0.6rem; - background: rgba(255, 255, 255, 0.95); + background: var(--tme-surface); font-size: 0.85rem; font-weight: 600; color: inherit; @@ -376,16 +331,16 @@ html.dark .bn-step-editor .overtype-wrapper .overtype-preview a.step-preview-lin .bn-teststep-plain { padding: 4px 0; - font-family: "Inter", system-ui, -apple-system, sans-serif; + font-family: var(--tme-font-sans); font-size: 14px; line-height: 20px; - color: #262626; + color: var(--tme-fg); } .bn-teststep-plain__data, .bn-teststep-plain__expected { display: block; - color: #737373; + color: var(--tme-muted-fg); font-size: 13px; } @@ -394,7 +349,7 @@ html.dark .bn-step-editor .overtype-wrapper .overtype-preview a.step-preview-lin .bn-teststep input, .bn-teststep textarea, .bn-teststep select { - font-family: "Inter", system-ui, -apple-system, sans-serif; + font-family: var(--tme-font-sans); } .bn-teststep { @@ -517,7 +472,7 @@ html.dark .bn-step-editor .overtype-wrapper .overtype-preview a.step-preview-lin .bn-teststep__view-toggle svg { display: block; - color: #0a0a0a; + color: var(--tme-fg-strong); } .bn-teststep__view-toggle--horizontal svg { @@ -718,33 +673,43 @@ html.dark .bn-teststep__view-toggle--compact svg { compact 22px → 14px range. BlockNote derives heading font-size from the `--level` custom property, so we just redefine it per level. ============================================ */ -.testomatio-editor [data-content-type="heading"] { - --level: 22px; /* h1 (level 1 has no [data-level] rule of its own) */ +.testomatio-editor [data-content-type="heading"], +.testomatio-editor [data-content-type="heading"][data-level="1"] { + --level: var(--tme-h1); /* h1 (level 1 has no [data-level] rule of its own) */ } .testomatio-editor [data-content-type="heading"][data-level="2"] { - --level: 20px; + --level: var(--tme-h2); } .testomatio-editor [data-content-type="heading"][data-level="3"] { - --level: 18px; + --level: var(--tme-h3); } .testomatio-editor [data-content-type="heading"][data-level="4"] { - --level: 16px; + --level: var(--tme-h4); } .testomatio-editor [data-content-type="heading"][data-level="5"] { - --level: 14px; + --level: var(--tme-h5); } .testomatio-editor [data-content-type="heading"][data-level="6"] { - --level: 14px; + --level: var(--tme-h6); } /* Keep size stable during BlockNote's heading-transition animation. */ .testomatio-editor [data-prev-level="1"] { - --prev-level: 22px; + --prev-level: var(--tme-h1); } .testomatio-editor [data-prev-level="2"] { - --prev-level: 20px; + --prev-level: var(--tme-h2); } .testomatio-editor [data-prev-level="3"] { - --prev-level: 18px; + --prev-level: var(--tme-h3); +} +.testomatio-editor [data-prev-level="4"] { + --prev-level: var(--tme-h4); +} +.testomatio-editor [data-prev-level="5"] { + --prev-level: var(--tme-h5); +} +.testomatio-editor [data-prev-level="6"] { + --prev-level: var(--tme-h6); } /* ============================================ @@ -788,267 +753,314 @@ html.dark .testomatio-editor [data-content-type="heading"] .bn-tag-badge { color: var(--color-primary-600); } html.dark .testomatio-editor .bn-auto-link { - color: rgba(129, 140, 248, 1); + color: var(--tme-link); } + /* ============================================ - TEST / SUITE METADATA BLOCK - Dimmed card for `` / `` comments. + TEST / SUITE HEADER + A card for `` / `` comments that folds + open into its fields. Colors come from the --tme-meta-* tokens. ============================================ */ -.bn-testmeta { - display: flex; - flex-direction: column; - gap: 4px; +.bn-testmeta-block { width: 100%; - box-sizing: border-box; - padding: 6px 10px; - background: var(--bg-muted); - /*border: 1px solid var(--border-default);*/ - /* Stronger top edge signals that the test case begins below this line. */ - border-top: 3px solid var(--color-slate-400); - /*border-radius: 8px;*/ - margin-top: 2rem; - opacity: 0.5; } -/* Compact marker for `` — a cropped variant of the testMeta bar - (fit-content width, not full editor width) labelling the table below it. */ -.bn-example-marker { - display: inline-flex; - align-items: center; - width: fit-content; - box-sizing: border-box; - padding: 4px 12px; - background: var(--bg-muted); - border-top: 3px solid var(--color-slate-400); - margin-top: 1rem; - opacity: 0.6; +/* The open border already marks the header in use; BlockNote's selection ring would double it. */ +.bn-block-content[data-content-type="testMeta"].ProseMirror-selectednode > *, +.ProseMirror-selectednode > .bn-block-content[data-content-type="testMeta"] > * { + outline: none; } -.bn-example-marker__label { - font-size: 11px; - font-weight: 600; - letter-spacing: 0.04em; - text-transform: uppercase; - color: var(--text-muted); +.bn-block-outer:not(:first-child):has(> .bn-block > .bn-block-content[data-content-type="testMeta"]) { + margin-top: var(--tme-meta-gap); } -/* Gray cells for the data table that follows an `` marker. The - `.bn-example-table` class is applied by a ProseMirror decoration (see - exampleTableHighlight.ts), bounded to tables between the marker and the next - test/suite comment (or end of document). */ -.bn-editor [data-content-type="table"].bn-example-table td, -.bn-editor [data-content-type="table"].bn-example-table th { - background: var(--bg-muted); +.bn-testmeta { + width: 100%; + box-sizing: border-box; + font-family: var(--tme-font-sans); + color: var(--tme-fg); + background: var(--tme-meta-bg); + border: 1px solid var(--tme-meta-border); + border-radius: var(--tme-radius); + transition: border-color 150ms ease; } -/* Header line: `TEST @T1233456 ............ [+]` — label, id, and add button - always share one row. */ -.bn-testmeta__header { - display: flex; - align-items: center; - gap: 8px; - margin-left: 8px; +.bn-testmeta--open { + border-color: var(--tme-meta-border-open); } -/* Add button + expand/collapse toggle live in a right-pinned action group. */ -.bn-testmeta__header .bn-testmeta__actions { - margin-left: auto; - display: inline-flex; - align-items: center; - gap: 2px; - flex-shrink: 0; +.bn-testmeta__header { + position: relative; + display: flex; + border-radius: inherit; } -/* Compact one-liner: the metadata rendered as truncated read text, click to - expand. Takes the remaining header width and ellipsizes when it overflows. */ -.bn-testmeta__summary { - flex: 1 1 auto; +.bn-testmeta__trigger { + display: flex; + flex: 1; min-width: 0; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; + min-height: 40px; + align-items: center; + gap: 8px; + box-sizing: border-box; + padding: 6px 12px; + font: inherit; + font-size: 14px; + font-weight: 500; + color: inherit; text-align: left; - font-family: inherit; - font-size: 13px; - color: var(--text-muted); background: transparent; - border: none; - padding: 0; + border: 1px solid transparent; + border-radius: inherit; cursor: pointer; + transition: background-color 150ms ease; } -.bn-testmeta__summary:hover { - color: var(--text-primary); +.bn-testmeta__header--actions .bn-testmeta__trigger { + padding-right: 44px; } -.bn-testmeta__summary--empty { - font-style: italic; - opacity: 0.7; +.bn-testmeta--open .bn-testmeta__trigger { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; } -.bn-testmeta__toggle { - width: 24px; - height: 24px; - display: inline-flex; - align-items: center; - justify-content: center; - color: var(--text-muted); - background: transparent; - border: none; - border-radius: 6px; - cursor: pointer; - padding: 0; - flex-shrink: 0; - transition: background-color 120ms ease; +.bn-testmeta__trigger:hover { + background: color-mix(in srgb, var(--tme-muted) 60%, transparent); } -.bn-testmeta__toggle:hover { - background: var(--step-bg-button-hover); - color: var(--text-primary); +.bn-testmeta__trigger:focus-visible { + outline: none; + border-color: var(--tme-primary); + box-shadow: 0 0 0 3px var(--tme-ring); } -.bn-testmeta__toggle svg { - transition: transform 150ms ease; +.bn-testmeta__title { + display: flex; + flex: 1; + min-width: 0; + align-items: center; + gap: 8px; } -.bn-testmeta__toggle--expanded svg { - transform: rotate(180deg); +.bn-testmeta__label, +.bn-testmeta__summary, +.bn-testmeta__status { + font-size: 12px; + font-weight: 400; + color: var(--tme-muted-fg); } -.bn-testmeta__label { - font-size: 11px; - font-weight: 600; - letter-spacing: 0.04em; - text-transform: uppercase; - color: var(--text-muted); +.bn-testmeta__label, +.bn-testmeta__id, +.bn-testmeta__more, +.bn-testmeta__status { flex-shrink: 0; } .bn-testmeta__id { - font-size: 13px; - font-weight: 600; - color: var(--text-primary); + font-family: var(--tme-font-mono); + font-size: 12px; } -.bn-testmeta__rows { - display: flex; - flex-direction: column; - gap: 2px; +.bn-testmeta__summary { + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; } -.bn-testmeta__row { - display: grid; - grid-template-columns: 140px minmax(0, 1fr) 24px; - align-items: center; - gap: 8px; +.bn-testmeta__more { + padding: 0 8px; + font-size: 12px; + font-weight: 400; + line-height: 20px; + color: var(--tme-muted-fg); + background: var(--tme-muted); + border-radius: 999px; } -.bn-testmeta__key { - min-width: 0; - font-size: 13px; - color: var(--text-muted); +.bn-testmeta__status { + margin-left: auto; } -/* Defined values blend into the block like normal text, and only reveal the - input affordance on hover/focus ("activate on click"). */ -.bn-testmeta__key--input, -.bn-testmeta__value { - width: 100%; - height: 26px; - padding: 0 8px; - box-sizing: border-box; - font-family: inherit; - font-size: 13px; - color: var(--text-primary); - background: transparent; - border: 1px solid transparent; - border-radius: 6px; - cursor: text; +.bn-testmeta__chevron { + flex-shrink: 0; + margin-left: auto; + color: var(--tme-muted-fg); + transition: transform 200ms ease-out, color 150ms ease; } -.bn-testmeta__key--input:hover, -.bn-testmeta__value:hover { - border-color: var(--border-light); +.bn-testmeta__trigger:hover .bn-testmeta__chevron { + color: var(--tme-fg); } -.bn-testmeta__key--input:focus, -.bn-testmeta__value:focus { - outline: none; - background: var(--bg-white); - border-color: var(--step-input-border-focus); - box-shadow: 0 0 0 2px var(--step-input-shadow); +.bn-testmeta--open .bn-testmeta__chevron { + transform: rotate(180deg); } -.bn-testmeta__remove, -.bn-testmeta__add { - width: 24px; - height: 24px; +.bn-testmeta__actions { + position: absolute; + top: 0; + right: 6px; + bottom: 0; + display: flex; + align-items: center; +} + +.bn-testmeta__icon-btn { display: inline-flex; align-items: center; justify-content: center; - font-size: 18px; - line-height: 1; - color: var(--text-muted); + width: 28px; + height: 28px; + padding: 0; + color: var(--tme-muted-fg); background: transparent; border: none; - border-radius: 6px; + border-radius: calc(var(--tme-radius) - 2px); cursor: pointer; - padding: 0; } -.bn-testmeta__remove:hover, -.bn-testmeta__add:hover { - background: var(--step-bg-button-hover); - color: var(--text-primary); +.bn-testmeta__icon-btn:hover { + color: var(--tme-fg); + background: var(--tme-muted); } -.bn-testmeta__add-wrap { - position: relative; +.bn-testmeta__remove { + width: 24px; + height: 24px; } .bn-testmeta__menu { position: absolute; - top: calc(100% + 4px); + top: calc(100% - 2px); right: 0; z-index: 100; - min-width: 160px; - max-height: 240px; - overflow-y: auto; display: flex; flex-direction: column; + width: 160px; + max-height: 240px; + overflow-y: auto; + box-sizing: border-box; padding: 4px; - background: var(--bg-white-opaque); - border: 1px solid var(--border-default); - border-radius: 8px; - box-shadow: 0 8px 24px var(--shadow-medium); + background: var(--tme-surface); + border: 1px solid var(--tme-border); + border-radius: var(--tme-radius); + box-shadow: 0 4px 12px var(--tme-shadow); } .bn-testmeta__menu-item { display: block; width: 100%; padding: 6px 8px; + font: inherit; + font-size: 14px; text-align: left; - font-family: inherit; - font-size: 13px; - color: var(--text-primary); + color: var(--tme-fg); background: transparent; border: none; - border-radius: 6px; + border-radius: calc(var(--tme-radius) - 2px); cursor: pointer; } .bn-testmeta__menu-item:hover { + background: var(--tme-muted); +} + +.bn-testmeta__menu-separator { + height: 1px; + margin: 4px -4px; + background: var(--tme-border); +} + +.bn-testmeta__rows { + display: flex; + flex-direction: column; + gap: 4px; + padding: 0 12px 8px; +} + +.bn-testmeta__empty { + margin: 0; + padding: 4px 0; + font-size: 12px; + color: var(--tme-muted-fg); +} + +.bn-testmeta__row { + display: grid; + grid-template-columns: 9rem minmax(0, 1fr) 1.5rem; + align-items: center; + gap: 8px; + margin-left: -8px; +} + +.bn-testmeta__key, +.bn-testmeta__value { + width: 100%; + min-width: 0; + height: 28px; + box-sizing: border-box; + padding: 0 8px; + font: inherit; + font-size: 14px; + color: var(--tme-fg); + background: transparent; + border: 1px solid transparent; + border-radius: calc(var(--tme-radius) - 2px); + outline: none; +} + +.bn-testmeta__key { + color: var(--tme-muted-fg); +} + +.bn-testmeta__key:not([readonly]):hover, +.bn-testmeta__value:not([readonly]):hover, +.bn-testmeta__key:not([readonly]):focus, +.bn-testmeta__value:not([readonly]):focus { + border-color: var(--tme-border); +} + +.bn-testmeta__key::placeholder, +.bn-testmeta__value::placeholder { + color: var(--tme-placeholder); +} + +/* Compact marker for `` — a cropped variant of the testMeta bar + (fit-content width, not full editor width) labelling the table below it. */ +.bn-example-marker { + display: inline-flex; + align-items: center; + width: fit-content; + box-sizing: border-box; + padding: 4px 12px; background: var(--bg-muted); + border-top: 3px solid var(--color-slate-400); + margin-top: 1rem; + opacity: 0.6; } -.bn-testmeta__menu-item--custom { - margin-top: 2px; - border-top: 1px solid var(--border-light); - border-radius: 0 0 6px 6px; +.bn-example-marker__label { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; color: var(--text-muted); } +/* Gray cells for the data table that follows an `` marker. The + `.bn-example-table` class is applied by a ProseMirror decoration (see + exampleTableHighlight.ts), bounded to tables between the marker and the next + test/suite comment (or end of document). */ +.bn-editor [data-content-type="table"].bn-example-table td, +.bn-editor [data-content-type="table"].bn-example-table th { + background: var(--bg-muted); +} + /* * The "new test" title heading is inserted directly after a testMeta panel, so it * is the block-outer immediately following the one containing a testMeta block. @@ -1092,7 +1104,7 @@ html.dark .testomatio-editor .bn-auto-link { flex-shrink: 0; width: 16px; height: 16px; - color: #737373; + color: var(--tme-muted-fg); } .bn-snippet-dropdown__text { @@ -1108,10 +1120,10 @@ html.dark .testomatio-editor .bn-auto-link { left: 0; top: calc(100% + 4px); width: 280px; - background: white; - border: 1px solid #e5e5e5; + background: var(--tme-surface); + border: 1px solid var(--tme-border); border-radius: 8px; - box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1); + box-shadow: 0 4px 10px var(--tme-shadow); z-index: 10; display: flex; flex-direction: column; @@ -1120,7 +1132,7 @@ html.dark .testomatio-editor .bn-auto-link { .bn-snippet-dropdown__search-wrapper { padding: 4px 16px 12px; - border-bottom: 1px solid #e5e5e5; + border-bottom: 1px solid var(--tme-border); } .bn-snippet-dropdown__search { @@ -1129,20 +1141,20 @@ html.dark .testomatio-editor .bn-auto-link { gap: 8px; height: 32px; padding: 0 12px; - border: 1px solid #d4d4d4; + border: 1px solid var(--tme-border-strong); border-radius: 6px; transition: border-color 120ms ease; } .bn-snippet-dropdown__search:focus-within { - border-color: #6366f1; + border-color: var(--tme-primary); } .bn-snippet-dropdown__search-icon { flex-shrink: 0; width: 20px; height: 20px; - color: #a4a4a4; + color: var(--tme-placeholder); } .bn-snippet-dropdown__search-input { @@ -1165,7 +1177,7 @@ html.dark .testomatio-editor .bn-auto-link { } .bn-snippet-dropdown__search-input::placeholder { - color: #a4a4a4; + color: var(--tme-placeholder); } .bn-snippet-dropdown__list { @@ -1207,7 +1219,7 @@ html.dark .testomatio-editor .bn-auto-link { } .bn-snippet-dropdown__item--selected { - background: #fafafa; + background: var(--tme-surface-hover); } .bn-snippet-dropdown__item:hover { @@ -1250,7 +1262,7 @@ html.dark .testomatio-editor .bn-auto-link { .bn-step-action-btn { border: none; background: transparent; - color: #404040; + color: var(--tme-fg-soft); font-weight: 500; font-size: 14px; line-height: 20px; @@ -1329,7 +1341,7 @@ html.dark .testomatio-editor .bn-auto-link { .bn-step-field__dismiss { border: none; background: transparent; - color: #737373; + color: var(--tme-muted-fg); width: 28px; height: 28px; display: inline-flex; @@ -1405,14 +1417,14 @@ html.dark .testomatio-editor .bn-auto-link { .bn-step-toolbar__divider { width: 1px; align-self: stretch; - background: #e5e5e5; + background: var(--tme-border); margin: 4px; } .bn-step-toolbar__button { border: none; background: transparent; - color: #0a0a0a; + color: var(--tme-fg-strong); width: 28px; height: 28px; border-radius: 8px; @@ -1448,9 +1460,9 @@ html.dark .testomatio-editor .bn-auto-link { top: calc(100% + 6px); left: 50%; transform: translateX(-50%); - background: #0a0a0a; - color: #fafafa; - font-family: Inter, sans-serif; + background: var(--tme-tooltip-bg); + color: var(--tme-tooltip-fg); + font-family: var(--tme-font-sans); font-weight: 400; font-size: 14px; line-height: 20px; @@ -1480,11 +1492,11 @@ html.dark .testomatio-editor .bn-auto-link { background: transparent; transition: none; min-height: 2.5rem; - font-family: "Inter", system-ui, -apple-system, sans-serif; - font-size: 14px; + font-family: var(--tme-font-sans); + font-size: var(--tme-text-size); font-weight: 400; - line-height: 20px; - color: #262626; + line-height: var(--tme-leading); + color: var(--tme-fg); overflow: hidden; } @@ -1512,13 +1524,10 @@ html.dark .testomatio-editor .bn-auto-link { padding: 10px 12px; white-space: pre-wrap; word-break: break-word; - color: #262626; + color: var(--tme-fg); cursor: text; } -html.dark .bn-step-editor--preview { - color: #e5e5e5; -} /* Wrapper around a non-edited step's read-only preview. Focusable so Tab enters a step (which mounts its editor); no lingering outline since focus moves to @@ -1533,7 +1542,7 @@ html.dark .bn-step-editor--preview { doesn't render), so the same declarations are repeated here for the flow `--preview` container. */ .bn-step-editor--preview a.step-preview-link { - color: #4f46e5; + color: var(--tme-link); text-decoration: underline; pointer-events: none; } @@ -1553,7 +1562,7 @@ html.dark .bn-step-editor--preview { background-color: transparent; font-family: inherit; font-size: inherit; - color: rgb(146, 64, 14); + color: var(--tme-code); } .bn-step-editor--preview img { @@ -1564,14 +1573,6 @@ html.dark .bn-step-editor--preview { pointer-events: none; } -html.dark .bn-step-editor--preview code.step-preview-code { - color: rgba(251, 191, 36, 1); -} - -html.dark .bn-step-editor--preview a.step-preview-link { - color: rgba(129, 140, 248, 1); -} - .bn-step-editor.bn-step-editor--focused { outline: none; box-shadow: none; @@ -1591,7 +1592,7 @@ html.dark .bn-step-editor--preview a.step-preview-link { padding: 10px 12px !important; min-height: inherit !important; background: transparent !important; - font-family: "Inter", system-ui, -apple-system, sans-serif !important; + font-family: var(--tme-font-sans) !important; font-size: inherit !important; font-weight: inherit !important; line-height: inherit !important; @@ -1599,7 +1600,7 @@ html.dark .bn-step-editor--preview a.step-preview-link { .bn-step-editor .overtype-wrapper .overtype-input { color: transparent !important; - caret-color: #262626 !important; + caret-color: var(--tme-fg) !important; } .bn-step-editor .overtype-wrapper .overtype-input.bn-step-caret-hidden { @@ -1611,13 +1612,13 @@ html.dark .bn-step-editor--preview a.step-preview-link { } .bn-step-editor .overtype-wrapper textarea.overtype-input::placeholder { - color: #a1a1a1 !important; + color: var(--tme-placeholder) !important; font-weight: 400 !important; opacity: 1 !important; } .bn-step-editor .overtype-wrapper textarea.overtype-input::-webkit-input-placeholder { - color: #a1a1a1 !important; + color: var(--tme-placeholder) !important; font-weight: 400 !important; } @@ -1626,11 +1627,11 @@ html.dark .bn-step-editor--preview a.step-preview-link { } .bn-step-editor .overtype-wrapper .overtype-preview { - color: #262626 !important; + color: var(--tme-fg) !important; } .bn-step-editor .overtype-wrapper .overtype-preview a.step-preview-link { - color: #4f46e5 !important; + color: var(--tme-link) !important; text-decoration: underline !important; pointer-events: none !important; } @@ -1650,7 +1651,7 @@ html.dark .bn-step-editor--preview a.step-preview-link { background-color: transparent !important; font-family: inherit !important; font-size: inherit !important; - color: rgb(146, 64, 14) !important; + color: var(--tme-code) !important; } .bn-step-editor .overtype-wrapper .overtype-preview li.bullet-list .syntax-marker, @@ -1663,7 +1664,7 @@ html.dark .bn-step-editor--preview a.step-preview-link { display: none; position: absolute; width: 1px; - background: #262626; + background: var(--tme-fg); z-index: 2; pointer-events: none; animation: bn-step-caret-blink 1s step-end infinite; @@ -1679,10 +1680,10 @@ html.dark .bn-step-editor--preview a.step-preview-link { align-items: center; gap: 6px; padding: 4px 8px; - background: #fff; - border: 1px solid var(--step-input-border, #d4d4d4); + background: var(--tme-surface); + border: 1px solid var(--tme-border-strong); border-radius: 8px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); + box-shadow: 0 4px 12px var(--tme-shadow); font-size: 12px; position: absolute; z-index: 20; @@ -1692,7 +1693,7 @@ html.dark .bn-step-editor--preview a.step-preview-link { } .bn-step-link-tooltip__url { - color: #6b7280; + color: var(--tme-muted-fg); max-width: 200px; overflow: hidden; text-overflow: ellipsis; @@ -1706,7 +1707,7 @@ html.dark .bn-step-editor--preview a.step-preview-link { padding: 2px 6px; border: none; background: transparent; - color: #4f46e5; + color: var(--tme-link); font-size: 12px; cursor: pointer; border-radius: 4px; @@ -1714,15 +1715,15 @@ html.dark .bn-step-editor--preview a.step-preview-link { } .bn-step-link-tooltip__btn:hover { - background: rgba(79, 70, 229, 0.08); + background: color-mix(in srgb, var(--tme-link) 8%, transparent); } .bn-step-link-tooltip__btn--danger { - color: #ef4444; + color: var(--tme-danger); } .bn-step-link-tooltip__btn--danger:hover { - background: rgba(239, 68, 68, 0.08); + background: color-mix(in srgb, var(--tme-danger) 8%, transparent); } .bn-step-editor .overtype-wrapper .overtype-preview img { @@ -1820,7 +1821,7 @@ html.dark .bn-step-editor--preview a.step-preview-link { position: relative; max-width: min(90vw, 800px); width: 100%; - background: var(--color-white); + background: var(--tme-surface); border-radius: 0.75rem; padding: 1rem; box-shadow: 0 25px 45px var(--shadow-heavy); diff --git a/src/editor/tagBadge.ts b/src/editor/tagBadge.ts index 67d8471..a400ba8 100644 --- a/src/editor/tagBadge.ts +++ b/src/editor/tagBadge.ts @@ -3,61 +3,10 @@ import type { Node as PMNode } from "prosemirror-model"; import { Plugin, PluginKey } from "prosemirror-state"; import { Decoration, DecorationSet } from "prosemirror-view"; -/** - * Tags are tokens that start with `@` and may contain alphanumerics plus a few - * symbols (`= - _ ( ) . : &`), e.g. `@smoke`, `@severity:high`, `@T1234abcd`. - * - * This is a faithful JS port of the backend (Ruby) tag regexes: - * TAG_PREFIX_REGEXP = /(?:^|[ \t])/ - * TAG_ALLOWED_SYMBOLS_REGEXP = /[\w\d\=\-\_\(\)\.\:\&]*[\w\d\)]/ - * TAGS_DETECT_REGEXP = /(?:^|[ \t])(\@)/ - * - * `\w` already covers `[0-9_]`, so the symbol class is kept minimal while - * staying faithful to the allowed characters. A tag must be preceded by the - * start of the string or whitespace — so email-like text (`user@example.com`) - * is correctly ignored. - */ -const TAG_ALLOWED_SYMBOLS = String.raw`[\w=\-_().:&]*[\w)]`; - -/** Matches a tag (capture group 1) preceded by start-of-string or whitespace. */ -export const TAGS_DETECT_REGEXP = new RegExp( - String.raw`(?:^|[ \t])(@${TAG_ALLOWED_SYMBOLS})`, - "g", -); - -export interface TagMatch { - /** Offset of the `@` within the scanned string (the leading space is excluded). */ - start: number; - /** Offset just past the last character of the tag. */ - end: number; - /** The matched tag text, including the leading `@`. */ - tag: string; -} +import { detectTags } from "./tags"; -/** - * Find all `@tag` tokens inside a plain string and return their offsets. - * Pure and DOM-free so it can be unit-tested directly. - */ -export function detectTags(text: string): TagMatch[] { - const matches: TagMatch[] = []; - // Use a fresh regex each call so the shared `lastIndex` state never leaks - // between invocations. - const regexp = new RegExp(TAGS_DETECT_REGEXP.source, "g"); - let match: RegExpExecArray | null; - while ((match = regexp.exec(text)) !== null) { - const tag = match[1]; - // The match may include a leading space/tab consumed by the prefix; the tag - // itself starts that many characters into the overall match. - const start = match.index + (match[0].length - tag.length); - matches.push({ start, end: start + tag.length, tag }); - // Defensive guard against a zero-length match looping forever (a tag always - // starts with `@`, so this should never trigger). - if (regexp.lastIndex === match.index) { - regexp.lastIndex++; - } - } - return matches; -} +export { TAGS_DETECT_REGEXP, detectTags, splitTags } from "./tags"; +export type { TagMatch } from "./tags"; const tagBadgePluginKey = new PluginKey("testomatioTagBadge"); diff --git a/src/editor/tags.ts b/src/editor/tags.ts new file mode 100644 index 0000000..4dd19fc --- /dev/null +++ b/src/editor/tags.ts @@ -0,0 +1,71 @@ +/** + * Tags are tokens that start with `@` and may contain alphanumerics plus a few + * symbols (`= - _ ( ) . : &`), e.g. `@smoke`, `@severity:high`, `@T1234abcd`. + * + * This is a faithful JS port of the backend (Ruby) tag regexes: + * TAG_PREFIX_REGEXP = /(?:^|[ \t])/ + * TAG_ALLOWED_SYMBOLS_REGEXP = /[\w\d\=\-\_\(\)\.\:\&]*[\w\d\)]/ + * TAGS_DETECT_REGEXP = /(?:^|[ \t])(\@)/ + * + * `\w` already covers `[0-9_]`, so the symbol class is kept minimal while + * staying faithful to the allowed characters. A tag must be preceded by the + * start of the string or whitespace — so email-like text (`user@example.com`) + * is correctly ignored. + */ +const TAG_ALLOWED_SYMBOLS = String.raw`[\w=\-_().:&]*[\w)]`; + +/** Matches a tag (capture group 1) preceded by start-of-string or whitespace. */ +export const TAGS_DETECT_REGEXP = new RegExp( + String.raw`(?:^|[ \t])(@${TAG_ALLOWED_SYMBOLS})`, + "g", +); + +export interface TagMatch { + /** Offset of the `@` within the scanned string (the leading space is excluded). */ + start: number; + /** Offset just past the last character of the tag. */ + end: number; + /** The matched tag text, including the leading `@`. */ + tag: string; +} + +/** + * Find all `@tag` tokens inside a plain string and return their offsets. + * Pure and DOM-free so it can be unit-tested directly. + */ +export function detectTags(text: string): TagMatch[] { + const matches: TagMatch[] = []; + // Use a fresh regex each call so the shared `lastIndex` state never leaks + // between invocations. + const regexp = new RegExp(TAGS_DETECT_REGEXP.source, "g"); + let match: RegExpExecArray | null; + while ((match = regexp.exec(text)) !== null) { + const tag = match[1]; + // The match may include a leading space/tab consumed by the prefix; the tag + // itself starts that many characters into the overall match. + const start = match.index + (match[0].length - tag.length); + matches.push({ start, end: start + tag.length, tag }); + // Defensive guard against a zero-length match looping forever (a tag always + // starts with `@`, so this should never trigger). + if (regexp.lastIndex === match.index) { + regexp.lastIndex++; + } + } + return matches; +} + +/** + * A heading split into its plain title and its `@tags`, e.g. + * `Login works @smoke` → `{ title: "Login works", tags: ["@smoke"] }`. + */ +export function splitTags(text: string): { title: string; tags: string[] } { + const matches = detectTags(text); + let title = ""; + let last = 0; + for (const match of matches) { + title += text.slice(last, match.start); + last = match.end; + } + title = (title + text.slice(last)).replace(/\s+/g, " ").trim(); + return { title: title || text, tags: matches.map((match) => match.tag) }; +} diff --git a/src/editor/testDocument.test.ts b/src/editor/testDocument.test.ts new file mode 100644 index 0000000..67373c1 --- /dev/null +++ b/src/editor/testDocument.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; +import { parseTestDocument, metaFieldValue } from "./testDocument"; +import { splitTags } from "./tags"; + +const SUITE = [ + "", + "# opencode test @sprint_01", + "", + "Suite notes.", + "", + "", + "## asdmalskdmn", + "", + "### Requirements", + "", + "### Steps", + "", + "* curl 'https://stable\\.testomat\\.io/api/runs?page=1' \\", + " -H 'accept: application/json'", + " *Expected result*: JSON comes back", + "* Log in", + "", + "", + "## Second @smoke", + "", + "```md", + "## Not a heading", + "\n## A\n### Steps\n* Go\n *Expected*: Done\n\n\n## B"); + expect(test.steps[0].expected).toBe("Done"); + expect(sections[1].steps[0].start).toBe(20); + }); + + it("keeps text above the first comment as its own section", () => { + const [intro, test] = parseTestDocument("Intro.\n\n\n# A"); + expect(intro.meta).toBeNull(); + expect([intro.start, intro.end]).toEqual([0, 2]); + expect(test.heading?.text).toBe("A"); + }); + + it("gives a file without comments one section", () => { + expect(parseTestDocument("")).toEqual([]); + expect(parseTestDocument("# Title")).toHaveLength(1); + }); +}); + +describe("splitTags", () => { + it("pulls @tags out of a title", () => { + expect(splitTags("Login works @smoke @severity:high")).toEqual({ + title: "Login works", + tags: ["@smoke", "@severity:high"], + }); + }); + + it("leaves emails alone", () => { + expect(splitTags("Mail a@b.com")).toEqual({ title: "Mail a@b.com", tags: [] }); + }); + + it("keeps a title made only of tags", () => { + expect(splitTags("@smoke")).toEqual({ title: "@smoke", tags: ["@smoke"] }); + }); +}); diff --git a/src/editor/testDocument.ts b/src/editor/testDocument.ts new file mode 100644 index 0000000..6de42f1 --- /dev/null +++ b/src/editor/testDocument.ts @@ -0,0 +1,144 @@ +import { scanMarkdownBlocks, type LocatedBlock } from "./customMarkdownConverter"; +import { isStepsHeading } from "./stepsHeading"; + +export type TestMetaKind = "test" | "suite"; + +export type TestMetaField = { key: string; value: string }; + +export type TestDocumentMeta = { + kind: TestMetaKind; + fields: TestMetaField[]; + inline: boolean; +}; + +export type TestDocumentStep = { + title: string; + data: string; + expected: string; + /** Set when the step is a snippet. */ + snippetId?: string; + start: number; + end: number; +}; + +export type TestDocumentHeading = { + line: number; + level: number; + /** The heading as written, `@tags` included. */ + text: string; +}; + +/** + * A `*.test.md` file cut at each `` / `` comment. + * Line numbers are 0-based; `end` is exclusive. + */ +export type TestDocumentSection = { + /** Null for text above the first comment. */ + meta: TestDocumentMeta | null; + start: number; + /** First line after the comment. */ + bodyStart: number; + end: number; + /** The first `#` or `##` heading: the test or suite title. */ + heading: TestDocumentHeading | null; + /** The `Steps` heading the steps sit under. */ + stepsHeading: TestDocumentHeading | null; + steps: TestDocumentStep[]; +}; + +/** Reads a `*.test.md` file with the same parser the editor uses. */ +export function parseTestDocument(markdown: string): TestDocumentSection[] { + const lines = markdown.replace(/\r\n/g, "\n").split("\n"); + const sections: TestDocumentSection[] = []; + let current = openSection(null, 0, 0); + + for (const located of scanMarkdownBlocks(markdown)) { + const { block } = located; + if (block.type === "testMeta") { + close(current, located.start); + current = openSection(readMeta(block.props), located.start, located.end); + continue; + } + if (block.type === "heading") { + const heading = readHeading(lines, located.start); + if (!heading) continue; + if (!current.heading && heading.level <= 2) current.heading = heading; + if (!current.stepsHeading && isStepsHeadingText(heading.text)) current.stepsHeading = heading; + continue; + } + const step = readStep(located); + if (step) current.steps.push(step); + } + close(current, lines.length); + return sections; + + function close(section: TestDocumentSection, end: number) { + section.end = end; + if (section.meta || hasText(lines, section.start, end)) sections.push(section); + } +} + +/** The value of a meta field, matched by key without case. */ +export function metaFieldValue( + meta: TestDocumentMeta | null | undefined, + key: string, +): string | undefined { + const wanted = key.toLowerCase(); + return meta?.fields.find((field) => field.key.trim().toLowerCase() === wanted)?.value; +} + +function openSection(meta: TestDocumentMeta | null, start: number, bodyStart: number): TestDocumentSection { + return { meta, start, bodyStart, end: bodyStart, heading: null, stepsHeading: null, steps: [] }; +} + +function readMeta(props: unknown): TestDocumentMeta { + const { metaKind, metaFields, metaInline } = (props ?? {}) as Record; + const kind: TestMetaKind = metaKind === "suite" ? "suite" : "test"; + return { + kind, + fields: JSON.parse(typeof metaFields === "string" ? metaFields : "[]") as TestMetaField[], + inline: metaInline === true, + }; +} + +function readHeading(lines: string[], line: number): TestDocumentHeading | null { + const match = lines[line]?.trim().match(/^(#+)\s+(.*)$/); + if (!match) return null; + return { line, level: match[1].length, text: match[2].trim() }; +} + +function isStepsHeadingText(text: string) { + return isStepsHeading(text.trim().toLowerCase().replace(/[:\-–—]$/, "")); +} + +function readStep(located: LocatedBlock): TestDocumentStep | null { + const { block, start, end } = located; + const props = (block.props ?? {}) as Record; + if (block.type === "testStep") { + return { + title: (props.stepTitle ?? "").trim(), + data: (props.stepData ?? "").trim(), + expected: (props.expectedResult ?? "").trim(), + start, + end, + }; + } + if (block.type === "snippet") { + return { + title: (props.snippetTitle ?? "").trim(), + data: (props.snippetData ?? "").trim(), + expected: (props.snippetExpectedResult ?? "").trim(), + snippetId: props.snippetId ?? "", + start, + end, + }; + } + return null; +} + +function hasText(lines: string[], start: number, end: number) { + for (let i = start; i < end; i++) { + if (lines[i].trim()) return true; + } + return false; +} diff --git a/src/index.ts b/src/index.ts index 7fd30c3..5499756 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,7 +6,13 @@ export { } from "./editor/customSchema"; export { stepBlock, canInsertStepOrSnippet, isStepsHeading, addStepsBlock, addSnippetBlock } from "./editor/blocks/step"; export { snippetBlock } from "./editor/blocks/snippet"; -export { testMetaBlock, addTestBlock } from "./editor/blocks/testMeta"; +export { + testMetaBlock, + addTestBlock, + TestMetaCard, + type TestMetaCardProps, + type MetaField, +} from "./editor/blocks/testMeta"; export { setMetaFieldSuggestions, getMetaFieldSuggestions, @@ -20,6 +26,7 @@ export { TagBadgeExtension, TAGS_DETECT_REGEXP, detectTags, + splitTags, type TagMatch, } from "./editor/tagBadge"; @@ -39,11 +46,24 @@ export { export { blocksToMarkdown, markdownToBlocks, + scanMarkdownBlocks, type CustomEditorBlock, type CustomPartialBlock, + type LocatedBlock, type MarkdownToBlocksOptions, } from "./editor/customMarkdownConverter"; +export { + parseTestDocument, + metaFieldValue, + type TestDocumentHeading, + type TestDocumentMeta, + type TestDocumentSection, + type TestDocumentStep, + type TestMetaField, + type TestMetaKind, +} from "./editor/testDocument"; + export { useStepAutocomplete, parseStepsFromJsonApi, diff --git a/src/markdown.ts b/src/markdown.ts new file mode 100644 index 0000000..2134854 --- /dev/null +++ b/src/markdown.ts @@ -0,0 +1,26 @@ +// Headless entry: the Markdown parser and serializer without React, BlockNote or a DOM. +export { + blocksToMarkdown, + markdownToBlocks, + scanMarkdownBlocks, +} from "./editor/customMarkdownConverter"; +export type { + CustomEditorBlock, + CustomPartialBlock, + LocatedBlock, + MarkdownToBlocksOptions, +} from "./editor/customMarkdownConverter"; +export { parseTestDocument, metaFieldValue } from "./editor/testDocument"; +export type { + TestDocumentHeading, + TestDocumentMeta, + TestDocumentSection, + TestDocumentStep, + TestMetaField, + TestMetaKind, +} from "./editor/testDocument"; +export { isStepsHeading } from "./editor/stepsHeading"; +export { TAGS_DETECT_REGEXP, detectTags, splitTags } from "./editor/tags"; +export type { TagMatch } from "./editor/tags"; +export { getMetaFieldSuggestions, setMetaFieldSuggestions } from "./editor/testMetaFields"; +export { setFileDisplayUrlResolver } from "./editor/fileDisplayUrl"; diff --git a/tsconfig.package.json b/tsconfig.package.json index e830343..0abde1c 100644 --- a/tsconfig.package.json +++ b/tsconfig.package.json @@ -22,6 +22,7 @@ "include": [ "src/index.ts", "src/snippets.ts", + "src/markdown.ts", "src/editor/**/*.ts", "src/editor/**/*.tsx" ],