%unimsg 0 repo/lantern/v0
lantern @repo/lantern/v0 {
conforms -> @vocab/git-repository/v0
at 2026-09-09T04:35:22Z
completeness :complete
doc "A sample repository written to exercise a repository view: three authors, three committers, a committer who is not the author, three branches, two merges — one of them a conflict resolved by hand — an annotated tag and a lightweight one, and files in Go, Python, Markdown, JSON, CSS, PNG and CRLF batch."
head symbolic "refs/heads/main"
object-format :sha1
title "Lantern, a small link checker, complete to its second tag"
config [
| name section value
| "repositoryformatversion" "core" "0"
| "filemode" "core" "false"
| "bare" "core" "false"
| "logallrefupdates" "core" "true"
| "symlinks" "core" "false"
| "ignorecase" "core" "true"
| "name" "user" "Amina Farouk"
| "email" "user" "amina@lantern.example"
| "gpgsign" "commit" "false"
]
index {
version 2
entries [
| mode object path stage
| "100644" #sha1:b586f64b002c2c4daabce009e96f009de6420437 ".gitattributes" 0
| "100644" #sha1:c2a027e52ed4296ec37a8042b630cce9f10272ab ".gitignore" 0
| "100644" #sha1:1aedcfe6acc7752223d6b1c8c4ae4afdb3039ac0 "README.md" 0
| "100644" #sha1:d08a1cfee0db58e54bfae28f24e14a764e85be03 "config/lantern.json" 0
| "100644" #sha1:6ea9fca1cbada8676bada0670f50141688a8e9fd "go.mod" 0
| "100644" #sha1:70497f27b831e9cac402cb9ca080bbd3c18dbb05 "main.go" 0
| "100644" #sha1:f6a4fb3baad0996d703da736d47a40de3225aae0 "scripts/build.bat" 0
| "100644" #sha1:ad9ad5c53d90d5e7115d22f5c3db50e84f031455 "tools/report.py" 0
| "100644" #sha1:ffc254b94a38c32c9a237fc8a4d33eb7f17c312b "web/logo.png" 0
| "100644" #sha1:a334080c02576c8285d7a206d965e5766dbb702c "web/style.css" 0
]
}
local-files {
description {
content "Unnamed repository; edit this file 'description' to name the repository.
"
}
info/exclude {
content "# git ls-files --others --exclude-from=.git/info/exclude
# Lines that start with '#' are comments.
# For a project mostly in C, the following would be a good set of
# exclude patterns (uncomment them if you want to use them):
# *.[oa]
# *~
"
}
}
objects {
"0328fdd7ff3a61bf4f4c8764a343450a4666973f" {
kind :tree
entries [
{
mode "100644"
name "style.css"
object #sha1:a334080c02576c8285d7a206d965e5766dbb702c
}
]
}
"06aec959ba02fa5d5c0e8feaa36689b8dc4a4006" {
kind :tree
entries [
| mode name object
| "100644" ".gitattributes" #sha1:b586f64b002c2c4daabce009e96f009de6420437
| "100644" ".gitignore" #sha1:c2a027e52ed4296ec37a8042b630cce9f10272ab
| "100644" "README.md" #sha1:152322abd7af7e068e6e2209b1d2faa640dbb122
| "40000" "config" #sha1:109a7538249b4898d923772e206bfff91881f7e1
| "100644" "go.mod" #sha1:6ea9fca1cbada8676bada0670f50141688a8e9fd
| "100644" "main.go" #sha1:70497f27b831e9cac402cb9ca080bbd3c18dbb05
| "40000" "scripts" #sha1:5a407cf3afa741546c1f60298d2da6df7b3cece8
| "40000" "tools" #sha1:8763870a51bfed27298f6cf86ee26088931de2bf
| "40000" "web" #sha1:2fd796ef433089dc8f5d18729d2cf2b192d9fdca
]
}
"0834e04ac145c7004a503eaa7cc1bbaa58d4814d" {
kind :blob
content "{
\"follow-external\": false,
\"extensions\": [\".html\", \".htm\"],
\"ignore\": [\"node_modules\", \".git\"],
\"report\": {
\"format\": \"html\",
\"stylesheet\": \"web/style.css\"
}
}
"
}
"0e7ee37b450c11248fd1ac8e7dacf76e891bcf73" {
kind :commit
parents [ #sha1:709cfc56fbbb69306c4e3c1b1b3640ecd7f7bd32 ]
tree #sha1:55e0085a1dcbc751eb9934e08144bc3691ba8bc2
author {
email "jonas@lantern.example"
name "Jonas Bergström"
offset "+0000"
when 1741773900
}
committer {
email "jonas@lantern.example"
name "Jonas Bergström"
offset "+0000"
when 1741773900
}
message "A Windows build script, CRLF and all
"
}
"109a7538249b4898d923772e206bfff91881f7e1" {
kind :tree
entries [
{
mode "100644"
name "lantern.json"
object #sha1:0834e04ac145c7004a503eaa7cc1bbaa58d4814d
}
]
}
"152322abd7af7e068e6e2209b1d2faa640dbb122" {
kind :blob
content "# Lantern
A small link checker for static sites. Point it at a directory of HTML and it
reports every link that does not resolve.
## Running
go run . ./site
Options live in `config/lantern.json`.
<<<<<<< HEAD
## Reporting
go run . ./site | python tools/report.py
=======
On Windows, `scripts\\build.bat` produces `lantern.exe`.
>>>>>>> fix/crlf
## Layout
| path | what it is |
|----------------------|----------------------------------|
| `main.go` | the checker |
| `tools/report.py` | turns a run into a summary |
| `config/` | defaults |
| `web/` | the report's stylesheet and mark |
"
}
"1aedcfe6acc7752223d6b1c8c4ae4afdb3039ac0" {
kind :blob
content "# Lantern
A small link checker for static sites. Point it at a directory of HTML and it
reports every link that does not resolve.
## Running
go run . ./site
Options live in `config/lantern.json`.
## Reporting
go run . ./site | python tools/report.py
On Windows, `scripts\\build.bat` produces `lantern.exe`.
## Layout
| path | what it is |
|----------------------|----------------------------------|
| `main.go` | the checker |
| `tools/report.py` | turns a run into a summary |
| `config/` | defaults |
| `web/` | the report's stylesheet and mark |
"
}
"2fd796ef433089dc8f5d18729d2cf2b192d9fdca" {
kind :tree
entries [
| mode name object
| "100644" "logo.png" #sha1:ffc254b94a38c32c9a237fc8a4d33eb7f17c312b
| "100644" "style.css" #sha1:a334080c02576c8285d7a206d965e5766dbb702c
]
}
"43277571a83a9de23c5c81760e25a34b97a0fff9" {
kind :tree
entries [
| mode name object
| "100644" ".gitignore" #sha1:c2a027e52ed4296ec37a8042b630cce9f10272ab
| "100644" "README.md" #sha1:d152a64a07c6261369a1fadf0a7eee0823e91eba
| "40000" "config" #sha1:109a7538249b4898d923772e206bfff91881f7e1
| "100644" "go.mod" #sha1:6ea9fca1cbada8676bada0670f50141688a8e9fd
| "100644" "main.go" #sha1:70497f27b831e9cac402cb9ca080bbd3c18dbb05
| "40000" "scripts" #sha1:5a407cf3afa741546c1f60298d2da6df7b3cece8
| "40000" "tools" #sha1:8763870a51bfed27298f6cf86ee26088931de2bf
| "40000" "web" #sha1:2fd796ef433089dc8f5d18729d2cf2b192d9fdca
]
}
"47e6beee7bdf9fbae813b7f638e2b39508d7e926" {
kind :tree
entries [
| mode name object
| "100644" ".gitattributes" #sha1:b586f64b002c2c4daabce009e96f009de6420437
| "100644" ".gitignore" #sha1:c2a027e52ed4296ec37a8042b630cce9f10272ab
| "100644" "README.md" #sha1:d152a64a07c6261369a1fadf0a7eee0823e91eba
| "40000" "config" #sha1:109a7538249b4898d923772e206bfff91881f7e1
| "100644" "go.mod" #sha1:6ea9fca1cbada8676bada0670f50141688a8e9fd
| "100644" "main.go" #sha1:70497f27b831e9cac402cb9ca080bbd3c18dbb05
| "40000" "scripts" #sha1:5a407cf3afa741546c1f60298d2da6df7b3cece8
| "40000" "tools" #sha1:8763870a51bfed27298f6cf86ee26088931de2bf
| "40000" "web" #sha1:2fd796ef433089dc8f5d18729d2cf2b192d9fdca
]
}
"55e0085a1dcbc751eb9934e08144bc3691ba8bc2" {
kind :tree
entries [
| mode name object
| "100644" ".gitignore" #sha1:c2a027e52ed4296ec37a8042b630cce9f10272ab
| "100644" "README.md" #sha1:d152a64a07c6261369a1fadf0a7eee0823e91eba
| "40000" "config" #sha1:109a7538249b4898d923772e206bfff91881f7e1
| "100644" "go.mod" #sha1:6ea9fca1cbada8676bada0670f50141688a8e9fd
| "100644" "main.go" #sha1:70497f27b831e9cac402cb9ca080bbd3c18dbb05
| "40000" "scripts" #sha1:5a407cf3afa741546c1f60298d2da6df7b3cece8
]
}
"5a407cf3afa741546c1f60298d2da6df7b3cece8" {
kind :tree
entries [
{
mode "100644"
name "build.bat"
object #sha1:f6a4fb3baad0996d703da736d47a40de3225aae0
}
]
}
"5dabc659fee85ca6cdf9b97cc4eed1045a3241e1" {
kind :commit
parents [ #sha1:7dca987561fd84764731e0629d18fa4bb9e339bb ]
tree #sha1:47e6beee7bdf9fbae813b7f638e2b39508d7e926
author {
email "priya@lantern.example"
name "Priya Nair"
offset "+0000"
when 1741958100
}
committer {
email "jonas@lantern.example"
name "Jonas Bergström"
offset "+0000"
when 1741958100
}
message "Say which files are text and which are not
"
}
"6352f66b6eb12f648028f13e8ba1a459cc47ad0f" {
kind :commit
parents [ #sha1:709cfc56fbbb69306c4e3c1b1b3640ecd7f7bd32 ]
tree #sha1:9fa004b4a55360baefea4bb3b6382a015029aa98
author {
email "priya@lantern.example"
name "Priya Nair"
offset "+0000"
when 1741681500
}
committer {
email "amina@lantern.example"
name "Amina Farouk"
offset "+0000"
when 1741681500
}
message "A report the reader can skim, and a stylesheet for it
"
}
"638dacd07e35859a2d1165a864ac4dfdb76393ec" {
kind :tag
object #sha1:709cfc56fbbb69306c4e3c1b1b3640ecd7f7bd32
tag "v0.1.0"
type :commit
message "First run that finds anything
"
tagger {
email "amina@lantern.example"
name "Amina Farouk"
offset "+0000"
when 1741433400
}
}
"647493ad5d3c1083ec4f94004e441bacc1701d19" {
kind :tree
entries [
| mode name object
| "100644" ".gitattributes" #sha1:b586f64b002c2c4daabce009e96f009de6420437
| "100644" ".gitignore" #sha1:c2a027e52ed4296ec37a8042b630cce9f10272ab
| "100644" "README.md" #sha1:e7c6f45523b0fd558fca8d2672361c9539980fe8
| "40000" "config" #sha1:109a7538249b4898d923772e206bfff91881f7e1
| "100644" "go.mod" #sha1:6ea9fca1cbada8676bada0670f50141688a8e9fd
| "100644" "main.go" #sha1:70497f27b831e9cac402cb9ca080bbd3c18dbb05
| "40000" "scripts" #sha1:5a407cf3afa741546c1f60298d2da6df7b3cece8
| "40000" "tools" #sha1:8763870a51bfed27298f6cf86ee26088931de2bf
| "40000" "web" #sha1:2fd796ef433089dc8f5d18729d2cf2b192d9fdca
]
}
"6cdd013273b025e9f4259f7810f7228f2cae84b4" {
kind :commit
parents [ #sha1:6352f66b6eb12f648028f13e8ba1a459cc47ad0f ]
tree #sha1:f658c2106a9ba4b8294bd3fb5441694e71c609ad
author {
email "priya@lantern.example"
name "Priya Nair"
offset "+0000"
when 1741711680
}
committer {
email "priya@lantern.example"
name "Priya Nair"
offset "+0000"
when 1741711680
}
message "A mark for the report header
"
}
"6ea9fca1cbada8676bada0670f50141688a8e9fd" {
kind :blob
content "module lantern
go 1.22
"
}
"70497f27b831e9cac402cb9ca080bbd3c18dbb05" {
kind :blob
content "// Command lantern reports links in a directory of HTML that do not resolve.
package main
import (
\t\"fmt\"
\t\"os\"
\t\"path/filepath\"
\t\"strings\"
)
func main() {
\tif len(os.Args) < 2 {
\t\tfmt.Fprintln(os.Stderr, \"usage: lantern
\")
\t\tos.Exit(2)
\t}
\troot := os.Args[1]
\tbad := 0
\terr := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
\t\tif err != nil || info.IsDir() || !strings.HasSuffix(path, \".html\") {
\t\t\treturn err
\t\t}
\t\tb, err := os.ReadFile(path)
\t\tif err != nil {
\t\t\treturn err
\t\t}
\t\tfor _, href := range hrefs(string(b)) {
\t\t\tif strings.HasPrefix(href, \"http\") || strings.HasPrefix(href, \"#\") {
\t\t\t\tcontinue
\t\t\t}
\t\t\ttarget := filepath.Join(filepath.Dir(path), href)
\t\t\tif _, err := os.Stat(target); err != nil {
\t\t\t\tfmt.Printf(\"%s -> %s\\n\", path, href)
\t\t\t\tbad++
\t\t\t}
\t\t}
\t\treturn nil
\t})
\tif err != nil {
\t\tfmt.Fprintln(os.Stderr, \"lantern:\", err)
\t\tos.Exit(1)
\t}
\tfmt.Printf(\"%d broken\\n\", bad)
}
func hrefs(s string) []string {
\tvar out []string
\tfor {
\t\ti := strings.Index(s, `href=\"`)
\t\tif i < 0 {
\t\t\treturn out
\t\t}
\t\ts = s[i+6:]
\t\tj := strings.IndexByte(s, '\"')
\t\tif j < 0 {
\t\t\treturn out
\t\t}
\t\tout = append(out, s[:j])
\t\ts = s[j:]
\t}
}
"
}
"709cfc56fbbb69306c4e3c1b1b3640ecd7f7bd32" {
kind :commit
parents [ #sha1:7e5be0e7bcde0697eb6c03e9a4ded2056caad151 ]
tree #sha1:f586bbd016aca5eac1c0c4bfe14e74001fba9988
author {
email "jonas@lantern.example"
name "Jonas Bergström"
offset "+0000"
when 1741431720
}
committer {
email "jonas@lantern.example"
name "Jonas Bergström"
offset "+0000"
when 1741431720
}
message "Defaults, so the flags have somewhere to come from
"
}
"7753026ad3953f3420a5abb64cc201eb87a109a5" {
kind :tree
entries [
| mode name object
| "100644" ".gitignore" #sha1:c2a027e52ed4296ec37a8042b630cce9f10272ab
| "100644" "README.md" #sha1:d152a64a07c6261369a1fadf0a7eee0823e91eba
| "100644" "go.mod" #sha1:6ea9fca1cbada8676bada0670f50141688a8e9fd
| "100644" "main.go" #sha1:70497f27b831e9cac402cb9ca080bbd3c18dbb05
]
}
"7dca987561fd84764731e0629d18fa4bb9e339bb" {
kind :commit
tree #sha1:43277571a83a9de23c5c81760e25a34b97a0fff9
author {
email "amina@lantern.example"
name "Amina Farouk"
offset "+0000"
when 1741858200
}
committer {
email "amina@lantern.example"
name "Amina Farouk"
offset "+0000"
when 1741858200
}
message "Merge feature/report: summaries and a stylesheet
"
parents [
#sha1:0e7ee37b450c11248fd1ac8e7dacf76e891bcf73
#sha1:6cdd013273b025e9f4259f7810f7228f2cae84b4
]
}
"7e5be0e7bcde0697eb6c03e9a4ded2056caad151" {
kind :commit
parents [ #sha1:91631bc029f3be10e6d650ea26b30267de7ea50c ]
tree #sha1:7753026ad3953f3420a5abb64cc201eb87a109a5
author {
email "amina@lantern.example"
name "Amina Farouk"
offset "+0000"
when 1741185600
}
committer {
email "amina@lantern.example"
name "Amina Farouk"
offset "+0000"
when 1741185600
}
message "The checker itself
"
}
"8763870a51bfed27298f6cf86ee26088931de2bf" {
kind :tree
entries [
{
mode "100644"
name "report.py"
object #sha1:ad9ad5c53d90d5e7115d22f5c3db50e84f031455
}
]
}
"91631bc029f3be10e6d650ea26b30267de7ea50c" {
kind :commit
tree #sha1:c0f912d6168873db2cc7194355dcbc3b56f63431
author {
email "amina@lantern.example"
name "Amina Farouk"
offset "+0000"
when 1741079520
}
committer {
email "amina@lantern.example"
name "Amina Farouk"
offset "+0000"
when 1741079520
}
message "Start lantern: readme and ignores
"
}
"9618139d007a6ea3c57c3258767ff5687228a896" {
kind :commit
parents [ #sha1:7dca987561fd84764731e0629d18fa4bb9e339bb ]
tree #sha1:da3cae99528a6e8bdb40013ddbfec61cdb70b933
author {
email "amina@lantern.example"
name "Amina Farouk"
offset "+0000"
when 1742058000
}
committer {
email "amina@lantern.example"
name "Amina Farouk"
offset "+0000"
when 1742058000
}
message "Show how the two halves are used together
"
}
"9fa004b4a55360baefea4bb3b6382a015029aa98" {
kind :tree
entries [
| mode name object
| "100644" ".gitignore" #sha1:c2a027e52ed4296ec37a8042b630cce9f10272ab
| "100644" "README.md" #sha1:d152a64a07c6261369a1fadf0a7eee0823e91eba
| "40000" "config" #sha1:109a7538249b4898d923772e206bfff91881f7e1
| "100644" "go.mod" #sha1:6ea9fca1cbada8676bada0670f50141688a8e9fd
| "100644" "main.go" #sha1:70497f27b831e9cac402cb9ca080bbd3c18dbb05
| "40000" "tools" #sha1:8763870a51bfed27298f6cf86ee26088931de2bf
| "40000" "web" #sha1:0328fdd7ff3a61bf4f4c8764a343450a4666973f
]
}
a334080c02576c8285d7a206d965e5766dbb702c {
kind :blob
content ":root {
--ink: #1b1b1f;
--paper: #fbfbf8;
--broken: #b3261e;
--rule: #d7d7d0;
}
body {
background: var(--paper);
color: var(--ink);
font: 15px/1.55 ui-serif, Georgia, serif;
margin: 0 auto;
max-width: 48rem;
padding: 3rem 1.5rem;
}
h1 { font-size: 1.6rem; font-weight: 600; letter-spacing: -0.01em; }
.page { border-top: 1px solid var(--rule); padding: 0.75rem 0; }
.page a { color: var(--broken); text-decoration: underline wavy; }
.count { color: #6b6b70; font-variant-numeric: tabular-nums; }
"
}
ad9ad5c53d90d5e7115d22f5c3db50e84f031455 {
kind :blob
content "\"\"\"Turn a lantern run into a summary a person can read.
Reads the tool's stdout on standard input and groups the broken links by the
page that holds them, because a page with nine dead links is one problem and
nine pages with one each are nine.
\"\"\"
import collections
import json
import sys
def main() -> int:
pages = collections.defaultdict(list)
for line in sys.stdin:
line = line.strip()
if \" -> \" not in line:
continue
page, href = line.split(\" -> \", 1)
pages[page].append(href)
if not pages:
print(\"nothing broken\")
return 0
for page, hrefs in sorted(pages.items(), key=lambda kv: -len(kv[1])):
print(f\"{page} ({len(hrefs)})\")
for href in sorted(hrefs):
print(f\" {href}\")
with open(\"lantern-report.json\", \"w\", encoding=\"utf-8\") as f:
json.dump(pages, f, indent=2, sort_keys=True)
return 1
if __name__ == \"__main__\":
sys.exit(main())
"
}
b586f64b002c2c4daabce009e96f009de6420437 {
kind :blob
content "* text=auto eol=lf
*.bat text eol=crlf
*.png binary
"
}
b5c82f28f358c1f4780ea909f84263d52fce6ece {
kind :commit
parents [ #sha1:5dabc659fee85ca6cdf9b97cc4eed1045a3241e1 ]
tree #sha1:647493ad5d3c1083ec4f94004e441bacc1701d19
author {
email "priya@lantern.example"
name "Priya Nair"
offset "+0000"
when 1741958520
}
committer {
email "jonas@lantern.example"
name "Jonas Bergström"
offset "+0000"
when 1741958520
}
message "Mention the Windows build in the readme
"
}
b650a8976225245b5df1155c0222f4df12f02637 {
kind :tree
entries [
{
mode "100644"
name "lantern.json"
object #sha1:d08a1cfee0db58e54bfae28f24e14a764e85be03
}
]
}
b7fbe8b2e8f67fd8dbefdc1bddd421d80e79e6ec {
kind :commit
tree #sha1:f1533f4bf6c8c81a44b0e45fb123421324303108
author {
email "amina@lantern.example"
name "Amina Farouk"
offset "+0000"
when 1742114400
}
committer {
email "amina@lantern.example"
name "Amina Farouk"
offset "+0000"
when 1742114400
}
message "Merge fix/crlf
# Conflicts:
#\tREADME.md
"
parents [
#sha1:9618139d007a6ea3c57c3258767ff5687228a896
#sha1:b5c82f28f358c1f4780ea909f84263d52fce6ece
]
}
c0f912d6168873db2cc7194355dcbc3b56f63431 {
kind :tree
entries [
| mode name object
| "100644" ".gitignore" #sha1:c2a027e52ed4296ec37a8042b630cce9f10272ab
| "100644" "README.md" #sha1:d152a64a07c6261369a1fadf0a7eee0823e91eba
]
}
c2a027e52ed4296ec37a8042b630cce9f10272ab {
kind :blob
content "/lantern
/lantern.exe
*.log
"
}
d08a1cfee0db58e54bfae28f24e14a764e85be03 {
kind :blob
content "{
\"follow-external\": false,
\"extensions\": [\".html\", \".htm\"],
\"ignore\": [\"node_modules\", \".git\"],
\"report\": {
\"format\": \"html\",
\"group-by-page\": true,
\"stylesheet\": \"web/style.css\"
}
}
"
}
d152a64a07c6261369a1fadf0a7eee0823e91eba {
kind :blob
content "# Lantern
A small link checker for static sites. Point it at a directory of HTML and it
reports every link that does not resolve.
## Running
go run . ./site
Options live in `config/lantern.json`.
## Layout
| path | what it is |
|----------------------|----------------------------------|
| `main.go` | the checker |
| `tools/report.py` | turns a run into a summary |
| `config/` | defaults |
| `web/` | the report's stylesheet and mark |
"
}
da3cae99528a6e8bdb40013ddbfec61cdb70b933 {
kind :tree
entries [
| mode name object
| "100644" ".gitignore" #sha1:c2a027e52ed4296ec37a8042b630cce9f10272ab
| "100644" "README.md" #sha1:dc68d3adf91ee1eeb972d67a499d3f48552695db
| "40000" "config" #sha1:109a7538249b4898d923772e206bfff91881f7e1
| "100644" "go.mod" #sha1:6ea9fca1cbada8676bada0670f50141688a8e9fd
| "100644" "main.go" #sha1:70497f27b831e9cac402cb9ca080bbd3c18dbb05
| "40000" "scripts" #sha1:5a407cf3afa741546c1f60298d2da6df7b3cece8
| "40000" "tools" #sha1:8763870a51bfed27298f6cf86ee26088931de2bf
| "40000" "web" #sha1:2fd796ef433089dc8f5d18729d2cf2b192d9fdca
]
}
dc68d3adf91ee1eeb972d67a499d3f48552695db {
kind :blob
content "# Lantern
A small link checker for static sites. Point it at a directory of HTML and it
reports every link that does not resolve.
## Running
go run . ./site
Options live in `config/lantern.json`.
## Reporting
go run . ./site | python tools/report.py
## Layout
| path | what it is |
|----------------------|----------------------------------|
| `main.go` | the checker |
| `tools/report.py` | turns a run into a summary |
| `config/` | defaults |
| `web/` | the report's stylesheet and mark |
"
}
e7c6f45523b0fd558fca8d2672361c9539980fe8 {
kind :blob
content "# Lantern
A small link checker for static sites. Point it at a directory of HTML and it
reports every link that does not resolve.
## Running
go run . ./site
Options live in `config/lantern.json`.
On Windows, `scripts\\build.bat` produces `lantern.exe`.
## Layout
| path | what it is |
|----------------------|----------------------------------|
| `main.go` | the checker |
| `tools/report.py` | turns a run into a summary |
| `config/` | defaults |
| `web/` | the report's stylesheet and mark |
"
}
f00a4a846dbbba36012025800148d68f6b15ba25 {
kind :commit
parents [ #sha1:b7fbe8b2e8f67fd8dbefdc1bddd421d80e79e6ec ]
tree #sha1:f407f5d6102cd7493b99d3062d73c2b78aee2be4
author {
email "jonas@lantern.example"
name "Jonas Bergström"
offset "+0000"
when 1742212800
}
committer {
email "jonas@lantern.example"
name "Jonas Bergström"
offset "+0000"
when 1742212800
}
message "Group the report by page, matching tools/report.py
"
}
f1533f4bf6c8c81a44b0e45fb123421324303108 {
kind :tree
entries [
| mode name object
| "100644" ".gitattributes" #sha1:b586f64b002c2c4daabce009e96f009de6420437
| "100644" ".gitignore" #sha1:c2a027e52ed4296ec37a8042b630cce9f10272ab
| "100644" "README.md" #sha1:1aedcfe6acc7752223d6b1c8c4ae4afdb3039ac0
| "40000" "config" #sha1:109a7538249b4898d923772e206bfff91881f7e1
| "100644" "go.mod" #sha1:6ea9fca1cbada8676bada0670f50141688a8e9fd
| "100644" "main.go" #sha1:70497f27b831e9cac402cb9ca080bbd3c18dbb05
| "40000" "scripts" #sha1:5a407cf3afa741546c1f60298d2da6df7b3cece8
| "40000" "tools" #sha1:8763870a51bfed27298f6cf86ee26088931de2bf
| "40000" "web" #sha1:2fd796ef433089dc8f5d18729d2cf2b192d9fdca
]
}
f407f5d6102cd7493b99d3062d73c2b78aee2be4 {
kind :tree
entries [
| mode name object
| "100644" ".gitattributes" #sha1:b586f64b002c2c4daabce009e96f009de6420437
| "100644" ".gitignore" #sha1:c2a027e52ed4296ec37a8042b630cce9f10272ab
| "100644" "README.md" #sha1:1aedcfe6acc7752223d6b1c8c4ae4afdb3039ac0
| "40000" "config" #sha1:b650a8976225245b5df1155c0222f4df12f02637
| "100644" "go.mod" #sha1:6ea9fca1cbada8676bada0670f50141688a8e9fd
| "100644" "main.go" #sha1:70497f27b831e9cac402cb9ca080bbd3c18dbb05
| "40000" "scripts" #sha1:5a407cf3afa741546c1f60298d2da6df7b3cece8
| "40000" "tools" #sha1:8763870a51bfed27298f6cf86ee26088931de2bf
| "40000" "web" #sha1:2fd796ef433089dc8f5d18729d2cf2b192d9fdca
]
}
f586bbd016aca5eac1c0c4bfe14e74001fba9988 {
kind :tree
entries [
| mode name object
| "100644" ".gitignore" #sha1:c2a027e52ed4296ec37a8042b630cce9f10272ab
| "100644" "README.md" #sha1:d152a64a07c6261369a1fadf0a7eee0823e91eba
| "40000" "config" #sha1:109a7538249b4898d923772e206bfff91881f7e1
| "100644" "go.mod" #sha1:6ea9fca1cbada8676bada0670f50141688a8e9fd
| "100644" "main.go" #sha1:70497f27b831e9cac402cb9ca080bbd3c18dbb05
]
}
f658c2106a9ba4b8294bd3fb5441694e71c609ad {
kind :tree
entries [
| mode name object
| "100644" ".gitignore" #sha1:c2a027e52ed4296ec37a8042b630cce9f10272ab
| "100644" "README.md" #sha1:d152a64a07c6261369a1fadf0a7eee0823e91eba
| "40000" "config" #sha1:109a7538249b4898d923772e206bfff91881f7e1
| "100644" "go.mod" #sha1:6ea9fca1cbada8676bada0670f50141688a8e9fd
| "100644" "main.go" #sha1:70497f27b831e9cac402cb9ca080bbd3c18dbb05
| "40000" "tools" #sha1:8763870a51bfed27298f6cf86ee26088931de2bf
| "40000" "web" #sha1:2fd796ef433089dc8f5d18729d2cf2b192d9fdca
]
}
f6a4fb3baad0996d703da736d47a40de3225aae0 {
kind :blob
content "@echo off
REM Build lantern on Windows. CRLF on purpose: cmd.exe wants it.
setlocal
go build -o lantern.exe .
if errorlevel 1 exit /b 1
echo built lantern.exe
"
}
ffc254b94a38c32c9a237fc8a4d33eb7f17c312b {
content ~hex:89504e470d0a1a0a0000000d494844520000001000000010080200000090916836000000234944415478da63909696270931d051c3ff6b697848ec3610a367d486c166c3a0497c00af37b3edb7e995620000000049454e44ae426082
kind :blob
}
}
pseudo-refs {
ORIG_HEAD #sha1:9618139d007a6ea3c57c3258767ff5687228a896
}
reflogs {
HEAD [
{
message "commit (initial): Start lantern: readme and ignores"
new #sha1:91631bc029f3be10e6d650ea26b30267de7ea50c
old #sha1:0000000000000000000000000000000000000000
who {
email "amina@lantern.example"
name "Amina Farouk"
offset "+0000"
when 1741079520
}
}
{
message "commit: The checker itself"
new #sha1:7e5be0e7bcde0697eb6c03e9a4ded2056caad151
old #sha1:91631bc029f3be10e6d650ea26b30267de7ea50c
who {
email "amina@lantern.example"
name "Amina Farouk"
offset "+0000"
when 1741185600
}
}
{
message "commit: Defaults, so the flags have somewhere to come from"
new #sha1:709cfc56fbbb69306c4e3c1b1b3640ecd7f7bd32
old #sha1:7e5be0e7bcde0697eb6c03e9a4ded2056caad151
who {
email "jonas@lantern.example"
name "Jonas Bergström"
offset "+0000"
when 1741431720
}
}
{
message "checkout: moving from main to feature/report"
new #sha1:709cfc56fbbb69306c4e3c1b1b3640ecd7f7bd32
old #sha1:709cfc56fbbb69306c4e3c1b1b3640ecd7f7bd32
who {
email "amina@lantern.example"
name "Amina Farouk"
offset "+1000"
when 1788928503
}
}
{
message "commit: A report the reader can skim, and a stylesheet for it"
new #sha1:6352f66b6eb12f648028f13e8ba1a459cc47ad0f
old #sha1:709cfc56fbbb69306c4e3c1b1b3640ecd7f7bd32
who {
email "amina@lantern.example"
name "Amina Farouk"
offset "+0000"
when 1741681500
}
}
{
message "commit: A mark for the report header"
new #sha1:6cdd013273b025e9f4259f7810f7228f2cae84b4
old #sha1:6352f66b6eb12f648028f13e8ba1a459cc47ad0f
who {
email "priya@lantern.example"
name "Priya Nair"
offset "+0000"
when 1741711680
}
}
{
message "checkout: moving from feature/report to main"
new #sha1:709cfc56fbbb69306c4e3c1b1b3640ecd7f7bd32
old #sha1:6cdd013273b025e9f4259f7810f7228f2cae84b4
who {
email "amina@lantern.example"
name "Amina Farouk"
offset "+1000"
when 1788928503
}
}
{
message "commit: A Windows build script, CRLF and all"
new #sha1:0e7ee37b450c11248fd1ac8e7dacf76e891bcf73
old #sha1:709cfc56fbbb69306c4e3c1b1b3640ecd7f7bd32
who {
email "jonas@lantern.example"
name "Jonas Bergström"
offset "+0000"
when 1741773900
}
}
{
message "merge feature/report: Merge made by the 'ort' strategy."
new #sha1:7dca987561fd84764731e0629d18fa4bb9e339bb
old #sha1:0e7ee37b450c11248fd1ac8e7dacf76e891bcf73
who {
email "amina@lantern.example"
name "Amina Farouk"
offset "+0000"
when 1741858200
}
}
{
message "checkout: moving from main to fix/crlf"
new #sha1:7dca987561fd84764731e0629d18fa4bb9e339bb
old #sha1:7dca987561fd84764731e0629d18fa4bb9e339bb
who {
email "amina@lantern.example"
name "Amina Farouk"
offset "+1000"
when 1788928503
}
}
{
message "commit: Say which files are text and which are not"
new #sha1:5dabc659fee85ca6cdf9b97cc4eed1045a3241e1
old #sha1:7dca987561fd84764731e0629d18fa4bb9e339bb
who {
email "jonas@lantern.example"
name "Jonas Bergström"
offset "+0000"
when 1741958100
}
}
{
message "commit: Mention the Windows build in the readme"
new #sha1:b5c82f28f358c1f4780ea909f84263d52fce6ece
old #sha1:5dabc659fee85ca6cdf9b97cc4eed1045a3241e1
who {
email "jonas@lantern.example"
name "Jonas Bergström"
offset "+0000"
when 1741958520
}
}
{
message "checkout: moving from fix/crlf to main"
new #sha1:7dca987561fd84764731e0629d18fa4bb9e339bb
old #sha1:b5c82f28f358c1f4780ea909f84263d52fce6ece
who {
email "amina@lantern.example"
name "Amina Farouk"
offset "+1000"
when 1788928504
}
}
{
message "commit: Show how the two halves are used together"
new #sha1:9618139d007a6ea3c57c3258767ff5687228a896
old #sha1:7dca987561fd84764731e0629d18fa4bb9e339bb
who {
email "amina@lantern.example"
name "Amina Farouk"
offset "+0000"
when 1742058000
}
}
{
message "commit (merge): Merge fix/crlf"
new #sha1:b7fbe8b2e8f67fd8dbefdc1bddd421d80e79e6ec
old #sha1:9618139d007a6ea3c57c3258767ff5687228a896
who {
email "amina@lantern.example"
name "Amina Farouk"
offset "+0000"
when 1742114400
}
}
{
message "commit: Group the report by page, matching tools/report.py"
new #sha1:f00a4a846dbbba36012025800148d68f6b15ba25
old #sha1:b7fbe8b2e8f67fd8dbefdc1bddd421d80e79e6ec
who {
email "jonas@lantern.example"
name "Jonas Bergström"
offset "+0000"
when 1742212800
}
}
]
refs/heads/feature/report [
{
message "branch: Created from HEAD"
new #sha1:709cfc56fbbb69306c4e3c1b1b3640ecd7f7bd32
old #sha1:0000000000000000000000000000000000000000
who {
email "amina@lantern.example"
name "Amina Farouk"
offset "+1000"
when 1788928503
}
}
{
message "commit: A report the reader can skim, and a stylesheet for it"
new #sha1:6352f66b6eb12f648028f13e8ba1a459cc47ad0f
old #sha1:709cfc56fbbb69306c4e3c1b1b3640ecd7f7bd32
who {
email "amina@lantern.example"
name "Amina Farouk"
offset "+0000"
when 1741681500
}
}
{
message "commit: A mark for the report header"
new #sha1:6cdd013273b025e9f4259f7810f7228f2cae84b4
old #sha1:6352f66b6eb12f648028f13e8ba1a459cc47ad0f
who {
email "priya@lantern.example"
name "Priya Nair"
offset "+0000"
when 1741711680
}
}
]
refs/heads/fix/crlf [
{
message "branch: Created from HEAD"
new #sha1:7dca987561fd84764731e0629d18fa4bb9e339bb
old #sha1:0000000000000000000000000000000000000000
who {
email "amina@lantern.example"
name "Amina Farouk"
offset "+1000"
when 1788928503
}
}
{
message "commit: Say which files are text and which are not"
new #sha1:5dabc659fee85ca6cdf9b97cc4eed1045a3241e1
old #sha1:7dca987561fd84764731e0629d18fa4bb9e339bb
who {
email "jonas@lantern.example"
name "Jonas Bergström"
offset "+0000"
when 1741958100
}
}
{
message "commit: Mention the Windows build in the readme"
new #sha1:b5c82f28f358c1f4780ea909f84263d52fce6ece
old #sha1:5dabc659fee85ca6cdf9b97cc4eed1045a3241e1
who {
email "jonas@lantern.example"
name "Jonas Bergström"
offset "+0000"
when 1741958520
}
}
]
refs/heads/main [
{
message "commit (initial): Start lantern: readme and ignores"
new #sha1:91631bc029f3be10e6d650ea26b30267de7ea50c
old #sha1:0000000000000000000000000000000000000000
who {
email "amina@lantern.example"
name "Amina Farouk"
offset "+0000"
when 1741079520
}
}
{
message "commit: The checker itself"
new #sha1:7e5be0e7bcde0697eb6c03e9a4ded2056caad151
old #sha1:91631bc029f3be10e6d650ea26b30267de7ea50c
who {
email "amina@lantern.example"
name "Amina Farouk"
offset "+0000"
when 1741185600
}
}
{
message "commit: Defaults, so the flags have somewhere to come from"
new #sha1:709cfc56fbbb69306c4e3c1b1b3640ecd7f7bd32
old #sha1:7e5be0e7bcde0697eb6c03e9a4ded2056caad151
who {
email "jonas@lantern.example"
name "Jonas Bergström"
offset "+0000"
when 1741431720
}
}
{
message "commit: A Windows build script, CRLF and all"
new #sha1:0e7ee37b450c11248fd1ac8e7dacf76e891bcf73
old #sha1:709cfc56fbbb69306c4e3c1b1b3640ecd7f7bd32
who {
email "jonas@lantern.example"
name "Jonas Bergström"
offset "+0000"
when 1741773900
}
}
{
message "merge feature/report: Merge made by the 'ort' strategy."
new #sha1:7dca987561fd84764731e0629d18fa4bb9e339bb
old #sha1:0e7ee37b450c11248fd1ac8e7dacf76e891bcf73
who {
email "amina@lantern.example"
name "Amina Farouk"
offset "+0000"
when 1741858200
}
}
{
message "commit: Show how the two halves are used together"
new #sha1:9618139d007a6ea3c57c3258767ff5687228a896
old #sha1:7dca987561fd84764731e0629d18fa4bb9e339bb
who {
email "amina@lantern.example"
name "Amina Farouk"
offset "+0000"
when 1742058000
}
}
{
message "commit (merge): Merge fix/crlf"
new #sha1:b7fbe8b2e8f67fd8dbefdc1bddd421d80e79e6ec
old #sha1:9618139d007a6ea3c57c3258767ff5687228a896
who {
email "amina@lantern.example"
name "Amina Farouk"
offset "+0000"
when 1742114400
}
}
{
message "commit: Group the report by page, matching tools/report.py"
new #sha1:f00a4a846dbbba36012025800148d68f6b15ba25
old #sha1:b7fbe8b2e8f67fd8dbefdc1bddd421d80e79e6ec
who {
email "jonas@lantern.example"
name "Jonas Bergström"
offset "+0000"
when 1742212800
}
}
]
}
refs {
refs/heads/feature/report #sha1:6cdd013273b025e9f4259f7810f7228f2cae84b4
refs/heads/fix/crlf #sha1:b5c82f28f358c1f4780ea909f84263d52fce6ece
refs/heads/main #sha1:f00a4a846dbbba36012025800148d68f6b15ba25
refs/tags/v0.1.0 #sha1:638dacd07e35859a2d1165a864ac4dfdb76393ec
refs/tags/v0.2.0 #sha1:f00a4a846dbbba36012025800148d68f6b15ba25
}
}
-- ===================================================================
-- THE VIEW THIS DOCUMENT CARRIES
-- ===================================================================
-- THE SECOND OF @vocab/presentation/v0'S THREE PLACEMENTS: `a block BESIDE the
-- data, same document`, carrying :over and :of. It is the placement that
-- matters before a vocabulary reaches a registry — the author sends ONE FILE,
-- and a reader holding neither @vocab/git-repository/v0 nor its default view can
-- still draw what is in it.
--
-- IT IS THE SAME VIEW THE VOCABULARY CARRIES, changed in three lines: a name of
-- its own, a title, and `of -> @repo/lantern/v0`, which is what makes it THIS
-- document's rather than every repository's. resolution-is-last-wins says a
-- document's own view beats the vocabulary's default, so a reader holding both
-- draws with this one.
--
-- NOTHING ABOUT THE DATA ABOVE CHANGED to accommodate it. That is the claim:
-- the subject block is byte-for-byte what tools/git2umsg wrote, and repocheck
-- reads it without noticing the view is there.
lantern-view @presentation/git-repository-inline/v0 {
conforms -> @vocab/presentation/v0
title "This repository, drawn by a view it carries itself"
status :draft
at 2026-09-07
over -> @vocab/git-repository/v0
of -> @repo/lantern/v0
emits :interface
-- ===================================================================
-- PROJECTIONS
-- ===================================================================
-- A tree is not a collection and a commit history is not a collection: both
-- are reached by following names into a keyed block. :each walks a flat
-- sequence, and recursion inside an element tree would break the projection's
-- totality. So the traversal is declared here, once, where its termination
-- can be argued — and it needs NO new reference root, because
-- `-> @projections.commits` is an ordinary path from this document's own root.
projections {
commits {
start -> @subject.refs
resolve -> @subject.objects
-- A REF MAY NAME AN ANNOTATED TAG, which is not a commit. :peel resolves
-- through :object where it is there and leaves a commit ref alone, so both
-- kinds of ref reach the same history. See
-- a-START-may-name-the-wrong-kind-of-thing.
peel [ :object ]
follow [ :parents ]
-- :ordinal IS THE ROW'S PLACE IN THIS ORDER, which is what lets a view
-- name the newest commit without a second projection to hold it: the
-- order below is total, so `ordinal 0` is one commit and always the same
-- one. See the-tip-is-the-first-row-of-an-order-that-is-total.
emit [ :key, :ordinal, :message, :parents, :tree, :committer, :author ]
order {
kind :topological
by :committer.when
direction :descending
tie :key
tie-direction :ascending
}
doc "every commit reachable from a ref, in an order that is BOTH
topologically consistent and total — and it takes all three keys
above to get there, which this trial established by watching two
simpler rules fail.
THE GRAPH DOES NOT ORDER THEM. Five commits with one merge admit
TWO valid topological orders, so :topological alone is the same
class of construct as the bezier easing @vocab/animation/v0
refused: two implementations may differ and both be right.
THE CLOCK DOES NOT ORDER THEM EITHER. Those five commits occupy
two distinct committer seconds and BOTH seconds are shared, so a
tie-break is not a corner case — it fires on a repository made in
one second. :key breaks it, because an object name is unique by
construction.
AND SORTING BY THE TWO TOGETHER IS TOTAL AND STILL WRONG. It
ignores ancestry: the first bake put `First commit` ABOVE its own
child, which is total, deterministic, and visibly wrong to anyone
who reads a commit list. So :kind is :topological and the other
three keys are the PRIORITY inside it — Kahn's algorithm, taking
the highest-ranked commit whose children are all already placed.
Consistent because it is a topological sort, total because the
priority admits no ties."
}
tree {
start { from -> @subject.refs, keyed-by -> @subject.head }
resolve -> @subject.objects
seed [ :tree ]
follow [ :entries.object ]
join { content { from -> @subject.objects, keyed-by :object, take :content }, kind { from -> @subject.objects, keyed-by :object, take :kind } }
emit [ :object, :depth, :name, :mode, :content, :kind ]
order :document
doc ":seed is the second thing this trial found. A commit does not
hold its files; it holds a name that resolves to a tree that
holds them. So a projection's start is a CHAIN of resolutions
through data, not a static path, and :seed is the steps taken
after the start resolves and before the walk begins."
}
refs {
start -> @subject.refs
emit [ :name, :target ]
order { kind :sorted, by :name, direction :ascending, tie :name, tie-direction :ascending }
doc "every ref, with what it points at. NO WALK AND NO RESOLVE: :refs is
already a collection, so :follow is absent and the projection is a
pass. It is here because a repository browser lists branches and
tags, and because it is the one place a view meets a key it can
ITERATE and cannot NAME — see a-ref-with-a-dot-in-its-name."
}
}
uses [ -> @library/ui/v0 ]
-- WHAT THE CODOMAIN REQUIRES AT ITS ROOT AND THE SUBJECT CANNOT SUPPLY. A
-- locale and a target profile are facts about the RENDERING, not about the
-- repository, so they are fixed here and copied through untouched.
defaults {
locale { language "en", direction :ltr }
targets [ external -> @ui/target/web/v0 ]
}
-- A VIEW HAS STATE OF ITS OWN and it is not a projection: which file is open
-- and which commit is expanded are facts about the SESSION, they change under
-- the reader's hand, and nothing in the subject holds them. They are declared
-- here in the codomain's own terms and survive expansion untouched.
state {
open-file { type :text, initial "" }
open-commit { type :text, initial "" }
}
-- THE COUNTS ARE EXPRESSIONS AND THE PLURALS ARE THE TARGET'S. Nothing is
-- stored, so nothing goes stale — derived-state-is-an-expression-not-a-variable
-- — and the document declares WHICH forms exist rather than writing one. This
-- is docs/61's corrected fence in use: the fence is around derived DATA, and a
-- formatted label is not derived data.
messages {
commit-count { params { n :int }, plural { zero "no commits", one "{n} commit", other "{n} commits" } }
branch-count { params { n :int }, plural { zero "no branches", one "{n} branch", other "{n} branches" } }
tag-count { params { n :int }, plural { zero "no tags", one "{n} tag", other "{n} tags" } }
file-count { params { n :int }, plural { zero "no files", one "{n} file", other "{n} files" } }
kib { params { n :int }, plural { zero "0 KiB", one "{n} KiB", other "{n} KiB" } }
bytes { params { n :int }, plural { zero "empty", one "{n} byte", other "{n} bytes" } }
share { params { n :int }, plural { zero "under 1%", one "{n}%", other "{n}%" } }
}
content {
role :surface
name "Repository"
layout { axis :block, gap :loose }
-- A WORKSPACE AND NOT A PAGE OF PROSE, so it takes the window: :fill is
-- "the space the parent has left", and a surface's parent is the window.
size { inline :fill }
children [
{
role -> @derived.page-header
with {
heading -> @subject.title
subtitle -> @subject.head
actions [
{ role :text, style { colour :muted },
text { message -> @messages.commit-count, with { n [ :count, -> @projections.commits ] } } },
{ role :text, style { colour :muted },
text { message -> @messages.branch-count,
with { n [ :count, [ :filter, -> @projections.refs, [ :starts-with, bound -> @it.name, "refs/heads/" ] ] ] } } },
{ role :text, style { colour :muted },
text { message -> @messages.tag-count,
with { n [ :count, [ :filter, -> @projections.refs, [ :starts-with, bound -> @it.name, "refs/tags/" ] ] ] } } },
-- HOW MUCH THERE IS TO READ, which is the first thing a stranger
-- wants and the last thing a list of names tells them. Both are
-- arithmetic over lengths the document holds: no file is measured
-- by anything but its own bytes, and a folder has none.
{ role :text, style { colour :muted },
text { message -> @messages.file-count,
with { n [ :count, [ :filter, -> @projections.tree, [ :present, bound -> @it.content ] ] ] } } },
-- IN BYTES AND NOT IN KIBIBYTES, because :mul, :div and :round are
-- operators @vocab/ui/v0 declares and no engine decides yet. A
-- number the document can stand behind beats a rounder one it
-- cannot. See an-operator-declared-is-not-an-operator-decided.
{ role :text, style { colour :muted },
text { message -> @messages.bytes, with { n [ :sum, [ :map, [ :filter, -> @projections.tree, [ :present, bound -> @it.content ] ], [ :length, bound -> @it.content ] ] ] } } },
]
}
},
@panes {
role :tabs
children [
@tab-code { role :tab, text "Code" },
@tab-commits { role :tab, text "Commits" },
-- IT LISTS EVERY REF AND NOT ONLY THE BRANCHES, which the list's own
-- name has said all along. The heading beside it counts branches and
-- tags separately, so the tab that shows both cannot be called after
-- one of them.
@tab-branches { role :tab, text "Refs" },
-- --- the working tree ---------------------------------------
{
role :tab-panel
children [
-- THE NEWEST COMMIT IS WHAT THE FILES BELOW ARE. A list of
-- names says what is in the repository and not which state of it
-- is being shown; the strip says whose commit it was, which one,
-- what it was for and when.
--
-- `ordinal 0` NAMES IT AND A SECOND PROJECTION DOES NOT. The
-- commits order is topological, then by the committer's clock,
-- then by the object name — total by construction — so its first
-- row is one commit and always the same one. The guard sits on
-- the CHILD because :of is a binding and nothing is in scope on
-- the element carrying :each. See
-- a-guard-over-a-member-sits-INSIDE-the-repetition.
{
role :group
each { of -> @projections.commits, as :tip, key bound -> @tip.key }
layout { axis :block, gap :none }
children [
{
role :group
when [ :eq, bound -> @tip.ordinal, 0 ]
style { colour :surface-variant, space :normal, shape :rounded }
layout { axis :inline, across :center, along :between, gap :normal }
children [
{
role :group
layout { axis :inline, across :center, gap :normal }
children [
{ role :text, value bound -> @tip.author.name },
{ role :text, style { type :mono, colour :muted },
value [ :slice, bound -> @tip.key, 0, 7 ] },
-- THE SUBJECT LINE AND NOT THE WHOLE MESSAGE. A commit
-- message is a subject, a blank line and a body; the
-- strip is one line high, and a message with no newline
-- in it is its own subject.
{ role :text, value [ :if, [ :gt, [ :index-of, bound -> @tip.message, "
" ], 0 ],
[ :slice, bound -> @tip.message, 0, [ :index-of, bound -> @tip.message, "
" ] ],
bound -> @tip.message ] },
]
},
{ role :text, style { colour :muted },
value [ :format, [ :instant, bound -> @tip.committer.when ] ] },
]
},
]
},
@tree {
role :list
name "Files"
each { of -> @projections.tree, as :node, key bound -> @node.object, nest { by :depth, name bound -> @node.name } }
children [
{
role :list-item
children [
{
role :group
when [ :eq, bound -> @node.kind, :tree ]
layout { axis :inline, across :start, gap :tight }
children [
{ role :disclosure, symbol "folder", name bound -> @node.name, style { labelling :symbol-and-name }, initially :closed, children [] },
]
},
{
role :group
when [ :eq, bound -> @node.kind, :blob ]
-- THE SIZES MAKE A COLUMN when the row takes the width
-- it is offered and puts what it ends with at the end.
-- Glued to the name, ten sizes are ten interruptions.
size { inline :fill }
layout { axis :inline, across :center, along :between, gap :tight }
children [
{
role :button
symbol "document"
name bound -> @node.name
style { labelling :symbol-and-name, prominence :quiet }
on { activate [ { do :set, at -> @state.open-file, value bound -> @node.object } ] }
},
-- HOW BIG, WHICH A LIST OF NAMES DOES NOT SAY. It
-- was a :meter first, against the bytes of the whole
-- tree: a bar per row reads as PROGRESS, and a file
-- is not partway to anything. The number says it.
{ role :text, style { type :caption, colour :muted },
text { message -> @messages.bytes, with { n [ :length, bound -> @node.content ] } } },
]
},
]
},
]
},
-- :each REPEATS AN ELEMENT'S CHILDREN, so a guard over the member
-- cannot sit on the element carrying :each — nothing is in scope
-- there. The repetition is a plain group and the GUARDED thing is
-- its child. See a-guard-over-a-member-sits-INSIDE-the-repetition.
{
role :group
each { of -> @projections.tree, as :file, key bound -> @file.object }
layout { axis :block, gap :normal }
children [
@viewer {
role :region
when [ :and, [ :present, bound -> @file.content ],
[ :or, [ :eq, -> @state.open-file, bound -> @file.object ],
[ :and, [ :empty, -> @state.open-file ],
[ :starts-with, bound -> @file.name, "README" ] ] ] ]
name bound -> @file.name
layout { axis :block, gap :normal }
children [
{
role :group
style { colour :surface-variant, space :normal }
layout { axis :inline, across :center, along :between, gap :normal }
children [
{
role :group
layout { axis :inline, across :center, gap :tight }
children [
{ role :icon, symbol "document", decorative true },
{ role :heading, text bound -> @file.name },
]
},
{ role :text, style { type :caption, colour :muted },
text { message -> @messages.bytes, with { n [ :length, bound -> @file.content ] } } },
]
},
-- A DOCUMENT IS DRAWN AS ONE; ANYTHING ELSE IS SHOWN AS BYTES.
{ role :text, when [ :not, [ :ends-with, bound -> @file.name, ".umsg" ] ],
style { type :mono }, value bound -> @file.content },
-- A DOCUMENT IS NOT RENDERED AS ONE YET AND SAYS SO. This
-- branch held a bare STRING in :children as a placeholder,
-- which is not an element, and no trial subject reached it
-- because none of them contains a .umsg file. The first real
-- repository did, eight times. See issue/029.
{ role :group, when [ :ends-with, bound -> @file.name, ".umsg" ],
layout { axis :block, gap :normal },
children [
{ role :text, style { colour :muted, type :caption },
value "a unimsg document — shown as its source until a nested rendering exists" },
{ role :text, style { type :mono }, value bound -> @file.content },
] },
]
},
]
},
]
},
-- --- the history --------------------------------------------
{
role :tab-panel
children [
@history {
role :table
name "Commits"
each { of -> @projections.commits, as :commit, key bound -> @commit.key }
children [
{
role :table-row
children [
{ role :table-header, text "Commit" },
{ role :table-header, text "Message" },
{ role :table-header, text "Date" },
]
},
{
role :table-row
children [
{
role :table-cell
children [
{
role :button
name [ :slice, bound -> @commit.key, 0, 7 ]
style { prominence :quiet, type :mono }
on {
activate [
{ do :set, at -> @state.open-commit,
value [ :if, [ :eq, -> @state.open-commit, bound -> @commit.key ],
"", bound -> @commit.key ] },
]
}
},
]
},
{
role :table-cell
children [
{
role :group
layout { axis :inline, across :baseline, gap :normal }
children [
@commit-message { role :text, value bound -> @commit.message },
{ role :badge, describes -> @commit-message, text "merge",
when [ :gt, [ :count, bound -> @commit.parents ], 1 ] },
]
},
]
},
{
role :table-cell
-- :when is `seconds since the epoch, an integer`, so :instant
-- reads it as a time and :format dates it.
children [ { role :text, style { colour :muted }, value [ :format, [ :instant, bound -> @commit.committer.when ] ] } ]
},
]
},
{
role :table-row
when [ :eq, -> @state.open-commit, bound -> @commit.key ]
children [
{
role :table-cell
span { columns 3 }
children [
{
role :group
style { colour :surface-variant, space :normal }
layout { axis :block, gap :normal }
children [
{ role :text, style { colour :muted },
value [ :trim, [ :slice, bound -> @commit.message,
[ :add, [ :index-of, bound -> @commit.message, "\n" ], 1 ],
[ :length, bound -> @commit.message ] ] ],
when [ :present, bound -> @commit.message ] },
{
role :wrap
layout { axis :inline, across :start, gap :normal }
overflow :wrap
children [
{
role :group
layout { axis :block, gap :none }
children [
{ role :text, style { type :caption, colour :muted }, text "author" },
{ role :text, value bound -> @commit.author.name },
-- THE EMAIL IS THE AUTHOR'S, so it is a second
-- line under the name rather than a field of its
-- own: one very wide column among five narrow
-- ones is what threw the row out of alignment.
{ role :text, style { type :caption, colour :muted },
value bound -> @commit.author.email },
]
},
-- THE COMMITTER IS SHOWN WHERE IT IS NOT THE AUTHOR,
-- and only there. A patch somebody else applied is
-- the commonest thing git records that no other
-- system does, and a surface naming only the author
-- says the wrong person made the change.
--
-- IT WAS INVISIBLE UNTIL A SAMPLE HAD ONE.
-- examples/repository-lantern.umsg is the first
-- repository here whose author and committer ever
-- differ; against every earlier sample this omission
-- and a correct view render identically. See
-- a-SAMPLE-THAT-CANNOT-DIFFER-CANNOT-DISAGREE.
--
-- THE COMPARISON IS AN EXPRESSION AT THE POINT OF
-- USE and not a projected field, which is what
-- computation-is-an-expression-and-not-a-field asks
-- for: no row is added, removed or altered.
{
role :group
layout { axis :block, gap :none }
when [ :ne, bound -> @commit.author.email, bound -> @commit.committer.email ]
children [
{ role :text, style { type :caption, colour :muted }, text "committed by" },
{ role :text, value bound -> @commit.committer.name },
{ role :text, style { type :caption, colour :muted },
value bound -> @commit.committer.email },
]
},
{
role :group
layout { axis :block, gap :none }
children [
{ role :text, style { type :caption, colour :muted }, text "committed" },
{ role :text, value [ :format, [ :instant, bound -> @commit.committer.when ] ] },
]
},
{
role :group
layout { axis :block, gap :none }
children [
{ role :text, style { type :caption, colour :muted }, text "tree" },
{ role :text, style { type :mono }, value bound -> @commit.tree },
]
},
{
role :group
layout { axis :block, gap :none }
children [
{ role :text, style { type :caption, colour :muted }, text "parents" },
-- A LIST FIELD IS WALKED AND NOT PRINTED. See
-- a-list-field-is-walked-and-not-printed.
{
role :group
each { of bound -> @commit.parents, as :parent, key bound -> @parent }
layout { axis :block, gap :none }
children [
{ role :text, style { type :mono }, value bound -> @parent },
]
},
]
},
-- LAST, BECAUSE IT IS THE WIDEST. A full object name
-- ahead of the short fields pushed every one of them
-- onto a second line of the wrap.
{
role :group
layout { axis :block, gap :none }
children [
{ role :text, style { type :caption, colour :muted }, text "commit" },
{ role :text, style { type :mono }, value bound -> @commit.key },
]
},
]
},
]
},
]
},
]
},
]
},
]
},
-- --- branches and tags -----------------------------------------
--
-- TWO LISTS AND NOT ONE. A tag and a branch are different things —
-- one moves and one does not — and the heading beside the title has
-- counted them apart all along while the rows drew them together. A
-- reader looking for a release is not looking through branches.
--
-- THE GUARD SITS INSIDE THE REPETITION because :of is a binding and
-- not an expression: both lists repeat over every ref, and each row
-- keeps only the refs of its own namespace. See
-- a-guard-over-a-member-sits-INSIDE-the-repetition.
{
role :tab-panel
layout { axis :block, gap :loose }
children [
{ role :heading, text "Branches", style { type :heading } },
@branches {
role :list
name "Branches"
each { of -> @projections.refs, as :ref, key bound -> @ref.name }
children [
{
role -> @derived.list-row
when [ :starts-with, bound -> @ref.name, "refs/heads/" ]
with {
-- A BRANCH NAME MAY HOLD A SLASH. `refs/heads/feature/report`
-- is one branch called `feature/report`, and taking the last
-- segment called it `report` — a name no git command answers
-- to. Every earlier sample here had single-segment branch
-- names, so the two readings agreed and the wrong one shipped.
-- See a-SAMPLE-THAT-CANNOT-DIFFER-CANNOT-DISAGREE.
--
-- THE NAMESPACE IS THE FIRST TWO SEGMENTS and the name is the
-- rest. With no :index-of that takes an offset, the inner
-- slice is written out twice rather than projected into a
-- field, which is what where-derived-data-goes asks for.
title [ :slice, [ :slice, bound -> @ref.name, [ :add, [ :index-of, bound -> @ref.name, "/" ], 1 ], [ :length, bound -> @ref.name ] ],
[ :add, [ :index-of, [ :slice, bound -> @ref.name, [ :add, [ :index-of, bound -> @ref.name, "/" ], 1 ], [ :length, bound -> @ref.name ] ], "/" ], 1 ],
[ :length, [ :slice, bound -> @ref.name, [ :add, [ :index-of, bound -> @ref.name, "/" ], 1 ], [ :length, bound -> @ref.name ] ] ] ]
subtitle bound -> @ref.name
symbol "branch"
trailing [
-- THE ONE THE REPOSITORY IS ON. :badge would be the
-- word for it and :badge requires :describes, which
-- needs a named element to point at; the title here
-- belongs to the library's row and has no name this
-- view can use. So it is a text, and it says which.
{ role :text, style { prominence :strong },
text "HEAD", when [ :eq, bound -> @ref.name, -> @subject.head ] },
{ role :text, style { type :mono, colour :muted }, value [ :slice, bound -> @ref.target, 0, 7 ] },
]
}
},
]
},
{ role :heading, text "Tags", style { type :heading } },
@tags {
role :list
name "Tags"
each { of -> @projections.refs, as :ref, key bound -> @ref.name }
children [
{
role -> @derived.list-row
when [ :starts-with, bound -> @ref.name, "refs/tags/" ]
with {
-- A BRANCH NAME MAY HOLD A SLASH. `refs/heads/feature/report`
-- is one branch called `feature/report`, and taking the last
-- segment called it `report` — a name no git command answers
-- to. Every earlier sample here had single-segment branch
-- names, so the two readings agreed and the wrong one shipped.
-- See a-SAMPLE-THAT-CANNOT-DIFFER-CANNOT-DISAGREE.
--
-- THE NAMESPACE IS THE FIRST TWO SEGMENTS and the name is the
-- rest. With no :index-of that takes an offset, the inner
-- slice is written out twice rather than projected into a
-- field, which is what where-derived-data-goes asks for.
title [ :slice, [ :slice, bound -> @ref.name, [ :add, [ :index-of, bound -> @ref.name, "/" ], 1 ], [ :length, bound -> @ref.name ] ],
[ :add, [ :index-of, [ :slice, bound -> @ref.name, [ :add, [ :index-of, bound -> @ref.name, "/" ], 1 ], [ :length, bound -> @ref.name ] ], "/" ], 1 ],
[ :length, [ :slice, bound -> @ref.name, [ :add, [ :index-of, bound -> @ref.name, "/" ], 1 ], [ :length, bound -> @ref.name ] ] ] ]
subtitle bound -> @ref.name
symbol "tag"
trailing [
{ role :text, style { type :mono, colour :muted }, value [ :slice, bound -> @ref.target, 0, 7 ] },
]
}
},
]
},
]
},
]
},
]
}
tables {
where-each-kind-of-change-belongs [
| "a change to" "lives in" because
| "what is shown, and its structure" "this view" "a column, a badge, a span, which field is a tagline — every one is an assertion about the interface"
| "which rows exist and in what order" "this view's :projections" "a walk, a join and an order are declared, and their totality is the point"
| "a border, a marker, a glyph, a wrap, a padding" "the target" "appearance is excluded from equality because it is not asserted. A bullet is not a fact about an interface"
| "a construct neither can express" "the codomain's vocabulary" ":span was missing from @vocab/ui/v0 and was added there, not worked around here"
]
}
rationale {
a-walk-emits-the-EDGE-and-joins-the-node "AND INVERTING IT ANSWERED A HOLE
THIS TRIAL FIRST RECORDED AS NEEDING A NEW KEY.
A TREE ENTRY'S :name AND :mode BELONG TO THE ENTRY. Walking `entries.object`
arrives at the OBJECT — a blob named by its own content, which has no name —
and the filename is behind, on the row the walk came through. The first
reading was that :follow needed a companion saying `carry :name from where I
came`.
IT DOES NOT. A projection whose rows are the ENTRIES has the name and the
mode already, and reaches the bytes by the :join it was going to need
anyway — `from :objects, keyed-by :object, take :content`. The edge is the
row and the node is the join, which is the same shape the encyclopaedia
trial used for a claim and its datatype.
SO THE RULE IS THAT A WALK EMITS WHAT IT TRAVERSES, and reaching what a name
points at is a join. No key was added, and the construct that answered it
was already here."
string-operations-this-codomain-LACKED "FOUR FIELDS COULD NOT BE WRITTEN AS
EXPRESSIONS AND THE VIEW SHOWED THE WHOLE VALUE INSTEAD.
a short hash take the first seven characters of a key
a commit body the message after its first line
a ref's short name the last segment of a path
a commit date an epoch second shown as a date
WHAT THAT LOOKED LIKE: a 40-character object name in the commit table and
in every branch target, the same ref name printed TWICE on every branch row,
a blank body on every expanded commit, and `1788741263` where a date
belongs. Nine places on three tabs.
THE CODOMAIN HAS ANSWERED, with the four uses behind it that this paragraph
was keeping count of. @vocab/ui/v0 gained :slice, :index-of, :last-index-of
and :instant, and every one of the four is now a composition of those with
arithmetic the kernel already had. See
a-VOCABULARY-STORES-A-VALUE-AND-A-SURFACE-SHOWS-PART-OF-IT.
NONE OF THEM WAS DERIVED DATA, WHICH IS WHY THE FENCE DID NOT ACTUALLY
FORBID THEM. where-derived-data-goes refuses a view CONSTRUCTING a fact —
a count, a total, a filtered subset. Showing seven characters of a name the
document already holds constructs nothing. That distinction had not been
written down, so the fence was read as covering both, and this paragraph
recorded four operations as impossible when three of them were merely
absent.
:format IS STILL THE TARGET'S. `its output appears in no observable and may
differ per target` — so tools/pgen resolves :instant here, where the member
is, and leaves the :format wrapping it for whatever renders the document."
the-README-is-CHOSEN-BY-A-GUARD-and-not-by-a-filter "AND THE PROJECTION THAT
WAS GOING TO DO IT WAS DELETED, because the tree projection already emits
every root entry joined to its content.
THE OBVIOUS SPELLING IS A `where` SELECTING THE README, and
where-derived-data-goes refuses a filter in a projection. So the projection
stays TOTAL over the entries and the CONTENT guards which one it draws,
with :starts-with from the expression kernel — the same guarded-sibling
mechanism a tagged union uses. A folder entry joins to no content and the
guard never reaches it.
A SECOND PROJECTION OVER THE SAME WALK IS THE SMELL. `readme` and `tree`
had the same start, the same seed and the same follow, and differed only in
what they emitted — which is a sign the FILTER was being smuggled in as a
second traversal. One projection and a guard is the honest shape."
a-guard-over-a-member-sits-INSIDE-the-repetition "THE FILE PANE RENDERED
NOTHING AND THE FIRST READING WAS THAT THE EXPANDER WAS WRONG.
@vocab/ui/v0 SAYS :each REPEATS AN ELEMENT'S CHILDREN. So on the element
that CARRIES :each, the member is not yet in scope — a `when` naming
`@file` there names nothing, and both the runtime and the expander are
right to leave it alone. The view had put the guard exactly there.
SO THE REPETITION AND THE GUARD ARE TWO ELEMENTS. A plain group carries the
:each; the guarded region is its child, where the member IS in scope. It
costs one wrapper and it is the codomain's own rule read correctly.
THIS VOCABULARY ADDS NOTHING FOR IT, and that is the point of
equality-is-inherited: the scoping rule is the codomain's, and a view that
breaks it is broken in the codomain's terms rather than in these."
a-guard-over-BOTH-a-member-and-state-is-PARTIALLY-EVALUATED "AND IT IS THE
THIRD CASE THE EXPANDER'S ONE-LINE RULE DID NOT HAVE.
`anything naming a projection is expanded, anything naming the view's own
state survives` DECIDES A GUARD THAT NAMES ONE OF THEM. The file pane's
guard names both: `open-file EQUALS this file's object, OR nothing is open
and this file is the README`. Expanding it fixes the answer at generation
time and no click can ever change it; leaving it whole ships `@file` into a
document where that alias does not exist.
SO THE MEMBER HALF BECOMES LITERALS AND THE STATE HALF STANDS. The emitted
guard reads `[ :eq, -> @state.open-file, \"a532464…\" ]`, which is exactly
what the row it belongs to means, and the runtime decides it.
PARTIAL EVALUATION IS THE GENERAL FORM and the other two are its ends. It
is worth saying plainly because an implementer who reads only the one-line
rule will get this wrong in the direction that still gates clean."
a-list-field-is-walked-and-not-printed "THE COMMIT PANEL SHOWED
`[3054bfc…, 66d6ae0…]` — a Go slice printed into a document.
THE PROJECTION WAS RIGHT TO EMIT THE LIST. computation-is-not-emitted-here
argues that joining parents into a string loses the one thing that matters:
each parent is separately addressable. Emitting the list keeps that and
hands the problem to the content, which is where it belongs.
SO THE CONTENT REPEATS OVER IT. `each { of bound -> @commit.parents, as
:parent }` is a repetition whose members are SCALARS rather than rows, so
the alias denotes the value itself — `bound -> @parent`, with no field
after it. It is the same construct one level in.
THE EXPANDER NEEDED IT AND THE VOCABULARY DID NOT. A repetition over a
member's own field is still `:each` over a sequence; nothing here changes.
What changed is that an expander which only understood
`-> @projections.name` had to learn that a sequence can also be a field of
the row it is already inside."
computation-is-not-emitted-here "SIX FIELDS LEFT THIS PROJECTION SET when
tools/pgen showed that nothing produced them, and five went to the content
as expressions rather than coming back as constructs.
:merge IS `[ :gt, [ :count, bound -> @commit.parents ], 1 ]`. :kind is
`[ :starts-with, bound -> @ref.name, \"refs/heads/\" ]`. :size is
`[ :length, bound -> @file.content ]`. :head is an `[ :eq ]` against the ref
the subject names. And :parents was never a field: a list joined into a
string cannot be walked and cannot be linked one parent at a time, so the
projection emits the list.
THE THREE THAT REMAIN ARE STRING OPERATIONS THIS CODOMAIN DOES NOT HAVE — a
short hash, a message's first line, a ref's last segment. They are marked
where they are used and they are @vocab/ui/v0's to answer, the way :span
was."
visit-once {
rule "A projection visits each resolved name AT MOST ONCE, and a name
already visited is not followed again.
THAT IS WHAT MAKES IT TOTAL WITHOUT KNOWING THE SUBJECT. Git's object
graph is acyclic because a name is the hash of its own bytes, but no
general rule over an arbitrary vocabulary can rely on that, and a view
that hung on a cyclic document would be the worst possible failure. A
seen-set is cheap and it makes termination a property of the mechanism
rather than of the data."
}
-- ===================================================================
-- THE SURFACE
-- ===================================================================
a-folder-is-a-disclosure-and-the-kernel-already-had-the-word "THE TREE WAS
FIRST BUILT AS PLAIN NESTED LISTS, which renders as a bullet outline that
never collapses. @vocab/ui/v0 already has the construct and it was not
used: :disclosure — `content that expands and collapses under a summary
that is always visible`, with :open and :close events.
ITS :expanded IS COMPUTED AND NOT AUTHORED, which is the whole reason it
fits here — but WHICH WAY IT STARTS is this view's to say, and until
a-DEFAULT-openness-is-a-fact-the-DOCUMENT-owns there were no words for it.
A file tree of 75 folders opened by default is not a file tree, and a
renderer that chose differently from ours was not wrong: the document had
not said. `initially :closed` says it. That vocabulary's account of the authored states says
`:expanded is computed on :disclosure, where openness is implicit state the
effects own`. So an expandable tree of any depth needs NO state per node, no
toggle intent, and no identity for a row — and a view that needed an
identity per row would have run straight into
a-collection-is-drillable-where-the-vocabulary-keys-it, since a tree entry
is a table row and a filename has a dot in it.
@library/ui/v0's tree-item WAS THE OBVIOUS CANDIDATE AND DOES NOT FIT. It
takes `expanded`, `children-of` and `on-toggle` REFERENCES, so it wants
exactly the per-node state the kernel construct does without; and its body
renders its children as plain items rather than as further tree-items,
because a derivation body cannot recurse. It is right for a hierarchy one
level deep, and a file tree is not one.
CONTAINMENT DECIDES THE SHAPE. A :list takes :list-item only and a
:list-item takes anything, so the disclosure sits INSIDE the item and the
nested list inside the disclosure — WHICH IS WHAT THE EMPTY :children ON THE
DISCLOSURE IS FOR. It is the slot the nesting fills; see issue/053, where
the expander filled the ITEM instead and every folder rendered permanently
open.
THIS PARAGRAPH USED TO END `Verified in the rendering: the web target emits
nested details elements, closed by default, three deep for
src/core/main.py`, and every clause of that was true while the sentence was
false — the files were beside the details rather than in them. WHAT IS
VERIFIED NOW, AND HOW: with every folder shut the Code tab shows README.md,
docs and src and nothing else; clicking docs reveals notes.md. Read from the
page's own text rather than from a count of open attributes.
AND THE FOLDER-OR-FILE CHOICE IS A GUARD, not a second projection. Content
emptiness cannot distinguish a tree from an empty or binary blob. Two
guarded siblings on the joined :kind — the same mechanism the encyclopaedia
trial used for a tagged union of five value kinds."
the-icon-is-decorative-and-the-marker-is-the-target-s "TWO APPEARANCE
QUESTIONS THAT LOOK ALIKE AND ARE ANSWERED IN DIFFERENT PLACES.
THE ICON IS THE DOCUMENT'S. `:icon` takes a `:symbol` — `a name from the
target icon set` — and it is `decorative true` here, which the kernel
defines as an icon carrying no information and for which a name is REFUSED.
That is the honest reading: the folder-ness is already carried by the
disclosure, which expands, and by the filename beside it. The icon repeats
what the structure says, which is what decorative means.
THE BULLET IS NOT THE DOCUMENT'S, and there is no key for it. The appearance
tokens are :colour, :type, :space, :prominence, :density, :shape, :emphasis
and :labelling — roles and scales, never measurements, and NOTHING for a
list marker. A bullet is not a fact about the document, so equality excludes
it and no view can ask for one or refuse one. Which rows get a marker is the
TARGET's, and tools/uigen's web target now drops it on an item that begins
with its own glyph or carries a disclosure — an item that already shows
where it starts does not need a second bullet.
THE TARGET ALSO GAINED THE GLYPH. `folder` was not in its icon set, and an
unknown symbol on a DECORATIVE icon renders as nothing at all — the
degradation the vocabulary promises, to the accessible name, is unavailable
precisely because a decorative icon has no name. That is a gap in the
target, not in the document, and it was fixed there."
a-commit-list-is-a-table-because-columns-align "A CARD PER COMMIT SHOWS THE
SAME FACTS AND CANNOT LINE THEM UP. Three columns down a list is what makes
a history scannable — every hash under every hash, every date under every
date — and that is a :table and nothing else.
THE EXPANSION IS A SECOND TEMPLATE ROW. @vocab/ui/v0's table takes a header
row rendered once and any number of rows rendered per member, so a summary
row and a detail row under one :each is the ordinary shape rather than a
trick.
THE DETAIL ROW IS ONE CELL SPANNING THE TABLE, and getting there changed
@vocab/ui/v0 rather than this view.
THAT VOCABULARY HAD NO CELL SPAN, and no row in its :not-here saying why an
interface table may not do what every interface table does — so the omission
was an oversight rather than a decision, which @vocab/vocabulary/v0 makes
checkable by requiring :not-here to account for what is absent.
A SPAN IS STRUCTURE, WHICH IS WHY IT COULD NOT BE FIXED HERE OR IN A TARGET.
@vocab/rich-document/v0 had already settled the same question for the same
construct: its observables compare `table geometry AFTER SPANS ARE
RESOLVED`. A cell covering three columns is a different assertion about the
data, not a different picture of it — so a presentation could not add it
(a view emits its codomain's vocabulary and adds nothing), and a stylesheet
could not fake it.
SO :span WAS ADDED TO @vocab/ui/v0 in that vocabulary's own terms — the
rich-document spelling unchanged, a projection step resolving spans before
nodes are compared, and two gate rules. See a-cell-may-span there."
the-panel-is-a-styled-group-and-the-lines-are-the-target-s "TWO HALVES AGAIN,
and the split is the same one the file tree's icon and bullet made.
THE DOCUMENT SAYS THE PANEL IS A BOX: a :group with :colour
:surface-variant and :space :normal, which are a colour ROLE and a spacing
STEP and never a measurement. It says the cell spans the table, which is
:span and is geometry. Both are assertions about the interface.
THE TARGET SAYS WHERE THE LINES GO. A data table is separated along ONE axis
— a rule under each row, none between cells — because vertical rules cut a
row into pieces and make an expanding row look like an unrelated one. A row
and the panel belonging to it lose the rule between them, and a cell holding
a box gives up its own padding so the box is flush. None of that is a fact
about the interface, and none of it is in this document.
IT WAS A card AT FIRST AND IS NOT ONE NOW. A card is a box INSIDE its
container, which is right in a list and wrong in a spanning cell: its inset
and its corners were exactly what made the panel read as a separate row."
a-file-opens-in-a-pane-and-not-in-the-tree "THE FIRST SHAPE EXPANDED A FILE
WHERE IT SAT, and a tree that grows where you read pushes every sibling
down: the shape of the repository came to depend on which file you had
open. A tree is a map of what exists and should not move when you read.
SO A FILE ROW IS A :button THAT SETS A SELECTION and the pane below shows
it. That is the same construct the commit table needed and for the same
reason — a row cannot hold its own openness — except here the identity is
the BLOB's name, which is hex and dot-free, so the selection round-trips.
THE DEFAULT IS A GUARD AND NOT A PROJECTION. With nothing selected, the
README's own pane shows itself: `[ :empty, -> @state.open-file ]` and
`[ :starts-with, name, \"README\" ]`. CHOOSING WHICH FILE TO FEATURE BY ITS
NAME IS A VIEW DECISION and asserts nothing about the bytes — which is a
different thing from reading the bytes AS markdown, and the difference is
the whole of the-readme-is-shown-and-not-rendered."
a-viewer-has-a-header-and-that-is-structure "The pane's header is a :group
carrying an icon, the file's name as a :heading and its size — not a
decoration, but the answer to `what am I looking at`, which a pane that
merely swapped its text would not give. Its box is :colour
:surface-variant and :space :normal, a colour role and a spacing step, and
no measurement."
the-chevron-and-the-glyph-are-the-target-s "A FOLDER SHOWS ITS OWN ICON, so
the browser's disclosure triangle beside it is a second marker for one
thing — the same redundancy as the bullet, and dropped in tools/uigen for
the same reason. The row stays operable: a summary is still focusable and
still toggles on Enter.
AND THE FILE GLYPH CHANGED THERE TOO, from a shaded square to a page with a
folded corner, because a symbol is `a name from the TARGET icon set` and
which glyph a name draws is that target's business. The document asks for
`document` either way."
nothing-checks-that-this-view-and-its-bake-agree "PARTLY ANSWERED SINCE, and
the part that is answered found eleven faults in this file the moment it
existed.
tools/pcheck NOW READS THIS DOCUMENT. On its first run it refused it: the
fragment was under a key called `surface`, which is @vocab/ui/v0's word and
not this vocabulary's, and the commits projection still declared
`emit [ :key, :depth ]` from its first draft while the content bound eight
other fields. Every one was drift of exactly the kind this paragraph was
written to admit could not be caught.
ANSWERED IN FULL SINCE, AND THE QUESTION DISSOLVED RATHER THAN BEING
SETTLED. tools/pgen NOW RUNS THIS DOCUMENT: it projects the rows, expands
:content and writes the @vocab/ui/v0 document, which tools/uigen then gates
and draws. The view is EXECUTED, so there is no longer a second artefact
for it to agree with. docs/64 says what that reader covers.
THE BAKE IS NOT THE ARTEFACT ANY MORE AND IS NOT BYTE-IDENTICAL TO WHAT IS
GENERATED, deliberately: the generated document shows a full object name
where a short one belongs and an epoch second where a date does, because
those four operations are owed to @vocab/ui/v0's expression kernel and this
vocabulary will not grow a second one to fake them. tools/repobake.py still
reproduces its own output; what it no longer is, is the answer.
THE ORIGINAL FINDING, KEPT BECAUSE IT IS WHY THE READER EXISTS.
THIS DOCUMENT USED TO BE TRANSCRIBED RATHER THAN RUN. tools/repobake.py was
a hand-written expansion of it, and the two were kept in step by whoever
edited them. They DRIFTED: :span, the email tagline and the field order
went into the bake and not into this file, and it took a reader asking
where the changes were going to notice.
THAT WAS DOCS/62'S SUBJECT ARRIVING IN ITS OWN EVIDENCE. A totality claim
with nothing to check it is exactly what that audit is for, and the claim
here — that the baked artefact is what this view says — was one. It is now
checked by being run, which is what a gate replaces belief with."
a-column-holds-what-its-header-says-and-nothing-else "THE MERGE MARKER WAS IN
THE DATE COLUMN and it is not a date. A second line under the date also made
that row taller than every other, so one commit in five set the height of
the whole table.
IT QUALIFIES THE MESSAGE, so it sits inline after the message and the Date
column holds dates. That is not a styling preference: a column is a claim
that everything under its header is the same kind of thing, and a table is
the one place in an interface where that claim is made structurally rather
than by arrangement.
THE FACT ITSELF IS STRUCTURAL AND NOT TEXTUAL. `merge` is on the row because
the projection emitted it from the commit having more than one parent — not
because the message happens to begin with the word.
AND IT IS A :badge, WHICH IS WHAT THE KERNEL CALLS THIS: `a small count or
state attached to another element`. The first attempt used one and the gate
refused it — a badge REQUIRES :describes, and there was nothing to point at.
The message now carries an identifier, so the badge names what it qualifies
instead of merely sitting beside it, and a reader that cannot see the pill is
told the same thing.
AN IDENTIFIER INSIDE A REPETITION IS ONE IDENTIFIER AND MANY INSTANCES,
which @vocab/ui/v0 already contemplates in
a-step-inside-a-repeated-element-says-which-member. The gate counts authored
elements and not rendered ones, so one badge in the document is one badge per
commit at run time."
a-table-row-cannot-hold-its-own-openness "AND THAT IS THE COST OF THE TABLE,
stated because the file tree pays nothing for the same behaviour.
A :disclosure OWNS ITS OPENNESS — :expanded is computed, and a folder or a
file therefore expands with no state, no intent and no identity. A TABLE ROW
HAS NO SUCH STATE. There is no :expanded on a row and no disclosure that can
wrap one without breaking the columns, so expansion has to be a SELECTION:
one :text in state holding the open commit's key, a button that toggles it,
and a detail row guarded on the match.
IT WORKS ONLY BECAUSE A COMMIT HAS AN IDENTITY, which is
a-collection-is-drillable-where-the-vocabulary-KEYS-it in use rather than in
the abstract. The key is the object name — hex, so no dot — it is already
the :each key, and it survives a re-render. A row of :index, whose members
have no name, could not do this at all.
SO THE CHOICE BETWEEN A LIST AND A TABLE IS NOT ONLY VISUAL. A list of
disclosures is stateless and cannot align; a table aligns and needs the
subject to have keyed the thing being expanded."
a-summary-is-one-text-run "WHY THE COMMIT ROW IS NOT A DISCLOSURE, which was
the first shape tried. A collapsed commit shows THREE facts — hash, message
and date — and a :disclosure summary is ONE TEXT RUN.
THE OBVIOUS FIX IS TO COMPOSE THEM INTO ONE NAME, and neither route exists.
The expression kernel has no concatenation: :format takes a number or a
time, and :lower, :upper and :trim take one text. A :messages entry WOULD
compose them — that is what a message with parameters is for — but a name
is not a message: tools/uigen's evalName takes a reference or an expression
where evalText also takes a message, and the vocabulary does not say a name
may be one.
SO THE COMPOSITION IS STRUCTURAL. @library/ui/v0's card holds what stays
visible — the heading, and an inline group of hash, date and a merge marker
— and a :disclosure inside it holds what does not. That needs nothing from
either vocabulary, and it puts the always-visible facts where they belong:
in the card, not in a summary string.
THE MERGE MARKER IS TEXT AND NOT A :badge, which the gate settled. A :badge
`requires :describes` — it attaches to another element by reference — and
inside a repetition there is no identifier to attach to."
opening-a-row-needs-no-selection-state "A FILE AND A COMMIT BOTH OPEN IN
PLACE, AND NEITHER NEEDS A SELECTED-ITEM VARIABLE.
THE OBVIOUS DESIGN IS A SELECTION AND A DETAIL PANE: state holding the
chosen key, a handler writing it, and a panel that looks the row up again.
THE LOOK-UP IS THE PROBLEM — it is the same dynamic-key resolution
a binding cannot spell, so the panel would need
the projection to be re-joined at the point of use.
A :disclosure PER ROW REMOVES ALL OF IT. The row's own detail is already in
scope through the member alias, its openness is computed, and there is no
second place for the two to disagree. No state, no intent, no identity, and
a row that cannot be named is still a row that can be opened.
A FILE'S CONTENT REACHES IT BY THE JOIN. :content is on the row because the
projection put it there, so `bound -> @node.content` is an alias-rooted
reference and not a lookup."
a-caption-is-not-a-label-and-the-gate-said-so "RECORDED BECAUSE THE GATE
CAUGHT IT AND READING DID NOT. The commit detail first spelled its field
names as :label, which reads correctly in English and is wrong here: a
:label is `the visible name of a control, associated by a reference`, and
the gate refuses one with no :labels. These name nothing; they are captions,
and :type :caption is what says so."
the-default-prefers-a-source-over-a-copy-generated-from-it "SEVERAL FILES CAN
ANSWER TO `readme`, AND IN A REAL REPOSITORY TWO DO. marasim carries
README.md and docs/readme.umsg, and the first three lines of the Markdown
say `GENERATED from docs/readme.umsg by tools/gen.mjs. Edit that document.`
SO THE GUARD ASKS FOR MORE THAN A NAME. The default pane opens the
readme-named file that DECLARES a vocabulary — :declares is on the row
because the projection read the blob's own header — and falls back to
whichever readme exists when none does. A repository with only a Markdown
README still gets one; a repository with the document behind it gets the
document.
THAT IS NOT A PREFERENCE FOR unimsg OVER MARKDOWN. It is a preference for
the file that can be drawn as what it IS over one that can only be shown as
the bytes of a rendering — and in this case the copy says so itself. A view
that opened the generated file would be showing a reader the output of a
build in place of the fact the build was run on."
a-blob-that-is-a-document-is-rendered-as-one "AND IT DISSOLVES THE MARKDOWN
PROBLEM RATHER THAN SOLVING IT, which is worth recording because the
objection below was correct and turned out not to matter.
A UNIMSG BLOB SAYS WHAT IT IS. `%unimsg 0 marasim/readme/v0` is a version
header in the CONTENT, and the block that follows is annotated with the
vocabulary it is written against. Nothing is inferred from a filename, so
reading it is not a guess — which is exactly the property markdown lacks.
SO THE PANE RENDERS THE DOCUMENT. Where the bytes declare a vocabulary the
reader knows, the file shows as the document it is; where they do not, the
source is shown. That is resolution-is-last-wins one level down: a reader
holding no view for a vocabulary still has something it can CHECK, and
merely cannot draw it.
THE marasim REPOSITORY MAKES THE POINT BETTER THAN THE ARGUMENT DOES. Its
docs/readme.umsg is written against @vocab/documentation/v0 and says of
itself: `The README, as a document rather than as Markdown ... README.md is
the reading copy and is generated from this file`. The Markdown carries
``. So the file this view could not
honestly render is the DERIVED one, and the fact was in the tree all along.
WHAT THE BAKE DOES HERE IS NOT A DECODE, said plainly. tools/repobake.py
walks the text far enough to show that the relation works and recognises one
vocabulary. A real consumer reads a nested document with the format's own
reader, which every consumer must already have."
the-readme-is-shown-and-not-rendered "IT DISPLAYS `# Trial` AND NOT A HEADING
IN THE TRIAL REPOSITORY, and that is a boundary rather than an omission.
@vocab/git-repository/v0 HOLDS BYTES. It has no content type, and nothing in it
says a blob is markdown — the `.md` in a filename is a convention of the
people who wrote it, not a declaration the document makes. So a view that
rendered the bytes as markdown would be acting on a guess about its subject.
AND RENDERING MARKDOWN IS A CONVERSION, not a projection. It parses one
document format into another, which is a MAPPING — markdown to
@vocab/rich-document/v0 — of exactly the shape
@mapping/encyclopaedia/prose-rendering/v0 already has for an entry's prose.
where-derived-data-goes puts it outside this vocabulary for the same reason
it puts a filtered series outside: the view draws what its subject holds.
THE CODOMAIN SAYS SO TOO. This view emits :interface, and @vocab/ui/v0 has
:heading, :text and :list and no rich runs at all. A properly set README
wants the :document codomain, which is where the encyclopaedia trial's
claims table ended up for the same reason.
SO WHAT IS SHOWN IS THE SOURCE, which is what a repository browser calls the
raw view — and the reason those browsers show a rendered README instead is
that they run a converter. This one names the converter it does not have."
nest-is-a-property-of-the-expansion "`nest :depth` ON THE :each, AND IT IS THE
ANSWER TO A PROBLEM THIS TRIAL FIRST RECORDED AS OPEN.
THE FLATTENING SOLVES TERMINATION AND NOT SHAPE. Its rows carry a :depth
and :each expands SIBLINGS, so the first bake drew a file tree flat — every
entry at one indent, with the depth column doing nothing.
THE OBVIOUS FIXES BOTH COST TOO MUCH. Adding a level key to :list-item
changes @vocab/ui/v0's CLOSED KERNEL, which is the expensive kind of change
and is not this vocabulary's to make. Recursing in the element tree is what
the projection exists to avoid.
AND NEITHER WAS NEEDED. `nest` produces the CONTAINMENT, and the kernel
already had the node — see a-folder-is-a-disclosure-and-the-kernel-already-
had-the-word. The depth column says where a row goes; :disclosure says what
it is when it has children.
SO NESTING IS DECLARED ON THE REPETITION AND PERFORMED BY THE EXPANSION.
`nest :depth` says a row at depth d+1 is placed inside a :list appended to
the preceding row at depth d. The element tree still says `a list of these
rows`; projection step 2, which already expands :each, expands this too.
THE OUTPUT NEEDS NOTHING NEW. A :list inside a :list-item is already legal —
@vocab/ui/v0's containment table gives :list-item children :any — and
nesting is how that vocabulary says a hierarchy is expressed. So the kernel
is untouched, and what changed is only how one sequence expands.
IT IS TOTAL AND MECHANICAL: one left-to-right pass, no recursion in the
document, well-formed as long as depth starts at zero and never rises by
more than one — which a walk that emits depth cannot violate."
-- ===================================================================
-- WHAT THIS VIEW COULD NOT SAY
-- ===================================================================
a-ref-with-a-dot-in-its-name-cannot-be-named "AND THE TRIAL REPOSITORY HAS
ONE. The specification says it plainly — `A key that itself contains a dot
cannot be reached by a path, because the dot is a separator` — and
@vocab/git-repository/v0 keys :refs, :reflogs and :local-files by names that
are chosen by the user, not by the format.
`refs/tags/v0.1` IS UNREACHABLE BY A DOCUMENT-ROOTED PATH, and it is the
universal convention for naming a version tag. `refs/heads/main` is fine;
the slash is not a separator and only the dot is.
WHAT STILL WORKS IS ITERATION. `:each -> @subject.refs` walks every member
including the dotted one, because a repetition needs no path per member. So
a view can LIST every tag and cannot NAME one, which is why this view has
no ref pane: it would have had to choose between listing refs it cannot
link to and naming refs it cannot reach.
:objects IS SAFE AND IT IS NOT LUCK. A hash is hex, and hex has no dot."
what-a-repository-browser-has-and-this-does-not "THE LAST COMMIT THAT TOUCHED
EACH FILE, with its message and its date, beside the file name. It is the
single most recognisable thing about Gitea, GitHub and Bitbucket, and it is
not here.
FINDING IT MEANS WALKING HISTORY AND DIFFING EACH COMMIT'S TREE AGAINST ITS
PARENT'S, PER PATH. That is not a projection step — not a :start, a :seed, a
:follow, a :join or an :order — and no combination of them reaches it. It is
derived data, and where-derived-data-goes says derived data belongs in a
second document of the subject's own vocabulary, computed under that
vocabulary's gate.
SO IT IS RECORDED RATHER THAN FAKED. A view that showed a plausible commit
beside each file, computed by a baker rather than declared here, would be
the exact failure this vocabulary exists to prevent: a rendering asserting
something its subject does not say."
the-subject-is-small-on-purpose "examples/repository-trial.umsg HAS FIVE
COMMITS AND FIVE FILES, and a browser over it looks like a browser over a
toy. It is not made prettier, because its five commits, its two shared
committer seconds and its dotted tag are the measurements docs/61 and
@vocab/presentation/v0 both cite. Regenerating it for a better demonstration
would invalidate the evidence it exists to be."
the-index-is-drawn-and-not-drilled "A repository's :index is a TABLE, because
rows repeat and their order is the index's own. So an index row can be
rendered and cannot be returned to: there is nothing to put in state that
names it. This view does not draw the index at all, which is the honest
answer while a-collection-is-drillable-where-the-vocabulary-keys-it stands."
}
}
-- Local UI derivation library used by the display view.
library @library/ui/v0 {
conforms -> @vocab/ui/v0
title "The widgets, decomposed into the kernel that was said to hold them"
status :draft
at 2026-08-26
doc "WHAT A CONSUMER MEETS TODAY IS SIXTY PRIMITIVES AND NOTHING ELSE, and
the reasonable conclusion from that is that the vocabulary cannot
express a real interface. It can. What it could not do was SHOW that it
can, because the library the argument rests on was never written.
EVERY ENTRY HERE IS THE ARGUMENT BEING PAID FOR. The role table removed
a card, a chip, a snackbar, an avatar, a wizard, a rating and a date
range on the explicit grounds that each is a derivation. Those seven
are the first seven entries below, written out, so the subtraction can
be checked rather than believed.
AN ENTRY EARNS ITS PLACE BY BEING WANTED AND DECOMPOSABLE. A widget
with no honest decomposition belongs in the kernel and is not here; a
widget nobody asks for is not here either, however easy it would be."
-- ===================================================================
-- THE SEVEN THE KERNEL SUBTRACTED
-- ===================================================================
derived {
card {
doc "a grouped surface with an optional heading, holding arbitrary content"
params { heading :text, content :children }
native { web "article", flutter "Card" }
body {
role :group
style { colour :surface-variant, shape :rounded, space :normal }
layout { axis :block, gap :normal }
children [
{ role :heading, text bound -> @params.heading, style { type :heading } },
{ role :group, layout { axis :block, gap :normal }, children -> @params.content },
]
}
}
chip {
doc "a compact action, shaped as a pill. An action, not a state — see filter-chip"
params { label :text, on-choose :reference }
body {
role :button
name bound -> @params.label
style { shape :pill, prominence :quiet, space :tight }
on { activate [ { do :emit, intent -> @params.on-choose } ] }
}
}
filter-chip {
doc "a chip that is on or off. A checkbox, because that is what it MEANS — the pill is appearance and the state is not"
params { label :text, at :reference }
body {
role :checkbox
name bound -> @params.label
value bound -> @params.at
style { shape :pill, space :tight }
}
}
snackbar {
doc "a transient message over the interface, with an optional single action"
params { message :message, action-label :text, on-action :reference }
native { web "output", flutter "SnackBar" }
body {
role :overlay
layout { anchor :center-bottom }
overflow :clip
children [
{
role :wrap
style { colour :surface-variant, shape :rounded, space :normal }
layout { axis :inline, across :center, gap :normal }
overflow :wrap
children [
{ role :status, text { message -> @params.message } },
{
role :button
name bound -> @params.action-label
when [:present, -> @params.action-label]
style { prominence :quiet }
on { activate [ { do :emit, intent -> @params.on-action } ] }
},
]
},
]
}
}
avatar {
doc "a person or thing, as a circular image. The name is required and is not decorative — an avatar with no name is a decoration and should say so by being an :image"
params { name :text, source :text }
body {
role :image
name bound -> @params.name
source bound -> @params.source
style { shape :circle }
}
}
rating {
doc "a score out of five, as one choice from five. THE EXAMPLE THE KERNEL USED TO ILLUSTRATE DERIVATION AND COULD NOT EXPRESS UNTIL :option-symbol EXISTED — see issues/002"
params { name :text, at :reference }
native { web "radiogroup", flutter "RatingBar" }
body {
role :radio-group
name bound -> @params.name
value bound -> @params.at
options [
| value label
| 1 "1 star"
| 2 "2 stars"
| 3 "3 stars"
| 4 "4 stars"
| 5 "5 stars"
]
option-symbol "star"
}
}
date-range {
doc "two dates that must not cross. The constraint is the whole reason this is one element and not two"
params { name :text, from :reference, to :reference }
body {
role :field-group
name bound -> @params.name
layout { axis :inline, across :baseline, gap :normal }
children [
@from-field {
role :date-field
name "From"
value bound -> @params.from
},
@to-field {
role :date-field
name "To"
value bound -> @params.to
constraints [
| must message
| [:not, [:lt, -> @params.to, -> @params.from]] "The end date cannot be before the start date"
]
},
]
}
}
-- =================================================================
-- CONTROLS THE GLYPH GAP USED TO BLOCK
-- =================================================================
icon-button {
doc "a control shown as a glyph alone. The name is required and stays the accessible name — the glyph is what it looks like, never what it is called. See issues/002"
params { name :text, symbol :text, on-activate :reference }
native { web "button", flutter "IconButton" }
body {
role :button
name bound -> @params.name
symbol bound -> @params.symbol
style { labelling :symbol-only, shape :circle, prominence :quiet }
on { activate [ { do :emit, intent -> @params.on-activate } ] }
}
}
close-button {
doc "the affordance every platform ships and this vocabulary could not write. An icon-button with the name already decided, because `Close` named anything else is the defect"
params { on-close :reference }
body {
role :button
name "Close"
symbol "close"
style { labelling :symbol-only, shape :circle, prominence :quiet }
on { activate [ { do :emit, intent -> @params.on-close } ] }
}
}
search-bar {
doc "a search field carrying its glyph, committing on the platform's search action"
params { name :text, at :reference, on-search :reference }
body {
role :search-field
name bound -> @params.name
value bound -> @params.at
symbol "search"
style { shape :pill }
on { submit [ { do :emit, intent -> @params.on-search, payload { query bound -> @params.at } } ] }
}
}
-- =================================================================
-- CONTROLS
-- =================================================================
segmented-control {
doc "one of a small closed set, laid out as adjacent segments. A radio group, because that is its tree; the segments are appearance"
params { name :text, at :reference, choices :reference }
native { web "radiogroup", flutter "SegmentedButton" }
body {
role :radio-group
name bound -> @params.name
value bound -> @params.at
options bound -> @params.choices
option-label -> @option.label
option-value -> @option.value
style { shape :pill, density :compact }
}
}
tag-input {
doc "several values typed or chosen, shown as tokens. A combobox with :multiple — the tokens are how a target draws a multiple selection"
params { name :text, at :reference, choices :reference }
body {
role :combobox
name bound -> @params.name
value bound -> @params.at
options bound -> @params.choices
option-label -> @option.label
option-value -> @option.value
multiple true
}
}
quantity {
doc "a bounded count with a label beside it"
params { label :text, at :reference, low :int, high :int }
body {
role :wrap
layout { axis :inline, across :center, gap :normal }
overflow :wrap
children [
{ role :label, text bound -> @params.label, labels -> @control },
@control {
role :stepper
name bound -> @params.label
value bound -> @params.at
min bound -> @params.low
max bound -> @params.high
step 1
},
]
}
}
colour-swatch {
doc "a colour choice shown as the colour itself. A colour-field — the swatch is the target's rendering of one"
params { name :text, at :reference }
body {
role :colour-field
name bound -> @params.name
value bound -> @params.at
style { shape :circle }
}
}
switch-row {
doc "a setting as a full-width row: what it is on one side, a switch on the other"
params { label :text, help :text, at :reference }
body {
role :wrap
layout { axis :inline, along :between, across :center, gap :normal }
overflow :wrap
children [
{
role :group
layout { axis :block, gap :tight }
children [
{ role :label, text bound -> @params.label, labels -> @switch },
{ role :text, text bound -> @params.help, when [:present, -> @params.help], style { type :caption, colour :muted } },
]
},
@switch { role :toggle, name bound -> @params.label, value bound -> @params.at },
]
}
}
-- =================================================================
-- STRUCTURE AND NAVIGATION
-- =================================================================
page-header {
doc "a title, an optional subtitle, and the actions that belong to the page"
params { heading :text, subtitle :text, actions :children }
body {
role :wrap
layout { axis :inline, along :between, across :start, gap :loose }
overflow :wrap
children [
{
role :group
layout { axis :block, gap :tight }
children [
{ role :heading, text bound -> @params.heading, style { type :title } },
{ role :text, text bound -> @params.subtitle, when [:present, -> @params.subtitle], style { colour :muted } },
]
},
{ role :toolbar, layout { axis :inline, gap :tight }, children -> @params.actions },
]
}
}
accordion-section {
doc "one titled, collapsible section. An accordion is several of these in a stack, which is why the group is not part of the entry"
params { heading :text, content :children }
body {
role :disclosure
name bound -> @params.heading
children -> @params.content
}
}
confirm-dialog {
doc "a question with two answers, the destructive one never the default"
params { heading :text, question :message, confirm-label :text, on-confirm :reference, on-cancel :reference }
native { web "dialog", flutter "AlertDialog" }
body {
role :dialog
name bound -> @params.heading
layout { axis :block, gap :normal }
children [
{ role :text, text { message -> @params.question } },
{
role :wrap
layout { axis :inline, along :end, gap :normal }
overflow :wrap
children [
{
role :button
name "Cancel"
style { prominence :quiet }
on { activate [ { do :emit, intent -> @params.on-cancel } ] }
},
{
role :button
name bound -> @params.confirm-label
style { prominence :strong }
on { activate [ { do :emit, intent -> @params.on-confirm } ] }
},
]
},
]
}
}
drawer {
doc "content from the edge of the surface, over what was there"
params { heading :text, content :children, on-close :reference }
native { web "dialog", flutter "Drawer" }
body {
role :sheet
name bound -> @params.heading
layout { axis :block, gap :normal }
children [
{
role :wrap
layout { axis :inline, along :between, across :center }
overflow :wrap
children [
{ role :heading, text bound -> @params.heading, style { type :heading } },
{ role -> @derived.close-button, with { on-close -> @params.on-close } },
]
},
{ role :scroll, children -> @params.content },
]
}
}
list-row {
doc "the row every list on every platform has: a glyph, a title, a second line, and something at the end"
params { title :text, subtitle :text, symbol :text, trailing :children }
body {
role :list-item
children [
{
role :wrap
layout { axis :inline, across :center, gap :normal }
overflow :wrap
children [
{ role :icon, symbol bound -> @params.symbol, when [:present, -> @params.symbol], decorative true },
{
role :group
layout { axis :block, gap :tight }
children [
{ role :text, text bound -> @params.title },
{ role :text, text bound -> @params.subtitle, when [:present, -> @params.subtitle], style { type :caption, colour :muted } },
]
},
{ role :group, layout { axis :inline, gap :tight }, children -> @params.trailing },
]
},
]
}
}
tree-item {
doc "one node of a hierarchy: its own label, and the nested list its children live in. A tree is a :list of these, and the nesting is the tree"
params { label :text, expanded :reference, children-of :reference, on-toggle :reference }
native { web "treeitem", flutter "ExpansionTile" }
body {
role :list-item
state { expanded bound -> @params.expanded }
children [
{
role :button
name bound -> @params.label
symbol "chevron"
style { labelling :symbol-and-name, prominence :quiet }
on { activate [ { do :emit, intent -> @params.on-toggle } ] }
},
{
role :list
name bound -> @params.label
when bound -> @params.expanded
each { of -> @params.children-of, as :child, key -> @child.id }
children [
{ role :list-item, children [ { role :text, text bound -> @child.label } ] },
]
},
]
}
}
sortable-column {
doc "a table column that can be ordered by. The direction is in the tree, because a column that sorts silently is the commonest data-table defect there is"
params { label :text, direction :symbol, on-sort :reference }
body {
role :table-header
text bound -> @params.label
sort bound -> @params.direction
on { activate [ { do :emit, intent -> @params.on-sort, payload { column bound -> @params.label } } ] }
}
}
pagination {
doc "which page of many, and how to reach the others"
params { name :text, pages :reference, on-choose :reference }
body {
role :navigation
name bound -> @params.name
each { of -> @params.pages, as :page, key -> @page.number }
children [
@page-link {
role :link
name bound -> @page.label
state { current bound -> @page.is-current }
on { activate [ { do :emit, intent -> @params.on-choose, payload { page bound -> @page.number } } ] }
},
]
}
}
wizard {
doc "steps in order, where a later step is unreachable until the ones before it are done. Tabs, with the reachability in :enabled-when"
params { name :text, at :reference, steps :children }
body {
role :tabs
name bound -> @params.name
value bound -> @params.at
children -> @params.steps
}
}
-- =================================================================
-- STATUS AND FEEDBACK
-- =================================================================
empty-state {
doc "what to show where content would be, and the one thing to do about it"
params { heading :text, explanation :message, action-label :text, on-action :reference }
body {
role :group
layout { axis :block, across :center, gap :normal }
children [
{ role :icon, symbol "empty", decorative true },
{ role :heading, text bound -> @params.heading, style { type :heading } },
{ role :text, text { message -> @params.explanation }, style { colour :muted } },
{
role :button
name bound -> @params.action-label
when [:present, -> @params.action-label]
style { prominence :strong }
on { activate [ { do :emit, intent -> @params.on-action } ] }
},
]
}
}
banner {
doc "a standing message about the whole surface, with an optional action. An :alert, so it is announced"
params { message :message, action-label :text, on-action :reference }
body {
role :wrap
style { colour :warning, shape :rounded, space :normal }
layout { axis :inline, across :center, gap :normal }
overflow :wrap
children [
{ role :icon, symbol "warning", decorative true },
{ role :alert, text { message -> @params.message } },
{
role :button
name bound -> @params.action-label
when [:present, -> @params.action-label]
style { prominence :quiet }
on { activate [ { do :emit, intent -> @params.on-action } ] }
},
]
}
}
stat {
doc "one number with what it counts. The caption is the label and the number is the value, in that order in the tree and the other way round on screen"
params { label :text, at :reference }
body {
role :group
layout { axis :block, gap :tight }
children [
{ role :label, text bound -> @params.label, labels -> @figure },
@figure { role :text, value bound -> @params.at, style { type :display } },
]
}
}
field-with-help {
doc "a text field, its label, and the help line that is announced with it rather than after it"
params { label :text, help :text, at :reference }
body {
role :group
layout { axis :block, gap :tight }
children [
{ role :label, text bound -> @params.label, labels -> @input },
@input {
role :text-field
name bound -> @params.label
value bound -> @params.at
description bound -> @params.help
},
{ role :text, text bound -> @params.help, style { type :caption, colour :muted } },
]
}
}
}
rationale {
-- ===================================================================
-- WHAT THIS FILE IS NOT
-- ===================================================================
it-is-not-a-design-system "THERE ARE NO COLOURS, SIZES OR SPACINGS BEYOND
TOKENS, because the kernel forbids them and a library that smuggled them
in would defeat the one property that makes a document portable.
SO TWO TARGETS RENDERING `card` WILL NOT LOOK ALIKE, and are not meant to.
They will MEAN alike, which is what the conformance comparison tests."
it-is-not-CLOSED "A LIBRARY THAT CLAIMED COMPLETENESS WOULD BE MAKING THE
KERNEL'S MISTAKE ONE LAYER UP. Anybody may write another of these, and no
generator has to be told. The measure of this file is whether the widgets
an author reaches for first are here, not whether every widget is."
-- ===================================================================
-- WHAT IS DELIBERATELY ABSENT
-- ===================================================================
absent [
| widget why-not
| "a rich text editor" "no portable decomposition and no portable capability. :rich-text exists as a capability precisely so a document can refuse rather than pretend"
| "a data grid" "not one widget. A table, sortable columns, a pagination and a toolbar are four entries here, and gluing them into one would hide which part an author wanted"
| "a chart" "@vocab/diagram/v0 and @vocab/graphic/v0 exist and this is not their kernel"
| "a map" "a viewport onto an external tile service is a capability nobody has portably"
| "a carousel" "asked for often and decomposes to a :scroll with :overflow :page. An entry would add a name and nothing else"
| "a toast queue" "snackbar is the widget; a QUEUE is application state, and this library holds no state"
| "a virtualised list" "a rendering strategy, not a tree. The tree of a virtualised list and a plain one are identical, which is exactly why it is not here"
| "a range slider" "WITHDRAWN AFTER IT WAS WRITTEN. Flutter has one RangeSlider and the web has no two-handled range, so the two targets project different trees and the vocabulary pinned neither. See a-range-is-not-two-of-anything"
]
the-absences-are-the-same-test "EACH ROW ABOVE FAILS ONE OF THE TWO
CONDITIONS: it has no honest decomposition, or it decomposes to something
already writable in one line. A library that admitted the second kind
would grow without bound and teach authors to look here before looking at
the kernel, which is the habit that made the kernel look small."
-- ===================================================================
-- SCRUTINY
-- ===================================================================
scrutiny "WHERE TO ATTACK THIS FILE.
NOT ONE ENTRY HAS BEEN GENERATED YET. They parse and they typecheck
against the element contract, and that is all that is known. Until a
document using these runs through both tools/uigen and the Flutter
generator and the two trees are diffed, this is a set of claims about
decomposition rather than a demonstration of it.
`native` IS UNTESTED EVERYWHERE. Several entries carry hints and no
generator reads them. If they turn out to be unimplementable the entries
are still correct — the hint is advisory by construction — but the file
would be advertising a route nobody has walked.
THE PARAMETER LISTS ARE GUESSES. `card` takes a heading and content; the
first real use will want a footer, a media area, or a click target for
the whole card. Each of those is a new parameter or a second entry, and
which one is the interesting question this file cannot answer alone.
AND `tree-item` IS THE WEAKEST ENTRY. Its nested list projects as nested
lists, not as a native tree, so a target with a real tree control has to
recognise the derivation to reach it. That is the mechanism working as
designed, but it is also the case where expanding the body gives
noticeably the worse interface, and it should be checked before anybody
relies on it."
}
}