feat(site): carousel arrows, a full logo grid, and three ways to show every provider (#62398)

This commit is contained in:
Adam
2026-11-06 12:07:09 -06:00
committed by GitHub
Unverified
parent e1989fcce4
commit 0fdff425ff
32 changed files with 765 additions and 30 deletions
@@ -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>
)
}
+27 -1
View File
@@ -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>
)
}
+13 -3
View File
@@ -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)
+4 -2
View File
@@ -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
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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)",
+2
View File
@@ -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",
+2
View File
@@ -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é",
+2
View File
@@ -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",
+2
View File
@@ -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": "コピー済み",
+2
View File
@@ -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": "복사됨",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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": "คัดลอกแล้ว",
+2
View File
@@ -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ı",
+2
View File
@@ -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",
+2
View File
@@ -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": "已复制",
+2
View File
@@ -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": "已複製",
+57
View File
@@ -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
+38 -3
View File
@@ -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;
}
}
}
+24
View File
@@ -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>
)