diff --git a/src/app/(rest)/blog/[slug]/page.tsx b/src/app/(rest)/blog/[slug]/page.tsx index ce1edbca..e2ac0797 100644 --- a/src/app/(rest)/blog/[slug]/page.tsx +++ b/src/app/(rest)/blog/[slug]/page.tsx @@ -61,27 +61,28 @@ export default async function Page({ params }: Props) { className="object-cover object-middle" /> -
-

{formatDate(metadata.date)}

-

- by{' '} - - {metadata.author.name} - -

-

{metadata.category}

-
-
+

{metadata.title}

{metadata.subtitle ?

{metadata.subtitle}

: null} +
+ {formatDate(metadata.date)} + + + {metadata.author.name} + + + {metadata.category} +
- +
+ +
- +
diff --git a/src/app/(rest)/blog/page.tsx b/src/app/(rest)/blog/page.tsx index 319f58c8..218edc95 100644 --- a/src/app/(rest)/blog/page.tsx +++ b/src/app/(rest)/blog/page.tsx @@ -1,4 +1,7 @@ +import Link from 'next/link' +import { CATEGORIES, type Category } from '~/lib/constants/blog' import { META } from '~/lib/constants/metadata' +import { cn } from '~/lib/utils/cn' import { createMetadata } from '~/lib/utils/create-metadata' import { getPostMetadata } from '~/lib/utils/posts' import { Card } from '~/ui/card' @@ -9,17 +12,87 @@ export const metadata = createMetadata({ title: `Blog | ${META.title}` }) -export default async function Page() { +type BlogSearchParams = Promise> + +const CATEGORY_SLUG_BY_LABEL = Object.fromEntries( + Object.values(CATEGORIES).map(category => [category, category.toLowerCase().replaceAll(' ', '-')]) +) as Record + +const CATEGORY_LABEL_BY_SLUG = Object.fromEntries( + Object.entries(CATEGORY_SLUG_BY_LABEL).map(([category, slug]) => [slug, category]) +) as Record + +const getSelectedCategories = (categoryParam: string | string[] | undefined) => { + const selectedSlugs = Array.isArray(categoryParam) + ? categoryParam.flatMap(category => category.split(',')) + : categoryParam + ? categoryParam.split(',') + : [] + + return selectedSlugs + .map(slug => CATEGORY_LABEL_BY_SLUG[slug]) + .filter((category): category is Category => Boolean(category)) +} + +const getCategoryHref = (category: Category, selectedCategories: Category[]) => { + const nextCategories = selectedCategories.includes(category) + ? selectedCategories.filter(selectedCategory => selectedCategory !== category) + : [...selectedCategories, category] + + if (nextCategories.length === 0) return '/blog' + + const params = new URLSearchParams() + params.set( + 'category', + nextCategories.map(nextCategory => CATEGORY_SLUG_BY_LABEL[nextCategory]).join(',') + ) + + return `/blog?${params.toString()}` +} + +export default async function Page({ searchParams }: { searchParams: BlogSearchParams }) { const posts = await getPostMetadata() + const resolvedSearchParams = await searchParams + const selectedCategories = getSelectedCategories(resolvedSearchParams.category) + const categories = Object.values(CATEGORIES).filter(category => + posts.some(post => post.category === category) + ) + const filteredPosts = + selectedCategories.length > 0 + ? posts.filter(post => selectedCategories.includes(post.category)) + : posts return (
-
-

Blog

-

The latest from our team

+
+
+

Blog

+

The latest from our team

+
+
+ {categories.map(category => { + const isSelected = selectedCategories.includes(category) + + return ( + + {category} + + ) + })} +
- {posts.map((post, index) => ( + {filteredPosts.map((post, index) => ( ))}
+ {filteredPosts.length === 0 ? ( +

No posts match the selected filters.

+ ) : null}
) } diff --git a/src/app/api/blog/challenging-the-chatbot/transaction-filters/route.ts b/src/app/api/blog/challenging-the-chatbot/transaction-filters/route.ts new file mode 100644 index 00000000..bf44be05 --- /dev/null +++ b/src/app/api/blog/challenging-the-chatbot/transaction-filters/route.ts @@ -0,0 +1,334 @@ +import { NextResponse } from 'next/server' +import { env } from '~/lib/env' + +export const dynamic = 'force-dynamic' + +const MODEL = 'meta-llama/llama-3.1-8b-instruct' + +const AMOUNT_OPERATORS = ['gt', 'gte', 'lt', 'lte', 'eq'] as const +const POLICY_VALUES = ['Approved', 'Needs review', 'Out of policy'] as const + +type AmountOperator = (typeof AMOUNT_OPERATORS)[number] +type PolicyValue = (typeof POLICY_VALUES)[number] + +type AmountFilter = { + operator: AmountOperator + value: number +} + +type RequestMetrics = { + costUsd?: number + latencyMs: number + tokens?: { + completion?: number + prompt?: number + total?: number + } +} + +export type TransactionFilterState = { + amount?: AmountFilter + merchant?: string + department?: string + policy?: PolicyValue +} + +const isOneOf = (values: T, value: unknown): value is T[number] => + typeof value === 'string' && values.includes(value) + +const sanitizeString = (value: unknown) => { + if (typeof value !== 'string') return undefined + const trimmed = value.trim() + return trimmed || undefined +} + +const sanitizeFilters = (value: unknown): TransactionFilterState => { + if (!value || typeof value !== 'object') return {} + const candidate = value as Record + const filters: TransactionFilterState = {} + const amount = candidate.amount + + if (amount && typeof amount === 'object') { + const amountCandidate = amount as Record + const numericValue = + typeof amountCandidate.value === 'number' ? amountCandidate.value : Number(amountCandidate.value) + if (isOneOf(AMOUNT_OPERATORS, amountCandidate.operator) && Number.isFinite(numericValue)) { + filters.amount = { + operator: amountCandidate.operator, + value: numericValue + } + } + } + + const merchant = sanitizeString(candidate.merchant) + const department = sanitizeString(candidate.department) + if (merchant) filters.merchant = merchant + if (department) filters.department = department + if (isOneOf(POLICY_VALUES, candidate.policy)) filters.policy = candidate.policy + + return filters +} + +const parseDollarAmount = (input: string) => { + const match = input.match(/\$?\s*(\d+(?:,\d{3})*(?:\.\d+)?|\d+(?:\.\d+)?)\s*(k)?/i) + if (!match?.[1]) return undefined + const base = Number(match[1].replaceAll(',', '')) + if (!Number.isFinite(base)) return undefined + return match[2]?.toLowerCase() === 'k' ? base * 1000 : base +} + +const inputMentionsAmount = (normalized: string) => + /(large|expensive|small|over|above|greater than|more than|>\s*|at least|minimum|min|>=|under|below|less than|<\s*|at most|maximum|max|<=|equal|equals|exactly|=|\$?\s*\d)/.test( + normalized + ) + +const inputMentionsPolicy = (normalized: string) => + /(out of policy|violation|violates|approved|ok|accepted|need(s)? review|review|unapproved|approval)/.test( + normalized + ) + +const inputMentionsDepartment = (normalized: string, department: string) => + normalized.includes(department.toLowerCase()) + +const inputMentionsMerchant = (normalized: string, merchant: string) => { + const normalizedMerchant = merchant.toLowerCase() + if (!normalizedMerchant) return false + if ( + ['software', 'purchase', 'purchases', 'transaction', 'transactions'].includes(normalizedMerchant) + ) { + return false + } + return normalized.includes(normalizedMerchant) +} + +const removeUnsupportedFilters = (input: string, filters: TransactionFilterState) => { + const normalized = input.toLowerCase() + const cleaned: TransactionFilterState = {} + + if (filters.amount && inputMentionsAmount(normalized)) cleaned.amount = filters.amount + if (filters.merchant && inputMentionsMerchant(normalized, filters.merchant)) { + cleaned.merchant = filters.merchant + } + if (filters.department && inputMentionsDepartment(normalized, filters.department)) { + cleaned.department = filters.department + } + if (filters.policy && inputMentionsPolicy(normalized)) cleaned.policy = filters.policy + + return cleaned +} + +const mergeWithDeterministicFilters = ( + input: string, + filters: TransactionFilterState +): TransactionFilterState => { + const deterministicFilters = fallbackParse(input) + const mergedFilters: TransactionFilterState = { + ...deterministicFilters, + ...filters + } + const amount = filters.amount ?? deterministicFilters.amount + + if (amount) mergedFilters.amount = amount + else delete mergedFilters.amount + + return mergedFilters +} + +const fallbackParse = (input: string): TransactionFilterState => { + const normalized = input.toLowerCase() + const filters: TransactionFilterState = {} + const amount = parseDollarAmount(normalized) + + if (amount !== undefined) { + if (/(at least|minimum|min|>=|greater than or equal|not less than)/.test(normalized)) { + filters.amount = { operator: 'gte', value: amount } + } else if (/(at most|maximum|max|<=|less than or equal|not more than)/.test(normalized)) { + filters.amount = { operator: 'lte', value: amount } + } else if (/(over|above|greater than|more than|>\s*)/.test(normalized)) { + filters.amount = { operator: 'gt', value: amount } + } else if (/(under|below|less than|<\s*)/.test(normalized)) { + filters.amount = { operator: 'lt', value: amount } + } else if (/(equal|equals|exactly|=)/.test(normalized)) { + filters.amount = { operator: 'eq', value: amount } + } + } else if (/large|expensive/.test(normalized)) { + filters.amount = { operator: 'gt', value: 1000 } + } else if (/small/.test(normalized)) { + filters.amount = { operator: 'lt', value: 1000 } + } + + if (normalized.includes('aws')) filters.merchant = 'AWS' + else if (normalized.includes('openai') || normalized.includes('open ai')) + filters.merchant = 'OpenAI' + else if (normalized.includes('figma')) filters.merchant = 'Figma' + else if (normalized.includes('delta')) filters.merchant = 'Delta' + + if (normalized.includes('engineering')) filters.department = 'Engineering' + else if (normalized.includes('sales')) filters.department = 'Sales' + else if (normalized.includes('operations')) filters.department = 'Operations' + else if (normalized.includes('design')) filters.department = 'Design' + + if (/out of policy|violation|violates/.test(normalized)) { + filters.policy = 'Out of policy' + } else if (/approved|ok|accepted/.test(normalized)) { + filters.policy = 'Approved' + } else if (/need(s)? review|review|unapproved|approval/.test(normalized)) { + filters.policy = 'Needs review' + } + + return filters +} + +const extractJson = (content: string): unknown => { + try { + return JSON.parse(content) + } catch { + const match = content.match(/\{[\s\S]*\}/) + if (!match) return undefined + try { + return JSON.parse(match[0]) + } catch { + return undefined + } + } +} + +const readNumber = (value: unknown) => { + const numericValue = typeof value === 'number' ? value : Number(value) + return Number.isFinite(numericValue) ? numericValue : undefined +} + +const getMetrics = (startedAt: number, usage: unknown): RequestMetrics => { + const metrics: RequestMetrics = { + latencyMs: Math.max(0, Math.round(performance.now() - startedAt)) + } + if (!usage || typeof usage !== 'object') return metrics + + const usageRecord = usage as Record + const prompt = readNumber(usageRecord.prompt_tokens) + const completion = readNumber(usageRecord.completion_tokens) + const total = readNumber(usageRecord.total_tokens) + const cost = readNumber(usageRecord.cost) ?? readNumber(usageRecord.total_cost) + + if (prompt !== undefined || completion !== undefined || total !== undefined) { + const tokens: NonNullable = {} + if (completion !== undefined) tokens.completion = completion + if (prompt !== undefined) tokens.prompt = prompt + if (total !== undefined) tokens.total = total + metrics.tokens = tokens + } + if (cost !== undefined) metrics.costUsd = cost + + return metrics +} + +export async function POST(request: Request) { + const body = (await request.json()) as { input?: unknown } + const input = typeof body.input === 'string' ? body.input.trim() : '' + + if (!input) return NextResponse.json({ filters: {}, metrics: { latencyMs: 0 }, source: 'empty' }) + + if (!env.OPENROUTER_API_KEY) { + return NextResponse.json({ + filters: fallbackParse(input), + metrics: { latencyMs: 0 }, + source: 'fallback' + }) + } + + const startedAt = performance.now() + const response = await fetch('https://openrouter.ai/api/v1/chat/completions', { + body: JSON.stringify({ + max_tokens: 80, + messages: [ + { + content: + 'You are a strict transaction filter extractor. Output a JSON object with ONLY filters explicitly named or directly requested. Do not infer hidden relationships. Do not add defaults. If a word is not an explicit filter, ignore it. Schema: amount is an object {operator,value}; operators are gt, gte, lt, lte, eq. merchant is an open-ended string. department is a department string. policy is one of Approved, Needs review, Out of policy. Amount rules: over/above/greater than/more than/> => gt; at least/minimum/>= => gte; under/below/less than/< => lt; at most/maximum/<= => lte; exactly/equal/= => eq. Large or expensive means {"operator":"gt","value":1000}. Small means {"operator":"lt","value":1000}. Important negatives: software, purchases, and transactions are not filters by themselves. Never add policy unless the user mentions approved, approval, review, or out of policy. Examples: engineering => {"department":"Engineering"}; sales => {"department":"Sales"}; operations => {"department":"Operations"}; openai => {"merchant":"OpenAI"}; merchant stripe => {"merchant":"stripe"}; approved => {"policy":"Approved"}; large purchases => {"amount":{"operator":"gt","value":1000}}; purchases under $700 => {"amount":{"operator":"lt","value":700}}; exactly $620 => {"amount":{"operator":"eq","value":620}}; software purchases => {}; sales purchases that are approved => {"department":"Sales","policy":"Approved"}; engineering purchases that need review => {"department":"Engineering","policy":"Needs review"}; software over 1000 that need approval => {"amount":{"operator":"gt","value":1000},"policy":"Needs review"}. Return JSON only.', + role: 'system' + }, + { + content: input, + role: 'user' + } + ], + model: MODEL, + response_format: { + json_schema: { + name: 'transaction_filters', + schema: { + additionalProperties: false, + properties: { + amount: { + additionalProperties: false, + properties: { + operator: { + enum: AMOUNT_OPERATORS, + type: 'string' + }, + value: { + type: 'number' + } + }, + required: ['operator', 'value'], + type: 'object' + }, + department: { + type: 'string' + }, + merchant: { + type: 'string' + }, + policy: { + enum: POLICY_VALUES, + type: 'string' + } + }, + type: 'object' + }, + strict: true + }, + type: 'json_schema' + }, + temperature: 0, + usage: { + include: true + } + }), + headers: { + Authorization: `Bearer ${env.OPENROUTER_API_KEY}`, + 'Content-Type': 'application/json', + 'HTTP-Referer': 'https://rubriclabs.com', + 'X-Title': 'Rubric Labs Blog Demo' + }, + method: 'POST' + }) + + if (!response.ok) { + return NextResponse.json( + { + error: 'OpenRouter request failed', + filters: fallbackParse(input), + metrics: getMetrics(startedAt, undefined), + source: 'fallback' + }, + { status: 200 } + ) + } + + const result = (await response.json()) as { + choices?: Array<{ message?: { content?: string } }> + usage?: unknown + } + const content = result.choices?.[0]?.message?.content + const filters = removeUnsupportedFilters( + input, + sanitizeFilters(typeof content === 'string' ? extractJson(content) : undefined) + ) + + return NextResponse.json({ + filters: mergeWithDeterministicFilters(input, filters), + metrics: getMetrics(startedAt, result.usage), + model: MODEL, + source: 'openrouter' + }) +} diff --git a/src/app/globals.css b/src/app/globals.css index 3441b36b..f7d395d0 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -99,6 +99,10 @@ scroll-margin-top: 2rem; } + article :is(h1, h2, h3) + :is(h2, h3) { + @apply mt-0; + } + article a:not(:has(button)) { @apply underline underline-offset-3 decoration-1 hover:decoration-4; } diff --git a/src/lib/constants/blog.ts b/src/lib/constants/blog.ts index 07e2bb55..202a7d28 100644 --- a/src/lib/constants/blog.ts +++ b/src/lib/constants/blog.ts @@ -24,7 +24,7 @@ const AUTHORS = { const CATEGORIES = { ANNOUNCEMENT: 'Announcement', - BREAKDOWN: 'Breakdown', + ANALYSIS: 'Analysis', CASE_STUDY: 'Case study', ESSAY: 'Essay', EXPERIMENT: 'Experiment' diff --git a/src/lib/env.ts b/src/lib/env.ts index b833c321..79f76986 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -10,6 +10,7 @@ export const env = createEnv({ NEXT_PUBLIC_POSTHOG_HOST: process.env.NEXT_PUBLIC_POSTHOG_HOST, NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY, NODE_ENV: process.env.NODE_ENV, + OPENROUTER_API_KEY: process.env.OPENROUTER_API_KEY, ROS_API_URL: process.env.ROS_API_URL, ROS_SECRET: process.env.ROS_SECRET, URL: process.env.URL, @@ -18,6 +19,7 @@ export const env = createEnv({ }, server: { NODE_ENV: z.string().min(1), + OPENROUTER_API_KEY: z.string().min(1).optional(), ROS_API_URL: z.string().min(1), ROS_SECRET: z.string().min(1), URL: z.string().min(1), diff --git a/src/lib/posts/challenging-the-chatbot.mdx b/src/lib/posts/challenging-the-chatbot.mdx new file mode 100644 index 00000000..3269c3b8 --- /dev/null +++ b/src/lib/posts/challenging-the-chatbot.mdx @@ -0,0 +1,173 @@ +import { AUTHORS, CATEGORIES } from '~/lib/constants/blog' +import { ChatbotStreamFigure, TransactionFiltersAfterFigure, TransactionFiltersBeforeFigure } from '~/ui/blog/challenging-the-chatbot' + +export const metadata = { + title: "Challenging the Chatbot", + subtitle: "Intelligent applications need new interaction patterns", + date: "2026-06-26", + isNew: true, + author: AUTHORS.SARIM_MALIK, + category: CATEGORIES.ANALYSIS, + description: "Why intelligent applications need structured, stateful interfaces beyond the default chatbot.", + bannerImageUrl: "/images/primitives.png", +} + + + +The chatbot is genuinely an incredible interface. + +
+ + A chatbot can make intent feel immediate, smooth, and conversational. +
+ +I personally love using chatbots. I use Cursor for coding every day. I use ChatGPT for querying things, exploring ideas, and getting unstuck. Even a lot of the products we build for ourselves are conversational in nature. + +What makes them so compelling is how well they capture intent. You can show up with a vague thought, a half-formed question, or a messy goal, and the system can still meet you there. That is a real shift in how people interact with software. + +I heard Naval describe Large Language Models (LLMs) as something like [**natural language computing**](https://youtu.be/yAj5EnyuakI?si=BFyuCrRxTOGSAYgp&t=1306). That phrase has stuck with me because it captures what makes chatbots feel so powerful. They let you compute with open-ended language. Instead of learning a command syntax, searching through menus, or translating your intent into the shape of an application, you can just say what you mean. + +> "Large language models let you compute with open-ended language." + +From a product design perspective, though, especially for the operational products people use every day, this is where things get more complicated. + +Tools like CRMs, email clients, dashboards, workflow builders, and collaborative systems have a lot of value in their fixed, deterministic interfaces. They expose state. They make repeated actions fast. They let teams look at the same object and understand what is happening. They give users buttons, filters, tables, timelines, approvals, and views that are optimized for the work. + +Chatbots have excelled in single-player use cases, especially when the user is exploring, writing, searching, or asking for help. But they struggle more in collaborative, high-frequency, operational workflows. + +You would not want to use a conversational chatbot at a checkout kiosk. In that context, the job is not to explore ambiguous intent. The job is to finish a repeated workflow with as few mistakes and as little friction as possible. A deterministic interface, optimized around the right sequence of actions, is simply better for that kind of work. + +That is the tension. Chat is an amazing way to express intent, but it is not always the best way to represent state, coordinate action, or run a product workflow. + +## What Exactly Is Software? + +To understand where chatbots fit, it is worth asking a more basic question: what exactly is software? + +At the simplest level, software is the human-computer interface. Computers can store information, run calculations, automate work, coordinate systems, and produce outputs faster than any person could by hand. But that power is only useful if humans have a way to direct it, inspect it, and trust what comes back. + +Even as models become more capable, the interface still matters. Someone still has to design how people express intent to the computer, how the computer represents its state, and how the user knows what to do next. + +One useful way to look at that interface is through a systems design lens: inputs, process, and output. The product gives users a way to provide information or intent. The system stores and transforms that information through some process. Then it produces an output the user can consume, trust, or act on. + +For decades, a lot of the software we use every day has followed a fairly deterministic pattern. + +We build interfaces that gather information from users, store that information as core entities or data models, apply some business logic on top, and then produce an output the user can rely on. + +
+
+
+

Input surface

+

Forms, fields, tables, prompts

+
+
->
+
+

Data model

+

Employees, accounts, records

+
+
->
+
+

Business logic

+

Rules, calculations, workflows

+
+
->
+
+

Output surface

+

Reports, records, artifacts

+
+
+ Most software turns structured input into durable output through a data model and business logic. +
+ +Take payroll software. The product gives you an input surface for defining employees, compensation, bank details, tax information, and pay schedules. Underneath that interface, the software applies the calculations and business rules required to run payroll. The output is not a message in a transcript. It is a durable artifact: payroll that has been processed, recorded, and can be returned to later. + +That pattern shows up everywhere. The interface collects structured information. The system applies logic. The product produces state or an artifact that users can inspect, trust, and act on again. + +This is why deterministic interfaces have mattered so much. Forms, tables, dropdowns, dashboards, approvals, and workflow states may feel rigid, but they make the system legible. They turn work into shared state that can be inspected, edited, repeated, and trusted by more than one person. + +## What Chatbots Unlocked + +This is where chatbots changed the input surface of software. + +In a traditional application, the user has to know where to go before they can do the thing they want to do. They navigate to the right page, find the right workflow, open the right form, and then translate their intent into the fields the product exposes. + +Chat changes that sequence. Instead of navigating through the product first, the user can describe what they want. The system can interpret that intent, route them toward the relevant workflow, and extract the inputs the workflow needs. The input surface is no longer only a deterministic form. It can be open-ended language. + +This gets even more powerful when the chatbot has access to more than the immediate message. If it can search the web, look across an internal database, retrieve documents, remember past context, or understand the user's history, the input process becomes personalized. The user is not filling out a blank form from scratch. They are collaborating with a system that can infer context, retrieve missing details, and ask for clarification only when it needs to. + +This is why chat feels so magical. It collapses navigation and input into one natural language surface. + +It also changes the shape of the workflow itself. + +In the past, companies had to define rigid deterministic pipelines for getting things done. They could not capture every possible user intent, so they settled on a fixed number of input fields required to execute a fixed workflow. Once the product had access to that data, it could run the application logic and produce the output. + +That is still useful. A lot of software should be deterministic. But LLMs are unusually good at joining the dots between things. They can understand how pieces of context relate to each other, decide which capabilities are relevant, and assemble a path through a larger surface area. + +This is the argument we made in [Primitives over Pipelines](/blog/primitives-over-pipelines): instead of forcing agents through rigid backend workflows, give them modular capabilities they can compose. + +Chatbots make that idea visible at the product level. Instead of building only narrow vertical tools with one prescribed workflow, products can expose primitives to an LLM and let the model assemble the right path for the user's intent. + +Take something like Notion. In the past, it was primarily a document editor and workspace. But once you expose its primitives to an LLM, it can start to feel like more than a document editor. It can become an application builder, an internal operating tool, or a flexible layer for assembling workflows across the information already inside the workspace. + +The product does not need to anticipate every possible path up front. If the model can accept open-ended input and the system exposes the right primitives, the model can assemble workflows and application logic at runtime. + +So the chatbot paradigm shifts two parts of the old software loop. It changes the input surface, because users can describe intent in language. And it changes the application logic surface, because the system can compose primitives instead of only executing predetermined pipelines. + +It also changes the output surface. + +In traditional software, the output was usually a deterministic artifact or state that everyone consumed in roughly the same way. A report, a record, a dashboard, an invoice, a payroll run, a task list. The product decided the shape of the artifact, and the user consumed it. + +LLMs make that output more malleable. The system can assemble an artifact that depends on a specific person's use case, context, role, or intent. One user might need a summary. Another might need a table. Another might need a workflow, a draft, a dashboard, a checklist, or a diff. + +That means the full lifecycle of software is changing. How we capture input intent, how we process application logic, and how we represent the output to the user can all become more adaptive. + +The chatbot is the most obvious expression of that shift, but the deeper change is not the chat box itself. The deeper change is that every part of the software loop has become more flexible. + +Chatbots are taking off because they are an incredible form factor for open-ended work. They are especially strong for coding, writing, research, and general-purpose assistants where the user is exploring intent as they go. + +But much of the software we use every day is not primarily exploratory. It is operational. Payroll, checkout, inventory, scheduling, CRM, analytics, support queues, and approval workflows all depend on visible state, repeated actions, and shared artifacts. In those contexts, the best interface is often not a conversation. Sometimes it is a table, a scanner, a dashboard, a diff, a form, a calendar, or a button. Sometimes, like the Uniqlo checkout, the best interface is almost no interface at all. + +So if chat is not the whole interface, what else should we design? + +## Language Into State + +One useful pattern is to let language capture intent, then immediately turn that intent into visible, editable application state. + +### Filters Become Intent + +Traditional dashboards often make users translate their goal into filter logic. If you want to find large software purchases from last quarter that still need approval, you have to know which fields exist, which menus to open, and how the product names each status or category. + +An intelligent interface can let the user **start with the goal instead**. The system can interpret the request, apply the relevant structured filters, and show the resulting filter state back to the user so it can be inspected, edited, or cleared. + +Let's look at a concrete example. Imagine a traditional banking interface with a table of transactions and a handful of filters for fields like amount, merchant, department, and policy. If you use a product like Brex or Ramp, this interface probably feels familiar. + +In traditional software, those fields usually become a dynamic form made of filter buttons, selects, combo boxes, and inputs. The user has to assemble the right combination by hand, then watch the table update as each piece of state changes. Try clicking around the figure below to feel how much of the interaction is spent translating intent into interface mechanics. + +
+ + Traditional software asks the user to assemble the right filter combination by hand. +
+ +Even in this small static example, you probably spent a few seconds learning the interface: opening menus, checking which values exist, and seeing how each choice changes the table. And even when it works, the outcome may not map exactly to what you had in mind. The interface can only express the shapes its developers anticipated. If you want to ask for the same information in a slightly different way, you run into the edges of the product. + +That is one reason traditional software can feel frustrating. There is an onboarding curve, an activation energy, and then a memory burden. You have to learn the product's vocabulary and remember how to operate it later. **But this model has a real advantage: it is predictable.** The same filters produce the same view for you, your colleague, and everyone else on the team. That shared deterministic state creates trust in a way a pure chatbot often does not, because two people can ask similar questions and receive different outputs depending on their context. + +So the opportunity is not to replace the interface with chat. The opportunity is to find a compromise: **let language express intent, then turn that intent into visible, editable state.** Try the version below by describing the transactions you want to see. + +
+ + Intelligent software can use language to assemble filters while preserving visible, editable state. +
+ +The demo above is making a real request to [Meta Llama 3.1 8B Instruct](https://openrouter.ai/meta-llama/llama-3.1-8b-instruct) through OpenRouter. As you type, the app debounces the input, asks the model to return a structured filter object, then renders those filters back into the same visible table state. + +Small aside: we love using OpenRouter because it makes this kind of prototyping weirdly fun. They recently released an [MCP server](https://openrouter.ai/blog/announcements/openrouter-mcp-server/) that can help you search for the cheapest and fastest model for a specific use case. We used that to explore options for this demo. The first recommendation we tried was `inclusionai/ling-2.6-flash`, which looked ideal on paper: extremely cheap, low-latency, and advertised support for structured outputs. But our tiny eval caught the product-level details that matter here. It sometimes returned merchant names with the wrong casing, and in one case invented a policy filter while missing the amount filter. So we switched to the Meta model, which passed the demo harness and gave us a better balance of speed, cost, and structured-output reliability. + +The practical takeaway is that this no longer feels like a science project bolted onto the side of the interface. In our local runs, most requests landed in the 300-700ms range, which is close enough to the latency people already tolerate in normal software interactions. The cost is also small enough to change the product math. One representative request cost `$0.00000842`, which means you could run roughly 1.2 million filter searches before spending `$10`. + +And this is the expensive, slow version of the future. Inference costs keep falling, latency keeps improving, and smaller models keep getting better at narrow structured tasks. That makes it reasonable to imagine interactions like this eventually feeling instantaneous enough that the user does not think about the model at all. + +The interesting design question becomes: **where is it worth spending a little intelligence on each keystroke?** Anywhere the product is trying to capture intent, translate vague language into application state, or help the user shape a query, this pattern starts to open up. + +That is why challenging the chatbot matters. The future interface may not look like one big conversation. It may look like the software we already know, with intelligence quietly embedded wherever users are trying to express what they mean. diff --git a/src/lib/posts/how-does-claude-code-actually-work.mdx b/src/lib/posts/how-does-claude-code-actually-work.mdx index 4f30f682..10472a4c 100644 --- a/src/lib/posts/how-does-claude-code-actually-work.mdx +++ b/src/lib/posts/how-does-claude-code-actually-work.mdx @@ -10,7 +10,7 @@ export const metadata = { subtitle: "A first-principles understanding of coding agents", author: AUTHORS.SARIM_MALIK, bannerImageUrl: "/images/claude-code.png", - category: CATEGORIES.BREAKDOWN, + category: CATEGORIES.ANALYSIS, description: "A first-principles breakdown of how Claude Code works, including the agent loop, tool use, context management, and key design tradeoffs behind modern coding agents." } diff --git a/src/ui/blog/challenging-the-chatbot/chatbot-stream-figure.tsx b/src/ui/blog/challenging-the-chatbot/chatbot-stream-figure.tsx new file mode 100644 index 00000000..971080fe --- /dev/null +++ b/src/ui/blog/challenging-the-chatbot/chatbot-stream-figure.tsx @@ -0,0 +1,156 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import { cn } from '~/lib/utils/cn' + +const STEP_INTERVAL_MS = 760 +const RESET_DELAY_MS = 1600 +const BUBBLE_HEIGHT = 30 +const BUBBLE_GAP = 8 +const TRACK_WIDTH = 288 + +const MESSAGES = [ + { from: 'assistant', id: 'intent', width: 144 }, + { from: 'user', id: 'shape', width: 112 }, + { from: 'assistant', id: 'context', width: 128 }, + { from: 'user', id: 'refine', width: 128 }, + { from: 'assistant', id: 'artifact', width: 160 } +] as const + +const SkeletonBubble = ({ + bottom, + from, + isNewest, + width +}: { + bottom: number + from: (typeof MESSAGES)[number]['from'] + isNewest: boolean + width: number +}) => ( +
+) + +export const ChatbotStreamFigure = () => { + const [visibleCount, setVisibleCount] = useState(1) + const [isResetting, setIsResetting] = useState(false) + const hasAutoPlayed = useRef(false) + const containerRef = useRef(null) + const [hasStarted, setHasStarted] = useState(false) + const visibleMessages = MESSAGES.slice(0, visibleCount) + const messageBottoms = visibleMessages.map((_, index) => { + const messagesAfter = visibleMessages.slice(index + 1) + return messagesAfter.reduce(bottom => bottom + BUBBLE_HEIGHT + BUBBLE_GAP, 0) + }) + + useEffect(() => { + setVisibleCount(1) + }, []) + + useEffect(() => { + const element = containerRef.current + if (!element) return undefined + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry?.isIntersecting && !hasAutoPlayed.current) { + hasAutoPlayed.current = true + setHasStarted(true) + } + }, + { threshold: 0.35 } + ) + + observer.observe(element) + return () => observer.disconnect() + }, []) + + useEffect(() => { + if (!hasStarted) return undefined + + if (visibleCount >= MESSAGES.length) { + const resetTimer = window.setTimeout(() => { + setIsResetting(true) + window.setTimeout(() => { + setVisibleCount(1) + setIsResetting(false) + }, 360) + }, RESET_DELAY_MS) + + return () => window.clearTimeout(resetTimer) + } + + const timer = setInterval(() => { + setVisibleCount(count => Math.min(count + 1, MESSAGES.length)) + }, STEP_INTERVAL_MS) + + return () => clearInterval(timer) + }, [hasStarted, visibleCount]) + + return ( +
+
+
+ {visibleMessages.map((message, position) => ( + + ))} +
+
+ + +
+ ) +} diff --git a/src/ui/blog/challenging-the-chatbot/index.ts b/src/ui/blog/challenging-the-chatbot/index.ts new file mode 100644 index 00000000..b90170f1 --- /dev/null +++ b/src/ui/blog/challenging-the-chatbot/index.ts @@ -0,0 +1,3 @@ +export { ChatbotStreamFigure } from './chatbot-stream-figure' +export { TransactionFiltersAfterFigure } from './transaction-filters-after-figure' +export { TransactionFiltersBeforeFigure } from './transaction-filters-before-figure' diff --git a/src/ui/blog/challenging-the-chatbot/transaction-filters-after-figure.tsx b/src/ui/blog/challenging-the-chatbot/transaction-filters-after-figure.tsx new file mode 100644 index 00000000..3e3b043e --- /dev/null +++ b/src/ui/blog/challenging-the-chatbot/transaction-filters-after-figure.tsx @@ -0,0 +1,108 @@ +'use client' + +import { useEffect, useState } from 'react' +import { + TransactionFilterFigureFrame, + type TransactionFilterState +} from './transaction-filters-shared' + +const DEFAULT_INPUT = 'large purchases that need review' + +type RequestMetrics = { + costUsd?: number + latencyMs: number +} + +const formatCost = (costUsd: number) => { + if (costUsd === 0) return '$0' + if (costUsd < 0.0001) return `$${costUsd.toFixed(6)}` + return `$${costUsd.toFixed(4)}` +} + +export const TransactionFiltersAfterFigure = () => { + const [input, setInput] = useState(DEFAULT_INPUT) + const [filters, setFilters] = useState({ + amount: { operator: 'gt', value: 1000 }, + policy: 'Needs review' + }) + const [metrics, setMetrics] = useState(null) + const [status, setStatus] = useState<'idle' | 'loading'>('idle') + + useEffect(() => { + const controller = new AbortController() + const timer = window.setTimeout(async () => { + const query = input.trim() + if (!query) { + setFilters({}) + setMetrics(null) + setStatus('idle') + return + } + + setStatus('loading') + try { + const response = await fetch('/api/blog/challenging-the-chatbot/transaction-filters', { + body: JSON.stringify({ input: query }), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + signal: controller.signal + }) + const result = (await response.json()) as { + filters?: TransactionFilterState + metrics?: RequestMetrics + } + if (!controller.signal.aborted) { + setFilters(result.filters ?? {}) + setMetrics(result.metrics ?? null) + } + } catch (error) { + if (!controller.signal.aborted) console.error(error) + } finally { + if (!controller.signal.aborted) setStatus('idle') + } + }, 420) + + return () => { + controller.abort() + window.clearTimeout(timer) + } + }, [input]) + + const removeFilter = (key: keyof TransactionFilterState) => { + setFilters(current => { + const next = { ...current } + delete next[key] + return next + }) + } + + return ( + +
+ setInput(event.target.value)} + className="h-10 w-full rounded-lg border border-neutral-200 bg-white px-3 pr-36 text-neutral-950 text-sm outline-none transition-[border-color,box-shadow,background-color] placeholder:text-neutral-400 focus:border-neutral-400 focus:shadow-[0_0_0_3px_rgba(0,0,0,0.04)] dark:border-neutral-700 dark:bg-neutral-950/60 dark:text-neutral-100 dark:focus:border-neutral-500 dark:focus:bg-neutral-950/75 dark:focus:shadow-[0_0_0_3px_rgba(255,255,255,0.06)] dark:placeholder:text-neutral-500" + placeholder="Describe the transactions" + /> + {status === 'loading' ? ( + + ) : metrics ? ( + + {metrics.latencyMs}ms + {metrics.costUsd !== undefined ? ` · ${formatCost(metrics.costUsd)}` : null} + + ) : null} +
+
+ ) +} diff --git a/src/ui/blog/challenging-the-chatbot/transaction-filters-before-figure.tsx b/src/ui/blog/challenging-the-chatbot/transaction-filters-before-figure.tsx new file mode 100644 index 00000000..7370dbde --- /dev/null +++ b/src/ui/blog/challenging-the-chatbot/transaction-filters-before-figure.tsx @@ -0,0 +1,207 @@ +'use client' + +import { useEffect, useState } from 'react' +import { cn } from '~/lib/utils/cn' +import { + type AmountOperator, + OPERATOR_LABELS, + TransactionFilterFigureFrame, + type TransactionFilterState +} from './transaction-filters-shared' + +type FilterKey = keyof TransactionFilterState + +const FILTERS: Array<{ + key: FilterKey + label: string + options?: Array<{ label: string; value: string }> +}> = [ + { + key: 'amount', + label: 'Amount' + }, + { + key: 'merchant', + label: 'Merchant', + options: [ + { label: 'AWS', value: 'AWS' }, + { label: 'OpenAI', value: 'OpenAI' }, + { label: 'Figma', value: 'Figma' }, + { label: 'Delta', value: 'Delta' }, + { label: 'FedEx', value: 'FedEx' } + ] + }, + { + key: 'department', + label: 'Department', + options: [ + { label: 'Engineering', value: 'Engineering' }, + { label: 'Sales', value: 'Sales' }, + { label: 'Operations', value: 'Operations' }, + { label: 'Design', value: 'Design' } + ] + }, + { + key: 'policy', + label: 'Policy', + options: [ + { label: 'Needs review', value: 'Needs review' }, + { label: 'Out of policy', value: 'Out of policy' }, + { label: 'Approved', value: 'Approved' } + ] + } +] + +const AMOUNT_OPERATOR_OPTIONS: Array<{ label: string; value: AmountOperator }> = [ + { label: 'Greater than', value: 'gt' }, + { label: 'At least', value: 'gte' }, + { label: 'Less than', value: 'lt' }, + { label: 'At most', value: 'lte' }, + { label: 'Equal to', value: 'eq' } +] + +const defaultFilters: TransactionFilterState = { + amount: { operator: 'gt', value: 1000 }, + policy: 'Needs review' +} + +export const TransactionFiltersBeforeFigure = () => { + const [activeMenu, setActiveMenu] = useState(null) + const [filters, setFilters] = useState(defaultFilters) + + const setFieldFilter = (key: Exclude, value: string) => { + setFilters(current => ({ ...current, [key]: value })) + setActiveMenu(null) + } + + const setAmountOperator = (operator: AmountOperator) => { + setFilters(current => ({ + ...current, + amount: { + operator, + value: current.amount?.value ?? 1000 + } + })) + } + + const setAmountValue = (value: string) => { + const numericValue = Number(value) + setFilters(current => { + if (!Number.isFinite(numericValue)) return current + return { + ...current, + amount: { + operator: current.amount?.operator ?? 'gt', + value: numericValue + } + } + }) + } + + const removeFilter = (key: FilterKey) => { + setFilters(current => { + const next = { ...current } + delete next[key] + return next + }) + setActiveMenu(null) + } + + useEffect(() => { + const handlePointerDown = (event: PointerEvent) => { + const target = event.target as HTMLElement + if (!target.closest('[data-filter-popover]')) setActiveMenu(null) + } + + window.addEventListener('pointerdown', handlePointerDown) + return () => window.removeEventListener('pointerdown', handlePointerDown) + }, []) + + return ( + +
+ {FILTERS.map(filter => { + const value = filters[filter.key] + const isActive = Boolean(value) + const isOpen = activeMenu === filter.key + + return ( +
+ + + {isOpen && filter.key === 'amount' ? ( +
+
+ {AMOUNT_OPERATOR_OPTIONS.map(option => ( + + ))} +
+ + setAmountValue(event.target.value)} + className="mt-1 h-8 w-full rounded-md border border-neutral-200 bg-white px-2 text-neutral-950 text-xs outline-none transition-[border-color,box-shadow,background-color] focus:border-neutral-400 focus:shadow-[0_0_0_3px_rgba(0,0,0,0.04)] dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100 dark:focus:border-neutral-500 dark:focus:bg-neutral-950/75 dark:focus:shadow-[0_0_0_3px_rgba(255,255,255,0.06)]" + /> +
+ ) : null} + + {isOpen && filter.key !== 'amount' ? ( +
+ {filter.options?.map(option => ( + + ))} +
+ ) : null} +
+ ) + })} +
+
+ ) +} diff --git a/src/ui/blog/challenging-the-chatbot/transaction-filters-shared.tsx b/src/ui/blog/challenging-the-chatbot/transaction-filters-shared.tsx new file mode 100644 index 00000000..16120f23 --- /dev/null +++ b/src/ui/blog/challenging-the-chatbot/transaction-filters-shared.tsx @@ -0,0 +1,238 @@ +import type { ReactNode } from 'react' +import { cn } from '~/lib/utils/cn' +import { CrossIcon } from '~/ui/icons/cross' + +export const AMOUNT_OPERATORS = ['gt', 'gte', 'lt', 'lte', 'eq'] as const +export const POLICY_VALUES = ['Approved', 'Needs review', 'Out of policy'] as const + +export type AmountOperator = (typeof AMOUNT_OPERATORS)[number] +export type PolicyValue = (typeof POLICY_VALUES)[number] + +export type AmountFilter = { + operator: AmountOperator + value: number +} + +export type TransactionFilterState = { + amount?: AmountFilter + department?: string + merchant?: string + policy?: PolicyValue +} + +type Transaction = { + amount: number + department: string + merchant: string + policy: PolicyValue +} + +export const FILTER_LABELS: Record = { + amount: 'Amount', + department: 'Department', + merchant: 'Merchant', + policy: 'Policy' +} + +export const OPERATOR_LABELS: Record = { + eq: '=', + gt: '>', + gte: '≥', + lt: '<', + lte: '≤' +} + +export const TRANSACTIONS: Transaction[] = [ + { + amount: 2840, + department: 'Engineering', + merchant: 'AWS', + policy: 'Needs review' + }, + { + amount: 1320, + department: 'Engineering', + merchant: 'OpenAI', + policy: 'Needs review' + }, + { + amount: 620, + department: 'Sales', + merchant: 'Delta', + policy: 'Approved' + }, + { + amount: 1180, + department: 'Design', + merchant: 'Figma', + policy: 'Approved' + }, + { + amount: 84, + department: 'Operations', + merchant: 'FedEx', + policy: 'Out of policy' + } +] + +export const formatCurrency = (amount: number) => + new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(amount) + +export const formatFilterValue = ( + key: keyof TransactionFilterState, + value: NonNullable +) => { + if (key === 'amount' && typeof value === 'object') { + return `${OPERATOR_LABELS[value.operator]} $${formatCurrency(value.value)}` + } + return String(value) +} + +const matchesTransactionFilters = (transaction: Transaction, filters: TransactionFilterState) => { + if (filters.amount) { + const { operator, value } = filters.amount + if (operator === 'gt' && transaction.amount <= value) return false + if (operator === 'gte' && transaction.amount < value) return false + if (operator === 'lt' && transaction.amount >= value) return false + if (operator === 'lte' && transaction.amount > value) return false + if (operator === 'eq' && transaction.amount !== value) return false + } + if ( + filters.merchant && + !transaction.merchant.toLowerCase().includes(filters.merchant.toLowerCase()) + ) + return false + if ( + filters.department && + transaction.department.toLowerCase() !== filters.department.toLowerCase() + ) + return false + if (filters.policy && transaction.policy !== filters.policy) return false + return true +} + +type TransactionFilterFigureFrameProps = { + children: ReactNode + filters: TransactionFilterState + onRemoveFilter?: (key: keyof TransactionFilterState) => void +} + +export const TransactionFilterFigureFrame = ({ + children, + filters, + onRemoveFilter +}: TransactionFilterFigureFrameProps) => { + const filteredTransactions = TRANSACTIONS.filter(transaction => + matchesTransactionFilters(transaction, filters) + ) + const activeFilterEntries = Object.entries(filters).filter(([, value]) => Boolean(value)) as Array< + [keyof TransactionFilterState, NonNullable] + > + + return ( +
+
+ {children} + +
+ {activeFilterEntries.length ? ( + activeFilterEntries.map(([key, value]) => ( + + + {FILTER_LABELS[key]} + {key === 'amount' ? ' ' : ': '} + {formatFilterValue(key, value)} + + {onRemoveFilter ? ( + + ) : null} + + )) + ) : ( +

No filters applied

+ )} +
+
+ +
+ + + + + + + + + + + {filteredTransactions.length === 0 ? ( + + + + ) : ( + <> + {filteredTransactions.map(transaction => ( + + + + + + + ))} + {Array.from({ length: TRANSACTIONS.length - filteredTransactions.length }).map( + (_, index) => ( + + + ) + )} + + )} + +
MerchantAmountDepartmentPolicy
+

+ No transactions match these filters +

+
{transaction.merchant} + ${formatCurrency(transaction.amount)} + + {transaction.department} + + + {transaction.policy} + +
+
+
+ +
+ Showing {filteredTransactions.length} of {TRANSACTIONS.length} transactions +
+
+ ) +} diff --git a/src/ui/card.tsx b/src/ui/card.tsx index b6b8d52d..ebaf51ae 100644 --- a/src/ui/card.tsx +++ b/src/ui/card.tsx @@ -1,6 +1,7 @@ import Link from 'next/link' import { cn } from '~/lib/utils/cn' import { formatDate } from '~/lib/utils/date' +import type { Post } from '~/lib/utils/posts' import { CustomImage } from '~/ui/custom-image' export const Card = ({ @@ -9,30 +10,40 @@ export const Card = ({ imgAlt, className }: { - post: { slug: string; title: string; category: string; date: string } + post: Post imgSrc: string imgAlt: string className?: string }) => { + const previewText = post.subtitle ?? post.description + return (
+ {previewText ? ( +
+
+

+ {previewText} +

+
+ ) : null}

{post.title}

-

{post.category}

-

{formatDate(post.date)}

+ +

{post.category}

diff --git a/src/ui/copiable-heading.tsx b/src/ui/copiable-heading.tsx index 99daed5a..996f5af5 100644 --- a/src/ui/copiable-heading.tsx +++ b/src/ui/copiable-heading.tsx @@ -12,14 +12,17 @@ export const CopiableHeading = ({ as: Component = 'h2', id: idProp, ...props -}: { children?: React.ReactNode; as?: HeadingLevel } & React.HTMLAttributes) => { +}: { + children?: React.ReactNode + as?: HeadingLevel +} & React.HTMLAttributes) => { const fallbackId = children?.toString().toLowerCase().replaceAll(' ', '-') const id = idProp ?? fallbackId const { copied, handleCopy } = useClipboard() useEffect(() => { - if (copied) toast.success('Link copied') + if (copied) toast.success('Shareable link to section copied') }, [copied]) return ( diff --git a/src/ui/cta.tsx b/src/ui/cta.tsx index 87351ff1..8ba07baf 100644 --- a/src/ui/cta.tsx +++ b/src/ui/cta.tsx @@ -10,20 +10,27 @@ const DEFAULT_HOOK = "We don't have a sales team. Let's talk." const HOOK_BY_CATEGORY: Record = { [CATEGORIES.ANNOUNCEMENT]: "If this sparked an idea for your roadmap, let's talk.", - [CATEGORIES.BREAKDOWN]: "Want help implementing this in production? Let's talk", + [CATEGORIES.ANALYSIS]: "Want help implementing this in production? Let's talk", [CATEGORIES.CASE_STUDY]: "Want outcomes like this in your product? Let's talk.", [CATEGORIES.ESSAY]: "If this perspective matches what you're seeing, let's talk.", [CATEGORIES.EXPERIMENT]: "If this sparked an idea for your roadmap, let's talk." } -export const CTA = ({ category }: { category?: Category }) => { +export const CTA = ({ + category, + size = 'default' +}: { + category?: Category + size?: 'default' | 'compact' +}) => { const hook = category ? HOOK_BY_CATEGORY[category] : DEFAULT_HOOK const posthog = usePostHog() + const headingClassName = size === 'compact' ? 'text-3xl sm:text-4xl' : 'text-4xl sm:text-5xl' return (
-

{hook}

+

{hook}

{category ? (

Rubric is an applied AI lab helping teams design and ship intelligent products. diff --git a/src/ui/figure.tsx b/src/ui/figure.tsx index d31869d8..7dd2e086 100644 --- a/src/ui/figure.tsx +++ b/src/ui/figure.tsx @@ -3,16 +3,12 @@ import { createContext, useCallback, useContext, useEffect } from 'react' import { toast } from 'sonner' import { useClipboard } from '~/lib/hooks/use-clipboard' -import { Button } from '~/ui/button' -import { ShareIcon } from '~/ui/icons/share' +import { cn } from '~/lib/utils/cn' +import { Copy } from '~/ui/icons/copy' const FigureIdContext = createContext(undefined) -const FigureCaption = ({ children }: { children: React.ReactNode }) => { - return

{children}
-} - -const FigureShare = ({ id }: { id?: string }) => { +const FigureShare = ({ className, id }: { className?: string; id?: string }) => { const figureId = useContext(FigureIdContext) const resolvedId = id ?? figureId const { copied, handleCopy } = useClipboard() @@ -24,20 +20,39 @@ const FigureShare = ({ id }: { id?: string }) => { }, [handleCopy, resolvedId]) useEffect(() => { - if (copied) toast.success('Link copied') + if (copied) toast.success('Shareable link to figure copied') }, [copied]) return ( - + + ) +} + +const FigureCaption = ({ children }: { children: React.ReactNode }) => { + const figureId = useContext(FigureIdContext) + + return ( +
+ {children} + {figureId ? : null} +
) } const FigureRoot = ({ children, id }: { children: React.ReactNode; id?: string }) => { return ( -
+
{children}
diff --git a/src/ui/table-of-contents.tsx b/src/ui/table-of-contents.tsx index 98084494..194b733a 100644 --- a/src/ui/table-of-contents.tsx +++ b/src/ui/table-of-contents.tsx @@ -29,7 +29,10 @@ export const TableOfContents = ({ >