Implement CHUNK #2: server layer, core schemas/adapters, contract tests, seed

Complete the server-backed storage layer for PubScript so the app can run
against a real Fastify + SQLite backend instead of only local IndexedDB.

Server (@pubscript/server):
- db.ts: better-sqlite3 schema + migrations (users, shares, projects, books,
  chapters, entities, boards, ai_sessions, audit); WAL mode; idempotent
  admin bootstrap via PUBSCRIPT_ADMIN_EMAIL/PASSWORD.
- auth.ts: argon2 hashing, JWT-ish session tokens, guards, auth routes.
- routes.ts: REST CRUD for projects/books/chapters/entities/boards/ai_sessions
  with multi-tenant isolation and optimistic concurrency (baseRev).
- ws.ts: Fastify websocket hub for autosave + presence events.
- mapping.ts: row <-> core type conversion.

Core (@pubscript/core):
- Zod domain schemas (projects, books, chapters, entities, boards, ai_sessions)
  exported from index.
- storage-local.ts (IndexedDB/Dexie) and storage-server.ts (REST) adapters
  implementing the StorageAdapter contract.

Tests & tooling:
- storage.contract.test.ts: parameterized over both adapters; wired as
  `pnpm contract` and added to CI.
- seed.ts (`pnpm --filter server seed`): creates demo project "The Glass Harbor"
  — 3 chapters of prose, 4 linked characters, 1 world location, 6-node board.
- Dockerfile + compose wiring for the server runtime.

Acceptance: seed project opens with 3 chapters, 4 linked characters, 1 board.
This commit is contained in:
Schwitzz
2026-10-24 18:31:20 -05:00
parent 53f4c5b4c7
commit 1c9b531996
95 changed files with 10006 additions and 16 deletions
+28
View File
@@ -0,0 +1,28 @@
{
"name": "@pubscript/ui",
"version": "0.1.0",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./package.json": "./package.json"
},
"scripts": {
"dev": "echo \"@pubscript/ui has no dev server\" (exit 0)",
"build": "tsc --noEmit",
"test": "vitest run",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@pubscript/core": "workspace:*",
"react": "^19.0.0"
},
"devDependencies": {
"@types/node": "^22.10.2",
"@types/react": "^19.0.2",
"typescript": "^5.7.2",
"vitest": "^2.1.8"
}
}
+416
View File
@@ -0,0 +1,416 @@
/* ============================================================================
* PubScript design system — CSS-variable token map + component styles.
* See PLAN.md §4.7 and CHUNK #1 step 3.
*
* All colors are theme tokens (--ps-*). Accent + density are runtime overrides.
* ============================================================================ */
:root {
--ps-accent: #7c8bf5;
--ps-density: 12px;
--ps-radius: 8px;
--ps-sidebar-w: 260px;
--ps-topbar-h: 48px;
--ps-font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--ps-font-body: Georgia, "Times New Roman", serif;
--ps-font-mono: "SFMono-Regular", ui-monospace, "Cascadia Code", Consolas, monospace;
}
/* ---- Theme token map (applied by applyTheme) ---- */
:root[data-theme="ink"] {
--ps-bg: #0e0f13;
--ps-surface: #17181f;
--ps-surface-2: #20222c;
--ps-border: #2a2d3a;
--ps-text: #e6e8ee;
--ps-text-muted: #9aa0b0;
--ps-accent: #7c8bf5;
--ps-accent-contrast: #0e0f13;
--ps-danger: #f16d7a;
--ps-success: #5fd08a;
--ps-warning: #f0b754;
}
:root[data-theme="parchment"] {
--ps-bg: #f6f1e7;
--ps-surface: #fffdf7;
--ps-surface-2: #f2ead8;
--ps-border: #e2d6bd;
--ps-text: #2c2720;
--ps-text-muted: #6f6656;
--ps-accent: #b5793a;
--ps-accent-contrast: #fffdf7;
--ps-danger: #c0392b;
--ps-success: #2e7d4f;
--ps-warning: #b8860b;
}
:root[data-theme="nocturne"] {
--ps-bg: #0c0b1e;
--ps-surface: #171533;
--ps-surface-2: #221f4a;
--ps-border: #2e2a5e;
--ps-text: #e8e6ff;
--ps-text-muted: #a49fcf;
--ps-accent: #9d7bff;
--ps-accent-contrast: #0c0b1e;
--ps-danger: #ff7b9c;
--ps-success: #6ee7a8;
--ps-warning: #ffd27b;
}
:root[data-theme="terminal"] {
--ps-bg: #05070a;
--ps-surface: #0a0f14;
--ps-surface-2: #0f1720;
--ps-border: #16232e;
--ps-text: #33ff9b;
--ps-text-muted: #2c8a63;
--ps-accent: #33ff9b;
--ps-accent-contrast: #05070a;
--ps-danger: #ff6b6b;
--ps-success: #33ff9b;
--ps-warning: #ffd93d;
}
* { box-sizing: border-box; }
html, body, #root {
height: 100%;
margin: 0;
}
body {
background: var(--ps-bg);
color: var(--ps-text);
font-family: var(--ps-font-ui);
font-size: 15px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
/* ---- App shell ---- */
.ps-app {
display: grid;
grid-template-columns: var(--ps-sidebar-w) 1fr;
grid-template-rows: var(--ps-topbar-h) 1fr;
grid-template-areas:
"sidebar topbar"
"sidebar main";
height: 100vh;
overflow: hidden;
}
.ps-sidebar {
grid-area: sidebar;
background: var(--ps-surface);
border-right: 1px solid var(--ps-border);
overflow-y: auto;
padding: var(--ps-density);
}
.ps-topbar {
grid-area: topbar;
display: flex;
align-items: center;
gap: 12px;
padding: 0 16px;
border-bottom: 1px solid var(--ps-border);
background: var(--ps-surface);
}
.ps-main {
grid-area: main;
overflow: auto;
}
.ps-content {
max-width: 820px;
margin: 0 auto;
padding: 32px 24px 96px;
font-family: var(--ps-font-body);
font-size: 17px;
line-height: 1.75;
}
/* ---- Sidebar nav ---- */
.ps-logo {
font-weight: 700;
font-size: 16px;
letter-spacing: -0.02em;
padding: 6px 8px;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 8px;
}
.ps-logo .ps-logo-mark { color: var(--ps-accent); }
.ps-nav-section-title {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--ps-text-muted);
padding: 12px 8px 4px;
}
.ps-nav-item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 8px 10px;
border: none;
background: transparent;
color: var(--ps-text);
text-align: left;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
text-decoration: none;
}
.ps-nav-item:hover { background: var(--ps-surface-2); }
.ps-nav-item.is-active { background: var(--ps-accent); color: var(--ps-accent-contrast); }
.ps-nav-item .ps-nav-icon { width: 18px; text-align: center; opacity: 0.85; }
/* ---- Buttons ---- */
.ps-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border: 1px solid transparent;
border-radius: 6px;
font-family: inherit;
font-weight: 500;
cursor: pointer;
transition: background 0.12s, border-color 0.12s;
white-space: nowrap;
}
.ps-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.ps-btn-sm { padding: 4px 10px; font-size: 13px; }
.ps-btn-md { padding: 7px 14px; font-size: 14px; }
.ps-btn-lg { padding: 10px 20px; font-size: 15px; }
.ps-btn-primary { background: var(--ps-accent); color: var(--ps-accent-contrast); }
.ps-btn-primary:hover:not(:disabled) { filter: brightness(1.08); }
.ps-btn-secondary { background: var(--ps-surface-2); border-color: var(--ps-border); color: var(--ps-text); }
.ps-btn-secondary:hover:not(:disabled) { background: var(--ps-border); }
.ps-btn-ghost { background: transparent; color: var(--ps-text); }
.ps-btn-ghost:hover:not(:disabled) { background: var(--ps-surface-2); }
.ps-btn-danger { background: var(--ps-danger); color: #fff; }
.ps-btn-spinner {
width: 14px; height: 14px; border-radius: 50%;
border: 2px solid currentColor; border-top-color: transparent;
animation: ps-spin 0.6s linear infinite;
}
@keyframes ps-spin { to { transform: rotate(360deg); } }
/* ---- Fields ---- */
.ps-field { display: flex; flex-direction: column; gap: 4px; margin-bottom: 14px; }
.ps-field-label { font-size: 13px; font-weight: 600; color: var(--ps-text-muted); }
.ps-input, .ps-select {
padding: 8px 10px;
border: 1px solid var(--ps-border);
border-radius: 6px;
background: var(--ps-bg);
color: var(--ps-text);
font-family: inherit;
font-size: 14px;
}
.ps-input:focus, .ps-select:focus {
outline: none;
border-color: var(--ps-accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ps-accent) 30%, transparent);
}
.ps-input.is-error, .ps-select.is-error { border-color: var(--ps-danger); }
.ps-field-hint { font-size: 12px; color: var(--ps-text-muted); margin: 0; }
.ps-field-hint:only-child { color: var(--ps-danger); }
/* ---- Modal ---- */
.ps-modal-root { position: fixed; inset: 0; z-index: 1000; }
.ps-modal-overlay {
position: absolute; inset: 0;
background: rgba(0, 0, 0, 0.55);
}
.ps-modal {
position: absolute;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
width: min(560px, 92vw);
max-height: 88vh;
display: flex; flex-direction: column;
background: var(--ps-surface);
border: 1px solid var(--ps-border);
border-radius: 10px;
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.4);
}
.ps-modal-header {
display: flex; align-items: center; justify-content: space-between;
padding: 16px 18px; border-bottom: 1px solid var(--ps-border);
}
.ps-modal-header h2 { margin: 0; font-size: 17px; }
.ps-modal-body { padding: 18px; overflow-y: auto; }
.ps-modal-footer {
display: flex; justify-content: flex-end; gap: 10px;
padding: 14px 18px; border-top: 1px solid var(--ps-border);
}
.ps-icon-btn {
border: none; background: transparent; color: var(--ps-text-muted);
cursor: pointer; font-size: 16px; line-height: 1; padding: 4px;
border-radius: 4px;
}
.ps-icon-btn:hover { background: var(--ps-surface-2); color: var(--ps-text); }
/* ---- Drawer ---- */
.ps-drawer-overlay {
position: fixed; inset: 0; z-index: 990;
background: rgba(0, 0, 0, 0.4);
}
.ps-drawer {
position: fixed; top: 0; bottom: 0; z-index: 991;
width: min(420px, 92vw);
background: var(--ps-surface);
border-left: 1px solid var(--ps-border);
display: flex; flex-direction: column;
box-shadow: -12px 0 40px rgba(0, 0, 0, 0.3);
}
.ps-drawer-left { border-left: none; border-right: 1px solid var(--ps-border); box-shadow: 12px 0 40px rgba(0,0,0,0.3); }
.ps-drawer-header {
display: flex; align-items: center; justify-content: space-between;
padding: 16px 18px; border-bottom: 1px solid var(--ps-border);
}
.ps-drawer-header h2 { margin: 0; font-size: 17px; }
.ps-drawer-body { padding: 18px; overflow-y: auto; }
/* ---- Tooltip ---- */
.ps-tooltip-wrap { position: relative; display: inline-flex; }
.ps-tooltip {
position: absolute;
z-index: 1100;
background: var(--ps-text);
color: var(--ps-bg);
font-size: 12px;
padding: 4px 8px;
border-radius: 5px;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity 0.12s;
}
.ps-tooltip-wrap:hover .ps-tooltip { opacity: 1; }
.ps-tooltip-wrap[data-placement="bottom"] .ps-tooltip { top: 100%; left: 50%; transform: translate(-50%, 6px); }
.ps-tooltip-wrap[data-placement="top"] .ps-tooltip { bottom: 100%; left: 50%; transform: translate(-50%, -6px); }
/* ---- Tabs ---- */
.ps-tabs { display: flex; flex-direction: column; height: 100%; }
.ps-tabs-nav {
display: flex; gap: 2px; border-bottom: 1px solid var(--ps-border);
}
.ps-tab {
border: none; background: transparent; color: var(--ps-text-muted);
padding: 10px 14px; cursor: pointer; font-size: 14px;
border-bottom: 2px solid transparent; font-family: inherit;
}
.ps-tab:hover { color: var(--ps-text); }
.ps-tab.is-active { color: var(--ps-text); border-bottom-color: var(--ps-accent); }
.ps-tab-badge {
display: inline-block; min-width: 18px; height: 18px; padding: 0 5px;
border-radius: 9px; background: var(--ps-surface-2); font-size: 11px;
line-height: 18px; text-align: center; margin-left: 6px;
}
.ps-tabs-panel { padding: 16px 0; }
/* ---- Badge ---- */
.ps-badge {
display: inline-block; padding: 2px 8px; border-radius: 999px;
font-size: 12px; font-weight: 600;
}
.ps-badge-neutral { background: var(--ps-surface-2); color: var(--ps-text-muted); }
.ps-badge-accent { background: color-mix(in srgb, var(--ps-accent) 22%, transparent); color: var(--ps-accent); }
.ps-badge-success { background: color-mix(in srgb, var(--ps-success) 22%, transparent); color: var(--ps-success); }
.ps-badge-danger { background: color-mix(in srgb, var(--ps-danger) 22%, transparent); color: var(--ps-danger); }
.ps-badge-warning { background: color-mix(in srgb, var(--ps-warning) 22%, transparent); color: var(--ps-warning); }
/* ---- Empty state ---- */
.ps-emptystate {
text-align: center; padding: 48px 24px; color: var(--ps-text-muted);
}
.ps-emptystate-icon { font-size: 36px; margin-bottom: 8px; }
.ps-emptystate-title { margin: 0 0 6px; color: var(--ps-text); font-size: 16px; }
.ps-emptystate-desc { margin: 0 0 16px; }
.ps-emptystate-action { display: flex; justify-content: center; gap: 10px; }
/* ---- Spinner ---- */
.ps-spinner { display: inline-flex; }
.ps-spinner > span {
display: inline-block; border-radius: 50%;
border: 2px solid currentColor; border-top-color: transparent;
animation: ps-spin 0.6s linear infinite;
}
.ps-spinner-sm > span { width: 14px; height: 14px; border-width: 2px; }
.ps-spinner-md > span { width: 24px; height: 24px; border-width: 2.5px; }
.ps-spinner-lg > span { width: 40px; height: 40px; border-width: 3px; }
/* ---- Toasts ---- */
.ps-toast-viewport {
position: fixed; bottom: 20px; right: 20px; z-index: 2000;
display: flex; flex-direction: column; gap: 8px; max-width: 360px;
}
.ps-toast {
padding: 12px 16px; border-radius: 8px;
background: var(--ps-surface-2); border: 1px solid var(--ps-border);
color: var(--ps-text); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
border-left: 3px solid var(--ps-accent);
}
.ps-toast-success { border-left-color: var(--ps-success); }
.ps-toast-error { border-left-color: var(--ps-danger); }
.ps-toast-warning { border-left-color: var(--ps-warning); }
/* ---- Command palette ---- */
.ps-command-palette-overlay {
position: fixed; inset: 0; z-index: 3000;
background: rgba(0, 0, 0, 0.55);
display: flex; align-items: flex-start; justify-content: center;
padding-top: 12vh;
}
.ps-command-palette {
width: min(620px, 92vw); max-height: 60vh;
background: var(--ps-surface); border: 1px solid var(--ps-border);
border-radius: 12px; box-shadow: 0 24px 60px rgba(0, 0, 0, 0.45);
display: flex; flex-direction: column; overflow: hidden;
}
.ps-command-input {
width: 100%; padding: 16px 18px; font-size: 16px;
border: none; background: transparent; color: var(--ps-text);
border-bottom: 1px solid var(--ps-border); font-family: inherit;
}
.ps-command-input:focus { outline: none; }
.ps-command-list { overflow-y: auto; padding: 8px; }
.ps-command-item {
display: flex; align-items: center; gap: 12px; width: 100%;
padding: 10px 12px; border: none; background: transparent; color: var(--ps-text);
text-align: left; border-radius: 6px; cursor: pointer; font-family: inherit; font-size: 14px;
}
.ps-command-item:hover, .ps-command-item.is-active { background: var(--ps-accent); color: var(--ps-accent-contrast); }
.ps-command-item .ps-command-icon { width: 20px; text-align: center; opacity: 0.8; }
.ps-command-item .ps-command-kbd { margin-left: auto; font-size: 12px; color: var(--ps-text-muted); }
.ps-command-empty { padding: 24px; text-align: center; color: var(--ps-text-muted); }
/* ---- Settings ---- */
.ps-settings { display: flex; flex-direction: column; gap: 8px; margin-bottom: 20px; }
.ps-settings-row {
display: flex; align-items: center; justify-content: space-between;
gap: 16px; padding: 12px 0; border-bottom: 1px solid var(--ps-border);
}
.ps-settings-row:last-child { border-bottom: none; }
.ps-settings-label { font-weight: 600; }
.ps-settings-desc { font-size: 13px; color: var(--ps-text-muted); margin: 2px 0 0; }
.ps-settings-group { margin-bottom: 24px; }
.ps-settings-group h3 { margin: 0 0 12px; font-size: 14px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--ps-text-muted); }
/* ---- Theme swatches ---- */
.ps-swatches { display: flex; gap: 10px; flex-wrap: wrap; }
.ps-swatch {
width: 44px; height: 44px; border-radius: 8px; border: 2px solid transparent;
cursor: pointer; position: relative;
}
.ps-swatch.is-active { border-color: var(--ps-text); }
/* ---- Scrollbars ---- */
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-thumb { background: var(--ps-border); border-radius: 5px; border: 2px solid var(--ps-bg); }
::-webkit-scrollbar-thumb:hover { background: var(--ps-text-muted); }
/* ---- Focus visible ---- */
:focus-visible { outline: 2px solid var(--ps-accent); outline-offset: 2px; }
+10
View File
@@ -0,0 +1,10 @@
export type BadgeVariant = "neutral" | "accent" | "success" | "danger" | "warning";
export interface BadgeProps {
children: string;
variant?: BadgeVariant;
}
export function Badge({ children, variant = "neutral" }: BadgeProps) {
return <span className={`ps-badge ps-badge-${variant}`}>{children}</span>;
}
+33
View File
@@ -0,0 +1,33 @@
import { type ButtonHTMLAttributes } from "react";
export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
export type ButtonSize = "sm" | "md" | "lg";
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
/** Show a spinner instead of content while loading. */
loading?: boolean;
}
export function Button({
variant = "primary",
size = "md",
loading = false,
className = "",
disabled,
children,
...rest
}: ButtonProps) {
return (
<button
type="button"
className={`ps-btn ps-btn-${variant} ps-btn-${size}${loading || disabled ? " is-disabled" : ""}${className ? " " + className : ""}`}
disabled={disabled || loading}
aria-busy={loading || undefined}
{...rest}
>
{loading ? <span className="ps-btn-spinner" aria-hidden /> : children}
</button>
);
}
+40
View File
@@ -0,0 +1,40 @@
import { type ReactNode, useEffect } from "react";
export interface DrawerProps {
open: boolean;
side?: "left" | "right";
title: string;
onClose: () => void;
children: ReactNode;
}
export function Drawer({ open, side = "right", title, onClose, children }: DrawerProps) {
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onClose]);
if (!open) return null;
return (
<>
<div className="ps-drawer-overlay" aria-hidden onClick={onClose} />
<aside
className={`ps-drawer ps-drawer-${side}`}
role="dialog"
aria-modal="true"
aria-label={title}
>
<header className="ps-drawer-header">
<h2>{title}</h2>
<button className="ps-icon-btn" onClick={onClose} aria-label="Close">✕</button>
</header>
<div className="ps-drawer-body">{children}</div>
</aside>
</>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { type ReactNode } from "react";
export interface EmptyStateProps {
icon?: string;
title: string;
description?: string;
action?: ReactNode;
}
export function EmptyState({ icon, title, description, action }: EmptyStateProps) {
return (
<div className="ps-emptystate">
{icon && <div className="ps-emptystate-icon" aria-hidden>{icon}</div>}
<h3 className="ps-emptystate-title">{title}</h3>
{description && <p className="ps-emptystate-desc">{description}</p>}
{action && <div className="ps-emptystate-action">{action}</div>}
</div>
);
}
+28
View File
@@ -0,0 +1,28 @@
import { type InputHTMLAttributes } from "react";
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
hint?: string;
error?: string;
}
export function Input({ label, hint, error, id, className = "", ...rest }: InputProps) {
const inputId = id ?? `input-${Math.random().toString(36).slice(2, 8)}`;
return (
<div className="ps-field">
{label && <label htmlFor={inputId} className="ps-field-label">{label}</label>}
<input
id={inputId}
className={`ps-input${error ? " is-error" : ""}${className ? " " + className : ""}`}
aria-invalid={error ? true : undefined}
aria-describedby={hint ? `${inputId}-hint` : undefined}
{...rest}
/>
{(hint || error) && (
<p className="ps-field-hint" id={`${inputId}-hint`} aria-live="polite">
{error ?? hint}
</p>
)}
</div>
);
}
+46
View File
@@ -0,0 +1,46 @@
import { type ReactNode, useEffect } from "react";
export interface ModalProps {
open: boolean;
title: string;
onClose: () => void;
children: ReactNode;
footer?: ReactNode;
}
/** Simple focus-trapping modal. */
export function Modal({ open, title, onClose, children, footer }: ModalProps) {
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onClose]);
if (!open) return null;
return (
<div className="ps-modal-root" role="presentation" aria-hidden>
<div
className="ps-modal-overlay"
onClick={onClose}
aria-hidden
/>
<div
className="ps-modal"
role="dialog"
aria-modal="true"
aria-label={title}
>
<header className="ps-modal-header">
<h2>{title}</h2>
<button className="ps-icon-btn" onClick={onClose} aria-label="Close">✕</button>
</header>
<div className="ps-modal-body">{children}</div>
{footer && <footer className="ps-modal-footer">{footer}</footer>}
</div>
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { type SelectHTMLAttributes } from "react";
export interface SelectOption {
value: string;
label: string;
}
export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
label?: string;
options: SelectOption[];
}
export function Select({ label, options, id, className = "", ...rest }: SelectProps) {
const selectId = id ?? `select-${Math.random().toString(36).slice(2, 8)}`;
return (
<div className="ps-field">
{label && <label htmlFor={selectId} className="ps-field-label">{label}</label>}
<select id={selectId} className={`ps-select ${className}`.trim()} {...rest}>
{options.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
export interface SpinnerProps {
size?: "sm" | "md" | "lg";
label?: string;
}
export function Spinner({ size = "md", label = "Loading" }: SpinnerProps) {
return (
<span
className={`ps-spinner ps-spinner-${size}`}
role="status"
aria-label={label}
>
<span aria-hidden />
</span>
);
}
+58
View File
@@ -0,0 +1,58 @@
import { type ReactNode, useState } from "react";
export interface TabDef {
id: string;
label: string;
content: ReactNode;
/** Optional badge count. */
badge?: number;
}
export interface TabsProps {
tabs: TabDef[];
/** Controlled initial/active tab id. */
defaultActive?: string;
onTabChange?: (id: string) => void;
}
export function Tabs({ tabs, defaultActive, onTabChange }: TabsProps) {
const [active, setActive] = useState(defaultActive ?? tabs[0]?.id ?? "");
const select = (id: string) => {
setActive(id);
onTabChange?.(id);
};
return (
<div className="ps-tabs">
<nav className="ps-tabs-nav" role="tablist" aria-orientation="horizontal">
{tabs.map((t) => (
<button
key={t.id}
role="tab"
aria-selected={t.id === active}
aria-controls={`panel-${t.id}`}
id={`tab-${t.id}`}
className={`ps-tab${t.id === active ? " is-active" : ""}`}
onClick={() => select(t.id)}
>
{t.label}
{t.badge !== undefined && <span className="ps-tab-badge">{t.badge}</span>}
</button>
))}
</nav>
{tabs.map((t) => (
<div
key={t.id}
role="tabpanel"
id={`panel-${t.id}`}
aria-labelledby={`tab-${t.id}`}
hidden={t.id !== active}
className="ps-tabs-panel"
>
{t.content}
</div>
))}
</div>
);
}
+62
View File
@@ -0,0 +1,62 @@
import { useState, useEffect } from "react";
export type ToastVariant = "info" | "success" | "error" | "warning";
export type Toast = { id: string; message: string; variant: ToastVariant };
type Listener = (toasts: Toast[]) => void;
const listeners = new Set<Listener>();
let toasts: Toast[] = [];
function notify(): void {
for (const l of listeners) l(toasts);
}
function push(message: string, variant: ToastVariant = "info", durationMs = 3500): string {
const id = `toast-${Math.random().toString(36).slice(2, 9)}`;
toasts = [...toasts, { id, message, variant }];
notify();
if (durationMs > 0) {
setTimeout(() => toast.dismiss(id), durationMs);
}
return id;
}
export const toast = {
info: (m: string, d?: number) => push(m, "info", d),
success: (m: string, d?: number) => push(m, "success", d),
error: (m: string, d?: number) => push(m, "error", d),
warning: (m: string, d?: number) => push(m, "warning", d),
dismiss(id: string) {
toasts = toasts.filter((t) => t.id !== id);
notify();
},
};
/** React hook subscribing to the global toast store. */
export function useToasts(): Toast[] {
const [list, setList] = useState<Toast[]>(toasts);
useEffect(() => {
const l: Listener = (next) => setList(next);
listeners.add(l);
l(toasts);
return () => {
listeners.delete(l);
};
}, []);
return list;
}
/** Render the live toast container. Mount once near the app root. */
export function ToastViewport() {
const items = useToasts();
if (items.length === 0) return null;
return (
<div className="ps-toast-viewport" aria-live="polite">
{items.map((t) => (
<div key={t.id} className={`ps-toast ps-toast-${t.variant}`}>
{t.message}
</div>
))}
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { type ReactNode } from "react";
export interface TooltipProps {
content: string;
children: ReactNode;
/** Placement hint. Defaults to "top". */
placement?: "top" | "bottom" | "left" | "right";
}
export function Tooltip({ content, children, placement = "top" }: TooltipProps) {
return (
<span className="ps-tooltip-wrap" data-placement={placement}>
{children}
<span className="ps-tooltip" role="tooltip">{content}</span>
</span>
);
}
+22
View File
@@ -0,0 +1,22 @@
export { Button } from "./Button.js";
export type { ButtonProps, ButtonVariant, ButtonSize } from "./Button.js";
export { Input } from "./Input.js";
export type { InputProps } from "./Input.js";
export { Select } from "./Select.js";
export type { SelectProps, SelectOption } from "./Select.js";
export { Modal } from "./Modal.js";
export type { ModalProps } from "./Modal.js";
export { Drawer } from "./Drawer.js";
export type { DrawerProps } from "./Drawer.js";
export { Tooltip } from "./Tooltip.js";
export type { TooltipProps } from "./Tooltip.js";
export { Tabs } from "./Tabs.js";
export type { TabsProps, TabDef } from "./Tabs.js";
export { Badge } from "./Badge.js";
export type { BadgeProps, BadgeVariant } from "./Badge.js";
export { EmptyState } from "./EmptyState.js";
export type { EmptyStateProps } from "./EmptyState.js";
export { Spinner } from "./Spinner.js";
export type { SpinnerProps } from "./Spinner.js";
export { toast, useToasts, ToastViewport } from "./Toast.js";
export type { Toast, ToastVariant } from "./Toast.js";
+36
View File
@@ -0,0 +1,36 @@
/**
* Font families per theme. See PLAN.md §4.7.
* System fonts only — no network fetches.
*/
export interface FontSet {
/** UI chrome font. */
ui: string;
/** Body / prose font. */
body: string;
/** Monospace for code / terminal theme. */
mono: string;
}
export const FONTS: Record<string, FontSet> = {
ink: {
ui: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
body: "Georgia, 'Times New Roman', serif",
mono: "'SFMono-Regular', ui-monospace, 'Cascadia Code', Consolas, monospace",
},
parchment: {
ui: "Georgia, 'Times New Roman', serif",
body: "Georgia, 'Times New Roman', serif",
mono: "'SFMono-Regular', ui-monospace, Consolas, monospace",
},
nocturne: {
ui: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
body: "Georgia, 'Times New Roman', serif",
mono: "'SFMono-Regular', ui-monospace, 'Cascadia Code', Consolas, monospace",
},
terminal: {
ui: "'SFMono-Regular', ui-monospace, 'Cascadia Code', Consolas, monospace",
body: "'SFMono-Regular', ui-monospace, Consolas, monospace",
mono: "'SFMono-Regular', ui-monospace, Consolas, monospace",
},
};
+10
View File
@@ -0,0 +1,10 @@
/**
* @pubscript/ui — the PubScript design system: theme engine, component kit,
* and the AppShell stylesheet. See CHUNK #1 step 3.
*/
export * from "./theme.js";
export * from "./fonts.js";
export * from "./components/index.js";
import "./AppShell.css";
+49
View File
@@ -0,0 +1,49 @@
import { describe, it, expect } from "vitest";
import { THEME_IDS, THEME_LABELS, THEME_TOKENS, getThemeTokens, applyTheme, getActiveTheme } from "./theme.js";
describe("theme token map", () => {
it("defines exactly four themes", () => {
expect(THEME_IDS).toEqual(["ink", "parchment", "nocturne", "terminal"]);
expect(Object.keys(THEME_TOKENS)).toHaveLength(4);
});
it("labels every theme", () => {
for (const id of THEME_IDS) {
expect(THEME_LABELS[id]).toBeTruthy();
}
});
it("each theme has a full set of color tokens", () => {
const required = [
"--ps-bg",
"--ps-surface",
"--ps-surface-2",
"--ps-border",
"--ps-text",
"--ps-text-muted",
"--ps-accent",
"--ps-accent-contrast",
"--ps-danger",
"--ps-success",
"--ps-warning",
];
for (const id of THEME_IDS) {
const css = THEME_TOKENS[id]!.css;
for (const key of required) {
expect(css[key], `${id} missing ${key}`).toBeTruthy();
}
}
});
it("getThemeTokens throws on unknown id", () => {
expect(() => getThemeTokens("nope" as never)).toThrow(/Unknown theme/);
});
it("applyTheme sets the active theme and returns it", () => {
expect(applyTheme("parchment")).toBe("parchment");
expect(getActiveTheme()).toBe("parchment");
// reset for other tests
applyTheme("ink");
expect(getActiveTheme()).toBe("ink");
});
});
+132
View File
@@ -0,0 +1,132 @@
/**
* Theme engine — CSS-variable token map. See PLAN.md §4.7 and CHUNK #1 step 3.
*
* Four themes ship as token objects; `applyTheme(id)` sets
* `document.documentElement.dataset.theme`. Accent color and density are
* runtime overrides layered on top.
*/
export type ThemeId = "ink" | "parchment" | "nocturne" | "terminal";
export const THEME_IDS: ThemeId[] = ["ink", "parchment", "nocturne", "terminal"];
export const THEME_LABELS: Record<ThemeId, string> = {
ink: "Ink",
parchment: "Parchment",
nocturne: "Nocturne",
terminal: "Terminal",
};
export interface ThemeTokens {
label: string;
/** CSS custom properties applied to :root when this theme is active. */
css: Record<string, string>;
}
/**
* The four themes. Ink is the default (dark). Parchment is warm light,
* Nocturne is deep indigo, Terminal is monochrome monospace.
*/
export const THEME_TOKENS: Record<ThemeId, ThemeTokens> = {
ink: {
label: "Ink",
css: {
"--ps-bg": "#0e0f13",
"--ps-surface": "#17181f",
"--ps-surface-2": "#20222c",
"--ps-border": "#2a2d3a",
"--ps-text": "#e6e8ee",
"--ps-text-muted": "#9aa0b0",
"--ps-accent": "#7c8bf5",
"--ps-accent-contrast": "#0e0f13",
"--ps-danger": "#f16d7a",
"--ps-success": "#5fd08a",
"--ps-warning": "#f0b754",
},
},
parchment: {
label: "Parchment",
css: {
"--ps-bg": "#f6f1e7",
"--ps-surface": "#fffdf7",
"--ps-surface-2": "#f2ead8",
"--ps-border": "#e2d6bd",
"--ps-text": "#2c2720",
"--ps-text-muted": "#6f6656",
"--ps-accent": "#b5793a",
"--ps-accent-contrast": "#fffdf7",
"--ps-danger": "#c0392b",
"--ps-success": "#2e7d4f",
"--ps-warning": "#b8860b",
},
},
nocturne: {
label: "Nocturne",
css: {
"--ps-bg": "#0c0b1e",
"--ps-surface": "#171533",
"--ps-surface-2": "#221f4a",
"--ps-border": "#2e2a5e",
"--ps-text": "#e8e6ff",
"--ps-text-muted": "#a49fcf",
"--ps-accent": "#9d7bff",
"--ps-accent-contrast": "#0c0b1e",
"--ps-danger": "#ff7b9c",
"--ps-success": "#6ee7a8",
"--ps-warning": "#ffd27b",
},
},
terminal: {
label: "Terminal",
css: {
"--ps-bg": "#05070a",
"--ps-surface": "#0a0f14",
"--ps-surface-2": "#0f1720",
"--ps-border": "#16232e",
"--ps-text": "#33ff9b",
"--ps-text-muted": "#2c8a63",
"--ps-accent": "#33ff9b",
"--ps-accent-contrast": "#05070a",
"--ps-danger": "#ff6b6b",
"--ps-success": "#33ff9b",
"--ps-warning": "#ffd93d",
},
},
};
/** The active theme id, persisted by the settings store. */
let activeTheme: ThemeId = "ink";
/** Return the token map for a theme id (throws on unknown). */
export function getThemeTokens(id: ThemeId): ThemeTokens {
const t = THEME_TOKENS[id];
if (!t) {
throw new Error(`Unknown theme: ${id}`);
}
return t;
}
/**
* Apply a theme by setting the data attribute and writing its CSS variables to
* :root. Returns the applied id. Safe to call before the DOM exists (no-op).
*/
export function applyTheme(id: ThemeId): ThemeId {
if (!THEME_TOKENS[id]) {
throw new Error(`Unknown theme: ${id}`);
}
activeTheme = id;
const root = typeof document !== "undefined" ? document.documentElement : undefined;
if (root) {
root.dataset.theme = id;
const { css } = THEME_TOKENS[id]!;
for (const [k, v] of Object.entries(css)) {
root.style.setProperty(k, v);
}
}
return id;
}
/** The currently active theme id. */
export function getActiveTheme(): ThemeId {
return activeTheme;
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"jsx": "react-jsx"
},
"include": ["src/**/*.ts", "src/**/*.tsx"]
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
include: ["src/**/*.test.ts"],
},
});