···592592 cancel: "Cancel",
593593 },
594594 mainLink: {
595595- sectionLabel: "Main Link / Web (URL)",
595595+ sectionLabel: "Web link",
596596 placeholder: "https://yourapp.com",
597597- required: "Main Link is required.",
598598- invalid: "Main Link must be a valid http(s) URL.",
597597+ required: "Add at least one Web, iOS, or Android link.",
598598+ invalid: "Web link must be a valid http(s) URL.",
599599 },
600600 appLinks: {
601601 iosLabel: "iOS link (optional)",
+16-17
islands/CreateProfileForm.tsx
···509509 return;
510510 }
511511 const trimmedMainLink = mainLink.value.trim();
512512- if (!trimmedMainLink) {
512512+ const trimmedIosLink = iosLink.value.trim();
513513+ const trimmedAndroidLink = androidLink.value.trim();
514514+ if (!trimmedMainLink && !trimmedIosLink && !trimmedAndroidLink) {
513515 message.value = { kind: "error", text: tMainLink.required };
514516 return;
515517 }
···518520 * URL parsing — this is just so the user doesn't have to round-trip
519521 * to find out they typed "yourapp.com" without a protocol.
520522 */
521521- try {
522522- const u = new URL(trimmedMainLink);
523523- if (u.protocol !== "http:" && u.protocol !== "https:") {
524524- throw new Error("non-http");
523523+ if (trimmedMainLink) {
524524+ try {
525525+ const u = new URL(trimmedMainLink);
526526+ if (u.protocol !== "http:" && u.protocol !== "https:") {
527527+ throw new Error("non-http");
528528+ }
529529+ } catch {
530530+ message.value = { kind: "error", text: tMainLink.invalid };
531531+ return;
525532 }
526526- } catch {
527527- message.value = { kind: "error", text: tMainLink.invalid };
528528- return;
529533 }
530530- const trimmedIosLink = iosLink.value.trim();
531534 if (trimmedIosLink && !isHttpUrl(trimmedIosLink)) {
532535 message.value = { kind: "error", text: tAppLinks.iosInvalid };
533536 return;
534537 }
535535- const trimmedAndroidLink = androidLink.value.trim();
536538 if (trimmedAndroidLink && !isHttpUrl(trimmedAndroidLink)) {
537539 message.value = { kind: "error", text: tAppLinks.androidInvalid };
538540 return;
···800802 {/* ---------------- Main Link ----------------------------- */}
801803 {
802804 /*
803803- Required. Renders as the Web button on the public profile.
804804- We keep the mobile-app links directly underneath it so the
805805- user sees the primary destinations together before adding
806806- Atmosphere and custom buttons.
805805+ Primary destinations render as buttons inside the public
806806+ profile card. A project needs at least one Web / iOS /
807807+ Android destination, but each individual field is optional.
807808 */
808809 }
809810 <label class="profile-form-field">
810811 <span class="profile-form-label">
811811- {tMainLink.sectionLabel}{" "}
812812- <span class="profile-form-required">*</span>
812812+ {tMainLink.sectionLabel}
813813 </span>
814814 <input
815815 type="url"
816816 class="profile-form-input"
817817 placeholder={tMainLink.placeholder}
818818 value={mainLink.value}
819819- required
820819 onInput={(e) =>
821820 mainLink.value = (e.currentTarget as HTMLInputElement).value}
822821 />
···2727 "type": "string",
2828 "format": "uri",
2929 "maxLength": 512,
3030- "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."
3030+ "description": "Primary web destination for the project (the actual web app, service, or website). Rendered as the Web button inside the public profile card when present. Optional when an iOS or Android destination is provided."
3131 },
3232 "iosLink": {
3333 "type": "string",
+4-7
lib/lexicons.ts
···116116 description: string;
117117 /**
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.
119119+ * button inside the public profile card when present.
122120 */
123121 mainLink?: string;
124122 /** Optional App Store URL for projects with an iOS app. */
···318316 if (normalizedDescription.length > 500) {
319317 return { ok: false, error: "description: must be <=500 chars" };
320318 }
321321- // mainLink: optional in the lexicon for backward compat, but if present
322322- // must parse as an http(s) URL <=512 chars. The registry UI / API both
323323- // enforce required-ness for new writes; we don't reject reads here so
324324- // pre-mainLink records keep validating.
319319+ // mainLink: optional, but if present must parse as an http(s) URL <=512
320320+ // chars. The registry UI / API require at least one primary destination
321321+ // among mainLink, iosLink, and androidLink on new writes.
325322 let normalizedMainLink: string | undefined;
326323 if (v.mainLink !== undefined && v.mainLink !== null && v.mainLink !== "") {
327324 if (!isStr(v.mainLink, 512) || !isUrl(v.mainLink)) {
+6-12
routes/api/registry/profile.ts
···4040interface ProfileFormPayload {
4141 name?: string;
4242 description?: string;
4343- /** Primary destination URL for the profile card. Required by the
4444- * registry; the form enforces this, the API double-checks. */
4343+ /** Optional Web destination. At least one of mainLink, iosLink, or
4444+ * androidLink is required for new writes. */
4545 mainLink?: string;
4646 /** Optional app store links rendered as iOS / Android buttons. */
4747 iosLink?: string;
···270270271271 const links = normalizeLinksPayload(body.links);
272272273273- /**
274274- * mainLink is required at the API layer too. The lexicon keeps it
275275- * optional for backward-compat reads of pre-mainLink records, but
276276- * any new write must carry one — that's how the listing card knows
277277- * where to send visitors.
278278- */
279273 const mainLink = trimOrNull(body.mainLink);
280280- if (!mainLink) {
274274+ const iosLink = trimOrNull(body.iosLink);
275275+ const androidLink = trimOrNull(body.androidLink);
276276+ if (!mainLink && !iosLink && !androidLink) {
281277 return new Response(
282282- "main link is required (the URL people land on when they tap your card)",
278278+ "at least one Web, iOS, or Android link is required",
283279 { status: 400 },
284280 );
285281 }
286286- const iosLink = trimOrNull(body.iosLink);
287287- const androidLink = trimOrNull(body.androidLink);
288282289283 const draft: ProfileRecord = {
290284 name: trimOrNull(body.name) ?? "",