Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

svg.gno

26.54 Kb · 651 lines
  1package nsview
  2
  3import (
  4	"encoding/base64"
  5	"strings"
  6	"time"
  7
  8	"gno.land/p/nt/ufmt/v0"
  9	"gno.land/r/nym-mnsrehearsal001/nsdata/v2"
 10)
 11
 12// The NFT artwork and every piece of display formatting live here, in a
 13// realm that can be redeployed, rather than in the vault that cannot.
 14// nsdata.TokenURI points at this realm through a settable prefix.
 15
 16// TokenURI returns the full data: URI for a token, generated on every
 17// read so the image always reflects current state (never baked at mint).
 18func TokenURI(tid string) string {
 19	return "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(BuildSVG(tid)))
 20}
 21
 22// BuildSVG renders a token. Everything on the card derives from four
 23// stored values — owner, registered, expires, and the domain's drawing —
 24// so a renewal moves `expires` and the bar, the time remaining and the
 25// years held all follow from it. Nothing is baked at mint: the card is
 26// generated on every read, which is what lets a domain's artwork be
 27// changed after its names have been sold.
 28func BuildSVG(tid string) string {
 29	var registered, expires int64 // unix seconds
 30	var streak int64
 31	var frozen bool
 32	var domainLabel string
 33
 34	if strings.HasPrefix(tid, "*") {
 35		label := tid[1:]
 36		if !nsdata.DomainExists(label) {
 37			return placeholderSVG("unknown")
 38		}
 39		_, registered, expires, streak, frozen = nsdata.GetDomainInfo(label)
 40		domainLabel = label
 41	} else {
 42		label, dom := splitTokenID(tid)
 43		if !nsdata.NameExists(label, dom) {
 44			return placeholderSVG("unknown")
 45		}
 46		_, registered, expires, streak, frozen, _ = nsdata.GetNameInfo(label, dom)
 47		domainLabel = dom
 48	}
 49
 50	now := time.Now().Unix()
 51	// The three vault reads happen HERE, not inside cardSVG. That is what
 52	// leaves the card itself a pure function of its inputs, which is what
 53	// lets a test assert what a marketplace will show without standing up
 54	// a registry to do it.
 55	// A SKIN REPLACES THE CARD, so it is decided before anything is read
 56	// from the vault for drawing one. The ordinary path is untouched on
 57	// every other day of the year.
 58	/* A BOUGHT SKIN BEATS THE CALENDAR, and that ordering is a
 59	   decision rather than an accident. The calendar skin is a gift
 60	   everybody gets on the same day; a worn skin is a thing somebody
 61	   paid for and chose. On the two or three days a year they collide,
 62	   the one that was bought wins.
 63
 64	   Only NAMES can wear one. A domain token has no owner to bind to
 65	   and no shop entry, so it takes the calendar path as before. */
 66	if !strings.HasPrefix(tid, "*") {
 67		label, dom := splitTokenID(tid)
 68		if art := wornArt(label, dom); art != "" {
 69			if card := skinnedCardSVG(art, label, dom, expires); card != "" {
 70				return card
 71			}
 72			// An unrenderable sprite falls through to the ordinary card
 73			// rather than returning nothing: a blank token is worse than
 74			// an unskinned one, and the holder still owns the name.
 75		}
 76	}
 77
 78	if sk := skinFor(now); sk != "" && hasSkin(sk) {
 79		return skinSVG(sk, tid, now)
 80	}
 81
 82	art := nsdata.GetDomainExtra(domainLabel, keyArt)
 83	return cardSVG(tid, art, registered, expires, streak, frozen, now, overlaySVG(now))
 84}
 85
 86// cardSVG is the card itself, with every stored value already resolved.
 87// Split out from BuildSVG so the markup can be rendered — and asserted
 88// against — without a vault under it. TestStaticFrameShowsTheDate is the
 89// reason: what a marketplace shows as a still is a property of this
 90// string and of nothing else, and it deserves to be checked rather than
 91// assumed.
 92func cardSVG(tid, art string, registered, expires, streak int64, frozen bool, now int64, overlay string) string {
 93	accent := accentOf(art)
 94	status, edge := statusFields(expires, frozen)
 95
 96	// The ribbon says something the rest of the card does not.
 97	// "DIAMOND HANDS" on a name minted this morning is a joke that lands
 98	// once; tied to tenure it is a thing you get TO.
 99	ribbon := status
100	if status == "LIVE" {
101		if streak >= 1 {
102			ribbon = "DIAMOND HANDS"
103		} else {
104			ribbon = "FIRST YEAR"
105		}
106	}
107
108	// The domain's drawing as a wash, centred: twelve cells at 26px is
109	// 312 wide, so 44px of margin on every side.
110	//
111	// 0.07, not 0.12. At 0.12 a bright sprite competed with the name
112	// sitting on top of it — the eye landed on the picture first and had
113	// to hunt for the type. A wash should be felt rather than read.
114	wash := spriteSVG(art, 26, 44, 44, "0.07")
115
116	// The bar is the whole term, filled by how much of it is left.
117	span := expires - registered
118	if span < 1 {
119		span = 1
120	}
121	left := expires - now
122	if left < 0 {
123		left = 0
124	}
125	if left > span {
126		left = span
127	}
128	fill := (308 * left) / span
129
130	heldLabel := "YEARS HELD"
131	if streak == 1 {
132		heldLabel = "YEAR HELD"
133	}
134
135	/* ---- SAYING IT IS DEAD, LOUDLY ----
136	 *
137	 * A lapsed card and a live card differed by a border colour and one
138	 * word in 12px type at the very bottom. In a wallet's grid they read
139	 * as the same purple card — for the one fact that changes what you
140	 * can DO with the token: an expired name cannot be sold, cannot be
141	 * transferred, and is about to belong to somebody else.
142	 *
143	 * A wash over the whole card in the status colour, and the status
144	 * word on a filled band at nearly twice the size. The band sits at
145	 * y=352 because the tenure caption is at 344 and a taller band ate
146	 * it; a card that shouts LAPSED and hides how long somebody held it
147	 * has traded one fact for another rather than adding one.
148	 *
149	 * A LIVE CARD IS UNTOUCHED. This must not make the ordinary case
150	 * noisier — that is the card somebody paid for.
151	 *
152	 * Mirrored byte-for-byte in the site's nft.js. If this changes, that
153	 * changes, or a preview stops matching what the chain draws. */
154	metalDefs, frameStroke, frameWidth := metalFrame(tid, status, edge, streak)
155
156	statusWash, statusBand := "", ""
157	if status == "GRACE PERIOD" || status == "LAPSED" {
158		washOp := "0.13"
159		if status == "LAPSED" {
160			washOp = "0.17"
161		}
162		statusWash = ufmt.Sprintf(
163			`<rect width="400" height="400" fill="%s" opacity="%s"/>`, edge, washOp)
164		statusBand = ufmt.Sprintf(
165			`<rect x="12" y="352" width="376" height="36" fill="%s" opacity="0.92"/>`+
166				`<rect x="12" y="352" width="376" height="36" fill="none" stroke="%s" stroke-width="3"/>`+
167				`<text x="200" y="378" text-anchor="middle" font-family="monospace" `+
168				`font-size="20" font-weight="bold" fill="#1b1200">%s</text>`,
169			edge, edge, ribbon)
170	}
171
172	body := ufmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" role="img">
173  %s<rect width="400" height="400" fill="#10101a"/>
174  %s%s
175  <rect x="12" y="12" width="376" height="376" rx="18" fill="none" stroke="%s" stroke-width="%s"/>
176  <text x="30" y="42" font-family="monospace" font-size="10" fill="%s" opacity="0.7">MINTED ON GNO.LAND</text>
177  <text x="370" y="42" text-anchor="end" font-family="monospace" font-size="10" fill="%s" opacity="0.7">SINCE %s</text>
178  <text x="200" y="196" text-anchor="middle" font-family="monospace" font-size="%d" font-weight="bold" fill="#ffffff">%s</text>
179  <!-- THE STATIC FRAME. A marketplace showing this token as a still
180       gets whichever of these two lines is visible with no animation
181       running, and that MUST be the date. Everything that matters about
182       a name at a glance -- is it live, is it expired, when does it go
183       -- is in the date; "412 days left" frozen into a thumbnail is a
184       number that was true once and is a lie thereafter.
185
186       It works out that way under both kinds of renderer, which is why
187       it is written like this and not any other way:
188
189         a renderer that IGNORES SMIL uses the base attribute in the
190         markup, so the date's opacity="1" and the counter's opacity="0"
191         decide it
192
193         a renderer that HONOURS SMIL and seeks to t=0 uses the first
194         keyTime, so the date's values="1;0" and the counter's "0;1"
195         decide it
196
197       Both must keep agreeing. Reorder these two lines, or drop an
198       explicit opacity, and the still becomes a countdown -- with
199       nothing failing and nothing to notice until a card in somebody
200       else's gallery says a name has 412 days left three years from
201       now. Guarded by TestStaticFrameShowsTheDate. -->
202  <text x="200" y="224" text-anchor="middle" font-family="monospace" font-size="13" fill="%s" opacity="1">%s<animate attributeName="opacity" calcMode="discrete" values="1;0" keyTimes="0;0.5" dur="8s" repeatCount="indefinite"/></text>
203  <text x="200" y="224" text-anchor="middle" font-family="monospace" font-size="13" fill="%s" opacity="0">%s<animate attributeName="opacity" calcMode="discrete" values="0;1" keyTimes="0;0.5" dur="8s" repeatCount="indefinite"/></text>
204  <rect x="46" y="256" width="308" height="9" rx="4" fill="#ffffff" opacity="0.13"/>
205  <rect x="46" y="256" width="%d" height="9" rx="4" fill="%s"/>
206  <text x="46" y="282" font-family="monospace" font-size="9" fill="#ffffff" opacity="0.45">%s</text>
207  <text x="354" y="282" text-anchor="end" font-family="monospace" font-size="9" fill="#ffffff" opacity="0.45">%s</text>
208  <text x="200" y="326" text-anchor="middle" font-family="monospace" font-size="30" font-weight="bold" fill="#ffffff">%d</text>
209  <text x="200" y="344" text-anchor="middle" font-family="monospace" font-size="9" fill="%s" opacity="0.8">%s</text>
210  %s
211  %s
212</svg>`,
213		metalDefs, wash, statusWash, frameStroke, frameWidth,
214		accent, accent, time.Unix(registered, 0).UTC().Format("2006"),
215		fitSize(tid), escapeXML(tid),
216		accent, time.Unix(expires, 0).UTC().Format("Jan 2, 2006"),
217		accent, remaining(expires, now),
218		fill, edge,
219		time.Unix(registered, 0).UTC().Format("Jan 2006"),
220		time.Unix(expires, 0).UTC().Format("Jan 2006"),
221		streak, accent, heldLabel,
222		smallRibbon(status, edge, ribbon, statusBand),
223		overlay)
224
225	/* ---- GRACE AND LAPSED, LAYERED ON TOP ----
226	 *
227	 * Both branch OUT of `body` rather than building their own copy of
228	 * it, on purpose: `body` above is untouched from before this pair of
229	 * treatments existed, so a LIVE or FROZEN card -- the two states
230	 * that are not supposed to change AT ALL -- takes neither branch and
231	 * returns exactly the string it always returned. TestCardUnchangedForLiveAndFrozen
232	 * pins that down against a captured pre-change baseline, which is a
233	 * stronger guarantee than "I re-read the template and it looks
234	 * right" for a template this dense.
235	 *
236	 * LAPSED only needs one more thing drawn on top, so it is one
237	 * strings.Replace of the closing tag.
238	 *
239	 * GRACE needs three pieces of `body` re-wrapped in place -- the art
240	 * wash, the frame-and-header block, and the term bar -- while the
241	 * status band and the date line in between are left exactly where
242	 * they are. That is done by locating each piece with a FIXED anchor
243	 * string from the template above (never a byte offset, and never
244	 * anything built from a dynamic value like a colour or a date, so a
245	 * future edit to what's IN the template cannot silently point this
246	 * at the wrong slice) and splicing a <g> around it. See heartbeat()
247	 * and TestGraceHeartbeatLeavesBandAndDateAlone. */
248	switch status {
249	case "LAPSED":
250		return strings.Replace(body, "</svg>", crackOverlay+"</svg>", 1)
251	case "GRACE PERIOD":
252		return graceHeartbeat(body, statusWash)
253	default:
254		return body
255	}
256}
257
258// graceHeartbeat re-wraps three pieces of an already-built grace card in
259// the failing-heartbeat flicker, leaving the status band and the date
260// line between them untouched. See the "GRACE AND LAPSED, LAYERED ON
261// TOP" comment in cardSVG for why this operates on the finished string
262// rather than building a second template.
263//
264// Every anchor here is fixed template text, not a dynamic value, so it
265// does not drift when a colour, a date or a name changes -- only when
266// the template itself does, and TestGraceHeartbeatLeavesBandAndDateAlone
267// would catch that (the -1 guards below make a missed anchor degrade to
268// the plain card rather than a corrupted one).
269func graceHeartbeat(body, statusWash string) string {
270	const bgAnchor = `<rect width="400" height="400" fill="#10101a"/>` + "\n  "
271	bgEnd := strings.Index(body, bgAnchor)
272	frameStart := strings.Index(body, `<rect x="12" y="12" width="376" height="376" rx="18"`)
273	commentStart := strings.Index(body, `<!-- THE STATIC FRAME`)
274	barStart := strings.Index(body, `<rect x="46" y="256" width="308" height="9" rx="4" fill="#ffffff" opacity="0.13"/>`)
275	bandStart := strings.Index(body, `<rect x="12" y="352"`)
276	if bgEnd < 0 || frameStart < 0 || commentStart < 0 || barStart < 0 || bandStart < 0 {
277		return body
278	}
279	washStart := bgEnd + len(bgAnchor)
280	washRel := strings.Index(body[washStart:], statusWash)
281	if washRel < 0 {
282		return body
283	}
284	washEnd := washStart + washRel
285
286	return body[:washStart] +
287		heartbeat(body[washStart:washEnd]) + // the art wash alone
288		body[washEnd:frameStart] + // statusWash, held steady
289		heartbeat(body[frameStart:commentStart]) + // frame, MINTED/SINCE, the name
290		body[commentStart:barStart] + // the STATIC FRAME comment + the date line, held steady
291		heartbeat(body[barStart:bandStart]) + // the term bar, both dates, the held count
292		body[bandStart:] // the status band onward, held steady
293}
294
295// fitSize solves for a size rather than stepping through a ladder.
296// Monospace glyphs are ~0.6em, so a name up to the full 32-character
297// limit sets on one line without overflowing 336px.
298func fitSize(s string) int {
299	n := len(s)
300	if n < 1 {
301		n = 1
302	}
303	size := 336 * 10 / (6 * n)
304	// 40, not 34: the name is the thing somebody bought, and on a card
305	// where the drawing sits behind it, the type has to be the loudest
306	// object on the tile. Short names were leaving the middle of the card
307	// half empty.
308	if size > 40 {
309		size = 40
310	}
311	if size < 11 {
312		size = 11
313	}
314	return size
315}
316
317// domainAccent reads the accent out of the domain's stored drawing, so
318// the card wears the domain's own colour without this realm carrying a
319// palette of its own. Falls back to the house colour.
320func domainAccent(domainLabel string) string {
321	return accentOf(nsdata.GetDomainExtra(domainLabel, keyArt))
322}
323
324func accentOf(enc string) string {
325	if len(enc) >= 8 && enc[1] == '#' && isHexColour(enc[1:8]) {
326		return enc[1:8]
327	}
328	return "#5eead4"
329}
330
331func remaining(expires, now int64) string {
332	if now >= expires {
333		d := (now - expires) / daySeconds
334		if d <= 0 {
335			return "expired today"
336		}
337		if d == 1 {
338			return "1 day past due"
339		}
340		return ufmt.Sprintf("%d days past due", d)
341	}
342	d := (expires - now) / daySeconds
343	if d <= 0 {
344		return "expires today"
345	}
346	if d == 1 {
347		return "1 day remaining"
348	}
349	return ufmt.Sprintf("%d days remaining", d)
350}
351
352// keyArt is the per-domain extension key holding the drawing. The value
353// is "A#facc15,B#7c2d12|............|....AAAA....|..." — the palette,
354// then twelve rows of twelve characters where '.' is transparent.
355//
356// PARSED DEFENSIVELY AND NEVER TRUSTED. It is written by whichever logic
357// realm is current, so a future one could store something malformed;
358// anything unexpected yields no sprite rather than broken markup inside
359// a token that a marketplace will render.
360const keyArt = "art"
361
362func spriteSVG(enc string, px, ox, oy int, opacity string) string {
363	if enc == "" {
364		return ""
365	}
366	parts := strings.Split(enc, "|")
367	if len(parts) != 13 {
368		return ""
369	}
370	// palette: "A#facc15,B#7c2d12"
371	colours := map[string]string{}
372	for _, entry := range strings.Split(parts[0], ",") {
373		// key + "#" + six hex digits: "A#facc15" is EIGHT characters.
374		// This read 7 and rejected every valid sprite; the test caught it.
375		if len(entry) != 8 || entry[1] != '#' {
376			return ""
377		}
378		key := entry[:1]
379		hex := entry[1:]
380		if !isHexColour(hex) {
381			return ""
382		}
383		colours[key] = hex
384	}
385	var b strings.Builder
386	for y := 1; y <= 12; y++ {
387		row := parts[y]
388		if len(row) != 12 {
389			return ""
390		}
391		for x := 0; x < 12; x++ {
392			ch := row[x : x+1]
393			if ch == "." {
394				continue
395			}
396			c, ok := colours[ch]
397			if !ok {
398				return ""
399			}
400			b.WriteString(ufmt.Sprintf(
401				`<rect x="%d" y="%d" width="%d" height="%d" fill="%s"/>`,
402				ox+(x*px), oy+((y-1)*px), px, px, c))
403		}
404	}
405	if b.Len() == 0 {
406		return ""
407	}
408	return `<g opacity="` + opacity + `">` + b.String() + `</g>`
409}
410
411func isHexColour(s string) bool {
412	if len(s) != 7 || s[0] != '#' {
413		return false
414	}
415	for i := 1; i < 7; i++ {
416		c := s[i]
417		if !(c >= '0' && c <= '9') && !(c >= 'a' && c <= 'f') && !(c >= 'A' && c <= 'F') {
418			return false
419		}
420	}
421	return true
422}
423
424func placeholderSVG(msg string) string {
425	return ufmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400">`+
426		`<rect width="400" height="400" fill="#10101a"/>`+
427		`<text x="200" y="200" text-anchor="middle" font-family="monospace" font-size="18" fill="#666680">%s</text></svg>`,
428		escapeXML(msg))
429}
430
431// statusFields formats a lifecycle state for display. Reads the grace
432// period from nsdata rather than hardcoding it, so retuning the lifecycle
433// is a config change and this stays correct.
434// All lifecycle times are unix seconds, so this is integer arithmetic
435// over a day constant rather than duration division.
436const daySeconds = int64(24 * 60 * 60)
437
438/* ---- THE METAL LADDER ----
439 *
440 * From the "Name States" study, 2026-08-23, where it was designed and
441 * then not built for a fortnight. One rung per year held, carried by the
442 * frame: 1 bronze, 2 silver, 3 gold, 4 platinum, 5+ diamond.
443 *
444 * THE LADDER IS PICKED FOR SEPARATION, not for what the metals are
445 * worth. Brown, then cool grey, then yellow, then warm white, then
446 * something that is not a metal at all. The obvious ladder — silver,
447 * platinum, rhodium — is three whitish metals in a row, and nobody can
448 * tell year two from year five across a grid of thumbnails. Platinum is
449 * pulled deliberately WARM to keep it off the gold below it.
450 *
451 * The highlight travels faster at the higher rungs, so a longer-held
452 * name reads as brighter as well as merely different.
453 *
454 * DIAMOND IS SATURATED AND KEEPS ITS WHITE. Fire in a real stone is
455 * spectral colour separated BY white; take the white bands out and it
456 * stops reading as a gem and starts reading as an oil slick.
457 *
458 * IT ADDS NO WORDS. The ribbon has said DIAMOND HANDS since year one and
459 * at year five the card finally makes good on it. The domain's own
460 * colour still runs through the type and the term bar, so a name reads
461 * as its domain first and its tenure second.
462 *
463 * YEAR ZERO GETS NO METAL, which is the whole point of a ladder: if a
464 * new name looked decorated, the rungs a name EARNS would mean nothing.
465 *
466 * Mirrored in the site's nft.js. These two move together or a preview
467 * stops matching what the chain draws.
468 */
469type metal struct {
470	dur   int
471	stops string // already-rendered <stop> elements
472}
473
474func metalFor(streak int64) (metal, bool) {
475	switch {
476	case streak <= 0:
477		return metal{}, false
478	case streak == 1:
479		return metal{13, `<stop offset="0%" stop-color="#3a1c0c"/><stop offset="32%" stop-color="#e6b088"/>` +
480			`<stop offset="52%" stop-color="#b87333"/><stop offset="100%" stop-color="#3a1c0c"/>`}, true
481	case streak == 2:
482		return metal{11, `<stop offset="0%" stop-color="#3f4650"/><stop offset="32%" stop-color="#f1f5f9"/>` +
483			`<stop offset="52%" stop-color="#aab4c0"/><stop offset="100%" stop-color="#3f4650"/>`}, true
484	case streak == 3:
485		return metal{9, `<stop offset="0%" stop-color="#6b2f0a"/><stop offset="32%" stop-color="#fde68a"/>` +
486			`<stop offset="52%" stop-color="#f59e0b"/><stop offset="100%" stop-color="#6b2f0a"/>`}, true
487	case streak == 4:
488		return metal{9, `<stop offset="0%" stop-color="#6b6357"/><stop offset="30%" stop-color="#fbf7ef"/>` +
489			`<stop offset="50%" stop-color="#ddd4c4"/><stop offset="100%" stop-color="#6b6357"/>`}, true
490	default:
491		return metal{5, `<stop offset="0%" stop-color="#0369a1"/><stop offset="16%" stop-color="#ffffff"/>` +
492			`<stop offset="30%" stop-color="#22d3ee"/><stop offset="44%" stop-color="#ffffff"/>` +
493			`<stop offset="58%" stop-color="#e879f9"/><stop offset="72%" stop-color="#ffffff"/>` +
494			`<stop offset="86%" stop-color="#38bdf8"/><stop offset="100%" stop-color="#0369a1"/>`}, true
495	}
496}
497
498/*
499metalFrame returns the <defs>, the stroke and the stroke width for a
500card, or the plain versions when no metal is earned.
501
502THE METAL ONLY APPLIES TO A LIVE NAME. A gilded frame around a lapsed
503card would celebrate a name that is about to belong to somebody else, and
504it would fight the red wash that exists to say so. Grace and lapsed lose
505the metal, which is itself information: the frame going plain is the
506tenure visibly at risk.
507
508THE GRADIENT ID CARRIES THE TOKEN. A Render() page draws many of these
509into one document and SVG resolves url(#id) document-wide, so a fixed id
510would make every card after the first wear the first card's metal. The
511token id is unique by construction; it is sanitised because "*" and "-"
512are not valid in an id.
513*/
514func metalFrame(tid, status, edge string, streak int64) (defs, stroke, width string) {
515	m, ok := metalFor(streak)
516	if !ok || status != "LIVE" {
517		return "", edge, "3"
518	}
519	id := "m" + idSafe(tid)
520	defs = ufmt.Sprintf(
521		`<defs><linearGradient id="%s" x1="0" y1="0" x2="1" y2="1">%s`+
522			`<animateTransform attributeName="gradientTransform" type="rotate" `+
523			`from="0 .5 .5" to="360 .5 .5" dur="%ds" repeatCount="indefinite"/>`+
524			`</linearGradient></defs>`, id, m.stops, m.dur)
525	return defs, "url(#" + id + ")", "4"
526}
527
528// An XML id may not contain "*", and a name may not contain anything
529// else that would need escaping — the registry's own label rules see to
530// that — so one substitution is enough.
531func idSafe(tid string) string {
532	return strings.ReplaceAll(tid, "*", "-")
533}
534
535// The foot of the card: a quiet ribbon while the name is alive, the loud
536// band once it is not. One function so the two cannot both be drawn.
537func smallRibbon(status, edge, ribbon, band string) string {
538	if band != "" {
539		return band
540	}
541	return ufmt.Sprintf(
542		`<text x="200" y="372" text-anchor="middle" font-family="monospace" `+
543			`font-size="12" font-weight="bold" fill="%s">%s</text>`, edge, ribbon)
544}
545
546/* ---- FAILING HEARTBEAT (grace period) ----
547 *
548 * From the "Name States" study, 2026-08-23 -- the "Failing heartbeat"
549 * treatment, built around pulses()/BURSTS/track() there. Bursts of two,
550 * one, three and one flash at 0.02/0.30/0.55/0.80 of a 7.5s cycle read
551 * as an unpredictable fault rather than an even, designed blink, so the
552 * rhythm carries over unchanged. graceValues and graceKeyTimes below are
553 * that generator's output for base=1, dip=0.25 -- computed once, not
554 * rebuilt on every read, since a fixed rhythm shared by every grace card
555 * on the chain has nothing left to vary.
556 *
557 * INVERTED FROM THE STUDY, and deliberately. There the track sits DARK
558 * and briefly flashes LIT -- fine for something animating live in a
559 * browser tab, useless for a marketplace that renders one still frame:
560 * the base opacity in the markup and the first keyframe are BOTH the
561 * dark value there, so a still render of that track is nearly
562 * invisible. Here the rest state is fully lit -- opacity 1, exactly the
563 * live card -- and the bursts are brief DIPS toward .25 instead, so
564 * whichever still a renderer takes (the base attribute for one that
565 * ignores SMIL, the first keyframe for one that honours it) lands on
566 * the fully-lit card. graceValues therefore reads "1;1;0.25;..." and
567 * graceKeyTimes starts "0.0000;...": both a "1" at index zero, on
568 * purpose. TestGraceHeartbeatLeavesBandAndDateAlone checks exactly that.
569 *
570 * Mirrored in the site's nft.js (pulseTrack/heartbeat there, same two
571 * numbers). If this changes, that changes, or a preview stops matching
572 * what the chain draws.
573 */
574const graceValues = "1;1;0.25;0.25;1;1;0.25;0.25;1;1;0.25;0.25;1;1;0.25;0.25;1;1;0.25;0.25;1;1;0.25;0.25;1;1;0.25;0.25;1;1"
575const graceKeyTimes = "0.0000;0.0200;0.0240;0.0350;0.0390;0.0570;0.0610;0.0720;0.0760;0.3000;0.3040;0.3150;0.3190;0.5500;0.5540;0.5650;0.5690;0.5870;0.5910;0.6020;0.6060;0.6240;0.6280;0.6390;0.6430;0.8000;0.8040;0.8150;0.8190;1.0000"
576
577// heartbeat wraps one piece of a grace card in the failing-heartbeat
578// flicker. Called three separate times in graceHeartbeat rather than
579// once around the whole card, so the status band and the date line can
580// sit between the wrapped pieces untouched. Every call shares the same
581// values/keyTimes/dur; SMIL times every <animate> from document load, so
582// three separate <g>s stay in lockstep without needing to share one.
583func heartbeat(inner string) string {
584	return `<g opacity="1">` + inner +
585		`<animate attributeName="opacity" values="` + graceValues +
586		`" keyTimes="` + graceKeyTimes + `" dur="7.5s" repeatCount="indefinite"/></g>`
587}
588
589/* ---- CRACKED (lapsed) ----
590 *
591 * From the same study, under LAPSED. Static, no animation: a fracture
592 * across the glass with two short branches, drawn last so it sits over
593 * everything else on the card; the art behind it is untouched and
594 * undimmed.
595 *
596 * THE STUDY'S PATH IS NOT USED AS-IS. Its middle segment runs through
597 * x=140-176 at y=168-214 -- dead centre, right through where the name
598 * sits on the study's fixed-width demo cards. That is not fine here:
599 * the name is centred on x=200 with text-anchor="middle" and can run up
600 * to 32 characters (see fitSize), so its box can span nearly the full
601 * card width -- as far out as roughly x=30 to x=370 -- for a long one.
602 * No amount of routing the crack sideways dodges that band, because a
603 * long enough name is somewhere underneath any vertical line drawn
604 * across it.
605 *
606 * So the crack is drawn as two pieces rather than one continuous line:
607 * an upper fracture that stays above y=158 and a lower one that stays
608 * below y=224, leaving the name's row (166-206) and the date line under
609 * it (214-228) untouched regardless of how long the name is. It reads
610 * as the fracture running into the nameplate and continuing on the far
611 * side, rather than through it -- arguably truer to how a crack behaves
612 * against a raised, painted surface anyway. Other type on the card (the
613 * term bar, the held count) is still legitimately crossed, same as in
614 * the study; only the name and the date get the gap.
615 *
616 * Mirrored byte-for-byte in the site's nft.js (CRACK_OVERLAY there).
617 */
618const crackOverlay = `<g stroke="#ffffff" fill="none" opacity="0.5" stroke-width="2">` +
619	`<path d="M92 24 L150 72 L112 118 L162 152"/>` +
620	`<path d="M112 118 L54 138"/>` +
621	`<path d="M158 224 L206 270 L172 318 L228 388"/>` +
622	`<path d="M172 318 L268 300"/>` +
623	`</g>` +
624	`<g stroke="#000000" fill="none" opacity="0.35" stroke-width="4">` +
625	`<path d="M94 26 L152 74 L114 120 L164 154"/>` +
626	`<path d="M160 226 L208 272 L174 320 L230 390"/>` +
627	`</g>`
628
629func statusFields(expires int64, frozen bool) (status, edge string) {
630	if frozen {
631		return "FROZEN", "#8888aa"
632	}
633	now := time.Now().Unix()
634	grace := nsdata.GetGracePeriod()
635	switch {
636	case now < expires:
637		return "LIVE", "#4ade80"
638	case now < expires+grace:
639		return "GRACE PERIOD", "#facc15"
640	default:
641		return "LAPSED", "#f87171"
642	}
643}
644
645func escapeXML(s string) string {
646	s = strings.ReplaceAll(s, "&", "&amp;")
647	s = strings.ReplaceAll(s, "<", "&lt;")
648	s = strings.ReplaceAll(s, ">", "&gt;")
649	s = strings.ReplaceAll(s, `"`, "&quot;")
650	return s
651}