Adapt Launch UI into a landing component set, and build the homepage from it

c4513dc0d911 · AtlantisPleb · · parent 5d64c6104af3

Adapt Launch UI into a landing component set, and build the homepage from it

Thirteen components in `OpenAgentsWeb.UI.Landing`, adapted from Launch UI
(MIT, © 2024 Mikolaj Dobrucki): section, hero, glow, beam, mockup, feature
grid, stats, pricing column, FAQ, CTA, logo wall, footer, layout lines. All
thirteen are catalogued and demoed, so the coverage test holds them.

Nothing is copied. The source is React and Tailwind -- `cva` variant maps over
utility strings, Radix primitives, default content baked into each section --
and none of that survives the move to HEEx. What carried over is composition
and visual judgement: that a glow is two stacked ellipses rather than one,
that a mockup frame is a second wider border rather than a drawn device, that
a feature grid carries no cell borders because a grid of boxes competes with
itself for attention, that a pricing column is lit by a fading top rule rather
than an accent fill.

Two departures are deliberate. Launch UI paints with its own `brand` palette
and layered `glass-*` gradients; every value here resolves to an existing
token instead, because a landing page that does not look like the product it
advertises is worse than a plainer one that does. And there is no JavaScript:
the FAQ is a native `<details>`, so nothing on the page a visitor loads first
waits on a bundle. The cost is that a native disclosure snaps rather than
animating open, which is stated in the adaptation doc rather than hidden.

The homepage is now composed from these rather than from page-local markup, so
it and the component library cannot drift. The hero keeps the copy the page
already had; the bands below it are new. `app/1` gained a `flush` mode that
skips the centred reading wrapper, because landing bands set their own rhythm
against the viewport and a wrapper's padding fights it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149rBWy7br1Z7bbz9NrQhEr
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified assets/css/openagents.css
  • added docs/2026-08-20-launch-ui-adaptation.md
  • modified lib/openagents_web/component_catalog.ex
  • added lib/openagents_web/components/landing.ex
  • modified lib/openagents_web/components/layouts.ex
  • modified lib/openagents_web/live/components_live.ex
  • modified lib/openagents_web/live/home_live.ex

Diff

7 files changed, +1947 -41

assets/css/openagents.css modified +889

@@ -3215,3 +3215,892 @@

3215 3215
    padding-block-start: 4px;
3216 3216
  }
3217 3217
}
3218
3219
/* ── Landing ──────────────────────────────────────────────────────────────── */
3220
3221
/* Marketing composition, adapted from Launch UI (MIT, © 2024 Mikolaj
3222
 * Dobrucki). The source is React and Tailwind and paints with its own `brand`
3223
 * palette and layered `glass-*` gradients; every value here resolves to an
3224
 * existing OpenAgents token instead, because a landing page that does not look
3225
 * like the product it advertises is worse than a plainer one that does.
3226
 *
3227
 * Sections are divided by a hairline rather than by fill, so the page reads as
3228
 * one surface in parts rather than a stack of coloured panels. */
