Text Style Annotations — delivery work

Original source · exact version bafyreiaqgdnknxclpxisntpfwuqi2xxokclxhzatjdxzcaio7agutzwah4 · Migration decision

Specify TextColor, BackgroundColor, TextSize and TextFamily with attributes.value, identity handling, editor range replacement, publish-time validation, rendering conversions and a proposed backwards-compatible migration.

This source documents an implementation or design task; no release commitment is inferred.

Original source detail


    Summary source block

    Add four annotation types for inline text styling: TextColor (existing), BackgroundColor (existing, renamed), TextSize (new), TextFamily (new). Formalize the distinction between additive and exclusive annotations, and rename attributes.color → attributes.value for consistency across all exclusive types. source block

    Enforcement applies to both documents and comments, on both web and desktop. source block

    This distinction already exists in practice — TextColor uses its color value in the identity key (unicode.ts:57-73) but has never been formalized or enforced. source block

    Type Definitions source block

    All exclusive annotations share the same structure via exclusiveAnnotationProperties: source block

    const exclusiveAnnotationProperties = {

...baseAnnotationProperties, attributes: z.object({ value: z.string() }).optional(),

}

export const TextColorAnnotationSchema = z.object({

type: z.literal('TextColor'), ...exclusiveAnnotationProperties,

}).strict()

export const BackgroundColorAnnotationSchema = z.object({

type: z.literal('BackgroundColor'), ...exclusiveAnnotationProperties,

}).strict()

export const TextSizeAnnotationSchema = z.object({

type: z.literal('TextSize'), ...exclusiveAnnotationProperties,

}).strict()

export const TextFamilyAnnotationSchema = z.object({

type: z.literal('TextFamily'), ...exclusiveAnnotationProperties,

}).strict() source block

    Concrete value constraints are enforced with .enum() in the schemas, but the base uses z.string() for extensibility without schema migration. source block

    Data Examples source block

    {

    "type": "TextColor", "starts": [0, 12], "ends": [5, 18], "attributes": { "value": "seed-blue" }

}

{

"type": "TextFamily", "starts": [0], "ends": [25], "attributes": { "value": "seed-serif" }

} source block

    Comment Publishing Type source block

    The HMPublishableAnnotation union (used when publishing comments) is extended: source block

    export type HMPublishableAnnotation =

    { type: 'Bold'

    'Italic'

    'Underline'

    'Strike'

    'Code'; starts: number[]; ends: number[] }

    { type: 'Link'

    'Embed'; starts: number[]; ends: number[]; link: string }




    { type: 'TextColor'

    'BackgroundColor'

    'TextSize'

    'TextFamily'; starts: number[]; ends: number[]; attributes: { value: string } } source block


    if (attributes) {

    if (attributes.link) return ${type}-${attributes.link} if (attributes.href) return ${type}-${attributes.href} if (attributes.value) return ${type}-${attributes.value} // was attributes.color

    } return type

} source block

    This means: source block

    TextFamily-seed-serif and TextFamily-seed-sans-serif are separate annotation objects with separate spans source block

    Bold has no attributes, so all bolds merge into one annotation with multiple spans source block

    Identity keys determine deduplication — they do NOT enforce non-overlap source block

    Overlap Enforcement (three layers) source block

    Layer 1: Editor UI (primary enforcement) source block

    When applying an exclusive annotation to a selection, the editor first removes any existing annotation of the same type with a different value from that range. This is the UX guarantee — the user can't accidentally create overlapping exclusive annotations. source block

    Layer 2: Publish boundary (gate) source block

    Before any publish operation (document or comment), a validation function checks the annotation array: source block

    const EXCLUSIVETYPES = ['TextColor', 'BackgroundColor', 'TextSize', 'TextFamily']

export function validateExclusiveAnnotations(annotations: HMAnnotation[]): void {

for (const type of EXCLUSIVETYPES) {

const ofType = annotations.filter(a => a.type === type) for (let i = 0; i < ofType.length; i++) {

for (let j = i + 1; j < ofType.length; j++) {

const aVal = (ofType[i].attributes as any)?.value const bVal = (ofType[j].attributes as any)?.value if (aVal && bVal && aVal === bVal) continue // same value = fine if (spansOverlap(ofType[i], ofType[j])) {

throw new Error(${type} annotations with different values overlap)

}

}

}

}

} source block

    Document path: Called in publishDocument() before PrepareDocumentChange. source block

    Comment path: Called inside annotationsToPublishable() in comment.ts — covers both createComment() and updateComment(). source block

    Backend path: Also validated server-side in applyChanges as a safety net. source block

    Layer 3: Zod refinement (defense-in-depth) source block

    Optional .refine() on HMAnnotationsSchema to reject invalid arrays at deserialization time. source block

    Rendering Pipeline source block

    Slate → HM (editorblock-to-hmblock.ts) source block

    if (leaf.styles?.textColor) {

    annotations.addSpan('TextColor', {value: leaf.styles.textColor}, start, end)

} if (leaf.styles?.backgroundColor) {

annotations.addSpan('BackgroundColor', {value: leaf.styles.backgroundColor}, start, end)

} if (leaf.styles?.textSize) {

annotations.addSpan('TextSize', {value: leaf.styles.textSize}, start, end)

} if (leaf.styles?.textFamily) {

annotations.addSpan('TextFamily', {value: leaf.styles.textFamily}, start, end)

} source block

    HM → Slate (hmblock-to-editorblock.ts) source block

    if (annotationData.type === 'TextColor') {

    leaf.styles.textColor = attributes?.value

} if (annotationData.type === 'BackgroundColor') {

leaf.styles.backgroundColor = attributes?.value

} if (annotationData.type === 'TextSize') {

leaf.styles.textSize = attributes?.value

} if (annotationData.type === 'TextFamily') {

leaf.styles.textFamily = attributes?.value

} source block

    SSR Renderer (ssr-render.ts) source block

    case 'TextColor': {

    html = <span data-text-color="${esc(value)}">${html}</span> break

} case 'BackgroundColor': {

html = <span data-background-color="${esc(value)}">${html}</span> break

} case 'TextSize': {

html = <span data-text-size="${esc(value)}">${html}</span> break

} case 'TextFamily': {

html = <span data-text-family="${esc(value)}">${html}</span> break

} source block

    Data Migration source block

    Existing documents with TextColor/BackgroundColor annotations using attributes: { color: "..." } need handling. source block

    Recommendation: lazy migration. Read attributes.value ?? (attributes as any).color as a fallback in the renderer and identity key. Add a deprecation comment on the color path. Remove the fallback after 6 months when all active documents have been re-saved with the new format. source block

Do you like what you are reading? Subscribe to receive updates.

Unsubscribe anytime

Attributes
map
name
Text Style Annotations
owner
status
backlog
workType
technical-task
storyRefs
1.
provenance
1.
reviewStatusneeds-review
sourceBlockIddbzVhoeq
sourceVersionbafyreiaqgdnknxclpxisntpfwuqi2xxokclxhzatjdxzcaio7agutzwah4
sourceDocument
extractionRationaleTechnical implementation specification with schemas, TypeScript, validation layers, conversion paths and migration guidance, supporting the parent formatting story rather than an independent journey or release claim.
description
Specify TextColor, BackgroundColor, TextSize and TextFamily with attributes.value, identity handling, editor range replacement, publish-time validation, rendering conversions and a proposed backwards-compatible migration.
migrationEntry
Attributes schemaThe fields this document carries.
Fetching schema…