mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-15 12:41:37 +03:00
feat(site): carousel arrows, a full logo grid, and three ways to show every provider (#62398)
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
/* Settings › Subscriptions style A, B and C (byos-options.tsx). Development only until one is chosen:
|
||||
every option renders, and the section's data-style shows one and its lead in place of the
|
||||
section's own. */
|
||||
[data-page="home"][data-site] [data-component="block"][data-section="byos"] {
|
||||
[data-slot="lead"] > [data-option],
|
||||
[data-slot="options"] > [data-option] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:is([data-style="A rows"], [data-style="B wall"], [data-style="C names"]) [data-slot="lead"] > :not([data-option]) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&[data-style="A rows"] [data-option="A rows"],
|
||||
&[data-style="B wall"] [data-option="B wall"],
|
||||
&[data-style="C names"] [data-option="C names"] {
|
||||
display: revert;
|
||||
}
|
||||
|
||||
[data-slot="options"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: var(--two-cards);
|
||||
}
|
||||
|
||||
[data-slot="options"] [data-slot="mark"] {
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
width: var(--mark, 16px);
|
||||
height: var(--mark, 16px);
|
||||
background: currentColor;
|
||||
mask: var(--logo) center / contain no-repeat;
|
||||
}
|
||||
|
||||
[data-slot="options"] a {
|
||||
color: var(--text-strong);
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--border-strong);
|
||||
text-underline-offset: 0.2em;
|
||||
transition: text-decoration-color 150ms ease;
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
&:hover {
|
||||
text-decoration-color: currentColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* A: a ruled list, one row per way in. The kind sits in a narrow mono column, like the split
|
||||
style's headings; on phones it stacks above its row. */
|
||||
&[data-style="A rows"] [data-slot="options"] > [data-option="A rows"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
|
||||
> div {
|
||||
display: grid;
|
||||
grid-template-columns: 8.5rem minmax(0, 1fr);
|
||||
gap: 0.75rem var(--gutter);
|
||||
padding-block: 1.25rem;
|
||||
border-top: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
dt {
|
||||
padding-top: 0.125rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-meta);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-weak);
|
||||
}
|
||||
|
||||
dd {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
ul {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
[data-slot="names"] {
|
||||
gap: 0.625rem 1.5rem;
|
||||
font-size: var(--fs-read);
|
||||
color: var(--text-strong);
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="marks"] {
|
||||
--mark: 20px;
|
||||
gap: 1rem 1.25rem;
|
||||
color: var(--text-weak);
|
||||
|
||||
li {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
transition: color 150ms ease;
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
&:hover {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dd > a {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-meta);
|
||||
}
|
||||
}
|
||||
|
||||
/* B: every mark in the tools grid's ruled cells, six across, then four (a multiple of twelve fills
|
||||
every row), and the count under it. */
|
||||
&[data-style="B wall"] [data-slot="options"] > [data-option="B wall"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
|
||||
ul {
|
||||
--mark: 28px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
border-top: 1px solid var(--border);
|
||||
border-inline-start: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 60rem) {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
--mark: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
li {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
height: 4.5rem;
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
height: 4rem;
|
||||
}
|
||||
border-inline-end: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
transition: color 150ms ease;
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
&:hover {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-meta);
|
||||
color: var(--text-weak);
|
||||
}
|
||||
}
|
||||
|
||||
/* C: the names as one wrapping run of text, subscriptions in ink and the rest muted, each mark the
|
||||
height of its name. */
|
||||
&[data-style="C names"] [data-slot="options"] > [data-option="C names"] {
|
||||
--mark: 1em;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 1.375rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
font-size: var(--fs-lead);
|
||||
line-height: 1.5;
|
||||
color: var(--text-weak);
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
gap: 0.375rem 1rem;
|
||||
font-size: var(--fs-read);
|
||||
}
|
||||
|
||||
li {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-kind="subscription"] {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import "./byos-options.css"
|
||||
import { For, Show } from "solid-js"
|
||||
import { useI18n } from "~/context/i18n"
|
||||
import { rich } from "~/i18n/rich"
|
||||
import { byos } from "~/lib/byos"
|
||||
import { byosOptions } from "~/lib/byos-options"
|
||||
import { docsUrl } from "~/lib/docs"
|
||||
import { linkPhrases } from "~/lib/home-content"
|
||||
import { providerLogo, providerMarks } from "~/lib/providers"
|
||||
import { Rich } from "./rich-text"
|
||||
|
||||
/**
|
||||
* Settings › Subscriptions style A, B and C in "Bring your own subs" (lib/byos-options.ts): ways
|
||||
* to show that OpenCode works with nearly every subscription, provider and model. Like the section's
|
||||
* other styles, every option renders in development and the section's data-style shows one
|
||||
* (byos-options.css); production renders none while the default is "grid" (script/fold-settings.ts).
|
||||
*/
|
||||
|
||||
// The providers with a mark (lib/providers.ts), never Anthropic's.
|
||||
const marks = providerMarks.filter((provider) => provider.id !== "anthropic")
|
||||
// Marks the subscriptions and local rows already show.
|
||||
const shown = new Set(["github-copilot", "gitlab", "ollama", "lmstudio"])
|
||||
const providers = marks.filter((provider) => !shown.has(provider.id))
|
||||
|
||||
/** Each option's lead, in place of the section's own (inside its lead paragraph). */
|
||||
export function ByosOptionLeads() {
|
||||
const link = (text: string) => (
|
||||
<Rich
|
||||
value={linkPhrases(
|
||||
text,
|
||||
byos.planLinks.map((phrase) => ({ text: phrase, href: byos.plansHref })),
|
||||
)}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<>
|
||||
<span data-option="A rows">{link(byosOptions.leads.rows)}</span>
|
||||
<span data-option="B wall">{link(byosOptions.leads.wall)}</span>
|
||||
<span data-option="C names">{link(byosOptions.leads.names)}</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function ByosOptions() {
|
||||
const i18n = useI18n()
|
||||
const providersLink = docsUrl("/providers/")
|
||||
return (
|
||||
<div data-slot="options">
|
||||
{/* A: a ruled row for each way in, its kind in the first column. */}
|
||||
<dl data-option="A rows">
|
||||
<div>
|
||||
<dt>{byosOptions.rows.subscriptions}</dt>
|
||||
<dd>
|
||||
<ul data-slot="names">
|
||||
<For each={byosOptions.subscriptions}>
|
||||
{(item) => (
|
||||
<li>
|
||||
<Mark logo={item.logo} />
|
||||
{item.name}
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{byosOptions.rows.providers}</dt>
|
||||
<dd>
|
||||
<ul data-slot="marks">
|
||||
<For each={providers}>
|
||||
{(provider) => (
|
||||
<li data-tip={provider.name}>
|
||||
<Mark logo={providerLogo(provider.id)} name={provider.name} />
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
<a href={providersLink}>{byosOptions.rows.providersMore}</a>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{byosOptions.rows.local}</dt>
|
||||
<dd>
|
||||
<ul data-slot="names">
|
||||
<For each={byosOptions.local}>
|
||||
{(item) => (
|
||||
<li>
|
||||
<Show when={"logo" in item && item.logo}>{(logo) => <Mark logo={logo()} />}</Show>
|
||||
{item.name}
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{byosOptions.rows.opencode}</dt>
|
||||
<dd>
|
||||
<ul data-slot="names">
|
||||
<For each={byosOptions.opencode}>
|
||||
{(item) => (
|
||||
<li>
|
||||
<a href={item.href}>{item.name}</a>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{/* B: every provider's mark in the tools grid's ruled cells, in whole rows of six or four (a
|
||||
multiple of 12), then the count. */}
|
||||
<div data-option="B wall">
|
||||
<ul aria-label={i18n.t("home.providers.aria")}>
|
||||
<For each={marks.slice(0, marks.length - (marks.length % 12))}>
|
||||
{(provider) => (
|
||||
<li data-tip={provider.name}>
|
||||
<Mark logo={providerLogo(provider.id)} name={provider.name} />
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
<p>
|
||||
<Rich
|
||||
value={rich(i18n.t("home.providers.grid"), {
|
||||
modelsdev: "https://models.dev",
|
||||
providers: providersLink,
|
||||
})}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* C: the names as one wrapping run, the subscriptions in ink, each with its mark. */}
|
||||
<ul data-option="C names" aria-label={i18n.t("home.providers.aria")}>
|
||||
<For each={byosOptions.subscriptions}>
|
||||
{(item) => (
|
||||
<li data-kind="subscription">
|
||||
<Mark logo={item.logo} />
|
||||
{item.name}
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
<For
|
||||
each={[
|
||||
...providers.map((provider) => ({ ...provider, logo: providerLogo(provider.id) })),
|
||||
...byosOptions.local,
|
||||
]}
|
||||
>
|
||||
{(item) => (
|
||||
<li>
|
||||
<Show when={"logo" in item && item.logo}>{(logo) => <Mark logo={logo()} />}</Show>
|
||||
{item.name}
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
<li>
|
||||
<a href={providersLink}>{byosOptions.all}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** A provider's mark in the text colour; named only where no text beside it says the name. */
|
||||
function Mark(props: { logo: string; name?: string }) {
|
||||
return (
|
||||
<span
|
||||
data-slot="mark"
|
||||
role={props.name ? "img" : undefined}
|
||||
aria-label={props.name}
|
||||
aria-hidden={props.name ? undefined : "true"}
|
||||
style={{ "--logo": `url(${props.logo})` }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/* A carousel's previous and next buttons (carousel-arrows.tsx): 24px targets side by side, the
|
||||
chevrons in the muted text colour, the ink on hover or press, and fainter at an end. */
|
||||
[data-site] [data-component="carousel-arrows"] {
|
||||
display: flex;
|
||||
|
||||
button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
color: var(--text-weak);
|
||||
transition: color 150ms ease;
|
||||
|
||||
&:focus-visible {
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
&[aria-disabled="true"] {
|
||||
color: var(--text-weaker);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
&:not([aria-disabled="true"]):hover {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
}
|
||||
|
||||
&:not([aria-disabled="true"]):active {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* The chevrons point the way the page reads. */
|
||||
[dir="rtl"] [data-site] [data-component="carousel-arrows"] svg {
|
||||
scale: -1 1;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import "./carousel-arrows.css"
|
||||
|
||||
/**
|
||||
* Previous and next buttons for a carousel (the changelog's media, the product cards): 24px targets
|
||||
* with 12px chevrons in the muted text colour. An end the carousel has reached is aria-disabled, so
|
||||
* the button keeps focus there; the chevrons point the way the page reads.
|
||||
*/
|
||||
export function CarouselArrows(props: {
|
||||
previous: string
|
||||
next: string
|
||||
onPrevious: () => void
|
||||
onNext: () => void
|
||||
atStart?: boolean
|
||||
atEnd?: boolean
|
||||
controls?: string
|
||||
"data-slot"?: string
|
||||
}) {
|
||||
return (
|
||||
<span data-component="carousel-arrows" data-slot={props["data-slot"]}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={props.previous}
|
||||
data-tip={props.previous}
|
||||
aria-controls={props.controls}
|
||||
aria-disabled={props.atStart ? "true" : undefined}
|
||||
onClick={() => !props.atStart && props.onPrevious()}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<path d="M7.5 3L4.5 6L7.5 9" stroke="currentColor" stroke-width="1.25" stroke-linecap="square" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={props.next}
|
||||
data-tip={props.next}
|
||||
aria-controls={props.controls}
|
||||
aria-disabled={props.atEnd ? "true" : undefined}
|
||||
onClick={() => !props.atEnd && props.onNext()}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<path d="M4.5 3L7.5 6L4.5 9" stroke="currentColor" stroke-width="1.25" stroke-linecap="square" />
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -275,20 +275,12 @@
|
||||
[data-slot="bars"] button:hover > [data-slot="bar"] {
|
||||
background: var(--link);
|
||||
}
|
||||
|
||||
[data-slot="arrows"] button:hover {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="bars"] button:active > [data-slot="bar"] {
|
||||
background: var(--link);
|
||||
}
|
||||
|
||||
[data-slot="arrows"] button:active {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
/* bars + count: "1/3" at the right end of the row. */
|
||||
[data-slot="count"] {
|
||||
margin-inline-start: auto;
|
||||
@@ -297,7 +289,8 @@
|
||||
color: var(--text-weak);
|
||||
}
|
||||
|
||||
/* bars + arrows: previous and next at the right end, their icons lined up with the media edge. */
|
||||
/* bars + arrows: previous and next (carousel-arrows.tsx) at the right end, their icons lined up
|
||||
with the media edge. */
|
||||
[data-slot="arrows"] {
|
||||
display: flex;
|
||||
margin-inline-start: auto;
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type ChangelogNote,
|
||||
type ChangelogView,
|
||||
} from "~/lib/changelog-notes"
|
||||
import { CarouselArrows } from "./carousel-arrows"
|
||||
import { Chevron, ExternalArrow, NewTab } from "./icons"
|
||||
import { Inline, ReleaseItem } from "./release"
|
||||
import { prefersReducedMotion } from "./products"
|
||||
@@ -572,18 +573,13 @@ function EntryCarousel(props: { slides: Slide[]; title: string }) {
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={siteSettings.changelogCarousel === "bars + arrows"}>
|
||||
<span data-slot="arrows">
|
||||
<button type="button" aria-label={i18n.t("changelog.media.previous")} onClick={() => go(current() - 1)}>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<path d="M7.5 3L4.5 6L7.5 9" stroke="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" aria-label={i18n.t("changelog.media.next")} onClick={() => go(current() + 1)}>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<path d="M4.5 3L7.5 6L4.5 9" stroke="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
<CarouselArrows
|
||||
data-slot="arrows"
|
||||
previous={i18n.t("changelog.media.previous")}
|
||||
next={i18n.t("changelog.media.next")}
|
||||
onPrevious={() => go(current() - 1)}
|
||||
onNext={() => go(current() + 1)}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/* The products carousel's previous and next (products-arrows.tsx): under the cards at the content's
|
||||
end edge, the chevron lined up with it, in the space before the next section, so they take no room
|
||||
and move nothing when they appear. Fine pointers only; touch screens swipe. */
|
||||
[data-site] [data-component="products"] {
|
||||
position: relative;
|
||||
|
||||
> [data-slot="carousel-nav"] {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: calc(100% + 1rem);
|
||||
inset-inline-end: calc(var(--edge) - 6px);
|
||||
}
|
||||
|
||||
@media (pointer: fine) {
|
||||
> [data-slot="carousel-nav"] {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import "./products-arrows.css"
|
||||
import { createSignal, onCleanup, onMount, Show } from "solid-js"
|
||||
import { useI18n } from "~/context/i18n"
|
||||
import { CarouselArrows } from "./carousel-arrows"
|
||||
import { prefersReducedMotion } from "./products"
|
||||
|
||||
/**
|
||||
* The products carousel's previous and next, for a mouse, whose wheel can't scroll the cards
|
||||
* sideways. ProductCarousel loads this only on a fine pointer once the cards overflow, so touch
|
||||
* screens and screens wide enough for every card never download it. Shown while the cards overflow,
|
||||
* each end disabled once the cards reach it.
|
||||
*/
|
||||
export default function ProductsArrows(props: { track: HTMLElement }) {
|
||||
const i18n = useI18n()
|
||||
const [scrolled, setScrolled] = createSignal<{ start: boolean; end: boolean }>()
|
||||
const track = props.track
|
||||
track.id ||= "products-track"
|
||||
const measure = () => {
|
||||
const max = track.scrollWidth - track.clientWidth
|
||||
const at = Math.abs(track.scrollLeft)
|
||||
setScrolled(max > 1 ? { start: at < 2, end: at > max - 2 } : undefined)
|
||||
}
|
||||
// By as many whole cards as fit (at least one); the snap lines them up.
|
||||
const page = (direction: 1 | -1) => {
|
||||
const card = track.firstElementChild
|
||||
if (!(card instanceof HTMLElement)) return
|
||||
const style = getComputedStyle(track)
|
||||
const step = card.offsetWidth + (parseFloat(style.columnGap) || 0)
|
||||
const cards = Math.max(1, Math.floor(track.clientWidth / step))
|
||||
track.scrollBy({
|
||||
left: direction * (style.direction === "rtl" ? -1 : 1) * step * cards,
|
||||
behavior: prefersReducedMotion() ? "instant" : "smooth",
|
||||
})
|
||||
}
|
||||
onMount(() => {
|
||||
measure()
|
||||
const resize = new ResizeObserver(measure)
|
||||
resize.observe(track)
|
||||
track.addEventListener("scroll", measure, { passive: true })
|
||||
onCleanup(() => {
|
||||
resize.disconnect()
|
||||
track.removeEventListener("scroll", measure)
|
||||
})
|
||||
})
|
||||
return (
|
||||
<Show when={scrolled()}>
|
||||
{(edges) => (
|
||||
<div data-slot="carousel-nav">
|
||||
<CarouselArrows
|
||||
previous={i18n.t("site.products.previous")}
|
||||
next={i18n.t("site.products.next")}
|
||||
controls={track.id}
|
||||
atStart={edges().start}
|
||||
atEnd={edges().end}
|
||||
onPrevious={() => page(-1)}
|
||||
onNext={() => page(1)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -6,10 +6,12 @@ import {
|
||||
createUniqueId,
|
||||
For,
|
||||
Match,
|
||||
lazy,
|
||||
on,
|
||||
onCleanup,
|
||||
onMount,
|
||||
Show,
|
||||
Suspense,
|
||||
Switch,
|
||||
type JSX,
|
||||
} from "solid-js"
|
||||
@@ -59,6 +61,9 @@ import { inlineKey } from "~/lib/direction"
|
||||
|
||||
export { installs, products, sdkSnippet, type ProductSlug } from "./product-data"
|
||||
|
||||
// Loaded only for a fine pointer once the carousel's cards overflow (ProductCarousel).
|
||||
const ProductsArrows = lazy(() => import("./products-arrows"))
|
||||
|
||||
/**
|
||||
* Loads a video again once it is on the page. A client-rendered video gets its src while it still
|
||||
* belongs to Solid's template document, and while the router holds a new page off screen for its
|
||||
@@ -245,8 +250,22 @@ export function ProductCarousel(props: { exclude?: ProductSlug; header?: JSX.Ele
|
||||
const productCopy = useArea("products", productsSource)
|
||||
const navigate = useNavigate()
|
||||
let track: HTMLDivElement | undefined
|
||||
// A mouse wheel can't scroll the cards sideways, so a fine pointer gets arrows, loaded once the
|
||||
// cards overflow (products-arrows.tsx). Touch screens swipe and never load them.
|
||||
const [arrows, setArrows] = createSignal(false)
|
||||
|
||||
onMount(() => trackScroll("/", track))
|
||||
onMount(() => {
|
||||
trackScroll("/", track)
|
||||
const check = () => {
|
||||
if (!track || track.scrollWidth - track.clientWidth < 2 || !matchMedia("(pointer: fine)").matches) return
|
||||
setArrows(true)
|
||||
window.removeEventListener("resize", check)
|
||||
}
|
||||
check()
|
||||
if (arrows()) return
|
||||
window.addEventListener("resize", check)
|
||||
onCleanup(() => window.removeEventListener("resize", check))
|
||||
})
|
||||
|
||||
// The visitor's order (Mobile first on a phone or tablet), fixed at the first render.
|
||||
const order = deviceOrder(products)
|
||||
@@ -375,6 +394,13 @@ export function ProductCarousel(props: { exclude?: ProductSlug; header?: JSX.Ele
|
||||
</article>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={arrows() && track}>
|
||||
{(element) => (
|
||||
<Suspense>
|
||||
<ProductsArrows track={element()} />
|
||||
</Suspense>
|
||||
)}
|
||||
</Show>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import type { logoAnimations, SiteSettings } from "./settings"
|
||||
/**
|
||||
* The entrance wash for a grid: the first time `element` scrolls into view, `start` becomes that
|
||||
* moment (performance.now()), and each item arrives `washStep` staggers later. Armed on the client
|
||||
* only, never with reduced motion, and only while `enabled`. `columns` follows the grid's tracks.
|
||||
* only, never with reduced motion, and only while `enabled`. `columns` follows the grid's tracks (or --wash-columns).
|
||||
* Give the container `data-entrance` and `data-mounted={mounted()}` so it waits from first paint.
|
||||
*/
|
||||
export function createWash(
|
||||
@@ -44,8 +44,18 @@ export function createWash(
|
||||
setMounted(true)
|
||||
const target = element()
|
||||
if (!target) return
|
||||
const measure = () =>
|
||||
setColumns(Math.max(1, getComputedStyle(target).gridTemplateColumns.split(" ").filter(Boolean).length))
|
||||
// A grid whose cells span several tracks (the logo tiles, index.css) gives its cells across in
|
||||
// --wash-columns.
|
||||
const measure = () => {
|
||||
const style = getComputedStyle(target)
|
||||
setColumns(
|
||||
Math.max(
|
||||
1,
|
||||
Number(style.getPropertyValue("--wash-columns")) ||
|
||||
style.gridTemplateColumns.split(" ").filter(Boolean).length,
|
||||
),
|
||||
)
|
||||
}
|
||||
measure()
|
||||
const resize = new ResizeObserver(measure)
|
||||
resize.observe(target)
|
||||
|
||||
@@ -495,8 +495,10 @@ export const bannerLabels = ["badge", "plain"] as const
|
||||
// a sentence, a dense grid with the count, or a slow marquee.
|
||||
export const providerLogoStyles = ["none", "row", "inline", "grid", "marquee"] as const
|
||||
// "Bring your own subs": the providers' grid then the plans, your subscriptions beside the plans,
|
||||
// or tabs between the two.
|
||||
export const subsStyles = ["grid", "split", "tabs"] as const
|
||||
// or tabs between the two. A, B and C (byos-options.tsx) explore showing that OpenCode works with
|
||||
// nearly every subscription, provider and model: a ruled row per way in, a wall of every provider's
|
||||
// mark, or the names set as one run of text.
|
||||
export const subsStyles = ["grid", "split", "tabs", "A rows", "B wall", "C names"] as const
|
||||
// "Get started": a numbered path, a price ladder, one large sentence, a terminal transcript, or two
|
||||
// cells in the tools grid's borders.
|
||||
export const getStartedStyles = ["steps", "ladder", "sentence", "terminal", "cards"] as const
|
||||
|
||||
@@ -282,6 +282,8 @@ export const dict = {
|
||||
"site.newTab": "{{label}} (يفتح في علامة تبويب جديدة)",
|
||||
"site.products.label": "المنتجات",
|
||||
"site.products.gridLabel": "منتجات OpenCode",
|
||||
"site.products.previous": "السابق",
|
||||
"site.products.next": "التالي",
|
||||
"site.action.copy": "نسخ",
|
||||
"site.action.copied": "نُسخ",
|
||||
"site.action.desktopDownloads": "تنزيلات Desktop",
|
||||
|
||||
@@ -284,6 +284,8 @@ export const dict = {
|
||||
"site.newTab": "{{label}} (abre em uma nova aba)",
|
||||
"site.products.label": "Produtos",
|
||||
"site.products.gridLabel": "Produtos OpenCode",
|
||||
"site.products.previous": "Anterior",
|
||||
"site.products.next": "Próximo",
|
||||
"site.action.forOs": "Baixar[ para {{os}}](os)",
|
||||
"site.action.copy": "Copiar",
|
||||
"site.action.copied": "Copiado",
|
||||
|
||||
@@ -285,6 +285,8 @@ export const dict = {
|
||||
"site.newTab": "{{label}} (åbner i en ny fane)",
|
||||
"site.products.label": "Produkter",
|
||||
"site.products.gridLabel": "OpenCode-produkter",
|
||||
"site.products.previous": "Forrige",
|
||||
"site.products.next": "Næste",
|
||||
"site.action.forOs": "Download[ til {{os}}](os)",
|
||||
"site.action.copy": "Kopier",
|
||||
"site.action.copied": "Kopieret",
|
||||
|
||||
@@ -284,6 +284,8 @@ export const dict = {
|
||||
"site.newTab": "{{label}} (öffnet in neuem Tab)",
|
||||
"site.products.label": "Produkte",
|
||||
"site.products.gridLabel": "OpenCode-Produkte",
|
||||
"site.products.previous": "Zurück",
|
||||
"site.products.next": "Weiter",
|
||||
"site.action.forOs": "Download[ für {{os}}](os)",
|
||||
"site.action.copy": "Kopieren",
|
||||
"site.action.copied": "Kopiert",
|
||||
|
||||
@@ -284,6 +284,8 @@ export const dict = {
|
||||
"site.newTab": "{{label}} (opens in a new tab)",
|
||||
"site.products.label": "Products",
|
||||
"site.products.gridLabel": "OpenCode products",
|
||||
"site.products.previous": "Previous",
|
||||
"site.products.next": "Next",
|
||||
"site.action.forOs": "Download[ for {{os}}](os)",
|
||||
"site.action.copyCommand": "Copy[ {{method}} command](sr)",
|
||||
"site.action.copiedCommand": "Copied[ {{method}} command](sr)",
|
||||
|
||||
@@ -285,6 +285,8 @@ export const dict = {
|
||||
"site.newTab": "{{label}} (se abre en una pestaña nueva)",
|
||||
"site.products.label": "Productos",
|
||||
"site.products.gridLabel": "Productos de OpenCode",
|
||||
"site.products.previous": "Anterior",
|
||||
"site.products.next": "Siguiente",
|
||||
"site.action.forOs": "Descargar[ para {{os}}](os)",
|
||||
"site.action.copy": "Copiar",
|
||||
"site.action.copied": "Copiado",
|
||||
|
||||
@@ -284,6 +284,8 @@ export const dict = {
|
||||
"site.newTab": "{{label}} (s'ouvre dans un nouvel onglet)",
|
||||
"site.products.label": "Produits",
|
||||
"site.products.gridLabel": "Produits OpenCode",
|
||||
"site.products.previous": "Précédent",
|
||||
"site.products.next": "Suivant",
|
||||
"site.action.forOs": "Télécharger[ pour {{os}}](os)",
|
||||
"site.action.copy": "Copier",
|
||||
"site.action.copied": "Copié",
|
||||
|
||||
@@ -284,6 +284,8 @@ export const dict = {
|
||||
"site.newTab": "{{label}} (si apre in una nuova scheda)",
|
||||
"site.products.label": "Prodotti",
|
||||
"site.products.gridLabel": "Prodotti OpenCode",
|
||||
"site.products.previous": "Precedente",
|
||||
"site.products.next": "Successivo",
|
||||
"site.action.forOs": "Scarica[ per {{os}}](os)",
|
||||
"site.action.copy": "Copia",
|
||||
"site.action.copied": "Copiato",
|
||||
|
||||
@@ -284,6 +284,8 @@ export const dict = {
|
||||
"site.newTab": "{{label}}(新しいタブで開きます)",
|
||||
"site.products.label": "製品",
|
||||
"site.products.gridLabel": "OpenCodeの製品",
|
||||
"site.products.previous": "前へ",
|
||||
"site.products.next": "次へ",
|
||||
"site.action.forOs": "ダウンロード[({{os}}版)](os)",
|
||||
"site.action.copy": "コピー",
|
||||
"site.action.copied": "コピー済み",
|
||||
|
||||
@@ -285,6 +285,8 @@ export const dict = {
|
||||
"site.newTab": "{{label}} (새 탭에서 열림)",
|
||||
"site.products.label": "제품",
|
||||
"site.products.gridLabel": "OpenCode 제품",
|
||||
"site.products.previous": "이전",
|
||||
"site.products.next": "다음",
|
||||
"site.action.forOs": "[{{os}}용 ](os)다운로드",
|
||||
"site.action.copy": "복사",
|
||||
"site.action.copied": "복사됨",
|
||||
|
||||
@@ -285,6 +285,8 @@ export const dict = {
|
||||
"site.newTab": "{{label}} (åpnes i ny fane)",
|
||||
"site.products.label": "Produkter",
|
||||
"site.products.gridLabel": "OpenCode-produkter",
|
||||
"site.products.previous": "Forrige",
|
||||
"site.products.next": "Neste",
|
||||
"site.action.forOs": "Last ned[ for {{os}}](os)",
|
||||
"site.action.copy": "Kopier",
|
||||
"site.action.copied": "Kopiert",
|
||||
|
||||
@@ -285,6 +285,8 @@ export const dict = {
|
||||
"site.newTab": "{{label}} (otwiera się w nowej karcie)",
|
||||
"site.products.label": "Produkty",
|
||||
"site.products.gridLabel": "Produkty OpenCode",
|
||||
"site.products.previous": "Wstecz",
|
||||
"site.products.next": "Dalej",
|
||||
"site.action.forOs": "Pobierz[ na {{os}}](os)",
|
||||
"site.action.copy": "Kopiuj",
|
||||
"site.action.copied": "Skopiowano",
|
||||
|
||||
@@ -281,6 +281,8 @@ export const dict = {
|
||||
"site.newTab": "{{label}} (откроется в новой вкладке)",
|
||||
"site.products.label": "Продукты",
|
||||
"site.products.gridLabel": "Продукты OpenCode",
|
||||
"site.products.previous": "Назад",
|
||||
"site.products.next": "Вперёд",
|
||||
"site.action.copy": "Копировать",
|
||||
"site.action.copied": "Скопировано",
|
||||
"site.action.desktopDownloads": "Загрузки Desktop",
|
||||
|
||||
@@ -285,6 +285,8 @@ export const dict = {
|
||||
"site.newTab": "{{label}} (เปิดในแท็บใหม่)",
|
||||
"site.products.label": "ผลิตภัณฑ์",
|
||||
"site.products.gridLabel": "ผลิตภัณฑ์ OpenCode",
|
||||
"site.products.previous": "ก่อนหน้า",
|
||||
"site.products.next": "ถัดไป",
|
||||
"site.action.forOs": "ดาวน์โหลด[สำหรับ {{os}}](os)",
|
||||
"site.action.copy": "คัดลอก",
|
||||
"site.action.copied": "คัดลอกแล้ว",
|
||||
|
||||
@@ -285,6 +285,8 @@ export const dict = {
|
||||
"site.newTab": "{{label}} (yeni sekmede açılır)",
|
||||
"site.products.label": "Ürünler",
|
||||
"site.products.gridLabel": "OpenCode ürünleri",
|
||||
"site.products.previous": "Önceki",
|
||||
"site.products.next": "Sonraki",
|
||||
"site.action.forOs": "[{{os}} için ](os)İndir",
|
||||
"site.action.copy": "Kopyala",
|
||||
"site.action.copied": "Kopyalandı",
|
||||
|
||||
@@ -280,6 +280,8 @@ export const dict = {
|
||||
"site.newTab": "{{label}} (відкриється в новій вкладці)",
|
||||
"site.products.label": "Продукти",
|
||||
"site.products.gridLabel": "Продукти OpenCode",
|
||||
"site.products.previous": "Назад",
|
||||
"site.products.next": "Далі",
|
||||
"site.action.copy": "Копіювати",
|
||||
"site.action.copied": "Скопійовано",
|
||||
"site.action.desktopDownloads": "Завантаження Desktop",
|
||||
|
||||
@@ -284,6 +284,8 @@ export const dict = {
|
||||
"site.newTab": "{{label}}(在新标签页中打开)",
|
||||
"site.products.label": "产品",
|
||||
"site.products.gridLabel": "OpenCode 产品",
|
||||
"site.products.previous": "上一个",
|
||||
"site.products.next": "下一个",
|
||||
"site.action.forOs": "下载[ {{os}} 版](os)",
|
||||
"site.action.copy": "复制",
|
||||
"site.action.copied": "已复制",
|
||||
|
||||
@@ -285,6 +285,8 @@ export const dict = {
|
||||
"site.newTab": "{{label}}(在新分頁中開啟)",
|
||||
"site.products.label": "產品",
|
||||
"site.products.gridLabel": "OpenCode 產品",
|
||||
"site.products.previous": "上一個",
|
||||
"site.products.next": "下一個",
|
||||
"site.action.forOs": "下載[ {{os}} 版](os)",
|
||||
"site.action.copy": "複製",
|
||||
"site.action.copied": "已複製",
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { docsUrl } from "~/lib/docs"
|
||||
import { providerLogo } from "~/lib/providers"
|
||||
|
||||
/**
|
||||
* Settings › Subscriptions style A, B and C (component/site/byos-options.tsx): explorations of showing
|
||||
* that OpenCode works with nearly every subscription, provider and model. English only while they
|
||||
* are explorations; the chosen one's copy moves into lib/byos.ts (and its translations). A module of
|
||||
* its own, so production, which folds the options out, never loads it. Checked on 2026-11-06 against
|
||||
* this repo:
|
||||
* - Log-ins (packages/core/src/plugin/provider): ChatGPT Plus/Pro (chatgpt.ts, "Sign in with
|
||||
* ChatGPT"), GitHub Copilot (github-copilot.ts), SuperGrok (xai.ts, "SuperGrok Subscription") and
|
||||
* GitLab Duo (gitlab.ts; GitLab Premium or Ultimate, experimental per the provider docs).
|
||||
* - Plans that take the plan's API key, each a provider in the bundled models.dev catalog
|
||||
* (packages/core/src/models-dev/snapshot.txt): zai-coding-plan, kimi-code-plan-global,
|
||||
* minimax-coding-plan (named "MiniMax Token Plan") and ollama-cloud.
|
||||
* - Local models: the Ollama, LM Studio and vLLM provider plugins (ollama.ts, lmstudio.ts, vllm.ts).
|
||||
* - "200+ providers": the snapshot lists 230 (models.dev's live API 235), written as 200+ like the
|
||||
* rest of the site.
|
||||
* No Anthropic mark or name anywhere in these.
|
||||
*/
|
||||
export const byosOptions = {
|
||||
leads: {
|
||||
rows: "Use the subscriptions, API keys and local models you already have, or an OpenCode subscription.",
|
||||
wall: "Log in with ChatGPT Plus or Pro, GitHub Copilot, SuperGrok or GitLab Duo, or connect any of 200+ providers with an API key. Or use an OpenCode subscription.",
|
||||
names:
|
||||
"Use the subscriptions and API keys you already have, or run models on your own machine. Or use an OpenCode subscription.",
|
||||
},
|
||||
rows: {
|
||||
subscriptions: "Subscriptions",
|
||||
providers: "Providers",
|
||||
local: "Local",
|
||||
opencode: "OpenCode",
|
||||
providersMore: "200+ through models.dev",
|
||||
},
|
||||
all: "All 200+ providers",
|
||||
subscriptions: [
|
||||
{ name: "ChatGPT Plus/Pro", id: "openai" },
|
||||
{ name: "GitHub Copilot", id: "github-copilot" },
|
||||
{ name: "SuperGrok", id: "xai" },
|
||||
{ name: "GitLab Duo", id: "gitlab" },
|
||||
{ name: "Z.ai Coding Plan", id: "zai" },
|
||||
{ name: "Kimi For Coding", id: "moonshotai" },
|
||||
{ name: "MiniMax Token Plan", id: "minimax" },
|
||||
{ name: "Ollama Cloud", id: "ollama" },
|
||||
].map((item) => ({ name: item.name, logo: providerLogo(item.id) })),
|
||||
local: [
|
||||
{ name: "Ollama", logo: providerLogo("ollama") },
|
||||
{ name: "LM Studio", logo: providerLogo("lmstudio") },
|
||||
{ name: "vLLM" },
|
||||
],
|
||||
// The same pages as Get started and the main nav link (lib/get-started.ts, links.ts).
|
||||
opencode: [
|
||||
{ name: "Free models", href: docsUrl("/console/models/#free-models") },
|
||||
{ name: "OpenCode Go", href: "/console/go" },
|
||||
{ name: "OpenCode Models", href: "/console/models" },
|
||||
],
|
||||
} as const
|
||||
@@ -1831,16 +1831,38 @@
|
||||
|
||||
/* Logo-only tiles ("Bring your own subs"): the mark centred, twice the icon's size. The
|
||||
models.dev marks share one square box with their own margins, so one size balances them; it
|
||||
steps down at narrow widths and stays on whole pixels. */
|
||||
steps down at narrow widths and stays on whole pixels.
|
||||
Four across (three on phones) on 12 tracks, so a short last row can share the full width
|
||||
equally and no cell is left empty, whatever the number of logos. --wash-columns tells the
|
||||
entrance wash (reveal.ts) the number of cells across. */
|
||||
&[data-tiles="icon"] {
|
||||
--icon-size: 40px;
|
||||
--wash-columns: 4;
|
||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||
|
||||
li {
|
||||
grid-column: span 3;
|
||||
justify-content: center;
|
||||
height: 4rem;
|
||||
padding-block: 0;
|
||||
}
|
||||
|
||||
@media (width > 40rem) {
|
||||
li:nth-child(4n + 1):nth-last-child(3),
|
||||
li:nth-child(4n + 1):nth-last-child(3) ~ li {
|
||||
grid-column: span 4;
|
||||
}
|
||||
|
||||
li:nth-child(4n + 1):nth-last-child(2),
|
||||
li:nth-child(4n + 1):nth-last-child(2) ~ li {
|
||||
grid-column: span 6;
|
||||
}
|
||||
|
||||
li:nth-child(4n + 1):last-child {
|
||||
grid-column: span 12;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="icon"] {
|
||||
width: var(--icon-size);
|
||||
height: var(--icon-size);
|
||||
@@ -1850,10 +1872,23 @@
|
||||
--icon-size: 36px;
|
||||
}
|
||||
|
||||
/* Phones: three across (the wordmark tiles keep two), so twelve logos make four full rows. */
|
||||
/* Phones: three across (the wordmark tiles keep two). */
|
||||
@media (max-width: 40rem) {
|
||||
--icon-size: 32px;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
--wash-columns: 3;
|
||||
|
||||
li {
|
||||
grid-column: span 4;
|
||||
}
|
||||
|
||||
li:nth-child(3n + 1):nth-last-child(2),
|
||||
li:nth-child(3n + 1):nth-last-child(2) ~ li {
|
||||
grid-column: span 6;
|
||||
}
|
||||
|
||||
li:nth-child(3n + 1):last-child {
|
||||
grid-column: span 12;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ import { byos } from "~/lib/byos"
|
||||
import { localize, useArea } from "~/i18n/content"
|
||||
import { announcementsSource } from "~/i18n/content/areas/announcements"
|
||||
import "~/component/site/byos.css"
|
||||
import { ByosOptionLeads, ByosOptions } from "~/component/site/byos-options"
|
||||
import { getStarted } from "~/lib/get-started"
|
||||
import "~/component/site/get-started.css"
|
||||
import { installs } from "~/component/site/product-data"
|
||||
@@ -1254,6 +1255,17 @@ function BringYourOwnSubs() {
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
{/* Spelled out so the production build folds it (script/fold-settings.ts). */}
|
||||
<Show
|
||||
when={
|
||||
import.meta.env.DEV ||
|
||||
siteSettings.subsStyle === "A rows" ||
|
||||
siteSettings.subsStyle === "B wall" ||
|
||||
siteSettings.subsStyle === "C names"
|
||||
}
|
||||
>
|
||||
<ByosOptionLeads />
|
||||
</Show>
|
||||
</p>
|
||||
|
||||
{/* "grid": the providers' grid. */}
|
||||
@@ -1308,6 +1320,18 @@ function BringYourOwnSubs() {
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* A, B and C: showing that OpenCode works with nearly every subscription, provider and model. */}
|
||||
<Show
|
||||
when={
|
||||
import.meta.env.DEV ||
|
||||
siteSettings.subsStyle === "A rows" ||
|
||||
siteSettings.subsStyle === "B wall" ||
|
||||
siteSettings.subsStyle === "C names"
|
||||
}
|
||||
>
|
||||
<ByosOptions />
|
||||
</Show>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user