Source 0082 — Text Style Annotations

Source decision 0082 — Text Style Annotations


    Original source · exact version bafyreiaqgdnknxclpxisntpfwuqi2xxokclxhzatjdxzcaio7agutzwah4 · Read-derived block

    Classification: delivery-work; source kind: delivery-work; decision: approve; confidence: high; review status: needs-review.

    Rationale: Technical 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.

    Review qualification: Uncertain identity/behavior/duplication remains a separate candidate; do not merge without review.

    Canonical target (to be published after manifest): Proposed canonical target

Evidence snapshot: original readable text and block backlinks

    Heading Summary source block

    Paragraph 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

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

    Heading Annotation Categories source block

    Paragraph 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

    Heading Type Definitions source block

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

    Code 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

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

    Heading Data Examples source block

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

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

    Heading Comment Publishing Type source block

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

    Code 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

    Heading Identity Model source block

    Paragraph The AnnotationSet.annotationId function in unicode.ts determines how annotations are deduplicated: source block

    Code 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
    } source block

    Paragraph This means: source block

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

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

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

    Heading Overlap Enforcement (three layers) source block

    Heading Layer 1: Editor UI (primary enforcement) source block

    Paragraph 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

    Heading Layer 2: Publish boundary (gate) source block

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

    Code 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

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

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

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

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

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

    Heading Rendering Pipeline source block

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

    Code 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

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

    Code 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

    Heading SSR Renderer (ssr-render.ts) source block

    Code 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

    Heading Data Migration source block

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

    Paragraph 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
name
Source 0082 — Text Style Annotations
owner
status
needs-review
sources
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.
reviewer
confidence
high
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.
candidateType
delivery-work
proposedRecord
reviewDecision
approve
reviewRationale
Horacio-authorized source-faithful migration decision. Near matches deliberately remain separate pending reviewer reconciliation.
duplicateCandidates

Empty list

classificationRationale
Technical 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.
Attributes schemaThe fields this document carries.
Fetching schema…