Fosi Fossil Service
Small teams. Open source. Built on Fossil.

CaaS / Lamina

Unified Management for Fossil Repositories

Public | Owner: CaaS | Default branch: trunk | Last update: 2026-10-25 00:41

Artifact 309b5672716b0dfc538ad401051d709bbcfc331fc3fe5ac0ce9daee3b235eef5:


;; 共享视觉层:公开页与管理后台均按 docs 参考图排版。
(defmodule lamina_ui
  (export all))

(defun public-css ()
  (lists:flatten
   (list
    ":root{color-scheme:light only;--ink:#1f2328;--muted:#656d76;--link:#1f2328;--line:#d0d7de;--bg:#ffffff;--fill:#f6f8fa;--btn:#1f2328}"
    "*{box-sizing:border-box}html,body{margin:0;background:var(--bg);color:var(--ink);"
    "font-family:-apple-system,BlinkMacSystemFont,\"Segoe UI\",Helvetica,Arial,sans-serif;font-size:16px;line-height:1.5}"
    "a{color:var(--ink);text-decoration:none}a:hover{color:var(--muted);text-decoration:none}"
    ".shell{max-width:980px;margin:0 auto;padding:0 32px 28px}"
    ".nav{display:flex;align-items:center;gap:16px;height:32px;border-bottom:1px solid var(--line)}"
    ".brand{font-size:16px;font-weight:700;color:var(--ink);letter-spacing:-.02em;text-decoration:none;margin-right:8px}"
    ".brand:hover{text-decoration:none}.nav-links{display:flex;gap:16px;font-size:13px;font-weight:400}"
    ".nav-links a,.signin,.signin a{color:var(--ink);text-decoration:none}"
    ".nav-links a:hover,.signin a:hover{color:var(--muted);text-decoration:none}"
    ".signin{margin-left:auto;font-size:13px;display:flex;align-items:center;gap:14px}.tagline{text-align:right;color:var(--muted);font-size:13px;padding:8px 0 4px}"
    "main{padding:18px 0 8px}h1{font-size:32px;font-weight:600;letter-spacing:-.03em;margin:0 0 8px;color:var(--ink)}"
    ".lede{margin:0 0 28px;color:var(--muted);font-size:16px}"
    ".section{margin:28px 0 8px}.section h2{font-size:20px;font-weight:600;margin:0 0 10px;padding-bottom:8px;border-bottom:1px solid var(--line)}"
    ".section p{color:var(--ink);margin:12px 0}"
    "table{width:100%;border-collapse:collapse}th,td{text-align:left;padding:10px 12px 10px 0;border-bottom:1px solid #eaeef2;vertical-align:top}"
    "th{font-size:14px;font-weight:600;color:var(--ink)}td{font-size:14px;color:var(--ink)}"
    "th:last-child,td:last-child{text-align:left;white-space:nowrap;color:var(--muted)}"
    "a.repo{color:var(--ink);font-weight:600}"
    "tr.group td{border-bottom:0;padding:18px 0 6px;font-size:13px;font-style:italic;color:var(--muted)}"
    ".clone{background:var(--fill);border:1px solid var(--line);border-radius:6px;padding:12px 16px;overflow:auto;"
    "font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;color:var(--ink);margin:14px 0}"
    "label{display:block;font-weight:600;font-size:14px;margin:18px 0 0}"
    "input,textarea,select{display:block;width:100%;max-width:640px;margin-top:6px;padding:7px 12px;border:1px solid var(--line);"
    "border-radius:6px;font:inherit;background:#fff}textarea{min-height:108px}"
    ".btn{display:inline-block;background:var(--btn);color:#fff;border:0;border-radius:6px;padding:8px 16px;font-size:14px;"
    "font-weight:500;cursor:pointer;margin-top:22px;text-decoration:none}.btn:hover{background:#32383f;text-decoration:none;color:#fff}"
    ".btn-ghost{background:#fff;color:var(--ink);border:1px solid var(--line)}"
    ".hint{color:var(--muted);font-size:14px}.err{color:#9b1c1c;background:#ffebe9;border:1px solid #ff8182;border-radius:6px;padding:10px 12px;margin:16px 0;max-width:640px}"
    ".foot{display:flex;justify-content:space-between;gap:16px;align-items:center;border-top:1px solid var(--line);margin-top:48px;padding:16px 0 8px;color:var(--muted);font-size:13px}"
    ".foot nav{display:flex;gap:18px}.foot a{color:var(--muted)}.foot a:hover{color:var(--ink);text-decoration:none}"
    ".login-block{max-width:640px}.login-block h2{font-size:20px;font-weight:600;margin:0 0 10px}.rule{border:0;border-top:1px solid var(--line);margin:36px 0 28px}"
    ".empty{color:var(--muted);padding:8px 0 24px}"
    ".nav-links a.active{font-weight:650}"
    ".docs-shell{max-width:1120px}"
    ".docs{display:grid;grid-template-columns:240px minmax(0,1fr);gap:40px;align-items:start;padding-top:12px}"
    ".docs-side{position:sticky;top:18px;max-height:calc(100dvh - 36px);overflow:auto;padding-right:8px}"
    ".docs-side h2{font-size:12px;font-weight:650;letter-spacing:.04em;text-transform:uppercase;color:var(--muted);margin:18px 0 6px}"
    ".docs-side h2:first-child{margin-top:0}"
    ".docs-side ul{list-style:none;margin:0;padding:0}"
    ".docs-side a{display:block;padding:4px 8px;border-radius:6px;font-size:14px;color:var(--ink);text-decoration:none}"
    ".docs-side a:hover{background:#f6f8fa;color:var(--ink);text-decoration:none}"
    ".docs-side a.active{background:#f3f4f6;font-weight:650}"
    ".docs-body{min-width:0}"
    ".docs-body h1{font-size:32px}"
    ".docs-body h2{font-size:22px;font-weight:650;margin:28px 0 8px;padding-bottom:6px;border-bottom:1px solid var(--line)}"
    ".docs-body h3{font-size:18px;font-weight:650;margin:22px 0 8px}"
    ".docs-body p,.docs-body ul,.docs-body ol,.docs-body blockquote,.docs-body pre,.docs-body table{margin:12px 0}"
    ".docs-body a{text-decoration:underline;text-underline-offset:2px}"
    ".docs-body a:hover{color:var(--muted)}"
    ".docs-body ul,.docs-body ol{padding-left:1.3em}"
    ".docs-body li{margin:4px 0}"
    ".docs-body blockquote{margin-left:0;padding:2px 0 2px 14px;border-left:3px solid var(--line);color:var(--muted)}"
    ".docs-body pre{background:var(--fill);border-radius:10px;padding:14px 16px;overflow:auto}"
    ".docs-body code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;background:#eef0f3;border-radius:6px;padding:1px 6px}"
    ".docs-body pre code{background:transparent;padding:0;font-size:13px}"
    ".docs-body img{max-width:100%;border-radius:8px}"
    ".docs-body table{display:block;overflow:auto;border-collapse:collapse}"
    ".docs-body th,.docs-body td{border-bottom:1px solid #eaeef2;padding:8px 16px 8px 0;white-space:normal;color:inherit;text-align:left}"
    ".docs-body hr{border:0;border-top:1px solid var(--line);margin:22px 0}"
    ".library-nav{display:flex;flex-wrap:wrap;gap:8px 18px;border-bottom:1px solid var(--line);margin-top:8px}"
    ".library-nav a{color:var(--muted);text-decoration:none;padding:8px 0;border-bottom:2px solid transparent;font-size:14px}"
    ".library-nav a.active,.library-nav a:hover{color:var(--ink);text-decoration:none}"
    ".library-nav a.active{font-weight:650;border-bottom-color:var(--ink)}"
    "@media(max-width:720px){.shell{padding:0 16px 24px}.nav{height:auto;min-height:32px;flex-wrap:wrap;padding:6px 0;gap:10px}"
    ".nav-links{order:3;width:100%;gap:16px}.signin{margin-left:auto}.tagline{text-align:left}h1{font-size:26px}"
    ".foot{display:block}.foot nav{margin-top:8px}th:last-child,td:last-child{white-space:normal}"
    ".docs{grid-template-columns:1fr;gap:18px}.docs-side{position:static;max-height:none}"
    ".docs-body h1{font-size:26px}}")))

