cantip 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +61 -0
  3. package/app/components/CanvasMount.tsx +62 -0
  4. package/app/components/CodeWrapToggle.tsx +78 -0
  5. package/app/components/FindOnPage.tsx +224 -0
  6. package/app/components/MobileBottomBar.tsx +93 -0
  7. package/app/components/MobileProjectsPanel.tsx +113 -0
  8. package/app/components/PageFloatingMenu.tsx +224 -0
  9. package/app/components/ProjectSwitcher.tsx +124 -0
  10. package/app/components/Search.tsx +930 -0
  11. package/app/components/ShortcutsHelp.tsx +113 -0
  12. package/app/components/Sidebar.tsx +1049 -0
  13. package/app/components/TabBar.tsx +227 -0
  14. package/app/components/Toc.tsx +129 -0
  15. package/app/components/TopBar.tsx +74 -0
  16. package/app/components/theme-toggle.tsx +71 -0
  17. package/app/components/ui/button.tsx +56 -0
  18. package/app/components/ui/card.tsx +55 -0
  19. package/app/components/ui/dropdown-menu.tsx +156 -0
  20. package/app/components/ui/input.tsx +21 -0
  21. package/app/entry.client.tsx +12 -0
  22. package/app/entry.server.tsx +155 -0
  23. package/app/generated/site.ts +19 -0
  24. package/app/generated/slots.ts +10 -0
  25. package/app/generated/theme.generated.css +60 -0
  26. package/app/lib/config/config.server.ts +50 -0
  27. package/app/lib/config/defaults.ts +120 -0
  28. package/app/lib/config/load.ts +82 -0
  29. package/app/lib/config/schema.ts +131 -0
  30. package/app/lib/config/site.ts +43 -0
  31. package/app/lib/content.server.ts +105 -0
  32. package/app/lib/projects.ts +86 -0
  33. package/app/lib/sidebar.server.ts +113 -0
  34. package/app/lib/site.ts +27 -0
  35. package/app/lib/slots.tsx +33 -0
  36. package/app/lib/tabs.tsx +128 -0
  37. package/app/lib/useKeyboardShortcuts.ts +149 -0
  38. package/app/lib/utils.ts +17 -0
  39. package/app/root.tsx +171 -0
  40. package/app/routes/$.tsx +158 -0
  41. package/app/routes/_index.tsx +60 -0
  42. package/app/styles/app.css +461 -0
  43. package/app/styles/obsidian.css +83 -0
  44. package/app/styles/tailwind.css +227 -0
  45. package/cli.js +119 -0
  46. package/components.json +21 -0
  47. package/dist/config.mjs +87 -0
  48. package/dist/generate-content.mjs +1665 -0
  49. package/package.json +112 -0
  50. package/scripts/build-search-index.ts +129 -0
  51. package/scripts/canonical.ts +34 -0
  52. package/scripts/canvas-to-md.ts +73 -0
  53. package/scripts/compile.ts +242 -0
  54. package/scripts/emit-config.ts +163 -0
  55. package/scripts/generate-content.ts +197 -0
  56. package/scripts/obsidian/files.ts +222 -0
  57. package/scripts/obsidian/fs.ts +34 -0
  58. package/scripts/obsidian/generate.ts +36 -0
  59. package/scripts/obsidian/html.ts +17 -0
  60. package/scripts/obsidian/logger.ts +10 -0
  61. package/scripts/obsidian/markdown.ts +56 -0
  62. package/scripts/obsidian/obsidian.ts +229 -0
  63. package/scripts/obsidian/path.ts +60 -0
  64. package/scripts/obsidian/rehype.ts +60 -0
  65. package/scripts/obsidian/remark.ts +712 -0
  66. package/scripts/obsidian/types.ts +31 -0
  67. package/vite.config.ts +62 -0
