A jam is a shared-deadline creative challenge: one prompt, a roster of participants, a single due date. Submissions point at records that li
0

Configure Feed

Select the types of activity you want to include in your feed.

web: rebuild /scenarios as a narrative tour + feature EPTSS

Replace the flat category list with a guided story built on the transit-line
vocabulary. Lifecycle and timeline rounds render as live subway diagrams (the
now-dot's position IS the derived state), gating gets a join-mode triptych, and
a legend teaches the visual language up front — hairline rows replace the
off-brand bordered boxes.

Lead with a real adopter: EPTSS's live "Round 29" (song as subject,
fm.plyr.track submissions), pulled from the known-organizer DID and kept out of
the test-data heuristics. Annotate the documented "reads OPEN with no
submission-deadline" gotcha inline wherever it occurs (Round 29 and the test
gotcha scenario). Loader + categorize unchanged; the transit lines are pure
functions of round.value, so the only added fetch is EPTSS itself.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

+428 -119
+428 -119
web/app/routes/scenarios.tsx
··· 1 1 /** 2 - * Test scenarios directory. 2 + * Scenarios — a working tour of atjam. 3 3 * 4 - * Lists every populated test round under the test-data organizer DID, 5 - * grouped by category (lifecycle / gating / shape / empty) so a viewer 6 - * can navigate to any round and visually verify the system's behavior 7 - * for that scenario. 4 + * Every populated test round under the test-data organizer DID, arranged as a 5 + * narrative rather than a flat list: a round is born, opens, runs, and closes; 6 + * people join under different trust models; a round carries just enough 7 + * structure to stay generic. The lifecycle and timeline rounds render as live 8 + * transit lines (the same subway diagram the round page uses) so the "state is 9 + * derived from milestones" story is shown, not told. 8 10 * 9 - * The page is driven by data: it queries the test organizer's PDS for 10 - * all at.atjam.round records and groups them by what they exercise 11 - * (joinMode, networkGate, milestone state, optional fields populated, 12 - * empty signups). No hardcoded rkeys — populate can re-run freely and 13 - * the directory updates on next load. 11 + * Data-driven: it queries the test organizer's PDS for all at.atjam.round 12 + * records and groups them by what they exercise (joinMode, networkGate, 13 + * milestone state, optional fields, empty signups). No hardcoded rkeys — 14 + * populate can re-run freely and the page updates on next load. 14 15 * 15 - * The list of scenarios populate.ts produces is meant to span every 16 - * documented behavior; if you add a new behavior to the lexicon, add a 17 - * scenario to populate AND a category to this page if it warrants one. 16 + * The transit lines are pure functions of round.value, so this page adds no 17 + * network calls beyond the single listRecords + the organizer handle lookup. 18 18 */ 19 + import type { ReactNode } from "react"; 19 20 import { Link } from "react-router"; 20 21 import { Queries, Round } from "@atjam/lexicons"; 21 22 import { StatePill } from "~/components/state-pill"; 23 + import { TransitLine } from "~/components/transit-line"; 24 + import { JOIN_MODE_GLYPHS, STATE_VISUALS } from "~/lib/round-visuals"; 22 25 import type { Route } from "./+types/scenarios"; 23 26 24 27 const { listRecords, resolveDid, parseAtUri } = Queries; ··· 28 31 // (or read from env if it becomes worth configuring at deploy time). 29 32 const TEST_ORGANIZER_DID = "did:plc:mlpvwx4onoxkwtv4dcybtndw"; 30 33 34 + // EPTSS — a real adopter running live on atjam, not test data. This is the 35 + // same DID the home feed polls (VITE_KNOWN_ORGANIZERS); pinned here so the 36 + // "in the wild" section is reliable regardless of build-time env. Verified to 37 + // host the "Round 29" at.atjam.round with a site.eptss.song subject, owned by 38 + // the everyoneplaysthesamesong.com handle. 39 + const EPTSS_ORGANIZER_DID = "did:plc:pf6izdjdonyd46isl3txwu4g"; 40 + 31 41 export const meta: Route.MetaFunction = () => [ 32 - { title: "Test scenarios · atjam" }, 42 + { title: "Scenarios · atjam" }, 33 43 { 34 44 name: "description", 35 45 content: 36 - "Live test rounds on atjam — one per documented behavior. Navigate to any to visually verify the system works as expected.", 46 + "A working tour of every behavior atjam can express — each one a live round on the network, not a mockup.", 37 47 }, 38 48 { name: "robots", content: "noindex" }, // test data; don't index 39 49 ]; ··· 118 128 } 119 129 120 130 export async function loader() { 121 - const [roundsRes, organizer] = await Promise.all([ 131 + const [roundsRes, organizer, eptssRes, eptssOrg] = await Promise.all([ 122 132 listRecords<Round.Round>({ 123 133 did: TEST_ORGANIZER_DID, 124 134 collection: "at.atjam.round", 125 135 limit: 100, 126 136 }), 127 137 resolveDid(TEST_ORGANIZER_DID).catch(() => null), 138 + // EPTSS is a real adopter, not test data — tolerate it being unreachable 139 + // rather than failing the whole page. 140 + listRecords<Round.Round>({ 141 + did: EPTSS_ORGANIZER_DID, 142 + collection: "at.atjam.round", 143 + limit: 100, 144 + }).catch(() => null), 145 + resolveDid(EPTSS_ORGANIZER_DID).catch(() => null), 128 146 ]); 129 147 const categorized = roundsRes.records.map(categorize); 148 + const eptss = (eptssRes?.records ?? []).map((record) => ({ 149 + record, 150 + state: Round.deriveState(record.value), 151 + })); 130 152 return { 131 153 categorized, 132 154 organizerHandle: organizer?.handle ?? null, 155 + eptss, 156 + eptssHandle: eptssOrg?.handle ?? null, 133 157 }; 134 158 } 135 159 136 - const CATEGORY_ORDER: CategorizedRound["category"][] = [ 137 - "lifecycle", 138 - "timeline", 139 - "gating", 140 - "shape", 141 - "empty", 142 - "other", 143 - ]; 160 + // ───────────────────────────────────────────────────────────────────────── 161 + // View 162 + 163 + const STATE_ORDER: Round.RoundState[] = ["open", "in-progress", "closed"]; 144 164 145 - const CATEGORY_TITLES: Record<CategorizedRound["category"], string> = { 146 - lifecycle: "Lifecycle", 147 - timeline: "Timeline / phases", 148 - gating: "Gating", 149 - shape: "Shape", 150 - empty: "Empty state", 151 - other: "Other", 165 + const MODE_MEANING: Record<Round.JoinMode, string> = { 166 + open: "Anyone may sign up — no invitation needed.", 167 + hosted: "The organizer must invite you directly.", 168 + network: "Invitations chain, but every chain must root at the organizer.", 152 169 }; 153 170 154 - const CATEGORY_BLURBS: Record<CategorizedRound["category"], string> = { 155 - lifecycle: 156 - "How a round's state derives from its milestones — open before signup-deadline, in-progress between deadlines, closed after submission-deadline.", 157 - timeline: 158 - "Custom milestone labels as named phases (the EPTSS pattern). The Timeline renders each as a station; Round.deriveCurrentPhase reports which phase 'now' is in.", 159 - gating: 160 - "How joinMode + invitation records + the validator interact. Hosted requires organizer invitations; network builds chains; the validator distinguishes legitimate from spoofed.", 161 - shape: 162 - "Optional structured fields and multi-type acceptance — confirms the page renders cleanly when these are populated.", 163 - empty: 164 - "Fresh round with nothing happening yet — confirms the empty-state UI for signups and submissions sections.", 165 - other: 166 - "Scenarios that don't fit a named category yet.", 167 - }; 171 + function roundPath(uri: string): string { 172 + const { did, rkey } = parseAtUri(uri); 173 + return `/round/${did}/${rkey}`; 174 + } 175 + 176 + /** A narrative section: hairline rule, title, framing prose, then a body. */ 177 + function Story({ 178 + title, 179 + intro, 180 + children, 181 + }: { 182 + title: string; 183 + intro: ReactNode; 184 + children: ReactNode; 185 + }) { 186 + return ( 187 + <section className="mt-16"> 188 + <h2 className="border-t border-line pt-8 text-xl font-semibold text-ink"> 189 + {title} 190 + </h2> 191 + <p className="mt-3 max-w-2xl leading-relaxed text-ink-2">{intro}</p> 192 + <div className="mt-8">{children}</div> 193 + </section> 194 + ); 195 + } 196 + 197 + /** 198 + * Read a song out of a round's `subject` defensively. `subject` is typed 199 + * `unknown` (the open extension point), so we probe for the EPTSS shape 200 + * ({ title, artist }) and degrade to nothing if it's some other domain. 201 + */ 202 + function songCaption(subject: unknown): ReactNode { 203 + if (!subject || typeof subject !== "object") return null; 204 + const s = subject as Record<string, unknown>; 205 + if (typeof s.title !== "string") return null; 206 + const artist = typeof s.artist === "string" ? s.artist : null; 207 + return ( 208 + <> 209 + <span aria-hidden>♪ </span> 210 + <span className="text-ink-2">{s.title}</span> 211 + {artist ? <span className="text-ink-3"> — {artist}</span> : null} 212 + </> 213 + ); 214 + } 215 + 216 + /** 217 + * If a round reads OPEN only because it has no `submission-deadline` (every 218 + * milestone has already passed), surface the documented gotcha inline. Fires 219 + * for the real EPTSS Round 29 and the test gotcha scenario alike; stays silent 220 + * on rounds that are legitimately open (future milestones) or properly closed. 221 + */ 222 + function openGotchaNote(round: Round.Round, state: Round.RoundState): ReactNode { 223 + if (state !== "open") return null; 224 + const ms = round.milestones; 225 + if (ms.length === 0) return null; 226 + if (ms.some((m) => m.label === "submission-deadline")) return null; 227 + const latest = Math.max(...ms.map((m) => new Date(m.date).getTime())); 228 + if (latest > Date.now()) return null; // genuinely still upcoming 229 + return ( 230 + <p className="mt-4 max-w-2xl text-xs leading-relaxed text-ink-3"> 231 + <span className="font-medium uppercase text-state-progress"> 232 + Reads open 233 + </span>{" "} 234 + — the timeline has no{" "} 235 + <code className="text-ink-2">submission-deadline</code>, so{" "} 236 + <code className="text-ink-2">deriveState</code> never closes it even though 237 + every milestone has passed: the documented{" "} 238 + <Link to="/docs/extending" className="text-signal hover:underline"> 239 + “keep a submission-deadline” gotcha 240 + </Link> 241 + , captured live. 242 + </p> 243 + ); 244 + } 245 + 246 + /** A scenario shown as its full transit line — the lifecycle/timeline stars. */ 247 + function TransitCard({ 248 + record, 249 + caption, 250 + }: { 251 + record: RoundRecord; 252 + caption?: ReactNode; 253 + }) { 254 + const v = record.value; 255 + const state = Round.deriveState(v); 256 + return ( 257 + <div> 258 + <Link to={roundPath(record.uri)} className="group inline-block"> 259 + <span className="text-base font-medium text-ink group-hover:underline"> 260 + {v.name ?? "Untitled round"} 261 + </span> 262 + </Link> 263 + {caption ? <div className="mt-1 text-sm">{caption}</div> : null} 264 + <div className="mt-5"> 265 + <TransitLine round={v} /> 266 + </div> 267 + {openGotchaNote(v, state)} 268 + </div> 269 + ); 270 + } 271 + 272 + /** A scenario as a compact timetable row — join glyph, name, state, excerpt. */ 273 + function ScenarioRow({ c }: { c: CategorizedRound }) { 274 + const v = c.record.value; 275 + const mode = JOIN_MODE_GLYPHS[Round.getJoinMode(v)]; 276 + return ( 277 + <li className="border-b border-line last:border-0"> 278 + <Link to={roundPath(c.record.uri)} className="group flex gap-4 py-4"> 279 + <span 280 + aria-hidden 281 + title={mode.label} 282 + className="mt-0.5 w-4 shrink-0 text-center text-lg leading-none text-ink-3" 283 + > 284 + {mode.glyph} 285 + </span> 286 + <div className="min-w-0 flex-1"> 287 + <div className="flex flex-wrap items-baseline gap-x-3 gap-y-1"> 288 + <span className="font-medium text-ink group-hover:underline"> 289 + {v.name ?? "Untitled round"} 290 + </span> 291 + <StatePill state={c.state} /> 292 + </div> 293 + <p className="mt-1 line-clamp-2 text-sm text-ink-3">{v.assignment}</p> 294 + </div> 295 + </Link> 296 + </li> 297 + ); 298 + } 299 + 300 + /** Up-front key to the visual language: state colors and join-mode glyphs. */ 301 + function Legend() { 302 + return ( 303 + <div className="mt-8 flex flex-col gap-3 border-y border-line py-4 text-xs text-ink-3 sm:flex-row sm:items-center sm:gap-10"> 304 + <div className="flex items-center gap-3"> 305 + <span className="text-label uppercase">State</span> 306 + {STATE_ORDER.map((s) => ( 307 + <span key={s} className="inline-flex items-center gap-1.5"> 308 + <span 309 + aria-hidden 310 + className={`h-2.5 w-2.5 rounded-full ${STATE_VISUALS[s].track}`} 311 + /> 312 + <span className={STATE_VISUALS[s].text}> 313 + {STATE_VISUALS[s].label} 314 + </span> 315 + </span> 316 + ))} 317 + </div> 318 + <div className="flex items-center gap-3"> 319 + <span className="text-label uppercase">Join</span> 320 + {(["open", "hosted", "network"] as Round.JoinMode[]).map((m) => ( 321 + <span key={m} className="inline-flex items-center gap-1.5"> 322 + <span aria-hidden className="text-base leading-none"> 323 + {JOIN_MODE_GLYPHS[m].glyph} 324 + </span> 325 + <span>{JOIN_MODE_GLYPHS[m].label}</span> 326 + </span> 327 + ))} 328 + </div> 329 + </div> 330 + ); 331 + } 168 332 169 333 export default function Scenarios({ loaderData }: Route.ComponentProps) { 170 - const { categorized, organizerHandle } = loaderData; 334 + const { categorized, organizerHandle, eptss, eptssHandle } = loaderData; 171 335 172 - // Bucket by category, preserving CATEGORY_ORDER. 173 336 const buckets = new Map<CategorizedRound["category"], CategorizedRound[]>(); 174 - for (const cat of CATEGORY_ORDER) buckets.set(cat, []); 175 - for (const c of categorized) buckets.get(c.category)!.push(c); 337 + for (const c of categorized) { 338 + const list = buckets.get(c.category) ?? []; 339 + list.push(c); 340 + buckets.set(c.category, list); 341 + } 342 + const get = (cat: CategorizedRound["category"]) => buckets.get(cat) ?? []; 176 343 177 - const hasAny = categorized.length > 0; 344 + const lifecycle = [...get("lifecycle")].sort( 345 + (a, b) => STATE_ORDER.indexOf(a.state) - STATE_ORDER.indexOf(b.state), 346 + ); 347 + const timeline = get("timeline"); 348 + const gating = get("gating"); 349 + const shape = get("shape"); 350 + const empty = get("empty"); 351 + const other = get("other"); 178 352 179 353 return ( 180 - <article className="prose prose-stone max-w-none dark:prose-invert"> 181 - <h1 className="text-3xl font-semibold tracking-tight"> 182 - Test scenarios 354 + <div className="text-ink-2"> 355 + <h1 className="text-3xl font-semibold tracking-tight text-ink"> 356 + Scenarios 183 357 </h1> 184 - <p className="mt-2 text-sm text-ink-3"> 185 - Live rounds on the network, one per documented behavior. 358 + <p className="mt-3 max-w-2xl leading-relaxed text-ink-2"> 359 + A working tour of every behavior atjam can express — each one a{" "} 360 + <em>live</em> round on the network, not a mockup. Read top to bottom: a 361 + round is born, opens, runs, and closes; people join under different 362 + trust models; and a round carries just enough structure to stay generic. 363 + </p> 364 + <p className="mt-3 max-w-2xl text-sm text-ink-3"> 365 + Every round here is a real record on the network.{" "} 366 + {eptss.length > 0 ? ( 367 + <>It opens with a jam running in production, then walks </> 368 + ) : ( 369 + <>It walks </> 370 + )} 371 + every behavior through a deliberate test set 372 + {organizerHandle ? ( 373 + <> 374 + {" "} 375 + on{" "} 376 + <span className="font-mono text-ink-2">@{organizerHandle}</span>’s 377 + PDS 378 + </> 379 + ) : null} 380 + , created by <code className="text-ink-2">pnpm populate</code>. Click 381 + any to open it. 186 382 </p> 187 383 188 - <section className="mt-8 space-y-4 text-ink-2"> 189 - <p> 190 - These are real records on a real PDS, owned by{" "} 191 - {organizerHandle ? ( 192 - <span className="font-mono text-ink"> 193 - @{organizerHandle} 194 - </span> 195 - ) : ( 196 - <span className="font-mono text-ink-3"> 197 - the test organizer 198 - </span> 199 - )} 200 - . They're created by{" "} 201 - <code className="text-sm">pnpm populate</code> in{" "} 202 - <code className="text-sm">tests/</code> and persist between 203 - runs (re-running resets first, so the set stays the same). 204 - </p> 205 - <p> 206 - Click any scenario below to see how the round detail page 207 - renders it. Each scenario's <em>assignment</em> field 208 - describes what to look for. 209 - </p> 210 - </section> 384 + <Legend /> 385 + 386 + {eptss.length > 0 && ( 387 + <Story 388 + title="In the wild: EPTSS" 389 + intro={ 390 + <> 391 + Not a fixture — this is{" "} 392 + <strong className="font-medium text-ink">EPTSS</strong> (“Everyone 393 + Plays the Same Song”) running live on atjam 394 + {eptssHandle ? ( 395 + <> 396 + , owned by{" "} 397 + <span className="font-mono text-ink-2">@{eptssHandle}</span> 398 + </> 399 + ) : null} 400 + . Each round names a song as its{" "} 401 + <code className="text-ink-2">subject</code>; participants submit a 402 + cover (an <code className="text-ink-2">fm.plyr.track</code>). Same 403 + primitives, same transit line as the test rounds below — see{" "} 404 + <Link to="/docs/extending" className="text-signal hover:underline"> 405 + how it maps onto the extension points 406 + </Link> 407 + . 408 + </> 409 + } 410 + > 411 + <div className="space-y-12"> 412 + {eptss.map(({ record }) => ( 413 + <TransitCard 414 + key={record.uri} 415 + record={record} 416 + caption={songCaption(record.value.subject)} 417 + /> 418 + ))} 419 + </div> 420 + </Story> 421 + )} 211 422 212 - {!hasAny ? ( 213 - <section className="mt-10 rounded border border-line bg-panel p-6 text-sm text-ink-2"> 214 - <p>No scenarios populated yet. Run:</p> 215 - <pre className="mt-2 rounded bg-panel p-3 text-xs"> 423 + {categorized.length === 0 ? ( 424 + <section className="mt-16 border-t border-line pt-8 text-sm text-ink-2"> 425 + <h2 className="text-xl font-semibold text-ink">The test set</h2> 426 + <p className="mt-3">No test scenarios populated yet. Run:</p> 427 + <pre className="mt-3 overflow-x-auto rounded border border-line bg-panel p-4 text-xs"> 216 428 cd tests &amp;&amp; pnpm populate 217 429 </pre> 218 430 </section> 219 431 ) : ( 220 - CATEGORY_ORDER.map((cat) => { 221 - const rounds = buckets.get(cat) ?? []; 222 - if (rounds.length === 0) return null; 223 - return ( 224 - <section key={cat} className="mt-12"> 225 - <h2 className="mt-12 text-xl font-semibold text-ink"> 226 - {CATEGORY_TITLES[cat]} 227 - </h2> 228 - <p className="mt-1 text-sm text-ink-3"> 229 - {CATEGORY_BLURBS[cat]} 230 - </p> 231 - <ul className="mt-4 space-y-3"> 232 - {rounds.map(({ record, state }) => { 233 - const { did, rkey } = parseAtUri(record.uri); 234 - return ( 235 - <li key={record.uri}> 236 - <Link 237 - to={`/round/${did}/${rkey}`} 238 - className="block rounded border border-line p-4 hover:bg-panel" 239 - > 240 - <div className="mb-1 flex items-baseline gap-3"> 241 - <span className="font-medium text-ink"> 242 - {record.value.name ?? "Untitled round"} 243 - </span> 244 - <StatePill state={state} /> 245 - </div> 246 - <p className="line-clamp-3 text-sm text-ink-2"> 247 - {record.value.assignment} 248 - </p> 249 - </Link> 250 - </li> 251 - ); 252 - })} 432 + <> 433 + {lifecycle.length > 0 && ( 434 + <Story 435 + title="The life of a round" 436 + intro={ 437 + <> 438 + A round has no status field — its state is <em>derived</em>{" "} 439 + from where <span className="text-signal">now</span> falls among 440 + its milestones. Open before the signup deadline, in-progress 441 + between the deadlines, closed once submissions close. Same 442 + record shape; watch the red <span className="text-signal"> 443 + now 444 + </span>{" "} 445 + dot sit in a different stretch of track in each. 446 + </> 447 + } 448 + > 449 + <div className="space-y-12"> 450 + {lifecycle.map((c) => ( 451 + <TransitCard key={c.record.uri} record={c.record} /> 452 + ))} 453 + </div> 454 + </Story> 455 + )} 456 + 457 + {timeline.length > 0 && ( 458 + <Story 459 + title="Naming the phases" 460 + intro={ 461 + <> 462 + Milestones are an open list. Past the two standard deadlines you 463 + can add as many named phases as you like — each becomes another 464 + station, and <code className="text-ink-2">deriveCurrentPhase</code>{" "} 465 + reports which stretch now is in. The catch: state only{" "} 466 + <em>closes</em> on a{" "} 467 + <code className="text-ink-2">submission-deadline</code>, so a 468 + round built from custom labels alone reads OPEN forever. 469 + </> 470 + } 471 + > 472 + <div className="space-y-12"> 473 + {timeline.map((c) => ( 474 + <TransitCard key={c.record.uri} record={c.record} /> 475 + ))} 476 + </div> 477 + </Story> 478 + )} 479 + 480 + {gating.length > 0 && ( 481 + <Story 482 + title="Who's allowed in" 483 + intro={ 484 + <> 485 + Three trust models, encoded by <em>form</em> rather than colour 486 + (colour is reserved for state). A signup cites the invitation 487 + that admitted it; a read-time validator then walks the chain and 488 + separates legitimate from spoofed — open any round to see the 489 + green / amber badges. 490 + </> 491 + } 492 + > 493 + <dl className="mb-8 grid gap-x-8 gap-y-4 sm:grid-cols-3"> 494 + {(["open", "hosted", "network"] as Round.JoinMode[]).map((m) => ( 495 + <div key={m} className="border-t border-line pt-3"> 496 + <dt className="flex items-baseline gap-2"> 497 + <span aria-hidden className="text-lg leading-none"> 498 + {JOIN_MODE_GLYPHS[m].glyph} 499 + </span> 500 + <span className="font-medium capitalize text-ink"> 501 + {m} 502 + </span> 503 + </dt> 504 + <dd className="mt-1 text-sm text-ink-3">{MODE_MEANING[m]}</dd> 505 + </div> 506 + ))} 507 + </dl> 508 + <ul> 509 + {gating.map((c) => ( 510 + <ScenarioRow key={c.record.uri} c={c} /> 511 + ))} 253 512 </ul> 254 - </section> 255 - ); 256 - }) 513 + </Story> 514 + )} 515 + 516 + {shape.length > 0 && ( 517 + <Story 518 + title="What a round carries" 519 + intro={ 520 + <> 521 + A round stays generic on purpose. Domain specifics ride optional,{" "} 522 + <code className="text-ink-2">$type</code>-tagged extension 523 + points — a <code className="text-ink-2">subject</code> and a{" "} 524 + <code className="text-ink-2">closingEvent</code> — plus a list of 525 + accepted submission types. atjam never learns what your domain 526 + is. 527 + </> 528 + } 529 + > 530 + <ul> 531 + {shape.map((c) => ( 532 + <ScenarioRow key={c.record.uri} c={c} /> 533 + ))} 534 + </ul> 535 + </Story> 536 + )} 537 + 538 + {empty.length > 0 && ( 539 + <Story 540 + title="The quiet baseline" 541 + intro={ 542 + <> 543 + A fresh round with nobody in it yet — proof the zero case is 544 + drawn as deliberately as the full one. 545 + </> 546 + } 547 + > 548 + <ul> 549 + {empty.map((c) => ( 550 + <ScenarioRow key={c.record.uri} c={c} /> 551 + ))} 552 + </ul> 553 + </Story> 554 + )} 555 + 556 + {other.length > 0 && ( 557 + <Story title="Other" intro={<>Scenarios without a named home yet.</>}> 558 + <ul> 559 + {other.map((c) => ( 560 + <ScenarioRow key={c.record.uri} c={c} /> 561 + ))} 562 + </ul> 563 + </Story> 564 + )} 565 + </> 257 566 )} 258 - </article> 567 + </div> 259 568 ); 260 569 }