(defun admin-css ()
  (lists:flatten
   (list
    ":root{color-scheme:light only;--ink:#1f2328;--muted:#656d76;--link:#1f2328;--line:#d0d7de;--bg:#f6f8fa;--panel:#fff;--active:#f3f4f6}"
    "*{box-sizing:border-box}html,body{margin:0;background:var(--bg);color:var(--ink);"
    "font-family:-apple-system,BlinkMacSystemFont,\"Segoe UI\",Helvetica,Arial,sans-serif}"
    "a{color:var(--ink);text-decoration:none}a:hover{color:var(--muted);text-decoration:none}"
    ".app{display:flex;min-height:100dvh}"
    ".side{width:248px;flex:none;background:var(--panel);border-right:1px solid var(--line);padding:20px 14px 24px}"
    ".brand{font-size:18px;font-weight:700;color:var(--ink);text-decoration:none;display:block}"
    ".brand:hover{color:var(--muted);text-decoration:none}"
    ".brand-sub{font-size:12px;color:var(--muted);margin:2px 0 22px}"
    ".side-nav a{display:flex;align-items:center;gap:10px;padding:8px 12px;border-radius:8px;color:var(--ink);margin-bottom:2px;font-size:14px}"
    ".side-nav a:hover{background:#f3f4f6;text-decoration:none;color:var(--ink)}.side-nav a.active{background:var(--active);color:var(--ink);font-weight:600}"
    ".side-nav svg{flex:none;opacity:.85}"
    ".workspace{flex:1;min-width:0;display:flex;flex-direction:column}"
    ".topbar{display:flex;justify-content:flex-end;align-items:center;gap:12px;padding:14px 28px 0}"
    ".topbar .hint{color:var(--ink);text-decoration:none;font-size:14px}.topbar .hint:hover{color:var(--muted);text-decoration:none}"
    ".btn{display:inline-flex;align-items:center;gap:6px;background:#1f2328;color:#fff;border:0;border-radius:6px;padding:7px 14px;"
    "font-size:14px;font-weight:500;cursor:pointer;text-decoration:none}.btn:hover{background:#32383f;color:#fff;text-decoration:none}"
    ".btn.secondary,.btn-ghost{background:#fff;color:var(--ink);border:1px solid var(--line)}.btn.secondary:hover,.btn-ghost:hover{background:#f6f8fa;color:var(--ink)}"
    ".user{display:inline-flex;align-items:center;gap:8px;color:var(--ink);font-size:14px;font-weight:500}"
    ".avatar{width:32px;height:32px;border-radius:50%;background:#1f2328;color:#fff;display:inline-flex;align-items:center;justify-content:center;font-weight:600}"
    ".content{padding:18px 32px 32px;flex:1}"
    "h1{font-size:28px;font-weight:600;letter-spacing:-.03em;margin:0 0 6px}"
    ".lede{margin:0;color:var(--muted)}"
    ".hello{display:flex;justify-content:space-between;gap:24px;align-items:flex-start;margin-bottom:22px}"
    ".hello-meta{text-align:right;color:var(--muted);font-size:13px;line-height:1.6}"
    ".cards{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px;margin-bottom:16px}"
    ".stat{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:16px 18px;display:flex;flex-direction:column;gap:10px}"
    ".stat-top{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:13px;font-weight:500}"
    ".stat-top svg{opacity:.75}.stat .big{font-size:28px;font-weight:650;letter-spacing:-.03em;line-height:1}"
    ".stat .sub{color:var(--muted);font-size:12px}"
    ".dash{display:grid;grid-template-columns:minmax(0,1.6fr) minmax(280px,1fr);gap:14px}"
    ".panel{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:16px 18px}"
    ".panel h2{font-size:16px;margin:0;font-weight:600}.panel-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px}"
    ".repo-row,.org-row{display:flex;align-items:flex-start;gap:12px;padding:12px 0;border-bottom:1px solid #eaeef2}"
    ".repo-row:last-child,.org-row:last-child{border-bottom:0}"
    ".repo-row .name,.org-row .name{font-weight:600;color:var(--ink)}"
    ".panel-head a,.topline a,.topline-actions a{color:var(--muted);font-size:14px}.panel-head a:hover,.topline a:hover,.topline-actions a:hover{color:var(--ink)}"
    ".repo-row p,.org-row p{margin:2px 0 0;color:var(--muted);font-size:13px}"
    ".grow{flex:1;min-width:0}.badge{font-size:12px;color:#1a7f37;background:#dafbe1;border-radius:999px;padding:2px 8px;margin:0 8px}"
    ".when{color:var(--muted);font-size:12px;white-space:nowrap;margin-left:auto}"
    ".org-mark{width:36px;height:36px;border-radius:10px;background:#f3f4f6;color:var(--ink);display:flex;align-items:center;justify-content:center;font-weight:700;font-size:12px;flex:none}"
    ".new-org{display:block;text-align:center;border:1px dashed var(--line);border-radius:8px;padding:10px;margin-top:8px;color:var(--muted);font-size:14px}"
    ".new-org:hover{border-color:var(--ink);color:var(--ink);text-decoration:none}"
    ".qa a{display:flex;align-items:center;gap:8px;padding:8px 0;color:var(--ink);font-size:14px}"
    ".banner{display:flex;gap:12px;align-items:flex-start;margin-top:16px;position:relative}"
    ".banner h2{margin-bottom:4px}.banner .clone{margin:10px 0 0;display:flex;justify-content:space-between;gap:12px;align-items:center}"
    ".banner button{border:0;background:transparent;color:var(--muted);cursor:pointer;font-size:18px}"
    ".clone{background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:10px 12px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px}"
    ".admin-foot{display:flex;justify-content:space-between;gap:16px;padding:8px 32px 20px;color:var(--muted);font-size:12px}"
    ".admin-foot nav{display:flex;gap:14px}.admin-foot a{color:var(--muted)}.admin-foot a:hover{color:var(--ink);text-decoration:none}"
    ".topline{display:flex;justify-content:space-between;align-items:flex-start;gap:16px;margin-bottom:16px}"
    ".tablebox{background:var(--panel);border:1px solid var(--line);border-radius:12px;overflow:auto}"
    "table{width:100%;border-collapse:collapse}th,td{text-align:left;padding:12px 16px;border-bottom:1px solid #eaeef2;font-size:14px}"
    "th{color:var(--muted);font-weight:600;font-size:12px;text-transform:none}"
    "label{display:block;font-weight:600;margin:16px 0 0;max-width:720px}"
    "input,textarea,select{display:block;width:100%;margin-top:6px;padding:8px 10px;border:1px solid var(--line);border-radius:6px;font:inherit}"
    "textarea{min-height:120px}fieldset{border:1px solid var(--line);border-radius:12px;padding:12px 16px 16px;margin:18px 0;max-width:720px;background:var(--panel)}"
    ".box,.cardbox{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:18px;max-width:720px}"
    ".hint{color:var(--muted);font-size:13px}.err{color:#9b1c1c;background:#ffebe9;border:1px solid #ff8182;border-radius:6px;padding:10px;margin:12px 0;max-width:720px}"
    ".notice{color:#1a7f37;background:#dafbe1;border:1px solid #4ac26b;border-radius:6px;padding:10px;margin:12px 0;max-width:720px}"
    ".danger{background:#cf222e;color:#fff;border:0;border-radius:6px;padding:8px 14px;cursor:pointer;font:inherit;font-size:14px;font-weight:500}"
    ".settings-section{margin-top:20px}.actions{margin:18px 0;display:flex;gap:8px;flex-wrap:wrap}"
    ".settings-page{max-width:800px}"
    ".settings-page .lede{margin:0 0 18px}"
    ".settings-card{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:20px 22px;margin:0 0 16px}"
    ".settings-card h2{font-size:16px;font-weight:600;margin:0 0 6px}"
    ".settings-card h3{font-size:13px;font-weight:600;margin:16px 0 6px}"
    ".settings-card .hint{margin:0 0 10px;max-width:none}"
    ".settings-grid{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:4px 16px}"
    ".settings-page label{margin-top:12px;max-width:none;font-size:13px}"
    ".settings-page input,.settings-page textarea,.settings-page select{width:100%;max-width:none}"
    ".settings-page textarea{min-height:88px}"
    ".settings-page .nav-area textarea{min-height:112px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px}"
    ".settings-page .span-2{grid-column:1/-1}"
    ".settings-actions{margin:4px 0 8px}"
    ".settings-page .btn{margin-top:8px}"
    ".library-page{max-width:none}"
    ".library-page .lede{max-width:68ch}"
    ".library-split{display:grid;grid-template-columns:320px minmax(0,1fr);gap:20px;align-items:start}"
    ".library-add{position:sticky;top:16px;margin:0}"
    ".library-head{display:flex;justify-content:space-between;align-items:baseline;margin:0 0 10px}"
    ".library-head h2{font-size:16px;font-weight:600;margin:0}"
    ".library-empty{margin:0;padding:14px 16px}"
    ".library-table{width:100%;table-layout:fixed}"
    ".library-table th{white-space:nowrap}"
    ".library-table th,.library-table td{padding:8px 10px}"
    ".library-table td{vertical-align:middle}"
    ".library-table input,.library-table select{margin:0;padding:6px 8px}"
    ".library-page .library-table select,.library-page .library-table td.library-name input{width:100%;min-width:0;max-width:100%}"
    ".library-table td.library-name,.library-table th:nth-child(1){width:9.5rem}"
    ".library-table td.library-repo,.library-table th:nth-child(2){width:6.5rem}"
    ".library-table td.library-branch,.library-table th:nth-child(4){width:6.5rem}"
    ".library-table td.library-order,.library-table th:nth-child(5){width:7.5rem}"
    ".library-table td.library-actions,.library-table th:nth-child(6){width:9.25rem}"
    ".library-table td.library-name,.library-table td.library-repo,.library-table td.library-branch,.library-table td.library-order,.library-table td.library-actions{white-space:nowrap}"
    ".library-table td.library-repo,.library-table td.library-address{overflow:hidden}"
    ".library-table .btn{margin:0;padding:5px 8px;font-size:13px;flex:none}"
    ".library-ops{display:inline-flex;gap:6px;align-items:center;white-space:nowrap;vertical-align:middle}"
    ".library-repo{font-weight:600}"
    ".library-addr{flex:1 1 12rem;min-width:0;overflow:hidden;color:var(--muted);font-size:13px;white-space:nowrap;text-overflow:ellipsis}"
    ".library-addr:hover{color:var(--ink)}"
    ".library-meta{display:flex;flex-wrap:wrap;align-items:center;column-gap:12px;row-gap:4px;min-width:0}"
    ".library-facts{display:inline-flex;flex-wrap:wrap;justify-content:flex-end;gap:4px 14px;max-width:100%;margin-left:auto;color:var(--muted);font-size:13px}"
    ".library-facts b{color:var(--ink);font-weight:600;margin-right:4px}"
    ".library-miss{color:var(--muted)}"
    ".settings-page .notice{max-width:none}"
    ".repo-form-page{max-width:800px}"
    ".repo-form-page .topline{align-items:center;flex-wrap:wrap;margin-bottom:20px}"
    ".repo-form-page .topline .lede{margin:4px 0 0}"
    ".repo-form-page .topline-actions{flex:none}"
    ".topline .btn,.topline-actions .btn{margin-top:0}"
    ".topline a.btn,.topline-actions a.btn{color:#fff;font-size:14px}"
    ".topline a.btn:hover,.topline-actions a.btn:hover{color:#fff}"
    ".topline a.btn.secondary,.topline-actions a.btn.secondary{color:var(--ink)}"
    ".topline a.btn.secondary:hover,.topline-actions a.btn.secondary:hover{color:var(--ink)}"
    ".repo-form-page form>.form-actions{margin-bottom:24px}"
    ".choice-row{display:flex;gap:8px;margin-top:8px;flex-wrap:wrap}"
    ".settings-page .choice{flex:1;min-width:180px;display:flex;gap:10px;align-items:flex-start;margin:0;padding:12px 14px;border:1px solid var(--line);border-radius:10px;background:#fff;font-weight:500;cursor:pointer}"
    ".choice:has(input:checked){border-color:var(--ink);background:#f3f4f6}"
    ".settings-page .choice input{width:auto;margin:3px 0 0;padding:0;display:inline-block;border:0;box-shadow:none}"
    ".choice small{display:block;font-weight:400;color:var(--muted);margin-top:2px}"
    ".settings-page .check-row{display:flex;align-items:center;gap:8px;margin-top:12px;font-weight:500;max-width:none}"
    ".settings-page .check-row input{width:auto;margin:0;padding:0;display:inline-block;border:0;box-shadow:none}"
    "input[readonly]{background:#f6f8fa;color:var(--muted)}"
    ".form-actions{display:flex;gap:10px;align-items:center;margin-top:8px;flex-wrap:wrap}"
    ".org-form .form-actions{margin-top:22px}"
    ".form-actions .btn{margin-top:0}"
    ".form-actions a{color:var(--muted);font-size:14px}"
    ".topline-actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap}"
    ".pill{display:inline-flex;align-items:center;padding:3px 9px;border-radius:999px;font-size:12px;font-weight:600;border:1px solid var(--line);background:#fff;color:var(--muted)}"
    ".pill-up{color:#1a7f37;background:#dafbe1;border-color:#4ac26b}"
    ".pill-starting{color:#9a6700;background:#fff8c5;border-color:#d4a72c}"
    ".pill-down,.pill-unknown{color:#9b1c1c;background:#ffebe9;border-color:#ff8182}"
    ".meta-row{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:12px;margin-bottom:14px}"
    ".meta-item{background:#f6f8fa;border-radius:8px;padding:10px 12px}"
    ".meta-item span{display:block;color:var(--muted);font-size:12px;font-weight:600;margin-bottom:2px}"
    ".settings-card>.meta-item{margin-top:12px}"
    ".settings-card .meta-item a{word-break:break-all}"
    ".danger-card{border-color:#ffc1c0}"
    ".danger-card h2{color:#cf222e}"
    ".settings-page a.danger{display:inline-block;text-decoration:none;margin-top:16px}"
    ".ops-note{margin:4px 0 0;color:var(--muted);font-size:13px}"
    ".compact-table{margin:0}.compact-table th,.compact-table td{padding:8px 10px;font-size:13px}"
    ".log-empty{margin:0}"
    "code{background:#f6f8fa;padding:2px 6px;border-radius:4px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}"
    "@media(max-width:1100px){.cards{grid-template-columns:1fr 1fr}.dash{grid-template-columns:1fr}"
    ".library-split{grid-template-columns:1fr}.library-add{position:static}}"
    "@media(max-width:800px){.app{display:block}.side{width:auto;border-right:0;border-bottom:1px solid var(--line)}"
    ".side-nav{display:flex;flex-wrap:wrap}.content,.topbar,.admin-foot{padding-left:16px;padding-right:16px}"
    ".hello{display:block}.hello-meta{text-align:left;margin-top:8px}.cards{grid-template-columns:1fr}"
    ".settings-grid,.meta-row{grid-template-columns:1fr}}")))

