repos
/ orchard main

orchard

mirror

Every site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.

blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics

21.9 KB · 437 lines · markdown Raw History
  1# DESIGN.md
  2
  3What every site here looks like and why. Read this before changing anything
  4visual, and update it when a decision changes.
  5
  6There is no shared stylesheet and there is not going to be one. Every site owns
  7its own copy of its CSS the same way it owns its own copy of `web/`, so keeping
  8these sites looking alike is a thing people do on purpose by following this
  9file, not a thing a build step does. A change to the palette is a change in
 10five places.
 11
 12## Two phosphors
 13
 14Terminals before colour used one of two coatings, P3 amber and P1 green, and
 15that is the split the estate runs on.
 16
 17- **dash is amber.** It is the only public page with no login, it is glanced at
 18  rather than read, and it is the one site allowed to be a costume.
 19- **analytics, auth, chat, llm, logging, repos, search and status are green.** They are
 20  tools you read for minutes at a time. Green is the primary, and amber is
 21  chrome only, so caps labels, the hero eyebrow, and warnings. Amber is never
 22  the primary on any of them.
 23
 24Everything else is shared between the two: warm near black rather than pure
 25black, one monospace face, caps labels on a single tracking value, a structural
 26grid drawn in CSS, and no bitmap textures anywhere.
 27
 28## The palette
 29
 30`analytics`, `auth`, `logging` and `status` keep this in
 31`frontend/static_src/base/styles/_variables.scss`, and those four files are
 32**byte identical**. `repos` has no Sass variables file, so it carries the same
 33values as custom properties at the top of its `base.scss` and has to be edited
 34alongside them.
 35
 36| Role | Value | Where it goes |
 37|---|---|---|
 38| Ground | `#0e0d0a` | `body`, the page behind everything |
 39| Sunken | `#090806` | The navbar, the hero panel |
 40| Panel | `#1c1a15` | Cards, inputs, dropdowns, list groups |
 41| Raised | `#24211a` | Hover on a panel |
 42| Text | `#ddd7cd` | Body copy |
 43| Muted | `#b3aba2` | Secondary copy, feature descriptions |
 44| Faint | `#8a8279` | Labels, timestamps, placeholders |
 45| Green | `#6b9e78` | Primary, borders, focus |
 46| Green bright | `#7db88c` | Links, stat values, the accent word in a headline |
 47| Green hot | `#95cca2` | Link hover |
 48| Amber | `#c9a84c` | Caps labels, eyebrows, warnings |
 49| Terracotta | `#c47055` | Errors, down, over a limit |
 50| Info | `#7eaab8` | The rare thing that is neither good nor bad |
 51
 52dash keeps its own set, and amber `#ffb000` there is the primary rather than a
 53label colour.
 54
 55**search's `--faint` is not the estate's `Faint`.** It was `#7b7060` and read 4.0
 56to 1 against the ground before the overlays touched it, which is under the floor
 57for the small text it carries. The warm neutrals there are `--muted` `#c6b9a3`,
 58`--dim` `#ab9c86` and `--faint` `#9c8e7a`, and its lines are `--line` `#414e41`
 59and `--line-hot` `#5c725c`. Nothing in that bar gets the faint step.
 60
 61**The border alphas are a floor, not a preference.** Every border on the estate
 62measured between 1.04 and 1.36 to 1 before 2026-09-01, and the panels themselves
 63sat at 1.04, which is invisible under a bright light because glare adds light to
 64every pixel and the bottom of a dark palette has nowhere left to go. Card
 65borders sit at `0.42` alpha and inputs at `0.5` for that reason. Do not take
 66them back down to make something look softer, and do not introduce a new
 67hardcoded grey where a token exists.
 68
 69## Type
 70
 71One face does nearly everything.
 72
 73- **Monaspace Argon** for body, headings, labels, and code, at `0.92rem` base
 74  with `1.7` line height. It is set as both `$font-family-sans-serif` and
 75  `$font-family-monospace`, which is why the sites read as terminals without
 76  anything else having to say so.
 77- **Newsreader**, on `repos` alone, and only on repository names and the
 78  repository page heading. A serif earns its keep on a proper noun sitting above
 79  a wall of monospace and nowhere else here.
 80- Headings are weight 700 with `-0.01em` tracking. Hero titles run
 81  `clamp(2rem, 5vw, 3.2rem)`.
 82- Caps labels are `0.12em` tracked, weight 600, and amber. Every eyebrow, every
 83  feature label, every stat label, and the `// SECTION` headings in the footer.
 84
 85## The grid
 86
 87Every site draws a 32px grid on `body` in two CSS gradients and nothing else.
 88Green at `0.028` alpha on the five tools, amber at `0.022` on dash, which is
 89where the numbers differ because green reads fainter than amber at the same
 90value.
 91
 92```scss
 93background-image:
 94  linear-gradient(rgba(107, 158, 120, 0.028) 1px, transparent 1px),
 95  linear-gradient(90deg, rgba(107, 158, 120, 0.028) 1px, transparent 1px);
 96background-size: 32px 32px;
 97background-position: center top;
 98```
 99
