Skip to content

feat: mode-agnostic landing + HEAD-adaptive Sign up/Sign in (#435)#436

Merged
melvincarvalho merged 7 commits into
gh-pagesfrom
issue-435-landing-refinement
May 14, 2026
Merged

feat: mode-agnostic landing + HEAD-adaptive Sign up/Sign in (#435)#436
melvincarvalho merged 7 commits into
gh-pagesfrom
issue-435-landing-refinement

Conversation

@melvincarvalho
Copy link
Copy Markdown
Contributor

@melvincarvalho melvincarvalho commented May 14, 2026

Summary

Refines the Phase 3 landing page from #433/#434 so it adapts to the server's current state at load time instead of being frozen at seed time.

  • Mode-agnostic copy. Single welcome page used by every mode. Differences land in the buttons, which adapt at load time.
  • HEAD-adaptive Sign up / Sign in. Inline script probes HEAD /idp/register:
    • 200 → reveal both (multi-user + IDP, registration open)
    • 403 → reveal Sign in only (single-user — registration intentionally disabled)
    • 404 / network error → leave both hidden (no IDP)
  • Live server URL filled in client-side from window.location.origin — adapts to whichever host the visitor used (works behind reverse proxies, across localhost / 127.0.0.1 / LAN-IP) without re-rendering.
  • Get started → docs. Primary CTA points at https://jss.live/docs/getting-started/introduction (the canonical first page in the existing Getting Started section — Docusaurus 3 doesn't auto-generate a category index, so /docs/getting-started/ would 404).
  • Tone: professional enough to leave as the public landing on an IDP / multi-user deployment. No emoji headline, sentence case, brief Solid explainer for visitors landing fresh.

The seed model itself is unchanged — still skip-if-exists for /index.html, /.acl, /index.html.acl. Operator-written files preserved. The ./-relative .acl seeds from #434 still mean an operator-uploaded /index.html is publicly readable without them having to write WAC by hand.

Why

Two problems with the previous Phase 3 landing showed up the first time we tried switching modes on a real install:

  1. Stale across mode changes. Once --single-user ran first, every later run (any mode) served the same HTML because of skip-if-exists. A subsequent restart in another mode still showed "Mode: single-user" and a non-functional Sign-in button.
  2. Sign in 404. "Sign in" linked to /idp, which 404s when IDP isn't enabled. The button always rendered (worked out at write time), so the page kept promising something the server no longer offered after a mode change.

Copilot review surfaced two more during this PR's iteration:

  1. The .btn class's display: inline-block overrode the UA stylesheet's [hidden] { display: none } (equal specificity, author rule wins because it comes later). Sign up / Sign in would have flashed visible before the HEAD probe finished. Fixed with an explicit [hidden] { display: none !important } rule.
  2. The Mode pill and Features pills were still rendered into the seeded HTML at first start. Same skip-if-exists trap — they'd stale on the next mode change. Dropped both from the seed; the CLI banner already lists them at startup.

Tests

test/server-root.test.js rewritten for the new model. 20 tests:

  • 3 server-root integration: GET / serves seeded HTML, public-read on /index.html, seeded ACLs use ./ relative form (feat: default landing page + ACL at server root (#433, supersedes #303) #434 regression).
  • 2 operator-override: existing /index.html preserved + served as-is.
  • 10 renderer assertions: mode-agnostic byte-equality across singleUser flag, no Mode/Features pills baked in, Get started CTA at /docs/getting-started/introduction, hidden Sign-up/Sign-in anchors with the right data-cond attributes, [hidden] { display: none !important } CSS rule present, HEAD probe script presence + status literals, live-URL script, version interpolation + escape, footer GitHub link + customise hint.
  • 5 unit tests for the new decideRevealForRegisterStatus(status) helper covering 200 / 403 / 404 / 500 / undefined.

819/819 tests pass.

Test plan

Out of scope

  • A standalone /idp/login page (NSS-style direct credential form). That's the only way to give "Sign in" a destination when no OIDC client app is in the picture; deferred to its own feature.
  • A friendlier "first-run" walkthrough page in JavaScriptSolidServer/docs. The CTA currently lands on Introduction (a real, useful page); a more guided post-install page is being filed as a separate, independent docs PR.

Closes #435. Phase 3 refinement of #427.

Phase 3 refinement of #427. Builds on #433/#434.

The original Phase 3 page was mode-specific: it inlined "Create a pod"
and "Sign in" buttons based on the seed-time singleUser/idp flags. Two
problems showed up in practice:

1. The seeded /index.html went stale on mode change. Once seeded by a
   --single-user run, every later run (any mode) served the same HTML
   because of skip-if-exists, so a multi-user restart still showed
   "Mode: single-user" and a non-functional Sign-in button.
2. "Sign in" linked to /idp, which 404s when IDP isn't enabled. The
   button always rendered (we worked it out at write time), so a mode
   change to no-IDP left the page promising something the server no
   longer offers.

Refinement: render once, adapt at load.

- The page is now mode-agnostic: same headline, same explainer, same
  primary "Get started" CTA pointing at jss.live/docs/getting-started/.
- Sign up + Sign in anchors are emitted but `hidden` by default. An
  inline script does HEAD /idp/register on load:
    200 → reveal both Sign up and Sign in (multi-user + IDP, open)
    403 → reveal Sign in only (single-user — registration disabled)
    404 / network error → leave both hidden (no IDP)
  Same seeded HTML keeps working when the operator switches modes.
- The live server URL is filled in client-side from
  window.location.origin, so the page shows whichever host the visitor
  actually used (works behind reverse proxies, across localhost /
  127.0.0.1 / LAN-IP, without re-rendering the file).
- Tone tightened: no emoji headline, sentence case, brief paragraph
  about Solid for visitors landing on a public pod for the first time.
  Polished enough to leave as a public landing on an IDP / multi-user
  deployment without it looking like a first-run celebration screen.
- Footer carries the GitHub link and a single-line customise hint.

The seed model itself is unchanged: still skip-if-exists for
/index.html, /.acl, and /index.html.acl. Operator-written files are
preserved. The .acl seeds (relative './' / './index.html' from #434)
keep an operator-uploaded /index.html publicly readable without the
operator having to write WAC by hand.

Tests: server-root.test.js rewritten for the new model.
- 14 tests: same set of capabilities, refocused on the mode-agnostic
  behaviour (one render, all modes), the Get started link, hidden
  Sign-up/Sign-in anchors with the right data-cond attributes, the
  HEAD probe + live-URL scripts, version interpolation + escape, and
  the footer hints.
- The previous mode-specific render assertions (Create a pod text,
  Personal-pod-for-name subtitle, $-pattern and {{token}} re-scan
  regressions) drop with the values they were guarding — none of
  those interpolated values exist in the new template.

813/813 tests pass.

Refs #435.
Docusaurus 3 doesn't auto-generate a category index page, so
/docs/getting-started/ 404s on jss.live. The canonical URL for the
intro page is /docs/getting-started/introduction — point the landing's
primary CTA there.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Refines the seeded server-root landing page so the public welcome page is less mode-specific and uses client-side detection for IDP sign-up/sign-in availability.

Changes:

  • Reworks the landing page template with updated copy, styling, docs CTA, footer links, and inline scripts.
  • Updates the renderer to remove mode-specific action rendering and keep only template substitutions for title/version/mode/features.
  • Rewrites landing page tests around the new mode-agnostic copy and inline script presence.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

File Description
src/ui/server-root.html Replaces the landing page markup/styles and adds live URL plus HEAD-adaptive button scripts.
src/ui/server-root.js Simplifies renderer context and removes server-side action/button generation.
test/server-root.test.js Updates tests to assert the refreshed copy, docs link, hidden buttons, and inline scripts.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/ui/server-root.html
Comment on lines 28 to 30
.btn {
display: inline-block;
padding: 0.6rem 1.2rem;
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 694a5df: added [hidden] { display: none !important } so the UA-stylesheet [hidden] semantics win over the .btn class's display rule. New test pins the CSS rule down.

Comment thread src/ui/server-root.js Outdated
Comment on lines 68 to 72
const values = {
title: heading,
heading,
subtitle,
description,
actions: renderActions({ singleUser, idp }),
title: 'JSS Solid pod',
version: escape(version),
mode,
features
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 694a5df: dropped both Mode and Features from the seeded HTML — they would have gone stale on the next mode change because of skip-if-exists. The CLI banner already lists them at startup. Renderer simplified to just version. New test asserts neither pill appears in the seed.

Comment thread test/server-root.test.js Outdated
Comment on lines +142 to +149
assert.match(html, /Sign in/);
});

it('single-user + IDP shows Sign in only (no Create a pod)', () => {
const html = renderServerRoot({ version: '1.0.0', singleUser: true, idp: true, singleUserName: 'alice' });
assert.doesNotMatch(html, /Create a pod/);
assert.match(html, /Sign in/);
});

it('multi-user without IDP shows only the Docs link', () => {
const html = renderServerRoot({ version: '1.0.0', singleUser: false, idp: false });
assert.doesNotMatch(html, /Create a pod/);
assert.doesNotMatch(html, /Sign in/);
assert.match(html, /Docs/);
it('includes the HEAD-adaptive script targeting /idp/register', () => {
const html = renderServerRoot({ version: '1.0.0' });
assert.match(html, /fetch\(['"]\/idp\/register['"]/);
assert.match(html, /method:\s*['"]HEAD['"]/);
// The three documented branches: 200 → both, 403 → login only,
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 694a5df: extracted decideRevealForRegisterStatus(status) as a pure helper and added 5 unit tests covering 200 / 403 / 404 / 500 / undefined. The inline script implements the same matrix literally; the existing regex check on the script text pins the literals so they don't drift silently.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

Comment thread src/ui/server-root.js Outdated
*/
export function renderServerRoot(ctx = {}) {
const { version = 'unknown', singleUser = false, idp = false, singleUserName, enabled = {} } = ctx;
const { version = 'unknown', singleUser = false, enabled = {} } = ctx;
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 694a5df: dropped both Mode and Features from the seeded HTML — they would have gone stale on the next mode change because of skip-if-exists. The CLI banner already lists them at startup. Renderer simplified to just version. New test asserts neither pill appears in the seed.

Comment thread src/ui/server-root.html
Comment on lines +98 to +111
(function () {
fetch('/idp/register', { method: 'HEAD', cache: 'no-store' })
.then(function (res) {
var register = document.querySelector('[data-cond="register"]');
var login = document.querySelector('[data-cond="login"]');
if (res.status === 200) {
if (register) register.hidden = false;
if (login) login.hidden = false;
} else if (res.status === 403) {
if (login) login.hidden = false;
}
// 404 / other / network error: leave both hidden
})
.catch(function () { /* network error: leave both hidden */ });
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 694a5df: extracted decideRevealForRegisterStatus(status) as a pure helper and added 5 unit tests covering 200 / 403 / 404 / 500 / undefined. The inline script implements the same matrix literally; the existing regex check on the script text pins the literals so they don't drift silently.

Comment thread src/ui/server-root.html

{{actions}}
<div class="actions">
<a href="https://jss.live/docs/getting-started/introduction" class="btn btn-primary">Get started →</a>
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated the PR body to reflect /docs/getting-started/introduction (the canonical URL — Docusaurus 3 doesn't auto-generate a category index page, so /docs/getting-started/ would 404). Also dropped the docs-repo follow-up checkbox; introduction is a real, useful page already, and a friendlier 'first-run' walkthrough is being filed as a separate docs PR (this PR no longer blocks on it).

Three concrete fixes from the review thread.

1. .btn { display: inline-block } overrode the UA stylesheet's
   [hidden] { display: none } (equal specificity, author rule wins
   because it's later in the cascade). Sign up / Sign in would have
   flashed visible before the HEAD probe finished. Add an explicit
   [hidden] { display: none !important } so the attribute stays
   authoritative; new test pins the rule down.

2. The mode pill and feature-pills row were rendered into the seeded
   HTML at first start. Skip-if-exists then froze them — a mode change
   later would have left the seeded copy showing the old mode and
   feature flags even though the rest of the page is meant to adapt
   without regeneration. Drop both from the seeded template; the CLI
   banner already lists them at startup. The renderer drops the
   listFeatures helper and the singleUser/enabled context inputs.

3. The HEAD-decision matrix was only verified by regex checks on the
   inline script's text — a wrong-button regression for 200/403/404
   would have slipped through. Extract the matrix into a pure helper,
   `decideRevealForRegisterStatus(status)`, and unit-test the five
   cases. The inline script implements the same matrix literally;
   the regex test still pins the literals so they don't drift silently.

819/819 tests pass.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

Comment thread src/ui/server-root.html Outdated
<!-- Mode and enabled-feature pills used to live here, but the seeded
HTML can't reflect mode changes after first start (skip-if-exists).
The CLI banner already lists both at startup; surface them via a
runtime probe in a follow-up if needed. See #436 review. -->
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the next push: changed the reference to #435 (the issue being closed) — #436 is the PR itself, so the seeded HTML would have pointed readers at the wrong place.

The dropped-pills explanatory comment pointed at '#436 review' but
that's this PR, not the issue. The issue being closed is #435 —
update the reference so the rationale is traceable from the seeded
file.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

src/ui/server-root.html:106

  • Using an origin-root path for the HEAD probe breaks deployments mounted behind a reverse-proxy path prefix (the seeded ACLs explicitly use ./ to support that case). If the page is served from /jss/, this probes /idp/register instead of /jss/idp/register, so Sign up/Sign in stay hidden even when the IdP is available; use a page-relative URL or derive the base from the current document.
      fetch('/idp/register', { method: 'HEAD', cache: 'no-store' })

Comment thread src/ui/server-root.html Outdated
Comment on lines +66 to +67
<a href="/idp/register" class="btn btn-secondary" data-cond="register" hidden>Sign up</a>
<a href="/idp" class="btn btn-secondary" data-cond="login" hidden>Sign in</a>
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in b6ad396: switched the Sign up / Sign in anchor hrefs and the HEAD probe URL to page-relative (./idp/register, ./idp). They now resolve against document.baseURI, so a JSS instance behind a reverse proxy at /jss/ sends visitors to /jss/idp/register instead of escaping to the proxy origin. Same rationale as the relative ACL targets from #428. Tests assert both the new ./ form and the absence of the old absolute form.

Address Copilot pickup on #436. The Sign up / Sign in anchor hrefs
and the inline HEAD probe used origin-root absolute paths (/idp...).
Behind a reverse proxy at e.g. https://example/jss/, these would
escape the mount point and hit the proxy origin (/idp) instead of
the JSS-mounted server (/jss/idp).

Switch all three to page-relative (./idp...). Anchor hrefs and the
fetch() URL both resolve against document.baseURI, so they stay
inside whatever path prefix the server is mounted under. Same
rationale as the relative ACL targets from #428.

Tests assert both the new ./ form and the absence of the old
absolute form (so a regression to /idp would fail loud).

819/819 tests pass.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

Comment thread src/ui/server-root.html Outdated
Comment on lines +92 to +99
// whichever hostname/port they hit — works behind reverse proxies
// and across localhost / 127.0.0.1 / LAN-IP without re-rendering
// the file).
(function () {
var el = document.getElementById('server-url');
if (!el) return;
el.textContent = window.location.origin;
el.href = window.location.origin + '/';
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 5447f65: switched to new URL('./', window.location.href).href, which resolves to the document's directory and preserves any reverse-proxy path prefix the server is mounted under. A JSS instance at https://example.com/jss/ now displays and links to https://example.com/jss/, not the proxy origin. Same path-prefix-safe treatment as the IdP links and ACL targets in the rest of this PR. Test asserts both the new pattern and the absence of the old origin-only form.

Address Copilot pickup on #436. window.location.origin drops any
reverse-proxy path prefix the server is mounted under: a JSS instance
at https://example.com/jss/ would have displayed (and linked to)
'https://example.com/' for the live-URL line, escaping the mount.

Switch to new URL('./', window.location.href).href, which resolves to
the directory of the current document — i.e. the JSS server root,
prefix included. Same path-prefix-safe treatment as the IdP links and
ACL targets in the rest of this PR.

Test asserts both presence of the new pattern and absence of the
origin-only form.

819/819 tests pass.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

Comment thread src/ui/server-root.html Outdated
<div class="subtitle">{{subtitle}}</div>
<p>{{description}}</p>
<h1>Welcome</h1>
<p class="lede">Your JSS Solid pod is running at <a id="server-url" href="/">this server</a>.</p>
Comment thread src/ui/server-root.js Outdated
Comment on lines +46 to +47
title: 'JSS Solid pod',
version: escape(version)
Two more Copilot pickups on #436.

1. The version was still seed-time. Same skip-if-exists trap as the
   mode and feature pills already dropped — an upgrade wouldn't refresh
   the displayed version. Operators relying on it would silently see
   the old number forever. Drop the version row entirely; the CLI
   banner already shows it at startup, and `npm view` is one shell
   command away.

   With version gone, the info box has nothing left to show; remove
   the whole .info block plus its CSS. The renderer no longer has any
   dynamic values to substitute — collapses to a static readFileSync.
   Drop the template-substitution machinery (single-pass replacer)
   and the escape() helper that only existed to sanitise version.

2. The live-URL anchor's fallback href was "/" (origin-root absolute).
   If the inline script doesn't run (CSP, JS disabled, error before
   it executes), clicking the link would escape any reverse-proxy
   mount prefix. Switch to "./" so the fallback stays inside the
   mount, matching every other URL in this template.

Tests: dropped the version interpolation + injection-escape tests
(no longer applicable — no version is rendered, no values substituted).
Renamed the byte-equality test to make the static-HTML guarantee
explicit. Added a regression test asserting the page-relative
fallback href.

819/819 tests pass.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

Comment thread src/ui/server-root.html
Comment on lines +104 to +127
// HEAD-adaptive Sign up / Sign in. Reveals each button based on what
// the IDP currently exposes — the same seeded HTML works for
// single-user, multi-user-with-IDP, and no-IDP modes without
// regeneration on mode change.
// 200 → registration open: show both Sign up and Sign in
// 403 → IDP enabled but registration disabled (single-user mode):
// Sign in only
// 404 / network error → no IDP: hide both
(function () {
// Relative URL resolves against document base, same as the
// anchor hrefs above — preserves the path-prefix mount point.
fetch('./idp/register', { method: 'HEAD', cache: 'no-store' })
.then(function (res) {
var register = document.querySelector('[data-cond="register"]');
var login = document.querySelector('[data-cond="login"]');
if (res.status === 200) {
if (register) register.hidden = false;
if (login) login.hidden = false;
} else if (res.status === 403) {
if (login) login.hidden = false;
}
// 404 / other / network error: leave both hidden
})
.catch(function () { /* network error: leave both hidden */ });
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Real concern, but it requires an operator who both runs with --no-idp AND explicitly PUTs content to /idp/register — unlikely combination, and worst case is bad UX (revealed buttons leading to a 404), not a security issue. A robust fix (reserve the /idp/* namespace at the routing layer so LDP can never shadow it, or add a dedicated discovery endpoint) is meaningfully bigger than the rest of #435 — better to land this PR and iterate. Filed as #439 for follow-up.

@melvincarvalho melvincarvalho merged commit 9d2634b into gh-pages May 14, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Refine seeded landing page: mode-agnostic copy + HEAD-adaptive Sign up/Sign in (#427)

2 participants