(defun setup-css ()
  (lists:flatten
   (list
    ":root{color-scheme:light only;--ink:#1f2328;--muted:#656d76;--link:#1f2328;--line:#d0d7de}"
    "*{box-sizing:border-box}body{margin:0;background:#fff;color:var(--ink);"
    "font-family:-apple-system,BlinkMacSystemFont,\"Segoe UI\",Helvetica,Arial,sans-serif}"
    ".shell{max-width:1080px;margin:0 auto;padding:0 36px 24px}"
    ".top{display:flex;justify-content:space-between;align-items:flex-start;padding:22px 0 14px;border-bottom:1px solid var(--line)}"
    ".brand{color:var(--ink);font-size:22px;font-weight:700;text-decoration:none}.tag{margin:2px 0 0;color:var(--muted);font-size:13px}"
    ".top a{color:var(--ink);text-decoration:none}.top a:hover{color:var(--muted);text-decoration:none}"
    ".wrap{display:grid;grid-template-columns:200px minmax(0,1fr);gap:48px;padding:28px 0 40px}"
    ".steps div{padding:8px 12px;margin-bottom:2px;color:var(--ink);font-size:14px;border-radius:6px}"
    ".steps .cur{background:#f3f4f6;font-weight:600}"
    "h1{font-size:28px;margin:0 0 6px}h2{font-size:16px;margin:22px 0 8px}"
    ".kicker{color:var(--muted);margin:0 0 18px}.main p{max-width:62ch}"
    "hr{border:0;border-top:1px solid var(--line);margin:22px 0}"
    "label{display:block;font-weight:600;margin:16px 0 0}input{display:block;width:100%;max-width:520px;margin-top:6px;padding:8px 10px;border:1px solid var(--line);border-radius:6px;font:inherit}"
    "a{color:var(--ink);text-decoration:none}a:hover{color:var(--muted);text-decoration:none}"
    ".btn{background:#1f2328;color:#fff;border:0;border-radius:6px;padding:8px 16px;margin-top:22px;cursor:pointer;font-size:14px}"
    ".err{color:#9b1c1c;background:#ffebe9;border:1px solid #ff8182;border-radius:6px;padding:10px;margin:0 0 16px}"
    "ul.check{padding-left:18px}ul.check li{margin:6px 0}.ok{color:#1a7f37}.fail{color:#cf222e}"
    ".foot{display:flex;justify-content:space-between;border-top:1px solid var(--line);padding:14px 0;color:var(--muted);font-size:13px}"
    ".foot a{margin-left:14px;color:var(--muted);text-decoration:none}.foot a:hover{color:var(--ink)}"
    "@media(max-width:800px){.wrap{grid-template-columns:1fr;gap:18px}.shell{padding:0 16px}}")))

