a tool for shared writing and social publishing
0

Configure Feed

Select the types of activity you want to include in your feed.

publish highlight colors in post

Jared Pereira (May 21, 2026, 12:46 PM EDT) 31212795 29aad29d

+147 -14
+37 -6
app/lish/[did]/[publication]/[rkey]/Blocks/TextBlockCore.tsx
··· 1 1 import { UnicodeString } from "@atproto/api"; 2 2 import { PubLeafletRichtextFacet } from "lexicons/api"; 3 3 import { AtMentionLink } from "components/AtMentionLink"; 4 - import { ReactNode } from "react"; 4 + import { CSSProperties, ReactNode } from "react"; 5 + 6 + function highlightFacetToStyle( 7 + highlight: PubLeafletRichtextFacet.Highlight | undefined, 8 + ): CSSProperties | undefined { 9 + const color = highlight?.color; 10 + if (!color || typeof color !== "object") return undefined; 11 + const { r, g, b } = color as { r?: number; g?: number; b?: number }; 12 + if (typeof r !== "number" || typeof g !== "number" || typeof b !== "number") { 13 + return undefined; 14 + } 15 + const a = (color as { a?: number }).a; 16 + const backgroundColor = 17 + typeof a === "number" ? `rgba(${r}, ${g}, ${b}, ${a / 100})` : `rgb(${r}, ${g}, ${b})`; 18 + return { backgroundColor }; 19 + } 5 20 6 21 type Facet = PubLeafletRichtextFacet.Main; 7 22 ··· 48 63 let isHighlighted = segment.facet?.find( 49 64 PubLeafletRichtextFacet.isHighlight, 50 65 ); 66 + let highlightStyle = highlightFacetToStyle(isHighlighted); 51 67 52 68 if (isFootnote) { 53 69 let fnIndex = props.footnoteIndexMap?.get(isFootnote.footnoteId) ?? 0; ··· 91 107 ${isItalic ? "italic" : ""} 92 108 ${isUnderline ? "underline" : ""} 93 109 ${isStrikethrough ? "line-through decoration-tertiary" : ""} 94 - ${isHighlighted ? "highlight bg-highlight-1" : ""}`.replaceAll("\n", " "); 110 + ${isHighlighted ? (highlightStyle ? "highlight" : "highlight bg-highlight-1") : ""}`.replaceAll("\n", " "); 95 111 96 112 // Split text by newlines and insert <br> tags 97 113 const textParts = segment.text.split("\n"); ··· 103 119 104 120 if (isCode) { 105 121 children.push( 106 - <code key={counter} className={className} id={id?.id}> 122 + <code 123 + key={counter} 124 + className={className} 125 + id={id?.id} 126 + style={highlightStyle} 127 + > 107 128 {renderedText} 108 129 </code>, 109 130 ); ··· 112 133 if (DidMentionRenderer) { 113 134 children.push( 114 135 <DidMentionRenderer key={counter} did={isDidMention.did}> 115 - <span className="mention">{renderedText}</span> 136 + <span className="mention" style={highlightStyle}> 137 + {renderedText} 138 + </span> 116 139 </DidMentionRenderer>, 117 140 ); 118 141 } else { ··· 124 147 target="_blank" 125 148 className="no-underline" 126 149 > 127 - <span className="mention">{renderedText}</span> 150 + <span className="mention" style={highlightStyle}> 151 + {renderedText} 152 + </span> 128 153 </a>, 129 154 ); 130 155 } ··· 146 171 href={link.uri.trim()} 147 172 className={`text-accent-contrast hover:underline ${className}`} 148 173 target="_blank" 174 + style={highlightStyle} 149 175 > 150 176 {renderedText} 151 177 </a>, 152 178 ); 153 179 } else { 154 180 children.push( 155 - <span key={counter} className={className} id={id?.id}> 181 + <span 182 + key={counter} 183 + className={className} 184 + id={id?.id} 185 + style={highlightStyle} 186 + > 156 187 {renderedText} 157 188 </span>, 158 189 );
+27 -2
emails/post.tsx
··· 40 40 MailHead, 41 41 makeEmailIconUrl, 42 42 makeStaticUrl, 43 + mixRgb, 43 44 resolveColors, 44 45 type EmailTheme, 45 46 type ResolvedColors, ··· 1388 1389 } 1389 1390 }; 1390 1391 1392 + function highlightFacetBackground( 1393 + highlight: PubLeafletRichtextFacet.Highlight | undefined, 1394 + ): string | undefined { 1395 + const color = highlight?.color; 1396 + if (!color || typeof color !== "object") return undefined; 1397 + const { r, g, b } = color as { r?: number; g?: number; b?: number }; 1398 + if (typeof r !== "number" || typeof g !== "number" || typeof b !== "number") { 1399 + return undefined; 1400 + } 1401 + const a = (color as { a?: number }).a; 1402 + return typeof a === "number" 1403 + ? `rgba(${r}, ${g}, ${b}, ${a / 100})` 1404 + : `rgb(${r}, ${g}, ${b})`; 1405 + } 1406 + 1407 + // Mirrors the web's default --highlight-1: 1408 + // color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 75%) 1409 + // Gmail strips color-mix(), so we resolve it to a literal rgb() at render time. 1410 + const defaultHighlightBackground = (theme: EmailTheme): string => 1411 + mixRgb(theme.accentBackground, theme.pageBackground, 75); 1412 + 1391 1413 export const RichTextSpans = ({ 1392 1414 plaintext, 1393 1415 facets, ··· 1414 1436 PubLeafletRichtextFacet.isStrikethrough, 1415 1437 ); 1416 1438 const isCode = !!features?.find(PubLeafletRichtextFacet.isCode); 1417 - const isHighlight = !!features?.find(PubLeafletRichtextFacet.isHighlight); 1439 + const highlight = features?.find(PubLeafletRichtextFacet.isHighlight); 1440 + const isHighlight = !!highlight; 1441 + const highlightBackground = 1442 + highlightFacetBackground(highlight) ?? defaultHighlightBackground(theme); 1418 1443 1419 1444 const decorations: string[] = []; 1420 1445 if (isUnderline) decorations.push("underline"); ··· 1427 1452 backgroundColor: isCode 1428 1453 ? "rgba(0,0,0,0.06)" 1429 1454 : isHighlight 1430 - ? "#fff3a3" 1455 + ? highlightBackground 1431 1456 : undefined, 1432 1457 fontFamily: isCode ? "monospace" : undefined, 1433 1458 padding: isCode ? "1px 4px" : undefined,
+9 -1
lexicons/api/lexicons.ts
··· 2489 2489 type: 'object', 2490 2490 description: 'Facet feature for highlighted text.', 2491 2491 required: [], 2492 - properties: {}, 2492 + properties: { 2493 + color: { 2494 + type: 'union', 2495 + refs: [ 2496 + 'lex:pub.leaflet.theme.color#rgba', 2497 + 'lex:pub.leaflet.theme.color#rgb', 2498 + ], 2499 + }, 2500 + }, 2493 2501 }, 2494 2502 underline: { 2495 2503 type: 'object',
+5
lexicons/api/types/pub/leaflet/richtext/facet.ts
··· 9 9 is$typed as _is$typed, 10 10 type OmitKey, 11 11 } from '../../../../util' 12 + import type * as PubLeafletThemeColor from '../theme/color' 12 13 13 14 const is$typed = _is$typed, 14 15 validate = _validate ··· 128 129 /** Facet feature for highlighted text. */ 129 130 export interface Highlight { 130 131 $type?: 'pub.leaflet.richtext.facet#highlight' 132 + color?: 133 + | $Typed<PubLeafletThemeColor.Rgba> 134 + | $Typed<PubLeafletThemeColor.Rgb> 135 + | { $type: string } 131 136 } 132 137 133 138 const hashHighlight = 'highlight'
+9 -1
lexicons/pub/leaflet/richtext/facet.json
··· 105 105 "type": "object", 106 106 "description": "Facet feature for highlighted text.", 107 107 "required": [], 108 - "properties": {} 108 + "properties": { 109 + "color": { 110 + "type": "union", 111 + "refs": [ 112 + "pub.leaflet.theme.color#rgba", 113 + "pub.leaflet.theme.color#rgb" 114 + ] 115 + } 116 + } 109 117 }, 110 118 "underline": { 111 119 "type": "object",
+4 -1
lexicons/src/facet.ts
··· 1 1 import { LexiconDoc } from "@atproto/lexicon"; 2 + import { ColorUnion } from "./theme"; 2 3 const FacetItems: LexiconDoc["defs"] = { 3 4 link: { 4 5 type: "object", ··· 34 35 type: "object", 35 36 description: "Facet feature for highlighted text.", 36 37 required: [], 37 - properties: {}, 38 + properties: { 39 + color: ColorUnion, 40 + }, 38 41 }, 39 42 underline: { 40 43 type: "object",
+56 -3
src/utils/factsToPagesRecord.ts
··· 35 35 import { getBlocksWithTypeLocal } from "src/replicache/getBlocks"; 36 36 import { List, parseBlocksToList } from "src/utils/parseBlocksToList"; 37 37 import { Delta, YJSFragmentToString } from "src/utils/yjsFragmentToString"; 38 + import { ColorToRGB } from "components/ThemeManager/colorToLexicons"; 39 + import { ThemeDefaults } from "components/ThemeManager/themeUtils"; 40 + import { parseColor } from "@react-stately/color"; 38 41 39 42 type ExcludeString<T> = T extends string 40 43 ? string extends T ··· 75 78 }[]; 76 79 }; 77 80 81 + function resolveHighlightColors( 82 + scan: ReturnType<typeof scanIndexLocal>, 83 + root_entity: string, 84 + ): HighlightColors { 85 + const result: HighlightColors = {}; 86 + const slots: Array<{ 87 + slot: "2" | "3"; 88 + attribute: "theme/highlight-2" | "theme/highlight-3"; 89 + }> = [ 90 + { slot: "2", attribute: "theme/highlight-2" }, 91 + { slot: "3", attribute: "theme/highlight-3" }, 92 + ]; 93 + for (const { slot, attribute } of slots) { 94 + const stored = scan.eav(root_entity, attribute)?.[0]?.data.value; 95 + // Mirror useColorAttribute's fallback: theme/highlight-2 and -3 facts are 96 + // only written when the user opens the color picker, so an uncustomized 97 + // leaflet has no fact and the editor shows the JS default. Without this 98 + // fallback, slot-2 and slot-3 highlights would publish without a color 99 + // and collapse to slot-1 styling on render. 100 + const colorString = stored ? `hsba(${stored})` : ThemeDefaults[attribute]; 101 + try { 102 + result[slot] = ColorToRGB(parseColor(colorString)); 103 + } catch { 104 + // ignore unparseable color values 105 + } 106 + } 107 + return result; 108 + } 109 + 78 110 export async function processBlocksToPages(opts: { 79 111 facts: Fact<Attribute>[]; 80 112 root_entity: string; ··· 83 115 const { facts, root_entity, hooks } = opts; 84 116 const scan = scanIndexLocal(facts); 85 117 const pages: ProcessBlocksToPagesResult["pages"] = []; 118 + 119 + const highlightColors = resolveHighlightColors(scan, root_entity); 86 120 87 121 const firstEntity = scan.eav(root_entity, "root/page")?.[0]; 88 122 if (!firstEntity) throw new Error("No root page"); ··· 276 310 Y.applyUpdate(doc, update); 277 311 const nodes = doc.getXmlElement("prosemirror").toArray(); 278 312 const plaintext = YJSFragmentToString(nodes[0]); 279 - const { facets } = YJSFragmentToFacets(nodes[0]); 313 + const { facets } = YJSFragmentToFacets( 314 + nodes[0], 315 + 0, 316 + undefined, 317 + highlightColors, 318 + ); 280 319 return { plaintext, facets }; 281 320 }; 282 321 const getBlockContent = (b: string) => { ··· 292 331 nodes[0], 293 332 0, 294 333 footnoteContentResolver, 334 + highlightColors, 295 335 ); 296 336 return [stringValue, facets] as const; 297 337 }; ··· 570 610 } 571 611 } 572 612 613 + export type HighlightColors = Partial< 614 + Record<"1" | "2" | "3", PubLeafletRichtextFacet.Highlight["color"]> 615 + >; 616 + 573 617 export function YJSFragmentToFacets( 574 618 node: Y.XmlElement | Y.XmlText | Y.XmlHook, 575 619 byteOffset: number = 0, ··· 577 621 plaintext: string; 578 622 facets: PubLeafletRichtextFacet.Main[]; 579 623 }, 624 + highlightColors?: HighlightColors, 580 625 ): { facets: PubLeafletRichtextFacet.Main[]; byteLength: number } { 581 626 if (node.constructor === Y.XmlElement) { 582 627 if (node.nodeName === "footnote") { ··· 654 699 child, 655 700 currentOffset, 656 701 footnoteContentResolver, 702 + highlightColors, 657 703 ); 658 704 allFacets.push(...result.facets); 659 705 currentOffset += result.byteLength; ··· 683 729 684 730 if (d.attributes?.code) 685 731 facet.features.push({ $type: "pub.leaflet.richtext.facet#code" }); 686 - if (d.attributes?.highlight) 687 - facet.features.push({ $type: "pub.leaflet.richtext.facet#highlight" }); 732 + if (d.attributes?.highlight) { 733 + const slot = d.attributes.highlight.color as "1" | "2" | "3"; 734 + const resolvedColor = 735 + slot && slot !== "1" ? highlightColors?.[slot] : undefined; 736 + facet.features.push({ 737 + $type: "pub.leaflet.richtext.facet#highlight", 738 + ...(resolvedColor ? { color: resolvedColor } : {}), 739 + }); 740 + } 688 741 if (d.attributes?.underline) 689 742 facet.features.push({ $type: "pub.leaflet.richtext.facet#underline" }); 690 743 if (d.attributes?.strong)