diff --git a/.changeset/docs-part-jsdoc.md b/.changeset/docs-part-jsdoc.md new file mode 100644 index 0000000..2f8fdf5 --- /dev/null +++ b/.changeset/docs-part-jsdoc.md @@ -0,0 +1,5 @@ +--- +"@intentface/chat": patch +--- + +Documentation only, no runtime change. Every part of `Attachments`, `Chip`, `Message`, `Reasoning` and `Steps` now carries JSDoc saying what it is for and which element it renders, so editors show the same reference as the docs site. The package README is refreshed to match the rewritten docs. diff --git a/README.md b/README.md index 0857a08..b4f16db 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,8 @@ primitives for React — and the documentation site and playground at The package ships behavior, state, and wire formats with no styling of its own: a contenteditable composer with commands and chips, a thread with scroll auto-follow, message part segmentation, tool-call -timelines. This is the Base UI model applied to chat — the package owns behavior, you own every class. +timelines, and the app-shell parts around them — a collapsible sidebar, a nav tree, a closable tab +collection. This is the Base UI model applied to chat — the package owns behavior, you own every class. ## Layout diff --git a/app/(chat)/layout.tsx b/app/(chat)/layout.tsx deleted file mode 100644 index 63b5823..0000000 --- a/app/(chat)/layout.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { cookies } from "next/headers"; -import type { ReactNode } from "react"; -import { ChatShell } from "@/components/chat-shell"; -import { readSidebarLayout } from "@/lib/sidebar-cookie"; - -// A server component on purpose: the sidebar's layout is restored from the -// request here, so the first paint is already collapsed or expanded rather than -// snapping a frame later. Shell never reads storage itself for exactly this -// reason — the value has to arrive as a prop. -export default async function ChatLayout({ children }: { children: ReactNode }) { - const stored = readSidebarLayout((await cookies()).toString()); - - return ( - - {children} - - ); -} diff --git a/app/(docs)/[[...slug]]/page.tsx b/app/(docs)/[[...slug]]/page.tsx index db66f4b..59e5ca3 100644 --- a/app/(docs)/[[...slug]]/page.tsx +++ b/app/(docs)/[[...slug]]/page.tsx @@ -1,7 +1,10 @@ +import { ChevronRight } from "@keyline-icons/react"; import { notFound } from "next/navigation"; import { DocsTOC } from "@/components/docs/docs-toc"; +import { DocsTopBarTrigger } from "@/components/docs/docs-top-bar-trigger"; import { PageActions } from "@/components/docs/page-actions"; -import { getPage, source } from "@/lib/docs/source"; +import { ProgressiveBlur } from "@/components/ui/progressive-blur"; +import { getPage, getSectionLabel, source } from "@/lib/docs/source"; import { getMDXComponents } from "@/mdx-components"; type PageProps = { @@ -17,24 +20,60 @@ export default async function DocsPage(props: PageProps) { // fumadocs uses an empty slug for the docs index; the raw-markdown route keys // off the on-disk filename. const markdownSlug = slug?.length ? slug : ["index"]; + const section = getSectionLabel(page.url); return ( -
-
-
-

- {page.data.title} -

- {page.data.description && ( -

{page.data.description}

- )} - -
-
- +
+
+ {/* Content dissolves under the bar instead of cutting off: a progressive + blur plus a fade. */} + +
+ +
-
- + + + {/* isolate: demo z-indexes stay inside the page, below the sticky top bar. */} +
+
+
+
+

+ {page.data.title} +