3229
3230
@layer components {
3231
  .landing-section {
3232
    position: relative;
3233
    border-bottom: 1px solid var(--line-faint);
3234
    padding: 64px 24px;
3235
  }
3236
3237
  .landing-section--flush {
3238
    border-bottom: 0;
3239
  }
3240
3241
  @media (min-width: 640px) {
3242
    .landing-section {
3243
      padding-block: 96px;
3244
    }
3245
  }
3246
3247
  @media (min-width: 1024px) {
3248
    .landing-section {
3249
      padding-block: 128px;
3250
    }
3251
  }
3252
3253
  .landing-section__inner {
3254
    position: relative;
3255
    z-index: 1;
3256
    display: flex;
3257
    width: 100%;
3258
    max-width: 1104px;
3259
    flex-direction: column;
3260
    align-items: center;
3261
    margin-inline: auto;
3262
    gap: 32px;
3263
  }
3264
3265
  .landing-heading {
3266
    max-width: 36ch;
3267
    color: var(--text-primary);
3268
    font-size: clamp(1.75rem, 1.2rem + 2.4vw, 3rem);
3269
    font-weight: 600;
3270
    letter-spacing: -0.02em;
3271
    line-height: 1.15;
3272
    text-align: center;
3273
    text-wrap: balance;
3274
  }
3275
3276
  /* ── Glow ───────────────────────────────────────────────────────────────── */
3277
3278
  /* Two ellipses, not one: a wide faint wash under a narrower dense core. A
3279
     single gradient reads as a flat smudge; the pair reads as light. Both are
3280
     ellipses via a 50% radius on a wide, short box, which is cheaper than a
3281
     blur filter and does not cost a compositor layer. */
3282
  .glow {
3283
    position: absolute;
3284
    left: 0;
3285
    width: 100%;
3286
    pointer-events: none;
3287
  }
3288
3289
  .glow--top { top: 0; }
3290
  .glow--above { top: -128px; }
3291
  .glow--bottom { bottom: 0; }
3292
  .glow--below { bottom: -128px; }
3293
  .glow--center { top: 50%; }
3294
3295
  .glow__wash,
3296
  .glow__core {
3297
    position: absolute;
3298
    left: 50%;
3299
    border-radius: 50%;
3300
    translate: -50% 0;
3301
  }
3302
3303
  .glow--center .glow__wash,
3304
  .glow--center .glow__core {
3305
    translate: -50% -50%;
3306
  }
3307
3308
  .glow__wash {
3309
    width: 60%;
3310
    height: 256px;
3311
    background: radial-gradient(
3312
      closest-side,
3313
      color-mix(in oklab, var(--text-primary) 22%, transparent) 10%,
3314
      transparent 70%
3315
    );
3316
    scale: 2.5;
3317
  }
3318
3319
  .glow__core {
3320
    width: 40%;
3321
    height: 128px;
3322
    background: radial-gradient(
3323
      closest-side,
3324
      color-mix(in oklab, var(--text-primary) 30%, transparent) 10%,
3325
      transparent 70%
3326
    );
3327
    scale: 2;
3328
  }
3329
3330
  /* Light mode carries far less of it: the same intensity that reads as a lit
3331
     backdrop on a dark surface reads as a stain on a white one. */
3332
  :root[data-theme="light"] .glow,
3333
  :root:not([data-theme="dark"]) .glow {
3334
    opacity: 0.35;
3335
  }
3336
3337
  @media (min-width: 640px) {
3338
    .glow__wash { height: 512px; }
3339
    .glow__core { height: 256px; }
3340
  }
3341
3342
  /* ── Beam ───────────────────────────────────────────────────────────────── */
3343
3344
  .beam {
3345
    position: relative;
3346
  }
3347
3348
  .beam::after {
3349
    content: "";
3350
    position: absolute;
3351
    z-index: -1;
3352
    inset: 0;
3353
    border-radius: 50%;
3354
    scale: 2;
3355
  }
3356
3357
  .beam--default::after {
3358
    background: radial-gradient(
3359
      closest-side,
3360
      color-mix(in oklab, var(--text-primary) 26%, transparent) 10%,
3361
      transparent 65%
3362
    );
3363
  }
3364
3365
  .beam--bright::after {
3366
    background: radial-gradient(
3367
      closest-side,
3368
      color-mix(in oklab, var(--text-ink) 34%, transparent) 10%,
3369
      transparent 65%
3370
    );
3371
  }
3372
3373
  /* ── Mockup ─────────────────────────────────────────────────────────────── */
3374
3375
  /* A second, wider border in a lighter fill is what reads as a device edge.
3376
     Drawing a literal laptop dates the page and fixes the aspect ratio of
3377
     whatever goes inside it. */
3378
  .mockup-frame {
3379
    position: relative;
3380
    z-index: 1;
3381
    display: flex;
3382
    overflow: hidden;
3383
    border-radius: 20px;
3384
    background: color-mix(in oklab, var(--line) 45%, transparent);
3385
  }
3386
3387
  .mockup-frame--small { padding: 8px; }
3388
  .mockup-frame--large { padding: 16px; }
3389
3390
  .mockup {
3391
    position: relative;
3392
    z-index: 1;
3393
    display: flex;
3394
    width: 100%;
3395
    overflow: hidden;
3396
    border: 1px solid var(--line);
3397
    background: var(--ink-surface);
3398
    box-shadow: var(--lift);
3399
  }
3400
3401
  .mockup--window { border-radius: 12px; }
3402
3403
  .mockup--phone {
3404
    max-width: 350px;
3405
    border-radius: 44px;
3406
  }
3407
3408
  .mockup > * {
3409
    width: 100%;
3410
    height: auto;
3411
  }
3412
3413
  /* ── Hero ───────────────────────────────────────────────────────────────── */
3414
3415
  .hero__lede {
3416
    display: flex;
3417
    flex-direction: column;
3418
    align-items: center;
3419
    gap: 24px;
3420
    text-align: center;
3421
  }
3422
3423
  .hero__title {
3424
    max-width: 20ch;
3425
    color: var(--text-primary);
3426
    font-size: clamp(2.5rem, 1.4rem + 5vw, 5.5rem);
3427
    font-weight: 600;
3428
    letter-spacing: -0.035em;
3429
    line-height: 1.05;
3430
    text-wrap: balance;
3431
  }
3432
3433
  .hero__description {
3434
    max-width: 46rem;
3435
    color: var(--text-muted);
3436
    font-size: clamp(1rem, 0.94rem + 0.3vw, 1.25rem);
3437
    line-height: 1.55;
3438
    text-wrap: balance;
3439
  }
3440
3441
  .hero__actions {
3442
    display: flex;
3443
    flex-wrap: wrap;
3444
    justify-content: center;
3445
    gap: 12px;
3446
  }
3447
3448
  .hero__figure {
3449
    position: relative;
3450
    width: 100%;
3451
    padding-top: 32px;
3452
  }
3453
3454
  .hero__figure-inner {
3455
    position: relative;
3456
    z-index: 1;
3457
  }
3458
3459
  /* ── Features ───────────────────────────────────────────────────────────── */
3460
3461
  /* No borders and no fills on the cells. A grid of boxes competes with itself
3462
     for attention, and the alignment already separates them. */
3463
  .feature-grid {
3464
    display: grid;
3465
    width: 100%;
3466
    grid-template-columns: repeat(2, minmax(0, 1fr));
3467
    gap: 8px;
3468
  }
3469
3470
  @media (min-width: 640px) {
3471
    .feature-grid {
3472
      grid-template-columns: repeat(3, minmax(0, 1fr));
3473
      gap: 16px;
3474
    }
3475
  }
3476
3477
  @media (min-width: 1024px) {
3478
    .feature-grid {
3479
      grid-template-columns: repeat(4, minmax(0, 1fr));
3480
    }
3481
  }
3482
3483
  .feature {
3484
    display: flex;
3485
    flex-direction: column;
3486
    gap: 8px;
3487
    padding: 16px;
3488
  }
3489
3490
  .feature__title {
3491
    display: flex;
3492
    align-items: center;
3493
    gap: 8px;
3494
    color: var(--text-primary);
3495
    font-size: 0.9375rem;
3496
    font-weight: 600;
3497
    letter-spacing: -0.01em;
3498
    line-height: 1.3;
3499
  }
3500
3501
  .feature__icon {
3502
    width: 18px;
3503
    height: 18px;
3504
    flex: none;
3505
    color: var(--icon-secondary);
3506
    font-size: 18px;
3507
  }
3508
3509
  .feature__description {
3510
    color: var(--text-muted);
3511
    font-size: 0.875rem;
3512
    line-height: 1.5;
3513
    text-wrap: pretty;
3514
  }
3515
3516
  /* ── Stats ──────────────────────────────────────────────────────────────── */
3517
3518
  .stat-row {
3519
    display: grid;
3520
    width: 100%;
3521
    max-width: 960px;
3522
    grid-template-columns: repeat(2, minmax(0, 1fr));
3523
    gap: 40px;
3524
  }
3525
3526
  @media (min-width: 640px) {
3527
    .stat-row {
3528
      grid-template-columns: repeat(4, minmax(0, 1fr));
3529
    }
3530
  }
3531
3532
  .stat {
3533
    display: flex;
3534
    flex-direction: column;
3535
    align-items: flex-start;
3536
    gap: 8px;
3537
  }
3538
3539
  .stat__label {
3540
    color: var(--text-muted);
3541
    font-size: 0.8125rem;
3542
    font-weight: 600;
3543
  }
3544
3545
  .stat__value {
3546
    display: flex;
3547
    align-items: baseline;
3548
    gap: 4px;
3549
    color: var(--text-primary);
3550
    font-size: clamp(2.25rem, 1.5rem + 3vw, 3.75rem);
3551
    font-weight: 500;
3552
    font-variant-numeric: tabular-nums;
3553
    letter-spacing: -0.03em;
3554
    line-height: 1;
3555
  }
3556
3557
  .stat__suffix {
3558
    color: var(--text-muted);
3559
    font-size: 0.4em;
3560
    font-weight: 600;
3561
  }
3562
3563
  .stat__description {
3564
    color: var(--text-muted);
3565
    font-size: 0.8125rem;
3566
    font-weight: 500;
3567
    text-wrap: pretty;
3568
  }
3569
3570
  /* ── Pricing ────────────────────────────────────────────────────────────── */
3571
3572
  .pricing-column {
3573
    position: relative;
3574
    display: flex;
3575
    flex: 1;
3576
    flex-direction: column;
3577
    gap: 24px;
3578
    overflow: hidden;
3579
    border: 1px solid var(--line);
3580
    border-radius: var(--radius-lg);
3581
    background: var(--ink-surface);
3582
    padding: 28px;
3583
  }
3584
3585
  /* A hairline that fades at both ends, so the top of the column is lit rather
3586
     than boxed. On the featured column it is brighter, which is the whole of
3587
     the emphasis -- no accent fill, no scaling. */
3588
  .pricing-column__rule {
3589
    position: absolute;
3590
    top: 0;
3591
    left: 10%;
3592
    width: 80%;
3593
    height: 1px;
3594
    border: 0;
3595
    background: linear-gradient(
3596
      to right,
3597
      transparent,
3598
      color-mix(in oklab, var(--text-primary) 45%, transparent),
3599
      transparent
3600
    );
3601
  }
3602
3603
  .pricing-column--featured {
3604
    border-color: var(--line-strong);
3605
    box-shadow: var(--lift);
3606
  }
3607
3608
  .pricing-column--featured .pricing-column__rule {
3609
    background: linear-gradient(to right, transparent, var(--text-primary), transparent);
3610
  }
3611
3612
  .pricing-column__header {
3613
    display: flex;
3614
    flex-direction: column;
3615
    gap: 6px;
3616
  }
3617
3618
  .pricing-column__name {
3619
    color: var(--text-primary);
3620
    font-size: 1rem;
3621
    font-weight: 700;
3622
  }
3623
3624
  .pricing-column__description {
3625
    max-width: 26ch;
3626
    color: var(--text-muted);
3627
    font-size: 0.875rem;
3628
    line-height: 1.5;
3629
  }
3630
3631
  .pricing-column__price {
3632
    display: flex;
3633
    align-items: baseline;
3634
    gap: 6px;
3635
    color: var(--text-primary);
3636
    font-size: 2.5rem;
3637
    font-weight: 500;
3638
    letter-spacing: -0.03em;
3639
    line-height: 1;
3640
  }
3641
3642
  .pricing-column__note {
3643
    color: var(--text-muted);
3644
    font-size: 0.8125rem;
3645
    font-weight: 500;
3646
  }
3647
3648
  .pricing-column__features {
3649
    display: flex;
3650
    flex-direction: column;
3651
    gap: 10px;
3652
    margin: 0;
3653
    padding: 0;
3654
    list-style: none;
3655
  }
3656
3657
  .pricing-column__features li {
3658
    display: flex;
3659
    align-items: flex-start;
3660
    gap: 8px;
3661
    color: var(--text-muted);
3662
    font-size: 0.875rem;
3663
    line-height: 1.4;
3664
  }
3665
3666
  .pricing-column__features .icon {
3667
    width: 16px;
3668
    height: 16px;
3669
    flex: none;
3670
    margin-top: 2px;
3671
    color: var(--icon-tertiary);
3672
    font-size: 16px;
3673
  }
3674
3675
  /* ── FAQ ────────────────────────────────────────────────────────────────── */
3676
3677
  .faq-list {
3678
    display: flex;
3679
    width: 100%;
3680
    max-width: 800px;
3681
    flex-direction: column;
3682
  }
3683
3684
  .faq-item {
3685
    border-bottom: 1px solid var(--line-faint);
3686
  }
3687
3688
  .faq-item__question {
3689
    display: flex;
3690
    align-items: center;
3691
    justify-content: space-between;
3692
    gap: 16px;
3693
    cursor: pointer;
3694
    list-style: none;
3695
    padding-block: 20px;
3696
    color: var(--text-primary);
3697
    font-size: 1rem;
3698
    font-weight: 500;
3699
    line-height: 1.4;
3700
    text-align: start;
3701
  }
3702
3703
  .faq-item__question::-webkit-details-marker {
3704
    display: none;
3705
  }
3706
3707
  .faq-item__question:focus-visible {
3708
    outline: 2px solid var(--ring);
3709
    outline-offset: -2px;
3710
  }
3711
3712
  .faq-item__caret {
3713
    width: 16px;
3714
    height: 16px;
3715
    flex: none;
3716
    color: var(--icon-tertiary);
3717
    font-size: 16px;
3718
    transition: transform var(--motion-fast, 150ms) var(--ease);
3719
  }
3720
3721
  .faq-item[open] .faq-item__caret {
3722
    transform: rotate(90deg);
3723
  }
3724
3725
  .faq-item__answer {
3726
    padding-block-end: 20px;
3727
    color: var(--text-muted);
3728
    font-size: 0.9375rem;
3729
    line-height: 1.6;
3730
    text-wrap: pretty;
3731
  }
3732
3733
  .faq-item__answer > * + * {
3734
    margin-block-start: 0.75em;
3735
  }
3736
3737
  /* ── CTA ────────────────────────────────────────────────────────────────── */
3738
3739
  .cta {
3740
    overflow: hidden;
3741
  }
3742
3743
  .cta__description {
3744
    max-width: 44ch;
3745
    color: var(--text-muted);
3746
    font-size: 1rem;
3747
    line-height: 1.55;
3748
    text-align: center;
3749
    text-wrap: balance;
3750
  }
3751
3752
  .cta__actions {
3753
    display: flex;
3754
    flex-wrap: wrap;
3755
    justify-content: center;
3756
    gap: 12px;
3757
  }
3758
3759
  /* The light rises as the reader arrives at the last thing on the page. */
3760
  .cta__glow {
3761
    translate: 0 16px;
3762
    opacity: 0.8;
3763
    transition:
3764
      translate var(--motion-slow, 500ms) var(--ease),
3765
      opacity var(--motion-slow, 500ms) var(--ease);
3766
  }
3767
3768
  .cta:hover .cta__glow {
3769
    translate: 0 -32px;
3770
    opacity: 1;
3771
  }
3772
3773
  /* ── Logo wall ──────────────────────────────────────────────────────────── */
3774
3775
  .logos__title {
3776
    color: var(--text-muted);
3777
    font-size: 0.8125rem;
3778
    font-weight: 500;
3779
    text-align: center;
3780
  }
3781
3782
  .logo-wall {
3783
    display: flex;
3784
    flex-wrap: wrap;
3785
    align-items: center;
3786
    justify-content: center;
3787
    gap: 24px 40px;
3788
  }
3789
3790
  /* One weight for every mark, so a heavier logo cannot dominate the row. */
3791
  .logo-wall__item {
3792
    display: inline-flex;
3793
    align-items: center;
3794
    gap: 8px;
3795
    color: var(--text-dim);
3796
    font-size: 1rem;
3797
    font-weight: 600;
3798
    letter-spacing: -0.01em;
3799
  }
3800
3801
  .logo-wall__item .icon {
3802
    width: 20px;
3803
    height: 20px;
3804
    font-size: 20px;
3805
  }
3806
3807
  /* ── Footer ─────────────────────────────────────────────────────────────── */
3808
3809
  .landing-footer {
3810
    border-top: 1px solid var(--line);
3811
    padding: 48px 24px 32px;
3812
  }
3813
3814
  .landing-footer__inner {
3815
    display: grid;
3816
    width: 100%;
3817
    max-width: 1104px;
3818
    grid-template-columns: repeat(2, minmax(0, 1fr));
3819
    gap: 32px;
3820
    margin-inline: auto;
3821
  }
3822
3823
  @media (min-width: 768px) {
3824
    .landing-footer__inner {
3825
      grid-template-columns: 2fr repeat(3, minmax(0, 1fr));
3826
    }
3827
  }
3828
3829
  .landing-footer__name {
3830
    color: var(--text-primary);
3831
    font-size: 1rem;
3832
    font-weight: 700;
3833
  }
3834
3835
  .landing-footer__tagline {
3836
    max-width: 32ch;
3837
    padding-block-start: 6px;
3838
    color: var(--text-muted);
3839
    font-size: 0.875rem;
3840
    line-height: 1.5;
3841
  }
3842
3843
  .landing-footer__column {
3844
    display: flex;
3845
    flex-direction: column;
3846
    gap: 8px;
3847
  }
3848
3849
  .landing-footer__column-title {
3850
    color: var(--text-primary);
3851
    font-size: 0.8125rem;
3852
    font-weight: 600;
3853
  }
3854
3855
  .landing-footer__column a {
3856
    color: var(--text-muted);
3857
    font-size: 0.875rem;
3858
    text-decoration: none;
3859
    transition: color 150ms var(--ease);
3860
  }
3861
3862
  @media (hover: hover) {
3863
    .landing-footer__column a:hover {
3864
      color: var(--text-primary);
3865
    }
3866
  }
3867
3868
  .landing-footer__note {
3869
    max-width: 1104px;
3870
    margin-inline: auto;
3871
    padding-block-start: 32px;
3872
    color: var(--text-dim);
3873
    font-size: 0.75rem;
3874
  }
3875
3876
  /* ── Layout lines ───────────────────────────────────────────────────────── */
3877
3878
  .layout-lines {
3879
    position: fixed;
3880
    z-index: 0;
3881
    inset: 0;
3882
    pointer-events: none;
3883
  }
3884
3885
  .layout-lines__column {
3886
    width: 100%;
3887
    max-width: 1104px;
3888
    height: 100%;
3889
    margin-inline: auto;
3890
    border-inline: 1px dashed var(--line-faint);
3891
  }
3892
3893
  /* ── Appear ─────────────────────────────────────────────────────────────── */
3894
3895
  /* Decorative only: the page is complete without it, which is why the
3896
     reduced-motion rule can simply drop the animation and leave the element at
3897
     its final state rather than substituting a shorter one. */
3898
  .appear,
3899
  .appear-zoom {
3900
    animation-duration: 600ms;
3901
    animation-fill-mode: forwards;
3902
    animation-timing-function: var(--ease, cubic-bezier(0.16, 1, 0.3, 1));
3903
    opacity: 0;
3904
  }
3905
3906
  .appear { animation-name: landing-appear; }
3907
  .appear-zoom { animation-name: landing-appear-zoom; }
3908
3909
  .appear--delay-1 { animation-delay: 100ms; }
3910
  .appear--delay-2 { animation-delay: 300ms; }
3911
  .appear--delay-3 { animation-delay: 500ms; }
3912
  .appear--delay-4 { animation-delay: 700ms; }
3913
3914
  @keyframes landing-appear {
3915
    from {
3916
      opacity: 0;
3917
      translate: 0 1rem;
3918
      filter: blur(0.5rem);
3919
    }
3920
    50% {
3921
      filter: blur(0);
3922
    }
3923
    to {
3924
      opacity: 1;
3925
      translate: 0 0;
3926
      filter: blur(0);
3927
    }
3928
  }
3929
3930
  @keyframes landing-appear-zoom {
3931
    from {
3932
      opacity: 0;
3933
      scale: 0.9;
3934
    }
3935
    to {
3936
      opacity: 1;
3937
      scale: 1;
3938
    }
3939
  }
3940
3941
  @media (prefers-reduced-motion: reduce) {
3942
    .appear,
3943
    .appear-zoom {
3944
      animation: none;
3945
      opacity: 1;
3946
    }
3947
3948
    .cta__glow,
3949
    .faq-item__caret {
3950
      transition: none;
3951
    }
3952
  }
3953
}
3954
3955
/* ── Landing demos ────────────────────────────────────────────────────────── */
3956
3957
/* Landing blocks are built to own a viewport, so the component library shows
3958
 * them contained. The frame is the honest part: it says "this is a scaled-down
3959
 * view", rather than pretending the documentation column is a page. */
