Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
df882e2
feat(pipeline): add printer feature vectorization script
gativarshney Jun 23, 2026
a370869
feat(recommendations): implement printer compatibility similarity pip…
gativarshney Jun 23, 2026
b1abc78
feat(recommendations): surface compatible printer alternatives on pri…
gativarshney Jun 23, 2026
6a93780
feat(recommendations): add color-aware printer similarity features
gativarshney Jun 23, 2026
f3aab1f
feat(recommendations): add commandset-aware printer similarity
gativarshney Jun 23, 2026
73a9161
feat(recommendations): add PostScript and PCL language-aware similarity
gativarshney Jun 23, 2026
2d6f3b7
feat(recommendations): add resolution-aware printer similarity
gativarshney Jun 23, 2026
4d2a733
Merge branch 'OpenPrinting:master' into feat/printer-recommendations
gativarshney Aug 1, 2026
983f915
perf(recommendations): split recommendation data per printer
gativarshney Aug 1, 2026
403ba15
feat(ui): surface printer capabilities and recommendation confidence
gativarshney Aug 1, 2026
fa79393
build(foomatic): automate recommendation generation in CI
gativarshney Aug 7, 2026
e28f660
feat(recommendations): improve recommendation UX and accessibility
gativarshney Aug 7, 2026
bc25186
fix(foomatic): sanitize upstream HTML rendering
gativarshney Aug 7, 2026
9be7c7d
test(foomatic): add unit tests for recommendation pipeline
gativarshney Aug 7, 2026
856d780
docs(foomatic): add pipeline architecture and regeneration guides
gativarshney Aug 7, 2026
bac57a4
docs(foomatic): add contributor guide for extending the UI
gativarshney Aug 7, 2026
6d16b3c
docs(foomatic): add measured evidence of recommendation quality evolu…
gativarshney Aug 7, 2026
c654b5e
perf(recommendations): render similar printers without the full print…
gativarshney Aug 7, 2026
6c62b28
docs(foomatic): document similarity weights and confidence thresholds
gativarshney Aug 7, 2026
ae59b20
chore(foomatic): resolve final review and lint issues
gativarshney Aug 7, 2026
70e0e07
feat(recommendations): rank by IDF-weighted evidence-damped similarity
gativarshney Aug 7, 2026
4d44bf9
test(foomatic): extract scoring primitives into a tested module
gativarshney Aug 12, 2026
5de2a6c
fix(recommendations): replace unreachable exact-match badge with evid…
gativarshney Aug 12, 2026
9d54edf
docs(foomatic): align scoring docs with implementation and make evalu…
gativarshney Aug 12, 2026
15f65f5
feat(recommendations): surface similar printers from the printer page…
gativarshney Aug 12, 2026
ef0522a
Merge branch 'OpenPrinting:master' into feat/printer-recommendations
gativarshney Aug 12, 2026
2b72bc9
docs(foomatic): align documentation with final recommendation UI
gativarshney Aug 12, 2026
7637911
feat(assistant): extend printer catalogue for local queries
gativarshney Aug 12, 2026
c6438a8
feat(assistant): add deterministic natural-language query parsing
gativarshney Aug 12, 2026
bf1b5eb
feat(assistant): execute typed queries against local foomatic data
gativarshney Aug 12, 2026
81dea1b
test(assistant): validate grounding against real artifacts with an ev…
gativarshney Aug 12, 2026
4094b57
feat(assistant): add site-wide assistant launcher and chat panel
gativarshney Aug 12, 2026
b9812dd
fix(assistant): correct relaxation labels and duplex reporting edge c…
gativarshney Aug 12, 2026
dab24f2
feat(assistant): polish responsive chat ui and visual identity
gativarshney Aug 12, 2026
3abf422
fix(assistant): use one close affordance and sheet layout on short vi…
gativarshney Aug 12, 2026
2749f5d
fix(assistant): keep suggestions grounded and answer grade questions …
gativarshney Aug 12, 2026
2d5b235
fix(assistant): reveal new responses from the top
gativarshney Aug 12, 2026
5743f2f
fix(assistant): keep definition questions general on entity pages
gativarshney Aug 12, 2026
7383236
feat(assistant): add readable evidence lists and info follow-ups
gativarshney Aug 12, 2026
46b82a0
docs(assistant): document definition-question handling
gativarshney Aug 12, 2026
5424655
fix(assistant): keep the closed panel from blocking the launcher
gativarshney Aug 12, 2026
2af17cf
fix(assistant): answer printer-specific duplex questions with the dat…
gativarshney Aug 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,5 +76,10 @@ jobs:
- name: Run linter
run: ${{ steps.pm.outputs.runner }} lint

- name: Run unit tests
run: ${{ steps.pm.outputs.runner }} test

- name: Build Next.js
run: ${{ steps.pm.outputs.runner }} build
env:
FOOMATIC_SKIP_SIMILARITY: "1"
4 changes: 4 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ on:
push:
branches: [master]

# Weekly rebuild so recommendations pick up upstream foomatic-db changes.
schedule:
- cron: "0 2 * * 1"

