diff --git a/src/app/api/indexer/route.ts b/src/app/api/indexer/route.ts
index 3962ff50..d011ebfc 100644
--- a/src/app/api/indexer/route.ts
+++ b/src/app/api/indexer/route.ts
@@ -330,19 +330,23 @@ ${ACTIVITY_NODE_SELECTION}
}
`,
- // Every endorsement-typed badge award across the network — backs the
- // /endorsement-graph page. Returns the directed edge
- // (issuer `did` → `subject` DID) plus the issuer's denormalised actor
+ // Every badge award of one `badgeType` across the network — backs the
+ // /endorsement-graph page, which scans once per type ("endorsement" and
+ // "award"; the proxy allowlists the value). Returns the directed edge
+ // (issuer `did` → `subject`) plus the issuer's denormalised actor
// profile (same `issuer { ... }` join as ReceivedEndorsements, drops a
- // per-issuer resolve fan-out). Subjects that never issued an award have
- // no inline profile here, so the client resolves those DIDs via
+ // per-issuer resolve fan-out). The subject union carries the DID for
+ // account-targeted awards and the strong-ref `uri` for record-targeted
+ // ones (award-typed badges usually point at records; the at:// authority
+ // identifies the owning account). Subjects that never issued an award
+ // have no inline profile here, so the client resolves those DIDs via
// NetworkActorsByDids. `excludeAuthorLabels` hides awards authored by
// likely-test accounts, mirroring AwardCount so the graph matches the
// public counters. Paginated; the client unions pages up to a cap.
AllEndorsements: `
- query AllEndorsements($first: Int!, $after: String) {
+ query AllEndorsements($badgeType: String!, $first: Int!, $after: String) {
appCertifiedBadgeAward(
- where: { badgeType: { eq: "endorsement" } }
+ where: { badgeType: { eq: $badgeType } }
excludeAuthorLabels: ${JSON.stringify([...DEFAULT_HIDDEN_ORG_LABELS])}
first: $first
after: $after
@@ -357,6 +361,7 @@ ${ACTIVITY_NODE_SELECTION}
subject {
__typename
... on AppCertifiedDefsDid { did }
+ ... on ComAtprotoRepoStrongRef { uri }
}
issuer { did handle displayName avatarCid pds }
response { state }
@@ -1605,9 +1610,15 @@ function buildVariables(
return { viewer, degree: rawDegree }
}
case "AllEndorsements": {
- // Zero required vars — paginated network-wide scan. Same clamp
- // shape as the other 100-per-page reads (ReceivedEndorsements).
+ // Paginated network-wide scan, one badge type per pass. Strict
+ // allowlist on `badgeType` (defaults to "endorsement" for older
+ // clients) — anything else 400s here rather than fanning an
+ // arbitrary string out to the indexer. Same clamp shape as the
+ // other 100-per-page reads (ReceivedEndorsements).
+ const badgeType = vars.badgeType === undefined ? "endorsement" : vars.badgeType
+ if (badgeType !== "endorsement" && badgeType !== "award") return null
return {
+ badgeType,
first: clampFirst(vars.first, MAX_FIRST, 100),
after: readString(vars.after, MAX_AFTER_LEN),
}
diff --git a/src/app/help/page.tsx b/src/app/help/page.tsx
index a3e9afc0..a0b0df98 100644
--- a/src/app/help/page.tsx
+++ b/src/app/help/page.tsx
@@ -47,6 +47,21 @@ export default function HelpPage() {
+
+
+ What is certified.app?
+
+
+ certified.app is a view into the whole Certified network. It brings together every
+ account on the network, the activities and impact data they publish, and the
+ connections between them — who endorses whom, who follows whom, and who works
+ together in which group. The data itself doesn't live in this app: each account
+ keeps its own records on AT Protocol, and certified.app reads across all of them
+ to show the network in one place. Other compatible apps can present the same network
+ in their own way.
+
+
+
Take the walk-through
diff --git a/src/app/styles/visualization.css b/src/app/styles/visualization.css
index 636a6050..fec88954 100644
--- a/src/app/styles/visualization.css
+++ b/src/app/styles/visualization.css
@@ -145,6 +145,17 @@
min-width: 200px;
}
+/* Endorsement/award checkboxes float over the canvas, so give the row the
+ same card chrome as the legend for readability. */
+.viz__kind-toggles {
+ gap: var(--space-3, 12px);
+ padding: var(--space-2, 8px) var(--space-3, 12px);
+ background: var(--bg-elevated);
+ border: 1px solid var(--border-default);
+ border-radius: var(--radius);
+ box-shadow: var(--shadow-sm);
+}
+
/* Layout + scope switchers fill the control panel so their segments read
as one clear toggle rather than floating short. */
.viz__layout-toggle,
@@ -263,6 +274,10 @@
border-top-width: 3px;
}
+.viz__legend-swatch--award {
+ border-top-color: var(--color-warning);
+}
+
/* ---- Hover / selected node info panel ---- */
.viz__panel {
position: absolute;
@@ -446,6 +461,13 @@
gap: var(--space-3, 12px);
}
+/* With an odd cell count (5 since the Awards stat) the last cell spans the
+ 2-column grid instead of orphaning at half width. The tablet range
+ overrides this — there the five cells share one row. */
+.viz__stats-grid > .viz__stat:last-child:nth-child(odd) {
+ grid-column: 1 / -1;
+}
+
.viz__stat {
display: flex;
flex-direction: column;
@@ -560,7 +582,11 @@
}
.viz__stats-grid {
- grid-template-columns: repeat(4, minmax(0, 1fr));
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+ }
+
+ .viz__stats-grid > .viz__stat:last-child:nth-child(odd) {
+ grid-column: auto;
}
}
@@ -573,6 +599,12 @@
grid-template-columns: repeat(2, minmax(0, 1fr));
}
+ /* Back to 2 columns here, so the odd-count span applies again (the
+ tablet block above also matches this range and would otherwise win). */
+ .viz__stats-grid > .viz__stat:last-child:nth-child(odd) {
+ grid-column: 1 / -1;
+ }
+
.viz__panel {
width: 220px;
}
diff --git a/src/components/layout/bottom-nav.tsx b/src/components/layout/bottom-nav.tsx
index 63a8c00e..14c4a185 100644
--- a/src/components/layout/bottom-nav.tsx
+++ b/src/components/layout/bottom-nav.tsx
@@ -6,6 +6,7 @@ import { useFeedback } from "@/lib/feedback-context";
import { useOrg } from "@/lib/groups/org-context";
import { isRouteVisibleToActor } from "@/lib/groups/personal-only";
import { useLayoutBreakpoints } from "@/hooks/use-layout-breakpoints";
+import { isBottomNavVisible } from "@/lib/layout/bottom-nav-visibility";
import Tooltip from "@/components/ui/tooltip";
export default function BottomNav() {
@@ -15,22 +16,11 @@ export default function BottomNav() {
const { activeOrg } = useOrg();
const { isDesktop, isStandalone } = useLayoutBreakpoints();
- // Unmount at ≥800px — the left rail is the primary nav on desktop and
- // bottom-nav's focusable buttons would compete for tab order.
- if (isDesktop) return null;
-
- // Only show in installed / standalone mode (PWA or A2HS web clip).
- // In a regular browser tab the address bar and OS gestures provide
- // navigation; the bar also occludes content on /welcome for guests.
- if (!isStandalone) return null;
-
- // Embeds render bare (board-only) inside a third-party iframe.
- if (pathname.startsWith("/embed")) return null;
-
- // /welcome uses the minimal landing chrome (wordmark + sign-in) at every
- // width — no bottom nav. Matches the mobile navbar + desktop-top-bar,
- // which both return null on /welcome.
- if (pathname === "/welcome") return null;
+ // Visibility is centralized in isBottomNavVisible so the floating
+ // can hide itself under the exact same conditions
+ // (the bar already carries a Feedback entry). See the helper for the
+ // full per-condition rationale.
+ if (!isBottomNavVisible({ pathname, isDesktop, isStandalone })) return null;
const isHome = pathname === "/home" || pathname.startsWith("/home/");
diff --git a/src/components/layout/feedback-trigger.tsx b/src/components/layout/feedback-trigger.tsx
index c0abb446..4bc289ba 100644
--- a/src/components/layout/feedback-trigger.tsx
+++ b/src/components/layout/feedback-trigger.tsx
@@ -1,8 +1,11 @@
"use client"
import { useCallback, useEffect, useState } from "react"
+import { usePathname } from "next/navigation"
import { MessageSquare } from "lucide-react"
import { useFeedback } from "@/lib/feedback-context"
+import { useLayoutBreakpoints } from "@/hooks/use-layout-breakpoints"
+import { isBottomNavVisible } from "@/lib/layout/bottom-nav-visibility"
/**
* Floating "Share Feedback" button, pinned bottom-right. It's the app's
@@ -15,6 +18,8 @@ import { useFeedback } from "@/lib/feedback-context"
*/
export default function FeedbackTrigger() {
const { isOpen, openFeedback } = useFeedback()
+ const pathname = usePathname()
+ const { isDesktop, isStandalone } = useLayoutBreakpoints()
const [bottomOffset, setBottomOffset] = useState(20)
const updatePosition = useCallback(() => {
@@ -62,7 +67,16 @@ export default function FeedbackTrigger() {
}
}, [updatePosition])
- if (isOpen) return null
+ // Hidden on the marketing landing page (/welcome has its own contact
+ // CTA), and wherever the mobile bottom nav is on screen — that bar
+ // already carries a Feedback entry, so the floating button would
+ // duplicate it and overlap the bar.
+ if (
+ isOpen ||
+ pathname === "/welcome" ||
+ isBottomNavVisible({ pathname, isDesktop, isStandalone })
+ )
+ return null
return (
+ {/* Badge-kind toggles. The last checked box is disabled so at
+ least one kind is always visible. */}
+
{stats.participants.toLocaleString()}People
diff --git a/src/hooks/use-endorsement-graph.ts b/src/hooks/use-endorsement-graph.ts
index 67f75e1d..e76f8da2 100644
Binary files a/src/hooks/use-endorsement-graph.ts and b/src/hooks/use-endorsement-graph.ts differ
diff --git a/src/lib/layout/bottom-nav-visibility.ts b/src/lib/layout/bottom-nav-visibility.ts
new file mode 100644
index 00000000..008f9830
--- /dev/null
+++ b/src/lib/layout/bottom-nav-visibility.ts
@@ -0,0 +1,34 @@
+/**
+ * Single source of truth for whether the mobile bottom navigation bar
+ * () is on screen for the current environment.
+ *
+ * Shared by , which renders the bar, and ,
+ * which hides its floating "Feedback" button whenever the bar is present:
+ * the bar already carries a Feedback entry, so the floating button would
+ * be a redundant second door and would visually overlap the bar.
+ *
+ * The bar is shown only when ALL of these hold:
+ * - not desktop (<800px): at >=800px the left rail / top bar own
+ * navigation, and the bar's focusable buttons would compete for tab
+ * order, so it unmounts.
+ * - standalone (installed PWA / A2HS web clip): in a regular browser tab
+ * the address bar and OS gestures provide navigation, and the bar
+ * would occlude content (e.g. /welcome for guests).
+ * - not an /embed route: embeds render bare (board-only) inside a
+ * third-party iframe.
+ * - not /welcome: the minimal landing chrome (wordmark + sign-in) has no
+ * bottom nav at any width.
+ */
+export function isBottomNavVisible(params: {
+ pathname: string;
+ isDesktop: boolean;
+ isStandalone: boolean;
+}): boolean {
+ const { pathname, isDesktop, isStandalone } = params;
+ return (
+ !isDesktop &&
+ isStandalone &&
+ !pathname.startsWith("/embed") &&
+ pathname !== "/welcome"
+ );
+}