theme.gno
7.29 Kb · 244 lines
1// Package theme is the first look of gno.land/r/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/home.
2//
3// It draws a header card as inline SVG, a divider and a footer, on top of the
4// slot machinery the home realm provides. Everything else on the page comes
5// from slots, so this file only decides HOW things are drawn, never WHAT is
6// said.
7//
8// It is a candidate, not the page. Deploying it registers it (see init); the
9// authority accepts it in a separate transaction. To change the look in a way
10// slots and style knobs cannot express, copy this directory to theme/v1, edit,
11// deploy, Accept. The home realm and every slot stay exactly where they are.
12//
13// Style knobs read from slots (all optional):
14//
15// style.accent #RRGGBB brass by default
16// style.bg #RRGGBB card background
17// style.fg #RRGGBB card text
18// style.header card | plain | none
19//
20// Widgets this theme adds to the layout, beside every slot and the home
21// realm's computed placeholders:
22//
23// :header: the card (or a plain heading, or nothing, per style.header)
24// :divider: a thin accent rule
25// :footer: realm · revision · block · chain · theme · links
26package theme
27
28import (
29 "encoding/base64"
30 "strconv"
31 "strings"
32
33 "chain/runtime"
34
35 "gno.land/r/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/home"
36)
37
38const (
39 defaultAccent = "#D9A441"
40 defaultBg = "#14161C"
41 defaultFg = "#F4EFE6"
42)
43
44// DefaultLayout is what renders when no layout slot is set. content/layout.md
45// starts as a copy of it; push that slot to depart from it.
46const DefaultLayout = `:header:
47
48<gno-columns>
49
50<gno-columns-sep />
51:intro:
52
53:links:
54</gno-columns>
55
56## Now
57
58:now:
59
60## Previously
61
62:previously:
63
64## Stack
65
66:stack:
67
68## Before the rabbit hole
69
70:before:
71
72## Numbers
73
74:numbers:
75
76:divider:
77
78:about:
79
80:footer:
81`
82
83type clockwork struct{}
84
85var instance = &clockwork{}
86
87// Instance returns the theme object, for a caller that would rather register
88// it explicitly than rely on this realm's init having done it.
89func Instance() home.Theme { return instance }
90
91// init nominates this theme. Deploying is what registers it; it serves
92// nothing until the authority runs Accept("<this path>").
93func init(cur realm) {
94 home.Register(cross(cur), instance)
95}
96
97// Render draws the profile page and hands every other path to the built-in
98// views, so the editor and the system page look the same under any theme.
99func (clockwork) Render(path string) string {
100 if path != "" {
101 return home.Fallback(path)
102 }
103 layout := home.Layout()
104 if layout == "" {
105 layout = DefaultLayout
106 }
107 f := home.NewFiller()
108 f.Add("header", header)
109 f.Add("divider", divider)
110 f.Add("footer", footer)
111 return f.Fill(layout)
112}
113
114// ---------------------------------------------------------------------------
115// Widgets
116
117func header() string {
118 name := home.Get("name")
119 if name == "" {
120 name = home.Path()
121 }
122 tagline := home.Get("tagline")
123 switch home.Style("header", "card") {
124 case "none":
125 return ""
126 case "plain":
127 out := "# " + name + "\n"
128 if tagline != "" {
129 out += "\n_" + tagline + "_\n"
130 }
131 return out
132 }
133 meta := home.Get("handle")
134 if meta != "" {
135 meta += " · "
136 }
137 meta += "rev " + strconv.Itoa(home.Revision()) +
138 " · block " + strconv.FormatInt(runtime.ChainHeight(), 10)
139 return image(name, card(name, tagline, meta))
140}
141
142func divider() string {
143 accent := color("accent", defaultAccent)
144 svg := `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 10" width="1200" height="10">` +
145 `<defs><linearGradient id="g" x1="0" x2="1"><stop offset="0" stop-color="` + accent + `"/>` +
146 `<stop offset="1" stop-color="` + accent + `" stop-opacity="0"/></linearGradient></defs>` +
147 `<rect width="1200" height="10" rx="5" fill="url(#g)"/></svg>`
148 return image("", svg)
149}
150
151func footer() string {
152 theme := home.LivePath()
153 if theme == "" {
154 theme = "built-in"
155 }
156 return "`" + home.Path() + "` · rev " + strconv.Itoa(home.Revision()) +
157 " · block " + strconv.FormatInt(runtime.ChainHeight(), 10) +
158 " · " + runtime.ChainID() +
159 " · theme `" + theme + "`" +
160 " · [slots](" + home.Link("slots") + ")" +
161 " · [edit](" + home.Link("edit") + ")" +
162 " · [system](" + home.Link("system") + ")"
163}
164
165// ---------------------------------------------------------------------------
166// SVG
167
168// card draws the header: name, tagline and a meta line on a rounded panel,
169// with a gear on the right. Text is clipped, not wrapped, so keep the
170// tagline to one line.
171func card(name, tagline, meta string) string {
172 accent := color("accent", defaultAccent)
173 bg := color("bg", defaultBg)
174 fg := color("fg", defaultFg)
175
176 var b strings.Builder
177 b.WriteString(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 300" width="1200" height="300" role="img" aria-label="`)
178 b.WriteString(esc(name))
179 b.WriteString(`"><defs><clipPath id="c"><rect width="1200" height="300" rx="24"/></clipPath>`)
180 b.WriteString(`<clipPath id="t"><rect x="64" y="30" width="820" height="250"/></clipPath></defs>`)
181 b.WriteString(`<g clip-path="url(#c)">`)
182 b.WriteString(`<rect width="1200" height="300" fill="` + bg + `"/>`)
183 b.WriteString(`<rect width="14" height="300" fill="` + accent + `"/>`)
184 // The gear: a thick dashed ring reads as teeth without a single path command.
185 b.WriteString(`<g transform="translate(1030 150)" fill="none" stroke="` + accent + `" opacity="0.9">`)
186 b.WriteString(`<circle r="82" stroke-width="30" stroke-dasharray="21 15.8"/>`)
187 b.WriteString(`<circle r="42" stroke-width="12"/>`)
188 b.WriteString(`<circle r="11" fill="` + accent + `" stroke="none"/>`)
189 b.WriteString(`</g>`)
190 b.WriteString(`<g clip-path="url(#t)" fill="` + fg + `" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif">`)
191 b.WriteString(`<text x="64" y="132" font-size="76" font-weight="700">` + esc(name) + `</text>`)
192 if tagline != "" {
193 b.WriteString(`<text x="64" y="188" font-size="30" opacity="0.85">` + esc(tagline) + `</text>`)
194 }
195 b.WriteString(`<text x="64" y="246" font-size="24" fill="` + accent + `" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">` + esc(meta) + `</text>`)
196 b.WriteString(`</g></g></svg>`)
197 return b.String()
198}
199
200// image wraps an SVG document as a markdown image with a data: URI. gnoweb
201// rejects every data: image except image/svg+xml, and gno.land's CSP allows
202// data: in img-src, so this is the one way to draw without hosting anything.
203func image(alt, svg string) string {
204 alt = strings.NewReplacer("[", "", "]", "", "\n", " ").Replace(alt)
205 return ") + ")"
207}
208
209// color returns the style knob when it is a well-formed #RRGGBB, else the
210// default. Knobs are admin-written, but a value is still pasted into markup,
211// so the shape is checked rather than trusted.
212func color(key, fallback string) string {
213 v := home.Style(key, fallback)
214 if isHexColor(v) {
215 return v
216 }
217 return fallback
218}
219
220func isHexColor(s string) bool {
221 if len(s) != 7 || s[0] != '#' {
222 return false
223 }
224 for i := 1; i < 7; i++ {
225 c := s[i]
226 switch {
227 case c >= '0' && c <= '9', c >= 'a' && c <= 'f', c >= 'A' && c <= 'F':
228 default:
229 return false
230 }
231 }
232 return true
233}
234
235// esc escapes text for an SVG element body or attribute.
236func esc(s string) string {
237 return strings.NewReplacer(
238 "&", "&",
239 "<", "<",
240 ">", ">",
241 "\"", """,
242 "\n", " ",
243 ).Replace(s)
244}