gitshark

Clone repository

git clone https://gitshark.ha1nz.de/git/workaround/Gitshark.git
git clone git@gitshark.ha1nz.de:workaround/Gitshark.git

← Commits

๐Ÿ’„ (releases): Give the release forms labels, hints and room

702b7ec919168ae5f7bbdf1a5b44d9763e5e8894 ยท Phillip Souza Furtner ยท 2026-09-08T12:07:02Z

Changes

5 files changed, +258 -49

MODIFY docs/users/releases.md +10 -8
diff --git a/docs/users/releases.md b/docs/users/releases.md
index 2f5faec..69ca85c 100644
--- a/docs/users/releases.md
+++ b/docs/users/releases.md
@@ -25,15 +25,17 @@
25 25 ## Publishing a release
26 26
27 27 1. Open **Releases โ†’ New release**.
28 -2. **Tag** โ€” type the tag name, e.g. `v1.0.0`. The field suggests tags that
29 - already exist and have no release yet.
30 -3. **Create the tag from** โ€” pick a branch when the tag does not exist yet.
31 - git-shark then creates an annotated tag on that branch's current commit as
32 - part of publishing. When the tag already exists, this choice is ignored.
33 -4. **Title** โ€” optional; defaults to the tag name.
34 -5. **Release notes** โ€” optional Markdown (headings, lists, tables, links).
28 +2. In the **Tag** group:
29 + - **Tag name** โ€” the tag to publish, e.g. `v1.0.0`. The field suggests tags
30 + that already exist and have no release yet.
31 + - **Create the tag from** โ€” pick a branch when the tag does not exist yet.
32 + git-shark then creates an annotated tag on that branch's current commit as
33 + part of publishing. When the tag already exists, this choice is ignored.
34 +3. **Title** (optional) โ€” defaults to the tag name.
35 +4. **Release notes** (optional) โ€” Markdown (headings, lists, tables, links).
35 36 Embedded raw HTML is escaped, so notes can never inject markup into the page.
36 -6. **This is a pre-release** โ€” tick for a release candidate or beta.
37 +5. **This is a pre-release** โ€” tick for a release candidate or beta. It is
38 + listed like any other release but never gets the **Latest** badge.
37 39
38 40 Publishing takes you straight to the new release page.
39 41
MODIFY src/main/resources/META-INF/resources/shark.css +116 -12
diff --git a/src/main/resources/META-INF/resources/shark.css b/src/main/resources/META-INF/resources/shark.css
index 5c49b11..fa7a301 100644
--- a/src/main/resources/META-INF/resources/shark.css
+++ b/src/main/resources/META-INF/resources/shark.css
@@ -942,27 +942,131 @@
942 942 gap: var(--s2);
943 943 }
944 944
945 -/* release form: the target select and the pre-release checkbox sit inline with their label text,
946 - instead of stretching to the full field width like the text inputs above them */
947 -.release-target,
948 -.release-flag {
945 +/* Labelled single-column form (release pages). Every control carries a visible label plus optional
946 + hint text tied to it via aria-describedby; field width signals the expected input length instead
947 + of stretching everything to the column. */
948 +.stack-form {
949 949 display: flex;
950 - align-items: center;
951 - gap: var(--s2);
952 - font: 14px/1.4 var(--font);
950 + flex-direction: column;
951 + gap: var(--s5);
952 + /* wide enough to make the notes box worth typing in, capped so the short inputs above it don't
953 + stretch into fields far longer than the values they hold */
954 + max-width: 1200px;
955 + margin: 0 0 var(--s6);
956 +}
957 +
958 +.stack-form .field {
959 + display: flex;
960 + flex-direction: column;
961 + gap: var(--s1);
962 +}
963 +
964 +.stack-form label {
965 + font: 600 14px/1.4 var(--font);
966 + color: var(--ink);
967 +}
968 +
969 +.stack-form .optional {
970 + font-weight: 400;
953 971 color: var(--muted);
954 972 }
955 973
956 -.release-target select,
957 -.release-flag input[type="checkbox"] {
958 - width: auto;
959 - margin-top: 0;
974 +.stack-form .hint {
975 + margin: 0;
976 + font: 13px/1.45 var(--font);
977 + color: var(--muted);
960 978 }
961 979
962 -.release-flag input[type="checkbox"] {
980 +.stack-form input,
981 +.stack-form textarea,
982 +.stack-form select {
983 + margin-top: var(--s1);
984 +}
985 +
986 +.stack-form .field-short input,
987 +.stack-form .field-short select {
988 + max-width: 15rem;
989 +}
990 +
991 +.stack-form .field-medium input {
992 + max-width: 32rem;
993 +}
994 +
995 +/* the tag name and the ref it is cut from answer one question, so they share a group */
996 +.stack-form .field-group {
997 + display: flex;
998 + flex-direction: column;
999 + gap: var(--s4);
1000 + /* hug the fields instead of stretching the box across the whole form width */
1001 + align-self: flex-start;
1002 + margin: 0;
1003 + padding: var(--s4) var(--s4) var(--s5);
1004 + border: 1px solid var(--border);
1005 + border-radius: var(--radius);
1006 + background: var(--surface);
1007 +}
1008 +
1009 +.stack-form .field-row {
1010 + flex-direction: row;
1011 + flex-wrap: wrap;
1012 + gap: var(--s5);
1013 +}
1014 +
1015 +/* the basis has to sit on the field, not the control: the hint paragraph would otherwise size the
1016 + column and blow the row apart */
1017 +.stack-form .field-row > .field {
1018 + flex: 1 1 15rem;
1019 + max-width: 26rem;
1020 +}
1021 +
1022 +/* hints of different lengths leave the controls at different heights โ€” pushing them down lines the
1023 + row of inputs up along its bottom edge */
1024 +.stack-form .field-row > .field input,
1025 +.stack-form .field-row > .field select {
1026 + margin-top: auto;
1027 + max-width: none;
1028 +}
1029 +
1030 +.stack-form .field-group legend {
1031 + padding: 0 var(--s2);
1032 + font: 600 13px/1 var(--font);
1033 + color: var(--ink);
1034 +}
1035 +
1036 +.stack-form .check {
1037 + display: flex;
1038 + align-items: center;
1039 + gap: var(--s2);
1040 + /* the label wraps the box so the whole row is a target of at least 24px (WCAG 2.5.8) */
1041 + min-height: 24px;
1042 + cursor: pointer;
1043 +}
1044 +
1045 +.stack-form .check input[type="checkbox"] {
1046 + flex: none;
1047 + width: 16px;
1048 + height: 16px;
1049 + margin-top: 0;
963 1050 accent-color: var(--accent);
964 1051 }
965 1052
1053 +.stack-form .check-hint {
1054 + padding-left: calc(16px + var(--s2));
1055 +}
1056 +
1057 +.stack-form .form-actions {
1058 + margin-top: var(--s1);
1059 +}
1060 +
1061 +.form-cancel {
1062 + font: 500 14px/1 var(--font);
1063 + color: var(--muted);
1064 +}
1065 +
1066 +.form-cancel:hover {
1067 + color: var(--accent-deep);
1068 +}
1069 +
966 1070 /* the discussion comment box and its submit button would otherwise touch */
967 1071 .comment-form .form-actions {
968 1072 margin-top: var(--s3);
MODIFY src/main/resources/templates/ReleaseResource/editRelease.html +24 -7
diff --git a/src/main/resources/templates/ReleaseResource/editRelease.html b/src/main/resources/templates/ReleaseResource/editRelease.html
index 7662518..44c33a0 100644
--- a/src/main/resources/templates/ReleaseResource/editRelease.html
+++ b/src/main/resources/templates/ReleaseResource/editRelease.html
@@ -5,16 +5,33 @@
5 5 <section class="repo-main">
6 6 <p><a href="/repos/{repo.ownerHandle}/{repo.name}/releases/tag/{release.tagName}">โ† {release.tagName}</a></p>
7 7 <h2>Edit release</h2>
8 - <form class="issue-form" method="post"
8 + <form class="stack-form" method="post"
9 9 action="/repos/{repo.ownerHandle}/{repo.name}/releases/tag/{release.tagName}/edit">
10 - <input type="text" name="title" value="{release.title}" placeholder="Release title" autocomplete="off">
11 - <textarea name="body" placeholder="Release notes (Markdown, optional)" rows="10">{release.body ?: ''}</textarea>
12 - <label class="release-flag">
13 - <input type="checkbox" name="prerelease" {#if release.prerelease}checked{/if}> This is a pre-release
14 - </label>
10 + <div class="field field-medium">
11 + <label for="release-title">Title <span class="optional">(optional)</span></label>
12 + <p class="hint" id="release-title-hint">Defaults to the tag name</p>
13 + <input type="text" id="release-title" name="title" value="{release.title}" autocomplete="off"
14 + aria-describedby="release-title-hint">
15 + </div>
16 + <div class="field">
17 + <label for="release-body">Release notes <span class="optional">(optional)</span></label>
18 + <p class="hint" id="release-body-hint">Markdown is supported โ€” raw HTML is escaped</p>
19 + <textarea id="release-body" name="body" rows="18"
20 + aria-describedby="release-body-hint">{release.body ?: ''}</textarea>
21 + </div>
22 + <div class="field">
23 + <label class="check" for="release-prerelease">
24 + <input type="checkbox" id="release-prerelease" name="prerelease"
25 + aria-describedby="release-prerelease-hint" {#if release.prerelease}checked{/if}>
26 + <span>This is a pre-release</span>
27 + </label>
28 + <p class="hint check-hint" id="release-prerelease-hint">Listed like any other release, but never gets
29 + the Latest badge</p>
30 + </div>
15 31 <div class="form-actions">
16 32 <button type="submit" class="btn btn-primary">Save release</button>
17 - <a class="btn btn-secondary" href="/repos/{repo.ownerHandle}/{repo.name}/releases/tag/{release.tagName}">Cancel</a>
33 + <a class="form-cancel"
34 + href="/repos/{repo.ownerHandle}/{repo.name}/releases/tag/{release.tagName}">Cancel</a>
18 35 </div>
19 36 </form>
20 37 </section>
MODIFY src/main/resources/templates/ReleaseResource/newRelease.html +45 -22
diff --git a/src/main/resources/templates/ReleaseResource/newRelease.html b/src/main/resources/templates/ReleaseResource/newRelease.html
index 66526f7..81ee7a1 100644
--- a/src/main/resources/templates/ReleaseResource/newRelease.html
+++ b/src/main/resources/templates/ReleaseResource/newRelease.html
@@ -5,30 +5,53 @@
5 5 <section class="repo-main">
6 6 <p><a href="/repos/{repo.ownerHandle}/{repo.name}/releases">โ† Releases</a></p>
7 7 <h2>New release</h2>
8 - <form class="issue-form" method="post" action="/repos/{repo.ownerHandle}/{repo.name}/releases">
9 - <input type="text" name="tagName" placeholder="Tag (e.g. v1.0.0)" required autocomplete="off"
10 - list="release-tags">
11 - <datalist id="release-tags">
12 - {#for tag in untaggedTags}
13 - <option value="{tag}"></option>
14 - {/for}
15 - </datalist>
16 - <label class="release-target">
17 - Create the tag from
18 - <select name="target">
19 - {#for target in targets}
20 - <option value="{target}">{target}</option>
21 - {/for}
22 - </select>
23 - </label>
24 - <input type="text" name="title" placeholder="Release title (defaults to the tag)" autocomplete="off">
25 - <textarea name="body" placeholder="Release notes (Markdown, optional)" rows="10"></textarea>
26 - <label class="release-flag">
27 - <input type="checkbox" name="prerelease"> This is a pre-release
28 - </label>
8 + <form class="stack-form" method="post" action="/repos/{repo.ownerHandle}/{repo.name}/releases">
9 + <fieldset class="field-group field-row">
10 + <legend>Tag</legend>
11 + <div class="field field-short">
12 + <label for="release-tag">Tag name</label>
13 + <p class="hint" id="release-tag-hint">Existing tags without a release are suggested as you type</p>
14 + <input type="text" id="release-tag" name="tagName" placeholder="v1.0.0" required
15 + autocomplete="off" aria-describedby="release-tag-hint" list="release-tags">
16 + <datalist id="release-tags">
17 + {#for tag in untaggedTags}
18 + <option value="{tag}"></option>
19 + {/for}
20 + </datalist>
21 + </div>
22 + <div class="field field-short">
23 + <label for="release-target">Create the tag from</label>
24 + <p class="hint" id="release-target-hint">Only used when the tag does not exist yet</p>
25 + <select id="release-target" name="target" aria-describedby="release-target-hint">
26 + {#for target in targets}
27 + <option value="{target}">{target}</option>
28 + {/for}
29 + </select>
30 + </div>
31 + </fieldset>
32 + <div class="field field-medium">
33 + <label for="release-title">Title <span class="optional">(optional)</span></label>
34 + <p class="hint" id="release-title-hint">Defaults to the tag name</p>
35 + <input type="text" id="release-title" name="title" autocomplete="off"
36 + aria-describedby="release-title-hint">
37 + </div>
38 + <div class="field">
39 + <label for="release-body">Release notes <span class="optional">(optional)</span></label>
40 + <p class="hint" id="release-body-hint">Markdown is supported โ€” raw HTML is escaped</p>
41 + <textarea id="release-body" name="body" rows="18" aria-describedby="release-body-hint"></textarea>
42 + </div>
43 + <div class="field">
44 + <label class="check" for="release-prerelease">
45 + <input type="checkbox" id="release-prerelease" name="prerelease"
46 + aria-describedby="release-prerelease-hint">
47 + <span>This is a pre-release</span>
48 + </label>
49 + <p class="hint check-hint" id="release-prerelease-hint">Listed like any other release, but never gets
50 + the Latest badge</p>
51 + </div>
29 52 <div class="form-actions">
30 53 <button type="submit" class="btn btn-primary">Publish release</button>
31 - <a class="btn btn-secondary" href="/repos/{repo.ownerHandle}/{repo.name}/releases">Cancel</a>
54 + <a class="form-cancel" href="/repos/{repo.ownerHandle}/{repo.name}/releases">Cancel</a>
32 55 </div>
33 56 </form>
34 57 </section>
MODIFY src/test/java/de/workaround/web/ReleaseUiTest.java +63 -0
diff --git a/src/test/java/de/workaround/web/ReleaseUiTest.java b/src/test/java/de/workaround/web/ReleaseUiTest.java
index 2651d95..bd7b26f 100644
--- a/src/test/java/de/workaround/web/ReleaseUiTest.java
+++ b/src/test/java/de/workaround/web/ReleaseUiTest.java
@@ -7,6 +7,8 @@
7 7 import java.util.Map;
8 8 import java.util.zip.GZIPInputStream;
9 9 import java.util.zip.ZipEntry;
10 +import java.util.regex.Matcher;
11 +import java.util.regex.Pattern;
10 12 import java.util.zip.ZipInputStream;
11 13
12 14 import org.junit.jupiter.api.Test;
@@ -25,6 +27,7 @@
25 27 import static org.hamcrest.CoreMatchers.containsString;
26 28 import static org.hamcrest.CoreMatchers.not;
27 29 import static org.junit.jupiter.api.Assertions.assertEquals;
30 +import static org.junit.jupiter.api.Assertions.assertFalse;
28 31 import static org.junit.jupiter.api.Assertions.assertTrue;
29 32
30 33 @QuarkusTest
@@ -40,6 +43,66 @@
40 43 User.Repo users;
41 44
42 45 @Test
46 + @TestSecurity(user = "rel-form-owner")
47 + void newReleaseFormLabelsEveryControlAndKeepsPlaceholdersAsExamplesOnly() throws Exception
48 + {
49 + User owner = persistUser("rel-form-owner");
50 + seededRepo(owner, "formrepo");
51 +
52 + String html = given().when().get("/repos/" + owner.username + "/formrepo/releases/new")
53 + .then().statusCode(200)
54 + .extract().body().asString();
55 +
56 + for (String id : List.of("release-tag", "release-target", "release-title", "release-body", "release-prerelease"))
57 + {
58 + assertTrue(html.contains("for=\"" + id + "\""), "no label bound to " + id);
59 + assertTrue(html.contains("id=\"" + id + "\""), "no control with id " + id);
60 + }
61 +
62 + assertTrue(html.contains("id=\"release-body-hint\"") && html.contains("aria-describedby=\"release-body-hint\""),
63 + "the release-notes hint must be tied to the textarea via aria-describedby");
64 +
65 + assertTrue(html.contains("placeholder=\"v1.0.0\""), "the tag field keeps a format example as placeholder");
66 + assertFalse(html.contains("placeholder=\"Release title"), "the title label must not live in a placeholder");
67 + assertFalse(html.contains("placeholder=\"Release notes"), "the notes label must not live in a placeholder");
68 + assertFalse(html.contains("placeholder=\"Tag (e.g."), "the tag label must not live in a placeholder");
69 +
70 + assertTrue(html.contains("<legend>"), "tag name and target belong to one labelled group");
71 +
72 + assertTrue(html.contains("class=\"btn btn-primary\""), "the form needs exactly one primary action");
73 + assertFalse(html.contains("btn btn-secondary"), "cancel must be a link, not a second competing button");
74 + }
75 +
76 + @Test
77 + @TestSecurity(user = "rel-width-owner")
78 + void newReleaseFormSpreadsAcrossTheContentColumn() throws Exception
79 + {
80 + User owner = persistUser("rel-width-owner");
81 + seededRepo(owner, "widerepo");
82 +
83 + String html = given().when().get("/repos/" + owner.username + "/widerepo/releases/new")
84 + .then().statusCode(200)
85 + .extract().body().asString();
86 +
87 + assertTrue(html.contains("field-group field-row"),
88 + "tag name and target must sit next to each other instead of stacking in a narrow column");
89 +
90 + String css = given().when().get("/shark.css")
91 + .then().statusCode(200)
92 + .extract().body().asString();
93 +
94 + int start = css.indexOf(".stack-form {");
95 + assertTrue(start >= 0, ".stack-form rule missing from the stylesheet");
96 + String rule = css.substring(start, css.indexOf('}', start));
97 + Matcher cap = Pattern.compile("max-width:\\s*(\\d+)px").matcher(rule);
98 + assertTrue(cap.find(), "the form declares no max-width: " + rule);
99 + assertTrue(Integer.parseInt(cap.group(1)) >= 1000,
100 + "the form must span the content column, not a narrow reading column: " + rule);
101 +
102 + assertTrue(css.contains(".stack-form .field-row"), "the row layout for grouped fields is not defined");
103 + }
104 +
105 + @Test
43 106 @TestSecurity(user = "rel-ui-owner")
44 107 void ownerCanPublishBrowseEditAndDeleteARelease() throws Exception
45 108 {

Keyboard shortcuts

?Show this help
g hGo home
EscClose dialog