diff --git a/services/site/src/component/site/byos-options.css b/services/site/src/component/site/byos-options.css new file mode 100644 index 00000000000..f87786ca556 --- /dev/null +++ b/services/site/src/component/site/byos-options.css @@ -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); + } + } +} diff --git a/services/site/src/component/site/byos-options.tsx b/services/site/src/component/site/byos-options.tsx new file mode 100644 index 00000000000..42c73e06153 --- /dev/null +++ b/services/site/src/component/site/byos-options.tsx @@ -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) => ( + ({ text: phrase, href: byos.plansHref })), + )} + /> + ) + return ( + <> + {link(byosOptions.leads.rows)} + {link(byosOptions.leads.wall)} + {link(byosOptions.leads.names)} + + ) +} + +export function ByosOptions() { + const i18n = useI18n() + const providersLink = docsUrl("/providers/") + return ( +
+ {/* A: a ruled row for each way in, its kind in the first column. */} +
+
+
{byosOptions.rows.subscriptions}
+
+
    + + {(item) => ( +
  • + + {item.name} +
  • + )} +
    +
+
+
+
+
{byosOptions.rows.providers}
+
+
    + + {(provider) => ( +
  • + +
  • + )} +
    +
+ {byosOptions.rows.providersMore} +
+
+
+
{byosOptions.rows.local}
+
+
    + + {(item) => ( +
  • + {(logo) => } + {item.name} +
  • + )} +
    +
+
+
+
+
{byosOptions.rows.opencode}
+
+ +
+
+
+ + {/* 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. */} +
+
    + + {(provider) => ( +
  • + +
  • + )} +
    +
+

+ +

+
+ + {/* C: the names as one wrapping run, the subscriptions in ink, each with its mark. */} + +
+ ) +} + +/** 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 ( + + ) +} diff --git a/services/site/src/component/site/carousel-arrows.css b/services/site/src/component/site/carousel-arrows.css new file mode 100644 index 00000000000..64f95c693a5 --- /dev/null +++ b/services/site/src/component/site/carousel-arrows.css @@ -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; +} diff --git a/services/site/src/component/site/carousel-arrows.tsx b/services/site/src/component/site/carousel-arrows.tsx new file mode 100644 index 00000000000..86d184cffb3 --- /dev/null +++ b/services/site/src/component/site/carousel-arrows.tsx @@ -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 ( + + + + + ) +} diff --git a/services/site/src/component/site/changelog-entry.css b/services/site/src/component/site/changelog-entry.css index 0016f26cf44..5575ab6f16b 100644 --- a/services/site/src/component/site/changelog-entry.css +++ b/services/site/src/component/site/changelog-entry.css @@ -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; diff --git a/services/site/src/component/site/changelog-entry.tsx b/services/site/src/component/site/changelog-entry.tsx index 64a00457d65..715fbb8f0f0 100644 --- a/services/site/src/component/site/changelog-entry.tsx +++ b/services/site/src/component/site/changelog-entry.tsx @@ -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 }) { - - - - + go(current() - 1)} + onNext={() => go(current() + 1)} + /> diff --git a/services/site/src/component/site/products-arrows.css b/services/site/src/component/site/products-arrows.css new file mode 100644 index 00000000000..a4a05fb8825 --- /dev/null +++ b/services/site/src/component/site/products-arrows.css @@ -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; + } + } +} diff --git a/services/site/src/component/site/products-arrows.tsx b/services/site/src/component/site/products-arrows.tsx new file mode 100644 index 00000000000..ea5914c2bfe --- /dev/null +++ b/services/site/src/component/site/products-arrows.tsx @@ -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 ( + + {(edges) => ( +
+ page(-1)} + onNext={() => page(1)} + /> +
+ )} +
+ ) +} diff --git a/services/site/src/component/site/products.tsx b/services/site/src/component/site/products.tsx index cd05e2bb165..65df5ee407d 100644 --- a/services/site/src/component/site/products.tsx +++ b/services/site/src/component/site/products.tsx @@ -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 + + {(element) => ( + + + + )} + ) } diff --git a/services/site/src/component/site/reveal.ts b/services/site/src/component/site/reveal.ts index a3ea0bfc0d4..9cd06d9a8d9 100644 --- a/services/site/src/component/site/reveal.ts +++ b/services/site/src/component/site/reveal.ts @@ -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) diff --git a/services/site/src/component/site/settings.ts b/services/site/src/component/site/settings.ts index 65ec56795c4..12b60366540 100644 --- a/services/site/src/component/site/settings.ts +++ b/services/site/src/component/site/settings.ts @@ -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 diff --git a/services/site/src/i18n/ar.ts b/services/site/src/i18n/ar.ts index cd06011de4f..5bd0d30ca62 100644 --- a/services/site/src/i18n/ar.ts +++ b/services/site/src/i18n/ar.ts @@ -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", diff --git a/services/site/src/i18n/br.ts b/services/site/src/i18n/br.ts index ce074d8809c..6c371942293 100644 --- a/services/site/src/i18n/br.ts +++ b/services/site/src/i18n/br.ts @@ -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", diff --git a/services/site/src/i18n/da.ts b/services/site/src/i18n/da.ts index 0e035c1230c..b0e94a074f2 100644 --- a/services/site/src/i18n/da.ts +++ b/services/site/src/i18n/da.ts @@ -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", diff --git a/services/site/src/i18n/de.ts b/services/site/src/i18n/de.ts index 00b3320056f..ac5e77a187b 100644 --- a/services/site/src/i18n/de.ts +++ b/services/site/src/i18n/de.ts @@ -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", diff --git a/services/site/src/i18n/en.ts b/services/site/src/i18n/en.ts index 6b21dece9f5..b55305ca5c3 100644 --- a/services/site/src/i18n/en.ts +++ b/services/site/src/i18n/en.ts @@ -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)", diff --git a/services/site/src/i18n/es.ts b/services/site/src/i18n/es.ts index b363f85b7b9..2e4898a6210 100644 --- a/services/site/src/i18n/es.ts +++ b/services/site/src/i18n/es.ts @@ -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", diff --git a/services/site/src/i18n/fr.ts b/services/site/src/i18n/fr.ts index cc71ba5a583..2024d4e2b74 100644 --- a/services/site/src/i18n/fr.ts +++ b/services/site/src/i18n/fr.ts @@ -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é", diff --git a/services/site/src/i18n/it.ts b/services/site/src/i18n/it.ts index 1330d249f78..520a8f2b204 100644 --- a/services/site/src/i18n/it.ts +++ b/services/site/src/i18n/it.ts @@ -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", diff --git a/services/site/src/i18n/ja.ts b/services/site/src/i18n/ja.ts index d4df27694de..e1f197ce1b0 100644 --- a/services/site/src/i18n/ja.ts +++ b/services/site/src/i18n/ja.ts @@ -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": "コピー済み", diff --git a/services/site/src/i18n/ko.ts b/services/site/src/i18n/ko.ts index 8d2f3a92450..9739289db7f 100644 --- a/services/site/src/i18n/ko.ts +++ b/services/site/src/i18n/ko.ts @@ -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": "복사됨", diff --git a/services/site/src/i18n/no.ts b/services/site/src/i18n/no.ts index 77befc68190..1d02f9e2576 100644 --- a/services/site/src/i18n/no.ts +++ b/services/site/src/i18n/no.ts @@ -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", diff --git a/services/site/src/i18n/pl.ts b/services/site/src/i18n/pl.ts index c74751d1605..47cf6a13230 100644 --- a/services/site/src/i18n/pl.ts +++ b/services/site/src/i18n/pl.ts @@ -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", diff --git a/services/site/src/i18n/ru.ts b/services/site/src/i18n/ru.ts index cf566120318..3764200204a 100644 --- a/services/site/src/i18n/ru.ts +++ b/services/site/src/i18n/ru.ts @@ -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", diff --git a/services/site/src/i18n/th.ts b/services/site/src/i18n/th.ts index 2e61c1dfe79..df4a1805673 100644 --- a/services/site/src/i18n/th.ts +++ b/services/site/src/i18n/th.ts @@ -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": "คัดลอกแล้ว", diff --git a/services/site/src/i18n/tr.ts b/services/site/src/i18n/tr.ts index 93a75f787ec..271cf0d32ae 100644 --- a/services/site/src/i18n/tr.ts +++ b/services/site/src/i18n/tr.ts @@ -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ı", diff --git a/services/site/src/i18n/uk.ts b/services/site/src/i18n/uk.ts index 0c949d5257a..5b234decb31 100644 --- a/services/site/src/i18n/uk.ts +++ b/services/site/src/i18n/uk.ts @@ -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", diff --git a/services/site/src/i18n/zh.ts b/services/site/src/i18n/zh.ts index 0f913ee79dc..783529e3986 100644 --- a/services/site/src/i18n/zh.ts +++ b/services/site/src/i18n/zh.ts @@ -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": "已复制", diff --git a/services/site/src/i18n/zht.ts b/services/site/src/i18n/zht.ts index fe60535c32a..9e4c59a68a7 100644 --- a/services/site/src/i18n/zht.ts +++ b/services/site/src/i18n/zht.ts @@ -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": "已複製", diff --git a/services/site/src/lib/byos-options.ts b/services/site/src/lib/byos-options.ts new file mode 100644 index 00000000000..f7cf49d05f5 --- /dev/null +++ b/services/site/src/lib/byos-options.ts @@ -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 diff --git a/services/site/src/routes/index.css b/services/site/src/routes/index.css index 4d6934e1bae..f25b4cad84b 100644 --- a/services/site/src/routes/index.css +++ b/services/site/src/routes/index.css @@ -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; + } } } diff --git a/services/site/src/routes/index.tsx b/services/site/src/routes/index.tsx index 6beb062278c..8a434eb79eb 100644 --- a/services/site/src/routes/index.tsx +++ b/services/site/src/routes/index.tsx @@ -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() { )} /> + {/* Spelled out so the production build folds it (script/fold-settings.ts). */} + + +

{/* "grid": the providers' grid. */} @@ -1308,6 +1320,18 @@ function BringYourOwnSubs() { + + {/* A, B and C: showing that OpenCode works with nearly every subscription, provider and model. */} + + + )