@@ -0,0 +1,149 @@
1
+ import { useEffect } from 'react'
2
+
3
+ /**
4
+ * App-wide keyboard shortcuts: single keys (Gmail/GitHub-style — `l`, `w`) and
5
+ * two-key sequences (Gmail-style `g` then `c`). These live alongside the
6
+ * existing Cmd/Ctrl chords (Cmd+K search, Cmd+P file-open); those keep their own
7
+ * listeners and pass straight through this one untouched.
8
+ *
9
+ * Why single keys are safe: browsers reserve almost nothing for bare letters as
10
+ * long as focus isn't in a text field. The handler's first job is to bail when
11
+ * the user is typing (input/textarea/contenteditable) or when any modifier is
12
+ * held — that lets Cmd+K et al. fall through and stops shortcuts from firing
13
+ * mid-typing. Sequences never collide with the browser at all (it has no notion
14
+ * of "g then c").
15
+ */
16
+
17
+ export type ShortcutGroup = 'Дерево' | 'Вкладки' | 'Навигация'
18
+
19
+ /** A binding the caller wires to behavior, plus the metadata the `?` overlay shows. */
20
+ export type Shortcut = {
21
+ /** Single key (e.g. 'l') OR a two-key sequence (e.g. ['g', 'c']). Compared case-insensitively. */
22
+ keys: string | [string, string]
23
+ /** What it does — shown in the cheatsheet. */
24
+ label: string
25
+ /** Cheatsheet section. */
26
+ group: ShortcutGroup
27
+ /** Handler. Only invoked when focus is outside text fields and no modifier is held. */
28
+ run: () => void
29
+ }
30
+
31
+ /**
32
+ * A display-only entry for shortcuts owned elsewhere (the existing Cmd+K / Cmd+P
33
+ * chords, the Shift+Enter row action) so the `?` overlay can list everything in
34
+ * one place without those handlers being re-registered here.
35
+ */
36
+ export type ShortcutInfo = {
37
+ /** Human-readable key hint, e.g. '⌘K', 'Shift+Enter'. */
38
+ hint: string
39
+ label: string
40
+ group: ShortcutGroup
41
+ }
42
+
43
+ /**
44
+ * The full cheatsheet, hand-maintained as the single source of truth for what the
45
+ * `?` overlay shows. Handlers live with their components (Sidebar, TabBar) via
46
+ * useKeyboardShortcuts; this list just describes every binding in one place,
47
+ * including the Cmd/Ctrl chords and the Shift+Enter row action owned elsewhere.
48
+ * Keep in sync when adding a binding.
49
+ */
50
+ export const ALL_SHORTCUTS: ShortcutInfo[] = [
51
+ { hint: '⌘/Ctrl K', label: 'Поиск по содержимому', group: 'Навигация' },
52
+ { hint: '?', label: 'Показать список горячих клавиш', group: 'Навигация' },
53
+ { hint: '⌘/Ctrl P', label: 'Поиск файла по имени', group: 'Дерево' },
54
+ { hint: 'l', label: 'Найти текущую страницу в дереве', group: 'Дерево' },
55
+ { hint: 'c', label: 'Свернуть все папки', group: 'Дерево' },
56
+ { hint: 'w', label: 'Закрыть текущую вкладку', group: 'Вкладки' },
57
+ { hint: 'Shift+Enter', label: 'Файл — открыть в новой вкладке; папку — развернуть рекурсивно', group: 'Дерево' },
58
+ ]
59
+
60
+ /** True when focus is in an editable surface — single-key shortcuts must not fire there. */
61
+ function isTypingTarget(el: EventTarget | null): boolean {
62
+ if (!(el instanceof HTMLElement)) return false
63
+ const tag = el.tagName
64
+ return tag === 'INPUT' || tag === 'TEXTAREA' || el.isContentEditable
65
+ }
66
+
67
+ /** How long (ms) the first key of a sequence stays "armed" before the buffer clears. */
68
+ const SEQUENCE_WINDOW_MS = 1000
69
+
70
+ /**
71
+ * Register the given shortcuts on `window`. Re-registers when `shortcuts`
72
+ * changes identity, so callers should memoize or accept the (cheap) churn.
73
+ */
74
+ export function useKeyboardShortcuts(shortcuts: Shortcut[]): void {
75
+ useEffect(() => {
76
+ // Split into single-key and sequence bindings, keyed lowercase.
77
+ const singles = new Map<string, Shortcut>()
78
+ const sequences = new Map<string, Map<string, Shortcut>>() // firstKey -> (secondKey -> shortcut)
79
+ for (const s of shortcuts) {
80
+ if (typeof s.keys === 'string') {
81
+ singles.set(s.keys.toLowerCase(), s)
82
+ } else {
83
+ const [a, b] = s.keys
84
+ const branch = sequences.get(a.toLowerCase()) ?? new Map()
85
+ branch.set(b.toLowerCase(), s)
86
+ sequences.set(a.toLowerCase(), branch)
87
+ }
88
+ }
89
+
90
+ let pending: string | null = null
91
+ let timer: ReturnType<typeof setTimeout> | null = null
92
+ const clearPending = () => {
93
+ pending = null
94
+ if (timer) {
95
+ clearTimeout(timer)
96
+ timer = null
97
+ }
98
+ }
99
+
100
+ const onKey = (e: KeyboardEvent) => {
101
+ // Let Cmd/Ctrl/Alt chords (search, file-open, browser shortcuts) pass through,
102
+ // and never fire while the user is typing. Checked first, always.
103
+ if (e.metaKey || e.ctrlKey || e.altKey) {
104
+ clearPending()
105
+ return
106
+ }
107
+ if (isTypingTarget(e.target)) {
108
+ clearPending()
109
+ return
110
+ }
111
+
112
+ const key = e.key.toLowerCase()
113
+
114
+ // Resolving the second key of an armed sequence takes priority.
115
+ if (pending) {
116
+ const branch = sequences.get(pending)
117
+ clearPending()
118
+ const match = branch?.get(key)
119
+ if (match) {
120
+ e.preventDefault()
121
+ match.run()
122
+ return
123
+ }
124
+ // Not a valid continuation — fall through so this key can still be a
125
+ // single-key shortcut on its own.
126
+ }
127
+
128
+ // Arm a new sequence if this key starts one.
129
+ if (sequences.has(key)) {
130
+ pending = key
131
+ timer = setTimeout(clearPending, SEQUENCE_WINDOW_MS)
132
+ return
133
+ }
134
+
135
+ // Single-key shortcut.
136
+ const single = singles.get(key)
137
+ if (single) {
138
+ e.preventDefault()
139
+ single.run()
140
+ }
141
+ }
142
+
143
+ window.addEventListener('keydown', onKey)
144
+ return () => {
145
+ window.removeEventListener('keydown', onKey)
146
+ clearPending()
147
+ }
148
+ }, [shortcuts])
149
+ }
@@ -0,0 +1,17 @@
1
+ import { clsx, type ClassValue } from 'clsx'
2
+ import { twMerge } from 'tailwind-merge'
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs))
6
+ }
7
+
8
+ // MoSCoW priority tags, in descending priority. Stored alongside type tags
9
+ // (user-story, feature, …) in a doc's `tags` frontmatter array.
10
+ export const PRIORITY_TAGS = ['must-have', 'should-have', 'could-have', 'wont-have'] as const
11
+ export type PriorityTag = (typeof PRIORITY_TAGS)[number]
12
+
13
+ /** The MoSCoW priority tag from a doc's tags, or null if none is present. */
14
+ export function getPriority(tags: unknown): PriorityTag | null {
15
+ if (!Array.isArray(tags)) return null
16
+ return (PRIORITY_TAGS.find((p) => tags.includes(p)) as PriorityTag | undefined) ?? null
17
+ }
package/app/root.tsx ADDED
@@ -0,0 +1,171 @@
1
+ import {
2
+ Links,
3
+ Meta,
4
+ Outlet,
5
+ Scripts,
6
+ ScrollRestoration,
7
+ useLoaderData,
8
+ useLocation,
9
+ } from '@remix-run/react'
10
+ import { useEffect, useState } from 'react'
11
+ import type { LinksFunction, LoaderFunctionArgs } from '@remix-run/node'
12
+
13
+ import { buildSidebar, flattenSidebar } from '~/lib/sidebar.server'
14
+ import { getActiveProjectId } from '~/lib/projects'
15
+ import { site } from '~/lib/site'
16
+ import Sidebar from '~/components/Sidebar'
17
+ import { TopBar } from '~/lib/slots'
18
+ import TabBar from '~/components/TabBar'
19
+ import MobileBottomBar from '~/components/MobileBottomBar'
20
+ import MobileProjectsPanel from '~/components/MobileProjectsPanel'
21
+ import { ShortcutsHelp } from '~/components/ShortcutsHelp'
22
+ import { TabsProvider } from '~/lib/tabs'
23
+ import { cn } from '~/lib/utils'
24
+ import { themeInitScript } from '~/components/theme-toggle'
25
+ import { sidebarWidthInitScript } from '~/components/Sidebar'
26
+
27
+ import tailwindStyles from '~/styles/tailwind.css?url'
28
+ import appStyles from '~/styles/app.css?url'
29
+ import katexStyles from 'katex/dist/katex.min.css?url'
30
+ // Generated from docs.config.ts `theme` — loaded LAST so its :root/.dark token
31
+ // overrides win over the defaults declared in tailwind.css.
32
+ import themeStyles from '~/generated/theme.generated.css?url'
33
+
34
+ export const links: LinksFunction = () => [
35
+ { rel: 'stylesheet', href: tailwindStyles },
36
+ { rel: 'stylesheet', href: appStyles },
37
+ { rel: 'stylesheet', href: themeStyles },
38
+ { rel: 'stylesheet', href: katexStyles },
39
+ { rel: 'icon', type: 'image/svg+xml', href: site.favicon },
40
+ ]
41
+
42
+ // Root loader runs on every navigation; the active project is derived from the
43
+ // request URL and only that project's sidebar tree is built, so the nav persists
44
+ // across client-side navigations and swaps to match the project being viewed.
45
+ export async function loader({ request }: LoaderFunctionArgs) {
46
+ const projectId = getActiveProjectId(new URL(request.url).pathname)
47
+ // No active project (e.g. the home page) → no sidebar tree to build.
48
+ const sidebar = projectId ? flattenSidebar(await buildSidebar(projectId)) : null
49
+ return { sidebar, projectId }
50
+ }
51
+
52
+ export default function App() {
53
+ const { sidebar, projectId } = useLoaderData<typeof loader>()
54
+ const location = useLocation()
55
+ const [menuOpen, setMenuOpen] = useState(false)
56
+ const [projectsOpen, setProjectsOpen] = useState(false)
57
+
58
+ // Close any open mobile overlay whenever navigation happens (link/row click).
59
+ useEffect(() => {
60
+ setMenuOpen(false)
61
+ setProjectsOpen(false)
62
+ }, [location.pathname])
63
+
64
+ return (
65
+ <html lang={site.lang} className={site.defaultTheme === 'light' ? undefined : 'dark'}>
66
+ <head>
67
+ <meta charSet="utf-8" />
68
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
69
+ <Meta />
70
+ <Links />
71
+ {/* Set the theme class before paint to avoid a flash of the wrong theme. */}
72
+ <script dangerouslySetInnerHTML={{ __html: themeInitScript }} />
73
+ {/* Apply the persisted sidebar width before paint to avoid a layout shift. */}
74
+ <script dangerouslySetInnerHTML={{ __html: sidebarWidthInitScript }} />
75
+ </head>
76
+ <body>
77
+ <TabsProvider projectId={projectId}>
78
+ {/* Desktop top bar with theme toggle: in-flow at the top (takes layout
79
+ space), floats and hides on scroll-down / shows on scroll-up. */}
80
+ <TopBar projectId={projectId} />
81
+
82
+ {/* Two-row grid: row 1 holds the tab bar (over the content columns only),
83
+ row 2 holds the doc content + TOC. The sidebar spans both rows so it
84
+ stays full-height. When no tabs are open the TabBar renders nothing, so
85
+ its 0-height row collapses and the layout matches the no-tabs case.
86
+ With no active project (e.g. `/`) there's no sidebar, so the leading
87
+ sidebar column is dropped and content starts at the first column. */}
88
+ <div
89
+ className={cn(
90
+ 'grid min-h-screen grid-cols-1 grid-rows-[auto_minmax(0,1fr)]',
91
+ // No sidebar (e.g. `/`): a single full-width content column, no
92
+ // reserved TOC column — the page has no TOC, so leaving that column
93
+ // in place would push the centered content left of true center.
94
+ sidebar
95
+ ? 'md:grid-cols-[var(--sidebar-width)_minmax(0,1fr)] xl:grid-cols-[var(--sidebar-width)_minmax(0,1fr)_var(--toc-width,250px)]'
96
+ : 'md:grid-cols-[minmax(0,1fr)]',
97
+ )}
98
+ >
99
+ {/* key on projectId: the headless-tree instance caches expanded/registered
100
+ items and won't re-sync to a new `data` map on its own, so switching
101
+ projects would leave the tree showing stale state. Remounting on
102
+ project change rebuilds it cleanly with the new data + initial expand. */}
103
+ {sidebar && (
104
+ <Sidebar
105
+ key={projectId}
106
+ data={sidebar}
107
+ currentPath={location.pathname}
108
+ open={menuOpen}
109
+ className="md:col-start-1 md:row-span-2 md:row-start-1"
110
+ />
111
+ )}
112
+ {/* Tab strip: row 1, content column only (col-start-2) — not over the
113
+ sidebar (col 1) nor the TOC (col 3 at xl). The grid CELL is the
114
+ sticky element (top-11, just below the TopBar): a sticky grid item's
115
+ travel range is the whole grid container's box (tall), not its own
116
+ row — so it stays pinned while content scrolls. Making the inner
117
+ strip sticky instead would fail, since its parent cell is only as
118
+ tall as the strip. */}
119
+ <div
120
+ className={cn(
121
+ 'sticky top-11 z-40 md:row-start-1',
122
+ sidebar ? 'md:col-start-2' : 'md:col-start-1',
123
+ )}
124
+ >
125
+ <TabBar />
126
+ </div>
127
+ {/* Content lands in row 2, right of the sidebar. The route ($.tsx) emits
128
+ <main> + <Toc> as two cells; pinning the wrapper here keeps them in the
129
+ content/TOC columns of row 2 regardless of grid auto-placement order. */}
130
+ <div className="contents md:[&>*]:row-start-2">
131
+ <Outlet />
132
+ </div>
133
+ </div>
134
+ </TabsProvider>
135
+
136
+ {/* Fullscreen mobile project switcher (also houses the theme toggle).
137
+ Rendered before the bar so the floating bar stacks above it and its
138
+ Projects tab stays tappable to toggle the panel shut. */}
139
+ <MobileProjectsPanel
140
+ activeId={projectId}
141
+ open={projectsOpen}
142
+ onClose={() => setProjectsOpen(false)}
143
+ />
144
+
145
+ {/* Mobile-only floating bottom navigation: Home · Проекты · Файлы
146
+ (sidebar) · Поиск. No top navbar on mobile. The Files tab is
147
+ disabled with no active project (e.g. `/`), where there's no tree. */}
148
+ <MobileBottomBar
149
+ dirsOpen={menuOpen}
150
+ projectsOpen={projectsOpen}
151
+ filesEnabled={!!projectId}
152
+ onToggleDirs={() => {
153
+ setMenuOpen((o) => !o)
154
+ setProjectsOpen(false)
155
+ }}
156
+ onToggleProjects={() => {
157
+ setProjectsOpen((o) => !o)
158
+ setMenuOpen(false)
159
+ }}
160
+ />
161
+
162
+ {/* `?` cheatsheet for all keyboard shortcuts (single source: ALL_SHORTCUTS).
163
+ Owns its own open/close; renders nothing until triggered. */}
164
+ <ShortcutsHelp />
165
+
166
+ <ScrollRestoration />
167
+ <Scripts />
168
+ </body>
169
+ </html>
170
+ )
171
+ }
@@ -0,0 +1,158 @@
1
+ import { useEffect } from 'react'
2
+ import { json, redirect } from '@remix-run/node'
3
+ import { useLoaderData, useLocation } from '@remix-run/react'
4
+ import type { LoaderFunctionArgs, MetaFunction } from '@remix-run/node'
5
+
6
+ import { getDoc, resolvePermalink, getPermalinkForId } from '~/lib/content.server'
7
+ import { getPriority } from '~/lib/utils'
8
+ import { t, pageTitle } from '~/lib/site'
9
+ import { Toc, DocPageOverride } from '~/lib/slots'
10
+ import PageFloatingMenu from '~/components/PageFloatingMenu'
11
+ import CanvasMount from '~/components/CanvasMount'
12
+ import { CodeWrapToggle } from '~/components/CodeWrapToggle'
13
+
14
+ /** A colored MoSCoW priority pill, rendered inline next to the page title. */
15
+ function PriorityBadge({ priority }: { priority: string }) {
16
+ return (
17
+ <span className="priority-badge" data-priority={priority}>
18
+ {priority}
19
+ </span>
20
+ )
21
+ }
22
+
23
+ /** Render a single frontmatter value as text: arrays comma-joined, everything else stringified. */
24
+ function formatValue(value: unknown): string {
25
+ if (Array.isArray(value)) return value.map((v) => String(v)).join(', ')
26
+ if (value === null) return ''
27
+ if (typeof value === 'object') return JSON.stringify(value)
28
+ return String(value)
29
+ }
30
+
31
+ /** A generic key→value table of every frontmatter field, collapsed by default. */
32
+ function FrontmatterTable({ frontmatter }: { frontmatter: Record<string, unknown> }) {
33
+ const entries = Object.entries(frontmatter)
34
+ if (entries.length === 0) return null
35
+ return (
36
+ <details className="frontmatter">
37
+ <summary className="frontmatter__summary">{t('properties')}</summary>
38
+ <dl className="frontmatter__list">
39
+ {entries.map(([key, value]) => (
40
+ <div className="frontmatter__row" key={key}>
41
+ <dt className="frontmatter__key">{key}</dt>
42
+ <dd className="frontmatter__value">{formatValue(value)}</dd>
43
+ </div>
44
+ ))}
45
+ </dl>
46
+ </details>
47
+ )
48
+ }
49
+
50
+ export const loader = async ({ params }: LoaderFunctionArgs) => {
51
+ // The splat param holds the full doc path, e.g. "krista/глоссарий/коллекция".
52
+ const slug = (params['*'] ?? '').replace(/\/$/, '')
53
+
54
+ // Permalinks make a doc's URL independent of its file name. The permalink is
55
+ // the canonical URL: if `slug` is a permalink we serve the doc in place; if
56
+ // it is the file-path URL of a doc that has a permalink, we 301 to the
57
+ // permalink so there is a single canonical address that survives renames.
58
+ const permalinkTarget = await resolvePermalink(slug)
59
+ const docId = permalinkTarget ?? slug
60
+ if (!permalinkTarget) {
61
+ const canonical = await getPermalinkForId(slug)
62
+ if (canonical && canonical !== slug) {
63
+ return redirect(`/${canonical}/`, 301)
64
+ }
65
+ }
66
+
67
+ const doc = await getDoc(docId)
68
+ if (!doc || doc.frontmatter.draft === true) {
69
+ throw new Response('Not Found', { status: 404 })
70
+ }
71
+ const title =
72
+ (doc.frontmatter.title as string | undefined) ??
73
+ slug.split('/').pop()?.replace(/-/g, ' ') ??
74
+ slug
75
+ return json({ doc, title })
76
+ }
77
+
78
+ export const meta: MetaFunction<typeof loader> = ({ data }) => {
79
+ return [{ title: pageTitle(data?.title) }]
80
+ }
81
+
82
+ /**
83
+ * Route default export: render the user's `DocPage` override when one is
84
+ * configured (it receives the same loader data via `useLoaderData`), else the
85
+ * engine's default doc body below.
86
+ */
87
+ export default function DocPageRoute() {
88
+ if (DocPageOverride) return <DocPageOverride />
89
+ return <EngineDocPage />
90
+ }
91
+
92
+ function EngineDocPage() {
93
+ const { doc, title } = useLoaderData<typeof loader>()
94
+ const showToc = doc.frontmatter.tableOfContents !== false
95
+ const isCanvas = doc.html.includes('canvas-container')
96
+ const priority = getPriority(doc.frontmatter.tags)
97
+
98
+ // Scroll to the #hash heading after the doc renders. Remix's ScrollRestoration
99
+ // scrolls to the hash at the navigation's DOM commit, but when navigating
100
+ // *between* docs the new heading only exists once this component renders the
101
+ // new HTML — so that initial attempt misses and the first click appears to do
102
+ // nothing (a second click, now same-doc, works). Re-running keyed on doc.id +
103
+ // hash covers the cross-doc case. scrollIntoView honours the heading's
104
+ // scroll-margin-top, so it lands below the sticky bars.
105
+ const { hash } = useLocation()
106
+ useEffect(() => {
107
+ if (!hash) return
108
+ const id = decodeURIComponent(hash.slice(1))
109
+ // Wait a frame so the freshly-committed doc HTML is in the DOM.
110
+ requestAnimationFrame(() => {
111
+ document.getElementById(id)?.scrollIntoView({ block: 'start', behavior: 'smooth' })
112
+ })
113
+ }, [doc.id, hash])
114
+
115
+ if (isCanvas) {
116
+ // Canvas pages span the content + TOC columns (everything right of the sidebar).
117
+ return (
118
+ <>
119
+ <main className="min-w-0 xl:col-span-2">
120
+ <article className="content">
121
+ <h1 className="title-row px-10">
122
+ {title}
123
+ {priority && <PriorityBadge priority={priority} />}
124
+ </h1>
125
+ <div className="px-10">
126
+ <FrontmatterTable frontmatter={doc.frontmatter} />
127
+ </div>
128
+ <div className="body" dangerouslySetInnerHTML={{ __html: doc.html }} />
129
+ </article>
130
+ </main>
131
+ <CanvasMount deps={doc.id} />
132
+ <CodeWrapToggle deps={doc.id} />
133
+ </>
134
+ )
135
+ }
136
+
137
+ return (
138
+ <>
139
+ {/* On mobile the bottom padding clears the global bar PLUS the page actions
140
+ deck that opens above it (one bar-height row sitting 1.5rem higher), so
141
+ the last lines of content never hide behind either, open or closed. */}
142
+ <main className="mx-auto w-full min-w-0 max-w-[calc(720px+5rem)] px-10 pb-16 pt-8 max-md:px-4 max-md:pb-[calc(2*var(--mobile-bar-height)+env(safe-area-inset-bottom)+3rem)]">
143
+ <article className="content">
144
+ <h1 className="title-row">
145
+ {title}
146
+ {priority && <PriorityBadge priority={priority} />}
147
+ </h1>
148
+ <FrontmatterTable frontmatter={doc.frontmatter} />
149
+ <div className="body" dangerouslySetInnerHTML={{ __html: doc.html }} />
150
+ </article>
151
+ </main>
152
+ {showToc && <Toc headings={doc.headings} />}
153
+ {showToc && <PageFloatingMenu headings={doc.headings} />}
154
+ <CanvasMount deps={doc.id} />
155
+ <CodeWrapToggle deps={doc.id} />
156
+ </>
157
+ )
158
+ }
@@ -0,0 +1,60 @@
1
+ import { Link } from '@remix-run/react'
2
+ import type { MetaFunction } from '@remix-run/node'
3
+
4
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '~/components/ui/card'
5
+ import { getProjects } from '~/lib/projects'
6
+ import { site } from '~/lib/site'
7
+ import { HomeOverride } from '~/lib/slots'
8
+
9
+ export const meta: MetaFunction = () => [{ title: site.title }]
10
+
11
+ const projects = getProjects()
12
+
13
+ /**
14
+ * Route default export: render the user's `Home` override when configured, else
15
+ * the engine's default project-card grid below.
16
+ */
17
+ export default function IndexRoute() {
18
+ if (HomeOverride) return <HomeOverride />
19
+ return <EngineIndex />
20
+ }
21
+
22
+ function EngineIndex() {
23
+ return (
24
+ <main className="mx-auto w-full min-w-0 max-w-[calc(720px+5rem)] px-10 pb-16 pt-8 max-md:px-4 max-md:pb-20">
25
+ <article className="content">
26
+ <h1>{site.title}</h1>
27
+ {site.description && (
28
+ <p className="mb-8 text-lg text-muted-foreground">{site.description}</p>
29
+ )}
30
+
31
+ <div className="grid grid-cols-[repeat(auto-fit,minmax(280px,1fr))] gap-4">
32
+ {projects.map((p) => (
33
+ <Link key={p.id} to={p.landing} className="no-underline">
34
+ <Card className="h-full gap-3 py-5 transition-colors hover:border-ring">
35
+ <CardHeader>
36
+ <div className="flex items-center gap-2.5">
37
+ {/* Inline size: app.css's unlayered `img{height:auto}` would beat h-* utilities. */}
38
+ <img
39
+ src={p.logo}
40
+ alt=""
41
+ aria-hidden
42
+ width={28}
43
+ height={28}
44
+ style={{ height: 28, width: 28 }}
45
+ className="shrink-0 rounded-md"
46
+ />
47
+ <CardTitle className="text-base">{p.name}</CardTitle>
48
+ </div>
49
+ </CardHeader>
50
+ <CardContent>
51
+ <CardDescription>{p.description}</CardDescription>
52
+ </CardContent>
53
+ </Card>
54
+ </Link>
55
+ ))}
56
+ </div>
57
+ </article>
58
+ </main>
59
+ )
60
+ }