A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit 27e5426f8f

27e5426f8fd6a52362f9607743bd6ba02a144275

parent: 235ad49736

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-26T01:38:47Z

web: input states, segmented visibility control, working name pattern

Shape is the semantic on radios and checkboxes and nothing else is: a
circle takes one answer, a square takes any number, and that distinction
survives colour being removed. Squaring the radios to match the corners
elsewhere would trade the one cue that always works for consistency with
the borders, so the native shapes stay and only accent-color joins the
system.

Where the shape question actually bit — public against private on the new
repository form — a segmented control fits better than either. It is two
real radios with their labels styled as joined segments, so arrow keys,
grouping and screen reader output are unchanged, and a fieldset legend
gives the group a name it did not have.

The name field's pattern has never run. HTML compiles pattern with the
RegExp v flag, under which a bare - in a character class is a syntax
error, and a pattern that fails to compile is ignored rather than
reported: [a-z0-9][a-z0-9._-]* silently validated nothing. Escaping the
dash fixes it, and the rule now mirrors policy.namePat including the
63 character cap. Server-side validation was always correct, so this cost
feedback, not safety.

Invalid styling hangs off :user-invalid rather than :invalid, so a
required field is not coloured red before anyone has typed in it. Error
blocks are announced and read as blocks rather than as red prose.
Placeholder and selection colours join the palette.
internal/web/static/style.css +66 −1
@@ -530,7 +530,14 @@ code.fullsha { color: var(--muted); overflow-wrap: anywhere; }
530530 .empty-note { color: var(--muted); }
531531 .crumbs { color: var(--muted); font-size: var(--fs-2); }
532532 .desc, td.desc { color: var(--muted); }
533.error { color: var(--bad); }
533.error {
534 color: var(--fg);
535 border: 1px solid var(--bad);
536 border-left-width: 3px;
537 border-radius: var(--r-sm);
538 padding: var(--sp-2) var(--sp-3);
539 margin-bottom: var(--sp-4);
540}
534541 .meta { color: var(--muted); font-size: var(--fs-1); }
535542 svg.icon { vertical-align: -0.125em; }
536543 .lede { font-size: var(--fs-3); margin: var(--sp-2) 0; }
@@ -970,6 +977,64 @@ input:focus, textarea:focus, select:focus { border-color: var(--accent); }
970977 input[type="radio"], input[type="checkbox"] { accent-color: var(--fill); }
971978 label:has(> input[type="radio"]), label:has(> input[type="checkbox"]),
972979 label:has(> select) { display: inline-flex; align-items: center; gap: var(--sp-2); }
980
981/* Shape carries the meaning on these two and nothing else does: a circle
982 takes one answer, a square takes any number. It survives colour being
983 removed — greyscale, high contrast, colour blindness — so the native
984 shapes stay and only the colour joins the system. Drawing them by hand
985 would align the corners at the cost of the one cue that always works. */
986input[type="radio"], input[type="checkbox"] { accent-color: var(--fill); }
987
988/* a segmented control for a small, mutually exclusive set: real radios
989 underneath, so arrow keys and screen readers behave as they always did */
990fieldset.segmented { border: 0; margin: 0 0 var(--sp-4); padding: 0; }
991fieldset.segmented legend {
992 padding: 0;
993 margin-bottom: var(--sp-2);
994 color: var(--muted);
995 font-size: var(--fs-2);
996}
997fieldset.segmented .options { display: inline-flex; }
998fieldset.segmented label { gap: 0; }
999fieldset.segmented input {
1000 position: absolute;
1001 width: 1px;
1002 height: 1px;
1003 opacity: 0;
1004 pointer-events: none;
1005}
1006fieldset.segmented span {
1007 display: block;
1008 padding: var(--sp-2) var(--sp-4);
1009 border: 1px solid var(--line);
1010 font-size: var(--fs-2);
1011 cursor: pointer;
1012}
1013fieldset.segmented label + label span { border-left: 0; }
1014fieldset.segmented label:first-child span { border-radius: var(--r-sm) 0 0 var(--r-sm); }
1015fieldset.segmented label:last-child span { border-radius: 0 var(--r-sm) var(--r-sm) 0; }
1016fieldset.segmented span:hover { border-color: var(--muted); }
1017fieldset.segmented input:checked + span {
1018 background: var(--fill);
1019 border-color: var(--fill);
1020 color: #ffffff;
1021 font-weight: 600;
1022}
1023fieldset.segmented input:focus-visible + span {
1024 outline: 2px solid var(--accent);
1025 outline-offset: 2px;
1026}
1027
1028/* remaining input states */
1029::placeholder { color: var(--muted); opacity: 1; }
1030::selection { background: var(--fill); color: #ffffff; }
1031/* :user-invalid, not :invalid — a required field is invalid the moment
1032 the page loads, and colouring it red before anyone has typed is a
1033 scolding, not a hint */
1034input:user-invalid, textarea:user-invalid {
1035 border-color: var(--bad);
1036}
1037.hint { display: block; color: var(--muted); font-size: var(--fs-1); margin-top: var(--sp-1); }
9731038 button {
9741039 background: var(--fill);
9751040 color: #ffffff;
internal/web/templates/edit.html +1 −1
@@ -1,7 +1,7 @@
11 {{define "title"}}edit {{.Path}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
22 {{define "content"}}
33 <h1>edit {{.Repo.OwnerName}}/{{.Repo.Name}} : {{.Path}} @ {{.Ref}}</h1>
4{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
4{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
55 <form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/edit/{{.Ref}}/{{.Path}}">
66 <p><textarea name="content" aria-label="File contents" rows="24" style="width:100%" spellcheck="false">{{.Content}}</textarea></p>
77 <p><input name="message" aria-label="Commit message" placeholder="commit message" style="width:60%">
internal/web/templates/login.html +1 −1
@@ -1,7 +1,7 @@
11 {{define "title"}}login · {{.Site}}{{end}}
22 {{define "content"}}
33 <h1>Log in</h1>
4{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
4{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
55 <p>Browser sessions are minted over SSH — there is no password. From a machine
66 with your registered key:</p>
77 <pre class="message">ssh git@{{.Site}} web login</pre>
internal/web/templates/new.html +10 −4
@@ -1,15 +1,21 @@
11 {{define "title"}}new repository · {{.Site}}{{end}}
22 {{define "content"}}
33 <h1>New repository</h1>
4{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
4{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
55 <form method="post" action="/new">
66 <p><label>Owner <select name="owner">
77 <option value="{{.Viewer}}">{{.Viewer}}</option>
88 {{range .Orgs}}<option value="{{.}}">{{.}}</option>{{end}}
99 </select></label>
10<label>/ Name <input name="name" required pattern="[a-z0-9][a-z0-9._-]*"></label></p>
11<p><label><input type="radio" name="visibility" value="public" checked> Public</label>
12 <label><input type="radio" name="visibility" value="private"> Private</label></p>
10<label>/ Name <input name="name" required maxlength="63" pattern="[a-z0-9][a-z0-9._\-]{0,62}" aria-describedby="namehint"></label>
11<span class="hint" id="namehint">Lowercase letters, digits, dot, dash and underscore; must start with a letter or digit; up to 63 characters.</span></p>
12<fieldset class="segmented">
13 <legend>Visibility</legend>
14 <div class="options">
15 <label><input type="radio" name="visibility" value="public" checked><span>Public</span></label>
16 <label><input type="radio" name="visibility" value="private"><span>Private</span></label>
17 </div>
18</fieldset>
1319 <p><button type="submit">Create repository</button></p>
1420 </form>
1521 {{end}}
internal/web/templates/register.html +1 −1
@@ -4,7 +4,7 @@
44 <h1>Create an account</h1>
55 {{if eq .Mode "invite"}}<p class="lede">This instance is invite-only: you need an invite code from an admin.</p>
66 {{else}}<p class="lede">Open registration — your account activates once you verify your email.</p>{{end}}
7{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
7{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
88 <form method="post" action="/register" class="signupform">
99 <p><label>username<br><input type="text" name="username" value="{{.Username}}" required autofocus></label></p>
1010 {{if eq .Mode "invite"}}<p><label>invite code<br><input type="text" name="invite" required></label></p>
internal/web/templates/search.html +1 −1
@@ -5,7 +5,7 @@
55 <input type="search" name="q" aria-label="Search file contents" value="{{.Query}}" placeholder="search file contents on {{.Ref}}" autofocus>
66 <button type="submit">search</button>
77 </form>
8{{if .QueryErr}}<p class="error">{{.QueryErr}}</p>
8{{if .QueryErr}}<p class="error" role="alert">{{.QueryErr}}</p>
99 {{else if .Query}}
1010 {{if .Matches}}
1111 <p class="meta">{{len .Matches}} match{{if ne (len .Matches) 1}}es{{end}}{{if .Capped}} (capped — refine the query){{end}} on <code>{{.Ref}}</code></p>