3960
3961
@layer components {
3962
  .demo-frame {
3963
    position: relative;
3964
    overflow: hidden;
3965
    border: 1px solid var(--line);
3966
    border-radius: var(--radius-lg);
3967
    background: var(--ink-void);
3968
  }
3969
3970
  /* Landing sections set their own generous rhythm against a viewport; inside
3971
     a demo frame that rhythm is the only thing on screen. */
3972
  .demo-frame .landing-section {
3973
    padding-block: 32px;
3974
    padding-inline: 20px;
3975
  }
3976
3977
  .demo-frame .hero__title {
3978
    font-size: clamp(1.75rem, 1.2rem + 2vw, 2.75rem);
3979
  }
3980
3981
  .demo-frame .landing-footer {
3982
    padding: 28px 20px 20px;
3983
  }
3984
3985
  .demo-frame--lines {
3986
    display: flex;
3987
    align-items: center;
3988
    justify-content: center;
3989
    min-height: 160px;
3990
  }
3991
3992
  .demo-screenshot {
3993
    display: flex;
3994
    width: 100%;
3995
    min-height: 120px;
3996
    align-items: center;
3997
    justify-content: center;
3998
    background: var(--ink-raised);
3999
    color: var(--text-dim);
4000
    font-size: 0.8125rem;
4001
  }
4002
4003
  .demo-screenshot--tall {
4004
    min-height: 220px;
4005
  }
4006
4007
  .demo-glow-row {
4008
    display: flex;
4009
    flex-wrap: wrap;
4010
    gap: 12px;
4011
  }
4012
4013
  .demo-glow {
4014
    position: relative;
4015
    display: flex;
4016
    min-width: 160px;
4017
    min-height: 120px;
4018
    flex: 1;
4019
    align-items: center;
4020
    justify-content: center;
4021
    overflow: hidden;
4022
    border: 1px solid var(--line);
4023
    border-radius: var(--radius-lg);
4024
    background: var(--ink-void);
4025
  }
4026
4027
  .demo-glow__label {
4028
    position: relative;
4029
    z-index: 1;
4030
    color: var(--text-muted);
4031
    font-size: 0.75rem;
4032
  }
4033
4034
  .demo-beam {
4035
    display: flex;
4036
    min-width: 160px;
4037
    min-height: 120px;
4038
    flex: 1;
4039
    align-items: center;
4040
    justify-content: center;
4041
    border: 1px solid var(--line);
4042
    border-radius: var(--radius-lg);
4043
    background: var(--ink-void);
4044
  }
4045
4046
  .demo-mockup-row,
4047
  .demo-pricing-row {
4048
    display: flex;
4049
    flex-wrap: wrap;
4050
    align-items: flex-start;
4051
    gap: 16px;
4052
  }
4053
4054
  .demo-mockup-row > *,
4055
  .demo-pricing-row > * {
4056
    min-width: 260px;
4057
    flex: 1;
4058
  }
4059
}
4060
4061
/* The landing page is the one surface that scrolls itself: its bands set
4062
 * vertical rhythm against the viewport, so it owns the main area rather than
4063
 * sitting inside a padded, centred column. */
