web: a prerendered page shows a signed-in reader their corner, new and their own repo's settings from the first paint, before the app hydrates
Signed-off-by: dawn <dawn@tangled.org>
235 unmodified linesExpand all236237238239240241242243244245246247248249 unmodified linesExpand all235 unmodified linesExpand all color: var(--y-muted); }}
/* an owner's controls on a prerendered page, until the head script finds its reader is the owner */[data-owner] { display: none;}
/* an icon in a line of text sits on the letters' middle: its bottom on the baseline, then down by half its height less half an x-height. outside the layers and important, so a row's own items-center doesn't undo it. an icon alone in a box (anything inside .ic-box, like an icon-only button) stays centred */:has(> [data-icon]):not(.ic-box, .ic-box *) {249 unmodified linesExpand all4 unmodified lines5678991011121317 unmodified lines31323334353536373839116 unmodified linesExpand all4 unmodified lines import { resolve } from "$app/paths"; import type { Did } from "@atcute/lexicons/syntax"; import { AuthorizationError } from "@atcute/oauth-browser-client"; import { createAuth, resumeSignIn, AUTH_KEY } from "#lib/auth.svelte.ts"; import { signedInTag } from "#lib/auth/accounts.ts"; import { forgetSignedIn, signedInTag } from "#lib/auth/accounts.ts"; import { callbackPath } from "#lib/oauth-client-metadata.ts"; import { timeAgoTag } from "#lib/timeAgoScript.ts"; import AccountCorner, { SHELL_ACCOUNT } from "#lib/components/shell/AccountCorner.svelte"; import AttestationBanner from "#lib/components/attestations/AttestationBanner.svelte";17 unmodified lines afterNavigate(endNavigation);
// once nobody is signed in, the room kept for them goes $effect(() => { if (!auth.currentDid) delete document.documentElement.dataset.signedIn; if (!auth.currentDid) forgetSignedIn(); });
let mounted = $state(false); onMount(() => {116 unmodified linesExpand all1234567888 unmodified lines979899100991001011011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351 unmodified lineimport { browser } from "$app/env";import { isDid, type Did } from "@atcute/lexicons/syntax";import { avatarUrl, avatarWidthFor } from "#lib/avatar.ts";import { avatarSizePx } from "#lib/components/ui/Avatar.svelte";
// atcute owns oauth sessions; this stores metadata/order and active-account cookies.
export const CURRENT_DID_KEY = "tangled.currentDid";88 unmodified lines document.cookie = `${CURRENT_DID_KEY}=; path=/; max-age=0; samesite=lax`; document.cookie = `${CURRENT_HANDLE_KEY}=; path=/; max-age=0; samesite=lax`;};
// runs in the head before the first paint: a prerendered page can't know who reads it, so// this marks the root for a signed-in reader and the page keeps room for what they'll seeexport const signedInTag = `<script>document.cookie.split("; ").some((c) => c.startsWith("${CURRENT_DID_KEY}=")) && (document.documentElement.dataset.signedIn = "")</script>`;// runs in the head before the first paint: a prerendered page can't know who reads it, so this// marks the root for a signed-in reader, hands the corner their handle and avatar, and on their// own repos shows what carries data-owner and hides what carries data-visitor. the page keeps room for all of itexport const signedInTag = `<script>(() => { const read = (key) => { const pair = document.cookie.split("; ").find((c) => c.startsWith(key + "=")); return pair && decodeURIComponent(pair.slice(key.length + 1)); }; const did = read("${CURRENT_DID_KEY}"); if (!did) return; const root = document.documentElement; root.dataset.signedIn = ""; root.style.setProperty("--signed-handle", JSON.stringify(read("${CURRENT_HANDLE_KEY}") || did)); root.style.setProperty("--signed-avatar", 'url("' + "${avatarUrl("DID", avatarWidthFor(avatarSizePx.regular))}".replace("DID", encodeURIComponent(did)) + '")'); const owner = document.createElement("style"); owner.id = "signed-owner"; owner.textContent = "[data-owner=" + JSON.stringify(did) + "] { display: var(--owner-display, flex) !important; }" + "[data-visitor=" + JSON.stringify(did) + "] { display: none !important; }"; document.head.append(owner);})()</script>`;
// what signedInTag set, gone once nobody is signed inexport const forgetSignedIn = () => { const root = document.documentElement; delete root.dataset.signedIn; root.style.removeProperty("--signed-handle"); root.style.removeProperty("--signed-avatar"); document.getElementById("signed-owner")?.remove();};
export const readActiveDid = (): Did | null => { if (!browser) return null; const value = localStorage.getItem(CURRENT_DID_KEY); return isDid(value) ? value : null;1 unmodified line149 unmodified linesExpand all150151152153154154155149 unmodified linesExpand all [40.5506, 333, 46.6519, 1, 0, true, 4, false] ] } }, "_hash": "ede354b5113494c883666798d6499a93" "_hash": "f52b7dfb589942a3edc35c00b4959ef7"}43 unmodified lines4445464748484950515253545556575821 unmodified lines808182838485868788120 unmodified linesExpand all43 unmodified lines : []), ...(repo.spindle ? [{ id: "pipelines", label: "pipelines", href: `${base}/pipelines` }] : []), ...(access.canPush ? [{ id: "settings", label: "settings", href: `${base}/settings` }] : []) // without push access the tab is still drawn for the owner, the head script shows it to them { id: "settings", label: "settings", href: `${base}/settings`, owner: access.canPush ? undefined : repo.ownerDid } ]);
const showCurrent = (strip: HTMLElement) => { void active;21 unmodified lines {#each tabs as tab (tab.id)} <a class="y-seg" class:y-seg-a={active === tab.id && !extra} data-owner={"owner" in tab ? tab.owner : undefined} href={resolve(tab.href as "/")} aria-current={active === tab.id && !extra ? "page" : undefined} >{tab.label}{#if tab.count} <Pending> {#snippet skeleton()}<span class="n">…</span>{/snippet}120 unmodified linesExpand all19 unmodified lines2021222324242526272829303132333419 unmodified lines const shell = getContext<ShellAccount>(SHELL_ACCOUNT); const auth = getAuth();</script>
<!-- no viewer when prerendered, the account waits for mount --><!-- no viewer when prerendered: until mount the corner holds both, the head script picks one --><Topbar variant="corner" user={shell.mounted ? auth.currentUser : null} accounts={auth.accounts} loginHref={`/login?return_url=${encodeURIComponent(page.url.pathname)}`} loading={shell.mounted && auth.resolving} guess={!shell.mounted} onSignOut={shell.signOut} onSwitchAccount={shell.switchAccount}/>67 unmodified lines686970717273747576771 unmodified line798081828183848586878827 unmodified lines11611711811911711812012112212312412512612712812934 unmodified lines16416516616716116216316416516616716816816917017117265 unmodified lines2382392402412422432442452462472482492432502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812826 unmodified lines28929029129229329429529629729829930030130230330430530630730830931048 unmodified lines67 unmodified lines user: CurrentUser | null; accounts?: readonly AuthAccount[]; loginHref?: string; loading?: boolean; // prerendered and not mounted yet: draw both the account and the sign in link guess?: boolean; }
let { variant,1 unmodified line onSwitchAccount, user, accounts = [], loginHref = "/login", loading = false loading = false, guess = false }: Props = $props();
const appPath = (path: string) => resolve(path as "/");
27 unmodified lines </div> {/if}
<div class={style.actions()}> {#if user || loading} <div class={style.action({ class: "order-1 md:order-3" })}> {#if user || loading || guess} <div class={style.action({ class: ["order-1 md:order-3", !user && !loading && guess && "guess-in"] })} > {#snippet newItems()} <DropdownItem href={resolve("/repo/new")} icon={BookBookmark} >new repository</DropdownItem >34 unmodified lines {/if} {/if}
<div class={style.action({ class: "end order-4 md:order-5" })}> {#if loading} <div class={style.identity()} role="status" aria-label="signing in"> <span class={style.ball()}> <Spinner class="size-4" /> </span> <span class={style.bar()} aria-hidden="true"></span> </div> {:else if user} {#if user} {#snippet profileItems(current: CurrentUser)} {@const actor = profileIdentifier( isHandle(current.handle) ? current.handle : undefined, current.did65 unmodified lines {/snippet} {@render profileItems(user)} </Dropdown> {/if} {:else if loading} <div class={style.identity()} role="status" aria-label="signing in"> <span class={style.ball()}> <Spinner class="size-4" /> </span> <span class={style.bar()} aria-hidden="true"></span> </div> {:else if variant === "corner"} <a class="signin" data-testid="login" href={appPath(loginHref)}>sign in</a> {#if guess} <!-- the account's own markup, its handle and avatar from the head script --> <span class="guess guess-in contents"> <Dropdown id="profile-menu" group="topbar" align="right" buttonVariant="ghost" triggerClass="account" variant="button" size="small" > {#snippet trigger()} <span class="inline-flex min-w-0 items-center gap-1.5"> <Avatar class="guess-avatar size-4.25" /> <span class="guess-handle hidden max-w-56 min-w-0 truncate typography-paragraph-regular md:inline" ></span> </span> {/snippet} <DropdownItem href={appPath(loginHref)}>sign in</DropdownItem> </Dropdown> </span> {/if} <a class={["signin", guess && "guess-out"]} data-testid="login" href={appPath(loginHref)}>sign in</a > {:else} <Button data-testid="login" variant="ghost"6 unmodified lines </div></nav>
<style> /* before mount the corner holds both, the head script's mark picks one */ :global(:root:not([data-signed-in])) .corner :global(.guess-in), :global(:root[data-signed-in]) .corner :global(.guess-out) { display: none !important; } .guess-handle::before { content: var(--signed-handle); } .guess :global(.guess-avatar) { background: var(--signed-avatar) center / cover; } .guess :global(.guess-avatar > *) { visibility: hidden; } .corner { display: inline-flex; border: 1px solid var(--y-border); background: var(--y-card);48 unmodified lines16 unmodified lines1718192021222324252627282930313233343536212223242526272829303132333435363738394037 unmodified lines16 unmodified lines</script>
<div class="empty"> <Window title="no pipeline runs yet" bodyClass="steps"> {#if isOwner} <ol> <li><span>choose a spindle in <a href={settingsHref}>settings</a></span></li> <li> <span >add a workflow in <a href="https://docs.tangled.org/spindles/workflows/#pipelines" target="_blank" rel="noopener"><code>.tangled/workflows</code></a ></span > </li> <li><span>push</span></li> </ol> {:else} <p>runs show up here once the repo has a workflow.</p> <!-- drawn for every reader, a prerender can't tell the owner: the head script picks --> <ol data-owner={isOwner ? undefined : ownerDid} style:--owner-display="block"> <li><span>choose a spindle in <a href={settingsHref}>settings</a></span></li> <li> <span >add a workflow in <a href="https://docs.tangled.org/spindles/workflows/#pipelines" target="_blank" rel="noopener"><code>.tangled/workflows</code></a ></span > </li> <li><span>push</span></li> </ol> {#if !isOwner} <p data-visitor={ownerDid}>runs show up here once the repo has a workflow.</p> {/if} </Window></div>
37 unmodified lines