Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .review-stream-chunks.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Count chunks + measure progressive delivery of renderToStream(ssr:false)
import { html, renderToStream, Suspense } from '@webjsdev/core/server';

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function collect(label, stream) {
const reader = stream.getReader();
const chunks = [];
const t0 = Date.now();
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push({ at: Date.now() - t0, len: value.length, head: value.slice(0, 40) });
}
console.log(`\n== ${label} ==`);
for (const c of chunks) console.log(` +${c.at}ms len=${c.len} ${JSON.stringify(c.head)}`);
console.log(` total chunks: ${chunks.length}`);
}

// 1. Template with slow async text holes: progressive flushing?
const slow = sleep(150).then(() => 'SLOW');
await collect(
'async text holes',
renderToStream(html`<div>A</div><p>${slow}</p><div>B</div><p>${sleep(300).then(() => 'SLOWER')}</p>`, { ssr: false }),
);

// 2. Suspense inside a template hole (ssr:false raw streaming path)
const boundary = Suspense({
fallback: html`<em>loading…</em>`,
children: sleep(200).then(() => html`<strong>resolved</strong>`),
});
const { createSuspenseCtx } = await import('@webjsdev/core/server').catch(() => ({}));
await collect(
'suspense in template',
renderToStream(html`<section>${boundary}</section>`, { ssr: false }),
);
38 changes: 17 additions & 21 deletions website/app/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,14 +293,14 @@ export default function RootLayout({ children }: LayoutProps) {
@media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) {
--heart: oklch(0.74 0.18 6);
--fg: oklch(0.98 0 0); --fg-muted: oklch(0.80 0 0); --fg-subtle: oklch(0.68 0 0);
--bg: oklch(0 0 0); --bg-elev: oklch(0.12 0 0); --bg-subtle: oklch(0.08 0 0); --bg-sunken: oklch(0 0 0);
--border: oklch(0.26 0 0 / 0.9); --border-strong: oklch(0.38 0 0 / 0.95);
--fg: oklch(0.96 0.01 60); --fg-muted: oklch(0.78 0.015 60); --fg-subtle: oklch(0.66 0.02 65);
--bg: oklch(0.08 0.012 60); --bg-elev: oklch(0.14 0.015 60); --bg-subtle: oklch(0.11 0.014 60); --bg-sunken: oklch(0.06 0.01 60);
--border: oklch(0.24 0.015 60 / 0.9); --border-strong: oklch(0.36 0.02 60 / 0.95);
--accent: oklch(0.78 0.18 58); --accent-hover: oklch(0.83 0.17 58); --accent-fg: oklch(0 0 0); --logo-from: oklch(0.82 0.17 58); --logo-to: oklch(0.64 0.18 44);
--accent-live: oklch(0.78 0.18 58);
--glow-a: transparent;
--glow-strength: 0;
--cta-surface: transparent;
--glow-a: oklch(0.78 0.18 58);
--glow-strength: 0.08;
--cta-surface: color-mix(in oklch, var(--accent-live) 6%, var(--bg-elev));
--hover-surface: oklch(1 0 0 / 0.09);
--shadow-sm: 0 1px 2px oklch(0 0 0 / 0.4);
--shadow: 0 10px 40px oklch(0 0 0 / 0.5), 0 2px 6px oklch(0 0 0 / 0.35);
Expand All @@ -309,14 +309,14 @@ export default function RootLayout({ children }: LayoutProps) {
:root[data-theme='dark'] {
color-scheme: dark;
--heart: oklch(0.74 0.18 6);
--fg: oklch(0.98 0 0); --fg-muted: oklch(0.80 0 0); --fg-subtle: oklch(0.68 0 0);
--bg: oklch(0 0 0); --bg-elev: oklch(0.12 0 0); --bg-subtle: oklch(0.08 0 0); --bg-sunken: oklch(0 0 0);
--border: oklch(0.26 0 0 / 0.9); --border-strong: oklch(0.38 0 0 / 0.95);
--fg: oklch(0.96 0.01 60); --fg-muted: oklch(0.78 0.015 60); --fg-subtle: oklch(0.66 0.02 65);
--bg: oklch(0.08 0.012 60); --bg-elev: oklch(0.14 0.015 60); --bg-subtle: oklch(0.11 0.014 60); --bg-sunken: oklch(0.06 0.01 60);
--border: oklch(0.24 0.015 60 / 0.9); --border-strong: oklch(0.36 0.02 60 / 0.95);
--accent: oklch(0.78 0.18 58); --accent-hover: oklch(0.83 0.17 58); --accent-fg: oklch(0 0 0); --logo-from: oklch(0.82 0.17 58); --logo-to: oklch(0.64 0.18 44);
--accent-live: oklch(0.78 0.18 58);
--glow-a: transparent;
--glow-strength: 0;
--cta-surface: transparent;
--glow-a: oklch(0.78 0.18 58);
--glow-strength: 0.08;
--cta-surface: color-mix(in oklch, var(--accent-live) 6%, var(--bg-elev));
--hover-surface: oklch(1 0 0 / 0.09);
--shadow-sm: 0 1px 2px oklch(0 0 0 / 0.4);
--shadow: 0 10px 40px oklch(0 0 0 / 0.5), 0 2px 6px oklch(0 0 0 / 0.35);
Expand Down Expand Up @@ -356,18 +356,14 @@ export default function RootLayout({ children }: LayoutProps) {
selector outranks the border-* utilities that also set
border-right-color, so cascade order does not matter. */
.site-top > header { border-right: var(--wj-scrollbar-compensation, 0px) solid transparent; }
/* The page backdrop is deliberately UNCHANGED from what webjs.dev
serves: one fixed layer, two radials off --glow-a, no animation. A
dot-grid texture and a third magenta radial were tried here and
reverted. They tinted every page, which is a site-wide change, and the
glow that was actually wanted belongs to the closing CTA panel, where
it comes from --shadow-glow on the panel itself. */
/* Background glow layer with soft radial ambient glow */
.glow-layer { position: fixed; inset: 0; z-index: 0; pointer-events: none; }
.glow-layer::before {
content: ''; position: absolute; inset: 0;
background:
radial-gradient(58% 44% at 50% -4%, color-mix(in oklch, var(--glow-a) calc(var(--glow-strength) * 100%), transparent), transparent 72%),
radial-gradient(40% 36% at 88% 8%, color-mix(in oklch, var(--glow-a) calc(var(--glow-strength) * 60%), transparent), transparent 70%);
radial-gradient(60% 48% at 50% -5%, color-mix(in oklch, var(--glow-a) calc(var(--glow-strength) * 100%), transparent), transparent 75%),
radial-gradient(45% 40% at 85% 10%, color-mix(in oklch, var(--glow-a) calc(var(--glow-strength) * 60%), transparent), transparent 70%),
radial-gradient(50% 50% at 15% 85%, color-mix(in oklch, var(--glow-a) calc(var(--glow-strength) * 40%), transparent), transparent 70%);
}
.scroll-thin { scrollbar-width: thin; scrollbar-color: transparent transparent; transition: scrollbar-color var(--t); }
.scroll-thin:hover { scrollbar-color: color-mix(in oklch, var(--fg-subtle) 70%, transparent) transparent; }
Expand Down Expand Up @@ -400,7 +396,7 @@ export default function RootLayout({ children }: LayoutProps) {
<div class="glow-layer" aria-hidden="true"></div>

<div class="site-top fixed inset-x-0 top-0 z-20">
<header class="backdrop-blur-md bg-[color-mix(in_oklch,var(--color-bg)_78%,transparent)] border-b border-border">
<header class="backdrop-blur-md bg-[color-mix(in_oklch,var(--color-bg)_50%,transparent)] border-b border-border">
<div class="max-w-7xl mx-auto px-6 py-3 flex items-center justify-between gap-4">
<a class="inline-flex items-center no-underline text-fg shrink-0 transition-opacity duration-150 hover:opacity-80" href="/" aria-label="WebJs home">
${brandLockup('hdr', { height: 26 })}
Expand Down
20 changes: 7 additions & 13 deletions website/app/page.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { html } from '@webjsdev/core';
import '#components/copy-cmd.ts';
import '#components/like-button.ts';
import { COMPONENT_SAMPLE, ACTION_SAMPLE, PAGE_SAMPLE, USAGE_SAMPLE } from '#lib/samples.ts';
import { COMPONENT_SAMPLE, TOGGLE_SAMPLE, ACTION_SAMPLE, PAGE_SAMPLE, USAGE_SAMPLE } from '#lib/samples.ts';
import { DOCS_PATH, DOCS_START_PATH, GH_URL, NEW_TAB } from '#lib/links.ts';
// highlight() runs only at SSR (codeWindow renders its output into the served
// HTML), but it does ship to the client as a small dead module: the page loads
Expand Down Expand Up @@ -246,16 +246,12 @@ export default function LandingPage() {
</section>

<section class="py-16">
<div class="max-w-7xl mx-auto px-6">
<div class="max-w-5xl mx-auto px-6">
<div class="max-w-3xl mx-auto mb-12 text-center">
<h2 class="font-display font-bold text-h2 leading-[1.12] tracking-[-0.03em] my-3 text-balance">The whole stack, in three files</h2>
<p class="text-fg-muted text-base leading-[1.6] m-0">A component, a server action, and a page. No build, no boilerplate, all web standards.</p>
<h2 class="font-display font-bold text-h2 leading-[1.12] tracking-[-0.03em] my-3 text-balance">Server action & SSR page</h2>
<p class="text-fg-muted text-base leading-[1.6] m-0">A server action and a page. No build, no boilerplate, all web standards.</p>
</div>
<div class="grid gap-4 grid-cols-1 max-w-2xl mx-auto wide:grid-cols-3 wide:max-w-none">
<div class="flex flex-col min-w-0">
<p class="text-sm font-medium leading-[1.4] text-fg-subtle mb-2.5 ml-1">Interactive component</p>
${codeWindow('components/like-button.ts', COMPONENT_SAMPLE)}
</div>
<div class="grid gap-6 grid-cols-1 md:grid-cols-2">
<div class="flex flex-col min-w-0">
<p class="text-sm font-medium leading-[1.4] text-fg-subtle mb-2.5 ml-1">Server action (RPC)</p>
${codeWindow('actions/get-post.server.ts', ACTION_SAMPLE)}
Expand Down Expand Up @@ -381,17 +377,15 @@ export default function LandingPage() {
</div>
<div class="grid gap-4 grid-cols-1 max-w-2xl mx-auto wide:grid-cols-2 wide:max-w-3xl">
<div class="flex flex-col gap-3 p-6 min-w-0 rounded-2xl border border-border bg-bg-elev">
<span class="text-xs font-medium leading-none text-accent">Full-stack</span>
<h3 class="font-display font-bold text-lg leading-[1.25] m-0">Pages + API + components</h3>
<h3 class="font-display font-bold text-lg leading-[1.25] m-0">Full Stack</h3>
<p class="m-0 text-sm leading-[1.6] text-fg-muted">SSR pages, web components, server actions, Drizzle, streaming, and a browsable feature gallery. Auth (login, sessions, a protected route) ships as a gallery card. The default.</p>
<pre class="scroll-thin m-0 px-3.5 py-3 overflow-x-auto rounded-lg border border-border bg-bg-subtle font-mono text-xs leading-[1.6] text-fg-muted" role="region" tabindex="0" aria-label="Example files in a full-stack app">app/page.ts
components/counter.ts
actions/posts.server.ts</pre>
<div class="cmd-foot pt-2 mt-auto font-mono text-xs leading-[1.6] text-fg-muted max-w-full min-w-0"><copy-cmd>npm create webjs@latest my-app</copy-cmd></div>
</div>
<div class="flex flex-col gap-3 p-6 min-w-0 rounded-2xl border border-border bg-bg-elev">
<span class="text-xs font-medium leading-none text-accent">Backend (API)</span>
<h3 class="font-display font-bold text-lg leading-[1.25] m-0">Route handlers + Database</h3>
<h3 class="font-display font-bold text-lg leading-[1.25] m-0">Backend</h3>
<p class="m-0 text-sm leading-[1.6] text-fg-muted">A backend-only app, no UI or SSR. File-based route handlers, modules, middleware, rate limiting, WebSockets, a database, and a backend-features gallery.</p>
<pre class="scroll-thin m-0 px-3.5 py-3 overflow-x-auto rounded-lg border border-border bg-bg-subtle font-mono text-xs leading-[1.6] text-fg-muted" role="region" tabindex="0" aria-label="Example files in an API app">app/api/users/route.ts
app/api/chat/route.ts
Expand Down
4 changes: 2 additions & 2 deletions website/app/what-is-webjs/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ export default function WhatIsWebJs() {
<div class="max-w-3xl mx-auto mb-12 text-center">
<h2 class=${H2}>What a WebJs app looks like</h2>
<p class=${PROSE}>
A component, a server function, and a page. These are ordinary files in your project,
A component, a server action, and a page. These are ordinary files in your project,
served as written. There is no compile step between what you see here and what the
browser receives.
</p>
Expand All @@ -261,7 +261,7 @@ export default function WhatIsWebJs() {
${codeWindow('components/like-button.ts', COMPONENT_SAMPLE, 'A WebJs web component with a signal')}
</div>
<div class="flex flex-col min-w-0">
<p class="font-mono font-semibold text-xs leading-[1.4] tracking-widest uppercase text-fg-subtle mb-2.5 ml-1">A server function</p>
<p class="font-mono font-semibold text-xs leading-[1.4] tracking-widest uppercase text-fg-subtle mb-2.5 ml-1">A server action</p>
${codeWindow('actions/get-post.server.ts', ACTION_SAMPLE, 'A WebJs server action reading from the database')}
</div>
<div class="flex flex-col min-w-0">
Expand Down
79 changes: 79 additions & 0 deletions website/components/ui/accordion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* Accordion: vertical collapsible list built on native <details>/<summary>.
*
* Tier-1 (no custom element). Exclusive open behaviour (Radix's
* `type="single"`) comes from giving every <details> the same
* `name="..."` attribute. Independent open (`type="multiple"`) is the
* default when `name` is omitted. Both modes give `collapsible` for
* free: clicking the open <summary> closes it.
*
* shadcn parity:
* <Accordion type="single" collapsible> → <div class=${accordionClass()}> wrapping
* <details name="..."> items
* <Accordion type="multiple"> → same, omit `name`
* <AccordionItem> → <details class=${accordionItemClass()}>
* <AccordionTrigger> → <summary class=${accordionTriggerClass()}>
* <AccordionContent> → <div class=${accordionContentClass()}>
*
* Initial state: add `open` on the <details> that should render expanded
* on first paint. Programmatic toggling: `el.open = true | false`.
*
* `<details name="X">` is the platform's exclusive-accordion primitive:
* Chrome 120+, Safari 17.2+, Firefox 130+. Migrated from the prior
* <ui-accordion> custom element set.
*
* Design tokens used: --border, --ring, --foreground.
*
* @example
* ```html
* <div class=${accordionClass()}>
* <details name="faq" class=${accordionItemClass()} open>
* <summary class=${accordionTriggerClass()}>
* <span>Is it accessible?</span>
* <svg class="size-4 shrink-0 transition-transform group-open:rotate-180" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
* </summary>
* <div class=${accordionContentClass()}>Yes, it uses a native disclosure widget.</div>
* </details>
* <details name="faq" class=${accordionItemClass()}>
* <summary class=${accordionTriggerClass()}>
* <span>Is it styled?</span>
* <svg class="size-4 shrink-0 transition-transform group-open:rotate-180" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
* </summary>
* <div class=${accordionContentClass()}>Yes, with the shadcn design tokens.</div>
* </details>
* </div>
* ```
*/

/** Root wrapper. Holds the column-of-items rhythm; no display: rules. */
export const accordionClass = (): string => 'w-full';

/**
* Item: each <details>. The `group` utility lets the trigger's chevron
* rotate on open via `group-open:rotate-180`. `last:border-b-0` cleans
* the trailing edge.
*/
export const accordionItemClass = (): string => 'group border-b last:border-b-0';

/**
* Trigger: applied to <summary>. Hides the native disclosure triangle so
* authors can compose their own chevron icon (typical pattern: trailing
* lucide chevron with `group-open:rotate-180`).
*
* `disabled: true` returns the visual disabled state (greyed out,
* not-allowed cursor, no pointer events). For true keyboard prevention
*, the native disabled-disclosure-widget gap, add the standard
* `inert` attribute to the <details> element. shadcn's React `disabled`
* prop combines both; native HTML has no `disabled` on <details>.
*/
export const accordionTriggerClass = (opts: { disabled?: boolean } = {}): string => {
const base = 'flex w-full cursor-pointer list-none items-center justify-between gap-4 py-4 text-left text-sm font-medium outline-none transition-all hover:underline focus-visible:ring-2 focus-visible:ring-ring/50 marker:hidden [&::-webkit-details-marker]:hidden';
if (opts.disabled) return `${base} pointer-events-none cursor-not-allowed opacity-50`;
return base;
};

/**
* Content: <details> hides this entirely when not [open], so all we add
* is the typography rhythm matching shadcn (bottom padding, small text).
*/
export const accordionContentClass = (): string => 'pb-4 text-sm';
24 changes: 24 additions & 0 deletions website/components/ui/aspect-ratio.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* AspectRatio: preserve a width:height ratio for its child. Tier-1
* class helper over the modern CSS `aspect-ratio` property (Baseline
* 2022). No JS, no custom element.
*
* shadcn parity:
* AspectRatio (Radix primitive) → aspectRatioClass() + inline `aspect-ratio` style
*
* Design tokens used: none (layout only).
*
* @example
* ```html
* <div style="aspect-ratio: 16/9;" class="${aspectRatioClass()}">
* <img src="/hero.jpg" alt="Team offsite" class="h-full w-full object-cover rounded-md">
* </div>
*
* <!-- Or with Tailwind's arbitrary aspect-ratio (no helper needed): -->
* <div class="aspect-[16/9]">
* <img src="/hero.jpg" alt="Team offsite" class="h-full w-full object-cover rounded-md">
* </div>
* ```
*/

export const aspectRatioClass = (): string => 'relative w-full';
50 changes: 50 additions & 0 deletions website/components/ui/breadcrumb.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* Breadcrumb: semantic nav with breadcrumb list. Tier-1 class helpers;
* compose with native `<nav>` + `<ol>` + `<li>` + `<a>` / `<span>`.
*
* shadcn parity:
* Breadcrumb → <nav aria-label="breadcrumb" data-slot="breadcrumb">
* BreadcrumbList → breadcrumbListClass()
* BreadcrumbItem → breadcrumbItemClass()
* BreadcrumbLink → breadcrumbLinkClass()
* BreadcrumbPage → breadcrumbPageClass() (with aria-current="page")
* BreadcrumbSeparator → breadcrumbSeparatorClass() (aria-hidden + role="presentation")
* BreadcrumbEllipsis → breadcrumbEllipsisClass()
*
* A11y (required for accessible output): wrap the list in <nav
* aria-label="breadcrumb">, set aria-current="page" on the current-page
* element, and mark each separator role="presentation" aria-hidden="true".
* The class helpers emit none of these.
*
* Design tokens used: --muted-foreground, --foreground.
*
* @example
* ```html
* <nav aria-label="breadcrumb" data-slot="breadcrumb">
* <ol class=${breadcrumbListClass()}>
* <li class=${breadcrumbItemClass()}>
* <a class=${breadcrumbLinkClass()} href="/">Home</a>
* </li>
* <li class=${breadcrumbSeparatorClass()} role="presentation" aria-hidden="true">
* <svg class="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m9 18 6-6-6-6" /></svg>
* </li>
* <li class=${breadcrumbItemClass()}>
* <span class=${breadcrumbPageClass()} aria-current="page">Posts</span>
* </li>
* </ol>
* </nav>
* ```
*/

export const breadcrumbListClass = (): string =>
'flex flex-wrap items-center gap-1.5 text-sm break-words text-muted-foreground sm:gap-2.5';

export const breadcrumbItemClass = (): string => 'inline-flex items-center gap-1.5';

export const breadcrumbLinkClass = (): string => 'transition-colors hover:text-foreground';

export const breadcrumbPageClass = (): string => 'font-normal text-foreground';

export const breadcrumbSeparatorClass = (): string => '[&>svg]:size-3.5';

export const breadcrumbEllipsisClass = (): string => 'flex size-9 items-center justify-center';
Loading
Loading