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
Heading Value Constraints 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