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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,11 @@ jobs:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
- uses: denoland/setup-deno@v2
- name: Install dependencies
- name: Install dependencies (API)
run: uv sync --directory api --dev
- name: Install dependencies (web)
run: deno install
working-directory: web
- uses: pre-commit/action@v3.0.1

check-web:
Expand All @@ -23,6 +26,9 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: denoland/setup-deno@v2
- name: Install dependencies
run: deno install
working-directory: web
- name: Check
run: deno task check
working-directory: web
43 changes: 43 additions & 0 deletions web/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Librarian — Web Frontend

Fresh 2 frontend for the Librarian local AI research assistant.

Built with [Deno](https://deno.com), [Fresh 2](https://fresh.deno.dev), [Preact](https://preactjs.com), [Vite](https://vite.dev), and [Tailwind CSS v4](https://tailwindcss.com).

## Prerequisites

- [Deno](https://deno.com) v2+

## Commands

```sh
deno task dev # Start Vite dev server with HMR
deno task build # Build for production → _fresh/
deno task start # Run production build
deno task check # Fmt check, lint, and typecheck
```

## Structure

```
web/
├── routes/ # File-based pages and API handlers
│ └── api/ # /api/config, /api/health
├── islands/ # Client-hydrated interactive components
│ ├── Chat.tsx # Main chat interface (SSE streaming)
│ └── ControlsPanel.tsx # Collapsible settings sidebar
├── components/ # Server-rendered static components
├── utils/
│ ├── appState.ts # Persisted signals (localStorage)
│ └── bookmarks.ts # Bookmark group management
└── assets/
└── styles.css # Tailwind + global CSS custom properties
```

## Architecture

**Islands**: Only components in `islands/` run on the client. Everything in `components/` is server-rendered. This keeps the JS bundle small — interactivity is opt-in per component.

**State**: Shared signals live in `utils/appState.ts`. Persistent state (bookmark groups, controls settings, message history) is synced to `localStorage` via `createPersistedSignal`.

**Backend connection**: The frontend connects to the Python API on the port specified in `librarian.config.json` (read via `/api/config`). Queries stream over SSE.
323 changes: 323 additions & 0 deletions web/assets/styles.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,323 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";

/* SVGs */
:root {
--star-svg: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z" /></svg>');

/* Shared colors */
--color-muted: theme(--color-zinc-400);
--color-accent: theme(--color-green-300);
--color-text: color-mix(in srgb, theme(--color-green-200) 80%, transparent);

/* Backgrounds */
--bg-page: theme(--color-zinc-800);
--bg-panel: color-mix(in srgb, theme(--color-zinc-900) 50%, transparent);
--bg-panel-hover: color-mix(
in srgb,
theme(--color-zinc-900) 80%,
transparent
);

/* Content colors */
--color-answer: color-mix(in srgb, theme(--color-zinc-100) 90%, transparent);
--color-link: color-mix(in srgb, theme(--color-green-400) 90%, transparent);
--color-link-hover: theme(--color-green-300);
--color-danger: theme(--color-red-400);
--color-danger-muted: color-mix(
in srgb,
theme(--color-red-400) 50%,
transparent
);
}

/* Custom scrollbar styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}

::-webkit-scrollbar-track {
background: var(--bg-page);
}

::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--color-accent) 30%, transparent);
border-radius: 4px;
}

::-webkit-scrollbar-thumb:hover {
background: color-mix(in srgb, var(--color-accent) 50%, transparent);
}

/* Firefox */
* {
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--color-accent) 30%, transparent)
var(--bg-page);
}

/* Controls panel */
.controls-panel {
@apply border-zinc-700/50 text-green-200 transition-colors;
background-color: var(--bg-panel);

&:hover {
background-color: var(--bg-panel-hover);
}

@media (width >= 99rem) {
@apply absolute top-0 left-0 h-full z-10;
}

h1 {
@apply text-lg font-light mt-5;
color: var(--color-muted);
}

h2 {
@apply text-base font-light;
color: var(--color-accent);
}

h3 {
@apply text-sm font-light;
color: var(--color-muted);
}

.controls-action-btn {
@apply outline-1 w-9 h-7 rounded-full transition-colors cursor-pointer;
&:hover {
background-color: var(--color-accent);
}
&:active {
@apply scale-90;
}
}
}