4064
4065
@layer components {
4066
  .landing-page {
4067
    position: relative;
4068
    min-height: 0;
4069
    flex: 1;
4070
    overflow-y: auto;
4071
    overscroll-behavior: none;
4072
  }
4073
4074
  .landing-figure {
4075
    display: flex;
4076
    width: 100%;
4077
    min-height: 220px;
4078
    flex-direction: column;
4079
    background: var(--ink-raised);
4080
  }
4081
4082
  .landing-figure__bar {
4083
    display: flex;
4084
    flex: none;
4085
    align-items: center;
4086
    gap: 6px;
4087
    border-bottom: 1px solid var(--line);
4088
    padding: 10px 12px;
4089
  }
4090
4091
  .landing-figure__bar span {
4092
    width: 10px;
4093
    height: 10px;
4094
    border-radius: 50%;
4095
    background: var(--line-strong);
4096
  }
4097
4098
  .landing-figure__caption {
4099
    display: flex;
4100
    flex: 1;
4101
    align-items: center;
4102
    justify-content: center;
4103
    color: var(--text-dim);
4104
    font-size: 0.8125rem;
4105
  }
4106
}
docs/2026-08-20-launch-ui-adaptation.md added +114

@@ -0,0 +1,114 @@

1
# Launch UI adaptation
2
3
*2026-08-20*
4
5
`OpenAgentsWeb.UI.Landing` is adapted from [Launch UI][launch-ui], MIT-licensed,
6
© 2024 Mikolaj Dobrucki. This document records what was taken, what was not, and
7
why — so a later reader can tell which decisions are inherited and which are
8
ours.
9
10
## What "adapted" means here
11
12
Nothing was copied. Launch UI is React and Tailwind: each component is a
13
`class-variance-authority` variant map over utility strings, several depend on
14
Radix primitives, and the section components ship with their own default content
15
baked in. None of that survives a move to HEEx and a stylesheet.
16
17
What carried over is **composition and visual judgement**: what a hero contains
18
and in what order, that a glow is two stacked ellipses rather than one, that a
19
mockup frame is a second wider border rather than a drawn device, that a feature
20
grid divides four/three/two by width and carries no cell borders, that a pricing
21
column is lit by a fading top rule rather than an accent fill.
22
23
The MIT licence requires the copyright notice be retained. It is, in the module
24
docs, in the stylesheet section header, and here.
25
26
## Inventory
27
28
Thirteen components, all catalogued and demoed at `/components`:
29
30
| Ours | Source |
31
| --- | --- |
32
| `section/1` | `ui/section.tsx` |
33
| `glow/1` | `ui/glow.tsx` |
34
| `beam/1` | `ui/beam.tsx` |
35
| `mockup/1` | `ui/mockup.tsx` (frame + mockup collapsed into one) |
36
| `hero/1` | `sections/hero/default.tsx` |
37
| `feature_grid/1` | `sections/items/default.tsx` + `ui/item.tsx` |
38
| `stats/1` | `sections/stats/default.tsx` |
39
| `pricing_column/1` | `ui/pricing-column.tsx` |
40
| `faq/1` | `sections/faq/default.tsx` + `ui/accordion.tsx` |
41
| `cta/1` | `sections/cta/default.tsx` |
42
| `logo_wall/1` | `sections/logos/default.tsx` |
43
| `landing_footer/1` | `sections/footer/default.tsx` |
44
| `layout_lines/1` | `ui/layout-lines.tsx` |
45
46
Not adapted: `navbar`, `navigation-menu`, `sheet`, `dropdown-menu`,
47
`mode-toggle`, `screenshot`, `logo`, `link-button`, `button`, `badge`, `card`,
48
`item`. The navigation set duplicates the application's own sidebar and command
49
bar, which already exist and are already the thing a visitor sees on every other
50
page. The rest already exist in `OpenAgentsWeb.UI` or are Next.js-specific.
51
52
## Deliberate departures
53
54
### Tokens, not a second palette
55
56
Launch UI paints with `brand`, `brand-foreground`, and five layered `glass-*`
57
gradients over `card`. Adopting those would put a second colour system beside
58
the one every other surface uses, and a landing page that does not look like the
59
product it advertises is worse than a plainer one that does.
60
61
Every value in the landing stylesheet resolves to an existing token. Where the
62
source reaches for a brand hue — the glow, the stat value, the pricing rule —
63
ours uses `--text-primary` through `color-mix`, so the same component is correct
64
in both themes without a second set of declarations.
65
66
One consequence worth stating: the glow carries `opacity: 0.35` in light mode.
67
The same intensity that reads as a lit backdrop on a dark surface reads as a
68
stain on a white one, and the source solves this by having a separate brand
69
colour per theme.
70
71
### No JavaScript
72
73
The FAQ is a native `<details>` rather than a Radix accordion, and there is no
74
adapted navbar, so nothing on a landing page waits on a bundle. Marketing pages
75
are the first thing a visitor loads and the most likely to be read on a slow
76
connection.
77
78
This also removes the `--radix-accordion-content-height` animation the source
79
uses. A native disclosure snaps open. That is a real loss and a deliberate one:
80
animating it requires either JavaScript measurement or `interpolate-size`, which
81
is not yet broadly supported.
82
83
### Motion is decorative
84
85
`appear` and `appear-zoom` are ported, including the blur-out that makes the
86
source's entrance feel like focus rather than a slide. Both are guarded by
87
`prefers-reduced-motion`, and because they are purely decorative, the guard
88
simply drops the animation and leaves the element at its final state rather than
89
substituting a shorter one.
90
91
## The homepage is built from these
92
93
`OpenAgentsWeb.HomeLive` composes catalogued components rather than page-local
94
markup, so the homepage and the component library cannot drift: changing a
95
landing component changes the homepage, and the library demonstrates the same
96
thing a visitor sees.
97
98
The hero's headline and description are unchanged from what the page already
99
said. The other bands are new and carry new copy, which was unavoidable — a
100
feature grid with no features is not a demonstration of anything.
101
102
## What is not done
103
104
- **No stats band on the homepage.** `stats/1` exists and is demoed, but every
105
  figure that could go in it would be either a vanity metric or one nobody has
106
  agreed to publish. An empty or invented statistics row is worse than none.
107
- **No pricing page.** `pricing_column/1` exists and is demoed; there is no
108
  pricing to state yet.
109
- **The demos are contained.** Landing bands are built to own a viewport, and
110
  the component library shows them inside a frame at reduced rhythm. The frame
111
  is the honest part — it says "scaled-down view" instead of pretending the
112
  documentation column is a page.
113
114
[launch-ui]: https://github.com/launch-ui/launch-ui
lib/openagents_web/component_catalog.ex modified +98 -1

@@ -260,6 +260,102 @@ defmodule OpenAgentsWeb.ComponentCatalog do

260 260
        }
261 261
      ]
262 262
    },
263
    %{
264
      title: "Landing",
265
      items: [
266
        %{
267
          slug: "landing-section",
268
          title: "Section",
269
          icon: "stack",
270
          source: "OpenAgentsWeb.UI.Landing.section/1",
271
          summary: "Page band: rhythm, measure, and a closing hairline."
272
        },
273
        %{
274
          slug: "landing-hero",
275
          title: "Hero",
276
          icon: "sparkle",
277
          source: "OpenAgentsWeb.UI.Landing.hero/1",
278
          summary: "Eyebrow, headline, prose, actions, and a lit figure."
279
        },
280
        %{
281
          slug: "landing-glow",
282
          title: "Glow",
283
          icon: "sun",
284
          source: "OpenAgentsWeb.UI.Landing.glow/1",
285
          summary: "Two-ellipse radial lift, in five positions."
286
        },
287
        %{
288
          slug: "landing-beam",
289
          title: "Beam",
290
          icon: "bolt",
291
          source: "OpenAgentsWeb.UI.Landing.beam/1",
292
          summary: "Radial bloom behind a single element."
293
        },
294
        %{
295
          slug: "landing-mockup",
296
          title: "Mockup",
297
          icon: "desktop",
298
          source: "OpenAgentsWeb.UI.Landing.mockup/1",
299
          summary: "Framed figure in a window or phone shape."
300
        },
301
        %{
302
          slug: "landing-feature-grid",
303
          title: "Feature grid",
304
          icon: "grid",
305
          source: "OpenAgentsWeb.UI.Landing.feature_grid/1",
306
          summary: "Two-to-four column grid of capability statements."
307
        },
308
        %{
309
          slug: "landing-stats",
310
          title: "Stats",
311
          icon: "chart",
312
          source: "OpenAgentsWeb.UI.Landing.stats/1",
313
          summary: "A row of figures with labels and captions."
314
        },
315
        %{
316
          slug: "landing-pricing-column",
317
          title: "Pricing column",
318
          icon: "tag",
319
          source: "OpenAgentsWeb.UI.Landing.pricing_column/1",
320
          summary: "One plan, with a lit top rule and a featured state."
321
        },
322
        %{
323
          slug: "landing-faq",
324
          title: "FAQ",
325
          icon: "info",
326
          source: "OpenAgentsWeb.UI.Landing.faq/1",
327
          summary: "Questions on native disclosure, no JavaScript."
328
        },
329
        %{
330
          slug: "landing-cta",
331
          title: "Call to action",
332
          icon: "arrow-right",
333
          source: "OpenAgentsWeb.UI.Landing.cta/1",
334
          summary: "The closing ask, over a glow that rises on hover."
335
        },
336
        %{
337
          slug: "landing-logo-wall",
338
          title: "Logo wall",
339
          icon: "star",
340
          source: "OpenAgentsWeb.UI.Landing.logo_wall/1",
341
          summary: "A row of names at one weight."
342
        },
343
        %{
344
          slug: "landing-footer",
345
          title: "Landing footer",
346
          icon: "stack",
347
          source: "OpenAgentsWeb.UI.Landing.landing_footer/1",
348
          summary: "Mark, tagline, and columns of links."
349
        },
350
        %{
351
          slug: "landing-layout-lines",
352
          title: "Layout lines",
353
          icon: "grid",
354
          source: "OpenAgentsWeb.UI.Landing.layout_lines/1",
355
          summary: "Dashed rules marking the content column."
356
        }
357
      ]
358
    },
