|
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
|