starting new job...
→ running command: dir='/etc/byggsteg/job-clone//byggsteg/trunk'
branch='trunk'
if ! command -v git >/dev/null 2>&1; then
echo 'byggsteg: git is not on this deployment PATH, so the clone cannot be updated' >&2
exit 1
fi
if ! cd "$dir"; then
echo "byggsteg: cannot enter the clone $dir" >&2
exit 1
fi
root=$(git rev-parse --show-toplevel 2>/dev/null)
if [ "$root" != "$(pwd -P)" ]; then
echo "byggsteg: $dir is not a git checkout; remove it and the next run clones it again" >&2
exit 1
fi
if ! origin=$(git remote get-url origin 2>/dev/null); then
echo "byggsteg: $dir has no origin remote to fetch $branch from" >&2
exit 1
fi
before=$(git rev-parse --short HEAD 2>/dev/null) || before=none
attempt=1
while :; do
if GIT_TERMINAL_PROMPT=0 git fetch --quiet origin "+refs/heads/$branch:refs/remotes/origin/$branch"; then
break
fi
if [ "$attempt" -ge 3 ]; then
echo "byggsteg: could not fetch $branch from $origin after 3 attempts; is the branch still there, and is the remote reachable?" >&2
exit 1
fi
echo "byggsteg: fetching $branch from $origin failed; retrying in $((attempt * 2))s" >&2
sleep $((attempt * 2))
attempt=$((attempt + 1))
done
if ! git checkout --quiet --force -B "$branch" FETCH_HEAD; then
echo "byggsteg: fetched $branch from $origin but could not check it out" >&2
exit 1
fi
after=$(git rev-parse --short HEAD)
if [ "$before" = "$after" ]; then
echo "byggsteg: $branch was already at $after"
else
echo "byggsteg: $branch moved from $before to $after"
fi
byggsteg: trunk was already at fc2f115
→ running command: cd /etc/byggsteg/job-clone/byggsteg/trunk && uname -a
Linux wolk-jjba 7.2.11-gnu #1 SMP PREEMPT_DYNAMIC 1 x86_64 GNU/Linux
→ running command: cd /etc/byggsteg/job-clone/byggsteg/trunk && git log --name-status HEAD^..HEAD
commit fc2f115af54efcf982f9db2b98ce8e9cbac39dd1
Author: Josep Bigorra <jjbigorra@gmail.com>
Date: Wed Nov 4 23:22:46 2026 +0100
feat: Put the project logo in the navigation bar
M lib/byggsteg/html.scm
M pub/favicon.ico
M pub/img/byggsteg-logo.png
A pub/img/byggsteg-logo.svg
→ running command: cd /etc/byggsteg/job-clone/byggsteg/trunk && maak test
scanning directories for unit tests...
scanning present working directory for unit tests...
shuffled the files with seed 651288570 (replay with VERITAS_SEED=651288570)
found unit tests: (artifact-spec.scm error-pages-spec.scm console-resources-spec.scm runners-spec.scm password-reset-spec.scm job-search-spec.scm logs-page-spec.scm retention-spec.scm ansi-spec.scm about-page-spec.scm sqlite-spec.scm container-spec.scm heatmap-spec.scm password-strength-spec.scm user-bootstrap-spec.scm log-streaming-spec.scm tasks-spec.scm form-width-spec.scm logs-live-spec.scm terminal-glass-spec.scm api-v2-spec.scm clones-spec.scm analytics-spec.scm footer-version-spec.scm job-pipeline-spec.scm deployment-identity-spec.scm retry-spec.scm log-poll-spec.scm job-pipelines-spec.scm)
running unit tests from file: artifact-spec.scm, entrypoint: spec
ℹ️ info - shuffled with seed 651288570 (replay with VERITAS_SEED=651288570)
+----------------------------------------------+
[📑 suite] artifacts
[🧪 test] the table a collection writes to is the table the migrations make
[⚙️ assert] the artifact migration adds the artifact table
✅ got: (id job_id title artifact_path artifact_size created_at updated_at)
[🧪 test] a collected tarball is recorded, and the job it was built by finds it
[⚙️ assert] an artifact is read back by its id
✅ got: #t
[⚙️ assert] the artifact keeps the job that collected it
✅ got: spec-artifact-job
[⚙️ assert] the artifact keeps the size it was written with
✅ got: 2048
[⚙️ assert] both collections of one job are its artifacts
✅ got: (((id . spec-artifact-1) (job-id . spec-artifact-job) (title . byggsteg-job.tar.gz) (artifact-path . /tmp/byggsteg-artifact-spec-24568/spec-artifact-job.tar.gz) (artifact-size . 2048) (created-at . 1793831287) (updated-at . #f)) ((id . spec-artifact-2) (job-id . spec-artifact-job) (title . byggsteg-job-2.tar.gz) (artifact-path . /tmp/byggsteg-artifact-spec-24568/spec-artifact-job-2.tar.gz) (artifact-size . 4096) (created-at . 1793831287) (updated-at . #f)))
[⚙️ assert] a job nothing was collected for draws no artifacts
✅ got: ()
[🧪 test] only a file inside the artifact directory may be downloaded
[⚙️ assert] no row outside the artifact directory is servable
✅ got: ()
[⚙️ assert] a recorded tarball inside it is servable
✅ got: #t
[⚙️ assert] a row with no path is not servable
✅ got: #f
[🧪 test] a job asks for one tarball of bare paths, or a bundle per name
[⚙️ assert] a list of paths is one unnamed bundle, in the order written
✅ got: ((#f dist/wikimusic-api docs/manual))
[⚙️ assert] each named bundle keeps its name and its paths
✅ got: ((binaries dist bin) (docs docs/manual))
[⚙️ assert] bare paths and bundles mix, and keep the order they were written in
✅ got: ((#f README.org) (binaries dist) (docs docs/manual))
[⚙️ assert] a bundle written as one list is a name and the paths behind it
✅ got: ((binaries dist bin))
[⚙️ assert] a name written as a symbol is read as the name it spells
✅ got: ((binaries dist))
[⚙️ assert] one path written without its list is still one path
✅ got: ((#f dist/app))
[⚙️ assert] a field of nothing asks for nothing
✅ got: ()
[⚙️ assert] a bundle that names no path is dropped rather than packed empty
✅ got: ();;; note: auto-compilation is enabled, set GUILE_AUTO_COMPILE=0
;;; or pass the --no-auto-compile argument to disable.
;;; compiling ./test/veritas/unit/artifact-spec.scm
;;; compiling ./lib/byggsteg/artifacts.scm
;;; compiling ./lib/byggsteg/preferences.scm
;;; compiled /root/.cache/guile/ccache/3.0-LE-8-4.7/etc/byggsteg/job-clone/byggsteg/trunk/lib/byggsteg/preferences.scm.go
;;; compiling ./lib/byggsteg/upkeep.scm
;;; compiled /root/.cache/guile/ccache/3.0-LE-8-4.7/etc/byggsteg/job-clone/byggsteg/trunk/lib/byggsteg/upkeep.scm.go
;;; compiling ./lib/byggsteg/domain/artifact.scm
;;; compiling ./lib/byggsteg/domain/repo.scm
;;; compiled /root/.cache/guile/ccache/3.0-LE-8-4.7/etc/byggsteg/job-clone/byggsteg/trunk/lib/byggsteg/domain/repo.scm.go
;;; compiled /root/.cache/guile/ccache/3.0-LE-8-4.7/etc/byggsteg/job-clone/byggsteg/trunk/lib/byggsteg/domain/artifact.scm.go
;;; compiled /root/.cache/guile/ccache/3.0-LE-8-4.7/etc/byggsteg/job-clone/byggsteg/trunk/lib/byggsteg/artifacts.scm.go
;;; compiling ./lib/byggsteg/sqlite/artifact.scm
;;; compiling ./lib/byggsteg/sqlite/util.scm
;;; compiled /root/.cache/guile/ccache/3.0-LE-8-4.7/etc/byggsteg/job-clone/byggsteg/trunk/lib/byggsteg/sqlite/util.scm.go
;;; compiled /root/.cache/guile/ccache/3.0-LE-8-4.7/etc/byggsteg/job-clone/byggsteg/trunk/lib/byggsteg/sqlite/artifact.scm.go
;;; compiled /root/.cache/guile/ccache/3.0-LE-8-4.7/etc/byggsteg/job-clone/byggsteg/trunk/test/veritas/unit/artifact-spec.scm.go
;;; compiling ./test/veritas/unit/error-pages-spec.scm
;;; compiling ./test/veritas/support/deployment.scm
;;; compiled /root/.cache/guile/ccache/3.0-LE-8-4.7/etc/byggsteg/job-clone/byggsteg/trunk/test/veritas/support/deployment.scm.go
;;; compiling ./lib/byggsteg/errors.scm
;;; compiling ./lib/byggsteg/faces.scm
;;; compiling ./lib/byggsteg/ansi.scm
;;; compiled /root/.cache/guile/ccache/3.0-LE-8-4.7/etc/byggsteg/job-clone/byggsteg/trunk/lib/byggsteg/ansi.scm.go
WARNING: (hygguile hygguile): imported module (hygguile components select) overrides core binding `select'
;;; compiled /root/.cache/guile/ccache/3.0-LE-8-4.7/etc/byggsteg/job-clone/byggsteg/trunk/lib/byggsteg/faces.scm.go
;;; compiling ./lib/byggsteg/html.scm
;;; compiling ./lib/byggsteg/permissions.scm
;;; compiled /root/.cache/guile/ccache/3.0-LE-8-4.7/etc/byggsteg/job-clone/byggsteg/trunk/lib/byggsteg/permissions.scm.go
;;; compiling ./lib/byggsteg/version.scm
;;; compiled /root/.cache/guile/ccache/3.0-LE-8-4.7/etc/byggsteg/job-clone/byggsteg/trunk/lib/byggsteg/version.scm.go
;;; compiling ./lib/byggsteg/job/pipeline.scm
;;; compiling ./lib/byggsteg/job/steps.scm
;;; compiling ./lib/byggsteg/container.scm
;;; compiled /root/.cache/guile/ccache/3.0-LE-8-4.7/etc/byggsteg/job-clone/byggsteg/trunk/lib/byggsteg/container.scm.go
;;; compiling ./lib/byggsteg/job/runners.scm
;;; compiled /root/.cache/guile/ccache/3.0-LE-8-4.7/etc/byggsteg/job-clone/byggsteg/trunk/lib/byggsteg/job/runners.scm.go
;;; compiling ./lib/byggsteg/job/streaming.scm
;;; compiling ./lib/byggsteg/domain/log.scm
;;; compiled /root/.cache/guile/ccache/3.0-LE-8-4.7/etc/byggsteg/job-clone/byggsteg/trunk/lib/byggsteg/domain/log.scm.go
;;; compiled /root/.cache/guile/ccache/3.0-LE-8-4.7/etc/byggsteg/job-clone/byggsteg/trunk/lib/byggsteg/job/streaming.scm.go
;;; compiled /root/.cache/guile/ccache/3.0-LE-8-4.7/etc/byggsteg/job-clone/byggsteg/trunk/lib/byggsteg/job/steps.scm.go
;;; compiling ./lib/byggsteg/job/tasks.scm
;;; compiled /root/.cache/guile/ccache/3.0-LE-8-4.7/etc/byggsteg/job-clone/byggsteg/trunk/lib/byggsteg/job/tasks.scm.go
;;; compiling ./lib/byggsteg/domain/job.scm
;;; compiled /root/.cache/guile/ccache/3.0-LE-8-4.7/etc/byggsteg/job-clone/byggsteg/trunk/lib/byggsteg/domain/job.scm.go
;;; compiling ./lib/byggsteg/sqlite/job.scm
;;; compiled /root/.cache/guile/ccache/3.0-LE-8-4.7/etc/byggsteg/job-clone/byggsteg/trunk/lib/byggsteg/sqlite/job.scm.go
;;; compiling ./lib/byggsteg/sqlite/log.scm
;;; compiled /root/.cache/guile/ccache/3.0-LE-8-4.7/etc/byggsteg/job-clone/byggsteg/trunk/lib/byggsteg/sqlite/log.scm.go
;;; compiled
[🧪 test] forgetting a job's artifacts takes the tarballs and the rows
[⚙️ assert] both go, and a row naming a file outside the directory leaves it alone
✅ got: ((forgotten 3) (rows-left 0) (tarballs-gone (#f #f)) (a-file-outside-the-directory-was-left-alone #t))
[🧪 test] a sweep removes the tarballs no row points at, and nothing else
[⚙️ assert] the old orphan goes; the fresh one, the named one, a name byggsteg never writes and a directory stay
✅ got: ((removed (/tmp/byggsteg-artifact-spec-24568/orphan-old.tar.gz)) (the-old-orphan-is-gone #f) (the-fresh-one-is-kept #t) (one-a-row-still-names-is-kept #t) (a-name-byggsteg-never-writes-is-left #t) (a-directory-is-left #t))
[🧪 test] a download is offered under a name a header can carry
[⚙️ assert] a title becomes a plain file name
✅ got: my-build--trunk-..-x.tar.gz
[⚙️ assert] the second tarball of one job is numbered
✅ got: byggsteg-job-2.tar.gz
[⚙️ assert] a named bundle is offered under its own name
✅ got: (binaries.tar.gz binaries-2.tar.gz docs-manual.tar.gz)
[⚙️ assert] a size is read in the unit a reader can hold in mind
✅ got: 2.0 KiB
ℹ️ info - ran 1 tasks in: 0 seconds, 97566 microseconds
running unit tests from file: error-pages-spec.scm, entrypoint: spec
ℹ️ info - shuffled with seed 651288570 (replay with VERITAS_SEED=651288570)
+----------------------------------------------+
[📑 suite] error pages
[🧪 test] a page a reader can act on
[⚙️ assert] a missing credential is answered with the way to sign in
✅ got: /login
[⚙️ assert] a path that answers nothing still leads to the job queue
✅ got: /logs
[⚙️ assert] a server-side failure wears the red hairline
✅ got: border-redwood-700
[⚙️ assert] a client error keeps the phosphor hairline
✅ got: border-redwood-700
[🧪 test] Artanis is handed a page for each status
[⚙️ assert] every status has a handler that answers with a string
✅ got: ()
[⚙️ assert] a status the table does not name still gets a page
✅ got: #t
[⚙️ assert] and it names the status it was asked for
✅ got: 418 unexpected status
[🧪 test] a page is built by the components rather than describing them
[⚙️ assert] no page carries a component call instead of its element
✅ got: ()
[⚙️ assert] the 404 page heads with the code and traces it in a pre
✅ got: ()
[🧪 test] each status answers with a page of this deployment's console
[⚙️ assert] the 400 page carries the chrome, the code and its words
✅ got: ()
[⚙️ assert] the 401 page carries the chrome, the code and its words
✅ got: ()
[⚙️ assert] the 404 page carries the chrome, the code and its words
✅ got: ()
[⚙️ assert] the 500 page carries the chrome, the code and its words
✅ got: ()
[🧪 test] no page closes an element on itself
[⚙️ assert] no element of a status page swallows what follows it
✅ got: ()
ℹ️ info - ran 1 tasks in: 0 seconds, 93486 microseconds
running unit tests from file: console-resources-spec.scm, entrypoint: spec
ℹ️ info - shuffled with seed 651288570 (replay with VERITAS_SEED=651288570)
+----------------------------------------------+
[📑 suite] the console's own appearance, served by the deployment itself
[🧪 test] and the typography sheet names faces the deployment has
[⚙️ assert] the sheet declares the three families it bundles
✅ got: 10
[⚙️ assert] and the deployment has work-sans-italic-latin-ext.woff2 to serve
✅ got: #t
[⚙️ assert] and the deployment has work-sans-italic-latin.woff2 to serve
✅ got: #t
[⚙️ assert] and the deployment has work-sans-normal-latin-ext.woff2 to serve
✅ got: #t
[⚙️ assert] and the deployment has work-sans-normal-latin.woff2 to serve
✅ got: #t
[⚙️ assert] and the deployment has source-serif-4-italic-latin-ext.woff2 to serve
✅ got: #t
[⚙️ assert] and the deployment has source-serif-4-italic-latin.woff2 to serve
✅ got: #t
[⚙️ assert] and the deployment has source-serif-4-normal-latin-ext.woff2 to serve
✅ got: #t
[⚙️ assert] and the deployment has source-serif-4-normal-latin.woff2 to serve
✅ got: #t
[⚙️ assert] and the deployment has juliamono-normal-latin.woff2 to serve
✅ got: #t
[⚙️ assert] and the deployment has juliamono-bold-latin.woff2 to serve
✅ got: #t
[🧪 test] and serves every sheet it names itself
[⚙️ assert] the page names the console's own sheets, and no others
✅ got: (/css/fonts.css /css/phosphor.css /css/byggsteg.olive.min.css)
[⚙️ assert] and serves /css/fonts.css, which it has
✅ got: #t
[⚙️ assert] and serves /css/phosphor.css, which it has
✅ got: #t
[⚙️ assert] and serves /css/byggsteg.olive.min.css, which it has
✅ got: #t
[⚙️ assert] and serves /img/byggsteg-logo.png, which it has
✅ got: #t
[🧪 test] no page of the console asks anybody else for it
[⚙️ assert] the page asks nobody at fonts.googleapis.com
✅ got: fonts.googleapis.com
[⚙️ assert] the page asks nobody at fonts.gstatic.com
✅ got: fonts.gstatic.com
[⚙️ assert] the page asks nobody at cdn.jsdelivr.net
✅ got: cdn.jsdelivr.net
[⚙️ assert] the page asks nobody at unpkg.com
✅ got: unpkg.com
[⚙️ assert] the page asks nobody at cdnjs.cloudflare.com
✅ got: cdnjs.cloudflare.com
[⚙️ assert] the page asks nobody at /static-assets/
✅ got: /static-assets/
[🧪 test] a deployment that keeps its assets elsewhere names that host
[⚙️ assert] every sheet moves to the asset host, together
✅ got: (https://assets.example.org/css/fonts.css https://assets.example.org/css/phosphor.css https://assets.example.org/css/byggsteg.olive.min.css)
[⚙️ assert] and none of them is left at the deployment's own path
✅ got: <!DOCTYPE html><html lang="en" data-theme="dark"><head><title>console resources spec</title><script type="text/javascript">
(function () {
var root = document.documentElement
var choice = null
try { choice = localStorage.getItem('data-theme') } catch (error) { choice = null }
var scheme = function () {
root.style.colorScheme = root.getAttribute('data-theme') === 'light' ? 'light' : 'dark'
}
root.setAttribute('data-theme', choice === 'light' ? 'light' : 'dark')
scheme()
if (typeof MutationObserver !== 'undefined') {
new MutationObserver(scheme).observe(root, {attributes: true, attributeFilter: ['data-theme']})
}
})()
</script><meta name="viewport" content="width=device-width, initial-scale=1.0" /><meta charset="UTF-8" /><meta name="description" content="byggsteg is a hackable and extensible CI/CD orchestrator written in Guile Scheme: a pipeline is Lisp code, a running job's log is read while it is written, and the runner, the logs, the artifacts and the users are all yours to host." /><meta name="robots" content="index, follow" /><link rel="canonical" href="http://127.0.0.1:3000/" /><meta property="og:type" content="website" /><meta property="og:site_name" content="byggsteg" /><meta property="og:title" content="console resources spec" /><meta property="og:description" content="byggsteg is a hackable and extensible CI/CD orchestrator written in Guile Scheme: a pipeline is Lisp code, a running job's log is read while it is written, and the runner, the logs, the artifacts and the users are all yours to host." /><meta property="og:url" content="http://127.0.0.1:3000/" /><meta property="og:image" content="https://assets.example.org/img/byggsteg-logo.png" /><meta name="twitter:card" content="summary_large_image" /><meta name="twitter:title" content="console resources spec" /><meta name="twitter:description" content="byggsteg is a hackable and extensible CI/CD orchestrator written in Guile Scheme: a pipeline is Lisp code, a running job's log is read while it is written, and the runner, the logs, the artifacts and the users are all yours to host." /><meta name="twitter:image" content="https://assets.example.org/img/byggsteg-logo.png" /><link rel="icon" type="image/svg+xml" href="https://assets.example.org/img/byggsteg-logo.svg"></link><link rel="shortcut icon" type="image/x-icon" href="https://assets.example.org/favicon.ico"></link><link rel="stylesheet" href="https://assets.example.org/css/fonts.css" /><link rel="preload" as="style" onload="this.onload=null;this.rel='stylesheet'" href="https://assets.example.org/css/phosphor.css" /><noscript><link rel="stylesheet" href="https://assets.example.org/css/phosphor.css" /></noscript><style type="text/css">/*
hygguile - cozy and professional user-interfaces for everyone
Copyright © Josep Bigorra <jjbigorra@gmail.com>
hygguile is free software: you can redistribute it and/or modify
it under the terms of the Lesser GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
hygguile is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
Lesser GNU General Public License for more details.
You should have received a copy of the Lesser GNU General Public License
along with hygguile. If not, see <https://www.gnu.org/licenses/>.
*/
/* Flyout menu / dropdown */
.flyout-submenu-button[aria-expanded="true"]::after {
content: "↑";
padding-left: 0.8em;
}
.flyout-submenu-button[aria-expanded="false"]::after {
content: "↓";
padding-left: 0.8em;
}
.flyout-submenu[hidden] {
display: none;
}
</style><style type="text/css">/*
hygguile - cozy and professional user-interfaces for everyone
Copyright © Josep Bigorra <jjbigorra@gmail.com>
hygguile is free software: you can redistribute it and/or modify
it under the terms of the Lesser GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
hygguile is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
Lesser GNU General Public License for more details.
You should have received a copy of the Lesser GNU General Public License
along with hygguile. If not, see <https://www.gnu.org/licenses/>.
*/
/*
Companion stylesheet for hygguile.
Olive CSS intentionally only ships utility classes. A handful of
components need a little structural CSS that cannot be expressed with
single utilities (the styling of native <progress> and <details>, the
CSS-only tabs, switch, modal and tooltip widgets). Everything here is
plain, dependency-free CSS and works without JavaScript.
Wherever Olive already provides the feature we lean on its utilities
instead of reimplementing it: screen-reader-only text uses `sr-only`,
loading animations use `animate-spin` and `animate-pulse`, and focus
rings use the `focus-visible:outline-*` variants.
*/
/* ===== A couple of missing intrinsic-size utilities ===== */
.h-auto {
height: auto;
}
.w-auto {
width: auto;
}
/* ===== Spinner =====
Only the ring visual lives here; the rotation is Olive's
`animate-spin` utility, applied by the component itself. */
.hg-spinner {
border-radius: 50%;
border: 0.25rem solid rgba(120, 113, 108, 0.35);
border-top-color: #57534e;
}
[data-theme="dark"] .hg-spinner {
border-color: rgba(214, 211, 209, 0.25);
border-top-color: #d6d3d1;
}
/* ===== A readable default ink =====
Components carry their own colour classes, but text a page does not
decorate must still be readable, and Olive ships no base ink of its own.
Both rules land before olive.min.css in the document, so a `text-*`
utility anywhere in the markup still wins over them. */
body {
color: #1c1917;
}
[data-theme="dark"] body {
color: #ededee;
}
/* ===== Native progress bar ===== */
.hg-progress {
width: 100%;
height: 1rem;
border: 0;
border-radius: 81.92rem;
overflow: hidden;
appearance: none;
-webkit-appearance: none;
background: rgba(120, 113, 108, 0.25);
}
.hg-progress::-webkit-progress-bar {
background: rgba(120, 113, 108, 0.25);
border-radius: 81.92rem;
}
.hg-progress::-webkit-progress-value {
background: #57534e;
border-radius: 81.92rem;
}
.hg-progress::-moz-progress-bar {
background: #57534e;
border-radius: 81.92rem;
}
[data-theme="dark"] .hg-progress,
[data-theme="dark"] .hg-progress::-webkit-progress-bar {
background: rgba(214, 211, 209, 0.2);
}
[data-theme="dark"] .hg-progress::-webkit-progress-value,
[data-theme="dark"] .hg-progress::-moz-progress-bar {
background: #d6d3d1;
}
/* ===== CSS-only tabs (no JavaScript) ===== */
.hg-tabs {
display: flex;
flex-direction: column;
}
.hg-tab-input {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.hg-tab-labels {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 0.25rem;
border-bottom: 2px solid rgba(120, 113, 108, 0.35);
}
.hg-tab-label {
cursor: pointer;
padding: 0.5rem 1rem;
border-radius: 0.75rem 0.75rem 0 0;
font-weight: 500;
/* 7.4:1 on the panel, not the 4.6:1 of a lighter grey: an unselected
label is still text and still has to be read */
color: #57534e;
transition: color 0.15s ease, background-color 0.15s ease;
}
[data-theme="dark"] .hg-tab-label {
color: #d6d3d1;
}
.hg-tab-panels {
padding-top: 1rem;
}
.hg-tab-panel {
display: none;
}
/* active label highlight */
.hg-tabs:has(> .hg-tab-input:nth-of-type(1):checked) > .hg-tab-labels > .hg-tab-label:nth-child(1),
.hg-tabs:has(> .hg-tab-input:nth-of-type(2):checked) > .hg-tab-labels > .hg-tab-label:nth-child(2),
.hg-tabs:has(> .hg-tab-input:nth-of-type(3):checked) > .hg-tab-labels > .hg-tab-label:nth-child(3),
.hg-tabs:has(> .hg-tab-input:nth-of-type(4):checked) > .hg-tab-labels > .hg-tab-label:nth-child(4),
.hg-tabs:has(> .hg-tab-input:nth-of-type(5):checked) > .hg-tab-labels > .hg-tab-label:nth-child(5),
.hg-tabs:has(> .hg-tab-input:nth-of-type(6):checked) > .hg-tab-labels > .hg-tab-label:nth-child(6),
.hg-tabs:has(> .hg-tab-input:nth-of-type(7):checked) > .hg-tab-labels > .hg-tab-label:nth-child(7),
.hg-tabs:has(> .hg-tab-input:nth-of-type(8):checked) > .hg-tab-labels > .hg-tab-label:nth-child(8) {
background: rgba(120, 113, 108, 0.25);
color: #1c1917;
}
[data-theme="dark"] .hg-tabs:has(> .hg-tab-input:nth-of-type(1):checked) > .hg-tab-labels > .hg-tab-label:nth-child(1),
[data-theme="dark"] .hg-tabs:has(> .hg-tab-input:nth-of-type(2):checked) > .hg-tab-labels > .hg-tab-label:nth-child(2),
[data-theme="dark"] .hg-tabs:has(> .hg-tab-input:nth-of-type(3):checked) > .hg-tab-labels > .hg-tab-label:nth-child(3),
[data-theme="dark"] .hg-tabs:has(> .hg-tab-input:nth-of-type(4):checked) > .hg-tab-labels > .hg-tab-label:nth-child(4),
[data-theme="dark"] .hg-tabs:has(> .hg-tab-input:nth-of-type(5):checked) > .hg-tab-labels > .hg-tab-label:nth-child(5),
[data-theme="dark"] .hg-tabs:has(> .hg-tab-input:nth-of-type(6):checked) > .hg-tab-labels > .hg-tab-label:nth-child(6),
[data-theme="dark"] .hg-tabs:has(> .hg-tab-input:nth-of-type(7):checked) > .hg-tab-labels > .hg-tab-label:nth-child(7),
[data-theme="dark"] .hg-tabs:has(> .hg-tab-input:nth-of-type(8):checked) > .hg-tab-labels > .hg-tab-label:nth-child(8) {
background: rgba(214, 211, 209, 0.2);
color: #f5f5f4;
}
/* active panel */
.hg-tabs:has(> .hg-tab-input:nth-of-type(1):checked) > .hg-tab-panels > .hg-tab-panel:nth-child(1),
.hg-tabs:has(> .hg-tab-input:nth-of-type(2):checked) > .hg-tab-panels > .hg-tab-panel:nth-child(2),
.hg-tabs:has(> .hg-tab-input:nth-of-type(3):checked) > .hg-tab-panels > .hg-tab-panel:nth-child(3),
.hg-tabs:has(> .hg-tab-input:nth-of-type(4):checked) > .hg-tab-panels > .hg-tab-panel:nth-child(4),
.hg-tabs:has(> .hg-tab-input:nth-of-type(5):checked) > .hg-tab-panels > .hg-tab-panel:nth-child(5),
.hg-tabs:has(> .hg-tab-input:nth-of-type(6):checked) > .hg-tab-panels > .hg-tab-panel:nth-child(6),
.hg-tabs:has(> .hg-tab-input:nth-of-type(7):checked) > .hg-tab-panels > .hg-tab-panel:nth-child(7),
.hg-tabs:has(> .hg-tab-input:nth-of-type(8):checked) > .hg-tab-panels > .hg-tab-panel:nth-child(8) {
display: block;
}
/* keyboard focus */
.hg-tabs:has(> .hg-tab-input:nth-of-type(1):focus-visible) > .hg-tab-labels > .hg-tab-label:nth-child(1),
.hg-tabs:has(> .hg-tab-input:nth-of-type(2):focus-visible) > .hg-tab-labels > .hg-tab-label:nth-child(2),
.hg-tabs:has(> .hg-tab-input:nth-of-type(3):focus-visible) > .hg-tab-labels > .hg-tab-label:nth-child(3),
.hg-tabs:has(> .hg-tab-input:nth-of-type(4):focus-visible) > .hg-tab-labels > .hg-tab-label:nth-child(4),
.hg-tabs:has(> .hg-tab-input:nth-of-type(5):focus-visible) > .hg-tab-labels > .hg-tab-label:nth-child(5),
.hg-tabs:has(> .hg-tab-input:nth-of-type(6):focus-visible) > .hg-tab-labels > .hg-tab-label:nth-child(6),
.hg-tabs:has(> .hg-tab-input:nth-of-type(7):focus-visible) > .hg-tab-labels > .hg-tab-label:nth-child(7),
.hg-tabs:has(> .hg-tab-input:nth-of-type(8):focus-visible) > .hg-tab-labels > .hg-tab-label:nth-child(8) {
outline: 2px solid currentColor;
outline-offset: 2px;
}
/* ===== Switch backed by a native checkbox ===== */
.hg-switch-input {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.hg-switch {
position: relative;
display: inline-block;
flex: none;
width: 2.75rem;
height: 1.5rem;
border-radius: 81.92rem;
background: #a8a29e;
cursor: pointer;
transition: background 0.2s ease;
}
.hg-switch::after {
content: "";
position: absolute;
top: 0.125rem;
left: 0.125rem;
width: 1.25rem;
height: 1.25rem;
border-radius: 81.92rem;
background: #ffffff;
transition: transform 0.2s ease;
}
.hg-switch-input:checked ~ .hg-switch {
background: #57534e;
}
[data-theme="dark"] .hg-switch-input:checked ~ .hg-switch {
background: #d6d3d1;
}
.hg-switch-input:checked ~ .hg-switch::after {
transform: translateX(1.25rem);
}
.hg-switch-input:focus-visible ~ .hg-switch {
outline: 2px solid currentColor;
outline-offset: 2px;
}
/* ===== CSS-only modal via :target ===== */
.hg-modal {
display: none;
}
.hg-modal:target {
display: grid;
place-items: center;
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 64;
}
.hg-modal-backdrop {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background: rgba(12, 10, 9, 0.6);
}
.hg-modal-content {
position: relative;
z-index: 1;
width: 90%;
max-width: 34rem;
margin: 1rem;
padding: 1.25rem;
border-radius: 1rem;
box-shadow: 0 1.25rem 3rem rgba(0, 0, 0, 0.35);
background: #f5f5f4;
color: #1c1917;
}
[data-theme="dark"] .hg-modal-content {
background: #262626;
color: #f5f5f4;
}
.hg-modal-header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.75rem;
}
.hg-modal-close {
text-decoration: none;
font-size: 1.5rem;
line-height: 1;
color: inherit;
}
.hg-modal-body {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.hg-modal-footer {
display: flex;
flex-direction: row;
justify-content: flex-end;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 1rem;
}
/* ===== CSS-only tooltips ===== */
.hg-tooltip {
position: relative;
display: inline-flex;
align-items: center;
}
.hg-tooltip::after {
content: attr(data-hg-tooltip);
/* Hidden really means hidden: an absolutely positioned bubble still
occupies layout space, so a tooltip near the right edge would widen
the page and leave a phone layout that can be panned sideways.
`display: none` removes the box entirely, which is how the modal and
the fly-out menu already hide themselves. */
display: none;
position: absolute;
z-index: 64;
width: max-content;
max-width: 16rem;
padding: 0.35rem 0.6rem;
border-radius: 0.6rem;
background: #1c1917;
color: #f5f5f4;
font-size: 0.8rem;
line-height: 1.25;
pointer-events: none;
}
.hg-tooltip:hover::after,
.hg-tooltip:focus-visible::after {
display: block;
/* display cannot be transitioned, so the fade-in is an animation */
animation: hg-tooltip-in 0.15s ease;
}
@keyframes hg-tooltip-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.hg-tooltip-top::after {
bottom: 100%;
left: 50%;
transform: translate(-50%, -0.4rem);
}
.hg-tooltip-bottom::after {
top: 100%;
left: 50%;
transform: translate(-50%, 0.4rem);
}
.hg-tooltip-left::after {
right: 100%;
top: 50%;
transform: translate(-0.4rem, -50%);
}
.hg-tooltip-right::after {
left: 100%;
top: 50%;
transform: translate(0.4rem, -50%);
}
/* On a narrow viewport a wide bubble cannot always be centred on its
trigger without leaving the screen, so the vertical variants are
capped in width and aligned to the trigger's end edge instead. In
left-to-right writing, overflow to the left never makes a page pan
sideways, while overflow to the right does. */
@media (max-width: 40rem) {
.hg-tooltip-top::after,
.hg-tooltip-bottom::after {
left: auto;
right: 0;
max-width: calc(100vw - 3rem);
}
.hg-tooltip-top::after {
transform: translate(0, -0.4rem);
}
.hg-tooltip-bottom::after {
transform: translate(0, 0.4rem);
}
}
/* ===== Drifting colour washes =====
The wallpaper trick: a few oversized, heavily blurred discs of colour
behind a frosted panel. `.hg-blob` only adds the slow drift, so the
size, position, colour and blur stay in the markup where Olive already
expresses them (`blur-3xl`, `w-96`, `bg-naples-yellow-500/50`, ...).
Stagger the discs with an inline `animation-delay` so they never move
in lockstep.
How fast a disc drifts and how far it travels are the page's business,
not this sheet's: `glass-wash` writes `--hg-blob-duration`, `--hg-blob-x`
and `--hg-blob-y` as inline custom properties -- and an inline
`animation-delay` to stagger the discs -- so the values below are only
the defaults for a wash that says nothing. A keyframe resolves its
`var()`s per element, so one panel can breathe for a minute while the
next crosses its own corner in a couple of rems. */
.hg-blob {
animation: hg-blob-drift var(--hg-blob-duration, 26s) ease-in-out infinite alternate;
}
@keyframes hg-blob-drift {
from {
transform: translate3d(0, 0, 0) scale(1);
}
to {
transform: translate3d(var(--hg-blob-x, 1.5rem),
var(--hg-blob-y, -1.25rem),
0)
scale(var(--hg-blob-scale, 1.08));
}
}
/* ===== The layer the discs are painted in =====
A pane rounds the layer holding its washes and clips it, and neither the
rounding nor the clip stops a disc: a child with a `filter` is painted
with the blur that filter asks for, and the browser draws that blur into
the square box it reaches -- past the layer's rounded edge, into the
corner the radius curves away from. What is left on the page is a faint
square of wash beside each corner, a percent or two of ink: measured
against a mid-grey page, a corner square that should have held the
page's own 128 read 130. `overflow: clip`, `contain: paint`,
`isolation: isolate` and promoting the layer to one of its own all leave
it in place (each measured on its own); an explicit `clip-path` is what
clips it. And a clip has to name a radius -- it cannot inherit one the
way `border-radius: inherit` can.
So this class names it -- and names it off the very rounding the markup
gave the layer, since the layer wears the pane's own rounding classes.
The radius has to be restated as a length, so these rules restate
Olive's named ladder; they are the one place here that has to agree with
another sheet, and a spec pins the tokens the framework rounds its own
glass with to a rule apiece, so a rounding cannot lose its clip without
a test going red.
A radius given outright in `--hg-glass-clip' still wins, on the layer or
on any ancestor, which is what a caller rounding with one of Olive's
numeric steps (`rounded-24', say) reaches for, as does a pane whose
radius is not one of the named ones at all.
The default in each rule is `rounded-3xl', the rounding a pane wears
when a caller leaves `#:rounding' alone: a layer wearing something the
table below does not name still keeps its washes in. */
.hg-glass-layer {
clip-path: inset(0 round var(--hg-glass-clip, 1.28rem));
}
.hg-glass-layer[class~="rounded-none"] {
clip-path: inset(0 round var(--hg-glass-clip, 0));
}
.hg-glass-layer[class~="rounded-2xs"] {
clip-path: inset(0 round var(--hg-glass-clip, 0.16rem));
}
.hg-glass-layer[class~="rounded-xs"] {
clip-path: inset(0 round var(--hg-glass-clip, 0.32rem));
}
.hg-glass-layer[class~="rounded-sm"] {
clip-path: inset(0 round var(--hg-glass-clip, 0.48rem));
}
.hg-glass-layer[class~="rounded-md"] {
clip-path: inset(0 round var(--hg-glass-clip, 0.64rem));
}
.hg-glass-layer[class~="rounded-lg"] {
clip-path: inset(0 round var(--hg-glass-clip, 0.80rem));
}
.hg-glass-layer[class~="rounded-xl"] {
clip-path: inset(0 round var(--hg-glass-clip, 0.96rem));
}
.hg-glass-layer[class~="rounded-2xl"] {
clip-path: inset(0 round var(--hg-glass-clip, 1.12rem));
}
.hg-glass-layer[class~="rounded-3xl"] {
clip-path: inset(0 round var(--hg-glass-clip, 1.28rem));
}
.hg-glass-layer[class~="rounded-4xl"] {
clip-path: inset(0 round var(--hg-glass-clip, 1.44rem));
}
.hg-glass-layer[class~="rounded-5xl"] {
clip-path: inset(0 round var(--hg-glass-clip, 1.60rem));
}
.hg-glass-layer[class~="rounded-full"] {
clip-path: inset(0 round var(--hg-glass-clip, 9999px));
}
/* ===== Frost that survives a rounded corner =====
A backdrop-filter asks the browser to blur and colour whatever sits
behind a pane. Chromium paints that filtered backdrop in the pane's
square border box and ignores its `border-radius`, so a rounded pane
leaks a square patch of frost wherever its radius curves away from the
corner (Chromium issue 41212594, still open). A radius on that element
does not stop it, and a `clip-path` on it does not either. The one
thing that does is clipping the frosted box itself: an element that
carries `overflow: hidden` alongside its radius gets its frost rounded
off, because the browser then has a shape to clip the filtered backdrop
to.
The frost therefore lives one level in, on the pane's own `::before`:
a layer that rounds itself to the pane's radius with `border-radius:
inherit` -- so it follows whatever radius the markup or a caller gave
the pane -- clips itself, and carries the blur. Being a decoration
rather than a container, it leaves the pane's own children painted
above it and free to escape, which is what a bar holding a dropdown
needs.
How strong the frost is stays a knob: `--hg-frost-blur` is the blur
radius, 4rem unless something says otherwise (the same value Olive's
`backdrop-blur-md` utility sets). A custom property cannot be a utility
class on a pseudo-element, so it is the one setting that is passed as a
declaration rather than a class: `glass-panel' writes it from Scheme
for the panes it builds (`#:frost'), markup this sheet frosts by hand
passes the declaration itself, and a pane with neither reaches for
`hg-frost-soft' below instead.
`position: relative` gives the layer something to be absolute against
even when a pane places itself with layout classes alone. A pane that
sets a position utility of its own takes over, since olive.min.css is
inlined after this sheet.
`z-index: 0' on the pane and `-1' on the layer are what keep the frost
*under* the pane's content. An absolutely placed layer paints above
in-flow text and blocks, so a frosted pane whose content was never
wrapped in a layer of its own -- a dropdown's list is one -- would
otherwise have its contents blurred away behind the frost. A negative
stack level puts the layer in the first step of the pane's stacking
order instead, right after the pane's own background and border, which
is where the frost belongs. The pane was a stacking context anyway
while it carried the blur itself, so nothing about the order of what
is *outside* the pane changes, and the layer's own corner can no
longer paint over the pane's hairline. */
.hg-frost {
position: relative;
z-index: 0;
}
.hg-frost::before {
content: "";
position: absolute;
z-index: -1;
inset: 0;
border-radius: inherit;
overflow: hidden;
-webkit-backdrop-filter: blur(var(--hg-frost-blur, 4rem));
backdrop-filter: blur(var(--hg-frost-blur, 4rem));
}
/* A gentler frost, for the small panes the framework frosts with a class
rather than a declaration: the carousel's arrow discs, a couple of rems
across, would be all blur at a panel's strength. Olive's blur ladder
cannot reach a pseudo-element, so this sheet names the one step below
the default that something actually wears. */
.hg-frost-soft {
--hg-frost-blur: 3rem;
}
/* The smallest step, for the pills and labels a page frosts by hand: a
badge two rems tall is all blur at a pane's strength, and the label on a
colour swatch wants a hint of it and no more. Naming the step is what
keeps those pills on the same recipe as the panes -- the frost paints in
a rounded layer of its own, so the square patch of blur Olive's
`backdrop-blur-*' utilities leave at a rounded corner never appears. */
.hg-frost-pill {
--hg-frost-blur: 1rem;
}
/* ===== The heading type scale =====
Heading line-heights are ratios rather than lengths, because a heading is
sized by a utility the page may move up or down the scale at will: a
length would have to be re-picked whenever the font-size changed, and a
ratio follows on its own. The steps loosen as the size shrinks (1.3 for
the display sizes, 1.35 for a section title, 1.4 for the smaller
headings), which is the usual shape of a type scale, and every one of
them is loose enough that a line's glyphs -- descenders, accents, the lot
-- stay inside the box the line occupies.
That is a requirement, not a preference. Gradient ink is a background
clipped to the letters, so it can only paint where the heading's own box
reaches: a `g` whose descender falls below it is not cropped but drawn
transparent. The ratios here are measured against the two faces the
framework ships, of which Plus Jakarta Sans asks for the most room at
1.24 of the line box at worst; 1.3 is that with margin to spare. Body
copy sits looser still, at 1.5. */
.hg-leading-display {
line-height: 1.3;
}
.hg-leading-title {
line-height: 1.35;
}
.hg-leading-subtitle {
line-height: 1.4;
}
/* ===== Gradient text =====
One gradient per theme, since a deep sweep that sings on the light
background turns to mud on the dark one.
The compound `h1.`/`h2.` selectors are deliberate: Olive's text-colour
utilities are single classes, and this sheet is inlined before
olive.min.css, so a bare `.hg-gradient-text` would lose the cascade
and the heading would keep its solid colour.
Nothing here has to widen the box the ink is painted in, and that is the
heading scale's doing rather than this rule's: every heading it matches
carries a leaded line-height from `hg-leading-*' above, so the descenders
of a `g` sit inside the heading's own box and have gradient to paint.
A page that overrides the leading with a tighter one -- `leading-none' or
a `leading-*' of its own, which lands after this sheet -- takes the clip
back into its own hands. */
h1.hg-gradient-text,
h2.hg-gradient-text {
background-image: linear-gradient(100deg,
oklch(0.7404 0.1399 91.55 / 1),
oklch(0.6183 0.1557 45.26 / 1) 45%,
oklch(0.4929 0.161 279.98 / 1));
background-clip: text;
-webkit-background-clip: text;
color: transparent;
}
[data-theme="dark"] h1.hg-gradient-text,
[data-theme="dark"] h2.hg-gradient-text {
background-image: linear-gradient(100deg,
oklch(0.8855 0.1307 92.19 / 1),
oklch(0.7404 0.1656 39.8 / 1) 45%,
oklch(0.7439 0.1301 288.16 / 1));
}
/* ===== Expandable cards =====
An expandable card is a native <details>, so it opens and hides with no
script at all. The only structural help it asks for here is to drop the
browser's own summary marker -- the chevron in the summary is the
affordance, and it turns as the card opens -- and, because a card is also
what a section rail links to, to keep a header a link jumped to clear of
that rail. */
.hg-expandable-summary {
list-style: none;
/* deep enough for a card header to clear a sticky section rail above it */
scroll-margin-top: 7rem;
}
.hg-expandable-summary::-webkit-details-marker {
display: none;
}
details[open] > .hg-expandable-summary .hg-expandable-chevron {
transform: rotate(180deg);
}
/* ===== Showcase sort control =====
Three buttons offer to reorder the tray, and the chosen one is the one the
page's script has marked `aria-pressed="true"'. Olive tints every button
with a mid surface -- legible, but all three of them the same shape and
shade -- and a control that says which order you are reading has to be
unmistakable at a glance, so the pressed one is filled from the opposite
end of the ramp instead: the near-black of the ink in a light theme, the
near-white of a card in a dark theme, each carrying the ink that surface is
already known to carry. The fill is only the darkest or lightest step of
the palette the page is already built from; the contrast of every pair
below is the framework's own, and the gate checks it. */
.hg-sort-button[aria-pressed="true"] {
background-color: oklch(0.3588 0.0044 247.95 / 1);
color: oklch(0.987 0.0013 286.37);
border-color: oklch(0.987 0.0013 286.37 / 0.4);
}
[data-theme="dark"] .hg-sort-button[aria-pressed="true"] {
background-color: oklch(0.947 0.0013 286.37 / 1);
color: oklch(0.3588 0.0044 247.95);
border-color: oklch(0.3588 0.0044 247.95 / 0.4);
}
/* ===== Code samples =====
The panel that holds a specimen's source folds away until it is asked for,
and the `hidden' attribute is what folds it. The attribute is enough on its
own for an element with no display utility -- the browser's own rule hides
it -- but a panel lays itself out with `flex', and an author rule outbids
the user agent's `[hidden]' however low it sits in the cascade, which would
leave every specimen's source open on every page. So the fold is stated
here as the framework's own rule, at the strength it has to be stated at.
With scripting off the noscript stylesheet reveals the panel instead and
hides the control, so a reader who cannot fold the source still reads it. */
.hg-code-sample-panel[hidden] {
display: none !important;
}
/* ===== Respect reduced-motion preferences ===== */
@media (prefers-reduced-motion: reduce) {
.hg-switch,
.hg-switch::after,
.hg-tooltip::after,
.hg-tab-label,
.hg-blob {
transition: none;
animation: none;
}
}
</style><noscript><style type="text/css">/*
hygguile - cozy and professional user-interfaces for everyone
Copyright © Josep Bigorra <jjbigorra@gmail.com>
hygguile is free software: you can redistribute it and/or modify
it under the terms of the Lesser GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
hygguile is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
Lesser GNU General Public License for more details.
You should have received a copy of the Lesser GNU General Public License
along with hygguile. If not, see <https://www.gnu.org/licenses/>.
*/
/*
Progressive-enhancement rules that only apply when scripting is
disabled, because hygguile loads this file inside a <noscript> element
(see hygguile-recommended-resources).
The contract is simple:
- Anything that only works with JavaScript carries the .js-only class
and is hidden here.
- Components that can be made to work without JavaScript (the fly-out
submenus, the carousel) are progressively revealed instead of being
hidden.
*/
/* Interactive controls that depend on JavaScript do nothing without it. */
.js-only {
display: none !important;
}
/* Fly-out menus: reveal every submenu and drop the meaningless arrow. */
.flyout-submenu[hidden] {
display: block !important;
}
/* A code sample: the fold that hides the source needs scripting, and the
control that would lift it is a .js-only element, so without scripting
every specimen simply shows its own source rather than a dead button. */
.hg-code-sample-panel[hidden] {
display: flex !important;
}
.flyout-submenu {
position: static !important;
box-shadow: none !important;
background: transparent !important;
padding-left: 1rem !important;
}
.flyout-submenu-button::after {
content: "" !important;
padding-left: 0 !important;
}
</style></noscript><script type="text/javascript" defer="">/*
hygguile - cozy and professional user-interfaces for everyone
Copyright © Josep Bigorra <jjbigorra@gmail.com>
hygguile is free software: you can redistribute it and/or modify
it under the terms of the Lesser GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
hygguile is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
Lesser GNU General Public License for more details.
You should have received a copy of the Lesser GNU General Public License
along with hygguile. If not, see <https://www.gnu.org/licenses/>.
*/
function hygguileSaveDarkThemePreference(preference) {
document.documentElement.setAttribute('data-theme', preference)
localStorage.setItem('data-theme', preference)
}
function hygguileToggleDarkTheme() {
const currentTheme = document.documentElement.getAttribute('data-theme')
if (currentTheme == null) {
//
// by default set dark since hygguile has a light theme by default
//
hygguileSaveDarkThemePreference('dark')
} else {
if (currentTheme == 'dark') {
hygguileSaveDarkThemePreference('light')
} else {
hygguileSaveDarkThemePreference('dark')
}
}
}
</script><script type="text/javascript" defer="">/*
hygguile - cozy and professional user-interfaces for everyone
Copyright © Josep Bigorra <jjbigorra@gmail.com>
hygguile is free software: you can redistribute it and/or modify
it under the terms of the Lesser GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
hygguile is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
Lesser GNU General Public License for more details.
You should have received a copy of the Lesser GNU General Public License
along with hygguile. If not, see <https://www.gnu.org/licenses/>.
*/
function hygguileCopyPreInnerText(elementId) {
const id = elementId + '-pre'
console.log('copying inner text of element: ' + id)
var el = document.getElementById(id)
navigator.clipboard.writeText(el.innerText)
console.log(el.innerText)
}
</script><script>
document.addEventListener('DOMContentLoaded', () => {
const params = new URLSearchParams(window.location.search);
if(params.get('notify-body') == null && params.get('notify-title') == null){
return
}
const notificationTitle = params.get('notify-title') ?? ""
const notificationBody = params.get('notify-body') ?? ""
const notificationDurationMillis = params.get('notify-duration-millis') ?? 3000
const notificationBodyFontFamily = params.get('notify-body-font-family') ?? 'font-sans'
const notificationTray = document.getElementById('notification-tray')
notificationTray.innerHTML = "<div class=\"bg-stone-300/90 border-2 border-stone-500/25 dark:bg-onyx-700/75 dark:border-onyx-100/25 dark:text-stone-300 dark:text-stone-700 flex flex-col font-sans gap-4 hg-frost max-w-full max-w-prose px-5 py-5 relative rounded-xl text-onyx-100 text-onyx-300\"><div class=\"flex flex-row flex-wrap gap-10 items-center justify-between\"><h4 id=\"notification-title\" class=\"dark:text-asparagus-100 text-asparagus-900 text-xl\"></h4><i onclick=\"document.getElementById('notification-tray').innerHTML = ''\" class=\"cursor-pointer dark:text-asparagus-100 ph ph-x text-asparagus-900 text-xl\"></i></div><p id=\"notification-body\"></p></div>"
const notificationTitleNode = document.getElementById('notification-title')
const notificationBodyNode = document.getElementById('notification-body')
if(notificationTitleNode){
notificationTitleNode.textContent = notificationTitle
}
if(notificationBodyNode){
notificationBodyNode.textContent = notificationBody
notificationBodyNode.classList.toggle(notificationBodyFontFamily)
}
setTimeout(() => { notificationTray.innerHTML = '' }, notificationDurationMillis);
})
</script><script type="text/javascript" defer="" src="https://assets.example.org/js/byggsteg-busy.js"></script><script type="text/javascript" defer="">/*
hygguile - cozy and professional user-interfaces for everyone
Copyright © Josep Bigorra <jjbigorra@gmail.com>
hygguile is free software: you can redistribute it and/or modify
it under the terms of the Lesser GNU General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
hygguile is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
Lesser GNU General Public License for more details.
You should have received a copy of the Lesser GNU General Public License
along with hygguile. If not, see <https://www.gnu.org/licenses/>.
*/
const carouselItems = (list) => Array.from(list.querySelectorAll('.carousel-list-item'))
const carouselCentres = (list, items) => {
const listCentre = list.getBoundingClientRect().left + list.clientWidth / 2
const centres = items.map((item) => {
const rect = item.getBoundingClientRect()
return rect.left + rect.width / 2
})
const nearest = centres.reduce((best, itemCentre, index) => {
if (Math.abs(itemCentre - listCentre) < Math.abs(centres[best] - listCentre)) {
return index
}
return best
}, 0)
return { listCentre, centres, nearest }
}
// a real function declaration, so the inline onclick attributes emitted by
// the component always find it on the global scope
function handleCarouselClick(direction, id) {
const list = document.getElementById(id)
if (!list) {
return
}
const items = carouselItems(list)
if (!items.length) {
return
}
// the items are centred snap points, so page one item at a time by
// measuring where each one actually sits instead of guessing a step;
// this keeps the gap between items (which changes at the responsive
// breakpoints) and any width difference honest
const { listCentre, centres, nearest } = carouselCentres(list, items)
const step = direction === 'previous' ? -1 : 1
const target = Math.min(Math.max(nearest + step, 0), items.length - 1)
list.scrollBy({ left: centres[target] - listCentre, behavior: 'smooth' })
}
</script><script type="text/javascript" defer="">/*
hygguile - cozy and professional user-interfaces for everyone
Copyright © Josep Bigorra <jjbigorra@gmail.com>
hygguile is free software: you can redistribute it and/or modify
it under the terms of the Lesser GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
hygguile is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
Lesser GNU General Public License for more details.
You should have received a copy of the Lesser GNU General Public License
along with hygguile. If not, see <https://www.gnu.org/licenses/>.
*/
document.addEventListener('DOMContentLoaded', () => {
const themePreference = localStorage.getItem('data-theme')
if (themePreference === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark')
} else if (themePreference === 'light') {
document.documentElement.setAttribute('data-theme', 'light')
}
})
</script><script type="text/javascript" defer="">/*
hygguile - cozy and professional user-interfaces for everyone
Copyright © Josep Bigorra <jjbigorra@gmail.com>
hygguile is free software: you can redistribute it and/or modify
it under the terms of the Lesser GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
hygguile is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
Lesser GNU General Public License for more details.
You should have received a copy of the Lesser GNU General Public License
along with hygguile. If not, see <https://www.gnu.org/licenses/>.
*/
function cycleBgClass(currentClass) {
const parts = currentClass.split('-')
const currentNumber = parseInt(parts.toReversed()[0], 10)
const nextNumber = currentNumber >= 900 ? 100 : currentNumber + 200
const startParts = parts.slice(0, parts.length - 1).join("-")
return `${startParts}-${nextNumber}`
}
function cycleColorShow(el) {
const currentClass = [...el.classList].find(cls => cls.startsWith('bg-'))
const newClass = cycleBgClass(currentClass)
el.classList.toggle(currentClass)
el.classList.toggle(newClass)
el.children[0].innerText = newClass.replace("bg-", "")
}
</script><script type="text/javascript" defer="">/*
hygguile - cozy and professional user-interfaces for everyone
Copyright © Josep Bigorra <jjbigorra@gmail.com>
hygguile is free software: you can redistribute it and/or modify
it under the terms of the Lesser GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
hygguile is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
Lesser GNU General Public License for more details.
You should have received a copy of the Lesser GNU General Public License
along with hygguile. If not, see <https://www.gnu.org/licenses/>.
*/
const closeMenu = (button, menu) => {
button.setAttribute('aria-expanded', 'false')
menu.hidden = true
}
const openMenu = (button, menu) => {
button.setAttribute('aria-expanded', 'true')
menu.hidden = false
}
const closeAllOpenMenus = () => {
const menuContainers = document.querySelectorAll('.flyout-menu-container')
menuContainers.forEach(menuContainer => {
const menuButton = menuContainer.querySelector('.flyout-submenu-button')
if (menuButton) {
const isExpanded = menuButton.getAttribute('aria-expanded') === 'true'
if (isExpanded) {
menuButton.click()
}
}
})
}
const HOVER_DELAY = 2000
document.addEventListener('DOMContentLoaded', () => {
const menuContainers = document.querySelectorAll('.flyout-menu-container')
menuContainers.forEach(menuContainer => {
let hoverTimer
menuContainer.addEventListener('mouseenter', () => {
hoverTimer = setTimeout(() => {
const menuButton = menuContainer.querySelector('.flyout-submenu-button')
if (menuButton) {
const isExpanded = menuButton.getAttribute('aria-expanded') === 'true'
if (!isExpanded) {
closeAllOpenMenus()
menuButton.click()
}
}
}, HOVER_DELAY)
})
menuContainer.addEventListener('mouseleave', () => {
clearTimeout(hoverTimer)
})
})
menuContainers.forEach(menuContainer => {
const menuButton = menuContainer.querySelector('.flyout-submenu-button')
const dropdownMenu = menuContainer.querySelector('.flyout-submenu')
if (!menuButton || !dropdownMenu) {
return
}
const menuItems = dropdownMenu.querySelectorAll('a[href], button:not([disabled])')
const firstMenuItem = menuItems[0]
const lastMenuItem = menuItems[menuItems.length - 1]
menuButton.addEventListener('click', (event) => {
event.preventDefault()
const isExpanded = menuButton.getAttribute('aria-expanded') === 'true'
if (isExpanded) {
closeMenu(menuButton, dropdownMenu)
} else {
closeAllOpenMenus()
openMenu(menuButton, dropdownMenu)
}
})
menuButton.addEventListener('keydown', (event) => {
if (event.key === 'ArrowDown') {
event.preventDefault()
openMenu(menuButton, dropdownMenu)
if (firstMenuItem) {
firstMenuItem.focus()
}
} else if (event.key === 'Escape') {
closeMenu(menuButton, dropdownMenu)
menuButton.focus()
}
})
dropdownMenu.addEventListener('keydown', (event) => {
const { key } = event
const activeElement = document.activeElement
if (key === 'Escape') {
closeMenu(menuButton, dropdownMenu)
menuButton.focus()
return
}
if (key === 'ArrowUp') {
event.preventDefault()
if (activeElement === firstMenuItem) {
lastMenuItem.focus()
} else {
const currentIndex = Array.from(menuItems).indexOf(activeElement)
if (currentIndex > 0) {
menuItems[currentIndex - 1].focus()
}
}
} else if (key === 'ArrowDown') {
event.preventDefault()
if (activeElement === lastMenuItem) {
firstMenuItem.focus()
} else {
const currentIndex = Array.from(menuItems).indexOf(activeElement)
if (currentIndex < menuItems.length - 1) {
menuItems[currentIndex + 1].focus()
}
}
}
})
})
})
</script><script type="text/javascript" defer="">/*
hygguile - cozy and professional user-interfaces for everyone
Copyright © Josep Bigorra <jjbigorra@gmail.com>
hygguile is free software: you can redistribute it and/or modify
it under the terms of the Lesser GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
hygguile is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
Lesser GNU General Public License for more details.
You should have received a copy of the Lesser GNU General Public License
along with hygguile. If not, see <https://www.gnu.org/licenses/>.
*/
/*
The fold on a `code-sample': the source of a specimen is hidden until a
reader asks for it.
The component says everything this needs. The control carries an
`aria-controls' naming the panel and an `aria-expanded' saying whether the
panel is showing, and the panel carries the `hidden' attribute the component
wrote; the whole state the script keeps is those two attributes, so a page
that renders its own markup without the component gains nothing by loading
this file.
Without scripting the panel is revealed by the `noscript' stylesheet and the
control is hidden as a `.js-only' element, so a reader who cannot fold the
source still reads it.
*/
function hygguileToggleCodeSample(button) {
const id = button.getAttribute('aria-controls')
const panel = id && document.getElementById(id)
if (!panel) return
panel.hidden = !panel.hidden
button.setAttribute('aria-expanded', panel.hidden ? 'false' : 'true')
}
</script><script type="text/javascript" defer="">/*
hygguile - cozy and professional user-interfaces for everyone
Copyright © Josep Bigorra <jjbigorra@gmail.com>
hygguile is free software: you can redistribute it and/or modify
it under the terms of the Lesser GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
hygguile is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
Lesser GNU General Public License for more details.
You should have received a copy of the Lesser GNU General Public License
along with hygguile. If not, see <https://www.gnu.org/licenses/>.
*/
/*
The tray controls: the two things a page asks of a whole tray of cards at
once, which order it is in and whether all of it is open. Both are declared
in the markup by `sort-control' and `disclosure-controls', so the only
JavaScript a page carries is the call each button already makes.
Ordering is data, not code. A button reaches this file as
hygguileSortTray(this, 'title')
and the tray it orders is whichever container the button's group names in
its `data-hg-sort-target'. The items are ordered by their `data-title'
attribute -- numerically when both values are numbers, so data-order="10"
follows data-order="9" instead of sorting in front of it, and
alphabetically otherwise. The sort is stable, so items that compare equal
keep the order they are already in. A mode that no item carries is the
tray's own order: the items go back to the order the page wrote them in,
which is what a "Page order" button is for and why it needs no attribute
anywhere.
*/
(function () {
/* The order each tray was written in, read once the document is parsed:
by the time a button has been clicked it is too late to ask. */
const written = new Map()
const writtenOrder = (container) => written.get(container)
|| Array.prototype.slice.call(container.children)
const asNumber = (text) => {
const trimmed = text.trim()
return trimmed !== '' && !isNaN(Number(trimmed)) ? Number(trimmed) : null
}
const compare = (mode) => (a, b) => {
const left = a.getAttribute('data-' + mode) || ''
const right = b.getAttribute('data-' + mode) || ''
const leftNumber = asNumber(left)
const rightNumber = asNumber(right)
if (leftNumber !== null && rightNumber !== null) {
return leftNumber - rightNumber
}
return left.localeCompare(right)
}
const groupOf = (button) => button.closest('[data-hg-sort-target]')
/* One button of the group is the order in force; the stylesheet draws that
as the pressed one, and a screen reader reads it out. */
const mark = (button, group) => {
Array.prototype.forEach.call(group.querySelectorAll('button'),
(other) => {
other.setAttribute('aria-pressed',
other === button
? 'true' : 'false')
})
}
window.hygguileSortTray = (button, mode) => {
const group = groupOf(button)
const container = group
&& document.getElementById(group.getAttribute('data-hg-sort-target'))
if (!container) {
return
}
const items = Array.prototype.slice.call(container.children)
const carries = items.some((item) => item.hasAttribute('data-' + mode))
const ordered = carries
? items.sort(compare(mode))
: writtenOrder(container).slice()
ordered.forEach((item) => container.appendChild(item))
mark(button, group)
}
window.hygguileToggleTray = (open, selector) => {
Array.prototype.forEach.call(document.querySelectorAll(selector),
(details) => {
details.open = open
})
}
/* The tray is in the order the page wrote it in, and the first button of a
group is the order it opens in, so a page states its own order by writing
that mode first. */
document.addEventListener('DOMContentLoaded', () => {
Array.prototype.forEach.call(
document.querySelectorAll('[data-hg-sort-target]'),
(group) => {
const container = document.getElementById(
group.getAttribute('data-hg-sort-target'))
if (container) {
written.set(container, Array.prototype.slice.call(container.children))
}
const first = group.querySelector('button')
if (first) {
mark(first, group)
}
})
})
})()
</script><link rel="stylesheet" href="https://assets.example.org/css/byggsteg.olive.min.css"></link><script type="text/javascript" defer="" src="https://assets.example.org/js/symbanta.js" data-endpoint="https://api.symbanta.jointhefreeworld.org/v1/events/register" data-interval="4000"></script><script type="speculationrules">
{
"prerender": [
{
"where": {
"selector_matches": "a"
}
}
]
}
</script><script type="application/ld+json">{"@context":"https://schema.org","@type":"SoftwareApplication","name":"byggsteg","alternateName":"byggsteg CI/CD","applicationCategory":"DeveloperApplication","operatingSystem":"GNU/Linux","programmingLanguage":"Scheme","description":"byggsteg is a hackable and extensible CI/CD orchestrator written in Guile Scheme: a pipeline is Lisp code, a running job's log is read while it is written, and the runner, the logs, the artifacts and the users are all yours to host.","isAccessibleForFree":true,"license":"https://opensource.org/license/mit","codeRepository":"https://codeberg.org/jjba23/byggsteg","downloadUrl":"https://codeberg.org/jjba23/byggsteg","author":{"@type":"Person","name":"Josep Bigorra"},"offers":{"@type":"Offer","price":"0","priceCurrency":"EUR"},"url":"http://127.0.0.1:3000/"}</script></head><body class="bg-stone-100 dark:bg-onyx-900 dark:text-stone-700 font-mono rounded-none text-onyx-100"><div id="notification-tray" role="status" aria-live="polite" aria-atomic="true" class="fixed right-8 top-8 z-32"></div><div class="dark:text-stone-700 flex flex-col justify-between min-h-screen text-onyx-100"><div><header style="--hg-frost-blur: 3rem" class="bg-stone-100/75 border-asparagus-700 border-b-2 dark:bg-onyx-900/75 dark:border-asparagus-900 hg-frost md:px-5 px-2 py-1 shadow-md sm:px-3 sticky top-0 w-full z-10"><div class="pb-1 pt-1 px-2 sm:pb-2 sm:px-3 w-full"><a href="/" class="flex flex-row gap-2 items-center no-underline"><img src="https://assets.example.org/img/byggsteg-logo.png" alt="" loading="lazy" class="h-7 shrink-0 w-auto" /><span class="dark:text-asparagus-100 font-mono text-asparagus-900">$</span><h3 class="dark:hover:text-olive-300 dark:text-asparagus-100 font-bold font-sans hg-leading-display hover:text-asparagus-700 lg:text-xl sm:text-lg text-asparagus-900 text-md">byggsteg</h3><span class="animate-pulse dark:text-asparagus-100 font-mono text-asparagus-900">▮</span></a></div><div class="align-center flex flex-row flex-wrap gap-1 items-center sm:gap-2 sm:justify-between"><div class="contents gap-1 sm:flex sm:flex-row sm:flex-wrap sm:gap-2 sm:justify-start"><div><a href="/logs"><button onclick="" id="" type="button" class="active:scale-95 bg-stone-500/90 border-2 border-stone-700 cursor-pointer dark:bg-onyx-500/90 dark:border-onyx-300 dark:hover:bg-onyx-900 dark:hover:text-olive-300 dark:text-asparagus-100 duration-200 ease-in-out focus-visible:outline-2 focus-visible:outline-offset-2 font-medium font-mono hover:bg-stone-900/90 hover:text-asparagus-700 max-w-prose md:px-3 md:py-2 md:text-md outline-asparagus-500 px-1 py-1 rounded-none select-none shadow-md shrink-0 sm:px-2 text-asparagus-900 text-sm transition-colors" visible=""><div class="flex flex-row gap-1 items-center"><i class="dark:text-asparagus-100 hidden md:text-md ph ph-scroll select-none shrink-0 sm:inline text-asparagus-900 text-sm"></i><span>logs</span></div></button></a></div><div><a href="/activity"><button onclick="" id="" type="button" class="active:scale-95 bg-stone-500/90 border-2 border-stone-700 cursor-pointer dark:bg-onyx-500/90 dark:border-onyx-300 dark:hover:bg-onyx-900 dark:hover:text-olive-300 dark:text-asparagus-100 duration-200 ease-in-out focus-visible:outline-2 focus-visible:outline-offset-2 font-medium font-mono hover:bg-stone-900/90 hover:text-asparagus-700 max-w-prose md:px-3 md:py-2 md:text-md outline-asparagus-500 px-1 py-1 rounded-none select-none shadow-md shrink-0 sm:px-2 text-asparagus-900 text-sm transition-colors" visible=""><div class="flex flex-row gap-1 items-center"><i class="dark:text-asparagus-100 hidden md:text-md ph ph-chart-bar select-none shrink-0 sm:inline text-asparagus-900 text-sm"></i><span>activity</span></div></button></a></div><div><a href="/search"><button onclick="" id="" type="button" class="active:scale-95 bg-stone-500/90 border-2 border-stone-700 cursor-pointer dark:bg-onyx-500/90 dark:border-onyx-300 dark:hover:bg-onyx-900 dark:hover:text-olive-300 dark:text-asparagus-100 duration-200 ease-in-out focus-visible:outline-2 focus-visible:outline-offset-2 font-medium font-mono hover:bg-stone-900/90 hover:text-asparagus-700 max-w-prose md:px-3 md:py-2 md:text-md outline-asparagus-500 px-1 py-1 rounded-none select-none shadow-md shrink-0 sm:px-2 text-asparagus-900 text-sm transition-colors" visible=""><div class="flex flex-row gap-1 items-center"><i class="dark:text-asparagus-100 hidden md:text-md ph ph-magnifying-glass select-none shrink-0 sm:inline text-asparagus-900 text-sm"></i><span>search</span></div></button></a></div><div><a href="/profiles"><button onclick="" id="" type="button" class="active:scale-95 bg-stone-500/90 border-2 border-stone-700 cursor-pointer dark:bg-onyx-500/90 dark:border-onyx-300 dark:hover:bg-onyx-900 dark:hover:text-olive-300 dark:text-asparagus-100 duration-200 ease-in-out focus-visible:outline-2 focus-visible:outline-offset-2 font-medium font-mono hover:bg-stone-900/90 hover:text-asparagus-700 max-w-prose md:px-3 md:py-2 md:text-md outline-asparagus-500 px-1 py-1 rounded-none select-none shadow-md shrink-0 sm:px-2 text-asparagus-900 text-sm transition-colors" visible=""><div class="flex flex-row gap-1 items-center"><i class="dark:text-asparagus-100 hidden md:text-md ph ph-folder select-none shrink-0 sm:inline text-asparagus-900 text-sm"></i><span>profiles</span></div></button></a></div><div><a href="/clones"><button onclick="" id="" type="button" class="active:scale-95 bg-stone-500/90 border-2 border-stone-700 cursor-pointer dark:bg-onyx-500/90 dark:border-onyx-300 dark:hover:bg-onyx-900 dark:hover:text-olive-300 dark:text-asparagus-100 duration-200 ease-in-out focus-visible:outline-2 focus-visible:outline-offset-2 font-medium font-mono hover:bg-stone-900/90 hover:text-asparagus-700 max-w-prose md:px-3 md:py-2 md:text-md outline-asparagus-500 px-1 py-1 rounded-none select-none shadow-md shrink-0 sm:px-2 text-asparagus-900 text-sm transition-colors" visible=""><div class="flex flex-row gap-1 items-center"><i class="dark:text-asparagus-100 hidden md:text-md ph ph-git-branch select-none shrink-0 sm:inline text-asparagus-900 text-sm"></i><span>clones</span></div></button></a></div><div><a href="/about"><button onclick="" id="" type="button" class="active:scale-95 bg-stone-500/90 border-2 border-stone-700 cursor-pointer dark:bg-onyx-500/90 dark:border-onyx-300 dark:hover:bg-onyx-900 dark:hover:text-olive-300 dark:text-asparagus-100 duration-200 ease-in-out focus-visible:outline-2 focus-visible:outline-offset-2 font-medium font-mono hover:bg-stone-900/90 hover:text-asparagus-700 max-w-prose md:px-3 md:py-2 md:text-md outline-asparagus-500 px-1 py-1 rounded-none select-none shadow-md shrink-0 sm:px-2 text-asparagus-900 text-sm transition-colors" visible=""><div class="flex flex-row gap-1 items-center"><i class="dark:text-asparagus-100 hidden md:text-md ph ph-info select-none shrink-0 sm:inline text-asparagus-900 text-sm"></i><span>about</span></div></button></a></div><div><a class="dark:text-asparagus-100 flex flex-row font-medium font-sans gap-2 items-center lowercase no-underline text-asparagus-900" href="/login"><button onclick="" id="" type="button" class="active:scale-95 bg-stone-500/90 border-2 border-stone-700 cursor-pointer dark:bg-onyx-500/90 dark:border-onyx-300 dark:hover:bg-onyx-900 dark:hover:text-olive-300 dark:text-asparagus-100 duration-200 ease-in-out focus-visible:outline-2 focus-visible:outline-offset-2 font-medium font-mono hover:bg-stone-900/90 hover:text-asparagus-700 max-w-prose md:px-3 md:py-2 md:text-md outline-asparagus-500 px-1 py-1 rounded-none select-none shadow-md shrink-0 sm:px-2 text-asparagus-900 text-sm transition-colors" visible=""><div class="flex flex-row gap-1 items-center"><i class="dark:text-asparagus-100 hidden md:text-md ph ph-sign-in select-none shrink-0 sm:inline text-asparagus-900 text-sm"></i><span>login</span></div></button></a></div></div><div class="contents gap-1 sm:flex sm:flex-row sm:flex-wrap sm:gap-2 sm:justify-start"><a href="/jobs/request"><button onclick="" id="" type="button" class="active:scale-95 bg-stone-500/90 border-2 border-stone-700 cursor-pointer dark:bg-onyx-500/90 dark:border-onyx-300 dark:hover:bg-onyx-900 dark:hover:text-olive-300 dark:text-asparagus-100 duration-200 ease-in-out focus-visible:outline-2 focus-visible:outline-offset-2 font-medium font-mono hover:bg-stone-900/90 hover:text-asparagus-700 max-w-prose md:px-3 md:py-2 md:text-md outline-asparagus-500 px-1 py-1 rounded-none select-none shadow-md shrink-0 sm:px-2 text-asparagus-900 text-sm transition-colors" visible=""><div class="flex flex-row gap-1 items-center"><i class="dark:text-asparagus-100 hidden md:text-md ph ph-circuitry select-none shrink-0 sm:inline text-asparagus-900 text-sm"></i><span>run job</span></div></button></a><button onclick="hygguileToggleDarkTheme()" type="button" class="active:scale-95 bg-stone-500/90 border-2 border-stone-700 cursor-pointer dark:bg-onyx-500/90 dark:border-onyx-300 dark:hover:bg-onyx-900 dark:hover:text-olive-300 dark:text-asparagus-300 duration-200 ease-in-out focus-visible:outline-2 focus-visible:outline-offset-2 font-light font-mono hover:bg-stone-900/90 hover:text-asparagus-700 js-only max-w-prose md:px-3 md:py-2 md:text-md outline-asparagus-500 px-1 py-1 rounded-none select-none shadow-md shrink-0 sm:px-2 text-asparagus-900 text-sm transition-colors"><div class="flex flex-row gap-1 items-center"><i class="dark:text-asparagus-100 hidden md:text-md ph ph- select-none shrink-0 sm:inline text-asparagus-900 text-sm"></i><span><div><i class="dark:text-asparagus-100 md:text-md ph ph-sun select-none shrink-0 text-asparagus-900 text-sm"></i><i class="dark:text-asparagus-100 md:text-md ph ph-moon select-none shrink-0 text-asparagus-900 text-sm"></i></div></span></div></button></div></div></header><div class="container mx-auto my-4"><div class="bg-stone-300 border-2 border-asparagus-700 dark:bg-onyx-700 dark:border-asparagus-900 rounded-none shadow-md p-4"><img loading="lazy" class="h-auto w-48" src="https://assets.example.org/img/byggsteg-logo.png" /></div></div></div><footer class="bg-stone-300 border-asparagus-700 border-t-2 dark:bg-onyx-900 dark:border-asparagus-900 dark:text-stone-700 flex flex-col font-mono gap-1 items-start justify-start md:px-10 px-3 py-2 sm:px-6 sm:py-3 text-onyx-100"><div class="align-center flex flex-row flex-wrap gap-x-4 gap-y-1 items-center"><a href="https://codeberg.org/jjba23/byggsteg" class="dark:hover:text-moonstone-100 dark:text-asparagus-100 duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 font-bold font-mono hover:text-jeans-blue-900 hover:underline no-underline outline-jeans-blue-500 text-asparagus-900 text-sm transition-colors">source</a><a href="https://codeberg.org/jjba23/byggsteg/src/branch/trunk/README.org" class="dark:hover:text-moonstone-100 dark:text-asparagus-100 duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 font-bold font-mono hover:text-jeans-blue-900 hover:underline no-underline outline-jeans-blue-500 text-asparagus-900 text-sm transition-colors">manual</a><a href="https://jointhefreeworld.org/manuals/byggsteg/" class="dark:hover:text-moonstone-100 dark:text-asparagus-100 duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 font-bold font-mono hover:text-jeans-blue-900 hover:underline no-underline outline-jeans-blue-500 text-asparagus-900 text-sm transition-colors">documentation</a><a href="/about" class="dark:hover:text-moonstone-100 dark:text-asparagus-100 duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 font-bold font-mono hover:text-jeans-blue-900 hover:underline no-underline outline-jeans-blue-500 text-asparagus-900 text-sm transition-colors">about</a><a href="javascript:void(0)" class="cursor-pointer dark:text-asparagus-100 font-medium font-mono no-underline text-asparagus-900 text-sm" onclick="window.scrollTo({ top: 0, behavior: 'smooth' })">back to the top</a></div><div class="align-center flex flex-row flex-wrap gap-x-3 gap-y-1 items-center"><em class="dark:text-stone-900 font-light font-mono italic leading-5 text-onyx-300 text-sm">© Josep Bigorra & NalaGinrut -- MIT</em><em class="dark:text-stone-900 font-light font-mono italic leading-5 text-onyx-300 text-sm">version: 1.19.5 (fc2f115)</em></div></footer></div></body></html>
[⚙️ assert] the mark the landing page shows moves with them
✅ got: <!DOCTYPE html><html lang="en" data-theme="dark"><head><title>console resources spec</title><script type="text/javascript">
(function () {
var root = document.documentElement
var choice = null
try { choice = localStorage.getItem('data-theme') } catch (error) { choice = null }
var scheme = function () {
root.style.colorScheme = root.getAttribute('data-theme') === 'light' ? 'light' : 'dark'
}
root.setAttribute('data-theme', choice === 'light' ? 'light' : 'dark')
scheme()
if (typeof MutationObserver !== 'undefined') {
new MutationObserver(scheme).observe(root, {attributes: true, attributeFilter: ['data-theme']})
}
})()
</script><meta name="viewport" content="width=device-width, initial-scale=1.0" /><meta charset="UTF-8" /><meta name="description" content="byggsteg is a hackable and extensible CI/CD orchestrator written in Guile Scheme: a pipeline is Lisp code, a running job's log is read while it is written, and the runner, the logs, the artifacts and the users are all yours to host." /><meta name="robots" content="index, follow" /><link rel="canonical" href="http://127.0.0.1:3000/" /><meta property="og:type" content="website" /><meta property="og:site_name" content="byggsteg" /><meta property="og:title" content="console resources spec" /><meta property="og:description" content="byggsteg is a hackable and extensible CI/CD orchestrator written in Guile Scheme: a pipeline is Lisp code, a running job's log is read while it is written, and the runner, the logs, the artifacts and the users are all yours to host." /><meta property="og:url" content="http://127.0.0.1:3000/" /><meta property="og:image" content="https://assets.example.org/img/byggsteg-logo.png" /><meta name="twitter:card" content="summary_large_image" /><meta name="twitter:title" content="console resources spec" /><meta name="twitter:description" content="byggsteg is a hackable and extensible CI/CD orchestrator written in Guile Scheme: a pipeline is Lisp code, a running job's log is read while it is written, and the runner, the logs, the artifacts and the users are all yours to host." /><meta name="twitter:image" content="https://assets.example.org/img/byggsteg-logo.png" /><link rel="icon" type="image/svg+xml" href="https://assets.example.org/img/byggsteg-logo.svg"></link><link rel="shortcut icon" type="image/x-icon" href="https://assets.example.org/favicon.ico"></link><link rel="stylesheet" href="https://assets.example.org/css/fonts.css" /><link rel="preload" as="style" onload="this.onload=null;this.rel='stylesheet'" href="https://assets.example.org/css/phosphor.css" /><noscript><link rel="stylesheet" href="https://assets.example.org/css/phosphor.css" /></noscript><style type="text/css">/*
hygguile - cozy and professional user-interfaces for everyone
Copyright © Josep Bigorra <jjbigorra@gmail.com>
hygguile is free software: you can redistribute it and/or modify
it under the terms of the Lesser GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
hygguile is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
Lesser GNU General Public License for more details.
You should have received a copy of the Lesser GNU General Public License
along with hygguile. If not, see <https://www.gnu.org/licenses/>.
*/
/* Flyout menu / dropdown */
.flyout-submenu-button[aria-expanded="true"]::after {
content: "↑";
padding-left: 0.8em;
}
.flyout-submenu-button[aria-expanded="false"]::after {
content: "↓";
padding-left: 0.8em;
}
.flyout-submenu[hidden] {
display: none;
}
</style><style type="text/css">/*
hygguile - cozy and professional user-interfaces for everyone
Copyright © Josep Bigorra <jjbigorra@gmail.com>
hygguile is free software: you can redistribute it and/or modify
it under the terms of the Lesser GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
hygguile is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
Lesser GNU General Public License for more details.
You should have received a copy of the Lesser GNU General Public License
along with hygguile. If not, see <https://www.gnu.org/licenses/>.
*/
/*
Companion stylesheet for hygguile.
Olive CSS intentionally only ships utility classes. A handful of
components need a little structural CSS that cannot be expressed with
single utilities (the styling of native <progress> and <details>, the
CSS-only tabs, switch, modal and tooltip widgets). Everything here is
plain, dependency-free CSS and works without JavaScript.
Wherever Olive already provides the feature we lean on its utilities
instead of reimplementing it: screen-reader-only text uses `sr-only`,
loading animations use `animate-spin` and `animate-pulse`, and focus
rings use the `focus-visible:outline-*` variants.
*/
/* ===== A couple of missing intrinsic-size utilities ===== */
.h-auto {
height: auto;
}
.w-auto {
width: auto;
}
/* ===== Spinner =====
Only the ring visual lives here; the rotation is Olive's
`animate-spin` utility, applied by the component itself. */
.hg-spinner {
border-radius: 50%;
border: 0.25rem solid rgba(120, 113, 108, 0.35);
border-top-color: #57534e;
}
[data-theme="dark"] .hg-spinner {
border-color: rgba(214, 211, 209, 0.25);
border-top-color: #d6d3d1;
}
/* ===== A readable default ink =====
Components carry their own colour classes, but text a page does not
decorate must still be readable, and Olive ships no base ink of its own.
Both rules land before olive.min.css in the document, so a `text-*`
utility anywhere in the markup still wins over them. */
body {
color: #1c1917;
}
[data-theme="dark"] body {
color: #ededee;
}
/* ===== Native progress bar ===== */
.hg-progress {
width: 100%;
height: 1rem;
border: 0;
border-radius: 81.92rem;
overflow: hidden;
appearance: none;
-webkit-appearance: none;
background: rgba(120, 113, 108, 0.25);
}
.hg-progress::-webkit-progress-bar {
background: rgba(120, 113, 108, 0.25);
border-radius: 81.92rem;
}
.hg-progress::-webkit-progress-value {
background: #57534e;
border-radius: 81.92rem;
}
.hg-progress::-moz-progress-bar {
background: #57534e;
border-radius: 81.92rem;
}
[data-theme="dark"] .hg-progress,
[data-theme="dark"] .hg-progress::-webkit-progress-bar {
background: rgba(214, 211, 209, 0.2);
}
[data-theme="dark"] .hg-progress::-webkit-progress-value,
[data-theme="dark"] .hg-progress::-moz-progress-bar {
background: #d6d3d1;
}
/* ===== CSS-only tabs (no JavaScript) ===== */
.hg-tabs {
display: flex;
flex-direction: column;
}
.hg-tab-input {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.hg-tab-labels {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 0.25rem;
border-bottom: 2px solid rgba(120, 113, 108, 0.35);
}
.hg-tab-label {
cursor: pointer;
padding: 0.5rem 1rem;
border-radius: 0.75rem 0.75rem 0 0;
font-weight: 500;
/* 7.4:1 on the panel, not the 4.6:1 of a lighter grey: an unselected
label is still text and still has to be read */
color: #57534e;
transition: color 0.15s ease, background-color 0.15s ease;
}
[data-theme="dark"] .hg-tab-label {
color: #d6d3d1;
}
.hg-tab-panels {
padding-top: 1rem;
}
.hg-tab-panel {
display: none;
}
/* active label highlight */
.hg-tabs:has(> .hg-tab-input:nth-of-type(1):checked) > .hg-tab-labels > .hg-tab-label:nth-child(1),
.hg-tabs:has(> .hg-tab-input:nth-of-type(2):checked) > .hg-tab-labels > .hg-tab-label:nth-child(2),
.hg-tabs:has(> .hg-tab-input:nth-of-type(3):checked) > .hg-tab-labels > .hg-tab-label:nth-child(3),
.hg-tabs:has(> .hg-tab-input:nth-of-type(4):checked) > .hg-tab-labels > .hg-tab-label:nth-child(4),
.hg-tabs:has(> .hg-tab-input:nth-of-type(5):checked) > .hg-tab-labels > .hg-tab-label:nth-child(5),
.hg-tabs:has(> .hg-tab-input:nth-of-type(6):checked) > .hg-tab-labels > .hg-tab-label:nth-child(6),
.hg-tabs:has(> .hg-tab-input:nth-of-type(7):checked) > .hg-tab-labels > .hg-tab-label:nth-child(7),
.hg-tabs:has(> .hg-tab-input:nth-of-type(8):checked) > .hg-tab-labels > .hg-tab-label:nth-child(8) {
background: rgba(120, 113, 108, 0.25);
color: #1c1917;
}
[data-theme="dark"] .hg-tabs:has(> .hg-tab-input:nth-of-type(1):checked) > .hg-tab-labels > .hg-tab-label:nth-child(1),
[data-theme="dark"] .hg-tabs:has(> .hg-tab-input:nth-of-type(2):checked) > .hg-tab-labels > .hg-tab-label:nth-child(2),
[data-theme="dark"] .hg-tabs:has(> .hg-tab-input:nth-of-type(3):checked) > .hg-tab-labels > .hg-tab-label:nth-child(3),
[data-theme="dark"] .hg-tabs:has(> .hg-tab-input:nth-of-type(4):checked) > .hg-tab-labels > .hg-tab-label:nth-child(4),
[data-theme="dark"] .hg-tabs:has(> .hg-tab-input:nth-of-type(5):checked) > .hg-tab-labels > .hg-tab-label:nth-child(5),
[data-theme="dark"] .hg-tabs:has(> .hg-tab-input:nth-of-type(6):checked) > .hg-tab-labels > .hg-tab-label:nth-child(6),
[data-theme="dark"] .hg-tabs:has(> .hg-tab-input:nth-of-type(7):checked) > .hg-tab-labels > .hg-tab-label:nth-child(7),
[data-theme="dark"] .hg-tabs:has(> .hg-tab-input:nth-of-type(8):checked) > .hg-tab-labels > .hg-tab-label:nth-child(8) {
background: rgba(214, 211, 209, 0.2);
color: #f5f5f4;
}
/* active panel */
.hg-tabs:has(> .hg-tab-input:nth-of-type(1):checked) > .hg-tab-panels > .hg-tab-panel:nth-child(1),
.hg-tabs:has(> .hg-tab-input:nth-of-type(2):checked) > .hg-tab-panels > .hg-tab-panel:nth-child(2),
.hg-tabs:has(> .hg-tab-input:nth-of-type(3):checked) > .hg-tab-panels > .hg-tab-panel:nth-child(3),
.hg-tabs:has(> .hg-tab-input:nth-of-type(4):checked) > .hg-tab-panels > .hg-tab-panel:nth-child(4),
.hg-tabs:has(> .hg-tab-input:nth-of-type(5):checked) > .hg-tab-panels > .hg-tab-panel:nth-child(5),
.hg-tabs:has(> .hg-tab-input:nth-of-type(6):checked) > .hg-tab-panels > .hg-tab-panel:nth-child(6),
.hg-tabs:has(> .hg-tab-input:nth-of-type(7):checked) > .hg-tab-panels > .hg-tab-panel:nth-child(7),
.hg-tabs:has(> .hg-tab-input:nth-of-type(8):checked) > .hg-tab-panels > .hg-tab-panel:nth-child(8) {
display: block;
}
/* keyboard focus */
.hg-tabs:has(> .hg-tab-input:nth-of-type(1):focus-visible) > .hg-tab-labels > .hg-tab-label:nth-child(1),
.hg-tabs:has(> .hg-tab-input:nth-of-type(2):focus-visible) > .hg-tab-labels > .hg-tab-label:nth-child(2),
.hg-tabs:has(> .hg-tab-input:nth-of-type(3):focus-visible) > .hg-tab-labels > .hg-tab-label:nth-child(3),
.hg-tabs:has(> .hg-tab-input:nth-of-type(4):focus-visible) > .hg-tab-labels > .hg-tab-label:nth-child(4),
.hg-tabs:has(> .hg-tab-input:nth-of-type(5):focus-visible) > .hg-tab-labels > .hg-tab-label:nth-child(5),
.hg-tabs:has(> .hg-tab-input:nth-of-type(6):focus-visible) > .hg-tab-labels > .hg-tab-label:nth-child(6),
.hg-tabs:has(> .hg-tab-input:nth-of-type(7):focus-visible) > .hg-tab-labels > .hg-tab-label:nth-child(7),
.hg-tabs:has(> .hg-tab-input:nth-of-type(8):focus-visible) > .hg-tab-labels > .hg-tab-label:nth-child(8) {
outline: 2px solid currentColor;
outline-offset: 2px;
}
/* ===== Switch backed by a native checkbox ===== */
.hg-switch-input {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.hg-switch {
position: relative;
display: inline-block;
flex: none;
width: 2.75rem;
height: 1.5rem;
border-radius: 81.92rem;
background: #a8a29e;
cursor: pointer;
transition: background 0.2s ease;
}
.hg-switch::after {
content: "";
position: absolute;
top: 0.125rem;
left: 0.125rem;
width: 1.25rem;
height: 1.25rem;
border-radius: 81.92rem;
background: #ffffff;
transition: transform 0.2s ease;
}
.hg-switch-input:checked ~ .hg-switch {
background: #57534e;
}
[data-theme="dark"] .hg-switch-input:checked ~ .hg-switch {
background: #d6d3d1;
}
.hg-switch-input:checked ~ .hg-switch::after {
transform: translateX(1.25rem);
}
.hg-switch-input:focus-visible ~ .hg-switch {
outline: 2px solid currentColor;
outline-offset: 2px;
}
/* ===== CSS-only modal via :target ===== */
.hg-modal {
display: none;
}
.hg-modal:target {
display: grid;
place-items: center;
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 64;
}
.hg-modal-backdrop {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background: rgba(12, 10, 9, 0.6);
}
.hg-modal-content {
position: relative;
z-index: 1;
width: 90%;
max-width: 34rem;
margin: 1rem;
padding: 1.25rem;
border-radius: 1rem;
box-shadow: 0 1.25rem 3rem rgba(0, 0, 0, 0.35);
background: #f5f5f4;
color: #1c1917;
}
[data-theme="dark"] .hg-modal-content {
background: #262626;
color: #f5f5f4;
}
.hg-modal-header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.75rem;
}
.hg-modal-close {
text-decoration: none;
font-size: 1.5rem;
line-height: 1;
color: inherit;
}
.hg-modal-body {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.hg-modal-footer {
display: flex;
flex-direction: row;
justify-content: flex-end;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 1rem;
}
/* ===== CSS-only tooltips ===== */
.hg-tooltip {
position: relative;
display: inline-flex;
align-items: center;
}
.hg-tooltip::after {
content: attr(data-hg-tooltip);
/* Hidden really means hidden: an absolutely positioned bubble still
occupies layout space, so a tooltip near the right edge would widen
the page and leave a phone layout that can be panned sideways.
`display: none` removes the box entirely, which is how the modal and
the fly-out menu already hide themselves. */
display: none;
position: absolute;
z-index: 64;
width: max-content;
max-width: 16rem;
padding: 0.35rem 0.6rem;
border-radius: 0.6rem;
background: #1c1917;
color: #f5f5f4;
font-size: 0.8rem;
line-height: 1.25;
pointer-events: none;
}
.hg-tooltip:hover::after,
.hg-tooltip:focus-visible::after {
display: block;
/* display cannot be transitioned, so the fade-in is an animation */
animation: hg-tooltip-in 0.15s ease;
}
@keyframes hg-tooltip-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.hg-tooltip-top::after {
bottom: 100%;
left: 50%;
transform: translate(-50%, -0.4rem);
}
.hg-tooltip-bottom::after {
top: 100%;
left: 50%;
transform: translate(-50%, 0.4rem);
}
.hg-tooltip-left::after {
right: 100%;
top: 50%;
transform: translate(-0.4rem, -50%);
}
.hg-tooltip-right::after {
left: 100%;
top: 50%;
transform: translate(0.4rem, -50%);
}
/* On a narrow viewport a wide bubble cannot always be centred on its
trigger without leaving the screen, so the vertical variants are
capped in width and aligned to the trigger's end edge instead. In
left-to-right writing, overflow to the left never makes a page pan
sideways, while overflow to the right does. */
@media (max-width: 40rem) {
.hg-tooltip-top::after,
.hg-tooltip-bottom::after {
left: auto;
right: 0;
max-width: calc(100vw - 3rem);
}
.hg-tooltip-top::after {
transform: translate(0, -0.4rem);
}
.hg-tooltip-bottom::after {
transform: translate(0, 0.4rem);
}
}
/* ===== Drifting colour washes =====
The wallpaper trick: a few oversized, heavily blurred discs of colour
behind a frosted panel. `.hg-blob` only adds the slow drift, so the
size, position, colour and blur stay in the markup where Olive already
expresses them (`blur-3xl`, `w-96`, `bg-naples-yellow-500/50`, ...).
Stagger the discs with an inline `animation-delay` so they never move
in lockstep.
How fast a disc drifts and how far it travels are the page's business,
not this sheet's: `glass-wash` writes `--hg-blob-duration`, `--hg-blob-x`
and `--hg-blob-y` as inline custom properties -- and an inline
`animation-delay` to stagger the discs -- so the values below are only
the defaults for a wash that says nothing. A keyframe resolves its
`var()`s per element, so one panel can breathe for a minute while the
next crosses its own corner in a couple of rems. */
.hg-blob {
animation: hg-blob-drift var(--hg-blob-duration, 26s) ease-in-out infinite alternate;
}
@keyframes hg-blob-drift {
from {
transform: translate3d(0, 0, 0) scale(1);
}
to {
transform: translate3d(var(--hg-blob-x, 1.5rem),
var(--hg-blob-y, -1.25rem),
0)
scale(var(--hg-blob-scale, 1.08));
}
}
/* ===== The layer the discs are painted in =====
A pane rounds the layer holding its washes and clips it, and neither the
rounding nor the clip stops a disc: a child with a `filter` is painted
with the blur that filter asks for, and the browser draws that blur into
the square box it reaches -- past the layer's rounded edge, into the
corner the radius curves away from. What is left on the page is a faint
square of wash beside each corner, a percent or two of ink: measured
against a mid-grey page, a corner square that should have held the
page's own 128 read 130. `overflow: clip`, `contain: paint`,
`isolation: isolate` and promoting the layer to one of its own all leave
it in place (each measured on its own); an explicit `clip-path` is what
clips it. And a clip has to name a radius -- it cannot inherit one the
way `border-radius: inherit` can.
So this class names it -- and names it off the very rounding the markup
gave the layer, since the layer wears the pane's own rounding classes.
The radius has to be restated as a length, so these rules restate
Olive's named ladder; they are the one place here that has to agree with
another sheet, and a spec pins the tokens the framework rounds its own
glass with to a rule apiece, so a rounding cannot lose its clip without
a test going red.
A radius given outright in `--hg-glass-clip' still wins, on the layer or
on any ancestor, which is what a caller rounding with one of Olive's
numeric steps (`rounded-24', say) reaches for, as does a pane whose
radius is not one of the named ones at all.
The default in each rule is `rounded-3xl', the rounding a pane wears
when a caller leaves `#:rounding' alone: a layer wearing something the
table below does not name still keeps its washes in. */
.hg-glass-layer {
clip-path: inset(0 round var(--hg-glass-clip, 1.28rem));
}
.hg-glass-layer[class~="rounded-none"] {
clip-path: inset(0 round var(--hg-glass-clip, 0));
}
.hg-glass-layer[class~="rounded-2xs"] {
clip-path: inset(0 round var(--hg-glass-clip, 0.16rem));
}
.hg-glass-layer[class~="rounded-xs"] {
clip-path: inset(0 round var(--hg-glass-clip, 0.32rem));
}
.hg-glass-layer[class~="rounded-sm"] {
clip-path: inset(0 round var(--hg-glass-clip, 0.48rem));
}
.hg-glass-layer[class~="rounded-md"] {
clip-path: inset(0 round var(--hg-glass-clip, 0.64rem));
}
.hg-glass-layer[class~="rounded-lg"] {
clip-path: inset(0 round var(--hg-glass-clip, 0.80rem));
}
.hg-glass-layer[class~="rounded-xl"] {
clip-path: inset(0 round var(--hg-glass-clip, 0.96rem));
}
.hg-glass-layer[class~="rounded-2xl"] {
clip-path: inset(0 round var(--hg-glass-clip, 1.12rem));
}
.hg-glass-layer[class~="rounded-3xl"] {
clip-path: inset(0 round var(--hg-glass-clip, 1.28rem));
}
.hg-glass-layer[class~="rounded-4xl"] {
clip-path: inset(0 round var(--hg-glass-clip, 1.44rem));
}
.hg-glass-layer[class~="rounded-5xl"] {
clip-path: inset(0 round var(--hg-glass-clip, 1.60rem));
}
.hg-glass-layer[class~="rounded-full"] {
clip-path: inset(0 round var(--hg-glass-clip, 9999px));
}
/* ===== Frost that survives a rounded corner =====
A backdrop-filter asks the browser to blur and colour whatever sits
behind a pane. Chromium paints that filtered backdrop in the pane's
square border box and ignores its `border-radius`, so a rounded pane
leaks a square patch of frost wherever its radius curves away from the
corner (Chromium issue 41212594, still open). A radius on that element
does not stop it, and a `clip-path` on it does not either. The one
thing that does is clipping the frosted box itself: an element that
carries `overflow: hidden` alongside its radius gets its frost rounded
off, because the browser then has a shape to clip the filtered backdrop
to.
The frost therefore lives one level in, on the pane's own `::before`:
a layer that rounds itself to the pane's radius with `border-radius:
inherit` -- so it follows whatever radius the markup or a caller gave
the pane -- clips itself, and carries the blur. Being a decoration
rather than a container, it leaves the pane's own children painted
above it and free to escape, which is what a bar holding a dropdown
needs.
How strong the frost is stays a knob: `--hg-frost-blur` is the blur
radius, 4rem unless something says otherwise (the same value Olive's
`backdrop-blur-md` utility sets). A custom property cannot be a utility
class on a pseudo-element, so it is the one setting that is passed as a
declaration rather than a class: `glass-panel' writes it from Scheme
for the panes it builds (`#:frost'), markup this sheet frosts by hand
passes the declaration itself, and a pane with neither reaches for
`hg-frost-soft' below instead.
`position: relative` gives the layer something to be absolute against
even when a pane places itself with layout classes alone. A pane that
sets a position utility of its own takes over, since olive.min.css is
inlined after this sheet.
`z-index: 0' on the pane and `-1' on the layer are what keep the frost
*under* the pane's content. An absolutely placed layer paints above
in-flow text and blocks, so a frosted pane whose content was never
wrapped in a layer of its own -- a dropdown's list is one -- would
otherwise have its contents blurred away behind the frost. A negative
stack level puts the layer in the first step of the pane's stacking
order instead, right after the pane's own background and border, which
is where the frost belongs. The pane was a stacking context anyway
while it carried the blur itself, so nothing about the order of what
is *outside* the pane changes, and the layer's own corner can no
longer paint over the pane's hairline. */
.hg-frost {
position: relative;
z-index: 0;
}
.hg-frost::before {
content: "";
position: absolute;
z-index: -1;
inset: 0;
border-radius: inherit;
overflow: hidden;
-webkit-backdrop-filter: blur(var(--hg-frost-blur, 4rem));
backdrop-filter: blur(var(--hg-frost-blur, 4rem));
}
/* A gentler frost, for the small panes the framework frosts with a class
rather than a declaration: the carousel's arrow discs, a couple of rems
across, would be all blur at a panel's strength. Olive's blur ladder
cannot reach a pseudo-element, so this sheet names the one step below
the default that something actually wears. */
.hg-frost-soft {
--hg-frost-blur: 3rem;
}
/* The smallest step, for the pills and labels a page frosts by hand: a
badge two rems tall is all blur at a pane's strength, and the label on a
colour swatch wants a hint of it and no more. Naming the step is what
keeps those pills on the same recipe as the panes -- the frost paints in
a rounded layer of its own, so the square patch of blur Olive's
`backdrop-blur-*' utilities leave at a rounded corner never appears. */
.hg-frost-pill {
--hg-frost-blur: 1rem;
}
/* ===== The heading type scale =====
Heading line-heights are ratios rather than lengths, because a heading is
sized by a utility the page may move up or down the scale at will: a
length would have to be re-picked whenever the font-size changed, and a
ratio follows on its own. The steps loosen as the size shrinks (1.3 for
the display sizes, 1.35 for a section title, 1.4 for the smaller
headings), which is the usual shape of a type scale, and every one of
them is loose enough that a line's glyphs -- descenders, accents, the lot
-- stay inside the box the line occupies.
That is a requirement, not a preference. Gradient ink is a background
clipped to the letters, so it can only paint where the heading's own box
reaches: a `g` whose descender falls below it is not cropped but drawn
transparent. The ratios here are measured against the two faces the
framework ships, of which Plus Jakarta Sans asks for the most room at
1.24 of the line box at worst; 1.3 is that with margin to spare. Body
copy sits looser still, at 1.5. */
.hg-leading-display {
line-height: 1.3;
}
.hg-leading-title {
line-height: 1.35;
}
.hg-leading-subtitle {
line-height: 1.4;
}
/* ===== Gradient text =====
One gradient per theme, since a deep sweep that sings on the light
background turns to mud on the dark one.
The compound `h1.`/`h2.` selectors are deliberate: Olive's text-colour
utilities are single classes, and this sheet is inlined before
olive.min.css, so a bare `.hg-gradient-text` would lose the cascade
and the heading would keep its solid colour.
Nothing here has to widen the box the ink is painted in, and that is the
heading scale's doing rather than this rule's: every heading it matches
carries a leaded line-height from `hg-leading-*' above, so the descenders
of a `g` sit inside the heading's own box and have gradient to paint.
A page that overrides the leading with a tighter one -- `leading-none' or
a `leading-*' of its own, which lands after this sheet -- takes the clip
back into its own hands. */
h1.hg-gradient-text,
h2.hg-gradient-text {
background-image: linear-gradient(100deg,
oklch(0.7404 0.1399 91.55 / 1),
oklch(0.6183 0.1557 45.26 / 1) 45%,
oklch(0.4929 0.161 279.98 / 1));
background-clip: text;
-webkit-background-clip: text;
color: transparent;
}
[data-theme="dark"] h1.hg-gradient-text,
[data-theme="dark"] h2.hg-gradient-text {
background-image: linear-gradient(100deg,
oklch(0.8855 0.1307 92.19 / 1),
oklch(0.7404 0.1656 39.8 / 1) 45%,
oklch(0.7439 0.1301 288.16 / 1));
}
/* ===== Expandable cards =====
An expandable card is a native <details>, so it opens and hides with no
script at all. The only structural help it asks for here is to drop the
browser's own summary marker -- the chevron in the summary is the
affordance, and it turns as the card opens -- and, because a card is also
what a section rail links to, to keep a header a link jumped to clear of
that rail. */
.hg-expandable-summary {
list-style: none;
/* deep enough for a card header to clear a sticky section rail above it */
scroll-margin-top: 7rem;
}
.hg-expandable-summary::-webkit-details-marker {
display: none;
}
details[open] > .hg-expandable-summary .hg-expandable-chevron {
transform: rotate(180deg);
}
/* ===== Showcase sort control =====
Three buttons offer to reorder the tray, and the chosen one is the one the
page's script has marked `aria-pressed="true"'. Olive tints every button
with a mid surface -- legible, but all three of them the same shape and
shade -- and a control that says which order you are reading has to be
unmistakable at a glance, so the pressed one is filled from the opposite
end of the ramp instead: the near-black of the ink in a light theme, the
near-white of a card in a dark theme, each carrying the ink that surface is
already known to carry. The fill is only the darkest or lightest step of
the palette the page is already built from; the contrast of every pair
below is the framework's own, and the gate checks it. */
.hg-sort-button[aria-pressed="true"] {
background-color: oklch(0.3588 0.0044 247.95 / 1);
color: oklch(0.987 0.0013 286.37);
border-color: oklch(0.987 0.0013 286.37 / 0.4);
}
[data-theme="dark"] .hg-sort-button[aria-pressed="true"] {
background-color: oklch(0.947 0.0013 286.37 / 1);
color: oklch(0.3588 0.0044 247.95);
border-color: oklch(0.3588 0.0044 247.95 / 0.4);
}
/* ===== Code samples =====
The panel that holds a specimen's source folds away until it is asked for,
and the `hidden' attribute is what folds it. The attribute is enough on its
own for an element with no display utility -- the browser's own rule hides
it -- but a panel lays itself out with `flex', and an author rule outbids
the user agent's `[hidden]' however low it sits in the cascade, which would
leave every specimen's source open on every page. So the fold is stated
here as the framework's own rule, at the strength it has to be stated at.
With scripting off the noscript stylesheet reveals the panel instead and
hides the control, so a reader who cannot fold the source still reads it. */
.hg-code-sample-panel[hidden] {
display: none !important;
}
/* ===== Respect reduced-motion preferences ===== */
@media (prefers-reduced-motion: reduce) {
.hg-switch,
.hg-switch::after,
.hg-tooltip::after,
.hg-tab-label,
.hg-blob {
transition: none;
animation: none;
}
}
</style><noscript><style type="text/css">/*
hygguile - cozy and professional user-interfaces for everyone
Copyright © Josep Bigorra <jjbigorra@gmail.com>
hygguile is free software: you can redistribute it and/or modify
it under the terms of the Lesser GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
hygguile is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
Lesser GNU General Public License for more details.
You should have received a copy of the Lesser GNU General Public License
along with hygguile. If not, see <https://www.gnu.org/licenses/>.
*/
/*
Progressive-enhancement rules that only apply when scripting is
disabled, because hygguile loads this file inside a <noscript> element
(see hygguile-recommended-resources).
The contract is simple:
- Anything that only works with JavaScript carries the .js-only class
and is hidden here.
- Components that can be made to work without JavaScript (the fly-out
submenus, the carousel) are progressively revealed instead of being
hidden.
*/
/* Interactive controls that depend on JavaScript do nothing without it. */
.js-only {
display: none !important;
}
/* Fly-out menus: reveal every submenu and drop the meaningless arrow. */
.flyout-submenu[hidden] {
display: block !important;
}
/* A code sample: the fold that hides the source needs scripting, and the
control that would lift it is a .js-only element, so without scripting
every specimen simply shows its own source rather than a dead button. */
.hg-code-sample-panel[hidden] {
display: flex !important;
}
.flyout-submenu {
position: static !important;
box-shadow: none !important;
background: transparent !important;
padding-left: 1rem !important;
}
.flyout-submenu-button::after {
content: "" !important;
padding-left: 0 !important;
}
</style></noscript><script type="text/javascript" defer="">/*
hygguile - cozy and professional user-interfaces for everyone
Copyright © Josep Bigorra <jjbigorra@gmail.com>
hygguile is free software: you can redistribute it and/or modify
it under the terms of the Lesser GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
hygguile is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
Lesser GNU General Public License for more details.
You should have received a copy of the Lesser GNU General Public License
along with hygguile. If not, see <https://www.gnu.org/licenses/>.
*/
function hygguileSaveDarkThemePreference(preference) {
document.documentElement.setAttribute('data-theme', preference)
localStorage.setItem('data-theme', preference)
}
function hygguileToggleDarkTheme() {
const currentTheme = document.documentElement.getAttribute('data-theme')
if (currentTheme == null) {
//
// by default set dark since hygguile has a light theme by default
//
hygguileSaveDarkThemePreference('dark')
} else {
if (currentTheme == 'dark') {
hygguileSaveDarkThemePreference('light')
} else {
hygguileSaveDarkThemePreference('dark')
}
}
}
</script><script type="text/javascript" defer="">/*
hygguile - cozy and professional user-interfaces for everyone
Copyright © Josep Bigorra <jjbigorra@gmail.com>
hygguile is free software: you can redistribute it and/or modify
it under the terms of the Lesser GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
hygguile is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
Lesser GNU General Public License for more details.
You should have received a copy of the Lesser GNU General Public License
along with hygguile. If not, see <https://www.gnu.org/licenses/>.
*/
function hygguileCopyPreInnerText(elementId) {
const id = elementId + '-pre'
console.log('copying inner text of element: ' + id)
var el = document.getElementById(id)
navigator.clipboard.writeText(el.innerText)
console.log(el.innerText)
}
</script><script>
document.addEventListener('DOMContentLoaded', () => {
const params = new URLSearchParams(window.location.search);
if(params.get('notify-body') == null && params.get('notify-title') == null){
return
}
const notificationTitle = params.get('notify-title') ?? ""
const notificationBody = params.get('notify-body') ?? ""
const notificationDurationMillis = params.get('notify-duration-millis') ?? 3000
const notificationBodyFontFamily = params.get('notify-body-font-family') ?? 'font-sans'
const notificationTray = document.getElementById('notification-tray')
notificationTray.innerHTML = "<div class=\"bg-stone-300/90 border-2 border-stone-500/25 dark:bg-onyx-700/75 dark:border-onyx-100/25 dark:text-stone-300 dark:text-stone-700 flex flex-col font-sans gap-4 hg-frost max-w-full max-w-prose px-5 py-5 relative rounded-xl text-onyx-100 text-onyx-300\"><div class=\"flex flex-row flex-wrap gap-10 items-center justify-between\"><h4 id=\"notification-title\" class=\"dark:text-asparagus-100 text-asparagus-900 text-xl\"></h4><i onclick=\"document.getElementById('notification-tray').innerHTML = ''\" class=\"cursor-pointer dark:text-asparagus-100 ph ph-x text-asparagus-900 text-xl\"></i></div><p id=\"notification-body\"></p></div>"
const notificationTitleNode = document.getElementById('notification-title')
const notificationBodyNode = document.getElementById('notification-body')
if(notificationTitleNode){
notificationTitleNode.textContent = notificationTitle
}
if(notificationBodyNode){
notificationBodyNode.textContent = notificationBody
notificationBodyNode.classList.toggle(notificationBodyFontFamily)
}
setTimeout(() => { notificationTray.innerHTML = '' }, notificationDurationMillis);
})
</script><script type="text/javascript" defer="" src="https://assets.example.org/js/byggsteg-busy.js"></script><script type="text/javascript" defer="">/*
hygguile - cozy and professional user-interfaces for everyone
Copyright © Josep Bigorra <jjbigorra@gmail.com>
hygguile is free software: you can redistribute it and/or modify
it under the terms of the Lesser GNU General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
hygguile is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
Lesser GNU General Public License for more details.
You should have received a copy of the Lesser GNU General Public License
along with hygguile. If not, see <https://www.gnu.org/licenses/>.
*/
const carouselItems = (list) => Array.from(list.querySelectorAll('.carousel-list-item'))
const carouselCentres = (list, items) => {
const listCentre = list.getBoundingClientRect().left + list.clientWidth / 2
const centres = items.map((item) => {
const rect = item.getBoundingClientRect()
return rect.left + rect.width / 2
})
const nearest = centres.reduce((best, itemCentre, index) => {
if (Math.abs(itemCentre - listCentre) < Math.abs(centres[best] - listCentre)) {
return index
}
return best
}, 0)
return { listCentre, centres, nearest }
}
// a real function declaration, so the inline onclick attributes emitted by
// the component always find it on the global scope
function handleCarouselClick(direction, id) {
const list = document.getElementById(id)
if (!list) {
return
}
const items = carouselItems(list)
if (!items.length) {
return
}
// the items are centred snap points, so page one item at a time by
// measuring where each one actually sits instead of guessing a step;
// this keeps the gap between items (which changes at the responsive
// breakpoints) and any width difference honest
const { listCentre, centres, nearest } = carouselCentres(list, items)
const step = direction === 'previous' ? -1 : 1
const target = Math.min(Math.max(nearest + step, 0), items.length - 1)
list.scrollBy({ left: centres[target] - listCentre, behavior: 'smooth' })
}
</script><script type="text/javascript" defer="">/*
hygguile - cozy and professional user-interfaces for everyone
Copyright © Josep Bigorra <jjbigorra@gmail.com>
hygguile is free software: you can redistribute it and/or modify
it under the terms of the Lesser GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
hygguile is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
Lesser GNU General Public License for more details.
You should have received a copy of the Lesser GNU General Public License
along with hygguile. If not, see <https://www.gnu.org/licenses/>.
*/
document.addEventListener('DOMContentLoaded', () => {
const themePreference = localStorage.getItem('data-theme')
if (themePreference === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark')
} else if (themePreference === 'light') {
document.documentElement.setAttribute('data-theme', 'light')
}
})
</script><script type="text/javascript" defer="">/*
hygguile - cozy and professional user-interfaces for everyone
Copyright © Josep Bigorra <jjbigorra@gmail.com>
hygguile is free software: you can redistribute it and/or modify
it under the terms of the Lesser GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
hygguile is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
Lesser GNU General Public License for more details.
You should have received a copy of the Lesser GNU General Public License
along with hygguile. If not, see <https://www.gnu.org/licenses/>.
*/
function cycleBgClass(currentClass) {
const parts = currentClass.split('-')
const currentNumber = parseInt(parts.toReversed()[0], 10)
const nextNumber = currentNumber >= 900 ? 100 : currentNumber + 200
const startParts = parts.slice(0, parts.length - 1).join("-")
return `${startParts}-${nextNumber}`
}
function cycleColorShow(el) {
const currentClass = [...el.classList].find(cls => cls.startsWith('bg-'))
const newClass = cycleBgClass(currentClass)
el.classList.toggle(currentClass)
el.classList.toggle(newClass)
el.children[0].innerText = newClass.replace("bg-", "")
}
</script><script type="text/javascript" defer="">/*
hygguile - cozy and professional user-interfaces for everyone
Copyright © Josep Bigorra <jjbigorra@gmail.com>
hygguile is free software: you can redistribute it and/or modify
it under the terms of the Lesser GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
hygguile is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
Lesser GNU General Public License for more details.
You should have received a copy of the Lesser GNU General Public License
along with hygguile. If not, see <https://www.gnu.org/licenses/>.
*/
const closeMenu = (button, menu) => {
button.setAttribute('aria-expanded', 'false')
menu.hidden = true
}
const openMenu = (button, menu) => {
button.setAttribute('aria-expanded', 'true')
menu.hidden = false
}
const closeAllOpenMenus = () => {
const menuContainers = document.querySelectorAll('.flyout-menu-container')
menuContainers.forEach(menuContainer => {
const menuButton = menuContainer.querySelector('.flyout-submenu-button')
if (menuButton) {
const isExpanded = menuButton.getAttribute('aria-expanded') === 'true'
if (isExpanded) {
menuButton.click()
}
}
})
}
const HOVER_DELAY = 2000
document.addEventListener('DOMContentLoaded', () => {
const menuContainers = document.querySelectorAll('.flyout-menu-container')
menuContainers.forEach(menuContainer => {
let hoverTimer
menuContainer.addEventListener('mouseenter', () => {
hoverTimer = setTimeout(() => {
const menuButton = menuContainer.querySelector('.flyout-submenu-button')
if (menuButton) {
const isExpanded = menuButton.getAttribute('aria-expanded') === 'true'
if (!isExpanded) {
closeAllOpenMenus()
menuButton.click()
}
}
}, HOVER_DELAY)
})
menuContainer.addEventListener('mouseleave', () => {
clearTimeout(hoverTimer)
})
})
menuContainers.forEach(menuContainer => {
const menuButton = menuContainer.querySelector('.flyout-submenu-button')
const dropdownMenu = menuContainer.querySelector('.flyout-submenu')
if (!menuButton || !dropdownMenu) {
return
}
const menuItems = dropdownMenu.querySelectorAll('a[href], button:not([disabled])')
const firstMenuItem = menuItems[0]
const lastMenuItem = menuItems[menuItems.length - 1]
menuButton.addEventListener('click', (event) => {
event.preventDefault()
const isExpanded = menuButton.getAttribute('aria-expanded') === 'true'
if (isExpanded) {
closeMenu(menuButton, dropdownMenu)
} else {
closeAllOpenMenus()
openMenu(menuButton, dropdownMenu)
}
})
menuButton.addEventListener('keydown', (event) => {
if (event.key === 'ArrowDown') {
event.preventDefault()
openMenu(menuButton, dropdownMenu)
if (firstMenuItem) {
firstMenuItem.focus()
}
} else if (event.key === 'Escape') {
closeMenu(menuButton, dropdownMenu)
menuButton.focus()
}
})
dropdownMenu.addEventListener('keydown', (event) => {
const { key } = event
const activeElement = document.activeElement
if (key === 'Escape') {
closeMenu(menuButton, dropdownMenu)
menuButton.focus()
return
}
if (key === 'ArrowUp') {
event.preventDefault()
if (activeElement === firstMenuItem) {
lastMenuItem.focus()
} else {
const currentIndex = Array.from(menuItems).indexOf(activeElement)
if (currentIndex > 0) {
menuItems[currentIndex - 1].focus()
}
}
} else if (key === 'ArrowDown') {
event.preventDefault()
if (activeElement === lastMenuItem) {
firstMenuItem.focus()
} else {
const currentIndex = Array.from(menuItems).indexOf(activeElement)
if (currentIndex < menuItems.length - 1) {
menuItems[currentIndex + 1].focus()
}
}
}
})
})
})
</script><script type="text/javascript" defer="">/*
hygguile - cozy and professional user-interfaces for everyone
Copyright © Josep Bigorra <jjbigorra@gmail.com>
hygguile is free software: you can redistribute it and/or modify
it under the terms of the Lesser GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
hygguile is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
Lesser GNU General Public License for more details.
You should have received a copy of the Lesser GNU General Public License
along with hygguile. If not, see <https://www.gnu.org/licenses/>.
*/
/*
The fold on a `code-sample': the source of a specimen is hidden until a
reader asks for it.
The component says everything this needs. The control carries an
`aria-controls' naming the panel and an `aria-expanded' saying whether the
panel is showing, and the panel carries the `hidden' attribute the component
wrote; the whole state the script keeps is those two attributes, so a page
that renders its own markup without the component gains nothing by loading
this file.
Without scripting the panel is revealed by the `noscript' stylesheet and the
control is hidden as a `.js-only' element, so a reader who cannot fold the
source still reads it.
*/
function hygguileToggleCodeSample(button) {
const id = button.getAttribute('aria-controls')
const panel = id && document.getElementById(id)
if (!panel) return
panel.hidden = !panel.hidden
button.setAttribute('aria-expanded', panel.hidden ? 'false' : 'true')
}
</script><script type="text/javascript" defer="">/*
hygguile - cozy and professional user-interfaces for everyone
Copyright © Josep Bigorra <jjbigorra@gmail.com>
hygguile is free software: you can redistribute it and/or modify
it under the terms of the Lesser GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
hygguile is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
Lesser GNU General Public License for more details.
You should have received a copy of the Lesser GNU General Public License
along with hygguile. If not, see <https://www.gnu.org/licenses/>.
*/
/*
The tray controls: the two things a page asks of a whole tray of cards at
once, which order it is in and whether all of it is open. Both are declared
in the markup by `sort-control' and `disclosure-controls', so the only
JavaScript a page carries is the call each button already makes.
Ordering is data, not code. A button reaches this file as
hygguileSortTray(this, 'title')
and the tray it orders is whichever container the button's group names in
its `data-hg-sort-target'. The items are ordered by their `data-title'
attribute -- numerically when both values are numbers, so data-order="10"
follows data-order="9" instead of sorting in front of it, and
alphabetically otherwise. The sort is stable, so items that compare equal
keep the order they are already in. A mode that no item carries is the
tray's own order: the items go back to the order the page wrote them in,
which is what a "Page order" button is for and why it needs no attribute
anywhere.
*/
(function () {
/* The order each tray was written in, read once the document is parsed:
by the time a button has been clicked it is too late to ask. */
const written = new Map()
const writtenOrder = (container) => written.get(container)
|| Array.prototype.slice.call(container.children)
const asNumber = (text) => {
const trimmed = text.trim()
return trimmed !== '' && !isNaN(Number(trimmed)) ? Number(trimmed) : null
}
const compare = (mode) => (a, b) => {
const left = a.getAttribute('data-' + mode) || ''
const right = b.getAttribute('data-' + mode) || ''
const leftNumber = asNumber(left)
const rightNumber = asNumber(right)
if (leftNumber !== null && rightNumber !== null) {
return leftNumber - rightNumber
}
return left.localeCompare(right)
}
const groupOf = (button) => button.closest('[data-hg-sort-target]')
/* One button of the group is the order in force; the stylesheet draws that
as the pressed one, and a screen reader reads it out. */
const mark = (button, group) => {
Array.prototype.forEach.call(group.querySelectorAll('button'),
(other) => {
other.setAttribute('aria-pressed',
other === button
? 'true' : 'false')
})
}
window.hygguileSortTray = (button, mode) => {
const group = groupOf(button)
const container = group
&& document.getElementById(group.getAttribute('data-hg-sort-target'))
if (!container) {
return
}
const items = Array.prototype.slice.call(container.children)
const carries = items.some((item) => item.hasAttribute('data-' + mode))
const ordered = carries
? items.sort(compare(mode))
: writtenOrder(container).slice()
ordered.forEach((item) => container.appendChild(item))
mark(button, group)
}
window.hygguileToggleTray = (open, selector) => {
Array.prototype.forEach.call(document.querySelectorAll(selector),
(details) => {
details.open = open
})
}
/* The tray is in the order the page wrote it in, and the first button of a
group is the order it opens in, so a page states its own order by writing
that mode first. */
document.addEventListener('DOMContentLoaded', () => {
Array.prototype.forEach.call(
document.querySelectorAll('[data-hg-sort-target]'),
(group) => {
const container = document.getElementById(
group.getAttribute('data-hg-sort-target'))
if (container) {
written.set(container, Array.prototype.slice.call(container.children))
}
const first = group.querySelector('button')
if (first) {
mark(first, group)
}
})
})
})()
</script><link rel="stylesheet" href="https://assets.example.org/css/byggsteg.olive.min.css"></link><script type="text/javascript" defer="" src="https://assets.example.org/js/symbanta.js" data-endpoint="https://api.symbanta.jointhefreeworld.org/v1/events/register" data-interval="4000"></script><script type="speculationrules">
{
"prerender": [
{
"where": {
"selector_matches": "a"
}
}
]
}
</script><script type="application/ld+json">{"@context":"https://schema.org","@type":"SoftwareApplication","name":"byggsteg","alternateName":"byggsteg CI/CD","applicationCategory":"DeveloperApplication","operatingSystem":"GNU/Linux","programmingLanguage":"Scheme","description":"byggsteg is a hackable and extensible CI/CD orchestrator written in Guile Scheme: a pipeline is Lisp code, a running job's log is read while it is written, and the runner, the logs, the artifacts and the users are all yours to host.","isAccessibleForFree":true,"license":"https://opensource.org/license/mit","codeRepository":"https://codeberg.org/jjba23/byggsteg","downloadUrl":"https://codeberg.org/jjba23/byggsteg","author":{"@type":"Person","name":"Josep Bigorra"},"offers":{"@type":"Offer","price":"0","priceCurrency":"EUR"},"url":"http://127.0.0.1:3000/"}</script></head><body class="bg-stone-100 dark:bg-onyx-900 dark:text-stone-700 font-mono rounded-none text-onyx-100"><div id="notification-tray" role="status" aria-live="polite" aria-atomic="true" class="fixed right-8 top-8 z-32"></div><div class="dark:text-stone-700 flex flex-col justify-between min-h-screen text-onyx-100"><div><header style="--hg-frost-blur: 3rem" class="bg-stone-100/75 border-asparagus-700 border-b-2 dark:bg-onyx-900/75 dark:border-asparagus-900 hg-frost md:px-5 px-2 py-1 shadow-md sm:px-3 sticky top-0 w-full z-10"><div class="pb-1 pt-1 px-2 sm:pb-2 sm:px-3 w-full"><a href="/" class="flex flex-row gap-2 items-center no-underline"><img src="https://assets.example.org/img/byggsteg-logo.png" alt="" loading="lazy" class="h-7 shrink-0 w-auto" /><span class="dark:text-asparagus-100 font-mono text-asparagus-900">$</span><h3 class="dark:hover:text-olive-300 dark:text-asparagus-100 font-bold font-sans hg-leading-display hover:text-asparagus-700 lg:text-xl sm:text-lg text-asparagus-900 text-md">byggsteg</h3><span class="animate-pulse dark:text-asparagus-100 font-mono text-asparagus-900">▮</span></a></div><div class="align-center flex flex-row flex-wrap gap-1 items-center sm:gap-2 sm:justify-between"><div class="contents gap-1 sm:flex sm:flex-row sm:flex-wrap sm:gap-2 sm:justify-start"><div><a href="/logs"><button onclick="" id="" type="button" class="active:scale-95 bg-stone-500/90 border-2 border-stone-700 cursor-pointer dark:bg-onyx-500/90 dark:border-onyx-300 dark:hover:bg-onyx-900 dark:hover:text-olive-300 dark:text-asparagus-100 duration-200 ease-in-out focus-visible:outline-2 focus-visible:outline-offset-2 font-medium font-mono hover:bg-stone-900/90 hover:text-asparagus-700 max-w-prose md:px-3 md:py-2 md:text-md outline-asparagus-500 px-1 py-1 rounded-none select-none shadow-md shrink-0 sm:px-2 text-asparagus-900 text-sm transition-colors" visible=""><div class="flex flex-row gap-1 items-center"><i class="dark:text-asparagus-100 hidden md:text-md ph ph-scroll select-none shrink-0 sm:inline text-asparagus-900 text-sm"></i><span>logs</span></div></button></a></div><div><a href="/activity"><button onclick="" id="" type="button" class="active:scale-95 bg-stone-500/90 border-2 border-stone-700 cursor-pointer dark:bg-onyx-500/90 dark:border-onyx-300 dark:hover:bg-onyx-900 dark:hover:text-olive-300 dark:text-asparagus-100 duration-200 ease-in-out focus-visible:outline-2 focus-visible:outline-offset-2 font-medium font-mono hover:bg-stone-900/90 hover:text-asparagus-700 max-w-prose md:px-3 md:py-2 md:text-md outline-asparagus-500 px-1 py-1 rounded-none select-none shadow-md shrink-0 sm:px-2 text-asparagus-900 text-sm transition-colors" visible=""><div class="flex flex-row gap-1 items-center"><i class="dark:text-asparagus-100 hidden md:text-md ph ph-chart-bar select-none shrink-0 sm:inline text-asparagus-900 text-sm"></i><span>activity</span></div></button></a></div><div><a href="/search"><button onclick="" id="" type="button" class="active:scale-95 bg-stone-500/90 border-2 border-stone-700 cursor-pointer dark:bg-onyx-500/90 dark:border-onyx-300 dark:hover:bg-onyx-900 dark:hover:text-olive-300 dark:text-asparagus-100 duration-200 ease-in-out focus-visible:outline-2 focus-visible:outline-offset-2 font-medium font-mono hover:bg-stone-900/90 hover:text-asparagus-700 max-w-prose md:px-3 md:py-2 md:text-md outline-asparagus-500 px-1 py-1 rounded-none select-none shadow-md shrink-0 sm:px-2 text-asparagus-900 text-sm transition-colors" visible=""><div class="flex flex-row gap-1 items-center"><i class="dark:text-asparagus-100 hidden md:text-md ph ph-magnifying-glass select-none shrink-0 sm:inline text-asparagus-900 text-sm"></i><span>search</span></div></button></a></div><div><a href="/profiles"><button onclick="" id="" type="button" class="active:scale-95 bg-stone-500/90 border-2 border-stone-700 cursor-pointer dark:bg-onyx-500/90 dark:border-onyx-300 dark:hover:bg-onyx-900 dark:hover:text-olive-300 dark:text-asparagus-100 duration-200 ease-in-out focus-visible:outline-2 focus-visible:outline-offset-2 font-medium font-mono hover:bg-stone-900/90 hover:text-asparagus-700 max-w-prose md:px-3 md:py-2 md:text-md outline-asparagus-500 px-1 py-1 rounded-none select-none shadow-md shrink-0 sm:px-2 text-asparagus-900 text-sm transition-colors" visible=""><div class="flex flex-row gap-1 items-center"><i class="dark:text-asparagus-100 hidden md:text-md ph ph-folder select-none shrink-0 sm:inline text-asparagus-900 text-sm"></i><span>profiles</span></div></button></a></div><div><a href="/clones"><button onclick="" id="" type="button" class="active:scale-95 bg-stone-500/90 border-2 border-stone-700 cursor-pointer dark:bg-onyx-500/90 dark:border-onyx-300 dark:hover:bg-onyx-900 dark:hover:text-olive-300 dark:text-asparagus-100 duration-200 ease-in-out focus-visible:outline-2 focus-visible:outline-offset-2 font-medium font-mono hover:bg-stone-900/90 hover:text-asparagus-700 max-w-prose md:px-3 md:py-2 md:text-md outline-asparagus-500 px-1 py-1 rounded-none select-none shadow-md shrink-0 sm:px-2 text-asparagus-900 text-sm transition-colors" visible=""><div class="flex flex-row gap-1 items-center"><i class="dark:text-asparagus-100 hidden md:text-md ph ph-git-branch select-none shrink-0 sm:inline text-asparagus-900 text-sm"></i><span>clones</span></div></button></a></div><div><a href="/about"><button onclick="" id="" type="button" class="active:scale-95 bg-stone-500/90 border-2 border-stone-700 cursor-pointer dark:bg-onyx-500/90 dark:border-onyx-300 dark:hover:bg-onyx-900 dark:hover:text-olive-300 dark:text-asparagus-100 duration-200 ease-in-out focus-visible:outline-2 focus-visible:outline-offset-2 font-medium font-mono hover:bg-stone-900/90 hover:text-asparagus-700 max-w-prose md:px-3 md:py-2 md:text-md outline-asparagus-500 px-1 py-1 rounded-none select-none shadow-md shrink-0 sm:px-2 text-asparagus-900 text-sm transition-colors" visible=""><div class="flex flex-row gap-1 items-center"><i class="dark:text-asparagus-100 hidden md:text-md ph ph-info select-none shrink-0 sm:inline text-asparagus-900 text-sm"></i><span>about</span></div></button></a></div><div><a class="dark:text-asparagus-100 flex flex-row font-medium font-sans gap-2 items-center lowercase no-underline text-asparagus-900" href="/login"><button onclick="" id="" type="button" class="active:scale-95 bg-stone-500/90 border-2 border-stone-700 cursor-pointer dark:bg-onyx-500/90 dark:border-onyx-300 dark:hover:bg-onyx-900 dark:hover:text-olive-300 dark:text-asparagus-100 duration-200 ease-in-out focus-visible:outline-2 focus-visible:outline-offset-2 font-medium font-mono hover:bg-stone-900/90 hover:text-asparagus-700 max-w-prose md:px-3 md:py-2 md:text-md outline-asparagus-500 px-1 py-1 rounded-none select-none shadow-md shrink-0 sm:px-2 text-asparagus-900 text-sm transition-colors" visible=""><div class="flex flex-row gap-1 items-center"><i class="dark:text-asparagus-100 hidden md:text-md ph ph-sign-in select-none shrink-0 sm:inline text-asparagus-900 text-sm"></i><span>login</span></div></button></a></div></div><div class="contents gap-1 sm:flex sm:flex-row sm:flex-wrap sm:gap-2 sm:justify-start"><a href="/jobs/request"><button onclick="" id="" type="button" class="active:scale-95 bg-stone-500/90 border-2 border-stone-700 cursor-pointer dark:bg-onyx-500/90 dark:border-onyx-300 dark:hover:bg-onyx-900 dark:hover:text-olive-300 dark:text-asparagus-100 duration-200 ease-in-out focus-visible:outline-2 focus-visible:outline-offset-2 font-medium font-mono hover:bg-stone-900/90 hover:text-asparagus-700 max-w-prose md:px-3 md:py-2 md:text-md outline-asparagus-500 px-1 py-1 rounded-none select-none shadow-md shrink-0 sm:px-2 text-asparagus-900 text-sm transition-colors" visible=""><div class="flex flex-row gap-1 items-center"><i class="dark:text-asparagus-100 hidden md:text-md ph ph-circuitry select-none shrink-0 sm:inline text-asparagus-900 text-sm"></i><span>run job</span></div></button></a><button onclick="hygguileToggleDarkTheme()" type="button" class="active:scale-95 bg-stone-500/90 border-2 border-stone-700 cursor-pointer dark:bg-onyx-500/90 dark:border-onyx-300 dark:hover:bg-onyx-900 dark:hover:text-olive-300 dark:text-asparagus-300 duration-200 ease-in-out focus-visible:outline-2 focus-visible:outline-offset-2 font-light font-mono hover:bg-stone-900/90 hover:text-asparagus-700 js-only max-w-prose md:px-3 md:py-2 md:text-md outline-asparagus-500 px-1 py-1 rounded-none select-none shadow-md shrink-0 sm:px-2 text-asparagus-900 text-sm transition-colors"><div class="flex flex-row gap-1 items-center"><i class="dark:text-asparagus-100 hidden md:text-md ph ph- select-none shrink-0 sm:inline text-asparagus-900 text-sm"></i><span><div><i class="dark:text-asparagus-100 md:text-md ph ph-sun select-none shrink-0 text-asparagus-900 text-sm"></i><i class="dark:text-asparagus-100 md:text-md ph ph-moon select-none shrink-0 text-asparagus-900 text-sm"></i></div></span></div></button></div></div></header><div class="container mx-auto my-4"><div class="bg-stone-300 border-2 border-asparagus-700 dark:bg-onyx-700 dark:border-asparagus-900 rounded-none shadow-md p-4"><img loading="lazy" class="h-auto w-48" src="https://assets.example.org/img/byggsteg-logo.png" /></div></div></div><footer class="bg-stone-300 border-asparagus-700 border-t-2 dark:bg-onyx-900 dark:border-asparagus-900 dark:text-stone-700 flex flex-col font-mono gap-1 items-start justify-start md:px-10 px-3 py-2 sm:px-6 sm:py-3 text-onyx-100"><div class="align-center flex flex-row flex-wrap gap-x-4 gap-y-1 items-center"><a href="https://codeberg.org/jjba23/byggsteg" class="dark:hover:text-moonstone-100 dark:text-asparagus-100 duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 font-bold font-mono hover:text-jeans-blue-900 hover:underline no-underline outline-jeans-blue-500 text-asparagus-900 text-sm transition-colors">source</a><a href="https://codeberg.org/jjba23/byggsteg/src/branch/trunk/README.org" class="dark:hover:text-moonstone-100 dark:text-asparagus-100 duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 font-bold font-mono hover:text-jeans-blue-900 hover:underline no-underline outline-jeans-blue-500 text-asparagus-900 text-sm transition-colors">manual</a><a href="https://jointhefreeworld.org/manuals/byggsteg/" class="dark:hover:text-moonstone-100 dark:text-asparagus-100 duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 font-bold font-mono hover:text-jeans-blue-900 hover:underline no-underline outline-jeans-blue-500 text-asparagus-900 text-sm transition-colors">documentation</a><a href="/about" class="dark:hover:text-moonstone-100 dark:text-asparagus-100 duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 font-bold font-mono hover:text-jeans-blue-900 hover:underline no-underline outline-jeans-blue-500 text-asparagus-900 text-sm transition-colors">about</a><a href="javascript:void(0)" class="cursor-pointer dark:text-asparagus-100 font-medium font-mono no-underline text-asparagus-900 text-sm" onclick="window.scrollTo({ top: 0, behavior: 'smooth' })">back to the top</a></div><div class="align-center flex flex-row flex-wrap gap-x-3 gap-y-1 items-center"><em class="dark:text-stone-900 font-light font-mono italic leading-5 text-onyx-300 text-sm">© Josep Bigorra & NalaGinrut -- MIT</em><em class="dark:text-stone-900 font-light font-mono italic leading-5 text-onyx-300 text-sm">version: 1.19.5 (fc2f115)</em></div></footer></div></body></html>
ℹ️ info - ran 1 tasks in: 0 seconds, 177402 microseconds
running unit tests from file: runners-spec.scm, entrypoint: spec
ℹ️ info - shuffled with seed 651288570 (replay with VERITAS_SEED=651288570)
+----------------------------------------------+
[📑 suite] Runners Suite
[🧪 test] A runner answers with the words that run a target
[⚙️ assert] Make takes the target alone
✅ got: (make test)
[⚙️ assert] so does Just
✅ got: (just test)
[⚙️ assert] npm runs a script, so `run' stays
✅ got: (npm run test)
[⚙️ assert] and so does pnpm
✅ got: (pnpm run build)
[⚙️ assert] Deno's tasks are a subcommand
✅ got: (deno task test)
[⚙️ assert] Dune's targets are subcommands of their own
✅ got: (dune test)
[🧪 test] Every runner in the table is named once, and names a program
[⚙️ assert] No runner is named twice
✅ got: 12
[⚙️ assert] A runner's name is one word, since it is a command
✅ got: #t
[🧪 test] The order is the table's, and Make is the fallback
[⚙️ assert] a justfile wins over a Makefile
✅ got: just
[⚙️ assert] a maak.scm wins over a justfile
✅ got: maak
[⚙️ assert] a Makefile wins over Cargo.toml, which wraps it
✅ got: make
[⚙️ assert] a lockfile names the package manager
✅ got: pnpm
[⚙️ assert] a clone with no marker is a Make checkout
✅ got: make
[⚙️ assert] and so is one that is not there
✅ got: make
[🧪 test] The checkout's files say which runner it is
[⚙️ assert] a maak.scm is a Maak checkout
✅ got: maak
[⚙️ assert] a justfile is a Just checkout
✅ got: just
[⚙️ assert] a Taskfile.yml is a go-task checkout
✅ got: task
[⚙️ assert] a maskfile.md is a Mask checkout
✅ got: mask
[⚙️ assert] a Rakefile is a Rake checkout
✅ got: rake
[⚙️ assert] a Makefile is a Make checkout
✅ got: make
[⚙️ assert] a dune-project is a Dune checkout
✅ got: dune
[⚙️ assert] a Cargo.toml is a Cargo checkout
✅ got: cargo
[⚙️ assert] a deno.json is a Deno checkout
✅ got: deno
[⚙️ assert] a pnpm-lock.yaml is a pnpm checkout
✅ got: pnpm
[⚙️ assert] a yarn.lock is a yarn checkout
✅ got: yarn
[⚙️ assert] a package.json is an npm checkout
✅ got: npm
[🧪 test] A name may carry the runner it wants
[⚙️ assert] a bare name asks the checkout
✅ got: (#f . test)
[⚙️ assert] just- asks Just
✅ got: (just . test)
[⚙️ assert] npm- asks npm
✅ got: (npm . test)
[⚙️ assert] a target that itself begins with a runner's name is still reachable
✅ got: (make . just-deploy)
[⚙️ assert] a machine task is not split
✅ got: (#f . pull-and-restart)
ℹ️ info - ran 1 tasks in: 0 seconds, 32988 microseconds
running unit tests from file: password-reset-spec.scm, entrypoint: spec
ℹ️ info - shuffled with seed 651288570 (replay with VERITAS_SEED=651288570)
+----------------------------------------------+
[📑 suite] a password-reset link
[🧪 test] a link is worth following for the week after it was made
[⚙️ assert] yesterday is usable, the last instant of the week is usable, the instant after is not
✅ got: ((made-a-minute-ago #t) (made-yesterday #t) (made-inside-the-week #t) (made-exactly-a-week-ago #f) (made-a-week-and-a-day-ago #f))
[🧪 test] a link this deployment cannot date is not followed
[⚙️ assert] no row, no date and a date that is not a number all answer the same as an old link
✅ got: ((no-such-token #f) (a-row-without-a-date #f) (a-row-whose-date-is-not-an-instant #f))
ℹ️ info - ran 1 tasks in: 0 seconds, 3352 microseconds
running unit tests from file: job-search-spec.scm, entrypoint: spec
ℹ️ info - shuffled with seed 651288570 (replay with VERITAS_SEED=651288570)
+----------------------------------------------+
[📑 suite] search
[🧪 test] a question is answered from the jobs, the logs and the profiles
[⚙️ assert] a word found in one log and one job title
✅ got: ((jobs (search-job-b)) (logs (search-log-a)) (profiles ()))
[⚙️ assert] a found job is handed to the log its output is in
✅ got: search-log-b
[⚙️ assert] the same word written in capitals finds the same things
✅ got: ((jobs (search-job-b)) (logs (search-log-a)) (profiles ()))
[⚙️ assert] a word found in a profile's data is answered too
✅ got: search-profile-a
[🧪 test] the wildcards a reader types are searched for, not applied
[⚙️ assert] a percent sign finds the row that has one, not every row
✅ got: ((jobs (search-job-c)) (logs (search-log-b)) (profiles ()))
[⚙️ assert] an underscore finds the row that has one, not any character
✅ got: ((jobs (search-job-d)) (logs (search-log-c)) (profiles ()))
[🧪 test] a blank question asks for nothing rather than for everything
[⚙️ assert] an empty query, a query of spaces, and no query at all
✅ got: ((() () ()) (() () ()) (() () ()))
[🧪 test] each group of an answer is read up to the limit
[⚙️ assert] a letter most rows hold, answered twice over
✅ got: ((jobs 2) (logs 2) (profiles 1))
[⚙️ assert] and without a limit, every row that holds it
✅ got: ((jobs 4) (logs 3) (profiles 1))
[🧪 test] an excerpt is the part of a log the reader came for
[⚙️ assert] the window, its cuts, its one line, and where it starts
✅ got: ((holds-the-match #t) (marks-the-cut-before #t) (marks-the-cut-after #t) (is-one-line #t) (short-enough-to-read #t) (a-match-at-the-start-is-not-cut #t) (a-text-nothing-matches-begins-at-its-start #t))
ℹ️ info - ran 1 tasks in: 0 seconds, 50856 microseconds
running unit tests from file: logs-page-spec.scm, entrypoint: spec
ℹ️ info - shuffled with seed 651288570 (replay with VERITAS_SEED=651288570)
+----------------------------------------------+
[📑 suite] reading a run of a list of runs
[🧪 test] a row of a list of runs is the run's
[⚙️ assert] the row leads to the page its output is read on
✅ got: /logs/spec-log-id
[⚙️ assert] and carries the log's own name
✅ got: spec run
[⚙️ assert] and the moment the run was taken, which is the job's
✅ got: Thu Jan 1 01:00:00 1970
[⚙️ assert] and how the run went, which is the job's too
✅ got: job succeeded
[🧪 test] a log whose job is gone is drawn as no row
[⚙️ assert] there is no moment, no status and no page for it to say, so it says nothing
✅ got:
[🧪 test] the form that removes a run posts the run it is about
[⚙️ assert] the run's page offers the form
✅ got: #t
[⚙️ assert] and what it posts is the run's own id, and nothing else
✅ got: ((job-id . spec-job-id))
[⚙️ assert] so the handler reads the run out of the submission
✅ got: spec-job-id
ℹ️ info - ran 1 tasks in: 0 seconds, 40561 microseconds
running unit tests from file: retention-spec.scm, entrypoint: spec
ℹ️ info - shuffled with seed 651288570 (replay with VERITAS_SEED=651288570)
+----------------------------------------------+
[📑 suite] job retention
[🧪 test] a run that has outlived the retention is removed whole
[⚙️ assert] the tarball, its row, the log and the job all go, and the younger runs stay
✅ got: ((due-for-pruning (spec-old-run)) (pruned (spec-old-run)) (the-old-run-is-gone #f) (its-log-went-with-it #f) (its-tarball-row-went-with-it 0) (its-tarball-file-went-with-it #f) (the-run-from-yesterday-is-kept #t) (its-log-is-kept #t) (its-tarball-is-kept #t) (the-run-still-going-is-kept #t) (its-tarball-is-kept-too #t))
[🧪 test] a pass removes no more than the batch it was given
[⚙️ assert] the oldest goes first, the run behind it waits for the next pass
✅ got: ((due-asked-for-one (spec-oldest-run)) (due-asked-for-two (spec-oldest-run spec-older-run)) (a-pass-bounded-to-one (spec-oldest-run)) (the-run-behind-it-is-still-there #t) (and-the-next-pass-takes-it (spec-older-run)))
[🧪 test] a retention of 0 days keeps every run
[⚙️ assert] nothing is due, and a prune asked for anyway removes nothing
✅ got: ((nothing-is-due ()) (and-nothing-is-pruned 0))
[🧪 test] a run that is removed is removed whole
[⚙️ assert] the job, its log, its tarball's row and its tarball all go, and the run beside it stays
✅ got: ((the-removal-answers spec-removable-run) (the-job-is-gone #f) (its-log-went-with-it #f) (its-tarball-row-went-with-it 0) (its-tarball-file-went-with-it #f) (the-run-beside-it-is-still-there #t))
[🧪 test] a removal that raises part way leaves the run as it was
[⚙️ assert] the halves deleted before it raised are put back, down to the tarball
✅ got: ((the-removal-raised #t) (the-job-is-still-there #t) (its-log-is-still-there #t) (its-tarball-row-is-still-there 1) (its-tarball-file-is-still-there #t))
[🧪 test] a log whose job is gone is swept away
[⚙️ assert] the orphan goes, and the logs whose run is still there are left alone
✅ got: ((the-logs-whose-job-is-gone (log-spec-orphaned)) (the-orphan-is-gone #f) (the-logs-that-have-a-job-are-kept #t) (and-no-run-was-touched #t))
[🧪 test] a sweep removes no more than the batch it was given
[⚙️ assert] the oldest orphan goes first, the one behind it waits for the next sweep
✅ got: ((a-sweep-bounded-to-one (log-spec-elder-orphaned)) (the-one-behind-it-is-still-there #t) (and-the-next-sweep-takes-it (log-spec-younger-orphaned)))
ℹ️ info - ran 1 tasks in: 0 seconds, 150634 microseconds
running unit tests from file: ansi-spec.scm, entrypoint: spec
ℹ️ info - shuffled with seed 651288570 (replay with VERITAS_SEED=651288570)
+----------------------------------------------+
[📑 suite] ANSI console output
[🧪 test] the escapes a terminal would act on are read, and text is not
[⚙️ assert] plain text and its newlines are left whole
✅ got: (hello
world)
[⚙️ assert] an output that ends on a newline ends on that newline
✅ got: (a
)
[⚙️ assert] the text of a run is escaped for the serializer
✅ got: (a <b> & c)
[⚙️ assert] no escape byte survives into the markup
✅ got: [0m
[⚙️ assert] a title wrapped in an OSC is dropped, its text kept
✅ got: (abc)
[🧪 test] a line is drawn where the cursor is, not appended to
[⚙️ assert] a carriage return overwrites the line it is on
✅ got: (cd)
[⚙️ assert] a second frame shorter than the first leaves its tail
✅ got: (xycdef)
[⚙️ assert] a backspace steps the cursor back over what it wrote
✅ got: (bacspace)
[⚙️ assert] a tab advances to the next stop, eight columns on
✅ got: (tab end)
[🧪 test] what a progress line erases, it erases
[⚙️ assert] erase-in-line clears from the cursor to the end
✅ got: (done)
[⚙️ assert] clear-line clears the whole line
✅ got: (def)
[⚙️ assert] a line drawn again is the frame left on the screen
✅ got: (substitute: looking for substitutes... 100.0%)
[🧪 test] colour is asked for, changed and put back
[⚙️ assert] a weight is a class and a colour is this console's own
✅ got: ((span (@ (class font-bold)) bold) and (span (@ (class dark:text-asparagus-300 text-asparagus-700)) green))
[⚙️ assert] a reset puts the weight and the colour back
✅ got: ((span (@ (class font-bold)) loud) quiet)
[⚙️ assert] a bright colour takes the bright half of the palette
✅ got: ((span (@ (class dark:text-redwood-100 text-redwood-500)) red))
[⚙️ assert] the decorations a class can carry are carried for both themes
✅ got: ((span (@ (class italic line-through underline)) deco))
[🧪 test] the light end of the palette is the palette's own
[⚙️ assert] white ink is the pale end of the stone ramp
✅ got: ((span (@ (class dark:text-stone-500 text-onyx-500)) white))
[⚙️ assert] and its bright half the step above it, not the sheet's white
✅ got: ((span (@ (class dark:text-stone-300 text-onyx-300)) bright))
[⚙️ assert] a white ground is the console's panel paint, its bright half the paper
✅ got: ((span (@ (class bg-stone-300)) white) (span (@ (class bg-stone-100)) bright))
[⚙️ assert] and no colour of the sixteen is the browser's white or black
✅ got: bg-white
[⚙️ assert] nor the ink it draws its own text in
✅ got: text-white
[⚙️ assert] and nothing at all is grounded in the sheet's black
✅ got: bg-black
[⚙️ assert] nor drawn in it
✅ got: text-black
[🧪 test] a colour a class cannot name is written where it is exact
[⚙️ assert] a 256-colour value is the cube's own red
✅ got: ((span (@ (style color:#ff0000)) red))
[⚙️ assert] a grey of the ramp is the grey it names
✅ got: ((span (@ (style background-color:#303030)) grey))
[⚙️ assert] true colour is the colour it says, to the byte
✅ got: ((span (@ (style color:#123456)) true))
[⚙️ assert] a colour and a ground are both written
✅ got: ((span (@ (style color:#5fd7ff;background-color:#303030)) both))
[🧪 test] a line of output read as prose carries no escape and no earlier frame
[⚙️ assert] the escapes are dropped and what they coloured is kept
✅ got: red
[⚙️ assert] a line painted again comes back as the reading it ended in
✅ got: second
[⚙️ assert] and text an escape does not touch is left as it stands
✅ got: hello
world
[🧪 test] the bytes a command sent are the reading that spells them out
[⚙️ assert] an escape is spelled the way a terminal would print it
✅ got: ^[[31mred^[[0m
[⚙️ assert] a carriage return is shown rather than obeyed
✅ got: first^Msecond
[⚙️ assert] and the line break is still the line break
✅ got: a
b
[⚙️ assert] and a character outside ASCII is a character, not a byte
✅ got: café ▮ 日本語
[🧪 test] a pane reads its output the way the page is answered
[⚙️ assert] the pane draws a coloured run as a span
✅ got: #t
[⚙️ assert] the pane carries no escape into the markup
✅ got: [0m
[⚙️ assert] the pane redraws a progress line rather than keeping both
✅ got: #t
[⚙️ assert] and what the reader is shown is the reading it ended in
✅ got: >second<span class="ml-2
[⚙️ assert] while the reading it was painted over is folded out of sight
✅ got: >first<
[⚙️ assert] and the pane's own control is what unfolds it
✅ got: data-frames-toggle
ℹ️ info - ran 1 tasks in: 0 seconds, 9358 microseconds
running unit tests from file: about-page-spec.scm, entrypoint: spec
ℹ️ info - shuffled with seed 651288570 (replay with VERITAS_SEED=651288570)
+----------------------------------------------+
[📑 suite] about page
[🧪 test] the page a crawler is handed says what it is
[⚙️ assert] it declares itself a document of this deployment
✅ got: <!DOCTYPE html>
[⚙️ assert] it is dressed in the same sheet as every other page
✅ got: /css/byggsteg.olive.min.css
[⚙️ assert] its canonical address is the page's own
✅ got: /about
[⚙️ assert] its title names the deployment and the page
✅ got: <title>byggsteg - about</title>
[🧪 test] the account is on the page, and it is this deployment's
[⚙️ assert] the story names the people, the framework and the three doors
✅ got: ()
[⚙️ assert] and it holds nothing the page was never meant to say
✅ got: ()
[⚙️ assert] the page opens under the console's own prompt
✅ got: $ byggsteg --origin
[🧪 test] the page can be read on a phone
[⚙️ assert] no panel of it forces a width wider than the screen
✅ got: ()
[⚙️ assert] the panels are told they may shrink
✅ got: min-w-0
[🧪 test] the page is built by the components rather than describing them
[⚙️ assert] no component call was left un-made in the markup
✅ got: ()
[⚙️ assert] every element of the page is an element a browser knows
✅ got: ()
[⚙️ assert] no element of the page is closed on itself
❌ fail - error, expect: #t, got: (<img src="/img/byggsteg-logo.png" alt="" loading="lazy" class="h-7 shrink-0 w-auto" />)
ℹ️ info - ran 1 tasks in: 0 seconds, 236386 microseconds
❌ error -
aborting due to error in test case...
/root/.cache/guile/ccache/3.0-LE-8-4.7/etc/byggsteg/job-clone/byggsteg/trunk/lib/byggsteg/job/pipeline.scm.go
WARNING: (byggsteg html): `get-short-commit-sha' imported from both (hygguile hygguile) and (kracht prelude)
WARNING: (byggsteg html): imported module (hygguile hygguile) overrides core binding `select'
;;; compiled /root/.cache/guile/ccache/3.0-LE-8-4.7/etc/byggsteg/job-clone/byggsteg/trunk/lib/byggsteg/html.scm.go
;;; compiled /root/.cache/guile/ccache/3.0-LE-8-4.7/etc/byggsteg/job-clone/byggsteg/trunk/lib/byggsteg/errors.scm.go
;;; compiled /root/.cache/guile/ccache/3.0-LE-8-4.7/etc/byggsteg/job-clone/byggsteg/trunk/test/veritas/unit/error-pages-spec.scm.go
WARNING: (byggsteg html): `get-short-commit-sha' imported from both (hygguile hygguile) and (kracht prelude)
;;; compiling ./test/veritas/unit/console-resources-spec.scm
;;; compiled /root/.cache/guile/ccache/3.0-LE-8-4.7/etc/byggsteg/job-clone/byggsteg/trunk/test/veritas/unit/console-resources-spec.scm.go
;;; compiling ./test/veritas/unit/runners-spec.scm
;;; compiled /root/.cache/guile/ccache/3.0-LE-8-4.7/etc/byggsteg/job-clone/byggsteg/trunk/test/veritas/unit/runners-spec.scm.go
;;; compiling ./test/veritas/unit/password-reset-spec.scm
;;; compiling ./lib/byggsteg/domain/password-reset.scm
;;; compiled /root/.cache/guile/ccache/3.0-LE-8-4.7/etc/byggsteg/job-clone/byggsteg/trunk/lib/byggsteg/domain/password-reset.scm.go
;;; compiled /root/.cache/guile/ccache/3.0-LE-8-4.7/etc/byggsteg/job-clone/byggsteg/trunk/test/veritas/unit/password-reset-spec.scm.go
;;; compiling ./test/veritas/unit/job-search-spec.scm
;;; compiling ./lib/byggsteg/search.scm
;;; compiling ./lib/byggsteg/domain/profile.scm
;;; compiled /root/.cache/guile/ccache/3.0-LE-8-4.7/etc/byggsteg/job-clone/byggsteg/trunk/lib/byggsteg/domain/profile.scm.go
;;; compiling ./lib/byggsteg/sqlite/profile.scm
;;; compiled /root/.cache/guile/ccache/3.0-LE-8-4.7/etc/byggsteg/job-clone/byggsteg/trunk/lib/byggsteg/sqlite/profile.scm.go
;;; compiled /root/.cache/guile/ccache/3.0-LE-8-4.7/etc/byggsteg/job-clone/byggsteg/trunk/lib/byggsteg/search.scm.go
;;; compiled /root/.cache/guile/ccache/3.0-LE-8-4.7/etc/byggsteg/job-clone/byggsteg/trunk/test/veritas/unit/job-search-spec.scm.go
;;; compiling ./test/veritas/unit/logs-page-spec.scm
;;; compiling ./app/views/logs.scm
;;; compiled /root/.cache/guile/ccache/3.0-LE-8-4.7/etc/byggsteg/job-clone/byggsteg/trunk/app/views/logs.scm.go
;;; compiled /root/.cache/guile/ccache/3.0-LE-8-4.7/etc/byggsteg/job-clone/byggsteg/trunk/test/veritas/unit/logs-page-spec.scm.go
;;; compiling ./test/veritas/unit/retention-spec.scm
;;; compiling ./lib/byggsteg/job/retention.scm
;;; compiled /root/.cache/guile/ccache/3.0-LE-8-4.7/etc/byggsteg/job-clone/byggsteg/trunk/lib/byggsteg/job/retention.scm.go
;;; compiled /root/.cache/guile/ccache/3.0-LE-8-4.7/etc/byggsteg/job-clone/byggsteg/trunk/test/veritas/unit/retention-spec.scm.go
;;; compiling ./test/veritas/unit/ansi-spec.scm
;;; compiled /root/.cache/guile/ccache/3.0-LE-8-4.7/etc/byggsteg/job-clone/byggsteg/trunk/test/veritas/unit/ansi-spec.scm.go
;;; compiling ./test/veritas/unit/about-page-spec.scm
;;; compiling ./app/views/about.scm
;;; compiled /root/.cache/guile/ccache/3.0-LE-8-4.7/etc/byggsteg/job-clone/byggsteg/trunk/app/views/about.scm.go
;;; compiled /root/.cache/guile/ccache/3.0-LE-8-4.7/etc/byggsteg/job-clone/byggsteg/trunk/test/veritas/unit/about-page-spec.scm.go
maak: error: command failed (exit code 1): guix time-machine --channels=channels.scm -- shell -m manifest.scm -- guile --fresh-auto-compile -L ./lib -L . -L ./test -c '(begin (setlocale LC_ALL "C.UTF-8") (use-modules (veritas runner)) (run-tests))'
hint: Fix the command above, or run `maak --dry-run' to inspect it first.
--------------------------------------------------------------
Maak 0.14.0: the infinitely extensible command runner
Loading tasks from Maak file: /etc/byggsteg/job-clone/byggsteg/trunk/maak.scm
Executing Maak tasks: test
--------------------------------------------------------------
=> Running Maak task: test
Run the project's test suite.
The specs live under test/veritas/unit. That is where the runner looks for
suites -- given the test root ./test, the module prefix `veritas' and the suite
name `unit' -- and it is also what their own `(veritas unit ...)' module names
resolve to on the load path below.
The run names the locale it reads in rather than inheriting one, and it names it
before the runner is asked for: Guile reads a page by the locale in force, and a
pattern over a page that holds a character outside ASCII reports matches where
there are none under a locale that names no encoding. `C.UTF-8' is that name
for a machine that has never generated a locale -- a service, a container, and
this project's own continuous integration, where the job that tests byggsteg is
run by a byggsteg daemon whose Shepherd unit names no locale at all. The suite
reads the pages of this console, whose prose, whose marks and whose console
output are not ASCII; run without a locale it would find elements no browser
knows and fail for a reason that has nothing to do with the change it was run to
judge. Naming it here is part of what the pinned manifest is for: a run of the
suite reads the same pages wherever the pipeline runs.
The specs are compiled rather than read from a cache. A compiled spec outlives
the library it was compiled against -- Guile decides a compiled file is still
good by the spec's own text, and not by the macros and procedures that text was
compiled with -- so a spec compiled before `(veritas veritas)' changed shape is
loaded afterwards as if it still had the old one. What that reads like is a
failure inside this project: `Wrong type to apply: #<syntax-transformer test>'
at the first test of a suite, on a spec whose source is untouched and whose
suite passes when compiled afresh. Compiling from source every run costs a few
seconds and makes the reading depend on the specs alone, which is what a suite
that is run to judge a change has to be.
Executing: guix time-machine --channels=channels.scm -- shell -m manifest.scm -- guile --fresh-auto-compile -L ./lib -L . -L ./test -c '(begin (setlocale LC_ALL "C.UTF-8") (use-modules (veritas runner)) (run-tests))'