From ae730e522284012a8adf57ea9f2b5723097af292 Mon Sep 17 00:00:00 2001 From: myfreess Date: Fri, 7 Aug 2026 17:49:25 +0800 Subject: [PATCH] feat(playground): add shareable diff routes and refresh UI --- README.md | 12 + playground/main/logic_wbtest.mbt | 51 +- playground/main/main.mbt | 9 +- playground/main/moon.pkg | 3 + playground/main/share_url.mbt | 58 ++ playground/main/state.mbt | 160 ++++- playground/main/view.mbt | 320 +++++++--- playground/public/favicon.svg | 4 + playground/public/index.html | 9 +- playground/public/styles.css | 845 ++++++++++++++++++++++----- playground/tests/playground.spec.mjs | 61 +- 11 files changed, 1292 insertions(+), 240 deletions(-) create mode 100644 playground/main/share_url.mbt create mode 100644 playground/public/favicon.svg diff --git a/README.md b/README.md index 5b971fb..d1f4d6f 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,18 @@ endpoints, renders only MoonBit source/project files, and never accepts, stores, or sends a personal access token. Anonymous GitHub API rate limits therefore apply. +Submitting a GitHub URL updates the browser to a static-host-friendly share +route: + +```text +https://{playground-host}/{base}/#/owner/repo/commit/sha +``` + +Opening that URL restores and loads the same commit automatically. The result +page also exposes the full URL in a read-only field with a one-click copy +button. Hash routing keeps shared links working on GitHub Pages without a +server-side rewrite rule. + Run the live development server from the Warren app directory: ```sh diff --git a/playground/main/logic_wbtest.mbt b/playground/main/logic_wbtest.mbt index 4b5b010..cb96887 100644 --- a/playground/main/logic_wbtest.mbt +++ b/playground/main/logic_wbtest.mbt @@ -49,6 +49,45 @@ test "parse supported GitHub commit URL forms" { assert_eq(pr.sha, "0123456789abcdef") } +///| +test "shared playground routes round-trip to canonical GitHub commit URLs" { + let reference = CommitRef::{ + owner: "moonbit-community", + repo: "ldiff", + sha: "aBcDeF1", + } + assert_eq( + playground_route(reference), + "#/moonbit-community/ldiff/commit/aBcDeF1", + ) + assert_eq( + canonical_github_url(reference), + "https://github.com/moonbit-community/ldiff/commit/aBcDeF1", + ) + guard reference_from_playground_url( + "https://example.github.io/ldiff/#/moonbit-community/ldiff/commit/aBcDeF1", + ) + is Ok(Some(parsed)) else { + fail("expected the shared route to parse") + } + assert_true(parsed == reference) + assert_true( + reference_from_playground_url("https://example.com/ldiff/") is Ok(None), + ) + assert_true( + reference_from_playground_url( + "https://example.com/ldiff/#/moonbit-community/ldiff/pull/1", + ) + is Err(_), + ) + assert_true( + reference_from_playground_url( + "https://example.com/ldiff/#/bad owner/ldiff/commit/abcdef1", + ) + is Err(_), + ) +} + ///| test "reject unsupported or malformed URLs" { for @@ -132,7 +171,7 @@ test "pagination appends file metadata and stops at GitHub's 30-page cap" { assert_false(should_fetch_next_page(1, 99)) assert_false(should_fetch_next_page(30, 100)) assert_true( - commit_api_url(CommitRef::{ owner: "a", repo: "b", sha: "abcdef1" }, 30).has_suffix( + commit_api_url({ owner: "a", repo: "b", sha: "abcdef1" }, 30).has_suffix( "?per_page=100&page=30", ), ) @@ -197,11 +236,14 @@ test "stale commit responses are discarded by generation" { generation: 2, view_mode: Split, phase: Idle, + route: None, + share_url: None, + copy_status: CopyReady, } let fixture = decode_commit( read_fixture("playground/main/fixtures/root_commit.json"), ) - let emit : @rabbita.Emit[Msg] = @cmd.Emit(_ => @rabbita.none) + let emit : @rabbita.Emit[Msg] = Emit(_ => @rabbita.none) let (after, _) = update(model, CommitPageLoaded(1, 1, Ok(fixture)), emit) assert_true(after == model) } @@ -215,7 +257,7 @@ test "only the first twenty matching files auto-load and stale source replies ar for index in 0..<21 { files.push(fixture_file("src/file_\{index}.mbt", "added")) } - let emit : @rabbita.Emit[Msg] = @cmd.Emit(_ => @rabbita.none) + let emit : @rabbita.Emit[Msg] = Emit(_ => @rabbita.none) let reference = CommitRef::{ owner: "example", repo: "root", @@ -235,6 +277,9 @@ test "only the first twenty matching files auto-load and stale source replies ar generation: 7, view_mode: Split, phase: Showing(view), + route: Some(reference), + share_url: None, + copy_status: CopyReady, } let (after, _) = update(model, SourceLoaded(6, 0, New, Ok("stale")), emit) assert_true(after == model) diff --git a/playground/main/main.mbt b/playground/main/main.mbt index 33804d8..9c9e295 100644 --- a/playground/main/main.mbt +++ b/playground/main/main.mbt @@ -8,5 +8,12 @@ ///| fn main { - @rabbita.elmish(model=initial_model(), view=app_view, update~).mount("app") + @rabbita.new(() => { + let (model, emit) = @rabbita.create_state_with_init( + init=app_init, + update~, + subscriptions=app_subscriptions, + ) + model.map(model => app_view(model, emit)) + }).mount("app") } diff --git a/playground/main/moon.pkg b/playground/main/moon.pkg index b943ef9..037e200 100644 --- a/playground/main/moon.pkg +++ b/playground/main/moon.pkg @@ -1,8 +1,11 @@ import { "moonbit-community/ldiff", "moonbit-community/rabbita", + "moonbit-community/rabbita/clipboard", "moonbit-community/rabbita/html", "moonbit-community/rabbita/http", + "moonbit-community/rabbita/nav", + "moonbit-community/rabbita/sub", "moonbit-community/rabbita/url", "moonbitlang/core/encoding/utf8", "moonbitlang/core/json", diff --git a/playground/main/share_url.mbt b/playground/main/share_url.mbt new file mode 100644 index 0000000..081321a --- /dev/null +++ b/playground/main/share_url.mbt @@ -0,0 +1,58 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 + +///| +fn canonical_github_url(reference : CommitRef) -> String { + "https://github.com/\{reference.owner}/\{reference.repo}/commit/\{reference.sha}" +} + +///| +fn playground_route(reference : CommitRef) -> String { + "#/\{reference.owner}/\{reference.repo}/commit/\{reference.sha}" +} + +///| +fn reference_from_route(fragment : String?) -> Result[CommitRef?, String] { + guard fragment is Some(value) && !value.is_empty() else { return Ok(None) } + let parts : Array[String] = value + .split("/") + .map(StringView::to_owned) + .collect() + let reference = match parts { + ["", owner, repo, "commit", sha] | [owner, repo, "commit", sha] => + CommitRef::{ owner, repo, sha } + _ => return Err("This shared playground link is not valid.") + } + if !valid_owner(reference.owner) || + !valid_repo(reference.repo) || + !valid_sha(reference.sha) { + return Err("This shared playground link is not valid.") + } + Ok(Some(reference)) +} + +///| +fn reference_from_playground_url(input : String) -> Result[CommitRef?, String] { + let parsed = try @url.parse(input) catch { + _ => return Err("This shared playground link is not valid.") + } noraise { + parsed => parsed + } + reference_from_route(parsed.fragment) +} + +///| +extern "js" fn browser_current_url() -> String = "() => window.location.href" + +///| +extern "js" fn browser_base_url() -> String = "() => window.location.origin + window.location.pathname" + +///| +fn playground_share_url(reference : CommitRef) -> String { + "\{browser_base_url()}\{playground_route(reference)}" +} diff --git a/playground/main/state.mbt b/playground/main/state.mbt index e4419fd..fff8dd5 100644 --- a/playground/main/state.mbt +++ b/playground/main/state.mbt @@ -60,24 +60,46 @@ priv enum Phase { Showing(CommitView) } derive(Eq) +///| +priv enum CopyStatus { + CopyReady + Copied + CopyError(String) +} derive(Eq) + ///| priv struct Model { input : String generation : Int view_mode : ViewMode phase : Phase + route : CommitRef? + share_url : String? + copy_status : CopyStatus } derive(Eq) ///| fn initial_model() -> Model { - { input: "", generation: 0, view_mode: Split, phase: Idle } + { + input: "", + generation: 0, + view_mode: Split, + phase: Idle, + route: None, + share_url: None, + copy_status: CopyReady, + } } ///| priv enum Msg { InputChanged(String) Submit + RouteChanged(@url.Url) ToggleView + CopyShareLink + ShareCopied + ShareCopyFailed(String) CommitPageLoaded(Int, Int, Result[ApiCommit, Error]) ToggleFile(Int, Int) RetryFile(Int, Int) @@ -228,9 +250,48 @@ fn with_phase(model : Model, phase : Phase) -> Model { generation: model.generation, view_mode: model.view_mode, phase, + route: model.route, + share_url: model.share_url, + copy_status: model.copy_status, + } +} + +///| +fn begin_commit_load( + model : Model, + reference : CommitRef, + emit : @rabbita.Emit[Msg], +) -> (Model, @rabbita.Cmd) { + let generation = model.generation + 1 + ( + { + input: canonical_github_url(reference), + generation, + view_mode: model.view_mode, + phase: LoadingCommit(reference~, page=1, first=None, files=[]), + route: Some(reference), + share_url: Some(playground_share_url(reference)), + copy_status: CopyReady, + }, + commit_request(reference, generation, 1, emit), + ) +} + +///| +fn app_init(emit : @rabbita.Emit[Msg]) -> (Model, @rabbita.Cmd) { + let model = initial_model() + match reference_from_playground_url(browser_current_url()) { + Ok(Some(reference)) => begin_commit_load(model, reference, emit) + Ok(None) => (model, @rabbita.none) + Err(message) => (with_phase(model, LoadFailed(message)), @rabbita.none) } } +///| +fn app_subscriptions(_model : Model, emit : @rabbita.Emit[Msg]) -> @sub.Sub { + @sub.on_url_changed(url => emit(RouteChanged(url))) +} + ///| fn side_from_result(result : Result[String, Error]) -> SideLoad { match result { @@ -257,6 +318,9 @@ fn update( generation: model.generation, view_mode: model.view_mode, phase: model.phase, + route: model.route, + share_url: model.share_url, + copy_status: CopyReady, }, @rabbita.none, ) @@ -271,34 +335,110 @@ fn update( Split }, phase: model.phase, + route: model.route, + share_url: model.share_url, + copy_status: model.copy_status, }, @rabbita.none, ) - Submit => { - let generation = model.generation + 1 + Submit => match parse_commit_url(model.input) { Err(message) => ( { input: model.input, - generation, + generation: model.generation + 1, view_mode: model.view_mode, phase: LoadFailed(message), + route: model.route, + share_url: model.share_url, + copy_status: CopyReady, }, @rabbita.none, ) - Ok(reference) => + Ok(reference) => { + let already_at_route = model.route == Some(reference) + let (next, request) = begin_commit_load(model, reference, emit) + let navigation = if already_at_route { + @rabbita.none + } else { + @nav.push_url(playground_share_url(reference)) + } + (next, @rabbita.batch([navigation, request])) + } + } + RouteChanged(url) => + match reference_from_route(url.fragment) { + Err(message) => ( { - input: model.input, - generation, + input: "", + generation: model.generation + 1, view_mode: model.view_mode, - phase: LoadingCommit(reference~, page=1, first=None, files=[]), + phase: LoadFailed(message), + route: None, + share_url: None, + copy_status: CopyReady, }, - commit_request(reference, generation, 1, emit), + @rabbita.none, + ) + Ok(None) => + ( + { + input: "", + generation: model.generation + 1, + view_mode: model.view_mode, + phase: Idle, + route: None, + share_url: None, + copy_status: CopyReady, + }, + @rabbita.none, + ) + Ok(Some(reference)) => + if model.route == Some(reference) { + (model, @rabbita.none) + } else { + begin_commit_load(model, reference, emit) + } + } + CopyShareLink => + match model.share_url { + None => (model, @rabbita.none) + Some(url) => + ( + model, + @clipboard.copy(Text(url), copied=emit(ShareCopied), failed=message => { + emit(ShareCopyFailed(message)) + }), ) } - } + ShareCopied => + ( + { + input: model.input, + generation: model.generation, + view_mode: model.view_mode, + phase: model.phase, + route: model.route, + share_url: model.share_url, + copy_status: Copied, + }, + @rabbita.none, + ) + ShareCopyFailed(message) => + ( + { + input: model.input, + generation: model.generation, + view_mode: model.view_mode, + phase: model.phase, + route: model.route, + share_url: model.share_url, + copy_status: CopyError(message), + }, + @rabbita.none, + ) CommitPageLoaded(generation, page, result) => { if generation != model.generation { return (model, @rabbita.none) diff --git a/playground/main/view.mbt b/playground/main/view.mbt index c466195..d671cda 100644 --- a/playground/main/view.mbt +++ b/playground/main/view.mbt @@ -18,6 +18,16 @@ fn status_label(status : String) -> String { } } +///| +fn status_class(status : String) -> String { + match status { + "added" => "file-status status-added" + "removed" => "file-status status-removed" + "renamed" => "file-status status-renamed" + _ => "file-status" + } +} + ///| fn file_label(file : ApiFile) -> String { match file.previous_filename { @@ -35,6 +45,119 @@ fn source_problem(side : SideLoad) -> String? { } } +///| +fn brand_view() -> @rabbita.Html { + @html.h1(class="brand", [ + @html.span(class="brand-mark", [ + @html.span(class="mark-plus", "+"), + @html.span(class="mark-minus", "−"), + ]), + @html.span("ldiff"), + @html.span(class="brand-suffix", "playground"), + ]) +} + +///| +fn commit_form(model : Model, emit : @rabbita.Emit[Msg]) -> @rabbita.Html { + @html.form(on_submit=emit(Submit), class="commit-form", [ + @html.label(for_="commit-url", class="sr-only", "Public GitHub commit URL"), + @html.div(class="input-row", [ + @html.input( + id="commit-url", + input_type=Url, + value=model.input, + placeholder="https://github.com/owner/repo/commit/sha", + required=true, + on_input=value => emit(InputChanged(value)), + ), + @html.button(type_="submit", class="submit-button", [ + @html.span("View diff"), + @html.span(class="submit-arrow", "→"), + ]), + ]), + ]) +} + +///| +fn route_example() -> @rabbita.Html { + @html.div(class="route-example", [ + @html.code(class="route-old", [ + @html.span(class="route-prefix", "− github"), + @html.span(".com/owner/repo/commit/sha"), + ]), + @html.code(class="route-new", [ + @html.span(class="route-prefix", "+ playground"), + @html.span("/#/owner/repo/commit/sha"), + ]), + ]) +} + +///| +fn landing_details() -> @rabbita.Html { + @html.fragment([ + @html.div(class="public-note", [ + @html.span(class="note-dot", ""), + @html.p( + "Public repositories only. No token is requested, stored, or sent. Anonymous GitHub API limits apply.", + ), + ]), + @html.section(class="supported-links", [ + @html.h2("Supported links"), + @html.ul([ + @html.li([ + @html.span(class="list-arrow", "→"), + @html.code("github.com/owner/repo/commit/sha"), + ]), + @html.li([ + @html.span(class="list-arrow", "→"), + @html.code("github.com/owner/repo/pull/number/changes/sha"), + ]), + ]), + @html.p( + "Opening the generated playground URL restores the same commit automatically, ready to share with a teammate.", + ), + ]), + ]) +} + +///| +fn hero_view( + model : Model, + landing : Bool, + emit : @rabbita.Emit[Msg], +) -> @rabbita.Html { + @html.header( + class=if landing { "hero hero-landing" } else { "hero hero-workspace" }, + [ + @html.div(class="hero-copy", [ + brand_view(), + if landing { + @html.fragment([ + @html.p( + class="lede", + "Review MoonBit changes from a public GitHub commit in a fast, focused diff — then share the exact playground URL with anyone.", + ), + route_example(), + ]) + } else { + @html.p( + class="workspace-caption", + "Public GitHub commits, rendered with ldiff", + ) + }, + ]), + @html.div(class="hero-controls", [ + @html.div(class="input-panel", commit_form(model, emit)), + if landing { + landing_details() + } else { + @html.nothing + }, + ]), + ], + ) +} + ///| #warnings("-alert_xss_vulnerable") fn file_diff_view( @@ -93,7 +216,7 @@ fn file_diff_view( ) } (Loading, _) | (_, Loading) => - @html.p(class="file-message", "Loading file contents…") + @html.p(class="file-message loading-inline", "Loading file contents…") _ => @html.p(class="file-message", "Expand this file to load its diff.") } } @@ -106,52 +229,110 @@ fn file_card( mode : ViewMode, emit : @rabbita.Emit[Msg], ) -> @rabbita.Html { - @html.article(class="file-card", [ - @html.div(class="file-heading", [ - @html.div([ - @html.span(class="file-status", status_label(file.file.status)), - @html.code(class="file-path", file_label(file.file)), - ]), - @html.div(class="file-actions", [ - @html.span( - class="file-stats", - "+\{file.file.additions} −\{file.file.deletions}", - ), - @html.button( - type_="button", - class="secondary compact", - on_click=emit(ToggleFile(generation, index)), - if file.expanded { - "Collapse" - } else { - "Expand" - }, - ), + @html.article( + class=if file.expanded { "file-card expanded" } else { "file-card" }, + [ + @html.div(class="file-heading", [ + @html.div(class="file-identity", [ + @html.span( + class=status_class(file.file.status), + status_label(file.file.status), + ), + @html.code(class="file-path", file_label(file.file)), + ]), + @html.div(class="file-actions", [ + @html.span(class="file-stats", [ + @html.span(class="additions", "+\{file.file.additions}"), + @html.span(class="deletions", "−\{file.file.deletions}"), + ]), + @html.button( + type_="button", + class="secondary compact", + on_click=emit(ToggleFile(generation, index)), + if file.expanded { + "Collapse" + } else { + "Expand" + }, + ), + ]), ]), - ]), - if file.expanded { - file_diff_view(file, generation, index, mode, emit) - } else { - @html.nothing - }, - ]) + if file.expanded { + file_diff_view(file, generation, index, mode, emit) + } else { + @html.nothing + }, + ], + ) +} + +///| +fn copy_button_label(status : CopyStatus) -> String { + match status { + CopyReady => "Copy link" + Copied => "Copied" + CopyError(_) => "Try copy again" + } +} + +///| +fn share_view(model : Model, emit : @rabbita.Emit[Msg]) -> @rabbita.Html { + match model.share_url { + None => @html.nothing + Some(url) => + @html.div(class="share-panel", [ + @html.div(class="share-copy", [ + @html.label(for_="share-url", "Shareable playground URL"), + @html.p("Anyone with this link can open the same public commit."), + ]), + @html.div(class="share-row", [ + @html.input( + id="share-url", + value=url, + read_only=true, + class="share-url", + ), + @html.button( + type_="button", + class=if model.copy_status is Copied { + "copy-button copied" + } else { + "copy-button" + }, + title=match model.copy_status { + CopyError(message) => message + _ => "Copy the shareable playground URL" + }, + on_click=emit(CopyShareLink), + copy_button_label(model.copy_status), + ), + ]), + match model.copy_status { + CopyError(message) => + @html.p(class="copy-error", "Clipboard access failed: \{message}") + _ => @html.nothing + }, + ]) + } } ///| fn commit_view( commit : CommitView, - generation : Int, - mode : ViewMode, + model : Model, emit : @rabbita.Emit[Msg], ) -> @rabbita.Html { let file_views : Array[@rabbita.Html] = [] for index, file in commit.files { - file_views.push(file_card(file, generation, index, mode, emit)) + file_views.push( + file_card(file, model.generation, index, model.view_mode, emit), + ) } @html.section(class="results", [ @html.div(class="commit-card", [ @html.div(class="commit-title", [ - @html.div([ + @html.div(class="commit-identity", [ + @html.p(class="section-label", "Public commit"), @html.a( href=commit.html_url, target=Blank, @@ -170,7 +351,7 @@ fn commit_view( type_="button", class="view-toggle", on_click=emit(ToggleView), - match mode { + match model.view_mode { Split => "Use unified view" Unified => "Use split view" }, @@ -178,11 +359,15 @@ fn commit_view( ]), @html.pre(class="commit-message", commit.message), @html.div(class="summary-grid", [ - @html.span("+\{commit.stats.additions} additions"), - @html.span("−\{commit.stats.deletions} deletions"), + @html.span(class="summary-add", "+\{commit.stats.additions} additions"), + @html.span( + class="summary-del", + "−\{commit.stats.deletions} deletions", + ), @html.span("\{commit.total_files} files processed"), @html.span("\{commit.files.length()} MoonBit files shown"), ]), + share_view(model, emit), ]), if commit.files.length() == 0 { @html.div( @@ -198,16 +383,14 @@ fn commit_view( ///| fn phase_view(model : Model, emit : @rabbita.Emit[Msg]) -> @rabbita.Html { match model.phase { - Idle => - @html.div( - class="empty-state", - "Paste a public GitHub commit URL to render its MoonBit changes.", - ) + Idle => @html.nothing LoadingCommit(page~, ..) => - @html.div( - class="empty-state loading", - "Loading commit metadata (page \{page}, up to 100 files per page)…", - ) + @html.div(class="empty-state loading", [ + @html.span(class="spinner", ""), + @html.span( + "Loading commit metadata (page \{page}, up to 100 files per page)…", + ), + ]) LoadFailed(message) => @html.div(class="empty-state error", [ @html.p(message), @@ -218,46 +401,27 @@ fn phase_view(model : Model, emit : @rabbita.Emit[Msg]) -> @rabbita.Html { "Try again", ), ]) - Showing(commit) => - commit_view(commit, model.generation, model.view_mode, emit) + Showing(commit) => commit_view(commit, model, emit) + } +} + +///| +fn landing_mode(model : Model) -> Bool { + match model.phase { + Idle => true + LoadFailed(_) => model.route is None + _ => false } } ///| fn app_view(model : Model, emit : @rabbita.Emit[Msg]) -> @rabbita.Html { - @html.main_(class="shell", [ - @html.header(class="hero", [ - @html.div(class="hero-copy", [ - @html.p(class="eyebrow", "ldiff playground"), - @html.h1("GitHub Commit Diff for MoonBit"), - @html.p( - class="lede", - "Compare a public commit with its first parent. Only MoonBit source and project files are rendered.", - ), - ]), - @html.div(class="hero-controls", [ - @html.form(on_submit=emit(Submit), class="commit-form", [ - @html.label(for_="commit-url", "GitHub commit URL"), - @html.div(class="input-row", [ - @html.input( - id="commit-url", - input_type=Url, - value=model.input, - placeholder="https://github.com/owner/repo/commit/abcdef1", - required=true, - on_input=value => emit(InputChanged(value)), - ), - @html.button(type_="submit", "Load diff"), - ]), - ]), - @html.p( - class="privacy-note", - "Public repositories only. This page does not accept, store, or send personal access tokens. Anonymous GitHub API limits apply.", - ), - ]), - ]), + let landing = landing_mode(model) + @html.main_(class=if landing { "shell landing" } else { "shell workspace" }, [ + hero_view(model, landing, emit), phase_view(model, emit), @html.footer([ + @html.span("Built with ldiff for MoonBit · "), @html.a( href="https://github.com/moonbit-community/ldiff", target=Blank, diff --git a/playground/public/favicon.svg b/playground/public/favicon.svg new file mode 100644 index 0000000..5f0b90f --- /dev/null +++ b/playground/public/favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/playground/public/index.html b/playground/public/index.html index 998e10d..ef0b0cc 100644 --- a/playground/public/index.html +++ b/playground/public/index.html @@ -3,14 +3,17 @@ - - ldiff · GitHub Commit Diff + + + + ldiff playground · Review and share MoonBit diffs +
- +
diff --git a/playground/public/styles.css b/playground/public/styles.css index 18293a6..75dfb2e 100644 --- a/playground/public/styles.css +++ b/playground/public/styles.css @@ -1,19 +1,30 @@ :root { color-scheme: light; - font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - color: #17202a; - background: #f4f1e8; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; + color: #171717; + background: #f7f7f7; font-synthesis: none; - --ink: #17202a; - --muted: #65706f; - --paper: #fffdf7; - --line: #d9d3c4; - --accent: #0b6e69; - --accent-dark: #074b48; - --add: #e0f4e8; - --add-strong: #9ed8b1; - --del: #f9e4e0; - --del-strong: #efa9a0; + --background: #f7f7f7; + --workspace: #f2f2f2; + --surface: #ffffff; + --surface-subtle: #fafafa; + --foreground: #171717; + --muted: #737373; + --muted-strong: #575757; + --border: #dedede; + --border-soft: #e9e9e9; + --focus: rgba(23, 23, 23, 0.16); + --green: #18a46c; + --green-bright: #07c480; + --green-bg: rgba(7, 196, 128, 0.12); + --red: #ed3047; + --red-bright: #ff6762; + --red-bg: rgba(255, 103, 98, 0.13); + --add: #e9f8f1; + --add-strong: #b9ebd5; + --del: #fff0ef; + --del-strong: #ffc9c5; + --mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; } * { @@ -23,20 +34,28 @@ html, body, #app { + min-width: 320px; + min-height: 100%; max-width: 100%; } body { margin: 0; - min-width: 320px; overflow-x: hidden; - background: - radial-gradient(circle at top left, rgba(11, 110, 105, 0.12), transparent 34rem), - #f4f1e8; + color: var(--foreground); + background: var(--background); + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; } a { - color: var(--accent-dark); + color: inherit; + text-decoration-color: color-mix(in srgb, currentColor 45%, transparent); + text-underline-offset: 0.17em; +} + +a:hover { + text-decoration-color: currentColor; } button, @@ -45,58 +64,114 @@ input { } button { - border: 1px solid var(--accent); - border-radius: 0.45rem; - padding: 0.62rem 0.9rem; - color: white; - background: var(--accent); - font-weight: 700; + display: inline-flex; + min-height: 2rem; + align-items: center; + justify-content: center; + gap: 0.4rem; + border: 1px solid var(--foreground); + border-radius: 0.42rem; + padding: 0.45rem 0.72rem; + color: var(--surface); + background: var(--foreground); + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.04); + font-size: 0.82rem; + font-weight: 600; + line-height: 1; + white-space: nowrap; cursor: pointer; + transition: background 120ms ease, border-color 120ms ease, color 120ms ease, transform 120ms ease; } button:hover { - background: var(--accent-dark); + border-color: #353535; + background: #353535; +} + +button:active { + transform: translateY(1px); +} + +button:focus-visible, +input:focus-visible, +a:focus-visible { + outline: 3px solid var(--focus); + outline-offset: 2px; } button.secondary, button.view-toggle { - color: var(--accent-dark); - background: transparent; + border-color: var(--border); + color: var(--foreground); + background: var(--surface); } button.secondary:hover, button.view-toggle:hover { - color: white; - background: var(--accent-dark); + border-color: #bcbcbc; + background: var(--surface-subtle); } button.compact { - padding: 0.42rem 0.7rem; - font-size: 0.85rem; + min-height: 1.8rem; + padding: 0.38rem 0.6rem; + font-size: 0.76rem; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; } .shell { + width: 100%; min-width: 0; - margin: 0 1rem; - padding: 1rem 0 1.25rem; + min-height: 100svh; } -.hero, -.commit-card, -.file-card, -.empty-state { - border: 1px solid var(--line); - border-radius: 0.6rem; - background: var(--paper); - box-shadow: 0 5px 16px rgba(30, 38, 36, 0.045); +.landing { + display: flex; + max-width: 43rem; + margin: 0 auto; + padding: clamp(2rem, 9vh, 5rem) 1.5rem 2rem; + flex-direction: column; + justify-content: center; +} + +.workspace { + padding: 0.75rem; + background: var(--workspace); } .hero { + min-width: 0; +} + +.hero-landing { + width: 100%; +} + +.hero-workspace { display: grid; - grid-template-columns: minmax(18rem, 0.8fr) minmax(28rem, 1.2fr); + position: sticky; + z-index: 10; + top: 0.75rem; + grid-template-columns: minmax(15rem, 0.55fr) minmax(25rem, 1.45fr); align-items: center; - gap: clamp(1.25rem, 3vw, 3rem); - padding: clamp(1rem, 2vw, 1.5rem); + gap: 1.5rem; + border: 1px solid var(--border); + border-radius: 0.65rem; + padding: 0.75rem 0.85rem; + background: color-mix(in srgb, var(--surface) 94%, transparent); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.035), 0 8px 30px rgba(0, 0, 0, 0.035); + backdrop-filter: blur(14px); } .hero-copy, @@ -104,88 +179,302 @@ button.compact { min-width: 0; } -.eyebrow { - margin: 0 0 0.5rem; - color: var(--accent); - font-size: 0.8rem; +.brand { + display: flex; + margin: 0; + align-items: center; + gap: 0.42rem; + color: var(--foreground); + font-size: 1.52rem; + font-weight: 680; + letter-spacing: -0.045em; + line-height: 1; +} + +.brand-mark { + display: grid; + position: relative; + width: 1.5rem; + height: 1.5rem; + flex: 0 0 auto; + grid-template-columns: 1fr 1fr; + place-items: center; + border-radius: 0.4rem; + color: var(--surface); + background: var(--foreground); + font-family: var(--mono); + font-size: 0.72rem; font-weight: 800; - letter-spacing: 0.14em; - text-transform: uppercase; + letter-spacing: -0.15em; } -h1 { - margin: 0; - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; - font-size: clamp(1.75rem, 3.2vw, 3rem); - line-height: 1.05; - letter-spacing: -0.045em; +.mark-plus { + align-self: start; + margin-top: 0.16rem; +} + +.mark-minus { + align-self: end; + margin-bottom: 0.17rem; +} + +.brand-suffix { + color: var(--muted); + font-size: 0.73rem; + font-weight: 520; + letter-spacing: -0.01em; +} + +.workspace-caption { + margin: 0.35rem 0 0 1.95rem; + color: var(--muted); + font-size: 0.74rem; } .lede { - max-width: 62ch; - margin: 0.8rem 0 0; + max-width: 41rem; + margin: 1.05rem 0 0; color: var(--muted); - line-height: 1.55; + font-size: 0.99rem; + line-height: 1.52; + text-wrap: pretty; } -.commit-form label { - display: block; - margin-bottom: 0.45rem; +.route-example { + display: flex; + margin: 1.05rem 0 1.45rem; + flex-direction: column; + gap: 0.16rem; + color: var(--muted); + font-family: var(--mono); font-size: 0.86rem; - font-weight: 750; + line-height: 1.48rem; + letter-spacing: -0.025em; +} + +.route-example code { + display: flex; + min-width: 0; + align-items: center; + overflow: hidden; + border-left: 3px solid; + border-radius: 0.24rem 0 0 0.24rem; + font: inherit; + white-space: nowrap; +} + +.route-example code > span:last-child { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.route-old { + border-left-color: var(--red-bright) !important; +} + +.route-new { + border-left-color: var(--green-bright) !important; +} + +.route-prefix { + display: inline-flex; + margin-right: 0.16rem; + padding: 0.05rem 0.42rem; + border-radius: 0 0.22rem 0.22rem 0; +} + +.route-old .route-prefix { + color: var(--red); + background: var(--red-bg); +} + +.route-new .route-prefix { + color: var(--green); + background: var(--green-bg); +} + +.input-panel { + overflow: hidden; + border: 1px solid var(--border); + border-radius: 0.62rem; + background: var(--surface); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.025); +} + +.hero-landing .input-panel { + border-radius: 0.62rem 0.62rem 0 0; +} + +.commit-form { + min-width: 0; } .input-row { display: grid; + min-width: 0; grid-template-columns: minmax(0, 1fr) auto; - gap: 0.65rem; + align-items: center; + gap: 0.35rem; + padding: 0.34rem 0.4rem 0.34rem 0.88rem; } .input-row input { + width: 100%; + min-width: 0; + height: 2.55rem; + border: 0; + padding: 0; + color: var(--foreground); + background: transparent; + font-size: 0.96rem; + outline: none; +} + +.input-row input::placeholder { + color: #919191; +} + +.input-row input:focus-visible { + outline: 0; +} + +.input-panel:focus-within { + border-color: #b8b8b8; + box-shadow: 0 0 0 3px var(--focus); +} + +.submit-button { + min-height: 2.25rem; + padding-inline: 0.8rem; +} + +.submit-arrow { + font-size: 1rem; + line-height: 0; +} + +.public-note { + display: flex; + margin-top: -1px; + align-items: flex-start; + gap: 0.55rem; + border: 1px solid var(--border); + border-top-color: var(--border-soft); + border-radius: 0 0 0.62rem 0.62rem; + padding: 0.72rem 0.88rem; + color: var(--muted); + background: var(--surface); + font-size: 0.78rem; + line-height: 1.4; +} + +.public-note p { + margin: 0; +} + +.note-dot { + width: 0.45rem; + height: 0.45rem; + margin-top: 0.28rem; + flex: 0 0 auto; + border: 1px solid color-mix(in srgb, var(--green) 60%, var(--border)); + border-radius: 50%; + background: var(--green-bg); + box-shadow: inset 0 0 0 1px var(--surface); +} + +.supported-links { + margin-top: 1.65rem; +} + +.supported-links h2 { + margin: 0 0 0.48rem; + color: var(--muted); + font-size: 0.82rem; + font-weight: 450; +} + +.supported-links ul { + display: flex; + margin: 0; + padding: 0; + flex-direction: column; + gap: 0.32rem; + list-style: none; +} + +.supported-links li { + display: flex; + min-width: 0; + align-items: center; + gap: 0.42rem; + font-size: 0.82rem; +} + +.supported-links code { min-width: 0; - border: 1px solid #aaa594; - border-radius: 0.45rem; - padding: 0.65rem 0.8rem; - background: white; - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + overflow: hidden; + font-family: var(--mono); + text-overflow: ellipsis; + white-space: nowrap; } -.input-row input:focus { - outline: 3px solid rgba(11, 110, 105, 0.18); - border-color: var(--accent); +.list-arrow { + flex: 0 0 auto; + color: var(--muted); } -.privacy-note { - margin: 0.8rem 0 0; +.supported-links p { + margin: 1.05rem 0 0; color: var(--muted); - font-size: 0.83rem; + font-size: 0.8rem; + line-height: 1.45; + text-wrap: pretty; } .results, .empty-state { - margin-top: 0.7rem; + margin-top: 0.65rem; +} + +.empty-state, +.commit-card, +.file-card { + border: 1px solid var(--border); + border-radius: 0.62rem; + background: var(--surface); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.025); } .empty-state { - padding: 1.35rem; + display: flex; + min-height: 5rem; + padding: 1.25rem; + align-items: center; + justify-content: center; + gap: 0.65rem; color: var(--muted); + font-size: 0.86rem; text-align: center; } +.landing > .empty-state { + min-height: auto; +} + .empty-state p { - margin-top: 0; + margin: 0; } -.loading::before { - content: ""; - display: inline-block; - width: 0.7rem; - height: 0.7rem; - margin-right: 0.55rem; - border: 2px solid var(--line); - border-top-color: var(--accent); +.spinner { + width: 0.85rem; + height: 0.85rem; + flex: 0 0 auto; + border: 2px solid var(--border); + border-top-color: var(--foreground); border-radius: 50%; - animation: spin 0.75s linear infinite; + animation: spin 0.72s linear infinite; } @keyframes spin { @@ -193,64 +482,167 @@ h1 { } .error { - color: #8b2d25; + color: #a12435; } .commit-card { min-width: 0; - padding: 0.9rem; + padding: 0.95rem; } .commit-title, .file-heading, -.file-actions { +.file-actions, +.share-row { display: flex; align-items: center; justify-content: space-between; gap: 1rem; } +.commit-identity { + min-width: 0; +} + +.section-label { + margin: 0 0 0.26rem; + color: var(--muted); + font-size: 0.66rem; + font-weight: 650; + letter-spacing: 0.09em; + text-transform: uppercase; +} + .commit-title a { - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; - font-weight: 800; + display: block; overflow-wrap: anywhere; + font-family: var(--mono); + font-size: 0.92rem; + font-weight: 680; } .parent { - margin: 0.35rem 0 0; + margin: 0.3rem 0 0; color: var(--muted); - font-size: 0.82rem; + font-family: var(--mono); + font-size: 0.7rem; + overflow-wrap: anywhere; } .commit-message { - margin: 0.75rem 0; - padding: 0.65rem; - border-left: 4px solid var(--accent); + max-height: 12rem; + margin: 0.85rem 0; overflow: auto; + border-left: 3px solid var(--green-bright); + border-radius: 0 0.3rem 0.3rem 0; + padding: 0.7rem 0.75rem; + color: var(--muted-strong); + background: var(--surface-subtle); + font-family: var(--mono); + font-size: 0.78rem; + line-height: 1.48; white-space: pre-wrap; - background: #f6f5ef; } .summary-grid { display: flex; flex-wrap: wrap; - gap: 0.55rem; + gap: 0.42rem; } -.summary-grid span, +.summary-grid > span, .file-status { + border: 1px solid var(--border-soft); border-radius: 999px; - padding: 0.28rem 0.55rem; - background: #e9ede8; - color: #394846; + padding: 0.25rem 0.5rem; + color: var(--muted-strong); + background: var(--surface-subtle); + font-size: 0.7rem; + font-weight: 580; +} + +.summary-grid .summary-add, +.status-added { + border-color: color-mix(in srgb, var(--green-bright) 28%, var(--border)); + color: var(--green); + background: var(--green-bg); +} + +.summary-grid .summary-del, +.status-removed { + border-color: color-mix(in srgb, var(--red-bright) 28%, var(--border)); + color: var(--red); + background: var(--red-bg); +} + +.status-renamed { + color: #896d15; + background: rgba(229, 184, 35, 0.12); +} + +.share-panel { + display: grid; + margin-top: 0.85rem; + grid-template-columns: minmax(12rem, 0.48fr) minmax(20rem, 1.52fr); + align-items: center; + gap: 1rem; + border: 1px solid var(--border-soft); + border-radius: 0.52rem; + padding: 0.7rem; + background: var(--surface-subtle); +} + +.share-copy label { + display: block; font-size: 0.78rem; - font-weight: 750; + font-weight: 650; +} + +.share-copy p, +.copy-error { + margin: 0.2rem 0 0; + color: var(--muted); + font-size: 0.7rem; + line-height: 1.35; +} + +.share-row { + min-width: 0; + gap: 0.42rem; +} + +.share-url { + width: 100%; + min-width: 0; + height: 2.12rem; + border: 1px solid var(--border); + border-radius: 0.4rem; + padding: 0 0.58rem; + color: var(--muted-strong); + background: var(--surface); + font-family: var(--mono); + font-size: 0.7rem; +} + +.copy-button { + min-width: 5rem; +} + +.copy-button.copied { + border-color: var(--green); + color: var(--surface); + background: var(--green); +} + +.copy-error { + grid-column: 2; + color: var(--red); } .file-list { display: grid; - gap: 0.55rem; - margin-top: 0.55rem; + gap: 0.48rem; + margin-top: 0.48rem; } .file-card { @@ -258,53 +650,87 @@ h1 { overflow: hidden; } +.file-card.expanded { + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.035), 0 8px 24px rgba(0, 0, 0, 0.025); +} + .file-heading { - padding: 0.6rem 0.7rem; - border-bottom: 1px solid transparent; + min-height: 3rem; + padding: 0.58rem 0.68rem; } -.file-heading > div:first-child { +.file-identity { display: flex; min-width: 0; align-items: center; - gap: 0.65rem; + gap: 0.58rem; } .file-path { + min-width: 0; overflow-wrap: anywhere; - font-size: 0.9rem; + font-family: var(--mono); + font-size: 0.78rem; } .file-stats { + display: inline-flex; flex: none; - color: var(--muted); - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; - font-size: 0.82rem; + gap: 0.34rem; + font-family: var(--mono); + font-size: 0.72rem; +} + +.file-stats .additions { + color: var(--green); +} + +.file-stats .deletions { + color: var(--red); } .file-message { margin: 0; padding: 1rem; - border-top: 1px solid var(--line); + border-top: 1px solid var(--border-soft); color: var(--muted); + font-size: 0.8rem; +} + +.file-message p { + margin-top: 0; +} + +.loading-inline::before { + content: ""; + display: inline-block; + width: 0.58rem; + height: 0.58rem; + margin-right: 0.45rem; + border: 1.5px solid var(--border); + border-top-color: var(--foreground); + border-radius: 50%; + animation: spin 0.72s linear infinite; } .diff-scroll { min-width: 0; max-width: 100%; - border-top: 1px solid var(--line); overflow-x: auto; overflow-y: hidden; - background: white; + border-top: 1px solid var(--border-soft); + background: var(--surface); overscroll-behavior-x: contain; + scrollbar-color: #c5c5c5 transparent; + scrollbar-width: thin; } .diff-scroll table { width: 100%; border-collapse: collapse; - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; - font-size: 0.82rem; - line-height: 1.5; + font-family: var(--mono); + font-size: 0.76rem; + line-height: 1.55; } .diff-scroll table.split { @@ -326,7 +752,7 @@ h1 { .diff-scroll table.split td.empty { width: 50%; min-width: 32rem; - padding: 0 0.6rem; + padding: 0 0.58rem; } .diff-scroll table.unified td.ctx, @@ -334,32 +760,32 @@ h1 { .diff-scroll table.unified td.add, .diff-scroll table.unified td.empty { width: 100%; - padding: 0 0.6rem; + padding: 0 0.58rem; } .diff-scroll td.line-number { width: 1%; - padding: 0 0.4rem; - border-right: 1px solid #e7e4dc; - color: #747b7a; - background: #f4f5f2; + padding: 0 0.42rem; + border-right: 1px solid var(--border-soft); + color: #929292; + background: var(--surface-subtle); text-align: right; user-select: none; } .diff-scroll table.split td.new-line-number { - border-left: 2px solid #cbc7bc; + border-left: 1px solid var(--border); } .diff-scroll .hunk-header { - padding: 0.25rem 0.7rem; - color: #315e78; - background: #e9f2f6; - font-weight: 700; + padding: 0.3rem 0.65rem; + color: #536f90; + background: #edf4fb; + font-weight: 580; } .diff-scroll .ctx { - background: white; + background: var(--surface); } .diff-scroll .del { @@ -371,7 +797,7 @@ h1 { } .diff-scroll .empty { - background: #f5f3ed; + background: var(--surface-subtle); } .diff-scroll b.wd { @@ -385,27 +811,54 @@ h1 { } footer { - padding: 1rem 0 0.25rem; + width: 100%; + padding: 1.5rem 0 0.35rem; color: var(--muted); + font-size: 0.74rem; +} + +.landing footer { + margin-top: 1.55rem; + padding-top: 1.8rem; + border-top: 1px solid var(--border-soft); +} + +.workspace footer { text-align: center; - font-size: 0.85rem; } @media (max-width: 900px) { - .hero { + .hero-workspace { + position: static; grid-template-columns: minmax(0, 1fr); - gap: 1rem; + gap: 0.72rem; + } + + .workspace-caption { + display: none; + } + + .share-panel { + grid-template-columns: minmax(0, 1fr); + } + + .copy-error { + grid-column: 1; } } -@media (max-width: 700px) { - .shell { - margin: 0 0.5rem; - padding-top: 0.5rem; +@media (max-width: 680px) { + .landing { + min-height: 100svh; + padding: 1.5rem 1.15rem; } - .input-row { - grid-template-columns: 1fr; + .workspace { + padding: 0.42rem; + } + + .hero-workspace { + top: 0.42rem; } .commit-title, @@ -421,4 +874,110 @@ footer { .file-actions button { margin-left: auto; } + + .share-row { + align-items: stretch; + flex-direction: column; + } + + .copy-button { + min-height: 2.2rem; + } +} + +@media (max-width: 460px) { + .brand-suffix { + display: none; + } + + .lede { + font-size: 0.92rem; + } + + .route-example { + font-size: 0.74rem; + } + + .input-row { + padding-left: 0.7rem; + } + + .input-row input { + font-size: 0.86rem; + } + + .submit-button { + width: 2.3rem; + padding: 0; + } + + .submit-button > span:first-child { + display: none; + } + + .summary-grid > span { + font-size: 0.65rem; + } +} + +@media (prefers-color-scheme: dark) { + :root { + color-scheme: dark; + --background: #101010; + --workspace: #0b0b0b; + --surface: #171717; + --surface-subtle: #1d1d1d; + --foreground: #f1f1f1; + --muted: #9b9b9b; + --muted-strong: #c2c2c2; + --border: #333333; + --border-soft: #292929; + --focus: rgba(255, 255, 255, 0.18); + --green: #36d49a; + --red: #ff7774; + --green-bg: rgba(7, 196, 128, 0.1); + --red-bg: rgba(255, 103, 98, 0.1); + --add: #10291f; + --add-strong: #195f45; + --del: #321a1b; + --del-strong: #6a2c2d; + } + + button { + color: #171717; + background: #f1f1f1; + } + + button:hover { + border-color: #d3d3d3; + background: #d3d3d3; + } + + .input-row input::placeholder { + color: #777777; + } + + .error { + color: #ff8e96; + } + + .diff-scroll .hunk-header { + color: #a8c6e5; + background: #182635; + } + + .diff-scroll td.line-number { + color: #707070; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + } } diff --git a/playground/tests/playground.spec.mjs b/playground/tests/playground.spec.mjs index 44090a2..4c8fb58 100644 --- a/playground/tests/playground.spec.mjs +++ b/playground/tests/playground.spec.mjs @@ -58,16 +58,58 @@ async function loadMockedCommit(page) { }); await page.goto("/"); - await page.getByLabel("GitHub commit URL").fill(commitUrl); - await page.getByRole("button", { name: "Load diff" }).click(); + await page.getByLabel("Public GitHub commit URL").fill(commitUrl); + await page.getByRole("button", { name: "View diff" }).click(); await expect(page.locator("table.split")).toBeVisible(); } +async function openMockedShareLink(page) { + await page.route("https://**", route => route.abort("blockedbyclient")); + await page.route("https://api.github.com/**", async route => { + await route.fulfill({ + status: 200, + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + body: JSON.stringify(apiCommit), + }); + }); + await page.route("https://raw.githubusercontent.com/**", async route => { + const revision = new URL(route.request().url()).pathname.split("/")[3]; + await route.fulfill({ + status: 200, + contentType: "text/plain", + headers: { "access-control-allow-origin": "*" }, + body: revision === parentSha ? oldSource : newSource, + }); + }); + await page.goto(`/#/example/project/commit/${commitSha}`); + await expect(page.locator("table.split")).toBeVisible(); +} + +test("landing follows the compact DiffsHub-style URL handoff", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto("/"); + + await expect(page.getByRole("heading", { name: "ldiff playground" })).toBeVisible(); + await expect(page.getByText("− github", { exact: true })).toBeVisible(); + await expect(page.getByText("+ playground", { exact: true })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Supported links" })).toBeVisible(); + + const landingMetrics = await page.locator(".shell.landing").evaluate(shell => ({ + width: shell.getBoundingClientRect().width, + background: getComputedStyle(document.body).backgroundColor, + })); + expect(landingMetrics.width).toBeLessThanOrEqual(688); + expect(landingMetrics.background).toBe("rgb(247, 247, 247)"); +}); + test("desktop keeps split columns balanced and switches views", async ({ page }) => { await page.setViewportSize({ width: 1440, height: 900 }); await loadMockedCommit(page); await expect(page.getByText("example/project@", { exact: false })).toBeVisible(); + await expect(page).toHaveURL(`/#/example/project/commit/${commitSha}`); + await expect(page.getByLabel("Shareable playground URL")).toHaveValue(page.url()); const heroLayout = await page.locator(".hero").evaluate(hero => { const copy = hero.querySelector(".hero-copy").getBoundingClientRect(); @@ -107,6 +149,21 @@ test("desktop keeps split columns balanced and switches views", async ({ page }) await expect(page.locator("table.split")).toBeVisible(); }); +test("a shared playground URL restores the commit and can be copied", async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 800 }); + await page.context().grantPermissions(["clipboard-read", "clipboard-write"]); + await openMockedShareLink(page); + + await expect(page.getByLabel("Public GitHub commit URL")).toHaveValue(commitUrl); + await expect(page.getByLabel("Shareable playground URL")).toHaveValue(page.url()); + await page.getByRole("button", { name: "Copy link" }).click(); + await expect(page.getByRole("button", { name: "Copied" })).toBeVisible(); + await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(page.url()); + + await page.reload(); + await expect(page.locator("table.split")).toBeVisible(); +}); + test("narrow viewport scrolls only the diff and keeps controls usable", async ({ page }) => { await page.setViewportSize({ width: 640, height: 900 }); await loadMockedCommit(page);