✨ feat: add anonymous landing page at /
Changes
10 files changed, +334 -5
MODIFY
README.md
+2 -2
@@ -14,8 +14,8 @@
14
14
- push and private read authenticate with **personal access tokens** (HTTP Basic password)
15
15
- Clone/fetch/push over `ssh://git@<host>:2222/<owner>/<repo>.git`
16
16
- public-key authentication only; keys managed per user in the UI
17
-- Web UI: repository list, file/tree browser, commit log (paginated), branches & tags
18
-- OIDC login (authorization code flow); users provisioned on first login
17
+- Web UI: landing page with login CTA for visitors (`/`), repository list for authenticated users (`/`), public repository browse at `/explore`, file/tree browser, commit log (paginated), branches & tags
18
+- OIDC login (authorization code flow) via `GET /login`; users provisioned on first login
19
19
- Single access policy on all paths: owner read/write, public world-readable, private owner-only
20
20
21
21
## Architecture notes
ADD
openspec/changes/add-landing-page/.openspec.yaml
+2 -0
@@ -0,0 +1,2 @@
1
+schema: spec-driven
2
+created: 2026-06-19
ADD
openspec/changes/add-landing-page/design.md
+67 -0
@@ -0,0 +1,67 @@
1
+## Context
2
+
3
+Today `HomeResource.home()` (`GET /`) calls `Templates.home(service.listVisibleTo(user), user)` for
4
+everyone. `user` is `null` for anonymous requests (`CurrentUser.get()` returns null when the
5
+identity is anonymous), so anonymous visitors see a repository table with no product context.
6
+
7
+Constraints: server-rendered Qute templates, no JavaScript (the rest of the UI is JS-free), must
8
+compile to a GraalVM native image, single shared `layout.html`. OIDC login already exists.
9
+
10
+## Goals / Non-Goals
11
+
12
+**Goals:**
13
+- Show a branded landing page to unauthenticated visitors at `/`.
14
+- Keep the authenticated repository-list experience byte-for-byte unchanged.
15
+- Glowing ASCII-art hero using only HTML + CSS.
16
+- Preserve anonymous access to public repositories via an explicit link.
17
+
18
+**Non-Goals:**
19
+- No marketing CMS, no content management, no i18n.
20
+- No JavaScript, animations beyond CSS, or external assets/fonts/images.
21
+- No change to auth, SSH, Git protocol, or database.
22
+
23
+## Decisions
24
+
25
+**Decision: Branch inside `HomeResource.home()` on authentication, not a new route.**
26
+Keep `GET /` as the single entry. `home()` checks `currentUser.get()`: null → render
27
+`landing.html`; non-null → render the existing repo list. Rationale: anonymous landing at the root
28
+is the requirement; a separate `/landing` path would still need a redirect from `/`.
29
+Alternative considered: redirect anonymous `/` → `/welcome`. Rejected — extra round-trip and a
30
+second public route to secure for no benefit.
31
+
32
+**Decision: Move the anonymous-reachable public repo list to an explicit link.**
33
+The landing page links to repository browsing (e.g. `/explore`, or reuse the existing list view
34
+behind a query/path). Rationale: the spec requires public browsing not be removed. Simplest
35
+implementation: add an `/explore` GET that renders the current `home.html` repo table for
36
+`service.listVisibleTo(null)`; the landing CTA points there. Alternative: keep repo list at `/`
37
+with landing above it — rejected, muddies the hero.
38
+
39
+**Decision: ASCII-art glow via CSS `text-shadow` layers on a `<pre>` block.**
40
+Embed the shark ASCII art in a `<pre>` with a monospace font and stacked `text-shadow` (multiple
41
+blur radii in an accent color) over a dark background for the glow. Rationale: pure CSS, native-safe,
42
+no assets. Alternative: SVG filter glow — heavier markup, no benefit at this fidelity.
43
+
44
+**Decision: Dedicated landing markup, minimal coupling to `layout.html`.**
45
+The logged-in `layout.html` header carries authenticated nav (SSH keys, tokens, logout). The
46
+landing page needs a different, anonymous header (Log in / self-host). Either add a `{#if user}`
47
+guard in the layout nav, or give the landing template its own lightweight shell. Prefer guarding
48
+the nav in `layout.html` so styling stays centralized.
49
+
50
+## Risks / Trade-offs
51
+
52
+- [ASCII glow renders poorly on very narrow viewports] → wrap `<pre>` in an `overflow-x:auto`
53
+ container and scale font with `clamp()`; accept horizontal scroll on tiny screens.
54
+- [Splitting repo list to `/explore` changes the anonymous URL people may have bookmarked] →
55
+ acceptable for a young project; `/` still reaches public repos in one click.
56
+- [Qute `@CheckedTemplate` requires a new `landing(...)` native method] → add it alongside `home`;
57
+ trivial, compile-checked.
58
+
59
+## Migration Plan
60
+
61
+Pure additive UI change. Deploy normally; no schema or config migration. Rollback = revert the
62
+template + `HomeResource` change. No data touched.
63
+
64
+## Open Questions
65
+
66
+- Exact path for public browsing: `/explore` (new) vs. a flag on `/`. Leaning `/explore`.
67
+- Final ASCII-art content for the shark — placeholder until design-approved.
ADD
openspec/changes/add-landing-page/proposal.md
+40 -0
@@ -0,0 +1,40 @@
1
+## Why
2
+
3
+Anonymous visitors currently land on the bare repository list (`/`), which gives no sense of what
4
+git-shark is or why it exists. First-time, unauthenticated visitors need a page that states the
5
+product's stance and invites them to log in or self-host — not a raw table of public repos.
6
+
7
+## What Changes
8
+
9
+- Serve a dedicated **landing page** at `/` for unauthenticated requests, instead of the repository
10
+ list. Logged-in users keep seeing their repository list unchanged.
11
+- The landing page presents the product positioning:
12
+ - "Use AI as a tool, not as a feature"
13
+ - "Focus on co-working, not lazy feeds"
14
+ - "Easy and painless to self-host"
15
+- Visual treatment: dark hero background with glowing ASCII-art branding (the shark), rendered with
16
+ server-side markup + CSS only (no JS, native-image friendly).
17
+- Primary call to action: **Log in** (existing OIDC flow). Secondary: link to self-hosting docs/repo.
18
+- Anonymous browsing of public repositories stays reachable via an explicit link (e.g. `/explore` or
19
+ a "Browse public repositories" link), so no functionality is removed.
20
+
21
+## Capabilities
22
+
23
+### New Capabilities
24
+- `landing-page`: Unauthenticated entry page at `/` — hero with glowing ASCII-art background, three
25
+ value propositions, login CTA, self-host link, and a link to browse public repositories. Renders
26
+ for anonymous users only; authenticated users are routed to the repository list.
27
+
28
+### Modified Capabilities
29
+<!-- No main specs exist yet (create-git-platform is not archived). web-ui requirement change for the
30
+ anonymous `/` route is captured within this change's interaction with that pending capability;
31
+ no separate delta spec is added here. -->
32
+
33
+## Impact
34
+
35
+- `web/HomeResource.java`: branch `home()` on `currentUser.get()` — anonymous → landing template,
36
+ authenticated → existing repo list (or move repo list to `/explore`).
37
+- New Qute template `HomeResource/landing.html` (+ shared `layout.html` may need a variant header
38
+ without the logged-in nav).
39
+- New CSS for the dark hero + glowing ASCII art; no new dependencies, no JavaScript.
40
+- No database, SSH, or Git protocol changes.
ADD
openspec/changes/add-landing-page/specs/landing-page/spec.md
+55 -0
@@ -0,0 +1,55 @@
1
+## ADDED Requirements
2
+
3
+### Requirement: Anonymous root serves the landing page
4
+
5
+The system SHALL serve a dedicated landing page at `GET /` when the request is unauthenticated.
6
+The system SHALL serve the existing repository list to authenticated users, so the change is
7
+invisible to logged-in users.
8
+
9
+#### Scenario: Anonymous visitor sees the landing page
10
+
11
+- **WHEN** an unauthenticated client requests `GET /`
12
+- **THEN** the system responds with `200` and renders the landing page (not the repository list)
13
+
14
+#### Scenario: Authenticated user keeps the repository list
15
+
16
+- **WHEN** an authenticated user requests `GET /`
17
+- **THEN** the system renders their visible repository list, unchanged from prior behavior
18
+
19
+### Requirement: Landing page presents product positioning
20
+
21
+The landing page SHALL display the three value propositions as visible text:
22
+"Use AI as a tool, not as a feature", "Focus on co-working, not lazy feeds", and
23
+"Easy and painless to self-host".
24
+
25
+#### Scenario: Value propositions are rendered
26
+
27
+- **WHEN** an unauthenticated client requests `GET /`
28
+- **THEN** the response body contains all three value-proposition statements
29
+
30
+### Requirement: Landing page hero with glowing ASCII-art background
31
+
32
+The landing page SHALL render a dark hero section containing glowing ASCII-art branding, using
33
+only server-rendered markup and CSS (no client-side JavaScript), so it remains native-image
34
+compatible and degrades gracefully without scripting.
35
+
36
+#### Scenario: Hero renders without JavaScript
37
+
38
+- **WHEN** an unauthenticated client requests `GET /` with scripting disabled
39
+- **THEN** the ASCII-art hero and its styling are present in the served HTML/CSS
40
+
41
+### Requirement: Landing page calls to action
42
+
43
+The landing page SHALL provide a primary "Log in" call to action that initiates the existing OIDC
44
+login flow, a link to self-hosting documentation/repository, and a link to browse public
45
+repositories so anonymous browsing is not removed.
46
+
47
+#### Scenario: Login CTA initiates OIDC flow
48
+
49
+- **WHEN** an anonymous visitor activates the "Log in" call to action
50
+- **THEN** the system initiates the OIDC authorization-code login flow
51
+
52
+#### Scenario: Public repositories remain reachable
53
+
54
+- **WHEN** an anonymous visitor activates the "Browse public repositories" link
55
+- **THEN** the system shows the list of repositories visible to anonymous users
ADD
openspec/changes/add-landing-page/tasks.md
+26 -0
@@ -0,0 +1,26 @@
1
+## 1. Failing tests first
2
+
3
+- [x] 1.1 Add a test: anonymous `GET /` returns 200 and body contains all three value propositions ("Use AI as a tool, not as a feature", "Focus on co-working, not lazy feeds", "Easy and painless to self-host")
4
+- [x] 1.2 Add a test: anonymous `GET /` body does NOT render the repository table (asserts landing, not repo list)
5
+- [x] 1.3 Add a test: authenticated `GET /` still renders the repository list (existing behavior unchanged)
6
+- [x] 1.4 Add a test: anonymous `GET /explore` returns 200 and lists repositories visible to anonymous users
7
+- [x] 1.5 Run the suite, confirm the new tests fail (red)
8
+
9
+## 2. Backend routing
10
+
11
+- [x] 2.1 Add `landing()` native template method to `HomeResource.Templates` (`@CheckedTemplate`)
12
+- [x] 2.2 Branch `HomeResource.home()` on `currentUser.get()`: null → `Templates.landing(...)`, non-null → existing repo list
13
+- [x] 2.3 Add `GET /explore` endpoint rendering the repository list for `service.listVisibleTo(null)`
14
+- [x] 2.4 Add protected `GET /login` (in `authenticated` paths) that OIDC intercepts, then redirects to `/`
15
+
16
+## 3. Templates & styling
17
+
18
+- [x] 3.1 Create `templates/HomeResource/landing.html`: dark hero, glowing ASCII-art `<pre>` block, three value props, "Log in" CTA, self-host link, "Browse public repositories" → `/explore`
19
+- [x] 3.2 Add CSS for the dark hero + glow (stacked `text-shadow`, monospace `<pre>`, `clamp()` font size, `overflow-x:auto` wrapper); no JavaScript
20
+- [x] 3.3 Landing renders a self-contained anonymous header (Log in / self-host) instead of guarding `layout.html` nav — avoids hiding the authenticated nav on repo pages that don't pass `user`
21
+
22
+## 4. Verify
23
+
24
+- [x] 4.1 Run `./mvnw test` — all tests green (52/52)
25
+- [ ] 4.2 Manual check in `quarkus:dev`: anonymous `/` shows hero, logged-in `/` shows repos, `/explore` lists public repos
26
+- [ ] 4.3 Update `README.md` if the public landing/`/explore` behavior is user-facing
MODIFY
src/main/java/de/workaround/web/HomeResource.java
+24 -0
@@ -30,6 +30,8 @@
30
30
{
31
31
static native TemplateInstance home(List<Repository> repositories, User user);
32
32
33
+ static native TemplateInstance landing();
34
+
33
35
static native TemplateInstance newRepo(String error);
34
36
}
35
37
@@ -43,10 +45,32 @@
43
45
public TemplateInstance home()
44
46
{
45
47
User user = currentUser.get();
48
+ if (user == null)
49
+ {
50
+ return Templates.landing();
51
+ }
46
52
return Templates.home(service.listVisibleTo(user), user);
47
53
}
48
54
49
55
@GET
56
+ @Path("explore")
57
+ public TemplateInstance explore()
58
+ {
59
+ User user = currentUser.get();
60
+ return Templates.home(service.listVisibleTo(user), user);
61
+ }
62
+
63
+ @GET
64
+ @Path("login")
65
+ public Response login()
66
+ {
67
+ // Path is protected (authenticated); OIDC intercepts anonymous access and
68
+ // redirects here after login, then we send the user to their repository list.
69
+ currentUser.require();
70
+ return Response.seeOther(URI.create("/")).build();
71
+ }
72
+
73
+ @GET
50
74
@Path("repos/new")
51
75
public TemplateInstance newRepo()
52
76
{
MODIFY
src/main/resources/application.properties
+1 -1
@@ -14,7 +14,7 @@
14
14
quarkus.oidc.logout.post-logout-path=/
15
15
16
16
# Pages that require a logged-in user (git transport does its own auth)
17
-quarkus.http.auth.permission.authenticated.paths=/settings/*,/repos/new
17
+quarkus.http.auth.permission.authenticated.paths=/settings/*,/repos/new,/login
18
18
quarkus.http.auth.permission.authenticated.policy=authenticated
19
19
20
20
# Native image: JGit holds static Random/lazy state that must not be build-time initialized
ADD
src/main/resources/templates/HomeResource/landing.html
+99 -0
@@ -0,0 +1,99 @@
1
+<!DOCTYPE html>
2
+<html lang="en">
3
+<head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>git-shark — self-hosted Git, AI as a tool</title>
7
+ <style>
8
+ :root { --glow: #38d9c8; --glow2: #1f8f86; --ink: #e6edf3; --muted: #9fb0bd; }
9
+ * { box-sizing: border-box; }
10
+ body {
11
+ margin: 0; min-height: 100vh; color: var(--ink);
12
+ font-family: system-ui, sans-serif;
13
+ background: radial-gradient(1200px 600px at 50% -10%, #0c2b2a 0%, #07110f 55%, #04070a 100%);
14
+ display: flex; flex-direction: column;
15
+ }
16
+ a { color: var(--glow); }
17
+ header {
18
+ display: flex; align-items: center; gap: 1rem;
19
+ padding: 1rem 1.5rem;
20
+ }
21
+ header .brand { font-weight: 700; color: var(--ink); text-decoration: none; font-size: 1.1rem; }
22
+ header nav { margin-left: auto; display: flex; gap: 1rem; align-items: center; }
23
+ .btn {
24
+ display: inline-block; padding: 0.55rem 1.1rem; border-radius: 8px;
25
+ font-weight: 600; text-decoration: none;
26
+ color: #04070a; background: var(--glow);
27
+ box-shadow: 0 0 18px rgba(56, 217, 200, 0.55);
28
+ }
29
+ .btn.ghost { color: var(--glow); background: transparent; border: 1px solid var(--glow2); box-shadow: none; }
30
+ main { flex: 1; max-width: 860px; margin: 0 auto; padding: 2rem 1.5rem 4rem; text-align: center; }
31
+ .ascii-wrap { overflow-x: auto; margin: 1rem 0 2.5rem; }
32
+ pre.ascii {
33
+ display: inline-block; margin: 0; text-align: left;
34
+ font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
35
+ font-size: clamp(0.5rem, 1.6vw, 0.95rem); line-height: 1.05;
36
+ color: var(--glow);
37
+ text-shadow:
38
+ 0 0 4px var(--glow),
39
+ 0 0 10px var(--glow),
40
+ 0 0 22px var(--glow2),
41
+ 0 0 40px var(--glow2);
42
+ }
43
+ h1 { font-size: clamp(1.6rem, 4vw, 2.6rem); margin: 0 0 0.5rem; }
44
+ .tagline { color: var(--muted); font-size: 1.1rem; margin: 0 auto 2.5rem; max-width: 36rem; }
45
+ ul.values { list-style: none; padding: 0; margin: 0 auto 2.5rem; max-width: 40rem; text-align: left; }
46
+ ul.values li {
47
+ padding: 0.9rem 1.1rem; margin: 0.6rem 0; border-radius: 10px;
48
+ background: rgba(56, 217, 200, 0.06); border: 1px solid rgba(56, 217, 200, 0.18);
49
+ font-size: 1.05rem;
50
+ }
51
+ ul.values li strong { color: var(--glow); }
52
+ .cta { display: flex; gap: 1rem; justify-content: center; flex-wrap: wrap; }
53
+ footer { padding: 1.5rem; text-align: center; color: var(--muted); font-size: 0.9rem; }
54
+ </style>
55
+</head>
56
+<body>
57
+<header>
58
+ <a class="brand" href="/">🦈 git-shark</a>
59
+ <nav>
60
+ <a href="/explore">Browse public repositories</a>
61
+ <a class="btn" href="/login">Log in</a>
62
+ </nav>
63
+</header>
64
+<main>
65
+ <div class="ascii-wrap">
66
+<pre class="ascii">
67
+ ,-
68
+ _.-'' |
69
+ _.-'' | git-shark
70
+ _.-'' .-. |
71
+ ,______.-'' \ o`. |
72
+ / \ `.`-._
73
+ | . | `-. `-._
74
+ | |`. | `-. `.
75
+ | | `. \ `-.`.
76
+ \ \ `. `. `.`.
77
+ `. `. `-. `. `|
78
+ `._ `-._ `-.________`.__________/
79
+ `-._ `-._______________.--''
80
+ `-.________.--''
81
+</pre>
82
+ </div>
83
+ <h1>Self-hosted Git, for people who still write the code</h1>
84
+ <p class="tagline">A single natively-compiled binary. Your repositories, your server, no feed.</p>
85
+
86
+ <ul class="values">
87
+ <li><strong>Use AI as a tool, not as a feature.</strong> It assists when you ask; it never takes the wheel.</li>
88
+ <li><strong>Focus on co-working, not lazy feeds.</strong> Built for collaborating on code, not doomscrolling activity streams.</li>
89
+ <li><strong>Easy and painless to self-host.</strong> One binary, Postgres, done — fast startup, tiny footprint.</li>
90
+ </ul>
91
+
92
+ <div class="cta">
93
+ <a class="btn" href="/login">Log in</a>
94
+ <a class="btn ghost" href="https://github.com/workaround/git-shark">Self-host it</a>
95
+ </div>
96
+</main>
97
+<footer>🦈 git-shark — bare Git over HTTP & SSH, Qute UI, OIDC login.</footer>
98
+</body>
99
+</html>
MODIFY
src/test/java/de/workaround/web/WebUiTest.java
+18 -2
@@ -26,14 +26,14 @@
26
26
GitRepositoryService service;
27
27
28
28
@Test
29
- void anonymousSeesOnlyPublicRepositoriesOnHome() throws Exception
29
+ void anonymousSeesOnlyPublicRepositoriesOnExplore() throws Exception
30
30
{
31
31
User owner = persistUser("ui-bob-" + unique());
32
32
service.create(owner, "ui-pub", Repository.Visibility.PUBLIC, "public demo");
33
33
service.create(owner, "ui-priv", Repository.Visibility.PRIVATE, null);
34
34
35
35
given()
36
- .when().get("/")
36
+ .when().get("/explore")
37
37
.then()
38
38
.statusCode(200)
39
39
.body(containsString("ui-pub"))
@@ -41,6 +41,22 @@
41
41
}
42
42
43
43
@Test
44
+ void anonymousSeesLandingPageOnHome() throws Exception
45
+ {
46
+ User owner = persistUser("ui-zoe-" + unique());
47
+ service.create(owner, "landing-pub", Repository.Visibility.PUBLIC, null);
48
+
49
+ given()
50
+ .when().get("/")
51
+ .then()
52
+ .statusCode(200)
53
+ .body(containsString("Use AI as a tool, not as a feature"))
54
+ .body(containsString("Focus on co-working, not lazy feeds"))
55
+ .body(containsString("Easy and painless to self-host"))
56
+ .body(not(containsString("landing-pub")));
57
+ }
58
+
59
+ @Test
44
60
@TestSecurity(user = "ui-alice")
45
61
void ownerSeesOwnPrivateRepositoryOnHome() throws Exception
46
62
{