Hosted onhyper.mediavia theHypermedia Protocol

Summary

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.colorattributes.value for consistency across all exclusive types.

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

Annotation Categories

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.

Type Definitions

All exclusive annotations share the same structure via exclusiveAnnotationProperties:

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()

Value Constraints

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

Data Examples

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

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

Comment Publishing Type

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

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 } }

Identity Model

The AnnotationSet._annotationId function in unicode.ts determines how annotations are deduplicated:

_annotationId(type: string, attributes: {[key: string]: string} | null) {
  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
}

This means:

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

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

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

Overlap Enforcement (three layers)

Layer 1: Editor UI (primary enforcement)

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.

Layer 2: Publish boundary (gate)

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

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

export function validateExclusiveAnnotations(annotations: HMAnnotation[]): void {
  for (const type of EXCLUSIVE_TYPES) {
    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`)
        }
      }
    }
  }
}

Document path: Called in publishDocument() before PrepareDocumentChange.

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

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

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

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

Rendering Pipeline

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

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)
}

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

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
}

SSR Renderer (ssr-render.ts)

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
}

Data Migration

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

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.

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

Unsubscribe anytime