263 359
    %{
264 360
      title: "Forge",
265 361
      items: [

@@ -295,7 +391,8 @@ defmodule OpenAgentsWeb.ComponentCatalog do

295 391
      # renders nothing on its own, so it has no demoable page. graph_surface/1
296 392
      # is the host element and is demoed through the components that use it.
297 393
      OpenAgentsWeb.UI.Graph => [:graph_defs, :graph_surface],
298
      OpenAgentsWeb.Components.RepoHeader => []
394
      OpenAgentsWeb.Components.RepoHeader => [],
395
      OpenAgentsWeb.UI.Landing => []
299 396
    }
300 397
  end
301 398
end
lib/openagents_web/components/landing.ex added +382

@@ -0,0 +1,382 @@

1
defmodule OpenAgentsWeb.UI.Landing do
2
  @moduledoc """
3
  Marketing-page composition: the pieces a landing page is built from.
4
5
  Adapted from Launch UI (MIT, © 2024 Mikolaj Dobrucki), which is a React and
6
  Tailwind component set. Nothing is copied: the source expresses each piece as
7
  a `cva` variant map over utility classes with `class-variance-authority` and
8
  Radix primitives, and none of that survives the move to HEEx. What carried
9
  over is the composition — what a hero contains, how a feature grid divides,
10
  where a glow sits relative to the content it lifts — and the visual decisions
11
  behind it. See `docs/2026-08-20-launch-ui-adaptation.md`.
12
13
  Two departures from the source are deliberate:
14
15
    * **Tokens, not a second palette.** Launch UI paints with `brand`,
16
      `brand-foreground` and layered `glass-*` gradients. Those would introduce
17
      a second colour system beside the one the application already uses, and a
18
      landing page that does not look like the product it advertises is worse
19
      than a plainer one that does. Every value here resolves to an existing
20
      token.
21
22
    * **No JavaScript.** The accordion is `<details>` rather than a Radix
23
      disclosure, and the mobile navigation is a native popover. Marketing
24
      pages are the first thing a visitor loads and the most likely to be read
25
      on a slow connection, so nothing here should wait on a bundle.
26
27
  Motion is opt-in and respects `prefers-reduced-motion`; the appear animations
28
  are decorative, and the page is complete without them.
29
  """
30
31
  use Phoenix.Component
32
33
  alias OpenAgentsWeb.UI
34
35
  @doc """
36
  A page band: horizontal padding, generous vertical rhythm, a closing rule.
37
38
  Landing sections are separated by a hairline rather than by colour, so the
39
  page reads as one surface divided into parts rather than as a stack of
40
  differently coloured panels.
41
  """
42
  attr :class, :any, default: nil
43
  attr :rule, :boolean, default: true, doc: "draw the closing hairline"
44
  attr :rest, :global
45
  slot :inner_block, required: true
46
47
  def section(assigns) do
48
    ~H"""
49
    <section class={["landing-section", !@rule && "landing-section--flush", @class]} {@rest}>
50
      <div class="landing-section__inner">{render_slot(@inner_block)}</div>
51
    </section>
52
    """
53
  end
54
55
  @doc """
56
  A soft radial lift behind content.
57
58
  Two stacked ellipses rather than one: a wide faint wash and a narrower,
59
  denser core. A single gradient reads as a flat smudge, and the pair is what
60
  makes it look like light rather than paint. Decorative, so it is hidden from
61
  assistive technology and never carries meaning.
62
  """
63
  attr :variant, :atom, values: [:top, :above, :bottom, :below, :center], default: :top
64
  attr :class, :any, default: nil
65
  attr :rest, :global
66
67
  def glow(assigns) do
68
    ~H"""
69
    <div class={["glow", "glow--#{@variant}", @class]} aria-hidden="true" {@rest}>
70
      <span class="glow__wash"></span>
71
      <span class="glow__core"></span>
72
    </div>
73
    """
74
  end
75
76
  @doc """
77
  A radial bloom drawn behind whatever it wraps.
78
79
  Where `glow/1` is a band positioned against a section, this attaches to one
80
  element and blooms from it, for lighting a single figure rather than a whole
81
  region.
82
  """
83
  attr :tone, :atom, values: [:default, :bright], default: :default
84
  attr :class, :any, default: nil
85
  attr :rest, :global
86
  slot :inner_block, required: true
87
88
  def beam(assigns) do
89
    ~H"""
90
    <div class={["beam", "beam--#{@tone}", @class]} {@rest}>
91
      {render_slot(@inner_block)}
92
    </div>
93
    """
94
  end
95
96
  @doc """
97
  A framed screenshot.
98
99
  The frame is a second, wider border in a lighter fill, which is what reads as
100
  a device edge without drawing a literal laptop. `type` picks the corner
101
  radius: a phone's is large enough to change the shape, a window's is not.
102
  """
103
  attr :type, :atom, values: [:window, :phone], default: :window
104
  attr :size, :atom, values: [:small, :large], default: :small
105
  attr :class, :any, default: nil
106
  attr :rest, :global
107
  slot :inner_block, required: true
108
109
  def mockup(assigns) do
110
    ~H"""
111
    <div class={["mockup-frame", "mockup-frame--#{@size}", @class]} {@rest}>
112
      <div class={["mockup", "mockup--#{@type}"]}>{render_slot(@inner_block)}</div>
113
    </div>
114
    """
115
  end
116
117
  @doc """
118
  The opening band: an optional eyebrow, a headline, a line of prose, actions,
119
  and an optional framed figure lifted by a glow.
120
121
  The figure is a slot rather than an image attribute, so a page can put a live
122
  surface in the frame instead of a screenshot of one.
123
  """
124
  attr :title, :string, required: true
125
  attr :description, :string, default: nil
126
  attr :class, :any, default: nil
127
  slot :eyebrow
128
  slot :actions
129
  slot :figure
130
131
  def hero(assigns) do
132
    ~H"""
133
    <.section class={["hero", @class]} rule={false}>
134
      <div class="hero__lede">
135
        <div :if={@eyebrow != []} class="hero__eyebrow appear">{render_slot(@eyebrow)}</div>
136
        <h1 class="hero__title appear">{@title}</h1>
137
        <p :if={@description} class="hero__description appear appear--delay-1">
138
          {@description}
139
        </p>
140
        <div :if={@actions != []} class="hero__actions appear appear--delay-2">
141
          {render_slot(@actions)}
142
        </div>
143
      </div>
144
145
      <div :if={@figure != []} class="hero__figure">
146
        <.glow variant={:top} class="appear-zoom appear--delay-4" />
147
        <div class="hero__figure-inner appear appear--delay-3">{render_slot(@figure)}</div>
148
      </div>
149
    </.section>
150
    """
151
  end
152
153
  @doc """
154
  A grid of short capability statements.
155
156
  Four columns on a wide screen, two on a narrow one, and each cell is an icon
157
  and a title on one line above a sentence. The cells carry no borders or fills
158
  — a grid of boxes competes with itself for attention, and the alignment is
159
  already doing the work of separating them.
160
  """
161
  attr :title, :string, default: nil
162
  attr :class, :any, default: nil
163
164
  slot :item, doc: "one capability" do
165
    attr :title, :string, required: true
166
    attr :icon, :string
167
  end
168
169
  def feature_grid(assigns) do
170
    ~H"""
171
    <.section class={["features", @class]}>
172
      <h2 :if={@title} class="landing-heading">{@title}</h2>
173
      <div class="feature-grid">
174
        <article :for={item <- @item} class="feature">
175
          <h3 class="feature__title">
176
            <UI.icon :if={item[:icon]} name={item.icon} class="feature__icon" />
177
            {item.title}
178
          </h3>
179
          <p class="feature__description">{render_slot(item)}</p>
180
        </article>
181
      </div>
182
    </.section>
183
    """
184
  end
185
186
  @doc """
187
  A row of figures.
188
189
  The number is the largest thing in the cell and the words around it are
190
  small, because a statistic that has to be read to be understood is not doing
191
  the job a statistic is for. A `suffix` stays a separate element so `24` and
192
  `k` can be sized differently without splitting the value in the caller.
193
  """
194
  attr :class, :any, default: nil
195
196
  slot :stat, doc: "one figure" do
197
    attr :label, :string
198
    attr :value, :string, required: true
199
    attr :suffix, :string
200
  end
201
202
  def stats(assigns) do
203
    ~H"""
204
    <.section class={["stats", @class]}>
205
      <div class="stat-row">
206
        <div :for={stat <- @stat} class="stat">
207
          <p :if={stat[:label]} class="stat__label">{stat.label}</p>
208
          <p class="stat__value">
209
            {stat.value}<span :if={stat[:suffix]} class="stat__suffix">{stat.suffix}</span>
210
          </p>
211
          <p class="stat__description">{render_slot(stat)}</p>
212
        </div>
213
      </div>
214
    </.section>
215
    """
216
  end
217
218
  @doc """
219
  One column of a pricing table.
220
221
  `featured` raises a column above its neighbours with a brighter top rule and
222
  a lift. The price is a slot rather than a number so a caller can write "Free",
223
  "Usage-based", or a struck-through original beside a current one without this
224
  component having an opinion about currency.
225
  """
226
  attr :name, :string, required: true
227
  attr :description, :string, default: nil
228
  attr :price, :string, required: true
229
  attr :price_note, :string, default: nil
230
  attr :featured, :boolean, default: false
231
  attr :class, :any, default: nil
232
  slot :action
233
  slot :feature, doc: "one included capability"
234
235
  def pricing_column(assigns) do
236
    ~H"""
237
    <div class={["pricing-column", @featured && "pricing-column--featured", @class]}>
238
      <hr class="pricing-column__rule" />
239
      <header class="pricing-column__header">
240
        <h3 class="pricing-column__name">{@name}</h3>
241
        <p :if={@description} class="pricing-column__description">{@description}</p>
242
      </header>
243
244
      <p class="pricing-column__price">
245
        {@price}<span :if={@price_note} class="pricing-column__note">{@price_note}</span>
246
      </p>
247
248
      <div :if={@action != []} class="pricing-column__action">{render_slot(@action)}</div>
249
250
      <ul :if={@feature != []} class="pricing-column__features">
251
        <li :for={feature <- @feature}>
252
          <UI.icon name="check-circle" /> {render_slot(feature)}
253
        </li>
254
      </ul>
255
    </div>
256
    """
257
  end
258
259
  @doc """
260
  Questions and answers on native `<details>`.
261
262
  No JavaScript and no ARIA to keep in sync: the disclosure state lives in the
263
  element, so it works before any bundle has loaded and keyboard behaviour is
264
  the browser's. The caret rotates rather than swapping glyphs.
265
  """
266
  attr :title, :string, default: nil
267
  attr :class, :any, default: nil
268
269
  slot :item, doc: "one question" do
270
    attr :question, :string, required: true
271
    attr :open, :boolean
272
  end
273
274
  def faq(assigns) do
275
    ~H"""
276
    <.section class={["faq", @class]}>
277
      <h2 :if={@title} class="landing-heading">{@title}</h2>
278
      <div class="faq-list">
279
        <details :for={item <- @item} class="faq-item" open={item[:open] || false}>
280
          <summary class="faq-item__question">
281
            {item.question}
282
            <UI.icon name="chevron-right" class="faq-item__caret" />
283
          </summary>
284
          <div class="faq-item__answer">{render_slot(item)}</div>
285
        </details>
286
      </div>
287
    </.section>
288
    """
289
  end
290
291
  @doc """
292
  The closing ask.
293
294
  A glow sits under this one rather than behind it, and rises slightly on
295
  hover: the section is the last thing on the page, so the light reads as
296
  something below the fold rather than something behind the text.
297
  """
298
  attr :title, :string, required: true
299
  attr :description, :string, default: nil
300
  attr :class, :any, default: nil
301
  slot :actions
302
303
  def cta(assigns) do
304
    ~H"""
305
    <.section class={["cta", @class]}>
306
      <h2 class="landing-heading cta__title">{@title}</h2>
307
      <p :if={@description} class="cta__description">{@description}</p>
308
      <div :if={@actions != []} class="cta__actions">{render_slot(@actions)}</div>
309
      <.glow variant={:below} class="cta__glow" />
310
    </.section>
311
    """
312
  end
313
314
  @doc """
315
  A row of names, for the "used by" band.
316
317
  Rendered at a single muted weight regardless of what each logo is, so one
318
  contributor's heavier mark does not dominate the row.
319
  """
320
  attr :title, :string, default: nil
321
  attr :class, :any, default: nil
322
  slot :logo, doc: "one name or mark"
323
324
  def logo_wall(assigns) do
325
    ~H"""
326
    <.section class={["logos", @class]}>
327
      <p :if={@title} class="logos__title">{@title}</p>
328
      <div class="logo-wall">
329
        <div :for={logo <- @logo} class="logo-wall__item">{render_slot(logo)}</div>
330
      </div>
331
    </.section>
332
    """
333
  end
334
335
  @doc """
336
  The page footer: a mark, a line about it, and columns of links.
337
  """
338
  attr :name, :string, default: "OpenAgents"
339
  attr :tagline, :string, default: nil
340
  attr :note, :string, default: nil
341
  attr :class, :any, default: nil
342
343
  slot :column, doc: "one column of links" do
344
    attr :title, :string, required: true
345
  end
346
347
  def landing_footer(assigns) do
348
    ~H"""
349
    <footer class={["landing-footer", @class]}>
350
      <div class="landing-footer__inner">
351
        <div class="landing-footer__identity">
352
          <p class="landing-footer__name">{@name}</p>
353
          <p :if={@tagline} class="landing-footer__tagline">{@tagline}</p>
354
        </div>
355
356
        <nav :for={column <- @column} class="landing-footer__column" aria-label={column.title}>
357
          <p class="landing-footer__column-title">{column.title}</p>
358
          {render_slot(column)}
359
        </nav>
360
      </div>
361
      <p :if={@note} class="landing-footer__note">{@note}</p>
362
    </footer>
363
    """
364
  end
365
366
  @doc """
367
  Faint vertical rules marking the content column's edges.
368
369
  Fixed behind the page and pointer-transparent. Purely decorative: it gives a
370
  long marketing page a spine to read against, and removing it changes nothing
371
  about the content.
372
  """
373
  attr :class, :any, default: nil
374
375
  def layout_lines(assigns) do
376
    ~H"""
377
    <div class={["layout-lines", @class]} aria-hidden="true">
378
      <div class="layout-lines__column"></div>
379
    </div>
380
    """
381
  end
382
end
lib/openagents_web/components/layouts.ex modified +5 -1

@@ -73,7 +73,11 @@ defmodule OpenAgentsWeb.Layouts do

73 73
          !@flush && "overflow-y-auto overscroll-none p-4",
74 74
          @current_scope && "bg-background"
75 75
        ]}>
