diff --git a/src/components/TypeChip.tsx b/src/components/TypeChip.tsx new file mode 100644 index 00000000..132e9a47 --- /dev/null +++ b/src/components/TypeChip.tsx @@ -0,0 +1,18 @@ +import type { CSSProperties, HTMLAttributes, ReactNode } from 'react' +import './type-chip.css' + +interface TypeChipProps extends HTMLAttributes { + name: string + color?: string + children?: ReactNode +} + +/** A Type's color belongs to its definition; the chip derives theme-aware colors. */ +export function TypeChip({ name, color, children, className, style, ...props }: TypeChipProps) { + return #{children ?? name} +} diff --git a/src/components/tag-chip.css b/src/components/tag-chip.css index 479768b3..5098b7e0 100644 --- a/src/components/tag-chip.css +++ b/src/components/tag-chip.css @@ -21,6 +21,15 @@ background: var(--reams-tag-chip-background-hover); } +/* A recognized #type uses the Type definition's color; ordinary #tags stay on + * the editor accent. Keep the raw markdown marker editable. */ +.reams-type-tag-chip { + --reams-tag-chip-background: color-mix(in oklch, var(--reams-type-color) 18%, transparent); + --reams-tag-chip-background-hover: color-mix(in oklch, var(--reams-type-color) 26%, transparent); + --reams-tag-chip-border: color-mix(in oklch, var(--reams-type-color) 45%, var(--border-color)); + --reams-tag-chip-color: color-mix(in oklch, var(--reams-type-color) 25%, var(--text-primary)); +} + .cm-frontmatter-tag-chip.reams-tag-chip { display: inline-flex; } diff --git a/src/components/type-chip.css b/src/components/type-chip.css new file mode 100644 index 00000000..027c3e7d --- /dev/null +++ b/src/components/type-chip.css @@ -0,0 +1,36 @@ +.reams-type-chip { + --reams-type-chip-text: var(--text-secondary); + --reams-tag-chip-color: var(--reams-type-chip-text); + display: inline-flex; + align-items: center; + gap: 0; + min-width: 0; + max-width: 100%; + padding: 2px 6px; + border: 1px solid var(--border-color); + border-radius: 0; + background: var(--bg-hover); + color: var(--reams-type-chip-text); + font-size: 0.85em; + white-space: nowrap; +} + +/* Same prefix spacing and color as .reams-tag-chip-prefix::before, but real + * text so selecting or copying a Type chip preserves its # marker. */ +.reams-type-chip-prefix { + display: inline-block; + margin-right: 0.15em; + color: var(--reams-tag-chip-color); +} + +.reams-type-chip[data-colored] { + /* Tint the surrounding surface, rather than darkening the saved color. */ + background: color-mix(in oklch, var(--reams-type-color) 18%, transparent); + border-color: color-mix(in oklch, var(--reams-type-color) 45%, var(--border-color)); + /* Keep most of the theme's readable text color, even for very dark swatches. */ + --reams-type-chip-text: color-mix(in oklch, var(--reams-type-color) 25%, var(--text-primary)); +} + +.reams-type-chip[data-colored]:hover { + background: color-mix(in oklch, var(--reams-type-color) 26%, transparent); +} diff --git a/src/editor/CM6Editor.tsx b/src/editor/CM6Editor.tsx index 4b220526..f13519d9 100644 --- a/src/editor/CM6Editor.tsx +++ b/src/editor/CM6Editor.tsx @@ -15,6 +15,7 @@ import { syntaxHighlighting } from '@codemirror/language' import { classHighlighter } from '@lezer/highlight' import { reamsMarkdownLanguage } from './markdown-language' import { livePreview } from './extensions/live-preview' +import { setTagTypeColors } from './extensions/tag-decoration' import { blockRecordWidget, setBlockRecordPreview } from './extensions/block-record-widget' import { backlinksFooterWidget } from './extensions/backlinks-footer-widget' import { setListFoldPersistence } from './extensions/list-bullet-widget' @@ -84,6 +85,12 @@ import { lineAttachmentExtension, markerDeltaEffect } from './line-attachment-ex import { lineAttachmentMoveLineDown, lineAttachmentMoveLineUp } from './line-attachment-move' import './cm6-editor.css' +function typeTagColors(config: RootFrontmatterPreviewConfig): Readonly> { + if (config.typesEnabled === false) return {} + return Object.fromEntries(Object.entries(config.catalog.types).flatMap(([name, type]) => + type.schema.color ? [[name, type.schema.color]] : [])) +} + // #282: dev-only window hook for browser-mode tests. See the assignment // site below (in the mount effect) for the gating logic. The interface // is declared once here to avoid re-asserting the cast shape at every @@ -382,6 +389,7 @@ export function CM6Editor({ // mode-change effect recomposes from the same closure-captured values. const makeLivePreviewExtensions = () => livePreview({ + typeColors: typeTagColors(rootFrontmatterRef.current), wikilink: makeWikilinkConfig(), imageEmbed: { resolver: imageResolver }, imageGallery: { resolver: imageResolver }, @@ -775,7 +783,7 @@ export function CM6Editor({ didSkipInitialRootFrontmatterSyncRef.current = true return } - if (mode === 'live-preview') view.dispatch({ effects: setRootFrontmatterPreview.of(rootFrontmatterRef.current) }) + if (mode === 'live-preview') view.dispatch({ effects: [setRootFrontmatterPreview.of(rootFrontmatterRef.current), setTagTypeColors.of(typeTagColors(rootFrontmatterRef.current))] }) }, [mode, rootFrontmatter]) // Re-dispatch editorInfoField when the bound file changes. diff --git a/src/editor/__tests__/CM6Editor.test.tsx b/src/editor/__tests__/CM6Editor.test.tsx index 2fda25bd..ee4a781a 100644 --- a/src/editor/__tests__/CM6Editor.test.tsx +++ b/src/editor/__tests__/CM6Editor.test.tsx @@ -98,6 +98,18 @@ describe('CM6Editor', () => { }) }) + it('updates recognized #type colors when the scoped Type catalog changes', async () => { + const config = (color: string) => ({ catalog: { types: { task: { schema: { color } } }, revision: { key: color } }, documentId: 'doc', documentName: 'Example', typesEnabled: true }) as never + const props = createTestProps({ initialContent: 'A #task and #ordinary', rootFrontmatter: config('#dc2626') }) + const rendered = render() + expect(rendered.container.querySelector('.reams-type-tag-chip')?.style.getPropertyValue('--reams-type-color').trim()).toBe('#dc2626') + expect(rendered.container.querySelectorAll('.cm-md-tag')).toHaveLength(2) + rendered.rerender() + await vi.waitFor(() => expect(rendered.container.querySelector('.reams-type-tag-chip')?.style.getPropertyValue('--reams-type-color').trim()).toBe('#2563eb')) + rendered.rerender() + await vi.waitFor(() => expect(rendered.container.querySelector('.reams-type-tag-chip')).toBeNull()) + }) + describe('block record preview freshness', () => { it('keeps the old widget during a nonempty freshness gap', async () => { const record = { subject: { kind: 'block', documentId: 'automerge:test', nodeId: 'node-1' }, typeName: 'Task', type: { revision: { key: 'test' }, schema: { name: 'Task' } }, fields: [] } as any diff --git a/src/editor/cm6-editor.css b/src/editor/cm6-editor.css index 0af51b53..1f623b80 100644 --- a/src/editor/cm6-editor.css +++ b/src/editor/cm6-editor.css @@ -752,7 +752,7 @@ } .cm-root-frontmatter-surface .reams-root-type-chip { - width: 100%; + align-self: flex-start; } .cm-root-frontmatter-surface .cm-frontmatter-prop-row[data-type="checkbox"] .cm-frontmatter-prop-checkbox { @@ -1260,14 +1260,7 @@ } .reams-record-type-chip { - display: inline-flex; - align-items: center; min-height: 1.2rem; - padding: 0 0.35rem; - border-radius: 2px; - background: var(--bg-hover); - color: var(--text-secondary); - font-size: 0.75em; font-weight: 650; letter-spacing: 0.02em; } @@ -3852,6 +3845,24 @@ box-sizing: border-box; } +/* Applied Types use the same colored chip as Records and the Types browser. */ +.cm-root-frontmatter-surface .cm-frontmatter-prop-chip.reams-type-chip { + gap: 0.125rem; + padding: 2px 2px 2px 6px; + color: var(--reams-type-chip-text); +} + +.reams-root-type-chip > .cm-frontmatter-prop-chip-readonly-value { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.reams-root-type-chip > .cm-frontmatter-prop-chip-remove { + flex: 0 0 auto; +} + /* The chip's editable text input. Auto-sized by `field-sizing: content` * where supported; its HTML `size` attribute provides the fallback. * @@ -4106,25 +4117,32 @@ } .cm-frontmatter-tag-chip .cm-frontmatter-prop-chip-remove, -.cm-frontmatter-tag-chip .cm-frontmatter-prop-chip-remove-placeholder { +.cm-frontmatter-tag-chip .cm-frontmatter-prop-chip-remove-placeholder, +.reams-root-type-chip > .cm-frontmatter-prop-chip-remove { width: 0.75rem; height: 1rem; margin-left: 0; color: var(--reams-tag-chip-color, var(--accent)); } +.reams-root-type-chip > .cm-frontmatter-prop-chip-remove { + color: var(--reams-type-chip-text); +} + .cm-frontmatter-tag-chip.reams-tag-chip { padding-right: var(--reams-tag-chip-remove-padding-inline, 2px); } @media (pointer: coarse) { - .cm-frontmatter-tag-chip .cm-frontmatter-prop-chip-remove { + .cm-frontmatter-tag-chip .cm-frontmatter-prop-chip-remove, + .reams-root-type-chip > .cm-frontmatter-prop-chip-remove { position: relative; min-width: 0.75rem; min-height: 1em; } - .cm-frontmatter-tag-chip .cm-frontmatter-prop-chip-remove::after { + .cm-frontmatter-tag-chip .cm-frontmatter-prop-chip-remove::after, + .reams-root-type-chip > .cm-frontmatter-prop-chip-remove::after { content: ''; position: absolute; width: 24px; @@ -4139,6 +4157,16 @@ color: var(--text-primary); } +.reams-root-type-chip > .cm-frontmatter-prop-chip-remove:is(:hover, :focus-visible) { + color: var(--reams-type-chip-text); + opacity: 1; +} + +.reams-root-type-chip > .cm-frontmatter-prop-chip-remove:focus-visible { + box-shadow: inset 0 0 0 2px color-mix(in oklch, var(--reams-type-color, var(--accent)) 60%, var(--text-primary)); + background-color: color-mix(in oklch, var(--reams-type-color, var(--accent)) 18%, transparent); +} + /* Per-row remove (× on the right edge of each property row). * Always visible — the earlier hover-reveal added jitter and didn't * work on touch devices. diff --git a/src/editor/extensions/__tests__/block-record-widget.test.tsx b/src/editor/extensions/__tests__/block-record-widget.test.tsx index d9c5386c..21f7a98b 100644 --- a/src/editor/extensions/__tests__/block-record-widget.test.tsx +++ b/src/editor/extensions/__tests__/block-record-widget.test.tsx @@ -171,7 +171,7 @@ describe('block Type Record live preview widget', () => { await vi.waitFor(() => expect(root.querySelector('.reams-record-section')).toBeTruthy()) const glyph = root.querySelector('.cm-record-node-glyph')! expect(glyph.querySelectorAll('svg > *')).toHaveLength(2) - expect(root.querySelector('.reams-record-type-chip')).toHaveTextContent('Task') + expect(root.querySelector('.reams-record-type-chip')).toHaveTextContent('#task') fireEvent.click(glyph!) expect(root.querySelector('.reams-record-section')).toBeNull() expect(view.state.doc.toString()).toBe('hello world') diff --git a/src/editor/extensions/__tests__/root-frontmatter-widget.test.tsx b/src/editor/extensions/__tests__/root-frontmatter-widget.test.tsx index 93dd1bb5..e9bce382 100644 --- a/src/editor/extensions/__tests__/root-frontmatter-widget.test.tsx +++ b/src/editor/extensions/__tests__/root-frontmatter-widget.test.tsx @@ -15,6 +15,7 @@ const type = { schema: { version: 1 as const, name: 'Project', + color: '#7c3aed', fields: { title: { key: 'title', name: 'Title', kind: 'text' as const, default: '' }, done: { key: 'done', name: 'Done', kind: 'boolean' as const, default: false }, @@ -239,8 +240,11 @@ describe('root frontmatter widget', () => { const { view, parent } = createView('---\ntypes: [project]\nproject.title: Keep me\n---\nBody') await vi.waitFor(() => expect(parent.querySelector('[aria-label="Remove Type project"]')).not.toBeNull()) expect(parent.querySelector('[data-type-name="project"]')).toHaveClass('reams-root-type-chip') - expect(parent.querySelector('[data-type-name="project"] > .cm-frontmatter-prop-chip-readonly-value')).toHaveTextContent('Project') - expect(parent.querySelector('[aria-label="Remove Type project"]')).toHaveClass('cm-frontmatter-prop-remove') + expect(parent.querySelector('[data-type-name="project"]')).toHaveClass('reams-type-chip') + expect(parent.querySelector('[data-type-name="project"]')).toHaveStyle({ '--reams-type-color': '#7c3aed' }) + expect(parent.querySelector('[data-type-name="project"] > .cm-frontmatter-prop-chip-readonly-value')).toHaveTextContent('project') + expect(parent.querySelector('[data-type-name="project"]')).toHaveTextContent('#project×') + expect(parent.querySelector('[aria-label="Remove Type project"]')).toHaveClass('cm-frontmatter-prop-chip-remove') fireEvent.click(parent.querySelector('[aria-label="Remove Type project"]')!) await vi.waitFor(() => expect(parent.querySelector('[data-record-type="project"]')).toBeNull()) expect(view.state.doc.toString()).toContain('project.title: Keep me') diff --git a/src/editor/extensions/__tests__/tag-decoration.test.ts b/src/editor/extensions/__tests__/tag-decoration.test.ts index 5af1085d..c520b90f 100644 --- a/src/editor/extensions/__tests__/tag-decoration.test.ts +++ b/src/editor/extensions/__tests__/tag-decoration.test.ts @@ -5,7 +5,7 @@ import { EditorState, type Extension } from '@codemirror/state' import { EditorView } from '@codemirror/view' import { markdown, markdownLanguage } from '@codemirror/lang-markdown' import { GFM } from '@lezer/markdown' -import { tagDecoration, tagPlugin } from '../tag-decoration' +import { initialTagTypeColors, setTagTypeColors, tagDecoration, tagPlugin } from '../tag-decoration' import { markdownFrontmatterField } from '../markdown-context' import { listBulletWidget } from '../list-bullet-widget' @@ -56,6 +56,20 @@ describe('tagDecoration', () => { expect(tagRange).toEqual([2, 22]) // #project/work/active (20 chars) }) + it('uses scoped Type colors for exact #type markers and updates on catalog changes', () => { + const view = createView('A #task and #task/child and #ordinary', [initialTagTypeColors({ task: '#dc2626' })]) + const [task, nested, ordinary] = [...view.dom.querySelectorAll('.cm-md-tag')] + expect(task).toHaveClass('reams-type-tag-chip') + expect(task?.style.getPropertyValue('--reams-type-color').trim()).toBe('#dc2626') + expect(nested).not.toHaveClass('reams-type-tag-chip') + expect(ordinary).not.toHaveClass('reams-type-tag-chip') + view.dispatch({ effects: setTagTypeColors.of({ task: '#2563eb' }) }) + expect(view.dom.querySelector('.reams-type-tag-chip')?.style.getPropertyValue('--reams-type-color').trim()).toBe('#2563eb') + view.dispatch({ effects: setTagTypeColors.of({}) }) + expect(view.dom.querySelector('.reams-type-tag-chip')).toBeNull() + expect(view.state.doc.toString()).toBe('A #task and #task/child and #ordinary') + }) + it('does not decorate tags inside code blocks', () => { const view = createView('```\n#not-a-tag\n```') const decorations = view.plugin(tagPlugin)?.decorations diff --git a/src/editor/extensions/live-preview.ts b/src/editor/extensions/live-preview.ts index 177de493..23e1389c 100644 --- a/src/editor/extensions/live-preview.ts +++ b/src/editor/extensions/live-preview.ts @@ -44,7 +44,7 @@ import { listFoldDefaultClosed, listFoldPersistence, onListFoldChanged, listFold import { hrDecoration } from './hr-decoration' import { tableGrid } from './table-grid' import { footnoteDecoration } from './footnote-decoration' -import { tagDecoration } from './tag-decoration' +import { initialTagTypeColors, tagDecoration } from './tag-decoration' import { imageViewerClickHandler } from './image-viewer-overlay' import { fencedCodeBlockProcessor, type FencedCodeBlockProcessorConfig } from './fenced-code-block-widget' @@ -56,6 +56,7 @@ export interface LivePreviewConfig { listFolds?: { initiallyCollapsed?: boolean; persisted?: PersistedListFolds; nodes?: readonly ListFoldNode[]; onChanged?: (nodeId: string, collapsed: boolean) => void } getSourcePath?: FencedCodeBlockProcessorConfig['getSourcePath'] onNavigate?: FencedCodeBlockProcessorConfig['onNavigate'] + typeColors?: Readonly> } export function livePreview(config: LivePreviewConfig = {}): Extension { @@ -78,6 +79,7 @@ export function livePreview(config: LivePreviewConfig = {}): Extension { listFoldNodes.of(config.listFolds?.nodes ?? []), listBulletWidget, footnoteDecoration, + initialTagTypeColors(config.typeColors ?? {}), tagDecoration, tableGrid, ...(config.wikilink ? [wikilinkExtension(config.wikilink)] : []), diff --git a/src/editor/extensions/root-frontmatter-widget.tsx b/src/editor/extensions/root-frontmatter-widget.tsx index 17d583b9..3519d06b 100644 --- a/src/editor/extensions/root-frontmatter-widget.tsx +++ b/src/editor/extensions/root-frontmatter-widget.tsx @@ -21,6 +21,7 @@ import type { TypeCatalogSnapshot } from '../../types-records/type-catalog' import type { SetFieldIntent, RecordWriteOutcome } from '../../types-records/record-write-command' import { recordReferenceLabel, recordReferenceTarget, SuggestionInput, type RecordSuggestionProvider, type RecordSuggestionProviders } from '../../types-records/record-suggestions' import { WikilinkChip } from '../../components/WikilinkChip' +import { TypeChip } from '../../components/TypeChip' export interface RootFrontmatterPreviewConfig { readonly catalog: TypeCatalogSnapshot @@ -576,7 +577,7 @@ function TypeApplications({ surface, catalog, open, controlsId, onToggle, onOper Types
- {surface.appliedTypeNames.map(name => {catalog.types[name]?.schema.name ?? name} onOperation({ kind: 'remove-type', typeName: name })} />)} + {surface.appliedTypeNames.map(name => {name})}
} diff --git a/src/editor/extensions/tag-decoration.ts b/src/editor/extensions/tag-decoration.ts index 48ebb90b..06abca3f 100644 --- a/src/editor/extensions/tag-decoration.ts +++ b/src/editor/extensions/tag-decoration.ts @@ -22,7 +22,7 @@ import { ViewPlugin, type ViewUpdate, } from '@codemirror/view' -import { RangeSetBuilder, type Extension } from '@codemirror/state' +import { Facet, RangeSetBuilder, StateEffect, StateField, type Extension } from '@codemirror/state' import { getFrontmatterRange, isOffsetRangeInTableCell, @@ -39,12 +39,29 @@ const TAG_RE = /(?:^|(?<=\s))#([\w][\w/-]*)/gm const HEADING_RE = /^(#{1,6})\s+.+$/ const tagMarkDeco = Decoration.mark({ class: 'cm-md-tag reams-tag-chip' }) +const initialTypeTagColors = Facet.define>, Readonly>>({ + combine: values => values[values.length - 1] ?? {}, +}) +export const setTagTypeColors = StateEffect.define>>() +const typeTagColors = StateField.define>>({ + create: state => state.facet(initialTypeTagColors), + update: (colors, transaction) => { + for (const effect of transaction.effects) if (effect.is(setTagTypeColors)) colors = effect.value + return colors + }, +}) + +/** The editor resolves scoped Type colors; ordinary tags remain ordinary. */ +export function initialTagTypeColors(colors: Readonly>): Extension { + return initialTypeTagColors.of(colors) +} function buildDecorationsUnprofiled(view: EditorView): DecorationSet { const state = view.state const frontmatter = getFrontmatterRange(state) const fmEndOffset = frontmatter?.endOffset ?? 0 const builder = new RangeSetBuilder() + const colors = state.field(typeTagColors) const seenLines = new Set() const ranges = view.visibleRanges.length > 0 ? view.visibleRanges @@ -64,7 +81,11 @@ function buildDecorationsUnprofiled(view: EditorView): DecorationSet { const endOffset = startOffset + match[0].length if (shouldSkipMarkdownEnhancement(state, startOffset, endOffset, frontmatter)) continue if (isOffsetRangeInTableCell(state, startOffset, endOffset)) continue - builder.add(startOffset, endOffset, tagMarkDeco) + const color = Object.prototype.hasOwnProperty.call(colors, match[1]) ? colors[match[1]!] : undefined + const decoration = color && /^#[0-9a-fA-F]{6}$/.test(color) + ? Decoration.mark({ class: 'cm-md-tag reams-tag-chip reams-type-tag-chip', attributes: { style: `--reams-type-color: ${color}` } }) + : tagMarkDeco + builder.add(startOffset, endOffset, decoration) } } } @@ -85,7 +106,7 @@ export const tagPlugin = ViewPlugin.fromClass( } update(update: ViewUpdate) { - if (update.docChanged || update.viewportChanged) { + if (update.docChanged || update.viewportChanged || update.transactions.some(transaction => transaction.effects.some(effect => effect.is(setTagTypeColors)))) { this.decorations = buildDecorations(update.view) } } @@ -127,4 +148,4 @@ const tagClickHandler = EditorView.domEventHandlers({ /** * Public extension for tag decoration. Mounted via livePreview(). */ -export const tagDecoration: Extension = [tagPlugin, tagClickHandler] +export const tagDecoration: Extension = [typeTagColors, tagPlugin, tagClickHandler] diff --git a/src/plugins/types-browser/__tests__/type-editor-panel.test.tsx b/src/plugins/types-browser/__tests__/type-editor-panel.test.tsx index 39f7976a..429c26ad 100644 --- a/src/plugins/types-browser/__tests__/type-editor-panel.test.tsx +++ b/src/plugins/types-browser/__tests__/type-editor-panel.test.tsx @@ -34,12 +34,19 @@ describe('TypeEditorPanel', () => { expect(onChange).not.toHaveBeenCalled() }) - it('edits Type color metadata', () => { + it('edits the Type color and previews the shared chip', () => { render() + expect(screen.getByRole('region', { name: 'Type appearance' })).toHaveTextContent('Colors the #task chip and marker.') + expect(screen.getByText('No color saved (choose one)')).toBeInTheDocument() fireEvent.change(screen.getByLabelText('Type color'), { target: { value: '#7c3aed' } }) expect(JSON.parse(onChange.mock.lastCall![0] as string).color).toBe('#7c3aed') + expect(screen.getByText('#7c3aed')).toBeInTheDocument() + expect(screen.getByText('task')).toHaveStyle({ '--reams-type-color': '#7c3aed' }) + expect(screen.getByText('task')).toHaveTextContent('#task') fireEvent.click(screen.getByRole('button', { name: 'Clear Type color' })) expect(JSON.parse(onChange.mock.lastCall![0] as string).color).toBeUndefined() + expect(screen.getByText('No color saved (choose one)')).toBeInTheDocument() + expect(screen.getByText('task')).not.toHaveAttribute('data-colored') }) it('buffers options while typing and commits them on blur', async () => { diff --git a/src/plugins/types-browser/__tests__/types-browser-panel.test.tsx b/src/plugins/types-browser/__tests__/types-browser-panel.test.tsx index d46a8cdd..39044a58 100644 --- a/src/plugins/types-browser/__tests__/types-browser-panel.test.tsx +++ b/src/plugins/types-browser/__tests__/types-browser-panel.test.tsx @@ -84,14 +84,15 @@ describe('TypesBrowserPanel', () => { catalog.replaceDocument(contribution()) render() - expect(screen.getByText('#artist')).toBeInTheDocument() - expect(screen.getByText('#meeting')).toBeInTheDocument() - expect(screen.getByText('#meeting')).toHaveStyle({ borderColor: '#2563eb' }) + expect(screen.getByText('artist')).toHaveClass('reams-type-chip') + expect(screen.getByText('artist')).toHaveTextContent('#artist') + expect(screen.getByText('meeting')).toHaveStyle({ '--reams-type-color': '#2563eb' }) + expect(screen.getByRole('button', { name: 'Open Meeting (#meeting) base' }).querySelectorAll('.reams-type-chip')).toHaveLength(1) expect(screen.getByText('Alice Coltrane #artist')).toBeInTheDocument() expect(screen.getByText('example.md')).toBeInTheDocument() expect(screen.getByText('Genre')).toBeInTheDocument() expect(screen.getByText('Jazz')).toBeInTheDocument() - expect(screen.queryByText('#task')).not.toBeInTheDocument() + expect(screen.queryByText('task')).not.toBeInTheDocument() }) it('keeps a block-derived title visible in the sidebar details', () => { @@ -112,10 +113,10 @@ describe('TypesBrowserPanel', () => { catalog.replaceDocument(contribution()) render() - fireEvent.click(screen.getByRole('button', { name: 'Expand meeting records' })) + fireEvent.click(screen.getByRole('button', { name: 'Expand Meeting (#meeting) records' })) expect(screen.getByText('Weekly planning meeting')).toBeInTheDocument() expect(screen.queryByText('Alice Coltrane #artist')).not.toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Collapse meeting records' })).toHaveAttribute('aria-expanded', 'true') + expect(screen.getByRole('button', { name: 'Collapse Meeting (#meeting) records' })).toHaveAttribute('aria-expanded', 'true') }) it('folds an expanded Type so the remaining Type tags stay easy to scan', () => { @@ -123,16 +124,16 @@ describe('TypesBrowserPanel', () => { catalog.replaceDocument(contribution()) render() - const artist = screen.getByRole('button', { name: 'Collapse artist records' }) + const artist = screen.getByRole('button', { name: 'Collapse Artist (#artist) records' }) expect(artist).toHaveAttribute('aria-expanded', 'true') fireEvent.click(artist) expect(artist).toHaveAttribute('aria-expanded', 'false') expect(screen.queryByText('Alice Coltrane #artist')).not.toBeInTheDocument() - expect(screen.getByText('#meeting')).toBeInTheDocument() + expect(screen.getByText('meeting')).toBeInTheDocument() }) - it('shows root fields and filename once, including non-value field states', () => { + it('shows set and conflicted root fields without cluttering rows or labels with unset fields', () => { const catalog = new TypedNodeCatalog() catalog.replaceDocument(contribution({ types: [{ typeName: 'artist', displayName: 'Artist', revisionKey: 'artist-1', fields: [] }], @@ -145,6 +146,7 @@ describe('TypesBrowserPanel', () => { { fieldId: 'status', key: 'status', name: 'Status', kind: 'select', value: undefined, state: 'conflicted', candidates: ['Draft', 'Done'] }, { fieldId: 'blank', key: 'blank', name: 'Blank', kind: 'text', value: undefined, state: 'conflicted', candidates: [null, ''] }, { fieldId: 'owner', key: 'owner', name: 'Owner', kind: 'text', value: undefined, state: 'unset', candidates: [] }, + { fieldId: 'summary', key: 'summary', name: 'Summary', kind: 'text', value: '', state: 'explicit', candidates: [''] }, { fieldId: 'data', key: 'data', name: 'Data', kind: 'json', value: undefined, state: 'unsupported', candidates: [] }, ], }, @@ -155,9 +157,29 @@ describe('TypesBrowserPanel', () => { expect(screen.getAllByText('example.md')).toHaveLength(1) expect(screen.getByText('Draft / Done')).toBeInTheDocument() expect(screen.getByText('Null / Empty')).toBeInTheDocument() - expect(screen.getByText('Not set')).toBeInTheDocument() + expect(screen.queryByText('Owner')).not.toBeInTheDocument() + expect(screen.queryByText('Not set')).not.toBeInTheDocument() + expect(screen.getByText('Summary')).toBeInTheDocument() + expect(screen.getByText('Empty')).toBeInTheDocument() expect(screen.getByText('Unsupported')).toBeInTheDocument() - expect(screen.getByRole('button', { name: /Status: Draft \/ Done, Blank: Null \/ Empty, Owner: Not set, Data: Unsupported; file example.md/ })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Status: Draft \/ Done, Blank: Null \/ Empty, Summary: Empty, Data: Unsupported; file example.md/ })).toBeInTheDocument() + }) + + it('omits the field wrapper when every field is unset, but preserves the record row', () => { + const catalog = new TypedNodeCatalog() + catalog.replaceDocument(contribution({ + types: [{ typeName: 'task', displayName: 'Task', revisionKey: 'task-1', fields: [] }], + nodes: [{ + subject: { kind: 'root' }, + node: { kind: 'document', id: 'root', from: 0, to: 0, text: 'todo.md' }, + typeNames: ['task'], + fieldsByType: { task: [{ fieldId: 'due', key: 'due', name: 'Due', kind: 'date', value: undefined, state: 'unset', candidates: [] }] }, + }], + })) + render() + expect(screen.getByRole('button', { name: 'Open todo.md; file example.md' })).toBeInTheDocument() + expect(screen.queryByText('Due')).not.toBeInTheDocument() + expect(document.querySelector('.types-browser-fields')).toBeNull() }) it('renders root list and map fields without crashing', () => { @@ -190,10 +212,25 @@ describe('TypesBrowserPanel', () => { })) render() - expect(screen.getByText('#venue')).toBeInTheDocument() + expect(screen.getByText('venue')).toHaveClass('reams-type-chip') expect(screen.getByText('No matches for #venue.')).toBeInTheDocument() }) + it('distinguishes Types with the same display name by their visible identity', () => { + const catalog = new TypedNodeCatalog() + catalog.replaceDocument(contribution({ + types: [ + { typeName: 'meeting', displayName: 'Shared', revisionKey: 'meeting-1', fields: [] }, + { typeName: 'event', displayName: 'Shared', revisionKey: 'event-1', fields: [] }, + ], + nodes: [], + })) + render() + expect(screen.queryByText('Shared')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Open Shared (#meeting) base' })).toContainElement(screen.getByText('meeting')) + expect(screen.getByRole('button', { name: 'Open Shared (#event) base' })).toContainElement(screen.getByText('event')) + }) + it('updates from the catalog external store and preserves honest scope collisions', () => { const catalog = new TypedNodeCatalog() catalog.replaceDocument(contribution()) @@ -202,7 +239,7 @@ describe('TypesBrowserPanel', () => { act(() => { catalog.replaceDocument(contribution({ types: [ - { typeName: 'meeting', displayName: 'Gathering', revisionKey: 'meeting-local', fields: [] }, + { typeName: 'meeting', displayName: 'Gathering', color: '#16a34a', revisionKey: 'meeting-local', fields: [] }, { typeName: 'writer', displayName: 'Writer', revisionKey: 'writer-1', fields: [] }, ], nodes: [{ @@ -214,14 +251,20 @@ describe('TypesBrowserPanel', () => { catalog.replaceDocument({ ...contribution({ path: 'other.md' }), documentId: 'automerge:other' as TypedNodeDocumentContribution['documentId'], - effectiveTypes: [{ typeName: 'meeting', displayName: 'Meeting', revisionKey: 'meeting-root', fields: [] }], + effectiveTypes: [{ typeName: 'meeting', displayName: 'Meeting', color: '#2563eb', revisionKey: 'meeting-root', fields: [] }], nodes: [], }) }) - expect(screen.getByText('#writer')).toBeInTheDocument() - expect(screen.queryByText('#artist')).not.toBeInTheDocument() - expect(screen.getByText('Scopes: Gathering, Meeting')).toBeInTheDocument() + expect(screen.getByText('writer')).toHaveClass('reams-type-chip') + expect(screen.queryByText('artist')).not.toBeInTheDocument() + expect(screen.getByText(/Colors vary by scope/)).toBeInTheDocument() + expect(screen.getByText(/Scopes: Gathering, Meeting/)).toBeInTheDocument() + expect(screen.getByTitle('#meeting')).not.toHaveAttribute('data-colored') + expect(screen.getByTitle('#meeting')).toHaveTextContent('#meeting') + expect(screen.getByRole('button', { name: 'Open Gathering (#meeting), scopes: Gathering, Meeting, colors vary by scope base' }).querySelectorAll('.reams-type-chip')).toHaveLength(1) + expect(screen.getByRole('button', { name: 'Collapse Gathering (#meeting), scopes: Gathering, Meeting, colors vary by scope records' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Open Gathering (#meeting), scopes: Gathering, Meeting, colors vary by scope base' })).toBeInTheDocument() }) it('shows disabled, indexing, and no-Types states', () => { @@ -251,7 +294,7 @@ describe('TypesBrowserPanel', () => { expect(catalogSubscribe).not.toHaveBeenCalled() act(() => setTypesRecordsEnabled(true)) - await waitFor(() => expect(screen.getByText('#meeting')).toBeInTheDocument()) + await waitFor(() => expect(screen.getByText('meeting')).toBeInTheDocument()) expect(catalogSubscribe).toHaveBeenCalledTimes(1) act(() => setTypesRecordsEnabled(false)) @@ -285,7 +328,7 @@ describe('TypesBrowserPanel', () => { const harness = makeApp(catalog, file) render() - fireEvent.click(screen.getByRole('button', { name: 'Expand meeting records' })) + fireEvent.click(screen.getByRole('button', { name: 'Expand Meeting (#meeting) records' })) fireEvent.click(screen.getByRole('button', { name: /Open Weekly planning meeting/ })) expect(harness.openFile).toHaveBeenCalledWith(file) expect(harness.readMarkdown).not.toHaveBeenCalled() @@ -374,7 +417,7 @@ describe('TypesBrowserPanel', () => { render() fireEvent.click(screen.getByRole('button', { name: /Open Alice Coltrane/ })) - fireEvent.click(screen.getByRole('button', { name: 'Expand meeting records' })) + fireEvent.click(screen.getByRole('button', { name: 'Expand Meeting (#meeting) records' })) fireEvent.click(screen.getByRole('button', { name: /Open Weekly planning meeting/ })) expect(harness.openFile).toHaveBeenCalledTimes(1) diff --git a/src/plugins/types-browser/type-editor-panel.tsx b/src/plugins/types-browser/type-editor-panel.tsx index 6c8cd8ee..8cd5f7a1 100644 --- a/src/plugins/types-browser/type-editor-panel.tsx +++ b/src/plugins/types-browser/type-editor-panel.tsx @@ -15,6 +15,7 @@ import { TypeEditorModel } from './type-editor-model' import type { TypeEditorView } from './type-editor-view' import type { TypeField, TypeFieldKind } from '../../types-records/type-schema' import type { RecordValue } from '../../types-records/record-value' +import { TypeChip } from '../../components/TypeChip' const kinds: readonly TypeFieldKind[] = ['text', 'number', 'boolean', 'date', 'datetime', 'select', 'multi-select', 'url', 'email', 'phone', 'reference', 'references', 'json'] @@ -48,7 +49,7 @@ function BoundTypeEditorPanel({ leaf, path, docUrl, documentManager, sourceMode, if (sourceMode) return if (snapshot.status === 'invalid-external') return leaf.detach()} error={recoveryError} /> if (!snapshot.draft) return
Loading Type...
- return + return } function TypeSourceEditor({ content, deferBinding, onChange, bindView, onExit }: { content: string; deferBinding: boolean; onChange: (content: string, expectedBefore?: string) => string | null | undefined; bindView: ReturnType['bindView']; onExit: (content: string) => void }) { @@ -114,7 +115,7 @@ function InvalidState({ model, snapshot, onRecover, onClose, error }: { model: T return

Invalid Type JSON

This document is not a valid Type schema.

    {snapshot.diagnostics.map((item, index) =>
  • {item.message ?? item.code} ({item.path})
  • )}
{error ?

{error}

: null}{clipboardError ?

{clipboardError}

: null}
} -function EditorBody({ model, schema, resetToken, localInvalid, conflict, diagnostics, onRecover, recoveryError }: { model: TypeEditorModel; schema: NonNullable['draft']>; resetToken: number; localInvalid: boolean; conflict: boolean; diagnostics: readonly { message?: string; code: string; path: string }[]; onRecover: () => Promise; recoveryError?: string }) { +function EditorBody({ model, schema, typeName, resetToken, localInvalid, conflict, diagnostics, onRecover, recoveryError }: { model: TypeEditorModel; schema: NonNullable['draft']>; typeName: string; resetToken: number; localInvalid: boolean; conflict: boolean; diagnostics: readonly { message?: string; code: string; path: string }[]; onRecover: () => Promise; recoveryError?: string }) { const announce = useRef(null) const activeDragRef = useRef<{ id: string; pointerId: number }>() const [draggedId, setDraggedId] = useState() @@ -162,9 +163,15 @@ function EditorBody({ model, schema, resetToken, localInvalid, conflict, diagnos } }, [draggedId]) return
-

{schema.color ? : null}
+

{conflict ?
Remote Type changes conflict with this local draft.

Reload to use the remote Type, or keep editing this draft.

: null} {localInvalid ? : null} +
+
Type colorColors the #{typeName} chip and marker.
+ + + {schema.color ? : null} +
{schema.fieldOrder.length ? schema.fieldOrder.map((id, index) => ) :
No properties yetAdd properties that should be available on every Markdown item.
}
diff --git a/src/plugins/types-browser/type-editor.css b/src/plugins/types-browser/type-editor.css index b7a544b5..2dd0afe3 100644 --- a/src/plugins/types-browser/type-editor.css +++ b/src/plugins/types-browser/type-editor.css @@ -7,6 +7,13 @@ .type-editor-name input:hover { border-color: var(--border-color); } .type-editor-name input:focus-visible { outline: 2px solid var(--accent); outline-offset: 0; } .type-editor-header p { margin: var(--ui-space-1) 0 0; color: var(--text-secondary); font-size: .875rem; } +.type-editor-appearance { display: flex; align-items: center; flex-wrap: wrap; gap: var(--ui-space-2); margin-bottom: var(--ui-space-2); padding: var(--ui-space-2); min-height: var(--ui-control-height); background: var(--bg-secondary); } +.type-editor-appearance-description { display: grid; flex: 1 1 100%; gap: 2px; color: var(--text-secondary); font-size: .75rem; } +.type-editor-appearance-description strong { color: var(--text-primary); font-size: .85rem; } +.type-editor-color-control { display: inline-flex; align-items: center; gap: var(--ui-space-2); color: var(--text-primary); font-size: .78rem; cursor: pointer; } +.type-editor-appearance input[type="color"] { width: 2rem; height: var(--ui-control-height); padding: 2px; border: 1px solid var(--border-color); background: var(--bg-primary); cursor: pointer; } +.type-editor-appearance button { min-height: var(--ui-control-height); padding: 3px 6px; color: var(--text-secondary); background: transparent; border: 1px solid var(--border-color); border-radius: var(--ui-radius); } +.type-editor-appearance :is(input, button):focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } .type-editor-form, .type-editor-advanced-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); gap: var(--ui-space-2); } .type-editor-form { margin-bottom: var(--ui-space-1); } .type-editor-form label, .type-editor-field-details label, .type-editor-advanced label, .type-editor-default-options { display: grid; gap: var(--ui-space-1); align-content: start; font-size: .78rem; color: var(--text-secondary); } @@ -76,6 +83,7 @@ height: 34px; min-height: 34px; } + .type-editor-appearance input[type="color"], .type-editor-appearance button { min-height: var(--ui-touch-target); } .type-editor-field-row { padding-top: 5px; padding-bottom: 5px; } .type-editor-fields { gap: 0; padding-block: 5px; } .type-editor-add-row { padding: 5px 0; } diff --git a/src/plugins/types-browser/types-browser-panel.tsx b/src/plugins/types-browser/types-browser-panel.tsx index ead1e058..30d19019 100644 --- a/src/plugins/types-browser/types-browser-panel.tsx +++ b/src/plugins/types-browser/types-browser-panel.tsx @@ -6,7 +6,7 @@ import type { TypedNodeFieldDescriptor, } from '../../types-records/typed-node-catalog' import { useTypesRecordsEnabled } from '../../types-records/feature-setting' -import { TagChip } from '../../components/TagChip' +import { TypeChip } from '../../components/TypeChip' import { openTypeQuery } from './type-query-view' import { useOpenRow } from './use-open-row' import './types-browser.css' @@ -55,24 +55,25 @@ function visibleSource(row: TypedNodeCatalogRow): string | null { return row.subject.kind === 'root' && source === row.name ? null : source } -function rowAccessibleLabel(row: TypedNodeCatalogRow): string { +function rowAccessibleLabel(row: TypedNodeCatalogRow, visibleFields: readonly TypedNodeFieldDescriptor[]): string { const source = visibleSource(row) ?? row.name - const fields = row.fields.map(field => `${field.name}: ${fieldValue(field)}`).join(', ') + const fields = visibleFields.map(field => `${field.name}: ${fieldValue(field)}`).join(', ') const fieldDescription = fields ? `; ${fields}` : '' return `Open ${source}${fieldDescription}; file ${row.name}` } const TypesBrowserRow = memo(function TypesBrowserRow({ row, onOpen }: { row: TypedNodeCatalogRow; onOpen: (row: TypedNodeCatalogRow) => void }) { + const visibleFields = row.fields.filter(field => field.state !== 'unset') return
  • {expanded ? ( diff --git a/src/plugins/types-browser/types-browser.css b/src/plugins/types-browser/types-browser.css index 8e270c86..0e179fb5 100644 --- a/src/plugins/types-browser/types-browser.css +++ b/src/plugins/types-browser/types-browser.css @@ -87,10 +87,8 @@ transform: rotate(45deg) translate(-1px, -1px); } -.types-browser-marker, .types-browser-source, -.types-browser-path, -.types-browser-type-label { +.types-browser-path { min-width: 0; overflow: hidden; text-overflow: ellipsis; @@ -98,18 +96,19 @@ .types-browser-marker { justify-self: start; - white-space: nowrap; + flex-wrap: wrap; + white-space: normal; + overflow-wrap: anywhere; } -.types-browser-type-label, .types-browser-path { color: var(--text-secondary); font-size: 0.78em; white-space: nowrap; } -.types-browser-type-label { - grid-column: 1; +.types-browser-chip-detail { + font-size: 0.9em; } .types-browser-results { @@ -207,7 +206,6 @@ color: var(--warning); } -.types-browser-field[data-state='unset'] .types-browser-field-value, .types-browser-field[data-state='unsupported'] .types-browser-field-value { color: var(--text-muted); font-style: italic; diff --git a/src/types-records/__tests__/portent-profile.test.ts b/src/types-records/__tests__/portent-profile.test.ts index d622ef6b..79416c00 100644 --- a/src/types-records/__tests__/portent-profile.test.ts +++ b/src/types-records/__tests__/portent-profile.test.ts @@ -16,6 +16,10 @@ describe('Portent profile', () => { expect(selected.schema?.color).toBe(PORTENT_TYPE_COLORS[name]) } expect(JSON.parse(PORTENT_TYPE_JSON.task).TaskListItem).toBe('reams.task.done.v1') + expect(PORTENT_TYPE_COLORS).toEqual({ + project: '#db2777', operation: '#7c3aed', responsibility: '#2563eb', task: '#dc2626', + event: '#ca8a04', note: '#ea580c', topic: '#0891b2', person: '#16a34a', + }) expect(new Set(Object.values(PORTENT_TYPE_COLORS))).toHaveLength(PORTENT_TYPE_NAMES.length) }) diff --git a/src/types-records/__tests__/record-components.test.tsx b/src/types-records/__tests__/record-components.test.tsx index 03f54566..e85aebad 100644 --- a/src/types-records/__tests__/record-components.test.tsx +++ b/src/types-records/__tests__/record-components.test.tsx @@ -10,7 +10,7 @@ const field = (kind: 'boolean' | 'number' | 'text', value: null | boolean | numb const record = { subject: { kind: 'block', documentId: 'automerge:test', nodeId: 'node-1' }, typeName: 'task', - type: { schema: { name: 'Task' } }, + type: { schema: { name: 'Task', color: '#16a34a' } }, fields: [field('text', 'Write tests'), field('number', 2), field('boolean', false)], } as never @@ -234,7 +234,11 @@ describe('shared Type Record components', () => { const onChange = vi.fn() render() expect(screen.getByRole('region', { name: 'Task Record' })).toBeInTheDocument() - expect(screen.getByText('Task')).toHaveClass('reams-record-type-chip') + expect(screen.getByText('task')).toHaveClass('reams-record-type-chip') + expect(screen.getByText('task')).toHaveTextContent('#task') + expect(screen.getByText('task').querySelector('.reams-type-chip-prefix')).toHaveTextContent('#') + expect(screen.getByText('task')).toHaveClass('reams-type-chip') + expect(screen.getByText('task')).toHaveStyle({ '--reams-type-color': '#16a34a' }) expect(screen.queryByRole('button', { name: /Clear boolean/ })).not.toBeInTheDocument() expect(onChange).not.toHaveBeenCalled() }) diff --git a/src/types-records/__tests__/typed-node-catalog.test.ts b/src/types-records/__tests__/typed-node-catalog.test.ts index 8a777851..c043fc2d 100644 --- a/src/types-records/__tests__/typed-node-catalog.test.ts +++ b/src/types-records/__tests__/typed-node-catalog.test.ts @@ -73,6 +73,7 @@ describe('TypedNodeCatalog', () => { expect(catalog.getSnapshot().types.find(type => type.typeName === 'task')?.color).toBe('#16a34a') catalog.replaceDocument(contribution({ documentId: documentId('automerge:third'), heads: ['three'], effectiveTypes: [{ typeName: 'task', displayName: 'Task', revisionKey: 'three', fields: [] }], nodes: [] })) expect(catalog.getSnapshot().types.find(type => type.typeName === 'task')?.color).toBeUndefined() + expect(catalog.getSnapshot().types.find(type => type.typeName === 'task')?.colorConflict).toBe(true) }) it('deduplicates repeated Types on a node without collapsing its other Types', () => { diff --git a/src/types-records/portent-profile.ts b/src/types-records/portent-profile.ts index 0122342d..a287984f 100644 --- a/src/types-records/portent-profile.ts +++ b/src/types-records/portent-profile.ts @@ -26,14 +26,15 @@ function common(type: string): Array<[string, Field]> { } export const PORTENT_TYPE_COLORS: Readonly> = Object.freeze({ - project: '#7c3aed', - operation: '#2563eb', - responsibility: '#0891b2', - task: '#16a34a', - event: '#ea580c', - note: '#ca8a04', - topic: '#db2777', - person: '#dc2626', + // Portent-inspired starter colors; each Type is distinct at a glance. + project: '#db2777', + operation: '#7c3aed', + responsibility: '#2563eb', + task: '#dc2626', + event: '#ca8a04', + note: '#ea580c', + topic: '#0891b2', + person: '#16a34a', }) function schema(type: (typeof PORTENT_TYPE_NAMES)[number], extra: Array<[string, Field]> = []): string { diff --git a/src/types-records/record-components.tsx b/src/types-records/record-components.tsx index d40405ee..6de9d29c 100644 --- a/src/types-records/record-components.tsx +++ b/src/types-records/record-components.tsx @@ -3,6 +3,7 @@ import type { ProjectedField, ProjectedRecord } from './record-projection' import { isWikilinkReference, parseFiniteDecimal, type RecordValue } from './record-value' import { recordReferenceLabel, recordReferenceTarget, SuggestionInput, type RecordSuggestionProviders } from './record-suggestions' import { WikilinkChip } from '../components/WikilinkChip' +import { TypeChip } from '../components/TypeChip' import { useDebouncedRecordInput, type RecordDraftScope } from './use-debounced-record-input' export type RecordFieldValue = RecordValue @@ -397,7 +398,7 @@ export function RecordSection({ draftScope?: RecordDraftScope }) { return
    - {record.type.schema.name} +
    {record.fields.map(field => )}
    diff --git a/src/types-records/typed-node-catalog.ts b/src/types-records/typed-node-catalog.ts index 1158f042..9cecbffe 100644 --- a/src/types-records/typed-node-catalog.ts +++ b/src/types-records/typed-node-catalog.ts @@ -84,6 +84,8 @@ export interface TypedNodeCatalogType { /** Deterministic label; rows retain their scoped label when labels differ. */ readonly displayName: string readonly color?: string + /** Aggregated Types have no single color when their scoped definitions differ. */ + readonly colorConflict?: boolean readonly displayNames: readonly string[] readonly revisionKeys: readonly string[] readonly nodeTitleFieldIds?: readonly string[] @@ -460,6 +462,7 @@ function createTypeSummary(typeName: string, state: TypeState): TypedNodeCatalog typeName, displayName: displayNames[0] ?? typeName, color, + ...(colors.size > 1 ? { colorConflict: true } : {}), displayNames, revisionKeys, nodeTitleFieldIds,