workflow_dispatch:

permissions:
Expand Down
9 changes: 4 additions & 5 deletions app/foomatic/printers/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -261,18 +261,17 @@ export default function HomePage() {

if (selectedColorCapability !== "all") {
result = result.filter((printer) => {
const type = printer.type?.toLowerCase() || ""
const model = typeof printer.model === "string" ? printer.model.toLowerCase() : ""
const color = printer.color

if (selectedColorCapability === "color") {
return type.includes("color") || model.includes("color")
return color === true
}

if (selectedColorCapability === "monochrome") {
return type.includes("mono") || type.includes("dot-matrix") || model.includes("mono")
return color === false
}

return true
return color === "unknown" || color === undefined
})
}

Expand Down
2 changes: 2 additions & 0 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import "./globals.css";
import Navbar from "@/components/navbar";
import Footer from "@/components/footer";
import TopLoader from "@/components/top-loader";
import AssistantLauncher from "@/components/assistant/AssistantLauncher";
import { ThemeProvider } from "@/components/theme-provider";
import { siteConfig } from "@/config/site.config";
import { getSiteUrl } from "@/lib/site";
Expand Down Expand Up @@ -45,6 +46,7 @@ export default function RootLayout({
<Navbar />
{children}
<Footer />
<AssistantLauncher />
</ThemeProvider>
</body>
</html>
Expand Down
191 changes: 191 additions & 0 deletions components/assistant/AssistantBlocks.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
"use client"

// Renders the assistant's typed ResponsePlan blocks. Everything here is plain
// React over typed data - user text and data fields are rendered as text
// nodes (React escapes them), links are built exclusively by the canonical
// printerHref/driverHref helpers, and no HTML string is ever injected.
//
// Link/accent colour inside the assistant is the site's blue family
// (text-gradient-blue et al.) rather than --primary, which is near-white in
// dark mode and would not read as a link there.

import Link from "next/link"
import { ArrowRight } from "lucide-react"
import { FoomaticBadge, FoomaticCard, FoomaticStatusBadge } from "@/components/foomatic/shared"
import { driverHref, printerHref } from "@/lib/foomatic/routes"
import type { ResponseBlock } from "@/lib/assistant/types"

// Same tone mapping as RecommendedPrintersSection.tsx: tier wording and
// thresholds live in lib/foomatic/scoring.ts next to the model they describe.
const TONE_CLASSES: Record<string, string> = {
high: "text-emerald-700 dark:text-emerald-400",
good: "text-sky-700 dark:text-sky-400",
moderate: "text-amber-700 dark:text-amber-400",
limited: "text-muted-foreground",
}

const LINK_CLASSES =
"inline-flex items-center gap-1 text-xs font-medium text-blue-600 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring dark:text-blue-400"

interface BlockProps {
block: ResponseBlock
onAsk: (query: string) => void
busy: boolean
}

export function AssistantBlock({ block, onAsk, busy }: BlockProps) {
switch (block.kind) {
case "text":
return <p className="text-sm leading-relaxed text-foreground">{block.text}</p>

case "list":
return (
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">{block.title}</p>
<ul className="list-disc space-y-0.5 pl-5 text-sm leading-relaxed text-foreground">
{block.items.map(item => (
<li key={item}>{item}</li>
))}
</ul>
</div>
)

case "chips":
return (
<div className="flex flex-wrap gap-1.5">
{block.chips.map(chip => (
<button
key={chip.label + chip.query}
type="button"
disabled={busy}
onClick={() => onAsk(chip.query)}
className="min-h-[2.25rem] max-w-full rounded-full border border-border bg-background px-3.5 py-1.5 text-left text-xs font-medium leading-snug text-foreground transition-colors hover:border-blue-500/50 hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
>
{chip.label}
</button>
))}
</div>
)

case "printer-cards":
return (
<ul className="space-y-2" aria-label="Printer results">
{block.printers.map(card => {
const name = `${card.manufacturer} ${card.model}`.trim() || card.id
return (
<li key={card.id}>
<FoomaticCard className="p-3 transition-colors hover:border-blue-500/40">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
{card.manufacturer && (
<p className="text-xs text-muted-foreground">{card.manufacturer}</p>
)}
<p className="truncate text-sm font-semibold tracking-tight text-foreground">
{card.model || card.id}
</p>
</div>
{card.score !== undefined && card.tierLabel && (
<div className="shrink-0 text-right">
<p className={`text-xs font-medium ${TONE_CLASSES[card.tierTone ?? "limited"]}`}>
{card.tierLabel}
</p>
<p className="text-xs text-muted-foreground">{Math.round(card.score * 100)}% similarity</p>
</div>
)}
</div>
<div className="mt-2 flex flex-wrap items-center gap-1.5">
<FoomaticStatusBadge status={card.status} />
{card.type && (
<FoomaticBadge className="border-border text-muted-foreground">{card.type}</FoomaticBadge>
)}
{typeof card.driverCount === "number" && (
<FoomaticBadge className="border-border text-muted-foreground">
{card.driverCount} {card.driverCount === 1 ? "driver" : "drivers"}
</FoomaticBadge>
)}
{card.features?.map(feature => (
<FoomaticBadge
key={feature}
className="border-blue-500/20 bg-blue-500/5 text-blue-700 dark:text-blue-300"
>
{feature}
</FoomaticBadge>
))}
</div>
<div className="mt-2.5">
<Link
href={printerHref(card.id, card.manufacturer)}
aria-label={`View ${name} printer page`}
className={LINK_CLASSES}
>
View printer
<ArrowRight className="h-3 w-3" aria-hidden="true" />
</Link>
</div>
</FoomaticCard>
</li>
)
})}
</ul>
)

case "driver-card": {
const driver = block.driver
return (
<FoomaticCard className="p-3 transition-colors hover:border-blue-500/40">
<p className="text-sm font-semibold tracking-tight text-foreground">{driver.name}</p>
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
{driver.supplier && (
<FoomaticBadge className="border-border text-muted-foreground">{driver.supplier}</FoomaticBadge>
)}
{driver.type && (
<FoomaticBadge className="border-border text-muted-foreground">{driver.type}</FoomaticBadge>
)}
<FoomaticBadge className="border-border text-muted-foreground">
{driver.printerCount} {driver.printerCount === 1 ? "printer" : "printers"}
</FoomaticBadge>
</div>
<div className="mt-2.5">
<Link
href={driverHref(driver.id)}
aria-label={`View ${driver.name} driver page`}
className={LINK_CLASSES}
>
View driver
<ArrowRight className="h-3 w-3" aria-hidden="true" />
</Link>
</div>
</FoomaticCard>
)
}

case "comparison":
return (
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full border-collapse text-xs">
<caption className="sr-only">
Comparison of {block.aName} and {block.bName}
</caption>
<thead>
<tr className="border-b border-border bg-muted/50 text-left">
<th scope="col" className="px-2.5 py-2 font-medium text-muted-foreground" />
<th scope="col" className="px-2.5 py-2 font-semibold text-foreground">{block.aName}</th>
<th scope="col" className="px-2.5 py-2 font-semibold text-foreground">{block.bName}</th>
</tr>
</thead>
<tbody>
{block.rows.map((row, index) => (
<tr key={row.label} className={index < block.rows.length - 1 ? "border-b border-border/60" : ""}>
<th scope="row" className="px-2.5 py-2 text-left font-medium text-muted-foreground">
{row.label}
</th>
<td className="px-2.5 py-2 text-foreground">{row.a}</td>
<td className="px-2.5 py-2 text-foreground">{row.b}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
}
56 changes: 56 additions & 0 deletions components/assistant/AssistantLauncher.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"use client"

// Site-wide assistant entry point, mounted once in app/layout.tsx. The
// launcher is the only assistant code in the shared bundle; the panel (and
// the engine it imports) lives in a lazy chunk loaded on first open, and no
// assistant data is fetched until then.
//
// Visual identity: the site's blue accent family (hero gradients,
// text-gradient-blue) rather than --primary, which flips to near-white in
// dark mode and would make the launcher vanish against light content. White
// iconography on blue-500..700 keeps ≥3:1 contrast in both themes.

import { useState } from "react"
import dynamic from "next/dynamic"
import { MessageCircleMore, X } from "lucide-react"

const AssistantPanel = dynamic(() => import("./AssistantPanel"), { ssr: false })

const LAUNCHER_ID = "assistant-launcher"

export default function AssistantLauncher() {
const [open, setOpen] = useState(false)
const [loaded, setLoaded] = useState(false)

return (
<>
<button
id={LAUNCHER_ID}
type="button"
aria-label={open ? "Close printer assistant" : "Open printer assistant"}
aria-expanded={open}
aria-haspopup="dialog"
aria-controls="assistant-panel"
onClick={() => {
setLoaded(true)
setOpen(current => !current)
}}
className="group fixed bottom-[max(1.25rem,env(safe-area-inset-bottom))] right-[max(1.25rem,env(safe-area-inset-right))] z-40 flex h-14 w-14 items-center justify-center rounded-full bg-gradient-to-br from-blue-500 to-blue-700 text-white shadow-lg shadow-blue-600/25 ring-1 ring-white/20 transition-[box-shadow,transform] duration-200 hover:shadow-xl hover:shadow-blue-600/35 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-safe:hover:scale-105 motion-safe:active:scale-95 print:hidden sm:bottom-6 sm:right-6"
>
<span className="relative flex h-6 w-6 items-center justify-center" aria-hidden="true">
<MessageCircleMore
className={`absolute h-6 w-6 transition-all duration-200 motion-reduce:transition-none ${
open ? "scale-50 opacity-0" : "scale-100 opacity-100"
}`}
/>
<X
className={`absolute h-5 w-5 transition-all duration-200 motion-reduce:transition-none ${
open ? "scale-100 opacity-100" : "scale-50 opacity-0"
}`}
/>
</span>
</button>
{loaded && <AssistantPanel open={open} onClose={() => setOpen(false)} launcherId={LAUNCHER_ID} />}
</>
)
}
Loading
Loading