···20442044 padding: 1.5rem;
20452045 border-radius: 24px;
20462046}
20472047-/**
20482048- * When the project has a `mainLink`, the hero is rendered as an
20492049- * anchor — visitors can tap anywhere on the panel to jump straight
20502050- * to the project. The diagonal arrow in the top-right corner sits
20512051- * in a soft glass disc so it reads as a real button affordance, and
20522052- * the whole hero lifts a touch on hover the same way the listing
20532053- * cards do. Padding-right reserves space so long names don't slide
20542054- * under the disc.
20552055- */
20562056-a.profile-hero {
20572057- text-decoration: none;
20582058- color: inherit;
20592059-}
20602060-.profile-hero-button {
20612061- position: relative;
20622062- padding-right: 3.4rem;
20632063- cursor: pointer;
20642064- transition: transform 0.18s ease, box-shadow 0.18s ease;
20652065-}
20662066-.profile-hero-button:hover,
20672067-.profile-hero-button:focus-visible {
20682068- transform: translateY(-2px);
20692069- box-shadow: 0 14px 36px rgba(48, 70, 128, 0.14);
20702070-}
20712071-.profile-hero-arrow {
20722072- position: absolute;
20732073- top: 1rem;
20742074- right: 1rem;
20752075- display: inline-flex;
20762076- align-items: center;
20772077- justify-content: center;
20782078- width: 2.1rem;
20792079- height: 2.1rem;
20802080- border-radius: 999px;
20812081- background: rgba(255, 255, 255, 0.6);
20822082- border: 1px solid rgba(37, 74, 158, 0.2);
20832083- color: #254a9e;
20842084- box-shadow: 0 2px 6px rgba(48, 70, 128, 0.1);
20852085- transition:
20862086- transform 0.18s ease,
20872087- background 0.18s ease,
20882088- box-shadow 0.18s ease,
20892089- color 0.18s ease,
20902090- border-color 0.18s ease;
20912091- pointer-events: none;
20922092-}
20932093-.profile-hero-button:hover .profile-hero-arrow,
20942094-.profile-hero-button:focus-visible .profile-hero-arrow {
20952095- transform: translate(3px, -3px);
20962096- background: #254a9e;
20972097- border-color: #254a9e;
20982098- color: #ffffff;
20992099- box-shadow: 0 6px 16px rgba(37, 74, 158, 0.32);
21002100-}
21012101-.dark-phase .profile-hero-arrow {
21022102- background: rgba(255, 255, 255, 0.12);
21032103- border-color: rgba(200, 211, 255, 0.32);
21042104- color: #c8d3ff;
21052105-}
21062106-.dark-phase .profile-hero-button:hover .profile-hero-arrow,
21072107-.dark-phase .profile-hero-button:focus-visible .profile-hero-arrow {
21082108- background: #c8d3ff;
21092109- border-color: #c8d3ff;
21102110- color: #0e1428;
21112111-}
21122047.profile-hero-avatar {
21132048 width: 120px;
21142049 height: 120px;
···21712106 color: rgba(255, 255, 255, 0.85);
21722107}
2173210821742174-/* ---- Profile detail call-to-action buttons (Bluesky + Website) ---- */
21092109+/* ---- Profile detail call-to-action buttons (Web / apps / Atmosphere) ---- */
21752110.profile-actions {
21762111 margin-top: 1.5rem;
21772112 display: grid;
···24222357 flex-direction: column;
24232358 gap: 1.1rem;
24242359 margin-top: 1.5rem;
23602360+}
23612361+.profile-form-mobile-links {
23622362+ display: grid;
23632363+ grid-template-columns: repeat(2, minmax(0, 1fr));
23642364+ gap: 1rem;
23652365+}
23662366+@media (max-width: 720px) {
23672367+ .profile-form-mobile-links {
23682368+ grid-template-columns: 1fr;
23692369+ }
24252370}
2426237124272372/* Read-only "Signed in as @handle" row, with the sign-out button
+2-2
components/explore/ProfileCard.tsx
···99/**
1010 * Listing-grid card. Clicking the card opens the project's profile
1111 * detail page (/explore/<handle>) — visitors get the description,
1212- * Atmosphere services, landing page, etc. on the detail page, where
1313- * the hero card is what actually links out to mainLink.
1212+ * Atmosphere services, Web / iOS / Android links, and any custom buttons
1313+ * on the detail page.
1414 */
1515export default function ProfileCard({ profile }: Props) {
1616 const t = useT();
+6-41
components/explore/ProfileHero.tsx
···77}
8899/**
1010- * Profile detail hero. When the project has a `mainLink` (the actual
1111- * app/service URL), the whole hero becomes a button that opens that
1212- * destination in a new tab — the diagonal arrow in the top-right
1313- * corner is the affordance. Records without a mainLink (legacy or
1414- * deliberately empty) render the same content as a static panel so
1515- * the page still reads correctly.
1010+ * Profile detail hero. Destination links render as explicit action buttons
1111+ * below this panel (Web / iOS / Android / Atmosphere / custom), so the hero
1212+ * stays a static project summary.
1613 */
1714export default function ProfileHero({ profile }: Props) {
1815 const t = useT();
···2118 const tBadges = t.badges;
2219 const featured = profile.featured;
2320 const cats = profile.categories;
2424- const hasMainLink = !!profile.mainLink;
25212626- const inner = (
2727- <>
2828- {hasMainLink && (
2929- <span class="profile-hero-arrow" aria-hidden="true">
3030- <svg
3131- width="18"
3232- height="18"
3333- viewBox="0 0 24 24"
3434- fill="none"
3535- stroke="currentColor"
3636- stroke-width="2.4"
3737- stroke-linecap="round"
3838- stroke-linejoin="round"
3939- >
4040- <line x1="7" y1="17" x2="17" y2="7"></line>
4141- <polyline points="9 7 17 7 17 15"></polyline>
4242- </svg>
4343- </span>
4444- )}
2222+ return (
2323+ <div class="profile-hero glass">
4524 <div class="profile-hero-avatar">
4625 {profile.avatarCid
4726 ? (
···9170 </div>
9271 <p class="profile-hero-description">{profile.description}</p>
9372 </div>
9494- </>
7373+ </div>
9574 );
9696-9797- if (hasMainLink) {
9898- return (
9999- <a
100100- href={profile.mainLink!}
101101- target="_blank"
102102- rel="noopener noreferrer external"
103103- class="profile-hero glass profile-hero-button"
104104- >
105105- {inner}
106106- </a>
107107- );
108108- }
109109- return <div class="profile-hero glass">{inner}</div>;
11075}
+99-11
components/explore/ProfileLinks.tsx
···1313}
14141515/**
1616- * Renders the public profile's action buttons. We iterate `profile.links`
1717- * in author-defined order and resolve each entry to a render-ready
1818- * bundle via `resolveLink`. The resolver tags each link with an
1919- * optional `iconKind` so we can render the on-brand inline SVG (which
2020- * inherits the site's blue via currentColor) for known services, while
2121- * still falling back to favicons / glyphs for everything else.
1616+ * Renders the public profile's action buttons. Top-level app destinations
1717+ * (`mainLink`, `iosLink`, `androidLink`) always render first as Web / iOS /
1818+ * Android buttons. Then we iterate `profile.links` in author-defined order
1919+ * for Atmosphere and custom links.
2220 *
2321 * URL subtitles are intentionally hidden for atmosphere services and
2424- * the website button — the title alone is enough; the URL is a
2525- * destination, not metadata. Custom links keep their subtitle so the
2626- * user knows where they're going.
2222+ * platform buttons — the title alone is enough; the URL is a destination,
2323+ * not metadata.
2724 */
2825export default function ProfileLinks({ profile }: Props) {
2926 const t = useT();
3027 const tLink = t.linkKinds;
31282929+ const appLinks = [
3030+ profile.mainLink
3131+ ? {
3232+ title: tLink.website,
3333+ subtitle: "",
3434+ iconUrl: null,
3535+ glyph: "↗",
3636+ href: profile.mainLink,
3737+ iconKind: "website" as const,
3838+ }
3939+ : null,
4040+ profile.iosLink
4141+ ? {
4242+ title: "iOS",
4343+ subtitle: "",
4444+ iconUrl: null,
4545+ glyph: "iOS",
4646+ href: profile.iosLink,
4747+ iconKind: "ios" as const,
4848+ }
4949+ : null,
5050+ profile.androidLink
5151+ ? {
5252+ title: "Android",
5353+ subtitle: "",
5454+ iconUrl: null,
5555+ glyph: "A",
5656+ href: profile.androidLink,
5757+ iconKind: "android" as const,
5858+ }
5959+ : null,
6060+ ].filter((r): r is NonNullable<typeof r> => r !== null);
6161+3262 const resolved = profile.links
6363+ // The form no longer emits `website`; old records may still carry it
6464+ // as the former Landing Page button, which should no longer render.
6565+ .filter((entry) => entry.kind !== "website")
3366 .map((entry) => resolveLink(entry, profile.handle, tLink))
3467 .filter((r): r is NonNullable<typeof r> => r !== null);
35683636- if (resolved.length === 0) return null;
6969+ const actions = [...appLinks, ...resolved];
7070+7171+ if (actions.length === 0) return null;
37723873 return (
3974 <div class="profile-actions">
4040- {resolved.map((r, i) => (
7575+ {actions.map((r, i) => (
4176 <a
4277 class={i === 0
4378 ? "profile-action profile-action--primary"
···89124 </span>
90125 );
91126 }
127127+ if (iconKind === "ios") {
128128+ return (
129129+ <span class="profile-action-icon profile-action-icon--brand">
130130+ <AppleIcon class="profile-action-icon-svg" />
131131+ </span>
132132+ );
133133+ }
134134+ if (iconKind === "android") {
135135+ return (
136136+ <span class="profile-action-icon profile-action-icon--brand">
137137+ <AndroidIcon class="profile-action-icon-svg" />
138138+ </span>
139139+ );
140140+ }
92141 if (iconUrl) {
93142 return (
94143 <img
···104153 <span class="profile-action-icon profile-action-icon--glyph">{glyph}</span>
105154 );
106155}
156156+157157+function AppleIcon({ class: className }: { class?: string }) {
158158+ return (
159159+ <svg
160160+ viewBox="0 0 24 24"
161161+ xmlns="http://www.w3.org/2000/svg"
162162+ class={className}
163163+ fill="currentColor"
164164+ aria-hidden="true"
165165+ >
166166+ <path d="M16.53 12.52c-.02-2.1 1.72-3.12 1.8-3.17-1.01-1.48-2.55-1.68-3.08-1.7-1.29-.13-2.54.77-3.19.77-.67 0-1.68-.75-2.76-.73-1.4.02-2.7.83-3.42 2.1-1.48 2.56-.38 6.32 1.04 8.39.71 1.02 1.54 2.16 2.62 2.12 1.06-.04 1.46-.68 2.74-.68 1.27 0 1.64.68 2.76.66 1.14-.02 1.86-1.03 2.54-2.06.82-1.17 1.14-2.32 1.15-2.38-.02-.01-2.18-.84-2.2-3.32Z" />
167167+ <path d="M14.4 6.28c.57-.71.96-1.67.85-2.64-.82.04-1.84.57-2.43 1.26-.52.61-.99 1.61-.86 2.55.92.07 1.85-.47 2.44-1.17Z" />
168168+ </svg>
169169+ );
170170+}
171171+172172+function AndroidIcon({ class: className }: { class?: string }) {
173173+ return (
174174+ <svg
175175+ viewBox="0 0 24 24"
176176+ xmlns="http://www.w3.org/2000/svg"
177177+ class={className}
178178+ fill="none"
179179+ stroke="currentColor"
180180+ stroke-width="1.7"
181181+ stroke-linecap="round"
182182+ stroke-linejoin="round"
183183+ aria-hidden="true"
184184+ >
185185+ <path d="M7.5 9.5h9a3 3 0 0 1 3 3v4.25a1.75 1.75 0 0 1-1.75 1.75H6.25a1.75 1.75 0 0 1-1.75-1.75V12.5a3 3 0 0 1 3-3Z" />
186186+ <path d="M8 9.5 6.5 6.75" />
187187+ <path d="m16 9.5 1.5-2.75" />
188188+ <path d="M8.25 14h.01" />
189189+ <path d="M15.75 14h.01" />
190190+ <path d="M8 18.5v1.75" />
191191+ <path d="M16 18.5v1.75" />
192192+ </svg>
193193+ );
194194+}
+11-9
i18n/messages/en.tsx
···461461 bsky: "Bluesky",
462462 tangled: "Tangled",
463463 supper: "Supper",
464464- /** The legacy `website` link kind is repurposed as the optional
465465- * "Landing Page" button on the public profile detail row. */
466466- website: "Landing page",
464464+ website: "Web",
467465 custom: "Link",
468466 },
469467···590588 cancel: "Cancel",
591589 },
592590 mainLink: {
593593- sectionLabel: "Main Link (URL)",
591591+ sectionLabel: "Main Link / Web (URL)",
594592 placeholder: "https://yourapp.com",
595593 required: "Main Link is required.",
596594 invalid: "Main Link must be a valid http(s) URL.",
597595 },
598598- landingPage: {
599599- sectionLabel: "Landing Page (URL)",
600600- placeholder: "https://yourproject.com",
601601- hint:
602602- "Optional. Use this if your app or service has a separate landing or marketing page.",
596596+ appLinks: {
597597+ iosLabel: "iOS link (optional)",
598598+ iosPlaceholder: "https://apps.apple.com/app/…",
599599+ iosHint: "Add this if your project has an iPhone or iPad app.",
600600+ iosInvalid: "iOS link must be a valid http(s) URL.",
601601+ androidLabel: "Android link (optional)",
602602+ androidPlaceholder: "https://play.google.com/store/apps/details?id=…",
603603+ androidHint: "Add this if your project has an Android app.",
604604+ androidInvalid: "Android link must be a valid http(s) URL.",
603605 },
604606 customLinks: {
605607 sectionLabel: "Custom links",
+91-53
islands/CreateProfileForm.tsx
···2525 * (the chosen migration path was "treat existing website as Main
2626 * Link"). */
2727 mainLink: string | null;
2828+ /** Optional App Store / Android links rendered as platform buttons. */
2929+ iosLink: string | null;
3030+ androidLink: string | null;
2831 /** All categories that apply to the project (always non-empty). The
2932 * first item is the primary, used for sort/grouping in lists. */
3033 categories: string[];
···9497 return btoa(binary);
9598}
9699100100+function isHttpUrl(value: string): boolean {
101101+ try {
102102+ const u = new URL(value);
103103+ return u.protocol === "http:" || u.protocol === "https:";
104104+ } catch {
105105+ return false;
106106+ }
107107+}
108108+97109interface CustomLinkRow {
98110 label: string;
99111 url: string;
···105117 * `legacyWebsite` is the URL of any pre-mainLink `kind: website` entry.
106118 * Callers use it to auto-promote that URL into the new top-level
107119 * `mainLink` field when the existing record doesn't have one yet (the
108108- * "treat existing website as Main Link" migration); after promotion,
109109- * the landing-page input is left empty so the user doesn't end up with
110110- * duplicate buttons pointing at the same URL.
120120+ * "treat existing website as Main Link" migration). Current records no
121121+ * longer emit website links because mainLink renders as the Web button.
111122 */
112123function splitInitialLinks(links: LinkEntry[]): {
113124 bskyClientIds: string[];
···115126 tangledOn: boolean;
116127 supperOverride: string;
117128 supperOn: boolean;
118118- landing: string;
129129+ iosLink: string;
130130+ androidLink: string;
119131 legacyWebsite: string;
120132 custom: CustomLinkRow[];
121133} {
···124136 let tangledOn = false;
125137 let supperOverride = "";
126138 let supperOn = false;
127127- let landing = "";
139139+ let iosLink = "";
140140+ let androidLink = "";
141141+ let legacyWebsite = "";
128142 const custom: CustomLinkRow[] = [];
129143130144 for (const e of links) {
···141155 if (e.url) supperOverride = e.url;
142156 break;
143157 case "website":
144144- // The website kind now stores the optional Landing Page URL.
145145- if (e.url) landing = e.url;
158158+ if (e.url && !legacyWebsite) legacyWebsite = e.url;
146159 break;
147160 case "other":
148148- if (e.url) custom.push({ label: e.label ?? "", url: e.url });
161161+ if (e.url) {
162162+ const normalizedLabel = (e.label ?? "").trim().toLowerCase();
163163+ if (
164164+ !iosLink &&
165165+ (normalizedLabel === "ios" || normalizedLabel === "iphone")
166166+ ) {
167167+ iosLink = e.url;
168168+ } else if (
169169+ !androidLink &&
170170+ (normalizedLabel === "android" || normalizedLabel === "google play")
171171+ ) {
172172+ androidLink = e.url;
173173+ } else {
174174+ custom.push({ label: e.label ?? "", url: e.url });
175175+ }
176176+ }
149177 break;
150178 }
151179 }
···155183 tangledOn,
156184 supperOverride,
157185 supperOn,
158158- landing,
159159- legacyWebsite: landing,
186186+ iosLink,
187187+ androidLink,
188188+ legacyWebsite,
160189 custom,
161190 };
162191}
···176205 const tAtmos = tForm.atmosphereLinks;
177206 const tCustom = tForm.customLinks;
178207 const tMainLink = tForm.mainLink;
179179- const tLanding = tForm.landingPage;
208208+ const tAppLinks = tForm.appLinks;
180209 const tManage = t.explore.manage;
181210 /** Live registry status. Flips on save (-> true) and delete (-> false). */
182211 const published = useSignal<boolean>(initialPublished);
···187216 const description = useSignal(initial?.description ?? "");
188217 /**
189218 * Auto-promote the legacy `website` URL into the new mainLink slot
190190- * for records that pre-date mainLink. The landing-page input then
191191- * starts empty so the user doesn't unintentionally save the same
192192- * URL twice (once as Main Link, once as a Landing Page button).
219219+ * for records that pre-date mainLink. Current saves no longer emit
220220+ * `website` entries, so this is a one-way cleanup path.
193221 */
194222 const promoteLegacyWebsite = !initial?.mainLink &&
195223 !!initialSplit.legacyWebsite;
196224 const mainLink = useSignal<string>(
197225 initial?.mainLink ??
198226 (promoteLegacyWebsite ? initialSplit.legacyWebsite : ""),
227227+ );
228228+ const iosLink = useSignal<string>(initial?.iosLink ?? initialSplit.iosLink);
229229+ const androidLink = useSignal<string>(
230230+ initial?.androidLink ?? initialSplit.androidLink,
199231 );
200232 const categories = useSignal<string[]>(
201233 initial?.categories?.length ? initial.categories : ["app"],
···221253 * open, if any. `null` = no modal open. */
222254 const urlOverrideOpen = useSignal<"tangled" | "supper" | null>(null);
223255224224- /** Optional secondary URL — rendered as a globe-icon button on the
225225- * public profile detail page. Stored as `kind: website` in the
226226- * links[] array for backward compatibility with existing records. */
227227- const landingPage = useSignal<string>(
228228- promoteLegacyWebsite ? "" : initialSplit.landing,
229229- );
230256 const customLinks = useSignal<CustomLinkRow[]>(initialSplit.custom);
231257232258 const tIcon = tForm.icon;
···443469 * Reduce the form's working state into the lexicon-shaped LinkEntry[]
444470 * we send to the API. Order matters for the public profile button row
445471 * — atmosphere links first (in service order, with the user's chosen
446446- * primary bsky client at the head), then the optional Landing Page
447447- * (stored as `kind: website`), then custom links in display order.
472472+ * primary bsky client at the head), then custom links in display order.
448473 *
449474 * The Main Link is NOT in this array — it lives at top level on the
450475 * record (and on the API payload) and drives the listing card target.
···467492 if (u) entry.url = u;
468493 out.push(entry);
469494 }
470470- const landing = landingPage.value.trim();
471471- if (landing) out.push({ kind: "website", url: landing });
472495 for (const row of customLinks.value) {
473496 const url = row.url.trim();
474497 const label = row.label.trim();
···504527 message.value = { kind: "error", text: tMainLink.invalid };
505528 return;
506529 }
530530+ const trimmedIosLink = iosLink.value.trim();
531531+ if (trimmedIosLink && !isHttpUrl(trimmedIosLink)) {
532532+ message.value = { kind: "error", text: tAppLinks.iosInvalid };
533533+ return;
534534+ }
535535+ const trimmedAndroidLink = androidLink.value.trim();
536536+ if (trimmedAndroidLink && !isHttpUrl(trimmedAndroidLink)) {
537537+ message.value = { kind: "error", text: tAppLinks.androidInvalid };
538538+ return;
539539+ }
507540 submitting.value = true;
508541 message.value = null;
509542···514547 name: name.value.trim(),
515548 description: description.value.trim(),
516549 mainLink: trimmedMainLink,
550550+ iosLink: trimmedIosLink || null,
551551+ androidLink: trimmedAndroidLink || null,
517552 categories: categories.value,
518553 subcategories: showSubcategories ? subcategories.value : [],
519554 links: cleanedLinks,
···766801 {/* ---------------- Main Link ----------------------------- */}
767802 {
768803 /*
769769- Required. Drives the listing card's link target on /explore
770770- (whole card becomes a button). Also surfaced as a small
771771- arrow on hover. We keep it directly above Atmosphere links
772772- so the user sees the destination flow top-to-bottom: where
773773- the card goes (Main Link) → who runs the project (Atmosphere
774774- services) → optional secondary surfaces (Landing Page +
775775- custom).
804804+ Required. Renders as the Web button on the public profile.
805805+ We keep the mobile-app links directly underneath it so the
806806+ user sees the primary destinations together before adding
807807+ Atmosphere and custom buttons.
776808 */
777809 }
778810 <label class="profile-form-field">
···791823 />
792824 </label>
793825826826+ {/* ---------------- Mobile app links (optional) ---------- */}
827827+ <div class="profile-form-mobile-links">
828828+ <label class="profile-form-field">
829829+ <span class="profile-form-label">{tAppLinks.iosLabel}</span>
830830+ <input
831831+ type="url"
832832+ class="profile-form-input"
833833+ placeholder={tAppLinks.iosPlaceholder}
834834+ value={iosLink.value}
835835+ onInput={(e) =>
836836+ iosLink.value = (e.currentTarget as HTMLInputElement).value}
837837+ />
838838+ <p class="profile-form-hint">{tAppLinks.iosHint}</p>
839839+ </label>
840840+ <label class="profile-form-field">
841841+ <span class="profile-form-label">{tAppLinks.androidLabel}</span>
842842+ <input
843843+ type="url"
844844+ class="profile-form-input"
845845+ placeholder={tAppLinks.androidPlaceholder}
846846+ value={androidLink.value}
847847+ onInput={(e) =>
848848+ androidLink.value = (e.currentTarget as HTMLInputElement).value}
849849+ />
850850+ <p class="profile-form-hint">{tAppLinks.androidHint}</p>
851851+ </label>
852852+ </div>
853853+794854 {/* ---------------- Atmosphere links ----------------------- */}
795855 <fieldset class="profile-form-field">
796856 <legend class="profile-form-label">{tAtmos.sectionLabel}</legend>
···812872 )}
813873 </div>
814874 </fieldset>
815815-816816- {/* ---------------- Landing Page (optional) --------------- */}
817817- {
818818- /*
819819- Optional secondary URL — a separate marketing/landing page
820820- distinct from the Main Link. Renders as the globe-icon
821821- button on /explore/<handle>. Stored as `kind: website` for
822822- backward compatibility with existing records.
823823- */
824824- }
825825- <label class="profile-form-field">
826826- <span class="profile-form-label">{tLanding.sectionLabel}</span>
827827- <input
828828- type="url"
829829- class="profile-form-input"
830830- placeholder={tLanding.placeholder}
831831- value={landingPage.value}
832832- onInput={(e) =>
833833- landingPage.value = (e.currentTarget as HTMLInputElement).value}
834834- />
835835- <p class="profile-form-hint">{tLanding.hint}</p>
836836- </label>
837875838876 {/* ---------------- Custom links -------------------------- */}
839877 <div class="profile-form-field">
···2828 "type": "string",
2929 "format": "uri",
3030 "maxLength": 512,
3131- "description": "Primary destination for the project (the actual app, service, or landing page). The whole profile card on /explore is rendered as a button to this URL. Optional in the lexicon for backward compatibility with records created before mainLink existed; the registry UI requires it for new/updated records."
3131+ "description": "Primary web destination for the project (the actual web app, service, or website). Rendered as the Web button on the public profile. Optional in the lexicon for backward compatibility with records created before mainLink existed; the registry UI requires it for new/updated records."
3232+ },
3333+ "iosLink": {
3434+ "type": "string",
3535+ "format": "uri",
3636+ "maxLength": 512,
3737+ "description": "Optional App Store URL for projects with an iOS app. Rendered as an iOS button on the public profile when present."
3838+ },
3939+ "androidLink": {
4040+ "type": "string",
4141+ "format": "uri",
4242+ "maxLength": 512,
4343+ "description": "Optional Google Play or Android distribution URL for projects with an Android app. Rendered as an Android button on the public profile when present."
3244 },
3345 "avatar": {
3446 "type": "blob",
···7486 "type": "ref",
7587 "ref": "#linkEntry"
7688 },
7777- "description": "Outbound buttons shown on the public profile. Atmosphere links (kind = bsky / tangled / supper) derive their URL from the project's current handle by default; a `url` override is allowed for tangled / supper when the canonical destination differs from the handle. Custom link kinds (kind = website / other) always carry their `url`."
8989+ "description": "Outbound buttons shown on the public profile after the Web / iOS / Android app links. Atmosphere links (kind = bsky / tangled / supper) derive their URL from the project's current handle by default; a `url` override is allowed for tangled / supper when the canonical destination differs from the handle. Custom link kinds (kind = website / other) always carry their `url`; new records no longer emit website entries."
7890 },
7991 "createdAt": {
8092 "type": "string",
+8-1
lib/atmosphere-links.ts
···141141 * bluesky client; alt clients keep their favicon)
142142 * - `tangled` — inline Tangled "dolly" mark
143143 * - `website` — inline globe
144144+ * - `ios` — inline Apple-style mark
145145+ * - `android` — inline Android-style mark
144146 */
145145-export type ResolvedIconKind = "bsky" | "tangled" | "website";
147147+export type ResolvedIconKind =
148148+ | "bsky"
149149+ | "tangled"
150150+ | "website"
151151+ | "ios"
152152+ | "android";
146153147154export interface ResolvedLink {
148155 /** Display title for the button. */
+12
lib/db.ts
···9393 name TEXT NOT NULL,
9494 description TEXT NOT NULL,
9595 main_link TEXT,
9696+ ios_link TEXT,
9797+ android_link TEXT,
9698 categories TEXT NOT NULL DEFAULT '[]',
9799 subcategories TEXT NOT NULL DEFAULT '[]',
98100 links TEXT NOT NULL DEFAULT '[]',
···239241 table: "profile",
240242 column: "main_link",
241243 ddl: "ALTER TABLE profile ADD COLUMN main_link TEXT",
244244+ },
245245+ {
246246+ table: "profile",
247247+ column: "ios_link",
248248+ ddl: "ALTER TABLE profile ADD COLUMN ios_link TEXT",
249249+ },
250250+ {
251251+ table: "profile",
252252+ column: "android_link",
253253+ ddl: "ALTER TABLE profile ADD COLUMN android_link TEXT",
242254 },
243255 {
244256 table: "profile",
+34-11
lib/lexicons.ts
···115115 name: string;
116116 description: string;
117117 /**
118118- * Primary destination URL for the project — the actual app, service,
119119- * or marketing page. The whole profile card on /explore is rendered
120120- * as a button that opens this URL. Optional in the lexicon for
121121- * backward compatibility with records created before mainLink
122122- * existed; the registry UI enforces it as required for new/updated
123123- * records (the public listing falls back to /explore/<handle> when
124124- * absent on a legacy record).
118118+ * Primary web destination URL for the project. Rendered as the Web
119119+ * button on the public profile. Optional in the lexicon for backward
120120+ * compatibility with records created before mainLink existed; the
121121+ * registry UI enforces it as required for new/updated records.
125122 */
126123 mainLink?: string;
124124+ /** Optional App Store URL for projects with an iOS app. */
125125+ iosLink?: string;
126126+ /** Optional Google Play / Android distribution URL for projects with an Android app. */
127127+ androidLink?: string;
127128 avatar?: BlobRef;
128129 /**
129130 * Optional vector icon (SVG) intended for developer use — sign-in
···136137 categories: string[];
137138 subcategories?: string[];
138139 /**
139139- * Outbound buttons shown on the public profile (Atmosphere link
140140- * toggles, the optional Landing Page button, and any custom links).
141141- * The legacy `website` kind is rendered as a Landing Page button
142142- * post-migration; new records emit `website` for the same purpose.
140140+ * Outbound buttons shown on the public profile after the Web / iOS /
141141+ * Android app links (Atmosphere link toggles and any custom links).
142142+ * Legacy `website` entries are still valid for older records, but the
143143+ * current form no longer emits them.
143144 */
144145 links?: LinkEntry[];
145146 createdAt: string;
···328329 }
329330 normalizedMainLink = (v.mainLink as string).trim();
330331 }
332332+ let normalizedIosLink: string | undefined;
333333+ if (v.iosLink !== undefined && v.iosLink !== null && v.iosLink !== "") {
334334+ if (!isStr(v.iosLink, 512) || !isUrl(v.iosLink)) {
335335+ return { ok: false, error: "iosLink: must be an http(s) URL <=512" };
336336+ }
337337+ normalizedIosLink = (v.iosLink as string).trim();
338338+ }
339339+ let normalizedAndroidLink: string | undefined;
340340+ if (
341341+ v.androidLink !== undefined && v.androidLink !== null &&
342342+ v.androidLink !== ""
343343+ ) {
344344+ if (!isStr(v.androidLink, 512) || !isUrl(v.androidLink)) {
345345+ return {
346346+ ok: false,
347347+ error: "androidLink: must be an http(s) URL <=512",
348348+ };
349349+ }
350350+ normalizedAndroidLink = (v.androidLink as string).trim();
351351+ }
331352 // categories[]: required, deduped, every entry must be a known CATEGORY.
332353 // The first entry is treated as the primary category by the UI.
333354 let normalizedCategories: string[];
···391412 name: v.name as string,
392413 description: v.description as string,
393414 mainLink: normalizedMainLink,
415415+ iosLink: normalizedIosLink,
416416+ androidLink: normalizedAndroidLink,
394417 avatar: v.avatar as BlobRef | undefined,
395418 icon: v.icon as BlobRef | undefined,
396419 categories: normalizedCategories,
+7-1
lib/public-profile.ts
···1919 name: string;
2020 description: string;
2121 mainLink: string | null;
2222+ iosLink: string | null;
2323+ androidLink: string | null;
2224 categories: string[];
2325 subcategories: string[];
2426 links: LinkEntry[];
···6567 name: profile.name,
6668 description: profile.description,
6769 mainLink: profile.mainLink,
7070+ iosLink: profile.iosLink,
7171+ androidLink: profile.androidLink,
6872 categories: profile.categories,
6973 subcategories: profile.subcategories,
7070- links: profile.links,
7474+ // `website` was the former Landing Page button. The current public
7575+ // API exposes the primary web destination via `mainLink` instead.
7676+ links: profile.links.filter((entry) => entry.kind !== "website"),
7177 avatarCid: profile.avatarCid,
7278 avatarMime: profile.avatarMime,
7379 avatarUrl,
+21-6
lib/registry.ts
···4949 handle: string;
5050 name: string;
5151 description: string;
5252- /** Primary destination for the profile card on /explore. May be null
5353- * for legacy records created before mainLink existed; the listing
5454- * card falls back to /explore/<handle> in that case. */
5252+ /** Primary web destination rendered as the Web button. May be null
5353+ * for legacy records created before mainLink existed. */
5554 mainLink: string | null;
5555+ /** Optional App Store URL rendered as the iOS button on the public profile. */
5656+ iosLink: string | null;
5757+ /** Optional Android app URL rendered as the Android button on the public profile. */
5858+ androidLink: string | null;
5659 /** All categories that apply (always non-empty). The first item is the
5760 * primary category used for sort/grouping in lists. */
5861 categories: string[];
5962 subcategories: string[];
6060- /** Outbound links (atmosphere services, landing page, custom) in author-defined order. */
6363+ /** Outbound links (atmosphere services and custom links) in author-defined order. */
6164 links: LinkEntry[];
6265 avatarCid: string | null;
6366 avatarMime: string | null;
···101104 name: string;
102105 description: string;
103106 main_link: string | null;
107107+ ios_link: string | null;
108108+ android_link: string | null;
104109 categories: string;
105110 subcategories: string;
106111 links: string | null;
···186191 name: r.name,
187192 description: r.description,
188193 mainLink: r.main_link && r.main_link.length > 0 ? r.main_link : null,
194194+ iosLink: r.ios_link && r.ios_link.length > 0 ? r.ios_link : null,
195195+ androidLink: r.android_link && r.android_link.length > 0
196196+ ? r.android_link
197197+ : null,
189198 categories: safeJsonArray(r.categories),
190199 subcategories: safeJsonArray(r.subcategories),
191200 links: safeJsonLinks(r.links),
···237246 * Stored as the textual URL; the registry UI/API enforce required-ness
238247 * + URL shape on writes. */
239248 mainLink?: string | null;
249249+ iosLink?: string | null;
250250+ androidLink?: string | null;
240251 /** Required: 1-4 known category strings. The first is the primary. */
241252 categories: string[];
242253 subcategories: string[];
···283294 await c.execute({
284295 sql: `
285296 INSERT INTO profile (
286286- did, handle, name, description, main_link,
297297+ did, handle, name, description, main_link, ios_link, android_link,
287298 categories, subcategories, links,
288299 avatar_cid, avatar_mime, icon_cid, icon_mime, icon_status,
289300 icon_reviewed_by, icon_reviewed_at, icon_rejected_reason,
···293304 takedown_status, takedown_reason, takedown_by, takedown_at,
294305 pds_url, record_cid, record_rev, created_at, indexed_at
295306 ) VALUES (
296296- ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
307307+ ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
297308 NULL, NULL, NULL,
298309 NULL, NULL, NULL, NULL, NULL, NULL,
299310 NULL, NULL, NULL, NULL,
···304315 name=excluded.name,
305316 description=excluded.description,
306317 main_link=excluded.main_link,
318318+ ios_link=excluded.ios_link,
319319+ android_link=excluded.android_link,
307320 categories=excluded.categories,
308321 subcategories=excluded.subcategories,
309322 links=excluded.links,
···379392 input.name,
380393 input.description,
381394 input.mainLink ?? null,
395395+ input.iosLink ?? null,
396396+ input.androidLink ?? null,
382397 JSON.stringify(cats),
383398 JSON.stringify(input.subcategories ?? []),
384399 JSON.stringify(input.links ?? []),