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, "&", "&")
647 s = strings.ReplaceAll(s, "<", "<")
648 s = strings.ReplaceAll(s, ">", ">")
649 s = strings.ReplaceAll(s, `"`, """)
650 return s
651}