+ {page.data.description && ( +

+ {page.data.description} +

+ )} +
+
+ +
+
+
+ {/* The changelog lists versions only, not each "Patch Changes" heading. */} + item.depth === 2) + : page.data.toc + } + /> +
); } diff --git a/app/(docs)/layout.tsx b/app/(docs)/layout.tsx index 658ab67..95a071c 100644 --- a/app/(docs)/layout.tsx +++ b/app/(docs)/layout.tsx @@ -1,14 +1,11 @@ import type { ReactNode } from "react"; -import { DocsSidebar } from "@/components/docs/docs-sidebar"; -import { source } from "@/lib/docs/source"; +// The site shell lives in the root layout; the docs only add their own scroller. +// The panel scrolls, not the window, so the frame stays put around it. export default function DocsLayout({ children }: { children: ReactNode }) { return ( -
- -
-
{children}
-
+
+ {children}
); } diff --git a/app/fonts/IoskeleyMono-Medium.woff2 b/app/fonts/IoskeleyMono-Medium.woff2 new file mode 100644 index 0000000..9cce877 Binary files /dev/null and b/app/fonts/IoskeleyMono-Medium.woff2 differ diff --git a/app/fonts/IoskeleyMono-OFL.txt b/app/fonts/IoskeleyMono-OFL.txt new file mode 100644 index 0000000..d8aa8e9 --- /dev/null +++ b/app/fonts/IoskeleyMono-OFL.txt @@ -0,0 +1,92 @@ +Copyright (c) 2025, Ahmed Hatem (https://github.com/ahatem) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +--- + +## SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1. Neither the Font Software nor any of its individual components, + in Original or Modified Versions, may be sold by itself. + +1. Original or Modified Versions of the Font Software may be bundled, + redistributed and/or sold with any software, provided that each copy + contains the above copyright notice and this license. These can be + included either as stand-alone text files, human-readable headers or + in the appropriate machine-readable metadata fields within text or + binary files as long as those fields can be easily viewed by the user. + +1. No Modified Version of the Font Software may use the Reserved Font + Name(s) unless explicit written permission is granted by the corresponding + Copyright Holder. This restriction only applies to the primary font name as + presented to the users. + +1. The name(s) of the Copyright Holder(s) or the Author(s) of the Font + Software shall not be used to promote, endorse or advertise any + Modified Version, except to acknowledge the contribution(s) of the + Copyright Holder(s) and the Author(s) or with their explicit written + permission. + +1. The Font Software, modified or unmodified, in part or in whole, + must be distributed entirely under this license, and must not be + distributed under any other license. The requirement for fonts to + remain under this license does not apply to any document created + using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/app/fonts/IoskeleyMono-Regular.woff2 b/app/fonts/IoskeleyMono-Regular.woff2 new file mode 100644 index 0000000..5f8cefb Binary files /dev/null and b/app/fonts/IoskeleyMono-Regular.woff2 differ diff --git a/app/fonts/IoskeleyMono-SemiBold.woff2 b/app/fonts/IoskeleyMono-SemiBold.woff2 new file mode 100644 index 0000000..445da28 Binary files /dev/null and b/app/fonts/IoskeleyMono-SemiBold.woff2 differ diff --git a/app/globals.css b/app/globals.css index c2a109a..f4d4ca4 100644 --- a/app/globals.css +++ b/app/globals.css @@ -12,7 +12,7 @@ /* ─── Seeds ─── */ --bg: #ffffff; - --fg: #1a1a1a; + --fg: #18181b; --acc: #0169cc; --con: 0.3; @@ -32,11 +32,11 @@ 0.05 subtle near-bg, slight elevation 0.01 hairline barely visible separation -0.01 soft input-strength fill - -0.05 moderate clearly sunken below bg */ + -0.1 frame app frame and trays, sunk below bg */ --primary-lightness: calc(0.05 * var(--con)); --tertiary-lightness: calc(0.01 * var(--con)); --quaternary-lightness: calc(-0.01 * var(--con)); - --base-lightness: calc(-0.05 * var(--con)); + --base-lightness: calc(-0.1 * var(--con)); /* Surface families + state variants — defined once; in .dark the deltas flip sign and the surfaces redefine, so these formulas pick up both. @@ -93,19 +93,68 @@ keep fg's hue. Sign positive in light (fg dark, lifts toward white bg); negative in dark. */ --text-primary: var(--fg); - --text-secondary: oklch(from var(--fg) calc(l + 0.3) c h); - --text-tertiary: oklch(from var(--fg) calc(l + 0.45) c h); + --text-body: oklch(from var(--fg) calc(l + 0.16) c h); + --text-secondary: oklch(from var(--fg) calc(l + 0.34) c h); + --text-tertiary: oklch(from var(--fg) calc(l + 0.5) c h); + + /* Elevation. Raised controls are lit from above: a face a touch lighter + than the surface, a top highlight and a crisp ring. The .dark block + swaps in lighter rings and an inner edge. */ + --raised-bg: var(--primary-bg); + --raised-bg-hover: oklch(from var(--raised-bg) calc(l + var(--raised-hover-shift)) c h); + --raised-selected-bg: var(--raised-bg); + --raised-spread: 0.004; + --raised-hover-shift: -0.012; + --elevation-raised: + inset 0 1px 0 rgb(255 255 255), 0 0 0 1px rgb(0 0 0 / 0.075), 0 1px 2px rgb(0 0 0 / 0.07), 0 2px + 6px -2px rgb(0 0 0 / 0.05); + --elevation-card: + 0 0 0 1px rgb(0 0 0 / 0.075), 0 1px 2px rgb(0 0 0 / 0.06), 0 4px 8px -2px rgb(0 0 0 / 0.05); + --elevation-composer: + 0 0 0 1px rgb(0 0 0 / 0.08), 0 1px 2px -1px rgb(0 0 0 / 0.08), 0 6px 16px -6px rgb(0 0 0 / 0.1); + --elevation-overlay: + 0 0 0 1px rgb(0 0 0 / 0.075), 0 1px 2px rgb(0 0 0 / 0.06), 0 12px 32px -8px rgb(0 0 0 / 0.16), + 0 32px 64px -16px rgb(0 0 0 / 0.14); + + /* Accent controls (send, logo tile) share one cobalt bevel. */ + --elevation-logo: + inset 0 1px 0 rgb(255 255 255 / 0.28), inset 0 -2px 3px oklch(30% 0.12 258 / 0.35), 0 0 0 1px + oklch(from var(--acc) calc(l - 0.064) c h), 0 1px 2px oklch(from var(--acc) l c h / 0.35); + --elevation-logo-lg: + inset 0 1px 0 rgb(255 255 255 / 0.28), inset 0 -3px 5px oklch(30% 0.12 258 / 0.35), 0 0 0 1px + oklch(from var(--acc) calc(l - 0.064) c h), 0 1px 2px oklch(from var(--acc) l c h / 0.35), 0 + 4px 10px -2px oklch(from var(--acc) l c h / 0.3); + --elevation-accent: + inset 0 1px 0 rgb(255 255 255 / 0.28), 0 0 0 1px oklch(from var(--acc) calc(l - 0.064) c h), 0 + 1px 2px oklch(from var(--acc) l c h / 0.35); + + /* The composer: white in light; a step above the tray in dark. */ + --composer-bg: var(--primary-bg); + + /* Reference tables are two-tone like code blocks: a tray (header, expanded + rows) around a lighter panel of rows. */ + --table-tray: var(--base-bg); + --table-panel: var(--primary-bg); + + /* Code blocks are dark in both modes: fixed zinc, not seed-derived. */ + /* Code stays dark in light mode: the frame is the ink seed, the panel a step + lighter (scaled by contrast like the tables), so a theme's hue tints both. */ + --code-bg: var(--fg); + --code-panel: oklch(from var(--fg) calc(l + 0.13 * var(--con)) c h); + --elevation-code: + inset 0 0 0 1px rgb(255 255 255 / 0.1), inset 0 1px 0 rgb(255 255 255 / 0.06), 0 0 0 1px + var(--code-bg), 0 1px 2px rgb(0 0 0 / 0.12), 0 4px 10px -2px rgb(0 0 0 / 0.12); --radius: 8px; - --sidebar-width: 224px; + --sidebar-width: 256px; --thread-width: 672px; } .dark { - --bg: #111111; - --fg: #fcfcfc; - --acc: #4a9eed; + --bg: #18181b; + --fg: #f4f4f5; + --acc: #4c9bea; /* State deltas flip positive in dark — surfaces sit far from white, so states lift toward white instead of sinking. */ @@ -118,7 +167,7 @@ --primary-lightness: calc(0.1 * var(--con)); --tertiary-lightness: calc(0.2 * var(--con)); --quaternary-lightness: calc(0.3 * var(--con)); - --base-lightness: calc(-0.03 * var(--con)); + --base-lightness: calc(-0.075 * var(--con)); /* Absolute L floors. The natural lift compresses below 1 sRGB unit at bg=#000; floors guarantee tiers stay distinct. At @@ -139,15 +188,46 @@ ); --base-bg: oklch(from var(--bg) max(0, calc(l + var(--base-lightness))) c h); - --text-secondary: oklch(from var(--fg) calc(l - 0.3) c h); - --text-tertiary: oklch(from var(--fg) calc(l - 0.45) c h); + --text-body: oklch(from var(--fg) calc(l - 0.1) c h); + --text-secondary: oklch(from var(--fg) calc(l - 0.26) c h); + --text-tertiary: oklch(from var(--fg) calc(l - 0.42) c h); + + /* Dark rings stay light: a near-black ring reads as a cut-out. */ + --raised-bg: oklch(from var(--bg) calc(l + 0.29 * var(--con)) c h); + /* Selected rows sit a step below raised buttons so they don't shout. */ + --raised-selected-bg: oklch(from var(--bg) calc(l + 0.21 * var(--con)) c h); + --raised-spread: 0.014; + --raised-hover-shift: 0.02; + --elevation-raised: + inset 0 1px 0 rgb(255 255 255 / 0.1), inset 0 0 0 1px rgb(255 255 255 / 0.05), 0 0 0 1px + rgb(0 0 0 / 0.16), 0 1px 2px rgb(0 0 0 / 0.1); + --elevation-card: + inset 0 1px 0 rgb(255 255 255 / 0.05), inset 0 0 0 1px rgb(255 255 255 / 0.06), 0 0 0 1px + rgb(0 0 0 / 0.16), 0 1px 2px rgb(0 0 0 / 0.1); + --elevation-composer: + inset 0 1px 0 rgb(255 255 255 / 0.06), inset 0 0 0 1px rgb(255 255 255 / 0.07), + 0 0 0 1px rgb(0 0 0 / 0.2), 0 1px 2px rgb(0 0 0 / 0.12), 0 6px 16px -6px rgb(0 0 0 / 0.22); + --elevation-overlay: + inset 0 1px 0 rgb(255 255 255 / 0.06), inset 0 0 0 1px rgb(255 255 255 / 0.07), + 0 0 0 1px rgb(0 0 0 / 0.16), 0 12px 32px -8px rgb(0 0 0 / 0.4), 0 32px 64px -16px + rgb(0 0 0 / 0.36); + + --composer-bg: oklch(from var(--bg) calc(l + 0.13 * var(--con)) c h); + --table-tray: var(--primary-bg); + --table-panel: oklch(from var(--bg) calc(l + 0.2 * var(--con)) c h); + + --code-bg: oklch(from var(--bg) calc(l + 0.1 * var(--con)) c h); + --code-panel: oklch(from var(--bg) calc(l + 0.2 * var(--con)) c h); + --elevation-code: + inset 0 0 0 1px rgb(255 255 255 / 0.06), inset 0 1px 0 rgb(255 255 255 / 0.06), 0 0 0 1px + rgb(0 0 0 / 0.16), 0 1px 2px rgb(0 0 0 / 0.1); --shadow-border-opacity: 0.06; } @theme inline { --font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif; - --font-mono: var(--font-ibm-plex-mono), ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + --font-mono: var(--font-ioskeley-mono), ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --font-weight-normal: 450; @@ -158,6 +238,14 @@ /* 0px 0px 0px 1px oklch(from var(--primary-border) l c h / 1), */ --shadow-border: 0px 1px 2px -1px rgb(0 0 0 / 0.06), 0px 2px 4px 0px rgb(0 0 0 / 0.04); + --shadow-raised: var(--elevation-raised); + --shadow-card: var(--elevation-card); + --shadow-composer: var(--elevation-composer); + --shadow-overlay: var(--elevation-overlay); + --shadow-accent: var(--elevation-accent); + --shadow-logo: var(--elevation-logo); + --shadow-logo-lg: var(--elevation-logo-lg); + --shadow-code: var(--elevation-code); /* Background */ --color-base-bg: var(--base-bg); @@ -207,7 +295,14 @@ --color-accent-border-active: var(--accent-border-active); /* Text */ + --color-composer-bg: var(--composer-bg); + --color-table-tray: var(--table-tray); + --color-table-panel: var(--table-panel); + --color-code-bg: var(--code-bg); + --color-code-panel: var(--code-panel); + --color-ink-primary: var(--text-primary); + --color-ink-body: var(--text-body); --color-ink-secondary: var(--text-secondary); --color-ink-tertiary: var(--text-tertiary); @@ -228,6 +323,47 @@ --text-xl: 16px; } +/* Raised face: pair with shadow-raised (or shadow-accent for bg-accent-raised). + The gradient reads --raised-face on the element itself, so the hover and + selected utilities only swap that variable. */ +@utility bg-raised { + --raised-face: var(--raised-bg); + background-color: var(--raised-face); + background-image: linear-gradient( + 180deg, + oklch(from var(--raised-face) calc(l + var(--raised-spread)) c h), + oklch(from var(--raised-face) calc(l - var(--raised-spread)) c h) + ); +} + +@utility bg-raised-hover { + --raised-face: var(--raised-bg-hover); +} + +@utility bg-raised-selected { + --raised-face: var(--raised-selected-bg); + background-color: var(--raised-face); + background-image: linear-gradient( + 180deg, + oklch(from var(--raised-face) calc(l + var(--raised-spread)) c h), + oklch(from var(--raised-face) calc(l - var(--raised-spread)) c h) + ); +} + +@utility bg-accent-raised { + --accent-face: var(--acc); + background-color: var(--accent-face); + background-image: linear-gradient( + 180deg, + oklch(from var(--accent-face) calc(l + 0.043) c h), + var(--accent-face) + ); +} + +@utility bg-accent-raised-hover { + --accent-face: oklch(from var(--acc) calc(l + 0.03) c h); +} + @keyframes shimmer { 0% { background-position: 200% 50%; @@ -261,6 +397,18 @@ @apply bg-base-bg font-sans font-normal text-ink-primary; font-synthesis: none; } + + /* Ioskeley's web build carries ligatures; code shows what was typed. */ + code, + kbd, + pre, + samp, + .font-mono { + font-variant-ligatures: none; + font-feature-settings: + "liga" 0, + "calt" 0; + } } /* Streamdown element styles */ @@ -278,25 +426,25 @@ } [data-streamdown="code-block"] { - @apply my-3 overflow-hidden rounded-lg border border-primary-border bg-primary-bg [corner-shape:squircle]; + @apply my-3 overflow-hidden rounded-xl bg-primary-bg shadow-card; } [data-streamdown="code-block-header"] { - @apply flex items-center justify-between border-b border-primary-border bg-primary-bg p-3 text-xs text-ink-primary; + @apply flex h-9 items-center justify-between border-ink-primary/6 border-b bg-base-bg px-3 font-mono text-ink-secondary text-xs; } [data-streamdown="code-block-body"] { - @apply overflow-x-auto p-3 bg-primary-bg text-sm leading-relaxed; + @apply overflow-x-auto bg-primary-bg p-3 font-mono text-[13px] leading-[22px]; } [data-streamdown="inline-code"] { - @apply rounded-sm bg-primary-bg px-1 py-0.5 text-sm text-ink-primary; + @apply rounded-[4px] bg-ink-primary/6 px-1 py-px font-mono text-[0.85em] text-ink-primary; } [data-streamdown="link"] { - @apply text-ink-primary underline underline-offset-2 decoration-primary-border hover:decoration-ink-primary; + @apply text-accent-bg underline decoration-accent-bg/30 underline-offset-4 hover:decoration-accent-bg; } [data-streamdown="blockquote"] { - @apply my-3 border-l-2 border-primary-border pl-3 text-ink-primary; + @apply my-3 border-ink-primary/10 border-l-2 pl-4 text-ink-body; } [data-streamdown="ordered-list"], @@ -313,48 +461,171 @@ @apply pl-1; } [data-streamdown="list-item"]::marker { - color: var(--ink-secondary); + color: var(--text-tertiary); } [data-streamdown="list-item"] > [data-streamdown="paragraph"] { @apply my-0; } [data-streamdown="horizontal-rule"] { - @apply my-4 border-primary-border; + @apply my-4 border-ink-primary/8; } [data-streamdown="strong"] { - @apply font-medium text-ink-primary; + @apply font-semibold text-ink-primary; } [data-streamdown="table-wrapper"] { - @apply my-3 overflow-x-auto; + @apply my-3 overflow-x-auto rounded-lg shadow-card; } [data-streamdown="table"] { @apply w-full text-sm; } [data-streamdown="table-header"] { - @apply border-b border-primary-border bg-primary-bg; + @apply border-ink-primary/8 border-b bg-base-bg; } [data-streamdown="table-header-cell"] { - @apply px-3 py-1.5 text-left font-medium text-ink-primary; + @apply px-3 py-2 text-left font-medium text-ink-secondary text-xs; } [data-streamdown="table-row"] { - @apply border-b border-primary-border; + @apply border-ink-primary/6 border-b last:border-0; } [data-streamdown="table-cell"] { - @apply px-3 py-1.5 text-ink-primary; + @apply px-3 py-2 text-ink-body; } [data-streamdown="image"] { @apply max-w-full rounded-md; } -/* ─── Shiki dual-theme code blocks ─── - CodeBlock highlights with { light: github-light, dark: github-dark } and - defaultColor:false, so shiki emits per-token CSS variables instead of a fixed - color. Activate light by default and swap to dark under the .dark class. Only - the text color switches — the
 keeps its own bg-secondary surface. */
+/* Scroll masks fade the edges a scroller can still scroll past. Scroll-driven
+   animations size each fade, so an axis that can't scroll (or a browser without
+   scroll timelines) gets none. */
+@property --scroll-mask-top {
+  syntax: "";
+  inherits: false;
+  initial-value: 0px;
+}
+@property --scroll-mask-bottom {
+  syntax: "";
+  inherits: false;
+  initial-value: 0px;
+}
+@property --scroll-mask-left {
+  syntax: "";
+  inherits: false;
+  initial-value: 0px;
+}
+@property --scroll-mask-right {
+  syntax: "";
+  inherits: false;
+  initial-value: 0px;
+}
+
+@keyframes scroll-mask-top {
+  to {
+    --scroll-mask-top: 32px;
+  }
+}
+@keyframes scroll-mask-bottom {
+  from {
+    --scroll-mask-bottom: 32px;
+  }
+}
+@keyframes scroll-mask-left {
+  to {
+    --scroll-mask-left: 32px;
+  }
+}
+@keyframes scroll-mask-right {
+  from {
+    --scroll-mask-right: 32px;
+  }
+}
+
+@utility scroll-mask-y {
+  mask-image: linear-gradient(
+    to bottom,
+    transparent,
+    #000 var(--scroll-mask-top),
+    #000 calc(100% - var(--scroll-mask-bottom)),
+    transparent
+  );
+  animation:
+    scroll-mask-top linear both,
+    scroll-mask-bottom linear both;
+  animation-timeline: scroll(self y), scroll(self y);
+  animation-range:
+    0 32px,
+    calc(100% - 32px) 100%;
+}
+
+@utility scroll-mask {
+  mask-image:
+    linear-gradient(
+      to bottom,
+      transparent,
+      #000 var(--scroll-mask-top),
+      #000 calc(100% - var(--scroll-mask-bottom)),
+      transparent
+    ),
+    linear-gradient(
+      to right,
+      transparent,
+      #000 var(--scroll-mask-left),
+      #000 calc(100% - var(--scroll-mask-right)),
+      transparent
+    );
+  mask-composite: intersect;
+  animation:
+    scroll-mask-top linear both,
+    scroll-mask-bottom linear both,
+    scroll-mask-left linear both,
+    scroll-mask-right linear both;
+  animation-timeline: scroll(self y), scroll(self y), scroll(self x), scroll(self x);
+  animation-range:
+    0 32px,
+    calc(100% - 32px) 100%,
+    0 32px,
+    calc(100% - 32px) 100%;
+}
+
+/* Switching between the docs and the playground cross-dissolves the viewport (a
+   ViewTransition in SiteShell). The root swaps live rather than fading a frozen
+   snapshot, so the sidebar's own slide plays through. */
+/* biome-ignore lint/correctness/noUnknownTypeSelector: `root` is a view-transition name, not an element. */
+::view-transition-old(root) {
+  display: none;
+}
+/* biome-ignore lint/correctness/noUnknownTypeSelector: `root` is a view-transition name, not an element. */
+::view-transition-new(root) {
+  animation: none;
+}
+::view-transition-group(site-viewport) {
+  animation-duration: 220ms;
+  animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
+}
+
+/* Docs code blocks: a line-number gutter on multi-line blocks. */
+[data-code-block] pre[data-line-numbers] code {
+  counter-reset: line;
+}
+
+[data-code-block] pre[data-line-numbers] .line::before {
+  counter-increment: line;
+  content: counter(line);
+  display: inline-block;
+  width: 3rem;
+  padding-right: 1.25rem;
+  text-align: right;
+  color: #71717a;
+  user-select: none;
+}
+
+/* ─── Shiki dual-theme fallback ───
+   Highlighting that runs with { light, dark } themes and defaultColor:false
+   emits per-token CSS variables; activate light by default and swap under
+   .dark. Docs code blocks use one dark theme with inline colors instead. */
 .shiki,
 .shiki span {
   color: var(--shiki-light);
diff --git a/app/layout.tsx b/app/layout.tsx
index 17e2d0a..830ed16 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -1,6 +1,10 @@
 import type { Metadata } from "next";
-import { IBM_Plex_Mono, Inter } from "next/font/google";
+import { Inter } from "next/font/google";
+import localFont from "next/font/local";
 import { Providers } from "@/components/providers";
+import { SiteShell } from "@/components/site-shell";
+import { source } from "@/lib/docs/source";
+import chatPackage from "@/packages/chat/package.json";
 import "streamdown/styles.css";
 import "./globals.css";
 const inter = Inter({
@@ -10,12 +14,15 @@ const inter = Inter({
   display: "swap",
 });
 
-// IBM Plex Mono isn't a variable font — enumerate the weights actually used (regular,
-// medium, semibold) so next/font subsets only those.
-const ibmPlexMono = IBM_Plex_Mono({
-  variable: "--font-ibm-plex-mono",
-  weight: ["400", "500", "600"],
-  subsets: ["latin"],
+// Ioskeley Mono (OFL-1.1, github.com/ahatem/IoskeleyMono). Only the ligature
+// build ships web fonts, so globals.css turns ligatures off for mono text.
+const ioskeleyMono = localFont({
+  variable: "--font-ioskeley-mono",
+  src: [
+    { path: "./fonts/IoskeleyMono-Regular.woff2", weight: "400", style: "normal" },
+    { path: "./fonts/IoskeleyMono-Medium.woff2", weight: "500", style: "normal" },
+    { path: "./fonts/IoskeleyMono-SemiBold.woff2", weight: "600", style: "normal" },
+  ],
   display: "swap",
 });
 
@@ -36,10 +43,15 @@ export default function RootLayout({
   return (
     
       
-        {children}
+        
+          {/* Read here on the server, so only the version reaches the client. */}
+          
+            {children}
+          
+        
       
     
   );
diff --git a/bun.lock b/bun.lock
index 6fcf283..b1684a7 100644
--- a/bun.lock
+++ b/bun.lock
@@ -10,6 +10,8 @@
         "@ai-sdk/react": "^3.0.220",
         "@base-ui/react": "^1.6.0",
         "@intentface/chat": "workspace:*",
+        "@keyline-icons/react": "^1.4.0",
+        "@tabler/icons-react": "^3.46.0",
         "ai": "^6.0.218",
         "class-variance-authority": "^0.7.1",
         "clsx": "^2.1.1",
@@ -46,7 +48,7 @@
     },
     "packages/chat": {
       "name": "@intentface/chat",
-      "version": "0.2.0",
+      "version": "0.5.0",
       "dependencies": {
         "@floating-ui/dom": "^1.7.6",
         "nanoid": "^5.1.11",
@@ -284,6 +286,8 @@
 
     "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
 
+    "@keyline-icons/react": ["@keyline-icons/react@1.4.0", "", { "peerDependencies": { "react": ">=18" } }, "sha512-zpeRzPHRFQcSEC5qBM5LYE9hNePkM2wMGBAgD35vOSoXMghSLndeYrAQovjsFIPn0cDxg7EVJoFx3pga5QdmVg=="],
+
     "@manypkg/find-root": ["@manypkg/find-root@1.1.0", "", { "dependencies": { "@babel/runtime": "^7.5.5", "@types/node": "^12.7.1", "find-up": "^4.1.0", "fs-extra": "^8.1.0" } }, "sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA=="],
 
     "@manypkg/get-packages": ["@manypkg/get-packages@1.1.3", "", { "dependencies": { "@babel/runtime": "^7.5.5", "@changesets/types": "^4.0.1", "@manypkg/find-root": "^1.1.0", "fs-extra": "^8.1.0", "globby": "^11.0.0", "read-yaml-file": "^1.1.0" } }, "sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A=="],
@@ -342,6 +346,10 @@
 
     "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
 
+    "@tabler/icons": ["@tabler/icons@3.46.0", "", {}, "sha512-f2RYFl3fzPwj5WO82x6en0dmkjefxEfOm16D1ByM6cj/McNiwOkL4VaPUoP9VVIrXAD9WnTSVFr70px703b//A=="],
+
+    "@tabler/icons-react": ["@tabler/icons-react@3.46.0", "", { "dependencies": { "@tabler/icons": "3.46.0" }, "peerDependencies": { "react": ">= 16" } }, "sha512-CCm7xJWhDT2PH4ZIFkP6AgYKtVhq0gpYkjUN+GVh1AzmIQaa77OW0bQPBPQiTE0PsXMR9oSxFqA3qBglzPyrVQ=="],
+
     "@tailwindcss/node": ["@tailwindcss/node@4.3.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "5.21.6", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.2" } }, "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg=="],
 
     "@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.2", "@tailwindcss/oxide-darwin-arm64": "4.3.2", "@tailwindcss/oxide-darwin-x64": "4.3.2", "@tailwindcss/oxide-freebsd-x64": "4.3.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", "@tailwindcss/oxide-linux-x64-musl": "4.3.2", "@tailwindcss/oxide-wasm32-wasi": "4.3.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" } }, "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag=="],
diff --git a/components/ai/ask-user.tsx b/components/ai/ask-user.tsx
index 5ab4aec..2bfb3d1 100644
--- a/components/ai/ask-user.tsx
+++ b/components/ai/ask-user.tsx
@@ -6,12 +6,14 @@ import {
   useAskUserOption,
   useAskUserOptions,
 } from "@intentface/chat/ask-user";
-import { CheckIcon } from "lucide-react";
+import {
+  Check,
+  ChevronDown,
+  ChevronLeft,
+  ChevronRight,
+  CircleQuestion,
+} from "@keyline-icons/react";
 import { type ComponentProps, type RefObject, useState } from "react";
-import { ChevronDownIcon } from "@/components/icons/chevron-down";
-import { ChevronLeftMediumIcon } from "@/components/icons/chevron-left-medium";
-import { ChevronRightMediumIcon } from "@/components/icons/chevron-right-medium";
-import { CircleQuestionmarkIcon } from "@/components/icons/circle-questionmark";
 import { Collapsible } from "@/components/ui/collapsible";
 import type { AskUserQuestion } from "@/lib/ai/types";
 import { cn } from "@/lib/utils";
@@ -53,14 +55,14 @@ const AskUserNavigation = ({ className, ...props }: AskUserNavigationProps) => (
 );
 
 const navigationButtonClasses =
-  "flex size-6 cursor-pointer items-center justify-center rounded-md text-ink-secondary transition-colors hover:bg-tertiary-bg-hover hover:text-ink-primary disabled:pointer-events-none disabled:opacity-30";
+  "flex size-6 cursor-pointer items-center justify-center rounded-full text-ink-secondary transition-colors hover:bg-ink-primary/5 hover:text-ink-primary focus-visible:outline-2 focus-visible:outline-accent-bg/60 disabled:pointer-events-none disabled:opacity-30";
 
 /** Navigate to the previous step. */
 type AskUserPreviousProps = ComponentProps;
 
 const AskUserPrevious = ({ className, ...props }: AskUserPreviousProps) => (
   
-    
+    
   
 );
 
@@ -69,7 +71,7 @@ type AskUserNextProps = ComponentProps;
 
 const AskUserNext = ({ className, ...props }: AskUserNextProps) => (
   
-    
+    
   
 );
 
@@ -104,10 +106,10 @@ const AskUserOption = ({ className, ...props }: AskUserOptionProps) => (
    {
 };
 
 const INDICATOR_CLASS = cn(
-  "flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-tertiary-border bg-tertiary-bg text-2xs font-medium tabular-nums text-ink-secondary",
+  "flex size-4 shrink-0 items-center justify-center rounded-[5px] bg-raised font-medium text-[10px] text-ink-secondary tabular-nums shadow-raised",
 );
 
-const INDICATOR_SELECTED_CLASS =
-  "border-tertiary-border-active bg-tertiary-bg-active text-ink-primary";
+const INDICATOR_SELECTED_CLASS = "bg-accent-raised text-white shadow-accent";
 
 const AskUserOptionCheckIndicator = () => {
   const option = useAskUserOption();
@@ -138,7 +139,7 @@ const AskUserOptionCheckIndicator = () => {
       aria-hidden="true"
       className={cn(INDICATOR_CLASS, option.selected && INDICATOR_SELECTED_CLASS)}
     >
-      {option.selected && }
+      {option.selected && }
     
   );
 };
@@ -203,10 +204,10 @@ const AskUserSummary = ({ questions, answers, className, ...props }: AskUserSumm
       {...props}
     >
       
-        
+        
           Answered {count} {count === 1 ? "question" : "questions"}
         
-         (
           
- +
{q.question} diff --git a/components/ai/attachments.tsx b/components/ai/attachments.tsx index f019bf1..0baf92b 100644 --- a/components/ai/attachments.tsx +++ b/components/ai/attachments.tsx @@ -1,7 +1,7 @@ "use client"; import type { AttachmentErrorCode, AttachmentItem } from "@intentface/chat/attachments"; -import { PaperclipIcon } from "lucide-react"; +import { File, Paperclip, X } from "@keyline-icons/react"; import { AnimatePresence, motion } from "motion/react"; import Image from "next/image"; import type { ComponentProps, ReactNode } from "react"; @@ -10,9 +10,6 @@ import { createPortal } from "react-dom"; import { IconButton } from "@/components/ui/icon-button"; import { formatFileSize, isImageAttachment, isPdfAttachment } from "@/lib/ai/attachments"; import { cn } from "@/lib/utils"; -import { CrossMediumIcon } from "../icons/cross-medium"; -import { FileBendIcon } from "../icons/file-bend"; -import { PaperClipIcon } from "../icons/paperclip"; // Re-export the attachment surface so consumers import everything from this // module: the generic mechanics from the headless package, and this app's @@ -67,8 +64,8 @@ type AttachmentsItemProps = { }; const getFileIcon = (mediaType: string) => { - if (isPdfAttachment(mediaType)) return FileBendIcon; - return PaperclipIcon; + if (isPdfAttachment(mediaType)) return File; + return Paperclip; }; const AttachmentsItem = ({ item, children, className }: AttachmentsItemProps) => { @@ -90,7 +87,8 @@ const AttachmentsItem = ({ item, children, className }: AttachmentsItemProps) => isImageAttachment(mediaType) ? "image" : isPdfAttachment(mediaType) ? "pdf" : "file" } className={cn( - "group relative flex h-12 max-w-48 items-center gap-2 rounded-lg border border-secondary-border bg-secondary-bg px-2", + // Attachment chips are rounded squares, raised like the composer's buttons. + "group relative flex h-7 max-w-56 items-center gap-1.5 rounded-[7px] bg-raised pr-2 pl-1.5 shadow-raised", className, )} > @@ -99,21 +97,21 @@ const AttachmentsItem = ({ item, children, className }: AttachmentsItemProps) => width={32} height={32} alt={filename} - className="size-8 shrink-0 rounded-xs object-cover ring-1 ring-inset ring-slate-7/10" + className="size-[18px] shrink-0 rounded-[4px] object-cover ring-1 ring-ink-primary/10 ring-inset" src={item.url} /> ) : ( -
- -
+ )} -
- +
+ {filename.slice(0, -7)} {filename.slice(-7)} {item.fileSize != null && ( - {formatFileSize(item.fileSize)} + + {formatFileSize(item.fileSize)} + )}
{children} @@ -138,7 +136,7 @@ const AttachmentsRemove = ({ onRemove, filename, className }: AttachmentsRemoveP size="2xs" type="button" > - + ); @@ -151,9 +149,9 @@ type AttachmentsDropzoneProps = { const dropzoneVariants = { inline: - "absolute inset-0 m-1 flex items-center justify-center rounded-xl border border-dashed border-secondary-border bg-secondary-bg", + "absolute inset-0 m-1 flex items-center justify-center rounded-lg border border-accent-bg/50 border-dashed bg-composer-bg text-accent-bg", global: - "absolute inset-0 z-50 flex items-center justify-center rounded-[inherit] border-2 border-dashed border-secondary-border bg-secondary-bg/80 backdrop-blur-xs", + "absolute inset-0 z-50 flex items-center justify-center rounded-[inherit] border-2 border-accent-bg/50 border-dashed bg-secondary-bg/80 text-accent-bg backdrop-blur-xs", }; const GLOBAL_DROPZONE_SELECTOR = '[data-slot="sidebar-inset"]'; @@ -224,7 +222,7 @@ type AttachmentsTriggerProps = ComponentProps; const AttachmentsTrigger = ({ children, className, ...props }: AttachmentsTriggerProps) => { return ( - {children ?? } + {children ?? } ); }; diff --git a/components/ai/chip.tsx b/components/ai/chip.tsx index 86333e9..d2cf53e 100644 --- a/components/ai/chip.tsx +++ b/components/ai/chip.tsx @@ -12,7 +12,7 @@ import { cn } from "@/lib/utils"; // doesn't grow the line box; `box-decoration-clone` keeps padding/bg intact if a // chip wraps; `leading-[inherit]` so it never inflates the editor's line height. export const CHIP_SURFACE_CLASS = - "box-decoration-clone inline rounded-sm border border-primary-border bg-primary-bg px-0.75 py-0.5 align-baseline font-book leading-[inherit] whitespace-nowrap text-ink-primary"; + "box-decoration-clone inline rounded bg-ink-primary/6 px-1.5 py-0.5 align-baseline font-book leading-[inherit] whitespace-nowrap text-ink-primary"; const chipVariants = cva(CHIP_SURFACE_CLASS, { variants: { diff --git a/components/ai/composer.tsx b/components/ai/composer.tsx index c308342..4264a8a 100644 --- a/components/ai/composer.tsx +++ b/components/ai/composer.tsx @@ -1,4 +1,5 @@ "use client"; +import { ArrowUp, Stop } from "@keyline-icons/react"; // Styled composer — thin wrappers over @intentface/chat/composer. Behavior // (editor, store, machines, command plumbing) lives in the package; this file @@ -20,8 +21,6 @@ import { Children, type ComponentProps, type ReactNode, useMemo, useRef } from " import { AskUser } from "@/components/ai/ask-user"; import { Attachments } from "@/components/ai/attachments"; import { Chip } from "@/components/ai/chip"; -import { SendIcon } from "@/components/icons/send"; -import { StopIcon } from "@/components/icons/stop"; import Button from "@/components/ui/button"; import { IconButton } from "@/components/ui/icon-button"; import { Kbd } from "@/components/ui/kbd"; @@ -69,7 +68,16 @@ export type { export type ComposerRootProps = ComponentProps; const ComposerRoot = ({ className, ...props }: ComposerRootProps) => ( - + [data-composer-context-window][data-open]]:bg-ink-primary/[0.04] has-[>[data-composer-context-window][data-open]]:p-1 dark:has-[>[data-composer-context-window][data-open]]:bg-white/[0.02]", + className, + )} + {...props} + /> ); // --------------------------------------------------------------------------- @@ -81,10 +89,8 @@ type ComposerContainerProps = ComponentProps const ComposerContainer = ({ className, ...props }: ComposerContainerProps) => ( { const icon = chip.icon && isChipIconKey(chip.icon) ? CHIP_ICONS[chip.icon] : undefined; return ( - // The composer input uses the plain borderless chip surface; the - // bordered/filled CHIP_SURFACE_CLASS is reserved for message/docs chips. - + // One chip surface everywhere, so the badge commits without a jump. + {icon && {icon}} {chip.label} ); }} className={cn( - "max-h-32 min-h-8 overflow-y-auto py-2 px-3 text-md", + "max-h-32 min-h-12 overflow-y-auto px-2.5 pt-2.5 pb-1 text-md", "mask-[linear-gradient(to_bottom,transparent,black_16px,black_calc(100%-16px),transparent)]", // The editor element — engine-owned DOM, out of JSX reach. - "**:data-composer-editor:w-full **:data-composer-editor:max-w-none **:data-composer-editor:font-book **:data-composer-editor:leading-[1.7] [&_[data-composer-editor]:focus]:outline-none", - // Active-prefix badge: the composer's own borderless surface (matching the - // committed chip above, not the bordered CHIP_SURFACE_CLASS used in - // messages/docs) so the badge and the chip it becomes share one baseline — - // no jump on commit. - "**:data-command-badge:box-decoration-clone **:data-command-badge:inline **:data-command-badge:rounded-sm **:data-command-badge:px-0.75 **:data-command-badge:py-0.5 **:data-command-badge:align-baseline **:data-command-badge:font-book **:data-command-badge:leading-[inherit] **:data-command-badge:whitespace-nowrap **:data-command-badge:bg-primary-bg-hover **:data-command-badge:text-ink-primary", + "**:data-composer-editor:w-full **:data-composer-editor:max-w-none **:data-composer-editor:font-book **:data-composer-editor:leading-6 [&_[data-composer-editor]:focus]:outline-none", + // Active-prefix badge: the same surface as the committed chip above, so + // the badge and the chip it becomes share one baseline — no jump on commit. + "**:data-command-badge:box-decoration-clone **:data-command-badge:inline **:data-command-badge:rounded **:data-command-badge:px-1.5 **:data-command-badge:py-0.5 **:data-command-badge:align-baseline **:data-command-badge:font-book **:data-command-badge:leading-[inherit] **:data-command-badge:whitespace-nowrap **:data-command-badge:bg-ink-primary/6 **:data-command-badge:text-ink-primary", // The badge's hint element — ghost-text completion or the empty-query // placeholder (the package renders whichever applies into one slot). "**:data-command-hint:pointer-events-none **:data-command-hint:whitespace-nowrap **:data-command-hint:text-ink-tertiary", @@ -244,9 +247,7 @@ const ComposerPlaceholder = ({ placeholder, children, className }: ComposerPlace const { currentItem, key } = useLoop(loopItems); if (!isLooping && items.length === 1) { - return ( -
{items[0]}
- ); + return
{items[0]}
; } if (!isLooping && items.length === 0) return null; @@ -260,7 +261,7 @@ const ComposerPlaceholder = ({ placeholder, children, className }: ComposerPlace animate={{ opacity: 1, y: 0, filter: "blur(0px)" }} exit={{ opacity: 0, y: "-100%", filter: "blur(4px)" }} transition={{ duration: 0.3, ease: "easeOut" }} - className={cn("text-ink-tertiary font-book leading-[1.7]", className)} + className={cn("text-ink-tertiary font-book leading-6", className)} > {typeof items[0] === "string" ? currentItem : items[key % items.length]} @@ -278,11 +279,9 @@ type ComposerContextWindowProps = ComponentProps ( ) => ( - + ); type ComposerSubmitProps = ComponentProps & { @@ -319,6 +321,7 @@ const ComposerSubmit = ({ - {isGenerating ? : (children ?? )} + {isGenerating ? : (children ?? )} @@ -366,7 +369,7 @@ const ComposerPanel = ({ className, ...props }: ComposerPanelProps) => ( // coordinates); portaled to the body, so it never reserves layout in the composer. // Match the composer Container's width via the positioner's --anchor-width var. "absolute z-50 w-(--anchor-width) overflow-hidden", - "rounded-4xl border border-primary-border bg-primary-bg [corner-shape:squircle]", + "rounded-xl bg-primary-bg shadow-composer", "transition-[opacity,transform] duration-150 ease-out", "data-starting-style:opacity-0 data-ending-style:opacity-0", // Slide from the anchored edge: default (above) drops in from below; flipped @@ -396,7 +399,7 @@ const ComposerPopover = ({ className, ...props }: ComposerPopoverProps) => { // off the anchor token with a stronger shadow. `absolute` is the base positioning // context (the positioner then writes left/top in page coordinates); z-50 keeps // the portaled popover above the thread. - "absolute z-50 w-72 overflow-hidden border border-primary-border bg-primary-bg rounded-4xl shadow-lg [corner-shape:squircle]", + "absolute z-50 w-72 overflow-hidden rounded-xl bg-primary-bg shadow-composer", "transition-[opacity,transform,filter] duration-150 ease-out", "data-closed:opacity-0 data-closed:blur-[3px]", // Slide from the anchored edge — opens upward by default, downward when flipped. @@ -471,7 +474,7 @@ const ComposerCommandEmpty = ({ "hidden group-data-empty/composer-command-list:flex", // Shown only when nothing matches, where it acts as the single highlighted // option whose selection dismisses — so it carries the highlight styling. - "items-center gap-2 rounded-lg bg-primary-bg-hover px-3 h-8 text-sm text-ink-primary", + "items-center gap-2 rounded-lg bg-ink-primary/5 px-3 h-8 text-sm text-ink-primary", className, )} {...props} @@ -498,9 +501,9 @@ type ComposerCommandItemProps = ComponentProps ( ) => ( ); diff --git a/components/ai/message.tsx b/components/ai/message.tsx index aa526d0..ee06b3a 100644 --- a/components/ai/message.tsx +++ b/components/ai/message.tsx @@ -7,7 +7,7 @@ import { useMessageSelection, useMessageSelectionScope, } from "@intentface/chat/message"; -import { PaperclipIcon } from "lucide-react"; +import { Check, Copy, File, MessageSquare, Paperclip, Stop } from "@keyline-icons/react"; import { motion } from "motion/react"; import Image from "next/image"; import { type ComponentProps, useMemo } from "react"; @@ -21,11 +21,6 @@ import { useCopy } from "@/hooks/use-copy"; import { isImageAttachment, isPdfAttachment } from "@/lib/ai/attachments"; import { CHIP_ICONS, isChipIconKey } from "@/lib/ai/chip-icons"; import { cn } from "@/lib/utils"; -import { Bubble5Icon } from "../icons/bubble-5"; -import { CheckMarkMediumIcon } from "../icons/check-mark-medium"; -import { CopyIcon } from "../icons/copy"; -import { FileBendIcon } from "../icons/file-bend"; -import { StopIcon } from "../icons/stop"; type MessageRootProps = ComponentProps; @@ -82,16 +77,15 @@ const MessageContent = ({ className, ...props }: ComponentProps<"div">) => (
( } > {children} @@ -188,7 +182,7 @@ const MessageText = ({ children, className }: MessageTextProps) => ( const MessageError = ({ children, className, ...props }: ComponentProps<"div">) => (
) => ( className={cn("flex w-full justify-center", className)} {...props} > - - + + Stopped
@@ -230,7 +224,7 @@ const MessageStopped = ({ className, ...props }: ComponentProps<"div">) => ( const MessageLoading = ({ className, ...props }: ComponentProps<"div">) => (
Loading... @@ -256,7 +250,7 @@ const MessageCopy = ({ className={className} {...props} > - {isCopied ? : } + {isCopied ? : } } /> @@ -283,12 +277,12 @@ type MessageAttachmentProps = { const MessageAttachment = ({ attachment, className, ...props }: MessageAttachmentProps) => { const mediaType = attachment.mediaType ?? ""; const filename = attachment.filename ?? "Attachment"; - const Icon = isPdfAttachment(mediaType) ? FileBendIcon : PaperclipIcon; + const Icon = isPdfAttachment(mediaType) ? File : Paperclip; return (
@@ -315,12 +309,12 @@ const MessageAttachment = ({ attachment, className, ...props }: MessageAttachmen ) : ( -
- +
+
)}
- + {filename.slice(0, -7)} {filename.slice(-7)} @@ -388,7 +382,7 @@ const MessageSelection = ({ onAdd, className }: MessageSelectionProps) => { initialFocus={false} finalFocus={false} className={cn( - "flex items-center gap-1 rounded-full border border-primary-border bg-primary-bg p-0.5 shadow-md outline-none", + "flex items-center gap-1 rounded-full bg-primary-bg p-0.5 shadow-overlay outline-none", "data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95", "data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", "duration-100", @@ -401,13 +395,13 @@ const MessageSelection = ({ onAdd, className }: MessageSelectionProps) => { type="button" variant="ghost" size="sm" - className="gap-1.5 rounded-full" + className="gap-1.5" onClick={() => { if (selection) onAdd(selection.text); window.getSelection()?.removeAllRanges(); }} > - + Add to chat @@ -437,18 +431,20 @@ const MessageSource = ({ target="_blank" rel="noopener noreferrer" className={cn( - "inline-flex items-center gap-1.5 rounded-md border border-primary-border bg-primary-bg px-2 py-1 text-xs text-ink-secondary transition-colors hover:bg-primary-bg-hover", + "inline-flex h-6 items-center gap-1.5 rounded-full bg-raised pr-2 pl-1 font-medium text-ink-body text-xs shadow-raised transition-colors hover:bg-raised-hover focus-visible:outline-2 focus-visible:outline-accent-bg/60 focus-visible:outline-offset-2", className, )} {...props} > - + + + {domain} ); diff --git a/components/ai/reasoning.tsx b/components/ai/reasoning.tsx index 11eba2b..4b0f896 100644 --- a/components/ai/reasoning.tsx +++ b/components/ai/reasoning.tsx @@ -1,13 +1,12 @@ "use client"; import { Reasoning as ReasoningPrimitive, useReasoning } from "@intentface/chat/reasoning"; +import { Brain, ChevronDown } from "@keyline-icons/react"; import { AnimatePresence, motion } from "motion/react"; import { type ComponentProps, memo, type ReactNode } from "react"; -import { ChevronDownIcon } from "@/components/icons/chevron-down"; import { Markdown } from "@/components/ui/markdown"; import { splitReasoningByHeaders } from "@/lib/ai/message-info"; import { cn } from "@/lib/utils"; -import { BrainIcon } from "../icons/brain"; import { TextShimmer } from "../ui/text-shimmer"; export { useReasoning }; @@ -68,15 +67,15 @@ const ReasoningTrigger = memo( return ( {children ?? ( <> -
- +
+ {component}
- )} diff --git a/components/ai/steps.tsx b/components/ai/steps.tsx index 4064f3b..923280e 100644 --- a/components/ai/steps.tsx +++ b/components/ai/steps.tsx @@ -59,7 +59,7 @@ const StepsPanel = ({ className, children, ...props }: StepsPanelProps) => ( // The rail: a nested item's panel indents behind a vertical line, so // depth reads visually without any depth counter. `[data-nested]` is set // by the primitive on items that sit inside another item. - "in-data-nested:ml-2 in-data-nested:border-l in-data-nested:border-slate-6 in-data-nested:pl-4", + "in-data-nested:ml-2 in-data-nested:border-ink-primary/10 in-data-nested:border-l in-data-nested:pl-4", className, )} {...props} @@ -82,7 +82,7 @@ const StepsIcon = ({ className, ...props }: StepsIconProps) => ( "flex size-4 shrink-0 items-center justify-center", "data-[status=complete]:text-ink-secondary", "data-[status=active]:text-ink-primary", - "data-[status=pending]:text-slate-9", + "data-[status=pending]:text-ink-tertiary", className, )} {...props} @@ -99,7 +99,7 @@ const StepsLabel = ({ className, ...props }: StepsLabelProps) => ( "text-sm text-left", "data-[status=complete]:text-ink-secondary", "data-[status=active]:font-medium data-[status=active]:text-ink-primary", - "data-[status=pending]:text-slate-9", + "data-[status=pending]:text-ink-tertiary", className, )} {...props} diff --git a/components/ai/thread.tsx b/components/ai/thread.tsx index a5915b8..cfc4a3f 100644 --- a/components/ai/thread.tsx +++ b/components/ai/thread.tsx @@ -7,13 +7,13 @@ import { useThread, useThreadVisibility, } from "@intentface/chat/thread"; +import { ArrowDown } from "@keyline-icons/react"; import { AnimatePresence, motion } from "motion/react"; import type { ComponentProps } from "react"; import { memo, useCallback } from "react"; import Button from "@/components/ui/button"; import { ProgressiveBlur } from "@/components/ui/progressive-blur"; import { cn } from "@/lib/utils"; -import { ArrowDownIcon } from "../icons/arrow-down"; export { useThread, useThreadVisibility }; export type { ThreadAutoScrollMode, ThreadVisibilityState }; @@ -73,7 +73,7 @@ ThreadOverlay.displayName = "ThreadOverlay"; export type ThreadViewportProps = ComponentProps<"div">; const ThreadViewport = ({ children, className, ...props }: ThreadViewportProps) => ( - +
transition={{ duration: 0.2, ease: "easeOut" }} {...props} > - diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx deleted file mode 100644 index 00fc546..0000000 --- a/components/app-sidebar.tsx +++ /dev/null @@ -1,138 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { usePathname, useRouter } from "next/navigation"; -import { IntentfaceLogo } from "@/components/icons/intentface-logo"; -import { PlusMediumIcon } from "@/components/icons/plus-medium"; -import { TrashIcon } from "@/components/icons/trash"; -import { Sidebar } from "@/components/ui/sidebar"; -import { deleteChatInstance } from "@/lib/chat-instance"; -import { useChatStore } from "@/lib/store/chat"; -import { BookIcon } from "./icons/book"; -import { DotGrid1x3VerticalTightIcon } from "./icons/dot-grid-1x3-vertical-tight"; -import { GitHubIcon } from "./icons/github"; -import { NpmIcon } from "./icons/npm"; -import DropdownMenu from "./ui/dropdown-menu"; - -export const AppSidebar = () => { - const pathname = usePathname(); - const router = useRouter(); - const chats = useChatStore((state) => state.chats); - const deleteChat = useChatStore((state) => state.deleteChat); - - const handleDelete = (chatId: string) => { - const isActive = pathname === `/chat/${chatId}`; - deleteChat(chatId); - deleteChatInstance(chatId); - if (isActive) { - router.push("/playground"); - } - }; - - return ( - - - - - - - - - - - - New Chat - - } - /> - - - - - Threads - - - {chats.map((chat) => ( - - - {chat.title} - - - - Delete - - } - /> - - handleDelete(chat.id)} - aria-label="Delete" - > - - Delete - Delete - - - - - } - /> - - ))} - - - - - - - - - - Docs - - } - /> - - - - - GitHub - - } - /> - - - - - npm - - } - /> - - - - - ); -}; diff --git a/components/chat-nav.tsx b/components/chat-nav.tsx new file mode 100644 index 0000000..a6710ea --- /dev/null +++ b/components/chat-nav.tsx @@ -0,0 +1,78 @@ +"use client"; +import { Bin, MoreVertical, SquarePen } from "@keyline-icons/react"; +import Link from "next/link"; +import { usePathname, useRouter } from "next/navigation"; +import { Sidebar } from "@/components/ui/sidebar"; +import { deleteChatInstance } from "@/lib/chat-instance"; +import { useChatStore } from "@/lib/store/chat"; +import DropdownMenu from "./ui/dropdown-menu"; + +/** The playground half of the site sidebar: new chat and the threads. */ +export const ChatNav = () => { + const pathname = usePathname(); + const router = useRouter(); + const chats = useChatStore((state) => state.chats); + const deleteChat = useChatStore((state) => state.deleteChat); + + const handleDelete = (chatId: string) => { + const isActive = pathname === `/chat/${chatId}`; + deleteChat(chatId); + deleteChatInstance(chatId); + if (isActive) { + router.push("/playground"); + } + }; + + return ( + <> + + + New chat + + + + Threads + + + {chats.map((chat) => ( + + + {chat.title} + + + + Delete + + } + /> + + handleDelete(chat.id)} + aria-label="Delete" + > + + Delete + Delete + + + + + } + /> + + ))} + + + + + + ); +}; diff --git a/components/chat-shell.tsx b/components/chat-shell.tsx deleted file mode 100644 index 31c490d..0000000 --- a/components/chat-shell.tsx +++ /dev/null @@ -1,31 +0,0 @@ -"use client"; - -import type { ReactNode } from "react"; -import { AppSidebar } from "@/components/app-sidebar"; -import { Sidebar } from "@/components/ui/sidebar"; - -/** - * The chat area's shell composition. - * - * Its own client component rather than the layout itself, because `Sidebar` is - * an `Object.assign` compound: a server component importing it gets a proxy of - * the module's named exports and reads `undefined` off `Sidebar.Provider`. - * The layout stays a server component so it can read the sidebar cookie before - * the first paint and hand it down as `defaultOpen`. - */ -export const ChatShell = ({ - defaultOpen, - width, - children, -}: { - defaultOpen: boolean; - width?: number; - children: ReactNode; -}) => ( - - - - {children} - - -); diff --git a/components/chat.tsx b/components/chat.tsx index 1aa1243..b255be4 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -9,8 +9,20 @@ import { type MessageSegment, } from "@intentface/chat/message-utils"; import { isToolPart, type ToolPart, type UnknownPart } from "@intentface/chat/types"; +import { + Brain, + Check, + ChevronDown, + Circle, + CircleQuestion, + Quote, + RefreshCw, + TriangleAlert, + X, +} from "@keyline-icons/react"; +// Keyline has no sandbox icon, so this one stays on Tabler. +import { IconSandbox } from "@tabler/icons-react"; import type { ChatStatus } from "ai"; -import { CircleIcon } from "lucide-react"; import { AnimatePresence, motion, stagger } from "motion/react"; import { useRouter } from "next/navigation"; import { @@ -24,7 +36,6 @@ import { useRef, useState, } from "react"; -import { CHIP_SURFACE_CLASS } from "@/components/ai/chip"; import { type CommandItemData, Composer, @@ -37,15 +48,6 @@ import { Steps } from "@/components/ai/steps"; import { Thread } from "@/components/ai/thread"; import { ActiveTools, ToolsMenu } from "@/components/composer-tools"; import { Header } from "@/components/header"; -import { BrainIcon } from "@/components/icons/brain"; -import { CheckMarkMediumIcon } from "@/components/icons/check-mark-medium"; -import { ChevronDownIcon } from "@/components/icons/chevron-down"; -import { CircleQuestionmarkIcon } from "@/components/icons/circle-questionmark"; -import { CrossMediumIcon } from "@/components/icons/cross-medium"; -import { ExclamationTriangleIcon } from "@/components/icons/exclamation-triangle"; -import { OpenQuote2Icon } from "@/components/icons/open-quote-2"; -import { PlaygroundIcon } from "@/components/icons/playground"; -import { RefreshIcon } from "@/components/icons/refresh"; import { ModelSelector } from "@/components/model-selector"; import { PlaygroundSettings } from "@/components/playground-settings"; import { Markdown } from "@/components/ui/markdown"; @@ -68,7 +70,7 @@ import { useModelStore } from "@/lib/store/model"; import { usePlaygroundStore } from "@/lib/store/playground"; import { useSettingsStore } from "@/lib/store/settings"; import { cn } from "@/lib/utils"; -import { IntentfaceLogo } from "./icons/intentface-logo"; +import { LogoTile } from "./icons/logo-tile"; import { TextShimmer } from "./ui/text-shimmer"; type ChatSelection = { @@ -128,10 +130,10 @@ export const useChatMessages = (): ChatMessagesValue => { type IconComponent = React.ComponentType<{ className?: string }>; const statusIcons: Record = { - complete: CheckMarkMediumIcon, - active: CircleIcon, - pending: CircleIcon, - error: ExclamationTriangleIcon, + complete: Check, + active: Circle, + pending: Circle, + error: TriangleAlert, }; // A timeline row: static when it has no detail, collapsible (icon morphs to a @@ -153,14 +155,14 @@ const TimelineStep = ({ const iconClasses = cn( status === "complete" && "text-ink-secondary", status === "active" && "text-ink-primary", - status === "pending" && "text-slate-9", + status === "pending" && "text-ink-tertiary", status === "error" && "text-red-500", ); const labelClasses = cn( "text-sm text-left", status === "active" && "text-ink-primary font-medium", status === "complete" && "text-ink-secondary", - status === "pending" && "text-slate-9", + status === "pending" && "text-ink-tertiary", status === "error" && "text-red-500", ); @@ -186,7 +188,7 @@ const TimelineStep = ({ - + {label} @@ -207,7 +209,7 @@ const TimelineToolCall = ({ part }: { part: ToolPart }) => { {sources.map((source, index) => ( {source.domain} @@ -222,7 +224,7 @@ const TimelineAskUser = ({ part }: { part: ToolPart }) => { const { label, status, questions, answers, isComplete } = getAskUserStepInfo(part); return ( - +
{questions.map((q) => (
@@ -358,7 +360,7 @@ const InterleavedSteps = ({ {header} - + {segments.map((seg, i) => { @@ -374,7 +376,7 @@ const InterleavedSteps = ({ key={`r-${i}-${j}`} label={section.header ?? "Thinking"} status={streaming && j === sections.length - 1 ? "active" : "complete"} - icon={BrainIcon} + icon={Brain} > {section.body && ( @@ -523,7 +525,7 @@ const ChatMessageItem = memo( onClick={() => regenerate({ messageId: message.id })} tooltip="Regenerate" > - + )} @@ -610,7 +612,7 @@ const MENTION_ITEMS: CommandItemData[] = [ { value: "quarterly-report", label: "Q4 Quarterly Report", - icon: "fileText", + icon: "fileChart", }, { value: "meeting-notes", @@ -620,7 +622,7 @@ const MENTION_ITEMS: CommandItemData[] = [ { value: "product-roadmap", label: "Product Roadmap", - icon: "spreadsheet", + icon: "map", }, { value: "brand-guidelines", @@ -630,7 +632,7 @@ const MENTION_ITEMS: CommandItemData[] = [ { value: "api-documentation", label: "API Documentation", - icon: "fileChart", + icon: "code", }, { value: "screenshot-dashboard", @@ -959,30 +961,24 @@ const ChatInputInner = memo(({ panelState, status }: ChatInputInnerProps) => { {showContextStrip && ( - - + + Playground )} {selections.length > 0 && ( -
- +
+ {selections.length} selection{selections.length === 1 ? "" : "s"}
)} @@ -1022,7 +1018,7 @@ const ChatInputInner = memo(({ panelState, status }: ChatInputInnerProps) => { ) : ( -
+
@@ -1058,13 +1054,16 @@ const ChatPlaceholder = () => { className="flex flex-col items-center justify-center gap-4" > - +
- + Intentface Chat - + Start a conversation
@@ -1087,27 +1086,30 @@ const ChatDefaultLayout = memo(() => { {/* Auto-scroll behavior is driven by the user's setting: "bottom" lands the newest turn at the bottom and follows, "jump" lands it at the top without following, "follow" lands at the top and follows, "off" disables it. */} - -
- {showOverlays && } - - {isEmpty ? ( - - - - ) : ( - - )} - - - {showScrollButton && } - - - {showOverlays && } - {/* Rendered last so the card triggers come after the composer in tab - order; absolute positioning puts them in the top-right regardless. */} + {/* A row, so the settings card pushes the thread over instead of covering it. */} +
+ +
+ {showOverlays && } + + {isEmpty ? ( + + + + ) : ( + + )} + + + {showScrollButton && } + + + {showOverlays && } + + {/* Rendered last so the settings come after the composer in tab order; + the gear is positioned in the top-right regardless. */} - +
); }); diff --git a/components/composer-tools.tsx b/components/composer-tools.tsx index 25ed054..01bfdba 100644 --- a/components/composer-tools.tsx +++ b/components/composer-tools.tsx @@ -1,11 +1,7 @@ "use client"; +import { Brain, Globe, Paperclip, Plus, X } from "@keyline-icons/react"; import { useComposer } from "@/components/ai/composer"; -import { BrainIcon } from "@/components/icons/brain"; -import { CrossMediumIcon } from "@/components/icons/cross-medium"; -import { GlobeIcon } from "@/components/icons/globe"; -import { PaperClipIcon } from "@/components/icons/paperclip"; -import { PlusMediumIcon } from "@/components/icons/plus-medium"; import Button from "@/components/ui/button"; import DropdownMenu from "@/components/ui/dropdown-menu"; import { IconButton } from "@/components/ui/icon-button"; @@ -24,14 +20,18 @@ export const ActiveTools = ({ tools, onToolsChange }: ToolToggleProps) => { )} @@ -39,14 +39,18 @@ export const ActiveTools = ({ tools, onToolsChange }: ToolToggleProps) => { )}
@@ -60,14 +64,14 @@ export const ToolsMenu = ({ tools, onToolsChange }: ToolToggleProps) => { - + + } /> attachments.openFileDialog()}> - + Attach files @@ -75,13 +79,13 @@ export const ToolsMenu = ({ tools, onToolsChange }: ToolToggleProps) => { checked={tools.webSearch ?? false} onCheckedChange={(checked) => onToolsChange({ ...tools, webSearch: checked })} > - Web Search + Web Search onToolsChange({ ...tools, thinking: checked })} > - Thinking + Thinking diff --git a/components/docs/attributes-table.tsx b/components/docs/attributes-table.tsx index d314dc3..4b588ba 100644 --- a/components/docs/attributes-table.tsx +++ b/components/docs/attributes-table.tsx @@ -1,4 +1,6 @@ import type { ReactNode } from "react"; +import { ReferenceTable } from "./reference-table"; +import { ScrollLine } from "./scroll-line"; export type AttributeRow = { attribute: string; @@ -11,52 +13,54 @@ type AttributesTableProps = { }; // Hand-authored data-attribute reference — the styling hooks a part emits. -// Fixed-width columns match PropsTable so the two stay visually aligned down the -// page. Not collapsible: the description is the essential content here (no -// "default" to defer), so it stays inline. The Values column is omitted when no -// row provides one (e.g. keyboard shortcut tables). +// Descriptions are short, so every row shows its own: no expanding. Values, +// when an attribute has them, follow its description on the same line. CSS +// variables are values to style with, not hooks to select, so `--` rows get a +// table of their own. export const AttributesTable = ({ rows }: AttributesTableProps) => { - const hasValues = rows.some((row) => row.values); + const attributes = rows.filter((row) => !row.attribute.startsWith("--")); + const variables = rows.filter((row) => row.attribute.startsWith("--")); return ( -
- - - - {hasValues ? : null} - - - - - - {hasValues ? ( - - ) : null} - - - - - {rows.map((row) => ( - - - {hasValues ? ( - - ) : null} - - + <> + {attributes.length > 0 && ( + + {attributes.map((row) => ( + ))} - -
AttributeValuesDescription
- {row.attribute} - - {row.values ? ( - - {row.values} - - ) : ( - — - )} - {row.description}
-
+ + )} + {variables.length > 0 && ( + + {variables.map((row) => ( + + ))} + + )} + ); }; + +const AttributeTableRow = ({ row }: { row: AttributeRow }) => ( + + + + {row.attribute} + + + + + {row.description} + {row.values && ( + + {row.values} + + )} + + + +); diff --git a/components/docs/code-block.tsx b/components/docs/code-block.tsx index f052256..ebeaea7 100644 --- a/components/docs/code-block.tsx +++ b/components/docs/code-block.tsx @@ -1,28 +1,30 @@ import { highlight } from "fumadocs-core/highlight"; import type { ReactNode } from "react"; +import { codeTheme } from "@/lib/docs/code-theme"; import { cn } from "@/lib/utils"; +import { CodeFrame, CodeTab } from "./code-frame"; type CodeBlockProps = { code: string; lang?: string; - /** Small label shown above the block (e.g. a target file path). */ + /** The tab label, usually a file path. Falls back to the language. */ title?: ReactNode; className?: string; }; -// Server-side shiki highlight. Emits dual-theme spans (--shiki / --shiki-dark); -// the `.dark` activation rule lives in globals.css under the shiki section. -export const CodeBlock = async ({ code, lang = "tsx", title, className }: CodeBlockProps) => { - const rendered = await highlight(code, { +/** Server-side shiki highlight into a bare
, for any code chrome to wrap. */
+export const highlightCode = (code: string, lang = "tsx") =>
+  highlight(code, {
     lang,
-    themes: { light: "github-light", dark: "github-dark" },
-    defaultColor: false,
+    theme: codeTheme,
     components: {
-      pre: ({ className: preClassName, ...props }) => (
+      pre: ({ className, ...props }) => (
         
@@ -30,14 +32,12 @@ export const CodeBlock = async ({ code, lang = "tsx", title, className }: CodeBl
     },
   });
 
-  return (
-    
- {title && ( -
- {title} -
- )} -
pre]:rounded-t-none")}>{rendered}
-
- ); -}; +export const CodeBlock = async ({ code, lang = "tsx", title, className }: CodeBlockProps) => ( + {title ?? lang}} + code={code} + className={className} + > + {await highlightCode(code, lang)} + +); diff --git a/components/docs/code-files.tsx b/components/docs/code-files.tsx new file mode 100644 index 0000000..e73c2f2 --- /dev/null +++ b/components/docs/code-files.tsx @@ -0,0 +1,33 @@ +"use client"; + +import type { ReactNode } from "react"; +import { useState } from "react"; +import { CodeFrame, CodeTab } from "./code-frame"; + +export type CodeFileEntry = { + name: string; + source: string; + /** Pre-highlighted source, rendered on the server. */ + code: ReactNode; +}; + +// Client shell for a multi-file block: one tab per file. The server highlights +// every file up front; this only picks which one shows and gets copied. +export const CodeFiles = ({ files, className }: { files: CodeFileEntry[]; className?: string }) => { + const [name, setName] = useState(files[0].name); + const current = files.find((file) => file.name === name) ?? files[0]; + + return ( + ( + setName(file.name)}> + {file.name} + + ))} + > + {current.code} + + ); +}; diff --git a/components/docs/code-frame.tsx b/components/docs/code-frame.tsx new file mode 100644 index 0000000..fbd12f2 --- /dev/null +++ b/components/docs/code-frame.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { Check, Copy } from "@keyline-icons/react"; +import type { ReactNode } from "react"; +import { useCopy } from "@/hooks/use-copy"; +import { cn } from "@/lib/utils"; + +/** + * The dark code chrome shared by every block in the docs: a header of file (or + * package-manager) tabs with a copy button, over an inset code panel. Dark in + * both themes, tinted by the theme's seeds (see --code-bg in globals.css). + */ +export const CodeFrame = ({ + tabs, + code, + className, + children, +}: { + tabs: ReactNode; + /** What the copy button copies. */ + code: string; + className?: string; + children: ReactNode; +}) => ( +
+
+
{tabs}
+ +
+
+ {children} +
+
+); + +/** A header tab. Without `onClick` it is a plain label (a single file's name). */ +export const CodeTab = ({ + active = true, + onClick, + children, +}: { + active?: boolean; + onClick?: () => void; + children: ReactNode; +}) => { + const className = cn( + "flex h-full shrink-0 items-center font-medium font-mono text-xs transition-colors", + active + ? "text-white/92 shadow-[inset_0_-1px_0_rgb(255_255_255/0.92)]" + : "text-white/50 hover:text-white/80", + ); + + return onClick ? ( + + ) : ( + {children} + ); +}; + +const CodeCopyButton = ({ code }: { code: string }) => { + const { copy, copied } = useCopy(); + const isCopied = copied === code; + + return ( + + ); +}; diff --git a/components/docs/component-preview-frame.tsx b/components/docs/component-preview-frame.tsx index 658b68e..9b4ebe9 100644 --- a/components/docs/component-preview-frame.tsx +++ b/components/docs/component-preview-frame.tsx @@ -1,6 +1,10 @@ "use client"; +import { RotateCcw } from "@keyline-icons/react"; +import { motion, useReducedMotion } from "motion/react"; import { type ReactNode, useState } from "react"; +import { IconButton } from "@/components/ui/icon-button"; +import { useMeasure } from "@/hooks/use-measure"; import { cn } from "@/lib/utils"; type ComponentPreviewFrameProps = { @@ -8,35 +12,109 @@ type ComponentPreviewFrameProps = { code: ReactNode; }; +type View = "preview" | "code"; + +const SWITCH_TRANSITION = { duration: 0.3, ease: [0.32, 0.72, 0, 1] } as const; +const INSTANT = { duration: 0 } as const; +const SHOWN = { opacity: 1, filter: "blur(0px)", transitionEnd: { filter: "none" } }; +const HIDDEN = { opacity: 0, filter: "blur(4px)" }; + // Client shell for the preview/code toggle. The server component highlights the -// source and renders the demo; this only owns the tab state. +// source and renders the demo; this owns the tab state and the replay remount. +// One card holds both views: they cross-dissolve inside it while the card eases +// between their heights, and the demo stays mounted, keeping its state. export const ComponentPreviewFrame = ({ preview, code }: ComponentPreviewFrameProps) => { - const [tab, setTab] = useState<"preview" | "code">("preview"); + const [tab, setTab] = useState("preview"); + const [run, setRun] = useState(0); + // Both views are measured, so a switch knows its target height up front. + const [previewRef, previewBounds] = useMeasure(); + const [codeRef, codeBounds] = useMeasure(); + // Only a switch eases; a demo growing on its own is followed instantly. + const [switching, setSwitching] = useState(false); + const reduceMotion = useReducedMotion(); + + const height = (tab === "preview" ? previewBounds : codeBounds).height; + const paneTransition = reduceMotion ? INSTANT : SWITCH_TRANSITION; + + const select = (next: View) => { + if (next === tab) return; + setSwitching(true); + setTab(next); + }; return ( -
-
+
+
{(["preview", "code"] as const).map((value) => ( ))}
- {tab === "preview" ? ( -
{preview}
- ) : ( -
{code}
- )} + {/* Clips only mid-switch, so a demo's own popups can spill out otherwise. */} + setSwitching(false)} + className={cn( + "relative rounded-xl bg-primary-bg shadow-card", + switching && "overflow-hidden", + )} + > + + {/* Replay remounts the demo, so animations and seeded state start over. */} + setRun((current) => current + 1)} + className="absolute top-2 right-2" + > + + +
+ {preview} +
+
+ + {code} + +
); }; + +// The active view sits in flow; the other lies over it, faded out. +const paneClass = (active: boolean) => + active ? "relative" : "pointer-events-none absolute inset-x-0 top-0"; diff --git a/components/docs/demo.tsx b/components/docs/demo.tsx index cba4c8a..54d9e7e 100644 --- a/components/docs/demo.tsx +++ b/components/docs/demo.tsx @@ -1,7 +1,7 @@ -import { readFile } from "node:fs/promises"; -import path from "node:path"; import type { ReactNode } from "react"; -import { CodeBlock } from "./code-block"; +import { languageOf, readDemoFiles } from "@/lib/docs/demo-files"; +import { highlightCode } from "./code-block"; +import { CodeFiles } from "./code-files"; import { ComponentPreviewFrame } from "./component-preview-frame"; type DemoProps = { @@ -10,15 +10,18 @@ type DemoProps = { file: string; }; -// Server component: renders a colocated demo and shows that same file's source, -// read from disk at build time — the file that runs is the file displayed. +// Server component: renders a colocated demo and shows its source, read from +// disk at build time — the files that run are the files displayed. A demo split +// across local imports shows one tab per file. export const Demo = async ({ component, file }: DemoProps) => { - const source = await readFile(path.join(process.cwd(), "content", "docs", file), "utf8"); - - return ( - } - /> + const files = await readDemoFiles(file); + const entries = await Promise.all( + files.map(async ({ name, file: filePath, source }) => ({ + name, + source: source.trimEnd(), + code: await highlightCode(source.trimEnd(), languageOf(filePath)), + })), ); + + return } />; }; diff --git a/components/docs/docs-nav.tsx b/components/docs/docs-nav.tsx new file mode 100644 index 0000000..f01f868 --- /dev/null +++ b/components/docs/docs-nav.tsx @@ -0,0 +1,102 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import type { ReactNode } from "react"; +import { Sidebar } from "@/components/ui/sidebar"; +import type { SearchSuggestion } from "./docs-search"; + +// Structural mirror of fumadocs' page-tree nodes (typeof source.pageTree). We +// only render the node shapes our docs use: pages, separators and one level of folder. +type TreeItem = { type: "page"; name: ReactNode; url: string }; +type TreeFolder = { type: "folder"; name: ReactNode; children: TreeNode[] }; +type TreeSeparator = { type: "separator"; name?: ReactNode }; +type TreeNode = TreeItem | TreeFolder | TreeSeparator; + +export type NavGroup = { label?: ReactNode; items: TreeItem[] }; + +export type DocsTree = { children: TreeNode[] }; + +// Pages to flag as new in the sidebar. Drop a URL once its primitive has been +// out for a release or two. +const NEW_PAGES = new Set(["/primitives/shell", "/primitives/nav", "/primitives/tabs"]); + +// A separator opens a labelled group for the root pages after it; a folder is a +// group of its own. +export const toGroups = (nodes: TreeNode[]): NavGroup[] => { + const groups: NavGroup[] = []; + let open: NavGroup | null = null; + + for (const node of nodes) { + switch (node.type) { + case "separator": + open = { label: node.name, items: [] }; + groups.push(open); + break; + case "folder": + groups.push({ + label: node.name, + items: node.children.filter((child): child is TreeItem => child.type === "page"), + }); + open = null; + break; + case "page": + if (!open) { + open = { items: [] }; + groups.push(open); + } + open.items.push(node); + break; + } + } + + return groups; +}; + +// The search palette opens on these before anything is typed. +export const toSuggestions = (groups: NavGroup[]): SearchSuggestion[] => + groups + .flatMap((group) => group.items) + .filter((item) => item.url.startsWith("/primitives/")) + .map((item) => ({ + url: item.url, + title: typeof item.name === "string" ? item.name : item.url, + })); + +const NavLink = ({ item }: { item: TreeItem }) => { + const pathname = usePathname(); + + return ( + + + {item.name} + {NEW_PAGES.has(item.url) && ( + + New + + )} + + } + /> + + ); +}; + +/** The docs half of the site sidebar: the page tree. */ +export const DocsNav = ({ groups }: { groups: NavGroup[] }) => ( + + {groups.map((group, index) => ( + + {group.label && {group.label}} + + {group.items.map((item) => ( + + ))} + + + ))} + +); diff --git a/components/docs/docs-search.tsx b/components/docs/docs-search.tsx index 03bb80d..a8719b9 100644 --- a/components/docs/docs-search.tsx +++ b/components/docs/docs-search.tsx @@ -1,20 +1,155 @@ "use client"; +import { + AlignLeft, + ArrowDown, + ArrowUp, + CornerDownLeft, + FileText, + Hash, + Search, +} from "@keyline-icons/react"; import { useDocsSearch } from "fumadocs-core/search/client"; -import { SearchIcon } from "lucide-react"; import Link from "next/link"; -import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { type KeyboardEvent, type ReactNode, useEffect, useId, useState } from "react"; import { Dialog } from "@/components/ui/dialog"; import { IconButton } from "@/components/ui/icon-button"; +import { Kbd } from "@/components/ui/kbd"; import { cn } from "@/lib/utils"; -export const DocsSearch = () => { +type SearchResult = { + id: string; + url: string; + type: "page" | "heading" | "text"; + content: string; + breadcrumbs?: string[]; +}; + +type ResultSection = { label: string; items: SearchResult[] }; + +/* + * ⌘K palette over the static Orama index (/api/search). Results arrive flat — + * a page followed by its matching headings and text — so they are grouped by + * the page's section, and arrow keys walk the flat order across groups. + */ + +// A page opens a section named after its breadcrumb; headings and text follow +// the page they belong to. +const toSections = (results: SearchResult[]): ResultSection[] => { + const sections: ResultSection[] = []; + + for (const result of results) { + const last = sections.at(-1); + const label = result.type === "page" ? (result.breadcrumbs?.at(-1) ?? "Docs") : last?.label; + + if (last && last.label === label) last.items.push(result); + else sections.push({ label: label ?? "Docs", items: [result] }); + } + + return sections; +}; + +// Orama marks matches with ; content keeps markdown backticks. Render +// both — never the raw tags. +const Highlighted = ({ content }: { content: string }) => { + const marked = (text: string, keyPrefix: string) => + text.split(/(.*?<\/mark>)/g).map((part, index) => + part.startsWith("") ? ( + + {part.slice(6, -7)} + + ) : ( + part + ), + ); + + return content.split(/(`[^`]+`)/g).map((part, index) => + part.startsWith("`") && part.endsWith("`") && part.length > 1 ? ( + + {marked(part.slice(1, -1), String(index))} + + ) : ( + marked(part, String(index)) + ), + ); +}; + +const ResultRow = ({ + result, + section, + isActive, + optionId, + onActivate, + onSelect, +}: { + result: SearchResult; + section: string; + isActive: boolean; + optionId: string; + onActivate: () => void; + onSelect: () => void; +}) => { + const isPage = result.type === "page"; + + return ( + element?.scrollIntoView({ block: "nearest" }) : undefined} + className={cn( + "flex items-center gap-2.5 rounded-[10px] pr-2 outline-none", + isPage ? "h-11 pl-2" : "h-9 pl-[46px]", + isActive && "bg-ink-primary/5", + )} + > + {isPage ? ( + + + + ) : result.type === "heading" ? ( + + ) : ( + + )} + + + + {isPage && {section}} + {isActive && ( + + + + )} + + ); +}; + +export type SearchSuggestion = { url: string; title: string }; + +export const DocsSearch = ({ suggestions = [] }: { suggestions?: SearchSuggestion[] }) => { const [open, setOpen] = useState(false); // The "fetch" preset queries the /api/search route handler (createFromSource). const { search, setSearch, query } = useDocsSearch({ type: "fetch" }); + const router = useRouter(); + const listId = useId(); + + // The highlight belongs to the query it was made for; a new query starts at the top. + const [active, setActive] = useState({ search: "", index: 0 }); useEffect(() => { - const onKey = (event: KeyboardEvent) => { + const onKey = (event: globalThis.KeyboardEvent) => { if (event.key === "k" && (event.metaKey || event.ctrlKey)) { event.preventDefault(); setOpen((value) => !value); @@ -24,61 +159,161 @@ export const DocsSearch = () => { return () => window.removeEventListener("keydown", onKey); }, []); - const results = Array.isArray(query.data) ? query.data : []; + // An empty query is primed with the primitives, so the palette never opens blank. + const results = + search.length === 0 + ? suggestions.map( + (suggestion): SearchResult => ({ + id: suggestion.url, + url: suggestion.url, + type: "page", + content: suggestion.title, + breadcrumbs: ["Primitives"], + }), + ) + : Array.isArray(query.data) + ? (query.data as SearchResult[]) + : []; + const sections = toSections(results); + const flat = sections.flatMap((section) => + section.items.map((result) => ({ result, section: section.label })), + ); + const activeIndex = active.search === search ? Math.min(active.index, flat.length - 1) : 0; + const optionId = (index: number) => `${listId}-option-${index}`; + + const close = () => setOpen(false); + + const handleKeyDown = (event: KeyboardEvent) => { + if (flat.length === 0) return; + switch (event.key) { + case "ArrowDown": + event.preventDefault(); + setActive({ search, index: (activeIndex + 1) % flat.length }); + break; + case "ArrowUp": + event.preventDefault(); + setActive({ search, index: (activeIndex - 1 + flat.length) % flat.length }); + break; + case "Enter": + event.preventDefault(); + router.push(flat[activeIndex].result.url); + close(); + break; + } + }; + + let body: ReactNode; + switch (true) { + case search.length === 0 && flat.length === 0: + body = Search pages, headings and text.; + break; + case flat.length === 0 && query.isLoading: + body = Searching…; + break; + case flat.length === 0: + body = No results for “{search}”.; + break; + default: { + let index = -1; + body = sections.map((section, sectionIndex) => ( +
+
+ {section.label} +
+ {section.items.map((result) => { + index += 1; + const rowIndex = index; + return ( + setActive({ search, index: rowIndex })} + onSelect={close} + /> + ); + })} +
+ )); + } + } return ( - + } /> - + Search documentation -
- +
+ setSearch(event.target.value)} - placeholder="Search documentation..." - className="w-full bg-transparent py-3 text-ink-primary text-sm outline-none placeholder:text-ink-tertiary" + onKeyDown={handleKeyDown} + placeholder="Search documentation…" + role="combobox" + aria-expanded={flat.length > 0} + aria-controls={listId} + aria-activedescendant={flat.length > 0 ? optionId(activeIndex) : undefined} + className="h-full flex-1 bg-transparent text-ink-primary text-xl outline-none placeholder:text-ink-tertiary" /> + Esc +
+
+ {body}
-
- {results.length === 0 ? ( -

- {search ? "No results found." : "Type to search."} -

- ) : ( -
    - {results.map((result) => ( -
  • - setOpen(false)} - className={cn( - "block rounded-md px-3 py-2 text-sm transition-colors hover:bg-secondary-bg-hover", - result.type === "page" - ? "font-medium text-ink-primary" - : "pl-6 text-ink-secondary", - )} - > - {result.content} - -
  • - ))} -
- )} +
+ + + + + + + + + + Navigate + + + + + + Open + + + Esc + Close +
); }; + +const PaletteNote = ({ children }: { children: ReactNode }) => ( +

{children}

+); diff --git a/components/docs/docs-sidebar.tsx b/components/docs/docs-sidebar.tsx deleted file mode 100644 index baca6a1..0000000 --- a/components/docs/docs-sidebar.tsx +++ /dev/null @@ -1,180 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { usePathname } from "next/navigation"; -import { type ReactNode, useEffect, useMemo, useState } from "react"; -import { ChevronDownIcon } from "@/components/icons/chevron-down"; -import { GitHubIcon } from "@/components/icons/github"; -import { IntentfaceLogo } from "@/components/icons/intentface-logo"; -import { NpmIcon } from "@/components/icons/npm"; -import { PlaygroundIcon } from "@/components/icons/playground"; -import { Collapsible } from "@/components/ui/collapsible"; -import { cn } from "@/lib/utils"; -import { DocsSearch } from "./docs-search"; -import { DocsThemeToggle } from "./docs-theme-toggle"; - -// Structural mirror of fumadocs' page-tree nodes (typeof source.pageTree). We -// only render the two node shapes our docs use: pages and one level of folder. -type TreeItem = { type: "page"; name: ReactNode; url: string }; -type TreeFolder = { - type: "folder"; - name: ReactNode; - index?: TreeItem; - children: TreeNode[]; -}; -type TreeSeparator = { type: "separator"; name?: ReactNode }; -type TreeNode = TreeItem | TreeFolder | TreeSeparator; - -type DocsSidebarProps = { - tree: { children: TreeNode[] }; -}; - -// Pages to flag as new in the sidebar. Drop a URL once its primitive has been -// out for a release or two. -const NEW_PAGES = new Set(["/primitives/shell", "/primitives/nav", "/primitives/tabs"]); - -const NavLink = ({ url, name }: { url: string; name: ReactNode }) => { - const pathname = usePathname(); - const isActive = pathname === url; - return ( - - {name} - {NEW_PAGES.has(url) && ( - - New - - )} - - ); -}; - -const hasActiveNode = (nodes: TreeNode[], pathname: string): boolean => - nodes.some((child) => { - if (child.type === "page") return child.url === pathname; - if (child.type === "folder") { - if (child.index?.url === pathname) return true; - return hasActiveNode(child.children, pathname); - } - return false; - }); - -const TreeFolderNode = ({ node, index }: { node: TreeFolder; index: number }) => { - const pathname = usePathname(); - const containsActive = useMemo( - () => hasActiveNode(node.children, pathname) || node.index?.url === pathname, - [node, pathname], - ); - const [open, setOpen] = useState(true); - - useEffect(() => { - if (containsActive) setOpen(true); - }, [containsActive]); - - return ( -
  • - - - {node.name} - - - - - - -
  • - ); -}; - -const TreeNodes = ({ nodes }: { nodes: TreeNode[] }) => ( -
      - {nodes.map((node, index) => { - if (node.type === "separator") { - return ( -
    • - {node.name} -
    • - ); - } - if (node.type === "folder") { - return ; - } - return ( -
    • - -
    • - ); - })} -
    -); - -const ExternalLink = ({ href, icon, label }: { href: string; icon: ReactNode; label: string }) => ( - - {icon} - {label} - -); - -export const DocsSidebar = ({ tree }: DocsSidebarProps) => ( - -); diff --git a/components/docs/docs-theme-toggle.tsx b/components/docs/docs-theme-toggle.tsx index b5c0a45..42d1381 100644 --- a/components/docs/docs-theme-toggle.tsx +++ b/components/docs/docs-theme-toggle.tsx @@ -1,15 +1,15 @@ "use client"; -import { MoonIcon } from "@/components/icons/moon"; -import { SunIcon } from "@/components/icons/sun"; +import { Moon, Sun } from "@keyline-icons/react"; import { IconButton } from "@/components/ui/icon-button"; import { useInterfaceTheme } from "@/hooks/use-interface-theme"; /** - * A single button rather than a three-way control: the sidebar header has room + * A single button rather than a three-way control: the sidebar footer has room * for one affordance, and "system" is the starting mode, not a destination * anyone picks from here. It flips whichever mode is resolved, so the first - * press always changes what you see. + * press always changes what you see. The server can't know that mode, so the + * label is fixed and the icon follows the `.dark` class rather than state. */ export const DocsThemeToggle = () => { const { resolvedMode, setMode } = useInterfaceTheme(); @@ -18,12 +18,13 @@ export const DocsThemeToggle = () => { return ( setMode(next)} - className="rounded-full" + className="rounded-md" > - {resolvedMode === "dark" ? : } + + ); }; diff --git a/components/docs/docs-toc.tsx b/components/docs/docs-toc.tsx index b210687..7b69c63 100644 --- a/components/docs/docs-toc.tsx +++ b/components/docs/docs-toc.tsx @@ -52,25 +52,33 @@ export const DocsTOC = ({ items }: DocsTOCProps) => { const linkClass = (isActive: boolean, depth = 2) => cn( - "-ml-px border-transparent border-l py-1 text-sm transition-colors", - depth >= 3 ? "pl-6" : "pl-4", + "flex h-7 min-w-0 shrink-0 items-center text-sm transition-colors", + depth >= 3 ? "pl-[26px]" : "pl-3.5", + // The active entry draws its own 2px ink rule over the 1px rail. isActive - ? "border-accent-bg font-medium text-ink-primary" - : "text-ink-tertiary hover:text-ink-secondary", + ? "font-medium text-ink-primary shadow-[inset_2px_0_0_var(--color-ink-primary)]" + : "text-ink-secondary hover:text-ink-primary", ); return ( -
    + ); +}; diff --git a/components/docs/props-table.tsx b/components/docs/props-table.tsx index 02b9440..5a9aa43 100644 --- a/components/docs/props-table.tsx +++ b/components/docs/props-table.tsx @@ -1,9 +1,10 @@ "use client"; +import { ChevronDown } from "@keyline-icons/react"; import { motion } from "motion/react"; import { type ReactNode, useState } from "react"; -import { ChevronDownIcon } from "@/components/icons/chevron-down"; import { cn } from "@/lib/utils"; +import { ReferenceTable } from "./reference-table"; export type PropRow = { name: string; @@ -17,31 +18,22 @@ type PropsTableProps = { }; // Hand-authored prop reference. Rows are collapsible — collapsed shows the prop, -// type, and default (type truncated); expanding reveals the description and the -// full untruncated type. Columns are fixed-width so they stay consistent across -// every table on the page. +// its type (truncated) and default; expanding reveals the description and the +// full type. Columns are fixed-width so they line up across every table on the +// page, AttributesTable included. export const PropsTable = ({ rows }: PropsTableProps) => ( -
    - - - - - - - - - - - - - - - {rows.map((row) => ( - - ))} - -
    PropTypeDefault
    -
    + + {rows.map((row) => ( + + ))} + ); const PropsRow = ({ row }: { row: PropRow }) => { @@ -50,81 +42,63 @@ const PropsRow = ({ row }: { row: PropRow }) => { return ( <> setOpen((current) => !current)} > - - {row.name} + + {row.name} - - {row.type} + + {row.type} - -
    - {row.default ? ( - {row.default} - ) : ( - — - )} - -
    + + {row.default ? ( + {row.default} + ) : ( + — + )} + + + - + - - - {row.name} - - - {row.description} - - - - {row.type} - - - - {row.default ? ( - {row.default} - ) : ( - — - )} - - +
    +
    {row.description}
    + + {row.type} + +
    @@ -132,12 +106,13 @@ const PropsRow = ({ row }: { row: PropRow }) => { ); }; -// Mirror the collapsed row's column grid (first col 28%, matching the ): the -// label sits under the "Prop" column and its value under "Type", each with the cells' -// px-4 so the detail lines up with the row above it. -const DetailRow = ({ label, children }: { label: string; children: ReactNode }) => ( -
    -
    {label}
    -
    {children}
    -
    +const TypeChip = ({ className, children }: { className?: string; children: ReactNode }) => ( + + {children} + ); diff --git a/components/docs/reference-table.tsx b/components/docs/reference-table.tsx new file mode 100644 index 0000000..16f2ba6 --- /dev/null +++ b/components/docs/reference-table.tsx @@ -0,0 +1,67 @@ +import type { ReactNode } from "react"; + +type Column = { + label: string; + /** A width class for the column, e.g. "w-[30%]". Omit for the flexible column. */ + width?: string; + /** Screen-reader only label (e.g. the expand-toggle column). */ + srOnly?: boolean; +}; + +/** + * The frame every API reference table shares, built two-tone like a code block: + * column labels sit in an outer tray and the rows in an inset panel with its own + * edge. An expanded row takes the tray colour, so it reads as part of the frame. + * The visual header is a separate, hidden table on the same column widths; the + * real lives with the rows for assistive tech. + */ +export const ReferenceTable = ({ + columns, + children, +}: { + columns: Column[]; + children: ReactNode; +}) => { + const colgroup = ( + + {columns.map((column) => ( + + ))} + + ); + + return ( +
    + + {colgroup} + + + {columns.map((column) => ( + + ))} + + + +
    + {/* Dark draws its edge on an overlay: an inset shadow sits under the rows, + so an expanded row's background would paint over it. */} + + {colgroup} + + + {columns.map((column) => ( + + ))} + + + {children} +
    {column.label}
    +
    +
    + ); +}; diff --git a/components/docs/scroll-line.tsx b/components/docs/scroll-line.tsx new file mode 100644 index 0000000..65ea9d4 --- /dev/null +++ b/components/docs/scroll-line.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { type ReactNode, useCallback, useState } from "react"; + +/** + * One line that scrolls sideways with fading edges instead of wrapping. Only a + * line that actually overflows joins the tab order, so keyboard readers in + * browsers that don't focus scrollers (Safari) can reach the clipped text + * without every short row adding a tab stop. + */ +export const ScrollLine = ({ label, children }: { label: string; children: ReactNode }) => { + const [overflows, setOverflows] = useState(false); + + const ref = useCallback((node: HTMLDivElement | null) => { + if (!node) return; + const measure = () => setOverflows(node.scrollWidth > node.clientWidth); + measure(); + // The scroller's box and its content's: a web font swapping in widens the + // text without resizing the scroller. + const observer = new ResizeObserver(measure); + observer.observe(node); + if (node.firstElementChild) observer.observe(node.firstElementChild); + return () => observer.disconnect(); + }, []); + + return ( + // The wrapper draws the focus ring: a mask also fades its own element's + // outline, so the masked scroller can't show one whole. +
    +
    + {children} +
    +
    + ); +}; diff --git a/components/docs/values-table.tsx b/components/docs/values-table.tsx index 8b69c59..4a65513 100644 --- a/components/docs/values-table.tsx +++ b/components/docs/values-table.tsx @@ -1,4 +1,6 @@ import type { ReactNode } from "react"; +import { ReferenceTable } from "./reference-table"; +import { ScrollLine } from "./scroll-line"; export type ValueRow = { value: string; @@ -15,35 +17,23 @@ type ValuesTableProps = { // every other row). Fixed-width columns and chrome match PropsTable / // AttributesTable so the tables stay visually aligned down the page. export const ValuesTable = ({ rows }: ValuesTableProps) => ( -
    - - - - - - - - - - - - - {rows.map((row) => ( - - - - - ))} - -
    ValueDescription
    -
    - {row.value} - {row.default ? ( - - default - - ) : null} -
    -
    {row.description}
    -
    + + {rows.map((row) => ( + + +
    + {row.value} + {row.default ? ( + + default + + ) : null} +
    + + + {row.description} + + + ))} +
    ); diff --git a/components/header.tsx b/components/header.tsx index dd8423e..65960aa 100644 --- a/components/header.tsx +++ b/components/header.tsx @@ -2,18 +2,10 @@ import { Sidebar } from "@/components/ui/sidebar"; -// Mouse users expand via the edge-hover hotspot (trigger lives inside the -// sidebar); this header trigger stays for the mobile drawer and iPad-class -// touch screens, where there's no hover to summon the hotspot. -export const Header = () => { - return ( -
    -
    - -
    -
    - ); -}; +// The sidebar's collapse button, at the same spot as in the docs top bar: 12px +// from the top and left, like the settings gear on the right. +export const Header = () => ( +
    + +
    +); diff --git a/components/icons/appearance.tsx b/components/icons/appearance.tsx deleted file mode 100644 index e89e995..0000000 --- a/components/icons/appearance.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import type { SVGProps } from "react"; - -export const AppearanceIcon = (props: SVGProps) => ( - -); diff --git a/components/icons/arrow-down.tsx b/components/icons/arrow-down.tsx deleted file mode 100644 index 6b6f474..0000000 --- a/components/icons/arrow-down.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import type { SVGProps } from "react"; - -export const ArrowDownIcon = (props: SVGProps) => { - return ( - - ); -}; diff --git a/components/icons/arrow-triangle-right.tsx b/components/icons/arrow-triangle-right.tsx deleted file mode 100644 index 862b8ed..0000000 --- a/components/icons/arrow-triangle-right.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import type { SVGProps } from "react"; - -export const ArrowTriangleRightIcon = (props: SVGProps) => ( - -); diff --git a/components/icons/arrow-up.tsx b/components/icons/arrow-up.tsx deleted file mode 100644 index 53536ba..0000000 --- a/components/icons/arrow-up.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import type { SVGProps } from "react"; - -export function ArrowUpIcon(props: SVGProps) { - return ( - - ); -} diff --git a/components/icons/arrows-all-sides-2.tsx b/components/icons/arrows-all-sides-2.tsx deleted file mode 100644 index 7ddc64d..0000000 --- a/components/icons/arrows-all-sides-2.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import type { SVGProps } from "react"; - -export const ArrowsAllSides2Icon = (props: SVGProps) => ( - -); diff --git a/components/icons/book.tsx b/components/icons/book.tsx deleted file mode 100644 index e065071..0000000 --- a/components/icons/book.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import type { SVGProps } from "react"; - -export const BookIcon = (props: SVGProps) => ( - -); diff --git a/components/icons/brain.tsx b/components/icons/brain.tsx deleted file mode 100644 index 206110e..0000000 --- a/components/icons/brain.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import type { SVGProps } from "react"; - -export const BrainIcon = (props: SVGProps) => { - return ( - - ); -}; diff --git a/components/icons/bubble-5.tsx b/components/icons/bubble-5.tsx deleted file mode 100644 index 67052f8..0000000 --- a/components/icons/bubble-5.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import type { SVGProps } from "react"; - -export const Bubble5Icon = (props: SVGProps) => ( - -); diff --git a/components/icons/bubble-wide-sparkle.tsx b/components/icons/bubble-wide-sparkle.tsx deleted file mode 100644 index 867a528..0000000 --- a/components/icons/bubble-wide-sparkle.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import type { SVGProps } from "react"; - -export const BubbleWideSparkleIcon = (props: SVGProps) => { - return ( - - ); -}; diff --git a/components/icons/bug.tsx b/components/icons/bug.tsx deleted file mode 100644 index c9a1bfd..0000000 --- a/components/icons/bug.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import type { SVGProps } from "react"; - -export const BugIcon = (props: SVGProps) => { - return ( - - - - ); -}; diff --git a/components/icons/chain-link.tsx b/components/icons/chain-link.tsx deleted file mode 100644 index ccbc12c..0000000 --- a/components/icons/chain-link.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import type { SVGProps } from "react"; - -export const ChainLinkIcon = (props: SVGProps) => { - return ( - - ); -}; diff --git a/components/icons/check-mark-medium.tsx b/components/icons/check-mark-medium.tsx deleted file mode 100644 index 9a1e335..0000000 --- a/components/icons/check-mark-medium.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import type { SVGProps } from "react"; - -export const CheckMarkMediumIcon = (props: SVGProps) => ( - -); diff --git a/components/icons/check.tsx b/components/icons/check.tsx deleted file mode 100644 index 4e83252..0000000 --- a/components/icons/check.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import type { SVGProps } from "react"; - -export function CheckIcon(props: SVGProps) { - return ( - - ); -} diff --git a/components/icons/chevron-down-medium.tsx b/components/icons/chevron-down-medium.tsx deleted file mode 100644 index 3d211c3..0000000 --- a/components/icons/chevron-down-medium.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import type { SVGProps } from "react"; - -export const ChevronDownMediumIcon = (props: SVGProps) => ( - -); diff --git a/components/icons/chevron-down.tsx b/components/icons/chevron-down.tsx deleted file mode 100644 index 4f9f0d9..0000000 --- a/components/icons/chevron-down.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import type { SVGProps } from "react"; - -export const ChevronDownIcon = (props: SVGProps) => ( - -); diff --git a/components/icons/chevron-grabber-vertical.tsx b/components/icons/chevron-grabber-vertical.tsx deleted file mode 100644 index 165bc59..0000000 --- a/components/icons/chevron-grabber-vertical.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import type { SVGProps } from "react"; - -export const ChevronGrabberVerticalIcon = (props: SVGProps) => { - return ( - - ); -}; diff --git a/components/icons/chevron-left-medium.tsx b/components/icons/chevron-left-medium.tsx deleted file mode 100644 index 51f59e9..0000000 --- a/components/icons/chevron-left-medium.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import type { SVGProps } from "react"; - -export const ChevronLeftMediumIcon = (props: SVGProps) => ( - -); diff --git a/components/icons/chevron-right-medium.tsx b/components/icons/chevron-right-medium.tsx deleted file mode 100644 index 8d1b2c7..0000000 --- a/components/icons/chevron-right-medium.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import type { SVGProps } from "react"; - -export const ChevronRightMediumIcon = (props: SVGProps) => ( - -); diff --git a/components/icons/chevron-top-medium.tsx b/components/icons/chevron-top-medium.tsx deleted file mode 100644 index 521270b..0000000 --- a/components/icons/chevron-top-medium.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import type { SVGProps } from "react"; - -export const ChevronTopMediumIcon = (props: SVGProps) => ( - -); diff --git a/components/icons/circle-questionmark.tsx b/components/icons/circle-questionmark.tsx deleted file mode 100644 index 0ffdab7..0000000 --- a/components/icons/circle-questionmark.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import type { SVGProps } from "react"; - -export const CircleQuestionmarkIcon = (props: SVGProps) => ( - -); diff --git a/components/icons/circle.tsx b/components/icons/circle.tsx deleted file mode 100644 index 844e089..0000000 --- a/components/icons/circle.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import type { ComponentProps } from "react"; - -export const CircleIcon = (props: ComponentProps<"svg">) => ( - -); diff --git a/components/icons/code.tsx b/components/icons/code.tsx deleted file mode 100644 index 6fd2a72..0000000 --- a/components/icons/code.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import type { SVGProps } from "react"; - -export const CodeIcon = (props: SVGProps) => { - return ( - - ); -}; diff --git a/components/icons/color-swatch.tsx b/components/icons/color-swatch.tsx deleted file mode 100644 index 9b2e947..0000000 --- a/components/icons/color-swatch.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import type { SVGProps } from "react"; - -export const ColorSwatchIcon = (props: SVGProps) => ( - -); diff --git a/components/icons/copy.tsx b/components/icons/copy.tsx deleted file mode 100644 index 5439ad7..0000000 --- a/components/icons/copy.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import type { SVGProps } from "react"; - -export const CopyIcon = (props: SVGProps) => { - return ( - - ); -}; diff --git a/components/icons/cross-medium.tsx b/components/icons/cross-medium.tsx deleted file mode 100644 index fdef40f..0000000 --- a/components/icons/cross-medium.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import type { SVGProps } from "react"; - -export const CrossMediumIcon = (props: SVGProps) => ( - -); diff --git a/components/icons/dot-grid-1x3-horizontal-tight.tsx b/components/icons/dot-grid-1x3-horizontal-tight.tsx deleted file mode 100644 index 4eb4aef..0000000 --- a/components/icons/dot-grid-1x3-horizontal-tight.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import type { SVGProps } from "react"; - -export const DotGrid1x3HorizontalTightIcon = (props: SVGProps) => ( - -); diff --git a/components/icons/dot-grid-1x3-vertical-tight.tsx b/components/icons/dot-grid-1x3-vertical-tight.tsx deleted file mode 100644 index 8829c37..0000000 --- a/components/icons/dot-grid-1x3-vertical-tight.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import type { SVGProps } from "react"; - -export const DotGrid1x3VerticalTightIcon = (props: SVGProps) => ( - -); diff --git a/components/icons/edit.tsx b/components/icons/edit.tsx deleted file mode 100644 index a4e8b7c..0000000 --- a/components/icons/edit.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import type { SVGProps } from "react"; - -export const EditIcon = (props: SVGProps) => { - return ( - - ); -}; diff --git a/components/icons/exclamation-triangle.tsx b/components/icons/exclamation-triangle.tsx deleted file mode 100644 index 334d12d..0000000 --- a/components/icons/exclamation-triangle.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import type { SVGProps } from "react"; - -export const ExclamationTriangleIcon = (props: SVGProps) => ( - -); diff --git a/components/icons/eyedropper.tsx b/components/icons/eyedropper.tsx deleted file mode 100644 index 68c0a89..0000000 --- a/components/icons/eyedropper.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import type { SVGProps } from "react"; - -export const EyedropperIcon = (props: SVGProps) => ( - -); diff --git a/components/icons/file-bend.tsx b/components/icons/file-bend.tsx deleted file mode 100644 index 884479b..0000000 --- a/components/icons/file-bend.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import type { SVGProps } from "react"; - -export const FileBendIcon = (props: SVGProps) => ( - -); diff --git a/components/icons/file-chart.tsx b/components/icons/file-chart.tsx deleted file mode 100644 index 4db3623..0000000 --- a/components/icons/file-chart.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import type { SVGProps } from "react"; - -export const FileChartIcon = (props: SVGProps) => { - return ( - - ); -}; diff --git a/components/icons/file-text.tsx b/components/icons/file-text.tsx deleted file mode 100644 index 237ed21..0000000 --- a/components/icons/file-text.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import type { SVGProps } from "react"; - -export const FileTextIcon = (props: SVGProps) => { - return ( - - ); -}; diff --git a/components/icons/github.tsx b/components/icons/github.tsx deleted file mode 100644 index 1c4e6f0..0000000 --- a/components/icons/github.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import type { SVGProps } from "react"; - -export const GitHubIcon = (props: SVGProps) => { - return ( - - - - ); -}; diff --git a/components/icons/globe.tsx b/components/icons/globe.tsx deleted file mode 100644 index 4791664..0000000 --- a/components/icons/globe.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import type { SVGProps } from "react"; - -export const GlobeIcon = (props: SVGProps) => { - return ( - - ); -}; diff --git a/components/icons/image-alt.tsx b/components/icons/image-alt.tsx deleted file mode 100644 index 8a3cd6a..0000000 --- a/components/icons/image-alt.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import type { SVGProps } from "react"; - -export const ImageAltIcon = (props: SVGProps) => { - return ( - - ); -}; diff --git a/components/icons/input-form.tsx b/components/icons/input-form.tsx deleted file mode 100644 index 71980e9..0000000 --- a/components/icons/input-form.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import type { SVGProps } from "react"; - -export const InputFormIcon = (props: SVGProps) => ( - -); diff --git a/components/icons/intentface-logo.tsx b/components/icons/intentface-logo.tsx deleted file mode 100644 index fdda4a8..0000000 --- a/components/icons/intentface-logo.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import type { SVGProps } from "react"; - -export const IntentfaceLogo = (props: SVGProps) => { - return ( -