diff --git a/apps/main-app/public/editor/queries.ts b/apps/main-app/public/editor/queries.ts
index e93fa4f..0b21cea 100644
--- a/apps/main-app/public/editor/queries.ts
+++ b/apps/main-app/public/editor/queries.ts
@@ -1,6 +1,7 @@
import { QueryClient, type QueryKey, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { ApiError, api, errorText } from './api'
import { newestFirst, type Site, toPrivateSite, toPublicSite, toWebhook } from './model'
+import { rememberAccount } from './remembered-accounts'
import { notify } from './store'
export const queryClient = new QueryClient({
@@ -28,7 +29,12 @@ export const keys = {
} as const
export const useUser = () =>
- useQuery({ queryKey: keys.user, queryFn: api.user, retry: false, staleTime: Number.POSITIVE_INFINITY })
+ useQuery({
+ queryKey: keys.user,
+ queryFn: () => api.user().then(rememberAccount),
+ retry: false,
+ staleTime: Number.POSITIVE_INFINITY,
+ })
async function fetchSites(): Promise {
// Private sites are an extra; a failure there must not hide the public list.
diff --git a/apps/main-app/public/editor/remembered-accounts.ts b/apps/main-app/public/editor/remembered-accounts.ts
new file mode 100644
index 0000000..8daf598
--- /dev/null
+++ b/apps/main-app/public/editor/remembered-accounts.ts
@@ -0,0 +1,32 @@
+import type { UserInfo } from './api'
+
+/**
+ * Accounts that signed in on this device, newest first, for the landing
+ * page's account picker. Shared with landingpage.html by key and shape.
+ */
+const KEY = 'wisp-accounts'
+const LIMIT = 5
+
+interface RememberedAccount {
+ did: string
+ handle: string
+}
+
+const read = (): RememberedAccount[] => {
+ try {
+ const parsed: unknown = JSON.parse(localStorage.getItem(KEY) ?? '[]')
+ return Array.isArray(parsed) ? parsed : []
+ } catch {
+ return []
+ }
+}
+
+/** Remember the signed-in account; storage failures only cost the shortcut. */
+export const rememberAccount = (user: UserInfo): UserInfo => {
+ if (user.handle === 'unknown') return user
+ const accounts = [{ did: user.did, handle: user.handle }, ...read().filter((a) => a.did !== user.did)]
+ try {
+ localStorage.setItem(KEY, JSON.stringify(accounts.slice(0, LIMIT)))
+ } catch {}
+ return user
+}
diff --git a/apps/main-app/public/landingpage.html b/apps/main-app/public/landingpage.html
index 46ab6b1..9c826ef 100644
--- a/apps/main-app/public/landingpage.html
+++ b/apps/main-app/public/landingpage.html
@@ -175,6 +175,8 @@
}
main, header, footer { position: relative; z-index: 1; }
+ /* the tilted marquee is wider than a phone; without this, mobile browsers widen the layout viewport */
+ main { overflow-x: clip; }
header {
background: color-mix(in oklch, var(--paper) 82%, transparent);
@@ -703,6 +705,164 @@
.sticky .big { font-family: var(--hand); font-size: 2.1rem; font-weight: 700; line-height: 1; margin-bottom: 0.6rem; display: block; }
.sticky p { font-size: 0.95rem; line-height: 1.55; margin: 0; color: oklch(0.26 0.031 352); font-weight: 500; }
+ /* ------------------------------------------------------------------
+ Sign-in / sign-up dialog
+ ------------------------------------------------------------------ */
+ .door-dialog {
+ margin: auto;
+ width: min(40rem, calc(100vw - 2rem));
+ max-height: calc(100dvh - 2rem);
+ padding: 1.6rem;
+ border: 3px solid var(--line);
+ border-radius: 22px;
+ background: var(--paper-2);
+ color: var(--ink);
+ box-shadow: 6px 6px 0 var(--shadow);
+ overflow: auto;
+ }
+ .door-dialog [tabindex="-1"] { outline: none; }
+ .door-dialog :focus-visible:not(input, [tabindex="-1"]) { outline: 2px solid var(--rose); outline-offset: 2px; }
+ .door-dialog.narrow { width: min(27rem, calc(100vw - 2rem)); background: var(--paper); }
+ .door-dialog[open] { animation: door-pop 280ms cubic-bezier(.34,1.56,.64,1) both; }
+ .door-dialog::backdrop { background: color-mix(in oklch, oklch(0.26 0.031 352) 30%, transparent); backdrop-filter: blur(3px); }
+ @keyframes door-pop { from { opacity: 0; scale: 0.94; } }
+ .door-dialog .panel { animation: door-panel 320ms cubic-bezier(0.22, 1, 0.36, 1) both; }
+ .door-dialog .panel.from-back { animation-name: door-panel-back; }
+ @keyframes door-panel { from { opacity: 0; transform: translateX(14px); } }
+ @keyframes door-panel-back { from { opacity: 0; transform: translateX(-14px); } }
+
+ .door-close {
+ position: absolute; top: 0.7rem; right: 0.7rem; z-index: 2;
+ width: 2rem; height: 2rem; padding: 0;
+ display: grid; place-items: center;
+ border: 2px solid var(--line); border-radius: 50%;
+ background: var(--card); color: var(--ink); cursor: pointer; font-size: 1rem; line-height: 1;
+ }
+ .door-back {
+ display: inline-flex; align-items: center; gap: 0.3rem;
+ padding: 0.2rem 0.1rem; border: 0; background: none; cursor: pointer;
+ color: var(--ink-soft); font: inherit; font-size: 0.9rem; font-weight: 600;
+ }
+ .door-back:hover { color: var(--ink); }
+ .door-link {
+ padding: 0; border: 0; background: none; cursor: pointer; font: inherit; font-weight: 600;
+ color: var(--rose); text-decoration: underline; text-decoration-thickness: 1.5px; text-underline-offset: 3px;
+ }
+ .door-title {
+ font-family: var(--display); font-variation-settings: "SOFT" 100, "WONK" 1;
+ font-size: 1.75rem; font-weight: 700; letter-spacing: -0.02em; line-height: 1.1; margin: 0.2rem 0 0.6rem;
+ }
+ .door-heading { text-align: center; padding: 0 2rem; }
+ .door-heading .door-title { font-size: clamp(1.6rem, 4vw, 2rem); }
+ .door-dialog .kicker { font-size: 1.45rem; margin: 0; }
+ .door-body { color: var(--ink-soft); font-size: 0.95rem; margin: 0 0 1.1rem; }
+ .door-fine { color: var(--ink-soft); font-size: 0.8rem; margin: 0.8rem 0 0; }
+ .door-center { text-align: center; }
+ .door-stack { display: grid; gap: 0.6rem; }
+ .door-or {
+ display: flex; align-items: center; gap: 0.75rem; margin: 1rem 0;
+ color: var(--ink-soft); font-family: var(--hand); font-size: 1.25rem;
+ }
+ .door-or::before, .door-or::after { content: ""; flex: 1; border-top: 2px dashed var(--border-light); }
+
+ .doors { display: grid; grid-template-columns: 1fr 1fr; gap: 1.2rem; margin: 1.2rem 0 0.4rem; }
+ .door {
+ display: flex; flex-direction: column; align-items: center; min-height: 11rem; padding: 1.1rem 1.1rem 1rem;
+ text-decoration: none;
+ text-align: left; font: inherit; cursor: pointer; color: oklch(0.26 0.031 352);
+ background: var(--c); border: 2.5px solid var(--line); border-radius: 4px 4px 18px 4px;
+ box-shadow: 4px 5px 0 var(--shadow); rotate: var(--r);
+ transition: rotate 300ms cubic-bezier(.34,1.56,.64,1), transform 300ms cubic-bezier(.34,1.56,.64,1), box-shadow 200ms ease;
+ }
+ .door:hover, .door:focus-visible { rotate: 0deg; transform: translateY(-4px); box-shadow: 6px 8px 0 var(--shadow); }
+ .door .tape {
+ width: 4.5rem; height: 1.1rem; margin: -1.6rem auto 0.5rem; rotate: -3deg;
+ background: color-mix(in oklch, var(--paper) 65%, transparent);
+ border: 1px dashed oklch(0.26 0.031 352 / 0.3);
+ }
+ .door .big { font-family: var(--hand); font-size: 2.1rem; font-weight: 700; line-height: 1; margin-bottom: 0.5rem; }
+ .door p { margin: 0 0 1rem; font-size: 0.95rem; color: oklch(0.32 0.04 352); text-align: center; }
+ .door .go { margin-top: auto; font-weight: 800; font-size: 0.95rem; }
+
+ .door-info {
+ position: relative; display: inline-grid; place-items: center;
+ width: 1.05rem; height: 1.05rem; margin-left: 0.2rem; vertical-align: 0.1em;
+ border: 1.5px solid currentColor; border-radius: 50%;
+ font-family: var(--display); font-style: italic; font-weight: 700; font-size: 0.68rem; line-height: 1;
+ cursor: help;
+ }
+ .door-info::after {
+ content: attr(data-tip);
+ position: absolute; bottom: calc(100% + 0.45rem); left: 50%; translate: -50% 4px; z-index: 3;
+ width: max-content; max-width: 14rem; padding: 0.4rem 0.6rem; white-space: normal; text-align: center;
+ border: 2px solid var(--line); border-radius: 10px; background: var(--card); color: var(--ink);
+ box-shadow: 2px 2px 0 var(--shadow);
+ font-family: var(--hand); font-style: normal; font-weight: 500; font-size: 1.1rem; line-height: 1.15;
+ opacity: 0; pointer-events: none; transition: opacity 150ms ease, translate 150ms ease;
+ }
+ .door-info.end::after { left: auto; right: -0.5rem; translate: 0 4px; }
+ .door-info.end:hover::after, .door-info.end:focus::after { translate: 0 0; }
+ .door-dialog .nowrap { white-space: nowrap; }
+ .door-info:hover::after, .door-info:focus-visible::after, .door-info:focus::after { opacity: 1; translate: -50% 0; }
+ .door-dialog .btn-pop.block { width: 100%; justify-content: center; }
+ .row-card {
+ position: relative; display: flex; align-items: center; gap: 0.8rem; width: 100%; padding: 0.7rem 0.8rem;
+ text-align: left; font: inherit; color: var(--ink); cursor: pointer;
+ border: 2px solid var(--line); border-radius: 14px; background: var(--card); box-shadow: 2px 2px 0 var(--shadow);
+ transition: transform 160ms ease, box-shadow 160ms ease;
+ }
+ .row-card:hover { transform: translate(-1px, -1px); box-shadow: 4px 4px 0 var(--shadow); }
+ .row-card.ghost { border-style: dashed; background: transparent; box-shadow: none; }
+ .row-avatar {
+ flex: none; display: grid; place-items: center; width: 2.4rem; height: 2.4rem;
+ border: 2px solid var(--line); border-radius: 50%; background: var(--c, var(--pink));
+ color: oklch(0.26 0.031 352); font-family: var(--display); font-weight: 800;
+ }
+ .row-main { flex: 1; min-width: 0; display: grid; }
+ .row-main b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+ .row-forget {
+ flex: none; width: 1.7rem; height: 1.7rem; padding: 0; border: 0; border-radius: 50%;
+ background: transparent; color: var(--ink-soft); cursor: pointer;
+ }
+ .row-forget:hover { background: var(--paper-2); color: var(--ink); }
+ .saved-row { display: flex; align-items: center; gap: 0.3rem; }
+ .saved-row .row-card { flex: 1; }
+
+ .door-label { display: block; margin-bottom: 0.35rem; font-weight: 700; font-size: 0.9rem; }
+ .door-field {
+ display: flex; align-items: center; border: 2px solid var(--line); border-radius: 14px; background: var(--card);
+ box-shadow: inset 2px 2px 0 color-mix(in oklch, var(--shadow) 45%, transparent);
+ }
+ .door-field:focus-within { box-shadow: 0 0 0 3px color-mix(in oklch, var(--pink) 70%, transparent); }
+ .door-field .at { padding-left: 0.85rem; color: var(--ink-soft); font-weight: 600; }
+ .door-field input {
+ flex: 1; min-width: 0; padding: 0.75rem 0.85rem 0.75rem 0.25rem;
+ border: 0; outline: none; background: transparent; color: var(--ink); font: inherit; font-size: 1rem;
+ }
+ .door-field.bad { border-color: oklch(0.55 0.18 25); }
+ .door-error {
+ margin: 0.6rem 0 0; padding: 0.55rem 0.75rem; border-radius: 12px;
+ background: oklch(0.95 0.04 25); color: oklch(0.50 0.18 25); font-size: 0.85rem; font-weight: 600;
+ }
+ :root[data-theme="dark"] .door-error { background: oklch(0.30 0.06 25); color: oklch(0.82 0.11 25); }
+ @media (prefers-color-scheme: dark) {
+ :root:not([data-theme="light"]) .door-error { background: oklch(0.30 0.06 25); color: oklch(0.82 0.11 25); }
+ }
+
+ .door-going { display: grid; justify-items: center; padding: 1.5rem 0.5rem 1rem; text-align: center; }
+ .door-going img { width: 92px; animation: going-bob 1.6s ease-in-out infinite; }
+ @keyframes going-bob { 50% { transform: translateY(-8px) rotate(-6deg); } }
+ .door-going .host { padding: 0.15rem 0.55rem; border-radius: 6px; background: var(--paper-2); font-family: "JetBrains Mono", ui-monospace, monospace; font-size: 0.85em; }
+
+ @media (max-width: 560px) {
+ .doors { grid-template-columns: 1fr; }
+ .door { min-height: 0; }
+ }
+ @media (prefers-reduced-motion: reduce) {
+ .door-dialog[open], .door-dialog .panel, .door-going img { animation: none; }
+ .door { transition: none; }
+ }
+
/* ------------------------------------------------------------------
CLI
------------------------------------------------------------------ */
@@ -837,7 +997,7 @@
Blog
Status
- Sign in ✦
+ Sign in ✦
@@ -854,7 +1014,7 @@
A folder becomes a website.
✿ free, no card, run by one person for fun
@@ -896,7 +1056,7 @@
@@ -998,6 +1158,68 @@
+
+