/* Controls section: collapsible */
.controls-section {
margin-bottom: 2rem;

&:not([open]) {
margin-bottom: 1rem;
}

summary {
@apply transition-opacity duration-300;
}

&:not([open]) summary {
@apply opacity-50;
}

.controls-section-chevron {
@apply transition-transform;
color: var(--color-muted);
}

&[open] .controls-section-chevron {
@apply rotate-90;
}

.controls-section-underline {
flex-basis: 100%;
height: 1px;
margin-top: 0.5rem;
background-color: var(--color-accent);
transform: scaleX(0);
transform-origin: left;
transition: transform 0.3s ease;
}

&[open] .controls-section-underline {
margin-bottom: 0.65rem;
transform: scaleX(1);
}

&[open] .controls-section-content {
animation: controls-fade-in 0.3s cubic-bezier(0.9, 0, 1, 1);
}
}

@keyframes controls-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}

/* Bookmark group: collapsible */
.bookmark-group {
margin: 0.2rem 0.4rem 0 0.2rem;
@apply p-1;

summary {
@apply transition-opacity duration-300;
}

.bookmark-group-chevron {
@apply transition-transform;
color: var(--color-muted);
}

&[open] .bookmark-group-chevron {
@apply rotate-90;
}

input[type="checkbox"] {
appearance: none;
width: 11px;
height: 11px;
border-radius: 4px;
border: 1px solid var(--color-muted);
background-color: transparent;
cursor: pointer;
position: relative;
transition: border-color 0.15s;
flex-shrink: 0;
margin-left: 0.5px;
margin-top: 1.5px;

&:checked {
border-color: transparent;
}

&:checked::after {
content: "";
position: absolute;
bottom: 1px;
left: 2px;
width: 5px;
height: 9px;
border: solid var(--color-muted);
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}

&.checkbox-active:checked::after {
border-color: var(--color-accent);
}
}

&[open] > div {
animation: controls-fade-in 0.1s cubic-bezier(0.3, 0, 1, 1);
}
}

.bookmark-url {
color: inherit;
text-decoration: none;
}

.bookmark-url-active {
color: var(--color-link);
}

/* Controls row: label + control on a shared grid */
.controls-row {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 1rem;
padding: 0.5rem 0.2rem;
}

.controls-row.stacked {
grid-template-columns: 1fr;
gap: 0.1rem;
padding-bottom: 0.5rem;
}

/* Slider thumb */
input[type="range"].slider-thumb-svg {
appearance: none;
background: transparent;
}

input[type="range"].slider-thumb-svg::-webkit-slider-runnable-track {
height: 4px;
border-radius: 2px;
background: var(--color-accent);
}

input[type="range"].slider-thumb-svg::-webkit-slider-thumb {
appearance: none;
width: 24px;
height: 24px;
margin-top: -10px;
border: none;
background-color: var(--color-accent);
-webkit-mask: var(--star-svg) no-repeat center / contain;
}

input[type="range"].slider-thumb-svg::-moz-range-track {
height: 1px;
border-radius: 2px;
background: color-mix(in srgb, var(--color-accent) 10%, transparent);
}

input[type="range"].slider-thumb-svg::-moz-range-thumb {
appearance: none;
width: 24px;
height: 24px;
border: none;
background-color: var(--color-accent);
mask: var(--star-svg) no-repeat center / contain;
}

/* Chat input */
.chat-input {
@apply bg-transparent focus:outline-none font-light text-lg resize-none
w-full;
color: var(--color-text);
caret-color: var(--color-accent);
}

/* Loading indicator */
.loading-indicator {
@apply my-5 px-5 py-3 rounded-lg;
background-color: var(--bg-panel);
}

/* Answer prose overrides */
.answer-prose {
@apply font-light max-w-none mt-5 pl-5 prose prose-invert prose-sm
prose-p:my-2 prose-p:leading-normal prose-li:leading-normal;
color: var(--color-answer);

:where(a) {
color: var(--color-link);
}

:where(code) {
color: var(--color-accent);
}

:where(pre) {
background-color: var(--bg-page);
}
}

/* Shared link style */
.link {
color: var(--color-link);
transition: color 0.15s;

&:hover {
color: var(--color-link-hover);
}
}

/* Danger button (delete actions) */
.danger-btn {
color: var(--color-danger-muted);
transition: color 0.15s;

&:hover {
color: var(--color-danger);
}
}
2 changes: 2 additions & 0 deletions web/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Import CSS files here for hot module reloading to work.
import "./assets/styles.css";
Loading