Sync letter improvements from lief

- Make LetterContent.body optional; a letter requires a non-empty body
  or at least one sticker
- Replace colors:[]/flatMode with event-stripping: flat color moment =
  event field stripped from Stationery, removing the colors? field
- Remove edgeScale: stickers render at their stored scale value with no
  edge-proximity size reduction, matching lief and the NIP
- Fix sticker shrinking near card edges: add max-width:none to override
  Tailwind preflight max-width:100% on img/svg elements
This commit is contained in:
Chad Curtis
2026-03-30 04:30:00 -05:00
parent ef04de67c0
commit 4643830512
3 changed files with 28 additions and 65 deletions
+5 -44
View File
@@ -14,47 +14,10 @@ import { sanitizeSvg } from '@/lib/sanitizeSvg';
const MIN_SCALE = 0.5;
const MAX_SCALE = 4;
const BASE_SIZE_CQW = 14;
const BASE_SIZE_CQW = 14; // cqw — base sticker size at scale=1, scales with card width
/** Inward buffer (%) so sticker centers can't reach the very edge of the card. */
const EDGE_BUFFER = 5;
/** Aspect ratio of the letter card (width / height). */
const CARD_ASPECT = 5 / 4;
/**
* Compute an edge-proximity scale factor so stickers shrink as their center
* approaches any edge of the card. The sticker's nominal half-size (in %)
* is compared against the distance from the center to the nearest edge;
* when the distance is less than the half-size the sticker shrinks
* proportionally so it always fits within the card boundary.
*
* Returns a multiplier in (0, 1] that should be applied on top of the
* sticker's own `scale` value.
*/
function edgeScale(x: number, y: number, baseScale: number): number {
// Half-size of the sticker in percentage of container width
const halfW = (BASE_SIZE_CQW * baseScale) / 2; // in cqw, which == % of width
// Container height in cqw units = 100 / aspectRatio
const containerHeightCqw = 100 / CARD_ASPECT; // 80 for 5:4
// Half-size as % of container height
const halfH = (BASE_SIZE_CQW * baseScale) / 2 / containerHeightCqw * 100;
// Distance from center to each edge (in %)
const dLeft = x;
const dRight = 100 - x;
const dTop = y;
const dBottom = 100 - y;
// How much room we need vs how much we have, per axis
const fitX = Math.min(dLeft, dRight) / halfW;
const fitY = Math.min(dTop, dBottom) / halfH;
// Take the tightest constraint, clamp to (0, 1]
const fit = Math.min(fitX, fitY, 1);
// Floor at a tiny size so stickers don't vanish completely
return Math.max(fit, 0.15);
}
function isSafeUrl(url: string): boolean {
try {
return new URL(url).protocol === 'https:';
@@ -67,7 +30,7 @@ function StickerMedia({ sticker, sizeCqw, className }: { sticker: LetterSticker;
if (sticker.svg) {
return (
<div
style={{ width: sizeCqw, height: sizeCqw }}
style={{ width: sizeCqw, height: sizeCqw, maxWidth: 'none' }}
className={`sticker-svg-wrap ${className ?? ''}`}
dangerouslySetInnerHTML={{ __html: sanitizeSvg(sticker.svg) }}
/>
@@ -78,7 +41,7 @@ function StickerMedia({ sticker, sizeCqw, className }: { sticker: LetterSticker;
<img
src={sticker.url}
alt={sticker.shortcode}
style={{ width: sizeCqw, height: sizeCqw }}
style={{ width: sizeCqw, height: sizeCqw, maxWidth: 'none' }}
className={className}
draggable={false}
/>
@@ -87,8 +50,7 @@ function StickerMedia({ sticker, sizeCqw, className }: { sticker: LetterSticker;
function StaticSticker({ sticker }: { sticker: LetterSticker }) {
const s = sticker.scale ?? 1;
const es = edgeScale(sticker.x, sticker.y, s);
const sizeCqw = `${BASE_SIZE_CQW * s * es}cqw`;
const sizeCqw = `${BASE_SIZE_CQW * s}cqw`;
return (
<div
@@ -125,8 +87,7 @@ function EditableSticker({
containerRef,
}: EditableStickerProps) {
const s = sticker.scale ?? 1;
const es = edgeScale(sticker.x, sticker.y, s);
const sizeCqw = `${BASE_SIZE_CQW * s * es}cqw`;
const sizeCqw = `${BASE_SIZE_CQW * s}cqw`;
const dragging = useRef(false);
const hasMoved = useRef(false);
+17 -9
View File
@@ -259,15 +259,23 @@ export function StationeryPicker({ selected, onSelect }: StationeryPickerProps)
const hasEmoji = !!resolved?.emoji;
const isColorMoment = selected?.event?.kind === COLOR_MOMENT_KIND;
const isTheme = selected?.event?.kind === THEME_KIND;
const isSingleColor = isColorMoment && selected?.colors !== undefined && selected.colors.length === 0;
// Remember the last color moment event so we can restore it when toggling off flat mode
const lastColorMomentRef = useRef<NostrEvent | undefined>(undefined);
if (isColorMoment && selected?.event) lastColorMomentRef.current = selected.event;
// Flat mode = color moment was selected but event has been stripped (only color remains)
const isFlatMode = !!(selected && !selected.event && lastColorMomentRef.current);
const toggleSingleColor = () => {
if (!selected || !isColorMoment) return;
if (isSingleColor) {
const { colors: _, ...rest } = selected;
if (!selected) return;
if (isFlatMode && lastColorMomentRef.current) {
// Restore: put the event back
onSelect({ ...selected, event: lastColorMomentRef.current });
} else if (isColorMoment && selected.event) {
// Flatten: strip the event, keep only color
const { event: _, ...rest } = selected;
onSelect(rest as Stationery);
} else {
onSelect({ ...selected, colors: [] });
}
};
@@ -359,7 +367,7 @@ export function StationeryPicker({ selected, onSelect }: StationeryPickerProps)
{tab === 'themes' && <ThemesGrid key={scope} selectedStationery={selected} onSelect={onSelect} authors={scopedAuthors} />}
</div>
{((hasEmoji && !isTheme) || isColorMoment) && (
{((hasEmoji && !isTheme) || isColorMoment || isFlatMode) && (
<div className="flex items-center gap-4 px-1 pt-1">
{hasEmoji && !isTheme && (
<label className="flex items-center gap-1.5">
@@ -367,9 +375,9 @@ export function StationeryPicker({ selected, onSelect }: StationeryPickerProps)
<span className="text-sm text-muted-foreground font-medium">emblem</span>
</label>
)}
{isColorMoment && (
{(isColorMoment || isFlatMode) && (
<label className="flex items-center gap-1.5">
<Switch checked={isSingleColor} onCheckedChange={toggleSingleColor} />
<Switch checked={isFlatMode} onCheckedChange={toggleSingleColor} />
<span className="text-sm text-muted-foreground font-medium">flat</span>
</label>
)}
+6 -12
View File
@@ -29,7 +29,8 @@ export interface LetterSticker {
}
export interface LetterContent {
body: string;
/** Main letter text. Optional — a letter must have either a non-empty body or at least one sticker. */
body?: string;
closing?: string;
signature?: string;
/** Stickers placed on the letter card — stored in encrypted content for privacy */
@@ -53,8 +54,7 @@ export interface Stationery {
emoji?: string;
/** Emoji display mode: 'tile' (faint repeating pattern) or 'emblem' (single large centered glyph). */
emojiMode?: 'tile' | 'emblem';
/** Palette colors override. Empty array = "flat" mode (suppress palette). */
colors?: string[];
/** CSS font-family string (e.g. "Caveat, cursive"). Set from the sender's font choice. */
fontFamily?: string;
/** Frame style ID. */
@@ -110,14 +110,9 @@ export function resolveStationery(s: Stationery): ResolvedStationery {
const event = s.event;
if (event?.kind === COLOR_MOMENT_KIND) {
// Colors override: empty array = flat mode, undefined = read from event
if (s.colors !== undefined) {
base.colors = s.colors.length > 0 ? s.colors : undefined;
} else {
const hexRe = /^#[0-9A-Fa-f]{6}$/;
const eventColors = event.tags.filter(([n]) => n === 'c').map(([, c]) => c).filter((c) => hexRe.test(c));
if (eventColors.length >= 2) base.colors = eventColors;
}
const hexRe = /^#[0-9A-Fa-f]{6}$/;
const eventColors = event.tags.filter(([n]) => n === 'c').map(([, c]) => c).filter((c) => hexRe.test(c));
if (eventColors.length >= 2) base.colors = eventColors;
base.layout = s.layout ?? event.tags.find(([n]) => n === 'layout')?.[1];
if (!base.emoji) {
const raw = event.content?.trim();
@@ -150,7 +145,6 @@ export function resolveStationery(s: Stationery): ResolvedStationery {
// No event or unknown kind — use legacy flat fallbacks (old letters, presets)
base.textColor = s.textColor;
base.primaryColor = s.primaryColor;
base.colors = s.colors;
base.layout = s.layout;
base.imageUrl = s.imageUrl;
base.imageMode = s.imageMode ?? 'cover';