76
          <%= if @current_scope do %>
76
          <%!-- A flush surface owns the whole area and sets its own measure,
77
          so it is not wrapped either way. Signed out, an ordinary page is
78
          centred in a reading column; signed in, the page has the sidebar
79
          beside it and sets its own. --%>
80
          <%= if @flush or @current_scope do %>
77 81
            {render_slot(@inner_block)}
78 82
          <% else %>
79 83
            <div class={["mx-auto space-y-4", @wide && "max-w-6xl", !@wide && "max-w-2xl"]}>
lib/openagents_web/live/components_live.ex modified +308

@@ -13,6 +13,7 @@ defmodule OpenAgentsWeb.ComponentsLive do

13 13
14 14
  alias OpenAgentsWeb.ComponentCatalog
15 15
  alias OpenAgentsWeb.UI.Graph
16
  alias OpenAgentsWeb.UI.Landing
16 17
  alias OpenAgentsWeb.UI, as: UI
17 18
18 19
  @sample_rows [

@@ -941,6 +942,313 @@ defmodule OpenAgentsWeb.ComponentsLive do

941 942
    """
942 943
  end
943 944
945
  # ── Landing ───────────────────────────────────────────────────────────────
946
  #
947
  # These demo at reduced scale inside the documentation column. A hero is
948
  # meant to own a viewport, so what a page here can honestly show is the
949
  # composition and the type ramp, not the impression.
950
951
  defp component_demo(%{item: %{slug: "landing-section"}} = assigns) do
952
    ~H"""
953
    <div class="space-y-3">
954
      <p class="text-sm text-base-content/60">
955
        The band every landing block sits in: horizontal padding, vertical rhythm that
956
        grows with the viewport, a centred measure, and a closing hairline. Sections are
957
        divided by a rule rather than by fill, so the page reads as one surface in parts.
958
      </p>
959
      <div class="demo-frame">
960
        <Landing.section>
961
          <p class="landing-heading">A section</p>
962
        </Landing.section>
963
        <Landing.section rule={false}>
964
          <p class="landing-heading">The last one, with no rule</p>
965
        </Landing.section>
966
      </div>
967
    </div>
968
    """
969
  end
970
971
  defp component_demo(%{item: %{slug: "landing-hero"}} = assigns) do
972
    ~H"""
973
    <div class="space-y-3">
974
      <p class="text-sm text-base-content/60">
975
        Eyebrow, headline, one line of prose, actions, and an optional figure lifted by a
976
        glow. The figure is a slot rather than an image attribute, so a page can frame a
977
        live surface instead of a screenshot of one.
978
      </p>
979
      <div class="demo-frame">
980
        <Landing.hero
981
          title="The Agent Forge"
982
          description="Purpose-built for planning and shipping issues. Designed for the agent era."
983
        >
984
          <:eyebrow>
985
            <UI.badge>New</UI.badge>
986
          </:eyebrow>
987
          <:actions>
988
            <UI.button variant={:primary}>Get started</UI.button>
989
            <UI.button>Read the docs</UI.button>
990
          </:actions>
991
          <:figure>
992
            <Landing.mockup>
993
              <div class="demo-screenshot">Your surface here</div>
994
            </Landing.mockup>
995
          </:figure>
996
        </Landing.hero>
997
      </div>
998
    </div>
999
    """
1000
  end
1001
1002
  defp component_demo(%{item: %{slug: "landing-glow"}} = assigns) do
1003
    ~H"""
1004
    <div class="space-y-3">
1005
      <p class="text-sm text-base-content/60">
1006
        Two stacked ellipses — a wide faint wash under a narrower dense core. One
1007
        gradient alone reads as a flat smudge; the pair reads as light. Decorative, so it
1008
        is hidden from assistive technology, and it carries less intensity in light mode
1009
        where the same value reads as a stain rather than a backdrop.
1010
      </p>
1011
      <div class="demo-glow-row">
1012
        <div :for={variant <- [:top, :center, :bottom]} class="demo-glow">
1013
          <Landing.glow variant={variant} />
1014
          <span class="demo-glow__label">{variant}</span>
1015
        </div>
1016
      </div>
1017
    </div>
1018
    """
1019
  end
1020
1021
  defp component_demo(%{item: %{slug: "landing-beam"}} = assigns) do
1022
    ~H"""
1023
    <div class="space-y-3">
1024
      <p class="text-sm text-base-content/60">
1025
        Where a glow is a band positioned against a section, a beam attaches to one
1026
        element and blooms from it — for lighting a single figure rather than a region.
1027
      </p>
1028
      <div class="demo-glow-row">
1029
        <Landing.beam :for={tone <- [:default, :bright]} tone={tone} class="demo-beam">
1030
          <UI.badge>{tone}</UI.badge>
1031
        </Landing.beam>
1032
      </div>
1033
    </div>
1034
    """
1035
  end
1036
1037
  defp component_demo(%{item: %{slug: "landing-mockup"}} = assigns) do
1038
    ~H"""
1039
    <div class="space-y-3">
1040
      <p class="text-sm text-base-content/60">
1041
        A second, wider border in a lighter fill is what reads as a device edge. Drawing
1042
        a literal laptop dates the page and fixes the aspect ratio of whatever goes
1043
        inside it.
1044
      </p>
1045
      <div class="demo-mockup-row">
1046
        <Landing.mockup type={:window}>
1047
          <div class="demo-screenshot">Window</div>
1048
        </Landing.mockup>
1049
        <Landing.mockup type={:phone} size={:large}>
1050
          <div class="demo-screenshot demo-screenshot--tall">Phone</div>
1051
        </Landing.mockup>
1052
      </div>
1053
    </div>
1054
    """
1055
  end
1056
1057
  defp component_demo(%{item: %{slug: "landing-feature-grid"}} = assigns) do
1058
    ~H"""
1059
    <div class="space-y-3">
1060
      <p class="text-sm text-base-content/60">
1061
        Four columns wide, two narrow. The cells carry no borders and no fills: a grid of
1062
        boxes competes with itself for attention, and the alignment already separates
1063
        them.
1064
      </p>
1065
      <div class="demo-frame">
1066
        <Landing.feature_grid title="Everything the work needs">
1067
          <:item title="Issues" icon="file-document">
1068
            Plan, assign and close, with the same API shape you already use.
1069
          </:item>
1070
          <:item title="Projects" icon="grid">
1071
            Group issues into work that has a beginning and an end.
1072
          </:item>
1073
          <:item title="Agents" icon="bolt">
1074
            Durable workers that pick up an issue and see it through.
1075
          </:item>
1076
          <:item title="Receipts" icon="check-circle">
1077
            Every run leaves evidence you can read afterwards.
1078
          </:item>
1079
        </Landing.feature_grid>
1080
      </div>
1081
    </div>
1082
    """
1083
  end
1084
1085
  defp component_demo(%{item: %{slug: "landing-stats"}} = assigns) do
1086
    ~H"""
1087
    <div class="space-y-3">
1088
      <p class="text-sm text-base-content/60">
1089
        The number is the largest thing in the cell. A statistic that has to be read to
1090
        be understood is not doing the job a statistic is for. The suffix stays a
1091
        separate element so the value and its unit can be sized apart.
1092
      </p>
1093
      <div class="demo-frame">
1094
        <Landing.stats>
1095
          <:stat label="over" value="1443">tests on every commit</:stat>
1096
          <:stat label="under" value="7" suffix="s">to run the web suite</:stat>
1097
          <:stat label="across" value="756">vendored glyphs</:stat>
1098
          <:stat label="in" value="1">component system</:stat>
1099
        </Landing.stats>
1100
      </div>
1101
    </div>
1102
    """
1103
  end
1104
1105
  defp component_demo(%{item: %{slug: "landing-pricing-column"}} = assigns) do
1106
    ~H"""
1107
    <div class="space-y-3">
1108
      <p class="text-sm text-base-content/60">
1109
        The price is a slot, not a number, so a column can say "Free" or "Usage-based"
1110
        without this component holding an opinion about currency. The featured state is a
1111
        brighter top rule and a lift — no accent fill and no scaling, which would make
1112
        the other columns look broken rather than merely quieter.
1113
      </p>
1114
      <div class="demo-pricing-row">
1115
        <Landing.pricing_column
1116
          name="Open source"
1117
          description="The whole application, AGPL-3.0."
1118
          price="Free"
1119
        >
1120
          <:action><UI.button class="w-full">Clone it</UI.button></:action>
1121
          <:feature>Every surface you see here</:feature>
1122
          <:feature>Self-hosted, your database</:feature>
1123
        </Landing.pricing_column>
1124
1125
        <Landing.pricing_column
1126
          name="Hosted"
1127
          description="The same thing, run for you."
1128
          price="$20"
1129
          price_note="/month"
1130
          featured
1131
        >
1132
          <:action><UI.button variant={:primary} class="w-full">Start</UI.button></:action>
1133
          <:feature>Managed database and backups</:feature>
1134
          <:feature>Agents on our machines</:feature>
1135
          <:feature>Usage receipts</:feature>
1136
        </Landing.pricing_column>
1137
      </div>
1138
    </div>
1139
    """
1140
  end
1141
1142
  defp component_demo(%{item: %{slug: "landing-faq"}} = assigns) do
1143
    ~H"""
1144
    <div class="space-y-3">
1145
      <p class="text-sm text-base-content/60">
1146
        Native <code>&lt;details&gt;</code>, so the disclosure state lives in the element:
1147
        no JavaScript, no ARIA to keep in sync, and keyboard behaviour is the browser's.
1148
        A marketing page is the most likely thing to be read on a slow connection, so
1149
        nothing here should wait on a bundle.
1150
      </p>
1151
      <div class="demo-frame">
1152
        <Landing.faq title="Questions">
1153
          <:item question="Is this really the whole application?" open>
1154
            <p>Yes. The repository is AGPL-3.0 and this page is built from the same
1155
              component system as every other surface.</p>
1156
          </:item>
1157
          <:item question="Does the accordion need JavaScript?">
1158
            <p>No. It is a native disclosure element.</p>
1159
          </:item>
1160
        </Landing.faq>
1161
      </div>
1162
    </div>
1163
    """
1164
  end
1165
1166
  defp component_demo(%{item: %{slug: "landing-cta"}} = assigns) do
1167
    ~H"""
1168
    <div class="space-y-3">
1169
      <p class="text-sm text-base-content/60">
1170
        The closing ask. Its glow sits below the section rather than behind it and rises
1171
        on hover, so the light reads as something under the fold rather than behind the
1172
        text.
1173
      </p>
1174
      <div class="demo-frame">
1175
        <Landing.cta
1176
          title="Start shipping"
1177
          description="Open an issue and let an agent pick it up."
1178
        >
1179
          <:actions>
1180
            <UI.button variant={:primary}>Create an issue</UI.button>
1181
          </:actions>
1182
        </Landing.cta>
1183
      </div>
1184
    </div>
1185
    """
1186
  end
1187
1188
  defp component_demo(%{item: %{slug: "landing-logo-wall"}} = assigns) do
1189
    ~H"""
1190
    <div class="space-y-3">
1191
      <p class="text-sm text-base-content/60">
1192
        One weight for every mark, so a heavier logo cannot dominate the row.
1193
      </p>
1194
      <div class="demo-frame">
1195
        <Landing.logo_wall title="Built with">
1196
          <:logo>Elixir</:logo>
1197
          <:logo>Phoenix</:logo>
1198
          <:logo>LiveView</:logo>
1199
          <:logo>PostgreSQL</:logo>
1200
        </Landing.logo_wall>
1201
      </div>
1202
    </div>
1203
    """
1204
  end
1205
1206
  defp component_demo(%{item: %{slug: "landing-footer"}} = assigns) do
1207
    ~H"""
1208
    <div class="space-y-3">
1209
      <p class="text-sm text-base-content/60">
1210
        A mark, a line about it, and columns of links.
1211
      </p>
1212
      <div class="demo-frame">
1213
        <Landing.landing_footer
1214
          tagline="Purpose-built for planning and shipping issues."
1215
          note="AGPL-3.0. Every surface here is in the repository."
1216
        >
1217
          <:column title="Product">
1218
            <.link navigate={~p"/docs"}>Documentation</.link>
1219
            <.link navigate={~p"/components"}>Components</.link>
1220
          </:column>
1221
          <:column title="Transparency">
1222
            <.link navigate={~p"/changelog"}>Changelog</.link>
1223
            <.link navigate={~p"/status"}>Status</.link>
1224
          </:column>
1225
          <:column title="Work">
1226
            <.link navigate={~p"/OpenAgentsInc/openagents.com/issues"}>Issues</.link>
1227
          </:column>
1228
        </Landing.landing_footer>
1229
      </div>
1230
    </div>
1231
    """
1232
  end
1233
1234
  defp component_demo(%{item: %{slug: "landing-layout-lines"}} = assigns) do
1235
    ~H"""
1236
    <div class="space-y-3">
1237
      <p class="text-sm text-base-content/60">
1238
        Faint dashed rules marking the content column's edges, fixed behind the page and
1239
        pointer-transparent. Purely decorative: it gives a long marketing page a spine to
1240
        read against, and removing it changes nothing about the content. Shown here
1241
        contained rather than fixed, which is the one thing this demo cannot be honest
1242
        about.
1243
      </p>
1244
      <div class="demo-frame demo-frame--lines">
1245
        <Landing.layout_lines class="!absolute" />
1246
        <p class="landing-heading">Content sits between them</p>
1247
      </div>
1248
    </div>
1249
    """
1250
  end
1251
944 1252
  # The breadcrumb names the section a component lives in, so the trail matches
945 1253
  # the sidebar the reader navigated through.
946 1254
  defp section_title_for(slug) do
lib/openagents_web/live/home_live.ex modified +151 -39

@@ -1,54 +1,166 @@

1 1
defmodule OpenAgentsWeb.HomeLive do
2 2
  @moduledoc """
3
  Marketing-style homepage with a hero.
3
  The public landing page, composed from `OpenAgentsWeb.UI.Landing`.
4
5
  Every band here is a catalogued component rather than page-local markup, so
6
  the homepage and the component library cannot drift: changing a landing
7
  component changes this page, and the library demonstrates the same thing a
8
  visitor sees.
9
10
  The surface is flush -- it owns the full main area and scrolls itself --
11
  because landing bands set their own vertical rhythm against the viewport and
12
  a wrapper's padding would fight it.
4 13
  """
5 14
  use OpenAgentsWeb, :live_view
6 15
16
  alias OpenAgentsWeb.UI.Landing
17
7 18
  def mount(_params, _session, socket) do
8 19
    {:ok, socket}
9 20
  end
10 21
11 22
  def render(assigns) do
12 23
    ~H"""
13
    <Layouts.app flash={@flash} current_scope={@current_scope} wide>
14
      <section class="flex min-h-[60vh] items-center justify-center px-4">
15
        <div class="text-center">
16
          <div class="max-w-4xl space-y-6">
17
            <h1 class="text-5xl md:text-7xl font-bold tracking-tight">
18
              The Agent Forge
19
            </h1>
20
            <p class="text-xl md:text-2xl text-muted-foreground">
21
              Purpose-built for planning and shipping issues. Designed for the agent era.
22
            </p>
23
            <div id="github-tools" class="flex flex-wrap justify-center gap-4">
24
              <%= if @current_user do %>
25
                <.button
26
                  id="home-cta-create"
27
                  navigate={~p"/OpenAgentsInc/openagents.com/issues/new"}
28
                  variant={:primary}
29
                >
30
                  Create new issue
31
                </.button>
32
                <.button id="home-cta-browse" navigate={~p"/OpenAgentsInc/openagents.com/issues"}>
33
                  View issues
24
    <Layouts.app flash={@flash} current_scope={@current_scope} flush>
25
      <div class="landing-page">
26
        <Landing.layout_lines />
27
28
        <Landing.hero
29
          title="The Agent Forge"
30
          description="Purpose-built for planning and shipping issues. Designed for the agent era."
31
        >
32
          <:actions>
33
            <%= if @current_user do %>
34
              <.button
35
                id="home-cta-create"
36
                navigate={~p"/OpenAgentsInc/openagents.com/issues/new"}
37
                variant={:primary}
38
                size={:lg}
39
              >
40
                Create new issue
41
              </.button>
42
              <.button
43
                id="home-cta-browse"
44
                navigate={~p"/OpenAgentsInc/openagents.com/issues"}
45
                size={:lg}
46
              >
47
                View issues
48
              </.button>
49
            <% else %>
50
              <.form
51
                for={%{}}
52
                as={:auth}
53
                action={~p"/auth/github?github_tools=enabled"}
54
                method="post"
55
                class="m-0"
56
              >
57
                <.button type="submit" variant={:primary} size={:lg} id="home-cta-signin">
58
                  Sign in and enable GitHub tools
34 59
                </.button>
35
              <% else %>
36
                <.form
37
                  for={%{}}
38
                  as={:auth}
39
                  action={~p"/auth/github?github_tools=enabled"}
40
                  method="post"
41
                  class="m-0"
42
                >
43
                  <.button type="submit" variant={:primary} id="home-cta-signin">
44
                    Sign in and enable GitHub tools
45
                  </.button>
46
                </.form>
47
              <% end %>
48
            </div>
49
          </div>
50
        </div>
51
      </section>
60
              </.form>
61
              <.button navigate={~p"/docs"} size={:lg}>Read the docs</.button>
62
            <% end %>
63
          </:actions>
64
65
          <:figure>
66
            <Landing.mockup>
67
              <div class="landing-figure">
68
                <div class="landing-figure__bar">
69
                  <span></span><span></span><span></span>
70
                </div>
71
                <p class="landing-figure__caption">openagents.com</p>
72
              </div>
73
            </Landing.mockup>
74
          </:figure>
75
        </Landing.hero>
76
77
        <Landing.logo_wall title="Built with">
78
          <:logo>Elixir</:logo>
79
          <:logo>Phoenix</:logo>
80
          <:logo>LiveView</:logo>
81
          <:logo>PostgreSQL</:logo>
82
        </Landing.logo_wall>
83
84
        <Landing.feature_grid title="Everything the work needs. Nothing it doesn't.">
85
          <:item title="Issues" icon="file-document">
86
            Plan, assign, label and close, over an API shaped after the one you already
87
            use.
88
          </:item>
89
          <:item title="Projects" icon="grid">
90
            Group issues into work that has a beginning and an end.
91
          </:item>
92
          <:item title="Milestones" icon="flag">
93
            Dates and scope, stated where the work is rather than in another tool.
94
          </:item>
95
          <:item title="Code" icon="code">
96
            Browse repositories and commits beside the issues that changed them.
97
          </:item>
98
          <:item title="Agents" icon="bolt">
99
            Durable workers that pick up an issue and see it through.
100
          </:item>
101
          <:item title="Receipts" icon="check-circle">
102
            Every run leaves evidence that can be read afterwards.
103
          </:item>
104
          <:item title="Changelog" icon="text">
105
            What shipped, generated from what actually shipped.
106
          </:item>
107
          <:item title="Status" icon="info">
108
            The system's own account of whether it is working.
109
          </:item>
110
        </Landing.feature_grid>
111
112
        <Landing.faq title="Questions">
113
          <:item question="Is this the whole application?" open>
114
            <p>
115
              Yes. The repository is AGPL-3.0, and every surface on this site is built
116
              from the same component system documented in the component library.
117
            </p>
118
          </:item>
119
          <:item question="Does it work without JavaScript?">
120
            <p>
121
              The marketing and documentation surfaces do. Menus are native popovers and
122
              disclosures are native <code>&lt;details&gt;</code> elements, so navigation
123
              works before any bundle has loaded.
124
            </p>
125
          </:item>
126
          <:item question="What does the API look like?">
127
            <p>
128
              It is shaped after GitHub's REST API and served under <code>/api/v3</code>. An existing client usually needs only a base URL
129
              change.
130
            </p>
131
          </:item>
132
        </Landing.faq>
133
134
        <Landing.cta
135
          title="Start shipping"
136
          description="Open an issue and let an agent pick it up."
137
        >
138
          <:actions>
139
            <.button navigate={~p"/docs"} variant={:primary} size={:lg}>
140
              Read the docs
141
            </.button>
142
          </:actions>
143
        </Landing.cta>
144
145
        <Landing.landing_footer
146
          tagline="Purpose-built for planning and shipping issues."
147
          note="AGPL-3.0. Every surface here is in the repository."
148
        >
149
          <:column title="Product">
150
            <.link navigate={~p"/docs"}>Documentation</.link>
151
            <.link navigate={~p"/OpenAgentsInc/openagents.com/issues"}>Issues</.link>
152
          </:column>
153
          <:column title="Transparency">
154
            <.link navigate={~p"/changelog"}>Changelog</.link>
155
            <.link navigate={~p"/status"}>Status</.link>
156
            <.link navigate={~p"/leaderboard"}>Leaderboard</.link>
157
          </:column>
158
          <:column title="API">
159
            <.link navigate={~p"/docs/rest-api"}>REST API</.link>
160
            <.link navigate={~p"/docs/status-api"}>Status API</.link>
161
          </:column>
162
        </Landing.landing_footer>
163
      </div>
52 164
    </Layouts.app>
53 165
    """
54 166
  end

This page updates live while a promote is in flight · changelog