(defun public-nav (site-name)
  (public-nav site-name 'error))

(defun public-nav (site-name session)
  (public-nav site-name session 'none))

(defun public-nav (site-name session page)
  (list
   "<header class=\"nav\"><a class=\"brand\" href=\"/\">" (escape site-name) "</a>"
   "<nav class=\"nav-links\">"
   (nav-link "/" "Explore" (=:= page 'explore))
   (nav-link "/docs" "Docs" (=:= page 'docs))
   (nav-link "/library" "Library" (=:= page 'library))
   (lists:map #'nav-item-html/1 (lamina_site_mgr:nav-items))
   "</nav>"
   (nav-account session)
   "</header>"))

(defun nav-link (href label active)
  (list "<a href=\"" href "\""
        (if active " class=\"active\"" "")
        ">" (escape label) "</a>"))

(defun nav-item-html (item)
  (let ((label (maps:get 'label item ""))
        (url (maps:get 'url item "")))
    (list "<a href=\"" (escape url) "\""
          (if (or (lists:prefix "http://" url) (lists:prefix "https://" url))
              " target=\"_blank\" rel=\"noopener\""
              "")
          ">" (escape label) "</a>")))

(defun nav-account (session)
  (case session
    (`#(ok ,_)
     (list "<span class=\"signin\"><a href=\"/admin\">Dashboard</a>"
           "<a href=\"/logout\">Logout</a></span>"))
    (_
     "<a class=\"signin\" href=\"/login\">Sign in</a>")))

(defun public-tagline (tagline)
  (list "<div class=\"tagline\">" (escape tagline) "</div>"))

(defun public-footer (site)
  (let ((text (footer-text site))
        (terms (maps:get 'footer_terms_url site ""))
        (privacy (maps:get 'footer_privacy_url site ""))
        (contact (maps:get 'footer_contact_url site "")))
    (list "<footer class=\"foot\"><span>" (escape text) "</span><nav>"
          (footer-link terms "Terms")
          (footer-link privacy "Privacy")
          (footer-link contact "Contact")
          "</nav></footer>")))

(defun footer-text (site)
  (let ((text (to-str (maps:get 'footer_text site "")))
        (name (to-str (maps:get 'site_name site "lamina"))))
    (if (=:= "" text)
        (lists:flatten (list name " — simple code hosting for small teams."))
        text)))

(defun footer-link (url label)
  (if (=:= "" (to-str url))
      ""
      (list "<a href=\"" (escape url) "\">" label "</a>")))

(defun public-head (title)
  (public-head title "shell"))

(defun public-head (title shell-class)
  (list "<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\">"
        "<meta name=\"color-scheme\" content=\"light only\">"
        "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">"
        "<title>" (escape title) "</title><style>" (public-css) "</style></head><body><div class=\""
        shell-class "\">"))

(defun clone-box (command)
  (list "<pre class=\"clone\">" (escape command) "</pre>"))

(defun clone-command (root path)
  (lists:flatten
   (list "fossil clone " (trim-right-slash (to-str root)) (to-str path))))

(defun greeting (username)
  (let* ((now (calendar:local_time))
         (hour (element 1 (element 2 now)))
         (hello (cond
                  ((< hour 12) "Good morning")
                  ((< hour 18) "Good afternoon")
                  ('true "Good evening"))))
    (lists:flatten (list hello ", " (to-str username)))))

(defun today-label ()
  (let* ((date (element 1 (calendar:local_time)))
         (y (element 1 date))
         (m (element 2 date))
         (d (element 3 date)))
    (lists:flatten
     (io_lib:format "~s, ~s ~p, ~p"
                    (list (weekday date) (month-name m) d y)))))

(defun weekday (date)
  (case (calendar:day_of_the_week date)
    (1 "Monday") (2 "Tuesday") (3 "Wednesday") (4 "Thursday")
    (5 "Friday") (6 "Saturday") (7 "Sunday")
    (_ "Today")))

(defun month-name (m)
  (case m
    (1 "Jan") (2 "Feb") (3 "Mar") (4 "Apr") (5 "May") (6 "Jun")
    (7 "Jul") (8 "Aug") (9 "Sep") (10 "Oct") (11 "Nov") (12 "Dec")
    (_ "")))

(defun relative-time (stamp)
  (let ((s (to-str stamp)))
    (if (=:= "" s)
        "No activity yet"
        (case (catch (parse-stamp s))
          (`#(EXIT ,_) s)
          (then
           (let ((diff (- (os:system_time 'second) then)))
             (cond
               ((< diff 0) s)
               ((< diff 90) "just now")
               ((< diff 3600) (lists:flatten (io_lib:format "~p minutes ago" (list (div diff 60)))))
               ((< diff 86400) (lists:flatten (io_lib:format "~p hours ago" (list (div diff 3600)))))
               ((< diff 604800) (lists:flatten (io_lib:format "~p days ago" (list (div diff 86400)))))
               ((< diff 2419200) (lists:flatten (io_lib:format "~p weeks ago" (list (div diff 604800)))))
               ('true s))))))))

(defun parse-stamp (s)
  (let ((normalized (string:replace (string:replace s " " "T" 'all) " " "T" 'leading)))
    (let ((candidate (if (lists:member 84 normalized)
                         (if (or (lists:member 90 normalized) (lists:member 43 normalized))
                             normalized
                             (lists:flatten (list normalized "Z")))
                         s)))
      (calendar:rfc3339_to_system_time candidate (maps:from_list (list (tuple 'unit 'second)))))))

(defun icon (name)
  (case name
    ("repo" "<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" aria-hidden=\"true\"><path fill=\"currentColor\" d=\"M2 2.5A2.5 2.5 0 0 1 4.5 0h8.75a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1 0-1.5h1.75v-2h-8a1 1 0 0 0-.714 1.7.75.75 0 1 1-1.072 1.05A2.495 2.495 0 0 1 2 11.5v-9Zm10.5-1h-8a1 1 0 0 0-1 1v6.708A2.486 2.486 0 0 1 4.5 9h8ZM4.5 11.5a1 1 0 1 0 0 2h8.75a.75.75 0 0 0 0-1.5H4.5Z\"/></svg>")
    ("org" "<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" aria-hidden=\"true\"><path fill=\"currentColor\" d=\"M1.75 0A1.75 1.75 0 0 0 0 1.75v12.5C0 15.216.784 16 1.75 16h12.5A1.75 1.75 0 0 0 16 14.25V1.75A1.75 1.75 0 0 0 14.25 0ZM1.5 1.75a.25.25 0 0 1 .25-.25h12.5a.25.25 0 0 1 .25.25V4.5h-13ZM1.5 6h4v9h-3.75a.25.25 0 0 1-.25-.25Zm5.5 9V6h7.5v8.75a.25.25 0 0 1-.25.25Z\"/></svg>")
    ("disk" "<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" aria-hidden=\"true\"><path fill=\"currentColor\" d=\"M2.5 1.75v11.5c0 .192.168.25.25.25h10.5a.25.25 0 0 0 .25-.25V4.56L10.94 1.5H2.75a.25.25 0 0 0-.25.25ZM1 1.75C1 .784 1.784 0 2.75 0h8.69c.464 0 .91.184 1.237.513l2.81 2.81A1.75 1.75 0 0 1 16 4.56v8.69A1.75 1.75 0 0 1 14.25 15H2.75A1.75 1.75 0 0 1 1 13.25ZM4.75 13h6.5a.75.75 0 0 0 0-1.5h-6.5a.75.75 0 0 0 0 1.5ZM4 6.25a.75.75 0 0 1 .75-.75h6.5a.75.75 0 0 1 0 1.5h-6.5A.75.75 0 0 1 4 6.25Z\"/></svg>")
    ("clock" "<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" aria-hidden=\"true\"><path fill=\"currentColor\" d=\"M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm7-3.25v3.02l2.14 1.27a.75.75 0 1 1-.76 1.3L7.03 8.73a.75.75 0 0 1-.28-.52V4.75a.75.75 0 0 1 1.5 0Z\"/></svg>")
    ("plus" "<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" aria-hidden=\"true\"><path fill=\"currentColor\" d=\"M7.75 2a.75.75 0 0 1 .75.75V7h4.25a.75.75 0 0 1 0 1.5H8.5v4.25a.75.75 0 0 1-1.5 0V8.5H2.75a.75.75 0 0 1 0-1.5H7V2.75A.75.75 0 0 1 7.75 2Z\"/></svg>")
    ("gear" "<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" aria-hidden=\"true\"><path fill=\"currentColor\" d=\"M8 0a1.5 1.5 0 0 1 1.48 1.22l.12.58a5.5 5.5 0 0 1 1.3.75l.52-.28a1.5 1.5 0 0 1 1.95.4l1 1.73a1.5 1.5 0 0 1-.27 1.9l-.45.4c.06.34.1.7.1 1.06 0 .37-.04.72-.1 1.07l.45.39a1.5 1.5 0 0 1 .27 1.9l-1 1.73a1.5 1.5 0 0 1-1.95.4l-.52-.28a5.5 5.5 0 0 1-1.3.75l-.12.58A1.5 1.5 0 0 1 8 16a1.5 1.5 0 0 1-1.48-1.22l-.12-.58a5.5 5.5 0 0 1-1.3-.75l-.52.28a1.5 1.5 0 0 1-1.95-.4l-1-1.73a1.5 1.5 0 0 1 .27-1.9l.45-.39A6.5 6.5 0 0 1 2 8c0-.36.04-.72.1-1.06l-.45-.4a1.5 1.5 0 0 1-.27-1.9l1-1.73a1.5 1.5 0 0 1 1.95-.4l.52.28a5.5 5.5 0 0 1 1.3-.75l.12-.58A1.5 1.5 0 0 1 8 0Zm0 5a3 3 0 1 0 0 6 3 3 0 0 0 0-6Z\"/></svg>")
    ("compass" "<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" aria-hidden=\"true\"><path fill=\"currentColor\" d=\"M8.5.75a.75.75 0 0 0-1.5 0v5.19L3.47 2.47a.75.75 0 0 0-1.06 1.06L5.94 7H.75a.75.75 0 0 0 0 1.5h5.19l-3.53 3.53a.75.75 0 1 0 1.06 1.06L7 9.56v5.69a.75.75 0 0 0 1.5 0V9.56l3.53 3.53a.75.75 0 1 0 1.06-1.06L9.56 8.5h5.69a.75.75 0 0 0 0-1.5H9.56l3.53-3.53a.75.75 0 1 0-1.06-1.06L8.5 5.94Z\"/></svg>")
    ("home" "<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" aria-hidden=\"true\"><path fill=\"currentColor\" d=\"M6.906.64a1.75 1.75 0 0 1 2.187 0l5.25 4.2c.415.332.657.835.657 1.367v7.04A1.75 1.75 0 0 1 13.25 15h-3.5a.75.75 0 0 1-.75-.75V9H7v5.25a.75.75 0 0 1-.75.75h-3.5A1.75 1.75 0 0 1 1 13.25V6.207c0-.532.242-1.035.657-1.366Z\"/></svg>")
    ("book" "<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" aria-hidden=\"true\"><path fill=\"currentColor\" d=\"M0 1.75A.75.75 0 0 1 .75 1h4.253c1.227 0 2.317.59 3 1.501A3.744 3.744 0 0 1 11.006 1h4.245a.75.75 0 0 1 .75.75v10.5a.75.75 0 0 1-.75.75h-4.507a2.25 2.25 0 0 0-1.591.659l-.622.621a.75.75 0 0 1-1.06 0l-.622-.621A2.25 2.25 0 0 0 5.258 13.5H.75a.75.75 0 0 1-.75-.75Zm7.251 10.324.004-5.073-.002-2.253A2.25 2.25 0 0 0 5.003 2.5H1.5v9h3.757a3.75 3.75 0 0 1 1.994.574ZM8.755 4.75l-.004 7.322a3.752 3.752 0 0 1 1.992-.572H14.5v-9h-3.495a2.25 2.25 0 0 0-2.25 2.25Z\"/></svg>")
    ("library" "<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" aria-hidden=\"true\"><path fill=\"currentColor\" d=\"M1.75 1.5h3.5A.75.75 0 0 1 6 2.25v11.5a.75.75 0 0 1-.75.75h-3.5a.75.75 0 0 1-.75-.75V2.25a.75.75 0 0 1 .75-.75Zm5 2h3.5a.75.75 0 0 1 .75.75v9.5a.75.75 0 0 1-.75.75h-3.5a.75.75 0 0 1-.75-.75V4.25a.75.75 0 0 1 .75-.75Zm5-1h2.5A.75.75 0 0 1 15 3.25v10.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1-.75-.75V3.25a.75.75 0 0 1 .75-.75Z\"/></svg>")
    ("help" "<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" aria-hidden=\"true\"><path fill=\"currentColor\" d=\"M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.92 6.085h.001a.75.75 0 1 1-1.342-.67c.169-.339.436-.701.849-.977C6.845 4.16 7.369 4 8 4c.763 0 1.337.182 1.72.407.385.226.673.55.836.973.168.43.188.956-.056 1.496-.256.557-.699.943-1.156 1.168-.455.222-.858.254-1.098.254a.75.75 0 0 1 0-1.5c.032 0 .217-.03.47-.154.255-.125.425-.33.514-.546.082-.2.07-.313.05-.364C9.22 5.654 9.1 5.5 8 5.5c-.369 0-.595.09-.74.187a1.05 1.05 0 0 0-.34.398ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z\"/></svg>")
    (_ "")))

(defun initials (name)
  (let ((s (to-str name)))
    (cond
      ((=:= "" s) "??")
      ((> (length s) 1) (upper-two (lists:sublist s 1 2)))
      ('true (upper-two s)))))

(defun upper-two (chars)
  (lists:map
   (lambda (c)
     (if (and (>= c 97) (=< c 122)) (- c 32) c))
   chars))

(defun html (code body)
  (let ((b (if (is_binary body) body (list_to_binary (lists:flatten body)))))
    `#(status ,code #M(#"content-type" #"text/html; charset=utf-8") ,b)))

(defun trim-right-slash (value)
  (let ((s (to-str value)))
    (if (and (> (length s) 0) (=:= (lists:last s) 47))
        (trim-right-slash (lists:sublist s 1 (- (length s) 1)))
        s)))

(defun to-str (value)
  (cond
    ((is_binary value) (binary_to_list value))
    ((is_list value) value)
    ((or (=:= 'undefined value) (=:= 'null value)) "")
    ('true (lists:flatten (io_lib:format "~p" (list value))))))

(defun escape (value)
  (let ((s (to-str value)))
    (string:replace
     (string:replace
      (string:replace
       (string:replace
        (string:replace s "&" "&amp;" 'all)
        "<" "&lt;" 'all)
       ">" "&gt;" 'all)
      "\"" "&quot;" 'all)
     "'" "&#39;" 'all)))

Clone

You can clone this repository with the following command:

fossil clone https://fosi.dev/CaaS/Lamina

Contribute

The best way to contribute is through e-mail. See the project documentation for more details.