10032px because that is the padding rhythm the panels already use, so the grid
101lines up with the layout rather than cutting across it.
102
103**search borrows dash's texture and keeps green.** It is the one green site with
104the scanlines and the vignette, which contradicts the line below and is a
105choice: it is a page you sit and read answers on rather than a table of
106numbers, so the screen feel earns its place, while the phosphor stays green
107because the reading is sustained. Its palette is its own six step warm dark
108(`--void` through `--line-hot`, plus `--bar`) rather than the four role names in
109the table above, since it was built to dash's structure with green swapped in
110for amber. It has no Sass, so the tokens are custom properties at the top of
111`static/app.css`.
112
113**A multiply overlay costs more contrast than any colour choice does.** search
114shipped with the scanlines at `0.14` and the vignette at `0.45`, and measured
115against the ground under both at a page corner its body text came out at 3.9 to
1161 where the nominal number is 15.5. The navbar was the worst of it, because the
117vignette is darkest in the corners and the corners are where a bar keeps its
118smallest text, so the archive counts measured 1.7 to 1 on screen. Three things
119fixed it and the order matters, since the first two are worth more than the
120third:
121
1221. **The header renders above the vignette.** The vignette sits at `z-index: 5`
123   and the bar at `20`, so an opaque bar is never darkened by it.
1242. **The overlays came down**, scanlines `0.14` to `0.08` and the vignette
125   `0.45` to `0.22` starting at 62% rather than 55%.
1263. **Chrome is a step lighter than the ground, not darker.** The bar was
127   `--void`, below the body colour, which reads as a hole in the page and leaves
128   everything sat on it nowhere to go. It is `--bar` at `#201d16` now, and its
129   bottom border is `--line-hot` rather than `--line`.
130
131Body text now measures 13.1 to 1 in the reading column and 8.0 at a corner, and
132nothing in the bar is below 5.5. **Re-measure with the overlays applied when
133changing any of these**, because the nominal ratio is not what a reader gets.
134
135**Scanlines and the vignette stay on dash.** They are a `multiply` blend over
136the whole page, which darkens body text, and these sites are read rather than
137glanced at. A section that wants the grid to show through sets
138`background: transparent` rather than repainting the ground colour, which is why
139`.stat-strip` is transparent.
140
141## The home page
142
143All five tools open the same way, and the home page is an advert for running the
144thing yourself rather than a dashboard. It ends by pointing at the source.
145
146The skeleton, in order:
147
1481. **Eyebrow.** A pill with a green dot, then version, stage, and `self-hosted`.
1492. **Headline.** One line, on the pattern `<thing> for operators who host their
150   own <noun>`, with the last two words in green.
1513. **Hero paragraph.** Roughly 30 words. A list of what it does, then the
152   sentence `Your data never leaves your infrastructure.`
1534. **Terminal block.** Five or six lines of the thing actually happening, in the
154   site's own vocabulary. This is the best thing on any of these pages.
1555. **Call to action.** Access dashboard, then Docs where there is one, then View
156   source.
1576. **Stat strip.** Three or four real numbers, comma grouped through `num`.
1587. **Six feature cards.** An amber caps label, a title, and two sentences.
1598. **Footer.** One paragraph, then `// Pages` and `// Operator`.
160
161**The budget is about 250 rendered words** and status is the reference at 199.
162Anything past 300 is too long. Explanation belongs on the documentation page,
163which is what it is for.
164
165`repos` shows its hero to signed out visitors only, because signed in that page
166is a working repository list and an advert would be in the way every visit.
167
168## Dashboards
169
170The home page is an advert and the pages behind it are instruments. They are
171scanned rather than read, so the craft moves from typography to information
172design: the summary comes before the detail, and state is encoded in shape as
173well as in number.
174
175**The pieces, in the order a page uses them.** A `section-label` in amber caps
176introduces a band. `metric-tile` carries a label, a value and an optional delta
177chip. `chart-panel` wraps a canvas with its own caps title. `rankList` is the
178ranked table with a bar behind each row. Findings use a severity chip, never
179colour alone.
180
181**`.metric-label` grows only inside `.metric-header`.** The tile is a column
182flex, so a bare label with `flex: 1` eats the height and drops the number to the
183bottom of the tile. That is what the bot traffic tile on analytics did until
1842026-09-01, and it only showed up next to a tall neighbour.
185
186### Charts
187
188Every chart on every site draws from `chart_theme.js`, which is duplicated byte
189for byte in analytics, logging and status the same way `shipper.go` is. There is
190no shared bundle to put it in, so it gets copied and kept in step.
191
192**Series colours are punchier than the UI palette, on purpose.** Chrome can be
193muted because nothing depends on telling two borders apart, and a doughnut slice
194does. Same hues, more chroma.
195
196| Slot | Value | Job |
197|---|---|---|
198| 1 | `#57b378` | green, and `good` |
199| 2 | `#d8a83e` | amber, and `warn` |
200| 3 | `#dc6a4b` | red, and `bad` |
201| 4 | `#63a9c9` | slate, and `info` |
202| other | `#7d7469` | the folded tail, and `muted` |
203
204**That order is measured, not chosen.** Every adjacent pair clears the normal
205vision floor, and the worst pair sits at ΔE 6.7 for protanopia, which is inside
206the 6 to 8 band that is only acceptable when something other than colour also
207separates the series. That something is the legend, which is why every chart
208here keeps one. Reordering these means measuring them again rather than eyeballing
209the result.
210
211**Anything past the fourth category folds into `other`** rather than getting a
212generated hue, because a fifth and sixth colour out of these hues is not
213distinguishable from the four already used. `foldToPalette` does it.
214
215**Status is a separate job from identity.** ERROR, WARN, 5xx and the rest are
216keyed by name and never handed out in order, so a level keeps its colour
217whatever order the server sent the rows in.
218
219**One axis, ever.** No chart here has two y scales. Two measures of different
220size are two charts.
221
222**The map is a sequential ramp,** one hue from light to dark with no second
223colour in it. Selection is a status rather than a step on that ramp, which is
224why it is the one warm colour on the map.
225
226**A narrow panel puts its legend at the bottom.** At a third of a row wide there
227is not enough room beside a doughnut, and Chart.js silently truncates the labels
228to fit, which is how "Downtime" rendered as "Downt".
229
230### Direction, on a price or a reading
231
232`dash` and `chat` both draw a line whose colour says which way a number went,
233which is a different job from telling four series apart and does not use the
234palette above. Two colours and a neutral, defined per site so each sits on its
235own ground:
236
237| Slot | dash | chat | Job |
238|---|---|---|---|
239| up | `#5ddc82` | `#7fc98f` | gained, or a reading inside its good band |
240| down | `#ff5f4d` | `#d98063` | lost, or a reading past it |
241| flat | `#8a8172` | `#9c8e7a` | no move worth colouring |
242
243**Direction colours the move and the trace, never the figure itself.** The price
244stays `--text`, because it is the number being read and the move is the
245judgement about it. A chart that coloured both would have the reader chasing the
246same information twice.
247
248**The fill under the line is the same hue at about 9% alpha**, which reads as
249the line having weight rather than as a second series.
250
251**A dotted rule across the box is what the move is measured from.** Intraday
252that is the previous close, so the shape means something on its own, and over
253any longer span there is no rule because the first bar drawn is the baseline and
254the left edge already says so.
255
256**An svg stretched to the column width cannot carry a dot.** `preserveAspectRatio`
257is `none` on these, so a circle comes out an ellipse and a cursor has to be a
258vertical rule. Anything that has to stay round is an HTML element positioned
259over the top.
260
261## Components
262
263These are measured values, not approximations. `repos` writes its CSS by hand
264and the other four go through Bootstrap, so the only way to know they agree is
265to read the computed style off a rendered page. They agree today.
266
267**Button.** One object everywhere.
268
269| | Value |
270|---|---|
271| Padding | `0.6rem 1.1rem`, and `0.25rem 0.5rem` at `.btn-sm` |
272| Font size | `0.82rem`, and `0.805rem` at `.btn-sm` |
273| Tracking | `0.04em`, and `0.02em` at `.btn-sm` |
274| Radius | `0.25rem`, and `0.2rem` at `.btn-sm` |
275| Ghost | 1px `rgba(221, 215, 205, 0.28)`, text `#ddd7cd` |
276| Primary | 1px `#6b9e78` on `rgba(107, 158, 120, 0.16)`, text `#7db88c` |
277
278`repos` defined its buttons only inside `.hero-ctas` until 2026-09-01, so the
279one on its 404 fell back to a bare element rule and matched nothing.
280
281**Borders, in three weights.** `0.2` for a rule between bands, `0.3` for the
282navbar and the edges of a page section, and `0.42` for a card or panel. Nothing
283sits below `0.2`. The navbar was on `0.06` and the footer bar on `0.04` until
2842026-09-01, which is invisible rather than subtle.
285
286**Caps labels are one solid amber,** `#c9a84c`, at `0.7rem` and `0.12em`
287tracking. The footer headings ran at `0.55` alpha and the section labels at
288`0.75`, which read as two different colours side by side.
289
290**Focus and selection.** `:focus-visible` is a 2px `#6b9e78` outline at 2px
291offset, and `::selection` is `rgba(107, 158, 120, 0.3)`. Only `repos` had either
292until 2026-09-01, so tabbing through a form on the other four got whatever
293Bootstrap did per component.
294
295**Citations take two forms and that is on purpose.** `search` writes a bare
296superscript `[3]` in `#95cca2` beside a sentence, because the passage it points
297at is on the same page and the number is a jump. `chat` boxes the number on its
298own, 1px `rgba(65, 78, 65, 0.55)` on `--panel` at `0.62rem`, raised `0.32em`,
299because it points off the site and a reader has to see it is a link worth
300tapping. Both sit at the end of the sentence they belong to and never mid
301clause.
302
303**An unread answer is a dot, never a count.** `chat` marks a conversation whose
304turn finished while the reader was somewhere else with a `0.4rem` circle in
305`--green-bright` after the title, and the same dot at `0.35rem` on the button
306that opens the sidebar, since on a phone the list it marks is off screen. A
307number would be answering a question nobody asks: how many replies are waiting
308does not change what you do about them, and the row already says which
309conversation it was.
310
311**Content width** is Bootstrap's container scale, so 1140px and then 1320px past
312a 1400px viewport. `repos` matches it with a media query rather than sitting
313narrower on a wide screen.
314
315## The footer
316
317Four columns on every site, in this order, and the headings carry the `//`
318prefix in amber caps.
319
3201. **`// <SiteName>`** with one paragraph saying what the site is for. Around 25
321   words, and it says nothing about how it is built or where it runs, which
322   helps nobody and narrows an attacker's guesswork.
3232. **`// Elsewhere`** with Portfolio, Blog and GitHub. The same three links on
324   every site, so none of them ever links to itself.
3253. **`// Pages`** with the site's own pages, ending on Source.
3264. **`// Operator`** with the signed in pages, or Sign in when signed out.
327
328Then a footer bar with `© <year> <author> · Some rights reserved` on the left
329and the GitHub mark on the right, linking to that site's own source.
330
331## The 404
332
333Identical on all four, and it uses the description the handler already sets
334rather than writing its own line:
335
336```
337404 · NOT FOUND        amber caps section label
338No such page           heading
339That page does not exist.
340← Home                 ghost button
341```
342
343Left aligned like every other page here. `logging` centred its own version with
344a different sentence and a filled button until 2026-09-01, and `repos` had a
345bare `404` with an inline link.
346
347## Rules that are easy to get wrong
348
349**A flex row gives up the label, never the reading.** `chat`'s bar holds a
350title, then the flags, the context meter, the tokens a second and the model. The
351title is the only one that can lose width and still mean something, so
352`.bar-right` is `flex-shrink: 0` and the title ellipses. Without that the
353`NO SEARCH` flag appearing took its width out of the readings, and the tokens a
354second wrapped a character at a time into a vertical column. Anything narrow
355enough to wrap in that row needs `white-space: nowrap` of its own, which
356`.model` had and `.tps` and `.meter` did not.
357
358**Nothing says where it runs.** search's landing page footer named the
359repository, the machine and the tunnel in one sentence, which is three facts a
360visitor cannot use and a stranger can. The footer rule above already said a site
361says nothing about how it is built or where it runs, and it applies to the meta
362description, the hero copy and the stat strip too, not only the footer. A stat
363strip carries numbers, so `Go / stdlib, SQLite, one binary` was not a stat, it
364was a stack disclosure sat in the slot where a number goes. The source link
365stays, since a self-hosted thing that will not show its source is not one.
366
367**`auto-fit` with four cards leaves a hole.** search's landing grid was
368`repeat(auto-fit, minmax(15rem, 1fr))`, which at the container width resolved to
369three columns and put the fourth card alone beside an empty half row. Four cards
370is `repeat(2, 1fr)` and one column on a phone. Count the cards before reaching
371for `auto-fit`.
372
373**A wordmark is not a logo.** search's brand was the word `search` at body size
374with a `` in front of it, which is smaller than the text beside it in the bar.
375It is a bordered key carrying a cursor block and the name at `0.95rem` now.
376The block does not blink, since a logo that moves pulls the eye away from the page
377every second and a bar is the one thing on screen that should sit still.
378The cursor is a drawn block rather than a glyph, since a block character renders
379at a different width in every monospace face and came out as a hairline in some,
380which is an empty box where the mark should be.
381
382**No changelog.** analytics and status each had one and both are gone as of
3832026-09-01. The newest entry was four months old, the oldest was from 2022 and
384described a Django build that no longer exists, and neither was maintained. The
385source link carries the real history. Do not add one back.
386
387**The copy has to say what the thing is now.** The analytics home claimed
388"Built in Rust, ultralight" for months after the Go rebuild, and its View source
389button pointed at an archived repository. Both were invisible because nobody
390reads their own marketing copy. When a stack changes, grep the templates.
391
392**`num` is the thousands separator, on every site.** It takes `any`, because a
393template handing an `int` to a function declared `int64` fails at render time
394with `wrong type for value`, which compiles, passes every test, and 500s the
395home page. status called it `intcomma` until 2026-09-01, and search had no such
396helper at all until 2026-09-04, so its landing page printed `6020`.
397
398**A template listed with no file behind it crash-loops.** Deleting a page means
399deleting it from `pageTemplates` in the same commit. `web.NewRenderer` resolves
400the list at boot and not at build, so the image builds fine and then dies on
401start with `pattern matches no files`.
402
403**Chrome width has to match content width.** logging's navbar and footer were
404`container-fluid` while its home page was `container`, so the hero sat narrower
405than the bar above it. All four use `container` now, and only logging's
406dashboard pages go full width inside it.
407
408**A row of unequal strings is a stack, not a flex line.** dash's severe weather
409alerts were an event name that could not wrap, an NWS headline of 114 characters
410and an end time, all on one line, and on a phone that came out as five wrapped
411lines with the time dangling underneath. They are a card now, the event and a
412severity chip, then a row of labelled values that wrap as pairs, and the stripe
413down the left carries the severity as well as the colour does, since amber
414against red is one distinction a reader may not be able to make.
415
416**Say a thing once.** That NWS headline was the event name and the end time
417again in a sentence, so the longest string on the page was mostly a repeat of
418the two short ones beside it. The row shows what is not already there instead,
419which is the counties, the forecast office and how long is left.
420
421**Do not cite this file, or anything under `code/memory`, from a comment.** This
422repository is public and the vault is not.
423
424## Per site
425
426| Site | Phosphor | Notes |
427|---|---|---|
428| `analytics` | green | Bootstrap. Home, docs, dashboards. Four stat tiles |
429| `status` | green | Bootstrap. No docs page yet, which is the one gap in the family |
430| `logging` | green | Bootstrap. Dashboards run full width inside the container |
431| `auth` | green | Bootstrap. Carries the starfield on `/login`, which the grid sits under |
432| `repos` | green | Hand written CSS, no Bootstrap, since it is dense text. Newsreader on repository names |
433| `chat` | green | Hand written CSS, no Bootstrap. A conversation, so it reads at the longest length on the estate. Citation pills and a source row under each answer, and widget panels above one when a ticker or a forecast was looked up. An unread dot on a conversation that finished a turn off screen |
434| `search` | green | Hand written CSS, no Bootstrap. The one green site with dash's scanlines and vignette, both toned down. Its bar is `--bar`, a step above the ground, and sits over the vignette |
435| `dash` | amber | Hand written CSS. Scanlines, vignette, JetBrains Mono and Space Grotesk |
436| `blog`, `isaacbythewood.com` | neither | Separate identities. Nothing here applies to them |