);
}
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 ?? (
<>
-
-
+
-
>
)}
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}
>
-
-
+
+
Latest
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) => {
onToolsChange({ ...tools, webSearch: false })}
>
-
-
-
+
+
+
Web Search
+ {/* The "on" dot, as in the Paper composer. */}
+
)}
@@ -39,14 +39,18 @@ export const ActiveTools = ({ tools, onToolsChange }: ToolToggleProps) => {
onToolsChange({ ...tools, thinking: false })}
>
-
-
-
+
+
+
Thinking
+ {/* The "on" dot, as in the Paper composer. */}
+
)}
@@ -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}
-
-
-
-
- Attribute
- {hasValues ? (
- Values
- ) : null}
- Description
-
-
-
- {rows.map((row) => (
-
-
- {row.attribute}
-
- {hasValues ? (
-
- {row.values ? (
-
- {row.values}
-
- ) : (
- —
- )}
-
- ) : null}
- {row.description}
-
+ <>
+ {attributes.length > 0 && (
+
+ {attributes.map((row) => (
+
))}
-
-
-
+
+ )}
+ {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;
+}) => (
+
+);
+
+/** 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}
+
+ ) : (
+ {children}
+ );
+};
+
+const CodeCopyButton = ({ code }: { code: string }) => {
+ const { copy, copied } = useCopy();
+ const isCopied = copied === code;
+
+ return (
+ copy(code)}
+ aria-label={isCopied ? "Copied" : "Copy code"}
+ className="grid size-7 shrink-0 cursor-pointer place-items-center rounded-full text-white/56 transition-colors hover:bg-white/8 hover:text-white focus-visible:outline-2 focus-visible:outline-white/60"
+ >
+ {isCopied ? : }
+
+ );
+};
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) => (
setTab(value)}
+ role="tab"
+ aria-selected={tab === value}
+ onClick={() => select(value)}
className={cn(
- "cursor-pointer rounded-full border px-3 py-1.5 font-medium text-sm capitalize transition-colors",
+ "h-7 cursor-pointer rounded-full px-3 font-medium text-sm capitalize transition-[color,background-color,box-shadow]",
+ "focus-visible:outline-2 focus-visible:outline-accent-bg/60 focus-visible:outline-offset-2",
tab === value
- ? "border-secondary-border bg-secondary-bg text-ink-primary shadow-xs"
- : "border-transparent text-ink-tertiary hover:bg-primary-bg-hover hover:text-ink-secondary",
+ ? "bg-raised text-ink-primary shadow-raised"
+ : "text-ink-secondary hover:text-ink-primary",
)}
>
{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) => (
-
-
-
-
-
-
- {/* Internal route — next/link, not the external-anchor ExternalLink.
- The chat root IS the playground (corner config cards). */}
-
-
-
-
- Playground
-
-
}
- label="GitHub"
- />
-
}
- label="npm"
- />
-
-
-);
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 (
-
-
+
+ On this page
+
- Overview
+ Overview
{items.map((item) => {
const id = item.url.replace(/^#/, "");
const isActive = activeId === id;
return (
-
- {item.title}
+ // One line per entry: long headings truncate, the full text on hover.
+
+ {item.title}
);
})}
diff --git a/components/docs/docs-top-bar-trigger.tsx b/components/docs/docs-top-bar-trigger.tsx
new file mode 100644
index 0000000..ecf4a75
--- /dev/null
+++ b/components/docs/docs-top-bar-trigger.tsx
@@ -0,0 +1,7 @@
+"use client";
+
+import { Sidebar } from "@/components/ui/sidebar";
+
+// The sidebar's collapse button; the playground's header puts it at this same
+// spot. A client wrapper because the page is a server component.
+export const DocsTopBarTrigger = () => ;
diff --git a/components/docs/installation-block-tabs.tsx b/components/docs/installation-block-tabs.tsx
index bbc23d7..1749318 100644
--- a/components/docs/installation-block-tabs.tsx
+++ b/components/docs/installation-block-tabs.tsx
@@ -2,10 +2,7 @@
import type { ReactNode } from "react";
import { useState } from "react";
-import { CheckMarkMediumIcon } from "@/components/icons/check-mark-medium";
-import { CopyIcon } from "@/components/icons/copy";
-import { useCopy } from "@/hooks/use-copy";
-import { cn } from "@/lib/utils";
+import { CodeFrame, CodeTab } from "./code-frame";
export type InstallationEntry = {
manager: string;
@@ -19,40 +16,26 @@ type InstallationBlockTabsProps = {
};
// Client shell for the package-manager tabs. The server highlights every
-// command up front; this only picks which one shows and owns the copy button.
+// command up front; this only picks which one shows.
export const InstallationBlockTabs = ({ entries }: InstallationBlockTabsProps) => {
const [manager, setManager] = useState(entries[0].manager);
- const { copy, copied } = useCopy();
const current = entries.find((entry) => entry.manager === manager) ?? entries[0];
return (
-
-
- {entries.map((entry) => (
- setManager(entry.manager)}
- className={cn(
- "cursor-pointer rounded-full border px-3 py-1 font-mono text-sm transition-colors",
- entry.manager === manager
- ? "border-secondary-border bg-secondary-bg text-ink-primary shadow-xs"
- : "border-transparent text-ink-tertiary hover:bg-primary-bg-hover hover:text-ink-secondary",
- )}
- >
- {entry.manager}
-
- ))}
- copy(current.command)}
- aria-label={`Copy: ${current.command}`}
- className="ml-auto cursor-pointer rounded-md p-1.5 text-ink-tertiary transition-colors hover:bg-primary-bg-hover hover:text-ink-secondary"
+ (
+ setManager(entry.manager)}
>
- {copied === current.command ? : }
-
-
-
{current.code}
-
+ {entry.manager}
+
+ ))}
+ >
+ {current.code}
+
);
};
diff --git a/components/docs/installation-block.tsx b/components/docs/installation-block.tsx
index 29f389a..bde9734 100644
--- a/components/docs/installation-block.tsx
+++ b/components/docs/installation-block.tsx
@@ -1,5 +1,5 @@
import { PACKAGE_MANAGERS } from "@/lib/docs/package-managers";
-import { CodeBlock } from "./code-block";
+import { highlightCode } from "./code-block";
import { InstallationBlockTabs } from "./installation-block-tabs";
type InstallationBlockProps = {
@@ -8,11 +8,15 @@ type InstallationBlockProps = {
// Server component: highlights one install command per package manager and
// hands them all to the client shell, which shows the selected one.
-export const InstallationBlock = ({ packageName }: InstallationBlockProps) => (
+export const InstallationBlock = async ({ packageName }: InstallationBlockProps) => (
{
- const command = `${install} ${packageName}`;
- return { manager, command, code: };
- })}
+ entries={
+ await Promise.all(
+ PACKAGE_MANAGERS.map(async ({ manager, install }) => {
+ const command = `${install} ${packageName}`;
+ return { manager, command, code: await highlightCode(command, "bash") };
+ }),
+ )
+ }
/>
);
diff --git a/components/docs/keys-table.tsx b/components/docs/keys-table.tsx
index 1f82176..04dd0cd 100644
--- a/components/docs/keys-table.tsx
+++ b/components/docs/keys-table.tsx
@@ -1,4 +1,6 @@
import type { ReactNode } from "react";
+import { ReferenceTable } from "./reference-table";
+import { ScrollLine } from "./scroll-line";
export type KeyRow = {
/** The key or chord, as a reader would press it: "Arrow up", "Enter / Space". */
@@ -15,30 +17,18 @@ type KeysTableProps = {
// `default` badge — there is no such thing as a default keystroke. Column
// widths and chrome match the other tables so they stay aligned down the page.
export const KeysTable = ({ rows }: KeysTableProps) => (
-
-
-
-
-
-
-
-
- Key
- Description
-
-
-
- {rows.map((row) => (
-
-
-
- {row.keys}
-
-
- {row.description}
-
- ))}
-
-
-
+
+ {rows.map((row) => (
+
+
+
+ {row.keys}
+
+
+
+ {row.description}
+
+
+ ))}
+
);
diff --git a/components/docs/page-actions.tsx b/components/docs/page-actions.tsx
index 4e69f29..a43b443 100644
--- a/components/docs/page-actions.tsx
+++ b/components/docs/page-actions.tsx
@@ -1,6 +1,11 @@
-import { GitHubIcon } from "@/components/icons/github";
-import { MarkdownIcon } from "@/components/icons/markdown";
+"use client";
+
+import { Check } from "@keyline-icons/react";
+// Keyline has no brand or Markdown icons, so these stay on Tabler.
+import { IconBrandGithub, IconMarkdown } from "@tabler/icons-react";
import Button from "@/components/ui/button";
+import { IconButton } from "@/components/ui/icon-button";
+import { useCopy } from "@/hooks/use-copy";
const REPO = "https://github.com/Intentface/intentface-chat";
@@ -11,43 +16,39 @@ type PageActionsProps = {
source?: string;
};
-const actionButtonClass =
- "h-9 rounded-full px-4 text-md font-medium text-ink-secondary hover:bg-secondary-bg-hover hover:text-ink-primary";
+// "Copy page" puts the page's raw markdown on the clipboard (the same file the
+// `.md` route serves); the GitHub link goes to the primitive's source when the
+// page documents one, and to the repo otherwise.
+export const PageActions = ({ slug, source }: PageActionsProps) => {
+ const { copy, isCopied } = useCopy();
+ const markdownUrl = `/${slug.join("/")}.md`;
+
+ const copyPage = async () => {
+ const response = await fetch(markdownUrl);
+ if (response.ok) await copy(await response.text());
+ };
-// "View as Markdown" (raw .mdx as text) + "Primitive source" (the headless
-// component on GitHub — not the demos), mirroring Base UI's page header.
-export const PageActions = ({ slug, source }: PageActionsProps) => (
-
- {/* These navigate rather than act, so they render as anchors. Base UI needs
- telling: `nativeButton` defaults to true and warns when the rendered
- element is not a real . */}
-
-
- View as Markdown
-
- }
- />
- {source && (
-
+
+ {isCopied ? : }
+ {isCopied ? "Copied" : "Copy page"}
+
+
-
- Primitive source
+
}
/>
- )}
-
-);
+
+ );
+};
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) => (
-
-
-
-
-
-
-
-
-
- Prop
- Type
- Default
-
-
-
- {rows.map((row) => (
-
- ))}
-
-
-
+
+ {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}
- ) : (
- —
- )}
- {
- event.stopPropagation();
- setOpen((current) => !current);
- }}
- >
-
-
-
+
+ {row.default ? (
+ {row.default}
+ ) : (
+ —
+ )}
+
+
+ {
+ event.stopPropagation();
+ setOpen((current) => !current);
+ }}
+ >
+
+
-
+
-
-
- {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) => (
+
+ {column.srOnly ? null : column.label}
+
+ ))}
+
+
+
+
+ {/* 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) => (
+ {column.label}
+ ))}
+
+
+ {children}
+
+
+
+ );
+};
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.
+
+ );
+};
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) => (
-
-
-
-
-
-
-
-
- Value
- Description
-
-
-
- {rows.map((row) => (
-
-
-
- {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 (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-};
diff --git a/components/icons/logo-tile.tsx b/components/icons/logo-tile.tsx
new file mode 100644
index 0000000..aeed2b8
--- /dev/null
+++ b/components/icons/logo-tile.tsx
@@ -0,0 +1,34 @@
+import { cn } from "@/lib/utils";
+
+type LogoTileProps = {
+ size?: "sm" | "lg";
+ className?: string;
+};
+
+// The Intentface asterisk, white on a cobalt tile lit like the send button.
+export const LogoTile = ({ size = "sm", className }: LogoTileProps) => (
+
+
+
+
+
+);
diff --git a/components/icons/markdown.tsx b/components/icons/markdown.tsx
deleted file mode 100644
index 5d46e66..0000000
--- a/components/icons/markdown.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-import type { SVGProps } from "react";
-
-export const MarkdownIcon = (props: SVGProps) => {
- return (
-
-
-
- );
-};
diff --git a/components/icons/moon.tsx b/components/icons/moon.tsx
deleted file mode 100644
index c75368c..0000000
--- a/components/icons/moon.tsx
+++ /dev/null
@@ -1,20 +0,0 @@
-import type { SVGProps } from "react";
-
-export const MoonIcon = (props: SVGProps) => (
-
-
-
-
-
-);
diff --git a/components/icons/npm.tsx b/components/icons/npm.tsx
deleted file mode 100644
index 976615e..0000000
--- a/components/icons/npm.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import type { SVGProps } from "react";
-
-export const NpmIcon = (props: SVGProps) => {
- return (
-
-
-
- );
-};
diff --git a/components/icons/open-quote-2.tsx b/components/icons/open-quote-2.tsx
deleted file mode 100644
index 5df51fc..0000000
--- a/components/icons/open-quote-2.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-import type { SVGProps } from "react";
-
-export const OpenQuote2Icon = (props: SVGProps) => (
-
-
-
-
-
-
-);
diff --git a/components/icons/paper-plane.tsx b/components/icons/paper-plane.tsx
deleted file mode 100644
index f9c9553..0000000
--- a/components/icons/paper-plane.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import type { SVGProps } from "react";
-
-export const PaperPlaneIcon = (props: SVGProps) => {
- return (
-
-
-
-
-
- );
-};
diff --git a/components/icons/paperclip.tsx b/components/icons/paperclip.tsx
deleted file mode 100644
index bed101c..0000000
--- a/components/icons/paperclip.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-import type { SVGProps } from "react";
-
-export function PaperClipIcon(props: SVGProps) {
- return (
-
-
-
-
-
- );
-}
diff --git a/components/icons/playground.tsx b/components/icons/playground.tsx
deleted file mode 100644
index 9f90473..0000000
--- a/components/icons/playground.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-import type { SVGProps } from "react";
-
-export const PlaygroundIcon = (props: SVGProps) => {
- return (
-
-
-
-
-
- );
-};
diff --git a/components/icons/plus-medium.tsx b/components/icons/plus-medium.tsx
deleted file mode 100644
index a67d2e9..0000000
--- a/components/icons/plus-medium.tsx
+++ /dev/null
@@ -1,20 +0,0 @@
-import type { SVGProps } from "react";
-
-export const PlusMediumIcon = (props: SVGProps) => (
-
-
-
-
-
-);
diff --git a/components/icons/refresh.tsx b/components/icons/refresh.tsx
deleted file mode 100644
index 14c2e29..0000000
--- a/components/icons/refresh.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-import type { SVGProps } from "react";
-
-export const RefreshIcon = (props: SVGProps) => {
- return (
-
-
-
-
-
-
- );
-};
diff --git a/components/icons/search-line-sparkle.tsx b/components/icons/search-line-sparkle.tsx
deleted file mode 100644
index 540f241..0000000
--- a/components/icons/search-line-sparkle.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-import type { SVGProps } from "react";
-
-export const SearchLineSparkleIcon = (props: SVGProps) => {
- return (
-
-
-
-
-
-
-
- );
-};
diff --git a/components/icons/send.tsx b/components/icons/send.tsx
deleted file mode 100644
index fcaa5c8..0000000
--- a/components/icons/send.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import type { SVGProps } from "react";
-
-export function SendIcon(props: SVGProps) {
- return (
-
-
-
-
-
- );
-}
diff --git a/components/icons/settings-slider-three.tsx b/components/icons/settings-slider-three.tsx
deleted file mode 100644
index 41c094f..0000000
--- a/components/icons/settings-slider-three.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import type { SVGProps } from "react";
-
-export const SettingsSliderThreeIcon = (props: SVGProps) => (
-
-
-
-
-
-);
diff --git a/components/icons/settings.tsx b/components/icons/settings.tsx
deleted file mode 100644
index ef4999d..0000000
--- a/components/icons/settings.tsx
+++ /dev/null
@@ -1,114 +0,0 @@
-import type { ComponentProps } from "react";
-
-export const SettingsIcon = (props: ComponentProps<"svg">) => (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-);
diff --git a/components/icons/sidebar.tsx b/components/icons/sidebar.tsx
deleted file mode 100644
index cc2cf3a..0000000
--- a/components/icons/sidebar.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import type { SVGProps } from "react";
-
-export const SidebarIcon = (props: SVGProps) => (
-
-
-
-
-);
diff --git a/components/icons/spreadsheet.tsx b/components/icons/spreadsheet.tsx
deleted file mode 100644
index 0f7abd6..0000000
--- a/components/icons/spreadsheet.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-import type { SVGProps } from "react";
-
-export const SpreadsheetIcon = (props: SVGProps) => {
- return (
-
-
-
-
-
-
-
- );
-};
diff --git a/components/icons/square-lines.tsx b/components/icons/square-lines.tsx
deleted file mode 100644
index ab4d9ae..0000000
--- a/components/icons/square-lines.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import type { SVGProps } from "react";
-
-export const SquareLinesIcon = (props: SVGProps) => (
-
-
-
-
-
-);
diff --git a/components/icons/stop.tsx b/components/icons/stop.tsx
deleted file mode 100644
index 7f7d15c..0000000
--- a/components/icons/stop.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-import type { SVGProps } from "react";
-
-export function StopIcon(props: SVGProps) {
- return (
-
-
-
- );
-}
diff --git a/components/icons/sun.tsx b/components/icons/sun.tsx
deleted file mode 100644
index 0a61624..0000000
--- a/components/icons/sun.tsx
+++ /dev/null
@@ -1,54 +0,0 @@
-import type { SVGProps } from "react";
-
-// The source clipped to a 0,0,24,24 rect identical to the viewBox (a no-op), so
-// the clipPath def is dropped — the rays already sit inside the box.
-export const SunIcon = (props: SVGProps) => (
-
-
-
-
-
-
-
-
-
-
-
-
-
-);
diff --git a/components/icons/trash.tsx b/components/icons/trash.tsx
deleted file mode 100644
index 3fa7e58..0000000
--- a/components/icons/trash.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import type { SVGProps } from "react";
-
-export const TrashIcon = (props: SVGProps) => (
-
-
-
-
-
-);
diff --git a/components/model-selector.tsx b/components/model-selector.tsx
index f49311d..7fc2ed6 100644
--- a/components/model-selector.tsx
+++ b/components/model-selector.tsx
@@ -1,7 +1,6 @@
"use client";
+import { Check, ChevronDown } from "@keyline-icons/react";
-import { CheckMarkMediumIcon } from "@/components/icons/check-mark-medium";
-import { ChevronGrabberVerticalIcon } from "@/components/icons/chevron-grabber-vertical";
import { ClaudeIcon } from "@/components/icons/claude";
import { GeminiIcon } from "@/components/icons/gemini";
import { GrokIcon } from "@/components/icons/grok";
@@ -52,10 +51,10 @@ export const ModelSelector = ({ value, onValueChange }: ModelSelectorProps) => {
+
{CurrentProviderIcon ? : null}
{currentLabel}
-
+
}
/>
@@ -73,7 +72,7 @@ export const ModelSelector = ({ value, onValueChange }: ModelSelectorProps) => {
{group.models.map((model) => {
return (
onValueChange(model.id)}>
-
+
{model.label}
);
diff --git a/components/playground-settings.tsx b/components/playground-settings.tsx
index 1aa8012..719688d 100644
--- a/components/playground-settings.tsx
+++ b/components/playground-settings.tsx
@@ -1,36 +1,28 @@
"use client";
+import { Monitor, Moon, Settings as SettingsIcon, Sun, X } from "@keyline-icons/react";
-// Playground settings — a Linear-style display-options popover behind a single
-// icon trigger in the chat area's top-right corner, on every chat page. The
-// panel is split into four tabs (Theme / Thread / Composer / Key), mirroring
-// Linear's List/Board/Timeline switcher. Configuration persists via the
-// settings store; demo triggers (ask-user questions, the context strip) are
-// ephemeral playground-store state. The visitor's OpenAI key deliberately
-// persists nowhere client-side — see KeyTab. Desktop-only — hidden below md.
+// Playground settings — a Linear-style floating sidebar docked in the chat
+// area's top-right corner, on every chat page. Opening it pushes the chat over
+// rather than covering it. Three tabs: Theme / Chat / Key. Configuration
+// persists via the settings store; demo triggers (ask-user questions, the
+// context strip) are ephemeral playground-store state. The visitor's OpenAI key
+// deliberately persists nowhere client-side — see KeyTab. Desktop-only.
import { Tabs } from "@base-ui/react/tabs";
import { AnimatePresence, MotionConfig, motion } from "motion/react";
-import { type ReactNode, useState } from "react";
+import { type ReactNode, useId, useRef, useState } from "react";
+import { flushSync } from "react-dom";
import type { ComposerSubmitOn } from "@/components/ai/composer";
import type { ThreadAutoScrollMode } from "@/components/ai/thread";
-import { AppearanceIcon } from "@/components/icons/appearance";
-import { InputFormIcon } from "@/components/icons/input-form";
-import { MoonIcon } from "@/components/icons/moon";
-import { OpenAIIcon } from "@/components/icons/openai";
-import { SettingsIcon } from "@/components/icons/settings";
-import { SettingsSliderThreeIcon } from "@/components/icons/settings-slider-three";
-import { SquareLinesIcon } from "@/components/icons/square-lines";
-import { SunIcon } from "@/components/icons/sun";
import Button from "@/components/ui/button";
import { ColorPill } from "@/components/ui/color-pill";
import { IconButton } from "@/components/ui/icon-button";
import Input from "@/components/ui/input";
import { Kbd } from "@/components/ui/kbd";
-import { Popover } from "@/components/ui/popover";
import { PresetSwatch } from "@/components/ui/preset-swatch";
-import { Scaler } from "@/components/ui/scaler";
import Select from "@/components/ui/select";
import { Settings } from "@/components/ui/settings";
+import { Slider } from "@/components/ui/slider";
import { Switch } from "@/components/ui/switch";
import { ToggleGroup } from "@/components/ui/toggle-group";
import { useApiKey } from "@/hooks/use-api-key";
@@ -47,10 +39,10 @@ import { usePlaygroundStore } from "@/lib/store/playground";
import { type CommandSurface, useSettingsStore } from "@/lib/store/settings";
import { cn } from "@/lib/utils";
-// Right-aligned compact controls share one footprint across the rows.
-const COMPACT_CONTROL_CLASS = "h-8 w-28 shrink-0";
+// Right-aligned compact selects share one footprint across the rows.
+const COMPACT_CONTROL_CLASS = "h-7 w-[8.5rem] shrink-0";
-const PLAYGROUND_ROW_CLASS = "min-h-11 border-0 px-3 py-2";
+const PLAYGROUND_ROW_CLASS = "min-h-14 border-0 px-3.5 py-2.5";
// Label (with optional description) and control on one row.
const LabeledRow = ({
@@ -73,7 +65,7 @@ const LabeledRow = ({
// Divider-separated group inside a tab panel.
const SettingsSection = ({ children }: { children: ReactNode }) => (
- {children}
+ {children}
);
// Inline boolean row. The Settings.Label span carries no id, so the switch
@@ -120,11 +112,11 @@ const CUSTOM_PRESET_VALUE = "__custom__";
const MODE_OPTIONS: ReadonlyArray<{
value: InterfaceThemeMode;
label: string;
- Icon: typeof SunIcon;
+ Icon: typeof Sun;
}> = [
- { value: "light", label: "Light", Icon: SunIcon },
- { value: "dark", label: "Dark", Icon: MoonIcon },
- { value: "system", label: "System", Icon: AppearanceIcon },
+ { value: "light", label: "Light", Icon: Sun },
+ { value: "dark", label: "Dark", Icon: Moon },
+ { value: "system", label: "System", Icon: Monitor },
];
const ThemeTab = () => {
@@ -145,7 +137,7 @@ const ThemeTab = () => {
return (
-
+
{
-
+
-
+
-
+
{preset?.label ?? "Custom"}
@@ -187,44 +179,49 @@ const ThemeTab = () => {
-
+
setSeed("acc", value)}
/>
-
+
setSeed("bg", value)}
/>
-
+
setSeed("fg", value)}
/>
-
-
+ setSeed("con", value)}
+ onValueChange={(value) => {
+ if (typeof value === "number") setSeed("con", value);
+ }}
min={CONTRAST_MIN}
max={CONTRAST_MAX}
step={CONTRAST_STEP}
- tickStep={0.05}
- />
+ >
+
+
+
+
+
+
+
);
@@ -258,7 +255,7 @@ const ThreadTab = () => {
if (value) setScrollMode(value);
}}
>
-
+
{SCROLL_MODE_OPTIONS.find((option) => option.value === scrollMode)?.label}
@@ -327,7 +324,7 @@ const ComposerTab = () => {
if (value) setCommandSurface(value);
}}
>
-
+
{COMMAND_SURFACE_OPTIONS.find((option) => option.value === commandSurface)?.label}
@@ -348,7 +345,7 @@ const ComposerTab = () => {
if (value) setSubmitOn(value);
}}
>
-
+
{SUBMIT_OPTIONS.find((option) => option.value === submitOn)?.label}
@@ -411,14 +408,10 @@ const ComposerTab = () => {
Fire a demo question flow
- setDemoQuestions(singleQuestion)}>
+ setDemoQuestions(singleQuestion)}>
Single
- setDemoQuestions(multipleQuestions)}
- >
+ setDemoQuestions(multipleQuestions)}>
Multi
@@ -455,7 +448,7 @@ const KeyTab = () => {
{isSet ? (
{
) : (
-
+
OpenAI API key
@@ -495,9 +488,9 @@ const KeyTab = () => {
)}
{/* Outside the branches on purpose: clear() runs from the is-set side, so
an error rendered only in the not-set branch could never be seen. */}
- {error && {error}
}
+ {error && {error}
}
-
+
Your key is sent to this site's server to forward each request to OpenAI, and is kept only
for the length of that request — never written to disk or logged. It is stored in your
browser in a cookie that scripts cannot read.{" "}
@@ -515,16 +508,25 @@ const KeyTab = () => {
);
};
+// Thread and composer options share one tab: both shape the chat itself.
+const ChatTab = () => (
+ <>
+
+
+
+
+ >
+);
+
const PLAYGROUND_TABS = [
- { value: "theme", label: "Theme", Icon: SettingsSliderThreeIcon, content: },
- { value: "thread", label: "Thread", Icon: SquareLinesIcon, content: },
- { value: "composer", label: "Composer", Icon: InputFormIcon, content: },
- { value: "key", label: "Key", Icon: OpenAIIcon, content: },
+ { value: "theme", label: "Theme", content: },
+ { value: "chat", label: "Chat", content: },
+ { value: "key", label: "Key", content: },
] as const;
type PlaygroundTabValue = (typeof PLAYGROUND_TABS)[number]["value"];
-// One spring for the shell's height and the panels' travel, so everything settles
+// One spring for the card's height and the panels' travel, so everything settles
// together. bounce: 0 keeps a settings panel from wobbling.
const PANEL_SPRING = { duration: 0.5, type: "spring", bounce: 0 } as const;
@@ -537,10 +539,21 @@ const panelVariants = {
exit: (direction: number) => ({ x: `${-110 * direction}%`, opacity: 0 }),
};
+/**
+ * The gear trigger plus the docked card. Rendered as a flex sibling of the
+ * thread: the slot animates its width so the chat column narrows and re-centres
+ * instead of sitting under the card. The card stays mounted (inert while closed)
+ * and slides in from the right edge, where the viewport clips it.
+ */
export const PlaygroundSettings = () => {
- // The four panels differ in height, so the shell animates to the measured
- // content. Because it is a ResizeObserver, content that grows *inside* a tab
- // (the key form revealing a validation error) animates too, not just switches.
+ const [open, setOpen] = useState(false);
+ const triggerRef = useRef(null);
+ const cardRef = useRef(null);
+ const panelId = useId();
+
+ // The panels differ in height, so the card animates to the measured content.
+ // Because it is a ResizeObserver, content that grows *inside* a tab (the key
+ // form revealing a validation error) animates too, not just switches.
const [panelRef, { height }] = useMeasure();
// Controlled rather than defaultValue: the slide needs to know which way the
@@ -556,50 +569,87 @@ export const PlaygroundSettings = () => {
setActiveTab(next);
};
+ // Focus follows the toggle: into the card on open, back to the gear on close.
+ // preventScroll: the card starts off-screen, and a plain focus() would scroll
+ // the clipped viewport sideways to reveal it.
+ const show = () => {
+ flushSync(() => setOpen(true));
+ cardRef.current?.focus({ preventScroll: true });
+ };
+ const hide = () => {
+ flushSync(() => setOpen(false));
+ triggerRef.current?.focus({ preventScroll: true });
+ };
+
const activePanel = PLAYGROUND_TABS.find((tab) => tab.value === activeTab);
return (
-
-
-
- }
- >
-
-
-
+
+
+
+
+
{
+ if (event.key === "Escape" && !event.defaultPrevented) hide();
+ }}
+ className={cn(
+ "absolute top-3 right-3 z-20 flex max-h-[calc(100%-1.5rem)] w-[304px] flex-col overflow-hidden rounded-2xl bg-primary-bg shadow-card outline-none",
+ "transition-transform duration-200 ease-out motion-reduce:transition-none",
+ open ? "translate-x-0" : "pointer-events-none translate-x-[calc(100%+1rem)]",
+ )}
>
-
-
- {PLAYGROUND_TABS.map((tab) => (
- svg]:size-3.5 [&>svg]:shrink-0 [&>svg]:text-ink-tertiary data-active:[&>svg]:text-ink-primary",
- )}
- >
-
- {tab.label}
-
- ))}
-
+ {/* Capped at the viewport: the tab bar stays, the panel below scrolls. */}
+
+
+
+ {PLAYGROUND_TABS.map((tab) => (
+
+ {tab.label}
+
+ ))}
+
+
+
+
+
@@ -611,6 +661,7 @@ export const PlaygroundSettings = () => {
animate="active"
exit="exit"
custom={direction}
+ className="py-1"
>
{activePanel?.content}
@@ -620,8 +671,8 @@ export const PlaygroundSettings = () => {
-
-
-
+
+
+ >
);
};
diff --git a/components/site-shell.tsx b/components/site-shell.tsx
new file mode 100644
index 0000000..34bd972
--- /dev/null
+++ b/components/site-shell.tsx
@@ -0,0 +1,213 @@
+"use client";
+///
+
+import { BookOpen } from "@keyline-icons/react";
+// Keyline has no brand or sandbox icons, so these stay on Tabler.
+import { IconBrandGithub, IconBrandNpm, IconSandbox } from "@tabler/icons-react";
+import Link from "next/link";
+import { usePathname, useRouter } from "next/navigation";
+import { type ReactNode, useEffect, useState, ViewTransition } from "react";
+import { ChatNav } from "@/components/chat-nav";
+import { DocsNav, type DocsTree, toGroups, toSuggestions } from "@/components/docs/docs-nav";
+import { DocsSearch } from "@/components/docs/docs-search";
+import { DocsThemeToggle } from "@/components/docs/docs-theme-toggle";
+import { LogoTile } from "@/components/icons/logo-tile";
+import { IconButton } from "@/components/ui/icon-button";
+import { Kbd } from "@/components/ui/kbd";
+import { Sidebar } from "@/components/ui/sidebar";
+import Tooltip from "@/components/ui/tooltip";
+import { useAsRef } from "@/hooks/use-as-ref";
+import { cn } from "@/lib/utils";
+
+type Mode = "docs" | "playground";
+
+// Declaration order is slide order: Docs sits left of Playground.
+// `key` is the second half of its G-then-key shortcut.
+const MODES: { value: Mode; label: string; icon: ReactNode; key: string }[] = [
+ { value: "docs", label: "Docs", icon: , key: "D" },
+ { value: "playground", label: "Playground", icon: , key: "P" },
+];
+
+// The chat routes are the playground; every other page is the docs.
+const modeOf = (pathname: string): Mode =>
+ pathname === "/playground" || pathname.startsWith("/chat/") ? "playground" : "docs";
+
+// G then D / G then P switch sides, like Linear's go-to sequences: the second
+// key has to follow within a second.
+const SEQUENCE_MS = 1000;
+
+// Plain-letter shortcuts must never fire while someone is typing.
+const isTyping = (target: EventTarget | null) =>
+ target instanceof HTMLElement &&
+ (target.isContentEditable || target.closest("input, textarea, select") !== null);
+
+const useGoToShortcuts = (go: (mode: Mode) => void) => {
+ const goRef = useAsRef(go);
+
+ useEffect(() => {
+ let armedAt = 0;
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.defaultPrevented || event.repeat || event.metaKey || event.ctrlKey || event.altKey)
+ return;
+ if (isTyping(event.target)) return;
+ const key = event.key.toUpperCase();
+ const target = MODES.find((mode) => mode.key === key);
+ if (target && Date.now() - armedAt < SEQUENCE_MS) {
+ event.preventDefault();
+ armedAt = 0;
+ goRef.current(target.value);
+ return;
+ }
+ armedAt = key === "G" ? Date.now() : 0;
+ };
+ document.addEventListener("keydown", onKeyDown);
+ return () => document.removeEventListener("keydown", onKeyDown);
+ }, []);
+};
+
+/**
+ * One shell for the whole site, mounted in the root layout so moving between the
+ * docs and the playground keeps the sidebar — its open state and width — and
+ * only its body slides over, picked by the icon switch in the header. The
+ * collapse button sits in each side's top bar instead. A client component because `Sidebar` is an
+ * `Object.assign` compound, which a server component would read as a proxy.
+ *
+ * It reads no cookie: the docs stay statically rendered, so every full load
+ * starts with the sidebar open.
+ */
+export const SiteShell = ({
+ tree,
+ version,
+ children,
+}: {
+ tree: DocsTree;
+ /** The released package version, shown in the footer and linked to its notes. */
+ version: string;
+ children: ReactNode;
+}) => {
+ const pathname = usePathname();
+ const mode = modeOf(pathname);
+ const groups = toGroups(tree.children);
+
+ // Each side reopens on the page you left it at.
+ const [lastPath, setLastPath] = useState>({
+ docs: "/",
+ playground: "/playground",
+ });
+ if (lastPath[mode] !== pathname) setLastPath({ ...lastPath, [mode]: pathname });
+
+ const router = useRouter();
+ useGoToShortcuts((next) => {
+ if (next !== mode) router.push(lastPath[next]);
+ });
+
+ return (
+
+
+ {/* Centred on the viewport's top bar: both start 8px down, and the bar
+ (52px) sits under the viewport's 1px border, hence 54px. The extra
+ height is taken back from the gap below. */}
+
+
+
+ intentface/chat
+
+
+
+
+ {MODES.map(({ value, label, icon, key }) => (
+
+ svg]:size-[15px]",
+ "focus-visible:outline-2 focus-visible:outline-accent-bg/60 focus-visible:outline-offset-1",
+ mode === value
+ ? "bg-raised text-ink-primary shadow-raised"
+ : "text-ink-secondary hover:text-ink-primary",
+ )}
+ >
+ {icon}
+
+ }
+ />
+
+ {label}
+
+ G
+ {key}
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+ {/* Shared by both sides, so it stays put while the views slide. */}
+
+
+ v{version}
+
+
+ }
+ />
+ }
+ />
+
+
+
+
+
+
+ {/* Keyed by side: switching sides swaps the boundary, so the old and new
+ viewport cross-dissolve; pages within a side don't animate. */}
+
+ {children}
+
+
+
+
+ );
+};
+
+const FooterLink = ({ href, label, icon }: { href: string; label: string; icon: ReactNode }) => (
+
+ {icon}
+
+ }
+ />
+);
diff --git a/components/ui/button-group.tsx b/components/ui/button-group.tsx
index 25e5119..b0c1ffd 100644
--- a/components/ui/button-group.tsx
+++ b/components/ui/button-group.tsx
@@ -43,7 +43,7 @@ function ButtonGroupText({ className, render, ...props }: useRender.ComponentPro
props: mergeProps<"div">(
{
className: cn(
- "bg-muted gap-2 rounded-lg border px-2.5 text-sm font-medium [&_svg:not([class*='size-'])]:size-4 flex items-center [&_svg]:pointer-events-none",
+ "bg-base-bg text-ink-secondary gap-2 rounded-lg px-2.5 text-sm font-medium [&_svg:not([class*='size-'])]:size-4 flex items-center [&_svg]:pointer-events-none",
className,
),
},
@@ -66,7 +66,7 @@ function ButtonGroupSeparator({
data-slot="button-group-separator"
orientation={orientation}
className={cn(
- "bg-input relative self-stretch data-horizontal:mx-px data-horizontal:w-auto data-vertical:my-px data-vertical:h-auto",
+ "bg-ink-primary/8 relative self-stretch data-horizontal:mx-px data-horizontal:w-auto data-vertical:my-px data-vertical:h-auto",
className,
)}
{...props}
diff --git a/components/ui/button.tsx b/components/ui/button.tsx
index 7981b2b..a43a1e4 100644
--- a/components/ui/button.tsx
+++ b/components/ui/button.tsx
@@ -6,25 +6,30 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
- "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 aria-invalid:ring-3 [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none active:scale-[0.98] cursor-pointer",
+ [
+ "group/button inline-flex shrink-0 cursor-pointer select-none items-center justify-center whitespace-nowrap rounded-full font-medium text-sm transition-[color,background-color,box-shadow,scale] active:scale-[0.98]",
+ "focus-visible:outline-2 focus-visible:outline-accent-bg/60 focus-visible:outline-offset-2",
+ "disabled:pointer-events-none disabled:opacity-50",
+ "[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
+ ],
{
variants: {
variant: {
- primary:
- "bg-primary-bg border-primary-border text-ink-primary hover:bg-primary-bg-hover hover:border-primary-border-hover",
- secondary:
- "bg-secondary-bg border-secondary-border text-ink-primary hover:bg-secondary-bg-hover",
- tertiary:
- "bg-tertiary-bg border-tertiary-border text-ink-primary hover:bg-tertiary-bg-hover",
- ghost: "hover:bg-primary-bg-hover",
- link: "text-primary-bg underline-offset-4 hover:underline",
+ // Raised: lit from above, the default control on any surface.
+ primary: "bg-raised text-ink-primary shadow-raised hover:bg-raised-hover",
+ // Flat fills for secondary actions that shouldn't lift off the surface.
+ secondary: "bg-base-bg text-ink-primary hover:bg-base-bg-hover",
+ tertiary: "bg-tertiary-bg text-ink-primary hover:bg-tertiary-bg-hover",
+ accent: "bg-accent-raised text-white shadow-accent hover:bg-accent-raised-hover",
+ ghost: "text-ink-body hover:bg-ink-primary/5 hover:text-ink-primary",
+ link: "text-accent-bg underline-offset-4 hover:underline",
},
size: {
- xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
- sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
- md: "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
- lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
- xl: "h-10 gap-2 px-3 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
+ xs: "h-6 gap-1 px-2 text-xs has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
+ sm: "h-7 gap-1 px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
+ md: "h-8 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
+ lg: "h-9 gap-1.5 px-3.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
+ xl: "h-10 gap-2 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
},
},
defaultVariants: {
diff --git a/components/ui/checkbox.tsx b/components/ui/checkbox.tsx
index b387e07..cbad6bc 100644
--- a/components/ui/checkbox.tsx
+++ b/components/ui/checkbox.tsx
@@ -1,17 +1,17 @@
"use client";
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox";
+import { Check } from "@keyline-icons/react";
-import { CheckMarkMediumIcon } from "@/components/icons/check-mark-medium";
import { cn } from "@/lib/utils";
const Checkbox = ({ className, ...props }: CheckboxPrimitive.Root.Props) => (
(
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none [&>svg]:size-3"
>
-
+
);
diff --git a/components/ui/color-pill.tsx b/components/ui/color-pill.tsx
index dcf1e9f..6623f45 100644
--- a/components/ui/color-pill.tsx
+++ b/components/ui/color-pill.tsx
@@ -3,8 +3,6 @@
import { useCallback, useState } from "react";
import { HexColorPicker } from "react-colorful";
-import { ChevronDownMediumIcon } from "@/components/icons/chevron-down-medium";
-import { EyedropperIcon } from "@/components/icons/eyedropper";
import { Popover } from "@/components/ui/popover";
import { HexSchema } from "@/lib/interface-theme";
import { cn } from "@/lib/utils";
@@ -19,10 +17,11 @@ type ColorPillProps = {
};
const colorPillSizes = {
- default: "box-border h-9 w-full px-2.5",
- compact: "box-border h-8 w-28 shrink-0 px-2",
+ default: "h-9 w-full pr-2.5 pl-2",
+ compact: "h-7 w-[5.75rem] shrink-0 pr-2 pl-1.5",
} as const;
+// A raised field: the swatch opens the picker, the hex text is editable.
export const ColorPill = ({
id,
value,
@@ -44,33 +43,26 @@ export const ColorPill = ({
diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx
index 77f23db..43eb550 100644
--- a/components/ui/dialog.tsx
+++ b/components/ui/dialog.tsx
@@ -22,7 +22,7 @@ const DialogContent = ({
{
return (
-
+
) => (
);
diff --git a/components/ui/dropdown-menu.tsx b/components/ui/dropdown-menu.tsx
index 335b5d2..d846425 100644
--- a/components/ui/dropdown-menu.tsx
+++ b/components/ui/dropdown-menu.tsx
@@ -1,10 +1,8 @@
"use client";
import { Menu as MenuPrimitive } from "@base-ui/react/menu";
+import { Check, ChevronRight, CircleFull } from "@keyline-icons/react";
import type { ComponentProps, ReactNode } from "react";
-import { ArrowTriangleRightIcon } from "@/components/icons/arrow-triangle-right";
-import { CheckMarkMediumIcon } from "@/components/icons/check-mark-medium";
-import { CircleIcon } from "@/components/icons/circle";
import { cn } from "@/lib/utils";
function DropdownMenuRoot({ ...props }: MenuPrimitive.Root.Props) {
@@ -40,7 +38,7 @@ function DropdownMenuContent({
{children}
-
+
);
}
@@ -145,7 +143,7 @@ function DropdownMenuSubContent({
-
+
{children}
@@ -212,14 +210,14 @@ function DropdownMenuRadioItem({
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
- "data-highlighted:bg-primary-bg-hover gap-1.5 rounded-md h-8 px-2 text-sm font-book data-inset:pl-7 [&_svg:not([class*='size-'])]:size-3.5 relative flex cursor-pointer items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
+ "data-highlighted:bg-ink-primary/5 gap-1.5 rounded-lg h-8 px-2 text-sm font-book data-inset:pl-7 [&_svg:not([class*='size-'])]:size-3.5 relative flex cursor-pointer items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...props}
>
-
+
{children}
@@ -240,8 +238,8 @@ function DropdownMenuSwitchItem({
data-slot="dropdown-menu-switch-item"
closeOnClick={closeOnClick}
className={cn(
- "group/dropdown-menu-switch-item flex flex-nowrap items-center gap-2 rounded-sm cursor-pointer px-2 h-8 text-sm font-book outline-none select-none whitespace-nowrap",
- "data-highlighted:text-ink-primary data-highlighted:bg-primary-bg-hover",
+ "group/dropdown-menu-switch-item flex flex-nowrap items-center gap-2 rounded-lg cursor-pointer px-2 h-8 text-sm font-book outline-none select-none whitespace-nowrap",
+ "data-highlighted:text-ink-primary data-highlighted:bg-ink-primary/5",
"data-disabled:pointer-events-none data-disabled:opacity-50",
"[&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-3.5 [&_svg]:shrink-0 data-highlighted:[&_svg]:text-ink-primary",
className,
@@ -251,11 +249,11 @@ function DropdownMenuSwitchItem({
{children}
@@ -266,7 +264,7 @@ function DropdownMenuSeparator({ className, ...props }: MenuPrimitive.Separator.
return (
);
@@ -276,10 +274,7 @@ function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"spa
return (
);
diff --git a/components/ui/hover-card.tsx b/components/ui/hover-card.tsx
index 1f6689e..4802e39 100644
--- a/components/ui/hover-card.tsx
+++ b/components/ui/hover-card.tsx
@@ -33,7 +33,7 @@ function HoverCardContent({
) {
data-slot="input-group"
role="group"
className={cn(
- "border-input dark:bg-input/30 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-disabled:bg-input/50 dark:has-disabled:bg-input/80 h-8 rounded-lg border transition-colors in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-3 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5 group/input-group relative flex w-full min-w-0 items-center outline-none has-[>textarea]:h-auto",
+ "h-8 rounded-[7px] bg-primary-bg shadow-card transition-shadow has-[[data-slot=input-group-control]:focus-visible]:outline-2 has-[[data-slot=input-group-control]:focus-visible]:outline-accent-bg/60 has-[[data-slot=input-group-control]:focus-visible]:outline-offset-1 has-[[data-slot][aria-invalid=true]]:outline-2 has-[[data-slot][aria-invalid=true]]:outline-red-500/60 has-disabled:opacity-50 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5 group/input-group relative flex w-full min-w-0 items-center outline-none has-[>textarea]:h-auto",
className,
)}
{...props}
@@ -21,7 +21,7 @@ function InputGroupRoot({ className, ...props }: React.ComponentProps<"div">) {
}
const inputGroupAddonVariants = cva(
- "text-muted-foreground h-auto gap-2 py-1.5 text-sm font-medium group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4 flex cursor-text items-center justify-center select-none",
+ "text-ink-secondary h-auto gap-2 py-1.5 text-sm font-medium group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4 flex cursor-text items-center justify-center select-none",
{
variants: {
align: {
@@ -65,7 +65,7 @@ function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
) {
type={type}
data-slot="input"
className={cn(
- "dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-8 rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors file:h-6 file:text-sm file:font-medium focus-visible:ring-3 aria-invalid:ring-3 md:text-sm file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
+ // Fields are rounded squares with the crisp card ring.
+ "h-8 w-full min-w-0 rounded-[7px] bg-primary-bg px-2.5 py-1 text-base text-ink-primary shadow-card transition-shadow md:text-sm",
+ "placeholder:text-ink-tertiary",
+ "focus-visible:outline-2 focus-visible:outline-accent-bg/60 focus-visible:outline-offset-1",
+ "aria-invalid:outline-2 aria-invalid:outline-red-500/60",
+ "file:inline-flex file:h-6 file:border-0 file:bg-transparent file:font-medium file:text-ink-primary file:text-sm",
+ "disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
diff --git a/components/ui/kbd.tsx b/components/ui/kbd.tsx
index 6a538e6..aba7820 100644
--- a/components/ui/kbd.tsx
+++ b/components/ui/kbd.tsx
@@ -5,17 +5,18 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const kbdVariants = cva(
- "flex shrink-0 select-none items-center justify-center rounded-sm border font-medium font-sans leading-none [&_svg]:stroke-[2.5]",
+ "flex shrink-0 select-none items-center justify-center font-medium font-mono leading-none [&_svg]:stroke-[2.5]",
{
variants: {
size: {
- sm: "h-4 min-w-4 px-1 text-2xs [&>svg]:size-2.5",
- md: "h-5 min-w-5 px-1.5 text-xs [&>svg]:size-3",
- lg: "h-6 min-w-6 px-1.5 text-sm [&>svg]:size-3.5",
+ sm: "h-4 min-w-4 rounded-[4px] px-1 text-[10px] [&>svg]:size-2.5",
+ md: "h-5 min-w-5 rounded-[5px] px-1.5 text-2xs [&>svg]:size-3",
+ lg: "h-6 min-w-6 rounded-[6px] px-1.5 text-xs [&>svg]:size-3.5",
},
variant: {
- default: "border-secondary-border bg-secondary-bg text-ink-secondary",
- frosted: "border-white/15 bg-white/25 text-white backdrop-blur-sm",
+ // A small raised key, same lighting as buttons.
+ default: "bg-raised text-ink-secondary shadow-raised",
+ frosted: "border border-white/15 bg-white/25 text-white backdrop-blur-sm",
},
square: {
true: "",
diff --git a/components/ui/markdown.tsx b/components/ui/markdown.tsx
index 31cf575..1f0ca94 100644
--- a/components/ui/markdown.tsx
+++ b/components/ui/markdown.tsx
@@ -28,7 +28,7 @@ const CitationBadge = ({ href }: { href: string }) => {
href={href}
target="_blank"
rel="noopener noreferrer"
- className="inline-flex items-center gap-1 rounded-xs bg-primary-bg-hover px-0.5 h-lh text-xs text-ink-secondary no-underline transition-colors hover:bg-primary-bg-hover hover:text-ink-primary"
+ className="inline-flex h-lh items-center gap-1 rounded-full bg-ink-primary/6 px-1.5 text-ink-secondary text-xs no-underline transition-colors hover:bg-ink-primary/10 hover:text-ink-primary"
>
*:first-child]:mt-0 [&>*:last-child]:mb-0",
+ "text-md leading-6 [&_p]:whitespace-pre-wrap [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
className,
)}
{...props}
diff --git a/components/ui/popover.tsx b/components/ui/popover.tsx
index da153cd..d551b1e 100644
--- a/components/ui/popover.tsx
+++ b/components/ui/popover.tsx
@@ -34,7 +34,7 @@ const PopoverContent = ({
(
-
+
)}
diff --git a/components/ui/scaler.tsx b/components/ui/scaler.tsx
deleted file mode 100644
index fd55ed7..0000000
--- a/components/ui/scaler.tsx
+++ /dev/null
@@ -1,93 +0,0 @@
-"use client";
-
-import { Slider as SliderPrimitive } from "@base-ui/react/slider";
-
-import { cn } from "@/lib/utils";
-
-type ScalerProps = {
- value: number;
- onValueChange: (value: number) => void;
- min: number;
- max: number;
- step: number;
- tickStep?: number;
- disabled?: boolean;
- size?: "default" | "compact";
- className?: string;
-};
-
-const scalerSizes = {
- default: "box-border h-9 w-full",
- compact:
- "box-border m-0 h-8 w-28 shrink-0 overflow-hidden rounded-md border border-primary-border p-0",
-} as const;
-
-export const Scaler = ({
- value,
- onValueChange,
- min,
- max,
- step,
- tickStep,
- disabled,
- size = "default",
- className,
-}: ScalerProps) => {
- const tickCount = !tickStep ? 0 : Math.max(0, Math.floor((max - min) / tickStep) - 1);
-
- const handleValueChange = (next: number | readonly number[]) => {
- const value = typeof next === "number" ? next : next[0];
- onValueChange(value);
- };
-
- return (
-
-
-
- {tickCount > 0 && (
-
- {Array.from({ length: tickCount }, (_, i) => (
-
- ))}
-
- )}
-
-
-
-
-
-
- {value.toFixed(2)}
-
-
-
- );
-};
diff --git a/components/ui/select.tsx b/components/ui/select.tsx
index 930af61..e241114 100644
--- a/components/ui/select.tsx
+++ b/components/ui/select.tsx
@@ -1,45 +1,38 @@
"use client";
import { Select as SelectPrimitive } from "@base-ui/react/select";
+import { Check, ChevronDown, ChevronsUpDown, ChevronUp } from "@keyline-icons/react";
import { cva, type VariantProps } from "class-variance-authority";
import type * as React from "react";
import { cn } from "@/lib/utils";
-import { CheckMarkMediumIcon } from "../icons/check-mark-medium";
-import { ChevronDownMediumIcon } from "../icons/chevron-down-medium";
-import { ChevronGrabberVerticalIcon } from "../icons/chevron-grabber-vertical";
-import { ChevronTopMediumIcon } from "../icons/chevron-top-medium";
const selectTriggerVariants = cva(
[
// Layout
"flex w-fit items-center justify-between gap-1.5",
"whitespace-nowrap",
- // Base styles
- "rounded-lg border text-sm",
- "cursor-pointer select-none outline-none",
- "transition-colors",
+ // Base styles: fields are rounded squares, not pills
+ "rounded-[7px] text-sm text-ink-primary",
+ "cursor-pointer select-none",
+ "transition-[background-color,box-shadow]",
// Focus states
- "focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50",
- // Invalid states
- "aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20",
- "dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
+ "focus-visible:outline-2 focus-visible:outline-accent-bg/60 focus-visible:outline-offset-2",
// Disabled state
"disabled:cursor-not-allowed disabled:opacity-50",
// Placeholder styling
- "data-placeholder:text-muted-foreground",
+ "data-placeholder:text-ink-tertiary",
// Select value (child) styling
"*:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center",
"*:data-[slot=select-value]:gap-1.5 *:data-[slot=select-value]:line-clamp-1",
// Select icon (child) styling
"*:data-[slot=select-icon]:pointer-events-none *:data-[slot=select-icon]:shrink-0",
- "*:data-[slot=select-icon]:text-muted-foreground",
+ "*:data-[slot=select-icon]:text-ink-tertiary",
],
{
variants: {
variant: {
- primary:
- "border-border bg-primary-bg hover:bg-primary-bg-hover hover:border-primary-border-hover aria-expanded:bg-muted aria-expanded:text-foreground",
- ghost: "border-transparent hover:bg-primary-bg-hover aria-expanded:bg-primary-bg-hover",
+ primary: "bg-raised shadow-raised hover:bg-raised-hover aria-expanded:bg-raised-hover",
+ ghost: "hover:bg-ink-primary/5 aria-expanded:bg-ink-primary/5",
},
size: {
sm: "h-8 pr-2 pl-2.5 text-sm *:data-[slot=select-value]:text-sm *:data-[slot=select-icon]:size-3.5",
@@ -80,7 +73,7 @@ function SelectTrigger({
{...props}
>
{children}
-
} />
+
} />
);
}
@@ -118,8 +111,8 @@ function SelectContent({
"relative isolate z-50 min-w-36 origin-(--transform-origin) max-h-(--available-height)",
// "w-(--anchor-width)",
// Styling
- "rounded-lg p-1 shadow-md",
- "bg-primary-bg text-ink-primary border border-primary-border",
+ "rounded-xl p-1 shadow-overlay",
+ "bg-primary-bg text-ink-primary",
// Overflow
"overflow-x-hidden overflow-y-auto",
// Animation base
@@ -164,7 +157,7 @@ const SelectLabel = ({ className, ...props }: SelectPrimitive.GroupLabel.Props)
return (
);
@@ -175,10 +168,9 @@ const SelectItem = ({ className, children, ...props }: SelectPrimitive.Item.Prop
-
+
@@ -207,7 +199,7 @@ const SelectSeparator = ({ className, ...props }: SelectPrimitive.Separator.Prop
return (
);
@@ -226,7 +218,7 @@ const SelectScrollUpButton = ({
"top-0 z-10 flex w-full items-center justify-center",
"py-1",
// Styling
- "cursor-default hover:bg-primary-bg-hover",
+ "cursor-default hover:bg-ink-primary/5",
// SVG sizing
"[&_svg:not([class*='size-'])]:size-4",
],
@@ -234,7 +226,7 @@ const SelectScrollUpButton = ({
)}
{...props}
>
-
+
);
};
@@ -251,7 +243,7 @@ const SelectScrollDownButton = ({
"bottom-0 z-10 flex w-full items-center justify-center",
"py-1",
// Styling
- "cursor-default hover:bg-primary-bg-hover",
+ "cursor-default hover:bg-ink-primary/5",
// SVG sizing
"[&_svg:not([class*='size-'])]:size-4",
@@ -259,7 +251,7 @@ const SelectScrollDownButton = ({
)}
{...props}
>
-
+
);
};
diff --git a/components/ui/separator.tsx b/components/ui/separator.tsx
index e91bc00..6f6692d 100644
--- a/components/ui/separator.tsx
+++ b/components/ui/separator.tsx
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
-const separatorVariants = cva("bg-slate-7 shrink-0", {
+const separatorVariants = cva("bg-ink-primary/8 shrink-0", {
variants: {
type: {
line: "data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px data-[orientation=vertical]:self-stretch",
diff --git a/components/ui/settings.tsx b/components/ui/settings.tsx
index 1297ce7..ffc77f9 100644
--- a/components/ui/settings.tsx
+++ b/components/ui/settings.tsx
@@ -41,7 +41,7 @@ const SettingsSubtitle = ({ className, children, ...props }: ComponentProps<"p">
const SettingsCard = ({ className, children, ...props }: ComponentProps<"div">) => (
{children}
@@ -52,7 +52,7 @@ const SettingsRow = ({ className, children, ...props }: ComponentProps<"div">) =
) =
);
const SettingsLabel = ({ className, children, ...props }: ComponentProps<"span">) => (
-
+
{children}
);
diff --git a/components/ui/sidebar.tsx b/components/ui/sidebar.tsx
index 39b18b1..636e986 100644
--- a/components/ui/sidebar.tsx
+++ b/components/ui/sidebar.tsx
@@ -1,24 +1,26 @@
"use client";
import { useRender } from "@base-ui/react/use-render";
-import { SHELL_SIDEBAR_WIDTH_VAR, Shell, useShell } from "@intentface/chat/shell";
+import { Shell, useShell } from "@intentface/chat/shell";
+import { PanelLeft } from "@keyline-icons/react";
import { cva, type VariantProps } from "class-variance-authority";
+import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
type ComponentProps,
- type CSSProperties,
createContext,
+ type ReactNode,
+ useCallback,
useContext,
useEffect,
useMemo,
+ useRef,
useState,
} from "react";
-import { SidebarIcon } from "@/components/icons/sidebar";
import Drawer from "@/components/ui/drawer";
import Input from "@/components/ui/input";
import Separator from "@/components/ui/separator";
import Tooltip from "@/components/ui/tooltip";
import { useIsMobile } from "@/hooks/use-mobile";
-import { writeSidebarLayout } from "@/lib/sidebar-cookie";
import { cn } from "@/lib/utils";
import { IconButton } from "./icon-button";
@@ -72,17 +74,13 @@ const useSidebar = () => {
const SidebarProvider = ({
defaultOpen = true,
- width,
open,
onOpenChange,
className,
children,
...props
}: ComponentProps<"div"> & {
- /** Read it from the request with `readSidebarLayout` so the first paint is already right. */
defaultOpen?: boolean;
- /** A restored width, applied as the custom property the sidebar's `width` reads. */
- width?: number;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) => {
@@ -99,20 +97,8 @@ const SidebarProvider = ({
{
- onOpenChange?.(next);
- writeSidebarLayout({ open: next });
- }}
+ onOpenChange={onOpenChange}
data-slot="sidebar-wrapper"
- // A restored width goes back the way the grip writes it: the
- // custom property. There is no prop for it, because the width is CSS's.
- style={
- width === undefined
- ? undefined
- : ({ [SHELL_SIDEBAR_WIDTH_VAR]: `${width}px` } as CSSProperties)
- }
className={cn("group/sidebar-wrapper flex min-h-svh w-full bg-base-bg", className)}
{...props}
>
@@ -204,29 +190,27 @@ const SidebarRoot = ({
{/* Shell.Sidebar brings the hotspot hold/release pointer handlers with it. */}
writeSidebarLayout({ width: next })}
data-slot="sidebar"
className={cn(
- "fixed z-10 hidden w-(--sidebar-width) border border-transparent bg-base-bg overflow-hidden transition-[left,right,top,bottom,border-color,border-radius,box-shadow] duration-150 ease-linear motion-reduce:transition-none md:flex",
+ "fixed z-30 hidden w-(--sidebar-width) border border-transparent bg-base-bg overflow-hidden transition-[left,right,top,bottom,border-color,border-radius,box-shadow] duration-150 ease-linear motion-reduce:transition-none md:flex",
side === "left"
? cn(
"left-0 inset-y-0",
"group-data-[state=collapsed]:-left-(--sidebar-width)",
"group-data-[state=collapsed]:group-data-hotspot:left-2",
- "group-data-[state=collapsed]:inset-y-2 group-data-[state=collapsed]:rounded-xl group-data-[state=collapsed]:border-secondary-border",
- "group-data-hotspot:shadow-lg",
+ "group-data-[state=collapsed]:inset-y-2 group-data-[state=collapsed]:rounded-xl",
+ "group-data-hotspot:shadow-overlay",
)
: "right-0 inset-y-0 group-data-[state=collapsed]:-right-(--sidebar-width)",
className,
)}
{...props}
>
-
+ {/* Only a sliver of right padding while flush: the viewport's own p-2
+ supplies most of the seam, so the two add up to the 12px left edge.
+ The floating card pads all four sides, because then it has edges of
+ its own. */}
+
{children}
@@ -238,7 +222,7 @@ const SidebarRoot = ({
{side === "left" && (
)}
@@ -252,10 +236,15 @@ const SidebarTrigger = ({ className, onClick, ...props }: ComponentProps<"button
-
+
Toggle Sidebar
);
@@ -271,10 +260,10 @@ const SidebarTrigger = ({ className, onClick, ...props }: ComponentProps<"button
toggleSidebar();
}}
variant="ghost"
- className={cn("hover:bg-base-bg-hover", className)}
+ className={cn("rounded-md", className)}
{...props}
>
-
+
Toggle Sidebar
) : (
@@ -305,8 +294,12 @@ const SidebarViewport = ({ className, children, ...props }: ComponentProps<"div"
) => {
- return (
-
- );
+ return
;
};
const SidebarHeader = ({ className, ...props }: ComponentProps<"div">) => {
return (
);
@@ -346,11 +331,111 @@ const SidebarFooter = ({ className, ...props }: ComponentProps<"div">) => {
const SidebarSeparator = ({ className, ...props }: ComponentProps
) => {
return (
-
+
+ );
+};
+
+/**
+ * Navs that replace one another in the sidebar, shaped like Tabs: the root's
+ * `value` names the view shown and each `Sidebar.View` declares its own. A view
+ * declared later slides in from the right, an earlier one from the left.
+ */
+type SidebarViewsDirection = "forward" | "backward";
+
+type SidebarViewsState = {
+ value: string;
+ direction: SidebarViewsDirection;
+ register: (value: string) => void;
+};
+
+const SidebarViewsContext = createContext(null);
+
+const useSidebarViews = () => {
+ const context = useContext(SidebarViewsContext);
+ if (!context) throw new Error("Sidebar.View must be rendered inside Sidebar.Views");
+ return context;
+};
+
+const SidebarViews = ({
+ value,
+ className,
+ ...props
+}: ComponentProps<"div"> & {
+ /** The `value` of the view on screen. */
+ value: string;
+}) => {
+ // Declaration order, as the views first render: there is no list to read it from.
+ const order = useRef([]);
+ const [shown, setShown] = useState<{ value: string; direction: SidebarViewsDirection }>({
+ value,
+ direction: "forward",
+ });
+
+ // Decided while rendering, so the leaving and arriving views agree on the direction.
+ if (shown.value !== value) {
+ const from = order.current.indexOf(shown.value);
+ const to = order.current.indexOf(value);
+ setShown({ value, direction: to < from ? "backward" : "forward" });
+ }
+
+ const register = useCallback((view: string) => {
+ if (!order.current.includes(view)) order.current.push(view);
+ }, []);
+
+ return (
+
+
+
+ );
+};
+
+const VIEW_TRANSITION = { duration: 0.22, ease: [0.22, 1, 0.36, 1] } as const;
+
+const SidebarView = ({
+ value,
+ className,
+ children,
+}: {
+ value: string;
+ className?: string;
+ children?: ReactNode;
+}) => {
+ const { value: shown, direction, register } = useSidebarViews();
+ register(value);
+
+ // Reduced motion cross-fades instead: a lateral slide is what it asks to avoid.
+ const reduceMotion = useReducedMotion();
+ const offset = (side: 1 | -1) =>
+ reduceMotion ? { opacity: 0 } : { opacity: 0, x: `${side * 100}%` };
+ const variants = {
+ enter: (to: SidebarViewsDirection) => offset(to === "forward" ? 1 : -1),
+ shown: { opacity: 1, x: 0 },
+ exit: (to: SidebarViewsDirection) => offset(to === "forward" ? -1 : 1),
+ };
+
+ return (
+ // Each view owns its presence, so the leaving one still reads the direction.
+
+ {shown === value ? (
+
+ {children}
+
+ ) : null}
+
);
};
@@ -358,7 +443,13 @@ const SidebarContent = ({ className, ...props }: ComponentProps<"div">) => {
return (
);
@@ -382,7 +473,7 @@ const SidebarGroupLabel = ({ className, render, ...props }: useRender.ComponentP
"data-slot": "sidebar-group-label",
...props,
className: cn(
- "text-ink-tertiary flex shrink-0 items-center rounded-md px-2 py-1.5 text-sm font-medium outline-hidden focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
+ "text-ink-secondary flex h-7 shrink-0 items-center rounded-md px-2 text-xs font-medium focus-visible:outline-2 focus-visible:outline-accent-bg/60 [&>svg]:size-4 [&>svg]:shrink-0",
className,
),
},
@@ -401,7 +492,7 @@ const SidebarGroupAction = ({
"data-slot": "sidebar-group-action",
...props,
className: cn(
- "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
+ "text-ink-secondary hover:bg-ink-primary/5 hover:text-ink-primary absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 transition-transform focus-visible:outline-2 focus-visible:outline-accent-bg/60 [&>svg]:size-4 [&>svg]:shrink-0",
"after:absolute after:-inset-2 after:md:hidden",
className,
),
@@ -416,7 +507,7 @@ const SidebarGroupContent = ({ className, ...props }: ComponentProps<"div">) =>
const SidebarMenu = ({ className, ...props }: ComponentProps<"ul">) => (
);
@@ -432,13 +523,15 @@ const SidebarMenuItem = ({ className, ...props }: ComponentProps<"li">) => (
const sidebarMenuButtonVariants = cva(
[
"peer/menu-button cursor-pointer flex w-full items-center gap-2",
- "overflow-hidden rounded-md px-2 h-8 font-medium text-left text-md/none text-ink-secondary outline-hidden",
- "focus-visible:ring-1",
- "hover:bg-base-bg-hover hover:text-ink-primary data-active:bg-base-bg-hover data-active:text-ink-primary",
+ "overflow-hidden rounded-md px-2 h-[30px] font-medium text-left text-sm/none text-ink-body",
+ "focus-visible:outline-2 focus-visible:outline-accent-bg/60 focus-visible:-outline-offset-2",
+ // Hover is a faint wash; the active row rises like a button.
+ "hover:bg-ink-primary/5 hover:text-ink-primary",
+ "data-active:bg-raised-selected data-active:text-ink-primary data-active:shadow-raised",
"disabled:pointer-events-none disabled:opacity-50",
"group-has-data-[slot=sidebar-menu-action]/menu-item:pr-1",
"aria-disabled:pointer-events-none aria-disabled:opacity-50",
- "data-popup-open:bg-base-bg-hover data-popup-open:text-ink-primary",
+ "data-popup-open:bg-ink-primary/5 data-popup-open:text-ink-primary",
"[&>span:last-child]:truncate [&_svg]:text-ink-secondary [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:pointer-events-none hover:[&>svg]:text-ink-primary data-active:[&>svg]:text-ink-primary",
],
{
@@ -524,7 +617,7 @@ const SidebarMenuAction = ({
onClick: handleClick,
...props,
className: cn(
- "hover:bg-primary-bg-hover text-ink-secondary cursor-pointer hover:text-ink-primary flex aspect-square size-5 items-center justify-center rounded-sm outline-hidden focus-visible:ring-1 [&>svg]:size-3.5 [&>svg]:shrink-0",
+ "hover:bg-ink-primary/6 text-ink-secondary cursor-pointer hover:text-ink-primary flex aspect-square size-5 items-center justify-center rounded-sm focus-visible:outline-2 focus-visible:outline-accent-bg/60 [&>svg]:size-3.5 [&>svg]:shrink-0",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-popup-open:opacity-100 data-pressed:opacity-100 md:opacity-0",
className,
@@ -537,8 +630,8 @@ const SidebarMenuBadge = ({ className, ...props }: ComponentProps<"div">) => (
) => (
svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
- "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
+ "text-ink-body hover:bg-ink-primary/5 hover:text-ink-primary active:bg-ink-primary/8 [&>svg]:text-ink-secondary flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 focus-visible:outline-2 focus-visible:outline-accent-bg/60 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
+ "data-[active=true]:bg-raised-selected data-[active=true]:text-ink-primary data-[active=true]:shadow-raised",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
@@ -613,6 +706,8 @@ const Sidebar = Object.assign(SidebarRoot, {
Provider: SidebarProvider,
Separator: SidebarSeparator,
Trigger: SidebarTrigger,
+ View: SidebarView,
+ Views: SidebarViews,
});
export { useSidebar, Sidebar };
diff --git a/components/ui/skeleton.tsx b/components/ui/skeleton.tsx
index a9ea8e6..bd92365 100644
--- a/components/ui/skeleton.tsx
+++ b/components/ui/skeleton.tsx
@@ -4,7 +4,7 @@ const Skeleton = ({ className, ...props }: React.ComponentProps<"div">) => {
return (
);
diff --git a/components/ui/slider.tsx b/components/ui/slider.tsx
index 577240d..ca6e1a9 100644
--- a/components/ui/slider.tsx
+++ b/components/ui/slider.tsx
@@ -24,7 +24,7 @@ const SliderTrack = ({
...props
}: React.ComponentProps) => (
);
@@ -42,8 +42,8 @@ const SliderThumb = ({
}: React.ComponentProps) => (
(
data-slot="switch"
className={cn(
"inline-flex h-4 w-7 shrink-0 cursor-pointer items-center rounded-full px-0.5 transition-colors",
- "bg-base-bg-active data-checked:bg-accent-bg",
- "focus-visible:ring-2 focus-visible:ring-accent-bg/50 focus-visible:outline-none",
+ "bg-ink-primary/15 data-checked:bg-accent-bg",
+ "focus-visible:outline-2 focus-visible:outline-accent-bg/60 focus-visible:outline-offset-2",
"disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
@@ -18,7 +18,8 @@ const Switch = ({ className, ...props }: SwitchPrimitive.Root.Props) => (
>
);
diff --git a/components/ui/textarea.tsx b/components/ui/textarea.tsx
index c44cd47..cab6f8e 100644
--- a/components/ui/textarea.tsx
+++ b/components/ui/textarea.tsx
@@ -7,7 +7,11 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {