From ff847803055cb8d51df71c6dc49f7fad431ff24b Mon Sep 17 00:00:00 2001 From: Gregory Linscheid Date: Sun, 17 May 2026 20:13:02 -0700 Subject: [PATCH 1/4] as the title says --- ...react_vanilla_extract_hub_qq345m35.plan.md | 51 ++++ AGENTS.md | 23 +- bun.lock | 216 +++++++++++++++- index.html | 25 +- package.json | 4 + src/hub-app.ts | 86 ------- src/hub-app.tsx | 16 ++ src/hub.css | 191 --------------- src/hub/HubApp.tsx | 17 ++ src/hub/HubHeader.tsx | 21 ++ src/hub/HubSearch.tsx | 110 +++++++++ src/hub/global.css.ts | 29 +++ src/hub/hub.css.ts | 231 ++++++++++++++++++ src/hub/tokens.ts | 17 ++ src/routing.ts | 2 +- tsconfig.json | 2 + vite.config.ts | 6 +- 17 files changed, 734 insertions(+), 313 deletions(-) create mode 100644 .cursor/plans/preact_vanilla_extract_hub_qq345m35.plan.md delete mode 100644 src/hub-app.ts create mode 100644 src/hub-app.tsx delete mode 100644 src/hub.css create mode 100644 src/hub/HubApp.tsx create mode 100644 src/hub/HubHeader.tsx create mode 100644 src/hub/HubSearch.tsx create mode 100644 src/hub/global.css.ts create mode 100644 src/hub/hub.css.ts create mode 100644 src/hub/tokens.ts diff --git a/.cursor/plans/preact_vanilla_extract_hub_qq345m35.plan.md b/.cursor/plans/preact_vanilla_extract_hub_qq345m35.plan.md new file mode 100644 index 0000000..12102e7 --- /dev/null +++ b/.cursor/plans/preact_vanilla_extract_hub_qq345m35.plan.md @@ -0,0 +1,51 @@ +--- +name: Preact + Vanilla Extract hub (qq345m35) +overview: 'Upgrade vilos92.com `/` hub from vanilla DOM + hub.css to Preact with Vanilla Extract (zero-runtime, Workers-friendly). Reuse `publicProjects` / `searchPublicProjects`; validate with `vp check`, `vp test`, `bun run fallow:audit` before commit.' +todos: + - id: deps-ve-preact + content: 'Install Preact + @preact/preset-vite + @vanilla-extract/css + @vanilla-extract/vite-plugin via `bun i`; remove any Tailwind packages if present' + status: pending + - id: vite-tsconfig + content: 'Wire @preact/preset-vite and @vanilla-extract/vite-plugin in vite.config.ts; jsxImportSource preact in tsconfig' + status: pending + - id: hub-preact + content: 'Replace hub-app.ts with hub-app.tsx mount + HubApp component; slim index.html (#root only)' + status: pending + - id: hub-styles-ve + content: 'Replace hub.css utilities with Vanilla Extract styles (`.css.ts` modules); preserve dark minimal hub look + mobile layout' + status: pending + - id: validate-deploy + content: 'Run vp check, vp test, bun run fallow:audit; vp build + spot-check dev; do not complete qq345m35 until deploy-ready' + status: pending +isProject: false +--- + +# Preact + Vanilla Extract hub (`qq345m35`) + +**Dex:** `gdex greg show qq345m35 --full` · parent `stfb9fq5` (vilos92.com) + +## Stack (locked) + +| Layer | Choice | +| ------------ | ---------------------------------------------------------------------------- | +| UI | Preact (`preact`, `@preact/preset-vite`) | +| Styles | **Vanilla Extract** — `@vanilla-extract/css`, `@vanilla-extract/vite-plugin` | +| Not in scope | Tailwind, Panda | + +## Context on main + +- Hub entry: `src/hub-app.ts` + `index.html` + `src/hub.css` +- Search: `@/lib/project-search` (`searchPublicProjects`), catalog `@/lib/projects` (`publicProjects`) +- Worker: slug redirects only (`src/worker.ts`, `@/routing`) + +## Implementation notes + +- Entry stays `src/hub-app.tsx` per `.fallowrc.jsonc` `entry` +- Co-locate VE files e.g. `src/hub/HubApp.tsx` + `src/hub/hub.css.ts` +- Import global resets from `hub-app.tsx` or a small `src/hub/global.css.ts` if needed +- Match AGENTS.md section comments and `@/` imports + +## Done criteria (dex) + +- `/` uses Preact + Vanilla Extract +- Dev and production deploy look intentional, not starter-template diff --git a/AGENTS.md b/AGENTS.md index 2fab3f3..3c6930a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ Docs are local at `node_modules/vite-plus/docs` or online at https://viteplus.de ## Review Checklist - [ ] Run `vp install` after pulling remote changes and before getting started. -- [ ] Before commit (and after large / high-impact edits), run the **Validation** loop; fix failures before continuing. +- [ ] Run `vp check` and `vp test` to format, lint, type check and test changes. - [ ] Check if there are `vite.config.ts` tasks or `package.json` scripts necessary for validation, run via `vp run +
+ diff --git a/package.json b/package.json index 63f53a8..cc62342 100644 --- a/package.json +++ b/package.json @@ -20,11 +20,15 @@ "dependencies": { "fuzzysort": "^3.1.0", "hono": "^4.11.4", + "preact": "^10.29.2", "zod": "^4.4.3" }, "devDependencies": { "@cloudflare/vite-plugin": "^1.35.0", "@cloudflare/workers-types": "^4.20250525.0", + "@preact/preset-vite": "^2.10.5", + "@vanilla-extract/css": "^1.20.1", + "@vanilla-extract/vite-plugin": "^5.2.2", "fallow": "^2.66.1", "typescript": "~6.0.2", "vite": "npm:@voidzero-dev/vite-plus-core@latest", diff --git a/src/hub-app.ts b/src/hub-app.ts deleted file mode 100644 index 333c594..0000000 --- a/src/hub-app.ts +++ /dev/null @@ -1,86 +0,0 @@ -import '@/hub.css'; -import {GITHUB_PROFILE_URL} from '@/lib/github'; -import {searchPublicProjects} from '@/lib/project-search'; -import {publicProjects} from '@/lib/projects'; - -/* - * Constants. - */ - -const form = requireElement('#go'); -const input = requireElement('#slug'); -const resultsEl = requireElement('#results'); -const siteTitle = requireElement('.site-title'); - -/* - * Script. - */ - -siteTitle.href = GITHUB_PROFILE_URL; - -input.addEventListener('input', () => { - renderResults(input.value); -}); - -form.addEventListener('submit', submitEvent => { - submitEvent.preventDefault(); - goToSlug(input.value); -}); - -/* - * Helpers. - */ - -function requireElement(selector: string): T { - const element = document.querySelector(selector); - if (!element) { - throw new Error(`hub markup missing ${selector}`); - } - return element; -} - -function openInNewTab(url: string) { - globalThis.open(url, '_blank', 'noopener,noreferrer'); -} - -function goToSlug(slug: string) { - const trimmed = slug.trim(); - if (!trimmed) { - return; - } - openInNewTab(`/${encodeURIComponent(trimmed)}`); -} - -function newTabLink(url: string): HTMLAnchorElement { - const link = document.createElement('a'); - link.href = url; - link.target = '_blank'; - link.rel = 'noopener noreferrer'; - return link; -} - -function newTabHint(): HTMLSpanElement { - const hint = document.createElement('span'); - hint.className = 'visually-hidden'; - hint.textContent = ' (opens in new tab)'; - return hint; -} - -function renderResults(query: string) { - const matches = searchPublicProjects(publicProjects, query); - resultsEl.replaceChildren( - ...matches.map(project => { - const item = document.createElement('li'); - const link = newTabLink(`/${encodeURIComponent(project.slug)}`); - const name = document.createElement('span'); - name.className = 'name'; - name.textContent = project.name; - const slug = document.createElement('span'); - slug.className = 'slug'; - slug.textContent = `/${project.slug}`; - link.append(name, slug, newTabHint()); - item.append(link); - return item; - }) - ); -} diff --git a/src/hub-app.tsx b/src/hub-app.tsx new file mode 100644 index 0000000..13f1e35 --- /dev/null +++ b/src/hub-app.tsx @@ -0,0 +1,16 @@ +import {render} from 'preact'; + +import {HubApp} from '@/hub/HubApp'; + +import '@/hub/global.css'; + +/* + * Script. + */ + +const root = document.getElementById('root'); +if (!root) { + throw new Error('hub markup missing #root'); +} + +render(, root); diff --git a/src/hub.css b/src/hub.css deleted file mode 100644 index 7dfcd7a..0000000 --- a/src/hub.css +++ /dev/null @@ -1,191 +0,0 @@ -* { - box-sizing: border-box; -} - -body { - margin: 0; - min-height: 100dvh; - display: grid; - place-items: center; - font-family: system-ui, sans-serif; - background: #0f0f10; - color: #f4f4f5; -} - -main { - display: flex; - flex-direction: column; - align-items: center; - width: min(24rem, 92vw); -} - -h1 { - margin: 0 0 1rem; - width: 100%; - font-size: 1.25rem; - font-weight: 600; - letter-spacing: -0.02em; - text-align: center; -} - -.site-title { - color: inherit; - text-decoration: none; - border-radius: 0.25rem; -} - -.site-title:hover { - text-decoration: underline; - text-decoration-color: #71717a; - text-underline-offset: 0.2em; -} - -.site-title:focus-visible { - outline: 2px solid #71717a; - outline-offset: 3px; -} - -.visually-hidden { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip-path: inset(50%); - white-space: nowrap; - border: 0; -} - -.search { - position: relative; - width: 100%; -} - -form { - display: flex; - gap: 0.5rem; -} - -input { - flex: 1; - padding: 0.6rem 0.75rem; - border: 1px solid #3f3f46; - border-radius: 0.375rem; - background: #18181b; - color: inherit; - font: inherit; -} - -input:focus { - outline: 2px solid #71717a; - outline-offset: 1px; -} - -button { - padding: 0.6rem 1rem; - border: 0; - border-radius: 0.375rem; - background: #f4f4f5; - color: #18181b; - font: inherit; - font-weight: 600; - cursor: pointer; -} - -#results { - position: absolute; - top: calc(100% + 0.5rem); - right: 0; - left: 0; - z-index: 1; - margin: 0; - padding: 0; - max-height: min(18rem, 40dvh); - border: 1px solid #3f3f46; - border-radius: 0.375rem; - overflow-x: hidden; - overflow-y: auto; - list-style: none; - background: #18181b; - box-shadow: 0 12px 32px rgb(0 0 0 / 0.45); -} - -#results:empty { - display: none; -} - -#results a { - display: block; - padding: 0.5rem 0.75rem; - color: inherit; - text-decoration: none; -} - -#results a:hover, -#results a:focus-visible { - background: #27272a; -} - -#results .slug { - display: block; - font-size: 0.8125rem; - color: #a1a1aa; -} - -@media (pointer: coarse) { - body { - display: flex; - flex-direction: column; - align-items: stretch; - padding: max(1rem, env(safe-area-inset-top)) 1rem max(1rem, env(safe-area-inset-bottom)); - } - - main { - display: flex; - flex: 1; - flex-direction: column; - align-items: stretch; - width: 100%; - min-height: 0; - } - - h1 { - flex-shrink: 0; - margin-bottom: 0.75rem; - text-align: left; - } - - .search { - display: flex; - flex: 1; - flex-direction: column; - min-height: 0; - } - - form { - flex-shrink: 0; - } - - input, - button { - min-height: 2.75rem; - } - - #results { - position: static; - flex: 1; - min-height: 0; - margin-top: 0.75rem; - max-height: none; - box-shadow: none; - } - - #results:empty { - display: block; - } - - #results a { - padding: 0.75rem 1rem; - } -} diff --git a/src/hub/HubApp.tsx b/src/hub/HubApp.tsx new file mode 100644 index 0000000..d989f4f --- /dev/null +++ b/src/hub/HubApp.tsx @@ -0,0 +1,17 @@ +import {HubHeader} from '@/hub/HubHeader'; +import {HubSearch} from '@/hub/HubSearch'; + +import * as styles from '@/hub/hub.css'; + +/* + * Component. + */ + +export function HubApp() { + return ( +
+ + +
+ ); +} diff --git a/src/hub/HubHeader.tsx b/src/hub/HubHeader.tsx new file mode 100644 index 0000000..ae94b4c --- /dev/null +++ b/src/hub/HubHeader.tsx @@ -0,0 +1,21 @@ +import {GITHUB_PROFILE_URL} from '@/lib/github'; + +import * as styles from '@/hub/hub.css'; + +/* + * Component. + */ + +export function HubHeader() { + return ( +
+

+ + vilos92 + on GitHub (opens in new tab) + +

+

Jump to a public repo by slug

+
+ ); +} diff --git a/src/hub/HubSearch.tsx b/src/hub/HubSearch.tsx new file mode 100644 index 0000000..230395a --- /dev/null +++ b/src/hub/HubSearch.tsx @@ -0,0 +1,110 @@ +import {useMemo, useState} from 'preact/hooks'; + +import {searchPublicProjects} from '@/lib/project-search'; +import {publicProjects, type Project} from '@/lib/projects'; + +import * as styles from '@/hub/hub.css'; + +/* + * Component. + */ + +export function HubSearch() { + const [query, setQuery] = useState(''); + const matches = useMemo(() => searchPublicProjects(publicProjects, query), [query]); + const trimmedQuery = query.trim(); + const hasQuery = trimmedQuery.length > 0; + const showEmptyState = hasQuery && matches.length === 0; + const showResults = matches.length > 0 || showEmptyState; + + return ( +
+
{ + submitEvent.preventDefault(); + openProjectSlug(query); + }} + > + + { + setQuery((inputEvent.currentTarget as HTMLInputElement).value); + }} + placeholder="project slug" + spellcheck={false} + type="search" + value={query} + /> + +
+ + {showResults && ( + + )} +
+ ); +} + +/* + * Helpers. + */ + +type HubResultsProps = { + matches: Project[]; + showEmptyState: boolean; + trimmedQuery: string; +}; + +function HubResults({matches, showEmptyState, trimmedQuery}: HubResultsProps) { + return ( + + ); +} + +function openProjectSlug(slug: string) { + const trimmed = slug.trim(); + if (!trimmed) { + return; + } + + globalThis.open(`/${encodeURIComponent(trimmed)}`, '_blank', 'noopener,noreferrer'); +} diff --git a/src/hub/global.css.ts b/src/hub/global.css.ts new file mode 100644 index 0000000..883bf4d --- /dev/null +++ b/src/hub/global.css.ts @@ -0,0 +1,29 @@ +import {globalStyle} from '@vanilla-extract/css'; + +import {palette} from '@/hub/tokens'; + +/* + * Styles. + */ + +globalStyle('*', { + boxSizing: 'border-box' +}); + +globalStyle('body', { + margin: 0, + minHeight: '100dvh', + display: 'grid', + placeItems: 'center', + fontFamily: 'system-ui, sans-serif', + background: palette.pageBg, + color: palette.text, + '@media': { + '(pointer: coarse)': { + display: 'flex', + flexDirection: 'column', + alignItems: 'stretch', + padding: 'max(1rem, env(safe-area-inset-top)) 1rem max(1rem, env(safe-area-inset-bottom))' + } + } +}); diff --git a/src/hub/hub.css.ts b/src/hub/hub.css.ts new file mode 100644 index 0000000..169ced7 --- /dev/null +++ b/src/hub/hub.css.ts @@ -0,0 +1,231 @@ +import {style} from '@vanilla-extract/css'; + +import {palette} from '@/hub/tokens'; + +/* + * Styles. + */ + +export const main = style({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + width: 'min(24rem, 92vw)', + minWidth: 0, + '@media': { + '(pointer: coarse)': { + flex: 1, + alignItems: 'stretch', + width: '100%', + minHeight: 0 + } + } +}); + +export const header = style({ + marginBottom: '1rem', + width: '100%', + '@media': { + '(pointer: coarse)': { + flexShrink: 0, + marginBottom: '0.75rem' + } + } +}); + +export const title = style({ + margin: 0, + fontSize: '1.25rem', + fontWeight: 600, + letterSpacing: '-0.02em', + textAlign: 'center', + '@media': { + '(pointer: coarse)': { + textAlign: 'left' + } + } +}); + +export const siteTitle = style({ + color: 'inherit', + textDecoration: 'none', + borderRadius: '0.25rem', + selectors: { + '&:hover': { + textDecoration: 'underline', + textDecorationColor: palette.textSubtle, + textUnderlineOffset: '0.2em' + }, + '&:focus-visible': { + outline: `2px solid ${palette.textSubtle}`, + outlineOffset: '3px' + } + } +}); + +export const tagline = style({ + margin: '0.25rem 0 0', + fontSize: '0.875rem', + color: palette.textSubtle, + textAlign: 'center', + '@media': { + '(pointer: coarse)': { + textAlign: 'left' + } + } +}); + +export const search = style({ + position: 'relative', + width: '100%', + minWidth: 0, + '@media': { + '(pointer: coarse)': { + display: 'flex', + flex: 1, + flexDirection: 'column', + minHeight: 0 + } + } +}); + +export const form = style({ + display: 'flex', + gap: '0.5rem', + flexShrink: 0 +}); + +export const visuallyHidden = style({ + position: 'absolute', + width: '1px', + height: '1px', + padding: 0, + margin: '-1px', + overflow: 'hidden', + clipPath: 'inset(50%)', + whiteSpace: 'nowrap', + border: 0 +}); + +export const input = style({ + flex: 1, + minWidth: 0, + padding: '0.6rem 0.75rem', + border: `1px solid ${palette.border}`, + borderRadius: '0.375rem', + background: palette.surface, + color: 'inherit', + font: 'inherit', + outline: 'none', + selectors: { + '&::placeholder': { + color: palette.textSubtle + }, + '&:focus': { + borderColor: palette.borderFocus, + boxShadow: `0 0 0 2px ${palette.focusRing}` + } + }, + '@media': { + '(pointer: coarse)': { + minHeight: '2.75rem' + } + } +}); + +export const submitButton = style({ + flexShrink: 0, + padding: '0.6rem 1rem', + border: 0, + borderRadius: '0.375rem', + background: palette.buttonBg, + color: palette.buttonText, + font: 'inherit', + fontWeight: 600, + cursor: 'pointer', + transition: 'background 120ms ease', + selectors: { + '&:hover:not(:disabled)': { + background: '#ffffff' + }, + '&:focus-visible': { + outline: `2px solid ${palette.borderFocus}`, + outlineOffset: '2px' + }, + '&:disabled': { + cursor: 'not-allowed', + opacity: 0.4 + } + }, + '@media': { + '(pointer: coarse)': { + minHeight: '2.75rem' + } + } +}); + +export const results = style({ + position: 'absolute', + top: 'calc(100% + 0.5rem)', + right: 0, + left: 0, + zIndex: 10, + margin: 0, + padding: 0, + maxHeight: 'min(18rem, 40dvh)', + listStyle: 'none', + overflowX: 'hidden', + overflowY: 'auto', + border: `1px solid ${palette.border}`, + borderRadius: '0.375rem', + background: palette.surface, + boxShadow: '0 12px 32px rgb(0 0 0 / 0.45)', + '@media': { + '(pointer: coarse)': { + position: 'static', + flex: 1, + minHeight: 0, + marginTop: '0.75rem', + maxHeight: 'none', + boxShadow: 'none' + } + } +}); + +export const resultLink = style({ + display: 'block', + padding: '0.5rem 0.75rem', + color: 'inherit', + textDecoration: 'none', + selectors: { + '&:hover': { + background: palette.surfaceHover + }, + '&:focus-visible': { + background: palette.surfaceHover, + outline: 'none' + } + }, + '@media': { + '(pointer: coarse)': { + padding: '0.75rem 1rem' + } + } +}); + +export const resultName = style({ + display: 'block', + fontWeight: 500 +}); + +export const resultSlug = style({ + display: 'block', + fontSize: '0.8125rem', + color: palette.textMuted +}); + +export const emptyMessage = style({ + padding: '0.75rem', + fontSize: '0.875rem', + color: palette.textSubtle +}); diff --git a/src/hub/tokens.ts b/src/hub/tokens.ts new file mode 100644 index 0000000..cd116fc --- /dev/null +++ b/src/hub/tokens.ts @@ -0,0 +1,17 @@ +/* + * Constants. + */ + +export const palette = { + pageBg: '#0f0f10', + surface: '#18181b', + surfaceHover: '#27272a', + border: '#3f3f46', + borderFocus: '#71717a', + text: '#f4f4f5', + textMuted: '#a1a1aa', + textSubtle: '#71717a', + buttonBg: '#f4f4f5', + buttonText: '#18181b', + focusRing: 'rgb(113 113 122 / 0.6)' +} as const; diff --git a/src/routing.ts b/src/routing.ts index e6d3f49..743aa64 100644 --- a/src/routing.ts +++ b/src/routing.ts @@ -9,7 +9,7 @@ import {fuzzyFindPublicProject} from '@/lib/slug-fuzzy'; type RedirectResult = {kind: 'redirect'; location: string} | {kind: 'not_found'; slug: string}; /* - * API. + * Helpers. */ /** Resolve `/:slug` (not `/`) to a redirect target or `404`. */ diff --git a/tsconfig.json b/tsconfig.json index 3dddea3..848887d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,8 @@ "target": "es2023", "module": "esnext", "lib": ["ES2023", "DOM"], + "jsx": "react-jsx", + "jsxImportSource": "preact", "types": ["vite/client"], "skipLibCheck": true, "strict": true, diff --git a/vite.config.ts b/vite.config.ts index 4b65e9d..98f8c12 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,4 +1,6 @@ import {cloudflare} from '@cloudflare/vite-plugin'; +import preact from '@preact/preset-vite'; +import {vanillaExtractPlugin} from '@vanilla-extract/vite-plugin'; import {defineConfig} from 'vite-plus'; import tsconfigPaths from 'vite-tsconfig-paths'; @@ -20,11 +22,11 @@ const REPO_TS_FMT_OPTIONS = { }; /* - * API. + * Config. */ export default defineConfig({ - plugins: isVitest ? [tsconfigPaths()] : [cloudflare(), tsconfigPaths()], + plugins: isVitest ? [tsconfigPaths()] : [cloudflare(), vanillaExtractPlugin(), preact(), tsconfigPaths()], staged: { '*': 'vp check --fix' }, From 174b90aef9372eba544a3bd16089bc58f9ea266e Mon Sep 17 00:00:00 2001 From: Gregory Linscheid Date: Sun, 17 May 2026 20:55:27 -0700 Subject: [PATCH 2/4] bunfig.toml --- bunfig.toml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 bunfig.toml diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..c421fa9 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,5 @@ +# Bun project config — https://bun.sh/docs/runtime/bunfig +[install] +# Only install versions published at least 7 days ago (supply-chain mitigation). +# https://bun.sh/docs/runtime/bunfig#install-minimumreleaseage +minimumReleaseAge = 604_800 From 6ff15c731b2d2585891c482723c824666d108c9d Mon Sep 17 00:00:00 2001 From: Gregory Linscheid Date: Sun, 17 May 2026 21:58:48 -0700 Subject: [PATCH 3/4] coderabbit feedback --- src/hub/HubSearch.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/hub/HubSearch.tsx b/src/hub/HubSearch.tsx index 230395a..580cf62 100644 --- a/src/hub/HubSearch.tsx +++ b/src/hub/HubSearch.tsx @@ -67,18 +67,14 @@ type HubResultsProps = { function HubResults({matches, showEmptyState, trimmedQuery}: HubResultsProps) { return ( -