atproto pds in zig pds.zat.dev
pds atproto
25

Configure Feed

Select the types of activity you want to include in your feed.

Polish OAuth consent and landing links

zzstoatzz (May 25, 2026, 11:09 PM -0500) 9e0b4eaa 91429dcc

+260 -46
+216 -27
src/atproto/oauth.zig
··· 143 143 const scopes_html = scopesHtml(allocator, oauth_request.scope) catch { 144 144 return oauthError(request, .bad_request, "invalid_scope", "Failed to resolve requested permission set"); 145 145 }; 146 + const scopes_summary = scopeSummary(allocator, oauth_request.scope) catch { 147 + return oauthError(request, .bad_request, "invalid_scope", "Failed to summarize requested permission set"); 148 + }; 146 149 const issuer = config.publicUrl(); 147 150 const html = try std.fmt.allocPrint(allocator, 148 151 \\<!doctype html> ··· 152 155 \\<meta name="viewport" content="width=device-width, initial-scale=1"> 153 156 \\<title>Sign in to zds</title> 154 157 \\<style> 155 - \\:root{{color-scheme:light dark;--bg:#f7f3ee;--panel:#fffdf9;--text:#171412;--muted:#6f655c;--border:#d8cec3;--line:#ece3d8;--accent:#2563eb;--accent-ink:#fff;--danger:#b42318;--code:#f1eae1;--field:#fffaf3}} 156 - \\@media (prefers-color-scheme:dark){{:root{{--bg:#14110f;--panel:#1d1a17;--text:#fbf7ef;--muted:#b8aa9a;--border:#403832;--line:#332d28;--accent:#8ab4ff;--accent-ink:#091426;--danger:#ffb4a8;--code:#2a251f;--field:#24201c}}}} 157 - \\*{{box-sizing:border-box}} body{{margin:0;min-height:100vh;background:var(--bg);color:var(--text);font:16px/1.5 ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}} 158 - \\.shell{{width:min(100% - 32px,620px);margin:48px auto;background:var(--panel);border:1px solid var(--border);border-radius:14px;padding:32px;box-shadow:0 22px 70px rgba(0,0,0,.16)}} 159 - \\.brand{{font-size:13px;font-weight:780;letter-spacing:.08em;text-transform:uppercase;color:var(--accent);margin:0 0 18px}} 160 - \\h1{{font-size:30px;line-height:1.12;margin:0 0 22px;letter-spacing:0}} p{{margin:0;color:var(--muted)}} label{{display:block;font-weight:700;margin:18px 0 7px}} 161 - \\input{{width:100%;font:inherit;color:var(--text);background:var(--field);border:1px solid var(--border);border-radius:9px;padding:12px 13px}} 162 - \\input:focus{{outline:3px solid rgba(37,99,235,.25);border-color:var(--accent)}} input:-webkit-autofill{{box-shadow:0 0 0 1000px var(--field) inset;-webkit-text-fill-color:var(--text);caret-color:var(--text)}} input:autofill{{box-shadow:0 0 0 1000px var(--field) inset}} 163 - \\button{{width:100%;margin-top:24px;border:0;border-radius:9px;background:var(--accent);color:var(--accent-ink);font:inherit;font-weight:780;padding:12px 14px;cursor:pointer}} 164 - \\.client{{border:1px solid var(--border);border-radius:10px;padding:15px;margin:0 0 22px;background:var(--field)}} 165 - \\.client-label{{display:block;color:var(--muted);font-size:12px;font-weight:760;text-transform:uppercase;letter-spacing:.08em;margin-bottom:6px}} 166 - \\.client-name{{display:block;font-size:20px;font-weight:780;line-height:1.25}} 167 - \\.client-uri{{display:block;margin-top:2px;color:var(--muted);overflow-wrap:anywhere;font-size:14px;text-decoration:none}} 168 - \\details{{border:1px solid var(--border);border-radius:10px;margin:0 0 24px;background:transparent}} summary{{cursor:pointer;padding:13px 15px;font-weight:760}} summary::marker{{color:var(--accent)}} 169 - \\.scopes{{margin:0;padding:0 15px 12px;list-style:none;display:grid;gap:10px}} 170 - \\.scope{{border-top:1px solid var(--line);padding-top:10px}} 171 - \\.scope-code{{display:inline-block;max-width:100%;overflow-wrap:anywhere;border:1px solid var(--border);border-radius:6px;background:var(--code);padding:3px 6px;font:13px/1.35 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:var(--text)}} 172 - \\.scope-text{{display:block;margin-top:5px;color:var(--muted);font-size:14px}} 158 + \\:root{{color-scheme:dark;--bg:#070807;--panel:#101210;--panel-2:#151915;--line:#273026;--text:#f0f1ec;--muted:#aaa69b;--dim:#69645c;--accent:#91adff;--green:#39c178;--pink:#f05f8e;--danger:#ffb4a8;--focus:#c9d7ff;--field:#121510}} 159 + \\@media (prefers-color-scheme:light){{:root{{--bg:#f6f4ed;--panel:#fffdf7;--panel-2:#ece8dd;--line:#d8d0c0;--text:#161412;--muted:#625c53;--dim:#8f8679;--accent:#315dcb;--green:#1b7340;--pink:#b72e5c;--danger:#9f1d1d;--focus:#163f9f;--field:#fffaf1;color-scheme:light}}}} 160 + \\*{{box-sizing:border-box}} html{{min-height:100%;background:var(--bg)}} body{{margin:0;min-height:100vh;background:var(--bg);color:var(--text);font:15px/1.55 ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono",monospace}} 161 + \\a{{color:inherit}} a:focus-visible,button:focus-visible,input:focus-visible{{outline:2px solid var(--focus);outline-offset:3px}} 162 + \\.shell{{width:min(100%,780px);margin:0 auto;padding:24px 16px 34px}} 163 + \\.brand{{display:inline-flex;align-items:center;color:var(--text);font-size:14px;font-weight:700;margin:0 0 54px;text-decoration:none}} 164 + \\.hero{{margin:0 0 26px}} 165 + \\.kicker{{margin:0 0 12px;color:var(--green);font-size:13px}} 166 + \\h1{{font-size:clamp(42px,15vw,82px);line-height:.9;margin:0;letter-spacing:0;color:var(--text);font-weight:800}} 167 + \\.lede{{margin:18px 0 0;max-width:48ch;color:var(--muted);font-size:15px}} 168 + \\.lede strong{{color:var(--text);font-weight:800}} 169 + \\p{{margin:0;color:var(--muted)}} label{{display:block;font-weight:700;margin:18px 0 7px;color:var(--text)}} 170 + \\input{{width:100%;font:inherit;color:var(--text);background:var(--field);border:1px solid var(--line);border-radius:10px;padding:12px 13px}} 171 + \\input:focus{{border-color:var(--accent)}} input:-webkit-autofill{{box-shadow:0 0 0 1000px var(--field) inset;-webkit-text-fill-color:var(--text);caret-color:var(--text)}} input:autofill{{box-shadow:0 0 0 1000px var(--field) inset}} 172 + \\button{{width:100%;margin-top:24px;border:1px solid color-mix(in srgb,var(--accent) 64%,var(--line));border-radius:10px;background:var(--accent);color:#061021;font:inherit;font-weight:780;padding:12px 14px;cursor:pointer;box-shadow:0 10px 28px color-mix(in srgb,var(--accent) 18%,transparent)}} 173 + \\button:hover{{filter:brightness(1.05)}} 174 + \\.stack{{display:grid;gap:18px}} 175 + \\.panel{{border:1px solid var(--line);border-radius:8px;background:var(--panel);overflow:hidden}} 176 + \\.client{{padding:15px;background:linear-gradient(135deg,color-mix(in srgb,var(--green) 10%,transparent),transparent 46%),linear-gradient(315deg,color-mix(in srgb,var(--pink) 8%,transparent),transparent 44%),var(--panel)}} 177 + \\.client-label{{display:block;color:var(--dim);font-size:11px;font-weight:700;text-transform:lowercase;margin-bottom:5px}} 178 + \\.client-name{{display:block;font-size:19px;font-weight:780;line-height:1.25;color:var(--text)}} 179 + \\.client-uri{{display:block;margin-top:3px;color:var(--muted);overflow-wrap:anywhere;font-size:12px;text-decoration:none}} 180 + \\.client-uri:hover{{color:var(--green)}} 181 + \\.summary{{padding:14px 15px;color:var(--muted);border-top:1px solid var(--line)}} 182 + \\.summary strong{{color:var(--text)}} 183 + \\details{{border:1px solid var(--line);border-radius:8px;background:transparent;overflow:hidden}} summary{{cursor:pointer;padding:13px 15px;font-weight:760;color:var(--text)}} summary::marker{{color:var(--accent)}} 184 + \\.scopes{{margin:0;padding:0 15px 14px;list-style:none;display:grid;gap:9px}} 185 + \\.scope{{display:grid;gap:5px;border-top:1px solid var(--line);padding-top:10px}} 186 + \\.permission-set{{gap:7px}} 187 + \\.permission-title{{display:block;color:var(--text);font-size:15px;font-weight:780;line-height:1.35}} 188 + \\.scope-code{{display:inline-block;width:fit-content;max-width:100%;overflow-wrap:anywhere;border:1px solid color-mix(in srgb,var(--line) 86%,white 6%);border-radius:7px;background:color-mix(in srgb,var(--field) 88%,transparent);padding:3px 7px;font:13px/1.35 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:var(--text)}} 189 + \\.scope-text{{display:block;color:var(--muted);font-size:13px}} 190 + \\.scope-tech{{margin-top:3px;border:0;border-radius:0;background:transparent;overflow:visible}} 191 + \\.scope-tech summary{{padding:0;color:var(--dim);font-size:12px;font-weight:700}} 192 + \\.scope-tech .scope-code{{margin-top:7px}} 193 + \\.scope-expanded{{display:block;width:fit-content;max-width:100%;margin-top:5px;overflow-wrap:anywhere;color:var(--dim);font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}} 194 + \\.inline-code{{display:inline-block;border:1px solid color-mix(in srgb,var(--line) 82%,white 4%);border-radius:5px;background:color-mix(in srgb,var(--field) 76%,transparent);padding:1px 5px;color:var(--text);font:12px/1.35 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}} 195 + \\form{{border-top:1px solid var(--line);padding:0 15px 15px}} 173 196 \\.actions{{display:grid;grid-template-columns:1fr 1fr;gap:12px}} 174 - \\.deny{{background:transparent;color:var(--danger);border:1px solid var(--border)}} 175 - \\.foot{{font-size:13px;margin-top:20px;color:var(--muted)}} 197 + \\.deny{{background:transparent;color:var(--danger);border:1px solid var(--line);box-shadow:none}} 198 + \\.foot{{font-size:12px;margin:16px 0 0;color:var(--dim);overflow-wrap:anywhere}} 199 + \\@media (min-width:680px){{.stack{{grid-template-columns:minmax(0,1fr) minmax(280px,.8fr);align-items:start}}.auth{{grid-column:1 / -1}}}} 200 + \\@media (max-width:520px){{.shell{{padding:18px 14px 26px}}.brand{{margin-bottom:42px}}.actions{{grid-template-columns:1fr}}.deny{{order:2;margin-top:0}}button{{margin-top:18px}}}} 176 201 \\</style> 177 202 \\</head> 178 203 \\<body> 179 204 \\<main class="shell"> 180 - \\<p class="brand">zds auth</p> 181 - \\<h1>continue to {s}</h1> 182 - \\<section class="client"><span class="client-label">application</span><span class="client-name">{s}</span><a class="client-uri" href="{s}">{s}</a></section> 183 - \\<details open><summary>requested access</summary><ul class="scopes">{s}</ul></details> 205 + \\<a class="brand" href="/">zds</a> 206 + \\<section class="hero"> 207 + \\<p class="kicker">oauth authorization</p> 208 + \\<h1>continue</h1> 209 + \\<p class="lede"><strong>{s}</strong> wants to use this account on {s}.</p> 210 + \\</section> 211 + \\<div class="stack"> 212 + \\<section class="panel client"><span class="client-label">application</span><span class="client-name">{s}</span><a class="client-uri" href="{s}">{s}</a></section> 213 + \\<section class="panel auth"> 214 + \\<div class="summary">{s}</div> 215 + \\<details><summary>requested access</summary><ul class="scopes">{s}</ul></details> 184 216 \\<form method="post" action="/oauth/authorize"> 185 217 \\<input type="hidden" name="request_uri" value="{s}{s}"> 186 218 \\<label for="username">account</label> ··· 192 224 \\<button>authorize</button> 193 225 \\</div> 194 226 \\</form> 227 + \\</section> 228 + \\</div> 195 229 \\<p class="foot">{s}</p> 196 230 \\</main> 197 231 \\</body> 198 232 \\</html> 199 233 , .{ 200 234 try htmlEscape(allocator, client.name), 235 + try htmlEscape(allocator, displayHost(issuer)), 201 236 try htmlEscape(allocator, client.name), 202 237 try htmlEscape(allocator, client.uri), 203 238 try htmlEscape(allocator, client.uri), 239 + scopes_summary, 204 240 scopes_html, 205 241 request_uri_prefix, 206 242 oauth_request.request_id, ··· 630 666 if (std.mem.startsWith(u8, scope, "include:")) { 631 667 const permission_set = try permission_sets.resolvePermissionSet(allocator, scope); 632 668 try out.writer.print( 633 - "<li class=\"scope\"><code class=\"scope-code\">{s}</code><span class=\"scope-text\"><strong>{s}</strong><br>{s}</span></li>", 669 + "<li class=\"scope permission-set\"><span class=\"permission-title\">{s}</span><span class=\"scope-text\">{s}</span><details class=\"scope-tech\"><summary>technical scope</summary><code class=\"scope-code\">{s}</code><code class=\"scope-expanded\">{s}</code></details></li>", 634 670 .{ 635 - try htmlEscape(allocator, scope), 636 671 try htmlEscape(allocator, permission_set.title), 637 672 try htmlEscape(allocator, permission_set.detail), 673 + try htmlEscape(allocator, scope), 674 + try htmlEscape(allocator, permission_set.expanded_scope), 638 675 }, 639 676 ); 640 677 continue; 641 678 } 642 679 const display = scopeDisplay(scope); 680 + const scope_note = try scopeDetailHtml(allocator, scope, display.description); 643 681 try out.writer.print( 644 682 "<li class=\"scope\"><code class=\"scope-code\">{s}</code><span class=\"scope-text\">{s}</span></li>", 645 683 .{ 646 684 try htmlEscape(allocator, scope), 647 - try htmlEscape(allocator, display.description), 685 + scope_note, 648 686 }, 649 687 ); 650 688 } ··· 652 690 return out.toOwnedSlice(); 653 691 } 654 692 693 + fn scopeSummary(allocator: std.mem.Allocator, scope_text: []const u8) ![]const u8 { 694 + var has_signin = false; 695 + var has_blob = false; 696 + var has_rpc = false; 697 + var has_account = false; 698 + var has_identity = false; 699 + var permission_titles: std.Io.Writer.Allocating = .init(allocator); 700 + defer permission_titles.deinit(); 701 + var include_count: usize = 0; 702 + var repo_collections: std.Io.Writer.Allocating = .init(allocator); 703 + defer repo_collections.deinit(); 704 + var repo_count: usize = 0; 705 + 706 + var scopes = std.mem.splitScalar(u8, scope_text, ' '); 707 + while (scopes.next()) |scope| { 708 + if (scope.len == 0) continue; 709 + if (std.mem.eql(u8, scope, "atproto")) { 710 + has_signin = true; 711 + } else if (std.mem.startsWith(u8, scope, "include:")) { 712 + const permission_set = try permission_sets.resolvePermissionSet(allocator, scope); 713 + if (include_count < 2) { 714 + if (include_count > 0) try permission_titles.writer.writeAll(", "); 715 + try permission_titles.writer.print("<strong>{s}</strong>", .{try htmlEscape(allocator, permission_set.title)}); 716 + } 717 + include_count += 1; 718 + } else if (std.mem.startsWith(u8, scope, "repo:")) { 719 + if (repoCollection(scope)) |collection| { 720 + if (repo_count < 3) { 721 + if (repo_count > 0) try repo_collections.writer.writeAll(", "); 722 + try repo_collections.writer.writeAll(try htmlEscape(allocator, collection)); 723 + } 724 + repo_count += 1; 725 + } 726 + } else if (std.mem.startsWith(u8, scope, "repo?")) { 727 + if (repoQueryCollection(scope)) |collection| { 728 + if (repo_count < 3) { 729 + if (repo_count > 0) try repo_collections.writer.writeAll(", "); 730 + try repo_collections.writer.writeAll(try htmlEscape(allocator, collection)); 731 + } 732 + repo_count += 1; 733 + } 734 + } else if (std.mem.startsWith(u8, scope, "blob:")) { 735 + has_blob = true; 736 + } else if (std.mem.startsWith(u8, scope, "rpc:")) { 737 + has_rpc = true; 738 + } else if (std.mem.startsWith(u8, scope, "account:")) { 739 + has_account = true; 740 + } else if (std.mem.startsWith(u8, scope, "identity:")) { 741 + has_identity = true; 742 + } 743 + } 744 + 745 + var parts: std.Io.Writer.Allocating = .init(allocator); 746 + defer parts.deinit(); 747 + var count: usize = 0; 748 + try appendSummaryPart(&parts.writer, &count, has_signin, "sign you in"); 749 + if (include_count > 0) { 750 + const suffix = if (include_count > 2) " and more" else ""; 751 + const include_part = try std.fmt.allocPrint(allocator, "grant {s}{s}", .{ permission_titles.written(), suffix }); 752 + try appendSummaryPart(&parts.writer, &count, true, include_part); 753 + } 754 + if (repo_count > 0) { 755 + const suffix = if (repo_count > 3) " and more" else ""; 756 + const repo_part = try std.fmt.allocPrint(allocator, "also access <code>{s}{s}</code>", .{ repo_collections.written(), suffix }); 757 + try appendSummaryPart(&parts.writer, &count, true, repo_part); 758 + } 759 + try appendSummaryPart(&parts.writer, &count, has_blob, "use uploaded blobs"); 760 + try appendSummaryPart(&parts.writer, &count, has_rpc, "call XRPC methods"); 761 + try appendSummaryPart(&parts.writer, &count, has_account, "manage account settings"); 762 + try appendSummaryPart(&parts.writer, &count, has_identity, "manage identity"); 763 + if (count == 0) return allocator.dupe(u8, "<strong>requested access:</strong> sign in as this account."); 764 + return std.fmt.allocPrint(allocator, "<strong>requested access:</strong> {s}.", .{parts.written()}); 765 + } 766 + 767 + fn appendSummaryPart(writer: *std.Io.Writer, count: *usize, enabled: bool, text: []const u8) !void { 768 + if (!enabled) return; 769 + if (count.* > 0) try writer.writeAll(", "); 770 + try writer.writeAll(text); 771 + count.* += 1; 772 + } 773 + 655 774 const ScopeDisplay = struct { 656 775 name: []const u8, 657 776 description: []const u8, ··· 670 789 return .{ .name = "Requested scope", .description = "A scope requested by this OAuth client." }; 671 790 } 672 791 792 + fn scopeDetailHtml(allocator: std.mem.Allocator, scope: []const u8, fallback: []const u8) ![]const u8 { 793 + if (repoCollection(scope)) |collection| { 794 + const action = repoScopeAction(scope); 795 + if (action) |value| { 796 + return std.fmt.allocPrint( 797 + allocator, 798 + "Access <code class=\"inline-code\">{s}</code> records with <code class=\"inline-code\">{s}</code> permission.", 799 + .{ try htmlEscape(allocator, collection), try htmlEscape(allocator, value) }, 800 + ); 801 + } 802 + return std.fmt.allocPrint( 803 + allocator, 804 + "Access <code class=\"inline-code\">{s}</code> records.", 805 + .{try htmlEscape(allocator, collection)}, 806 + ); 807 + } 808 + if (repoQueryCollection(scope)) |collection| { 809 + const action = queryParam(scope, "action"); 810 + if (action) |value| { 811 + return std.fmt.allocPrint( 812 + allocator, 813 + "Access <code class=\"inline-code\">{s}</code> records with <code class=\"inline-code\">{s}</code> permission.", 814 + .{ try htmlEscape(allocator, collection), try htmlEscape(allocator, value) }, 815 + ); 816 + } 817 + return std.fmt.allocPrint( 818 + allocator, 819 + "Access <code class=\"inline-code\">{s}</code> records.", 820 + .{try htmlEscape(allocator, collection)}, 821 + ); 822 + } 823 + return htmlEscape(allocator, fallback); 824 + } 825 + 826 + fn repoCollection(scope: []const u8) ?[]const u8 { 827 + if (!std.mem.startsWith(u8, scope, "repo:")) return null; 828 + const rest = scope["repo:".len..]; 829 + const collection = if (std.mem.indexOfScalar(u8, rest, '?')) |idx| rest[0..idx] else rest; 830 + if (collection.len == 0 or std.mem.eql(u8, collection, "*")) return null; 831 + return collection; 832 + } 833 + 834 + fn repoQueryCollection(scope: []const u8) ?[]const u8 { 835 + if (!std.mem.startsWith(u8, scope, "repo?")) return null; 836 + return queryParam(scope, "collection"); 837 + } 838 + 839 + fn repoScopeAction(scope: []const u8) ?[]const u8 { 840 + if (std.mem.indexOfScalar(u8, scope, '?') == null) return null; 841 + return queryParam(scope, "action"); 842 + } 843 + 844 + fn queryParam(scope: []const u8, name: []const u8) ?[]const u8 { 845 + const query_start = std.mem.indexOfScalar(u8, scope, '?') orelse return null; 846 + var parts = std.mem.splitScalar(u8, scope[query_start + 1 ..], '&'); 847 + while (parts.next()) |part| { 848 + const equals = std.mem.indexOfScalar(u8, part, '=') orelse continue; 849 + if (std.mem.eql(u8, part[0..equals], name)) return part[equals + 1 ..]; 850 + } 851 + return null; 852 + } 853 + 673 854 fn htmlEscape(allocator: std.mem.Allocator, value: []const u8) ![]const u8 { 674 855 var out: std.Io.Writer.Allocating = .init(allocator); 675 856 defer out.deinit(); ··· 682 863 else => try out.writer.writeByte(byte), 683 864 }; 684 865 return out.toOwnedSlice(); 866 + } 867 + 868 + fn displayHost(public_url: []const u8) []const u8 { 869 + var value = public_url; 870 + if (std.mem.startsWith(u8, value, "https://")) value = value["https://".len..]; 871 + if (std.mem.startsWith(u8, value, "http://")) value = value["http://".len..]; 872 + if (std.mem.indexOfScalar(u8, value, '/')) |idx| value = value[0..idx]; 873 + return value; 685 874 } 686 875 687 876 fn pkceMatches(allocator: std.mem.Allocator, verifier: []const u8, challenge: []const u8) !bool {
+38 -14
src/http/landing/assets.zig
··· 11 11 \\:root { 12 12 \\ --bg: #070807; --panel: #101210; --panel-2: #151915; --line: #273026; 13 13 \\ --text: #f0f1ec; --muted: #aaa69b; --dim: #69645c; --accent: #91adff; 14 - \\ --green: #39c178; --pink: #f05f8e; --focus: #c9d7ff; color-scheme: dark; 14 + \\ --green: #39c178; --pink: #f05f8e; --focus: #c9d7ff; --glass: rgba(255,255,255,.085); 15 + \\ color-scheme: dark; 15 16 \\} 16 17 \\@media (prefers-color-scheme: light) { 17 18 \\ :root { 18 19 \\ --bg: #f6f4ed; --panel: #fffdf7; --panel-2: #ece8dd; --line: #d8d0c0; 19 20 \\ --text: #161412; --muted: #625c53; --dim: #8f8679; --accent: #315dcb; 20 - \\ --green: #1b7340; --pink: #b72e5c; --focus: #163f9f; color-scheme: light; 21 + \\ --green: #1b7340; --pink: #b72e5c; --focus: #163f9f; --glass: rgba(255,255,255,.72); color-scheme: light; 21 22 \\ } 22 23 \\} 23 24 \\* { box-sizing: border-box; } ··· 39 40 \\} 40 41 \\.brand { flex: 0 0 auto; display: inline-flex; align-items: center; color: var(--text); text-decoration: none; font-weight: 700; font-size: 14px; letter-spacing: 0; } 41 42 \\.brand:hover { opacity: 0.82; } 42 - \\nav { min-width: 0; display: flex; align-items: center; justify-content: flex-end; gap: 6px; } 43 + \\nav { min-width: 0; display: flex; align-items: center; justify-content: flex-end; gap: 7px; } 43 44 \\nav a, .settings { color: var(--muted); text-decoration: none; background: none; border: 0; padding: 0; font: inherit; cursor: pointer; } 44 45 \\nav a:hover, .settings:hover { color: var(--text); } 45 46 \\.icon-link, .settings { 46 - \\ position: relative; width: 32px; height: 32px; display: inline-grid; place-items: center; border: 1px solid color-mix(in srgb, var(--line) 78%, white 8%); 47 - \\ border-radius: 9px; background: 48 - \\ linear-gradient(145deg, rgba(255,255,255,.15), rgba(255,255,255,.025)), 49 - \\ color-mix(in srgb, var(--panel) 68%, transparent); 50 - \\ box-shadow: inset 0 1px 0 rgba(255,255,255,.16), 0 8px 22px rgba(0,0,0,.16); 47 + \\ position: relative; width: 34px; height: 34px; display: inline-grid; place-items: center; border: 1px solid color-mix(in srgb, var(--line) 74%, white 10%); 48 + \\ border-radius: 12px; background: 49 + \\ radial-gradient(circle at 30% 18%, rgba(255,255,255,.22), transparent 32%), 50 + \\ linear-gradient(145deg, rgba(255,255,255,.13), rgba(255,255,255,.03)), 51 + \\ color-mix(in srgb, var(--panel) 72%, transparent); 52 + \\ box-shadow: inset 0 1px 0 rgba(255,255,255,.18), 0 10px 28px rgba(0,0,0,.18); 51 53 \\ transition: color 0.12s, border-color 0.12s, background 0.12s, transform 0.12s; 52 54 \\} 53 - \\.icon-link img { position: relative; z-index: 1; width: 20px; height: 20px; object-fit: contain; display: block; filter: drop-shadow(0 1px 2px rgba(0,0,0,.45)); } 55 + \\.logo-link::after { 56 + \\ content: ""; position: absolute; inset: 5px; border-radius: 9px; 57 + \\ background: color-mix(in srgb, var(--glass) 78%, var(--panel)); 58 + \\ box-shadow: inset 0 1px 0 rgba(255,255,255,.18); 59 + \\} 60 + \\.icon-link img { position: relative; z-index: 1; width: 22px; height: 22px; object-fit: contain; display: block; filter: drop-shadow(0 1px 2px rgba(0,0,0,.45)); } 54 61 \\.icon-link svg, .settings svg { width: 17px; height: 17px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; } 55 62 \\.icon-link:hover, .settings:hover { border-color: color-mix(in srgb, var(--green) 55%, var(--line)); background: color-mix(in srgb, var(--panel-2) 86%, transparent); transform: translateY(-1px); } 56 - \\.settings { 57 - \\ flex: 0 0 auto; 63 + \\.text-link { 64 + \\ min-height: 30px; display: inline-flex; align-items: center; padding: 0 2px; 65 + \\ color: var(--dim); font-size: 12px; text-decoration: none; 58 66 \\} 67 + \\.text-link:hover { color: var(--green); } 68 + \\.nav-divider { width: 1px; height: 22px; background: color-mix(in srgb, var(--line) 70%, transparent); margin: 0 2px; } 69 + \\.settings { flex: 0 0 auto; margin-left: 2px; } 59 70 \\.hero { margin: 52px 0 24px; } 60 71 \\h1 { margin: 0; color: var(--text); font-size: clamp(36px, 11vw, 74px); line-height: 0.95; letter-spacing: 0; } 61 72 \\.lede { margin: 14px 0 0; max-width: 47ch; color: var(--muted); font-size: 15px; } ··· 114 125 \\.sheet { position: fixed; inset: 0; z-index: 20; display: none; align-items: flex-end; justify-content: center; padding: 16px; background: rgba(0, 0, 0, 0.42); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); } 115 126 \\.sheet.open { display: flex; } 116 127 \\.sheet-card { width: min(100%, 440px); border: 1px solid color-mix(in srgb, var(--line) 80%, white 8%); border-radius: 18px; background: 128 + \\ radial-gradient(circle at 18% 0%, color-mix(in srgb, var(--green) 16%, transparent), transparent 34%), 117 129 \\ linear-gradient(145deg, rgba(255,255,255,.13), rgba(255,255,255,.035)), 118 130 \\ color-mix(in srgb, var(--bg) 90%, transparent); 119 131 \\ box-shadow: inset 0 1px 0 rgba(255,255,255,.15), 0 24px 80px rgba(0, 0, 0, 0.42); overflow: hidden; backdrop-filter: blur(22px) saturate(1.16); -webkit-backdrop-filter: blur(22px) saturate(1.16); } ··· 125 137 \\.hint { margin: 0 0 12px; color: var(--dim); font-size: 11px; } 126 138 \\.client-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(92px, 1fr)); gap: 9px; } 127 139 \\.client { display: grid; justify-items: center; gap: 8px; min-height: 82px; padding: 11px 8px 10px; border: 1px solid color-mix(in srgb, var(--line) 82%, white 6%); border-radius: 13px; background: 128 - \\ linear-gradient(145deg, rgba(255,255,255,.12), rgba(255,255,255,.025)), 140 + \\ radial-gradient(circle at 30% 18%, rgba(255,255,255,.14), transparent 34%), 141 + \\ linear-gradient(145deg, rgba(255,255,255,.10), rgba(255,255,255,.025)), 129 142 \\ color-mix(in srgb, var(--panel) 78%, transparent); 130 143 \\ color: var(--muted); font: inherit; font-size: 10px; cursor: pointer; box-shadow: inset 0 1px 0 rgba(255,255,255,.12); } 131 144 \\.client:hover, .client.active { color: var(--text); border-color: color-mix(in srgb, var(--green) 65%, var(--line)); } 132 145 \\.client.active { background: 133 146 \\ linear-gradient(145deg, color-mix(in srgb, var(--green) 20%, transparent), color-mix(in srgb, var(--accent) 8%, transparent)), 134 147 \\ color-mix(in srgb, var(--panel) 82%, transparent); } 135 - \\.client-icon { width: 34px; height: 34px; display: grid; place-items: center; border-radius: 11px; background: color-mix(in srgb, white 82%, var(--panel) 18%); box-shadow: inset 0 1px 0 rgba(255,255,255,.45), 0 6px 18px rgba(0,0,0,.16); } 136 - \\.client img { width: 24px; height: 24px; border-radius: 6px; object-fit: contain; } 148 + \\.client-icon { width: 36px; height: 36px; display: grid; place-items: center; border-radius: 12px; background: color-mix(in srgb, var(--glass) 78%, var(--panel)); box-shadow: inset 0 1px 0 rgba(255,255,255,.28), 0 6px 18px rgba(0,0,0,.16); } 149 + \\.client img { width: 25px; height: 25px; object-fit: contain; filter: drop-shadow(0 1px 2px rgba(0,0,0,.28)); } 150 + \\.client span:last-child { white-space: nowrap; } 137 151 \\@media (min-width: 680px) { 138 152 \\ .shell { padding-top: 24px; } 139 153 \\ header { margin-top: -24px; } ··· 143 157 \\ .resident a { grid-template-columns: 42px minmax(0, 1fr) auto; align-items: center; } 144 158 \\ .meta { grid-column: auto; justify-content: flex-end; } 145 159 \\ .sheet { align-items: center; } 160 + \\} 161 + \\@media (max-width: 430px) { 162 + \\ .shell { padding-left: 14px; padding-right: 14px; } 163 + \\ header { margin-left: -14px; margin-right: -14px; padding-left: 14px; padding-right: 14px; gap: 8px; } 164 + \\ nav { gap: 5px; } 165 + \\ .icon-link, .settings { width: 32px; height: 32px; border-radius: 10px; } 166 + \\ .logo-link::after { inset: 5px; border-radius: 8px; } 167 + \\ .icon-link img { width: 20px; height: 20px; } 168 + \\ .text-link { display: none; } 169 + \\ .nav-divider { display: none; } 146 170 \\} 147 171 ; 148 172
+6 -5
src/http/landing/mod.zig
··· 101 101 \\ <header> 102 102 \\ <a class="brand" href="/" aria-label="zds home">zds</a> 103 103 \\ <nav aria-label="project and server links"> 104 - \\ <a class="icon-link" href="https://tangled.org/zat.dev/zds" aria-label="source on Tangled" title="source"><img alt="" src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg"></a> 105 - \\ <a class="icon-link" href="https://zat.dev" aria-label="zat.dev" title="zat.dev"><img alt="" src="https://zat.dev/favicon.svg"></a> 106 - \\ <a class="icon-link" href="https://atproto.com" aria-label="AT Protocol" title="atproto"><img alt="" src="https://atproto.com/favicon.ico"></a> 107 - \\ <a class="icon-link" href="/xrpc/_health" aria-label="health" title="health"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M4 12h4l2-5 4 10 2-5h4"/></svg></a> 108 - \\ <a class="icon-link" href="/xrpc/com.atproto.server.describeServer" aria-label="server description" title="server"><svg aria-hidden="true" viewBox="0 0 24 24"><ellipse cx="12" cy="5" rx="7" ry="3"/><path d="M5 5v7c0 1.7 3.1 3 7 3s7-1.3 7-3V5"/><path d="M5 12v7c0 1.7 3.1 3 7 3s7-1.3 7-3v-7"/></svg></a> 104 + \\ <a class="icon-link logo-link" href="https://tangled.org/zat.dev/zds" aria-label="source on Tangled" title="source"><img alt="" src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg"></a> 105 + \\ <a class="icon-link logo-link" href="https://zat.dev" aria-label="zat.dev" title="zat.dev"><img alt="" src="https://zat.dev/favicon.svg"></a> 106 + \\ <a class="icon-link logo-link" href="https://atproto.com" aria-label="AT Protocol" title="atproto"><img alt="" src="https://atproto.com/favicon.ico"></a> 107 + \\ <span class="nav-divider" aria-hidden="true"></span> 108 + \\ <a class="text-link" href="/xrpc/_health" aria-label="health endpoint" title="health">health</a> 109 + \\ <a class="text-link" href="/xrpc/com.atproto.server.describeServer" aria-label="server description" title="describeServer">server</a> 109 110 \\ <button class="settings" type="button" id="settings" aria-label="profile client settings" title="profile client"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M4 7h9M17 7h3M4 17h3M11 17h9"/><circle cx="15" cy="7" r="2"/><circle cx="9" cy="17" r="2"/></svg></button> 110 111 \\ </nav> 111 112 \\ </header>