andy-note-nuxt 0.3.0 → 0.4.1

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.
@@ -6,16 +6,55 @@
6
6
  * ================================================================= */
7
7
 
8
8
  /* Fonts — @import MUST precede @tailwind directives per CSS spec.
9
- Local @fontsource packages = self-host (no FOUT, no third-party request). */
10
- @import "@fontsource/space-grotesk/300.css";
11
- @import "@fontsource/space-grotesk/400.css";
12
- @import "@fontsource/space-grotesk/500.css";
13
- @import "@fontsource/space-grotesk/600.css";
14
- @import "@fontsource/space-grotesk/700.css";
15
- @import "@fontsource/literata/400.css";
16
- @import "@fontsource/literata/400-italic.css";
17
- @import "@fontsource/literata/600.css";
18
- @import "@fontsource/literata/700.css";
9
+ Local @fontsource packages = self-host (no FOUT, no third-party request).
10
+ We import only the weights/subsets actually referenced by the layer:
11
+ - Space Grotesk 500 → LocalStorageChecklist `.lsc-label`
12
+ - Space Grotesk 700 → every `font-bold` headline/label
13
+ - Literata 400 / 400-italic / 600 → prose body / <em> / <strong>
14
+ `latin-<weight>.css` ships only the latin @font-face rule, skipping the
15
+ cyrillic / greek / vietnamese / latin-ext declarations whose subsets
16
+ never get fetched anyway (unicode-range gates the WOFF2 request, but the
17
+ @font-face rules themselves still pad critical CSS). Consumers serving
18
+ non-latin content should override main.css with the broader imports. */
19
+ @import "@fontsource/space-grotesk/latin-500.css";
20
+ @import "@fontsource/space-grotesk/latin-700.css";
21
+ @import "@fontsource/literata/latin-400.css";
22
+ @import "@fontsource/literata/latin-400-italic.css";
23
+ @import "@fontsource/literata/latin-600.css";
24
+
25
+ /* ===== Fallback @font-face overrides — CLS 0.12 → 0.00 =====
26
+ While the real WOFF2 is in flight, `font-display: swap` paints with a
27
+ system font. Without metric overrides Arial / Times New Roman render at
28
+ a different x-height + advance width than the real face, so the swap
29
+ shifts every line of text below it (measured 0.1215 CLS on root /).
30
+ These two @font-face declarations re-publish the system font under a
31
+ synthetic family name with `ascent-override`, `descent-override`,
32
+ `line-gap-override`, and `size-adjust` re-mapped to match the webfont.
33
+ Wire-up: in `tailwind.config.js`, `fontFamily.display` slots
34
+ "Space Grotesk Fallback" between the real family and `-apple-system`,
35
+ and `fontFamily.prose` slots "Literata Fallback" between Literata and
36
+ Georgia. The system font then renders dimensionally identical to the
37
+ webfont — the swap is invisible.
38
+
39
+ Computed once via `scripts/compute-font-fallback.mjs` (Capsize). Recompute
40
+ only when the webfonts themselves change; metrics are intrinsic to the
41
+ font file, not the weight/subset. */
42
+ @font-face {
43
+ font-family: "Space Grotesk Fallback";
44
+ src: local('Arial'), local('ArialMT');
45
+ ascent-override: 89.7072%;
46
+ descent-override: 26.6204%;
47
+ line-gap-override: 0%;
48
+ size-adjust: 109.6903%;
49
+ }
50
+ @font-face {
51
+ font-family: "Literata Fallback";
52
+ src: local('Times New Roman'), local('TimesNewRomanPSMT');
53
+ ascent-override: 99.6159%;
54
+ descent-override: 26.0677%;
55
+ line-gap-override: 0%;
56
+ size-adjust: 118.1538%;
57
+ }
19
58
 
20
59
  @tailwind base;
21
60
  @tailwind components;
@@ -50,7 +89,17 @@
50
89
 
51
90
  html {
52
91
  -webkit-text-size-adjust: 100%;
53
- scroll-behavior: smooth;
92
+ }
93
+ /* Smooth scroll only when motion is allowed. Global `html { scroll-behavior:
94
+ smooth }` would make every hash-link, anchor jump, and `scrollIntoView()`
95
+ animate on the main thread — measurable INP cost on long articles, and
96
+ redundant since useStack's programmatic stack scroll already passes
97
+ `behavior: 'smooth'` explicitly on each scrollTo call. Honors the OS
98
+ reduced-motion preference for free. */
99
+ @media (prefers-reduced-motion: no-preference) {
100
+ html {
101
+ scroll-behavior: smooth;
102
+ }
54
103
  }
55
104
 
56
105
  body {
@@ -245,7 +294,7 @@
245
294
  }
246
295
  .list-row__description {
247
296
  @apply text-sm text-terminal-text-muted normal-case;
248
- font-family: 'Literata', Georgia, serif;
297
+ font-family: 'Literata', 'Literata Fallback', Georgia, serif;
249
298
  text-transform: none;
250
299
  letter-spacing: 0;
251
300
  }
@@ -304,7 +353,7 @@
304
353
  code/tables/lists. */
305
354
  .content {
306
355
  @apply text-terminal-text;
307
- font-family: 'Literata', Georgia, serif;
356
+ font-family: 'Literata', 'Literata Fallback', Georgia, serif;
308
357
  font-size: 1.0625rem;
309
358
  line-height: 1.65;
310
359
  letter-spacing: 0.008em;
@@ -317,11 +366,11 @@
317
366
  }
318
367
  .content h2 {
319
368
  @apply font-display font-bold uppercase tracking-tight text-xl md:text-2xl mt-10 mb-4;
320
- font-family: 'Space Grotesk', sans-serif;
369
+ font-family: 'Space Grotesk', 'Space Grotesk Fallback', sans-serif;
321
370
  }
322
371
  .content h3 {
323
372
  @apply font-display font-bold uppercase tracking-tight text-base md:text-lg mt-8 mb-3;
324
- font-family: 'Space Grotesk', sans-serif;
373
+ font-family: 'Space Grotesk', 'Space Grotesk Fallback', sans-serif;
325
374
  }
326
375
  .content h4, .content h5, .content h6 {
327
376
  @apply font-display font-bold uppercase tracking-widest text-xs text-terminal-text-muted mt-6 mb-2;
@@ -388,7 +437,7 @@
388
437
  width: 1.6rem;
389
438
  height: 1.3rem;
390
439
  box-shadow: 2px 2px 0px theme('colors.terminal.border');
391
- font-family: 'Space Grotesk', sans-serif;
440
+ font-family: 'Space Grotesk', 'Space Grotesk Fallback', sans-serif;
392
441
  }
393
442
 
394
443
  /* Nested lists (depth ≥ 2): drop the box stamp, switch to flat text markers,
@@ -1,9 +1,8 @@
1
1
  <script setup lang="ts">
2
- // `minimark` ships with @nuxt/content as a transitive dep — its body field is
3
- // minimark AST. `stringify` converts that AST back to markdown faithfully, so we
4
- // can produce a copy-friendly markdown blob without forcing consumers to enable
5
- // `rawbody` in their collection schema. See https://content.nuxt.com/docs/integrations/llms.
6
- import { stringify as stringifyMinimark } from 'minimark/stringify'
2
+ // `minimark/stringify` is imported lazily inside copyMarkdown() so it stays
3
+ // out of the initial route bundle `Copy as markdown` is a user-triggered
4
+ // action, the codepath only fires after a click. See https://content.nuxt.com/docs/integrations/llms
5
+ // for why we round-trip minimark AST → markdown rather than depending on rawbody.
7
6
  import { toast } from 'vue-sonner'
8
7
  import { useFloating, offset, flip, shift, autoUpdate } from '@floating-ui/vue'
9
8
 
@@ -66,32 +65,40 @@ if (malformedPath.value && !props.noThrow) {
66
65
 
67
66
  // Guard all queries: when path is malformed, skip them entirely (queryCollection
68
67
  // would crash with assertSafeQuery before we could handle the error).
69
- const { data: page } = await useAsyncData(`content-${path.value}`, () => {
70
- if (malformedPath.value) return Promise.resolve(null)
71
- return queryCollection('content')
72
- .where('path', '=', path.value)
73
- .first()
74
- })
75
-
76
- // Query descendants regardless of whether `_index.md` exists for the path — that lets
77
- // section roots like `/builds`, `/`, `/wiki` render a listing of their children even
78
- // without an explicit index file. Path = '/' must use prefix '/' (not '//') to match all.
68
+ //
69
+ // Page + children fire in parallel via Promise.all instead of two sequential
70
+ // awaits. With key=index TransitionGroup, every stack mutation mounts a fresh
71
+ // ContentView for each column (StackedColumn :key="path" remounts), so the
72
+ // SSR pass for a 4-deep stack used to issue 8 SQL queries serialized in
73
+ // setup order. Parallelizing halves wall-clock cost — both queries hit the
74
+ // same SQLite connection but the second no longer waits for the first to
75
+ // resolve before its `where(...)` plan is built and executed.
76
+ //
77
+ // Path = '/' must use prefix '/' (not '//') so the LIKE matches all rows.
79
78
  const childrenPrefix = path.value === '/' ? '/' : `${path.value}/`
80
- const { data: allChildren } = await useAsyncData(`children-${path.value}`, () => {
81
- if (malformedPath.value) return Promise.resolve([])
82
- return queryCollection('content')
83
- .where('path', 'LIKE', `${childrenPrefix}%`)
84
- .where('path', '<>', path.value)
85
- .where('path', 'NOT LIKE', '%/_index')
86
- // The convention filter used to live here as a SQL `where`, but SQL's
87
- // three-valued logic treats `NULL <> 'convention'` as NULL (not true),
88
- // so any row whose schema doesn't set document_type — i.e. most rows —
89
- // got silently filtered out and listings rendered as empty article
90
- // views. The client-side hierarchy filter (`!== 'convention'`) handles
91
- // this correctly in JS where `undefined !== 'convention'` is true.
92
- .select('path', 'title', 'description', 'document_type', 'updated', 'created')
93
- .all()
94
- })
79
+ const [{ data: page }, { data: allChildren }] = await Promise.all([
80
+ useAsyncData(`content-${path.value}`, () => {
81
+ if (malformedPath.value) return Promise.resolve(null)
82
+ return queryCollection('content')
83
+ .where('path', '=', path.value)
84
+ .first()
85
+ }),
86
+ useAsyncData(`children-${path.value}`, () => {
87
+ if (malformedPath.value) return Promise.resolve([])
88
+ return queryCollection('content')
89
+ .where('path', 'LIKE', `${childrenPrefix}%`)
90
+ .where('path', '<>', path.value)
91
+ .where('path', 'NOT LIKE', '%/_index')
92
+ // The convention filter used to live here as a SQL `where`, but SQL's
93
+ // three-valued logic treats `NULL <> 'convention'` as NULL (not true),
94
+ // so any row whose schema doesn't set document_type — i.e. most rows —
95
+ // got silently filtered out and listings rendered as empty article
96
+ // views. The client-side hierarchy filter (`!== 'convention'`) handles
97
+ // this correctly in JS where `undefined !== 'convention'` is true.
98
+ .select('path', 'title', 'description', 'document_type', 'updated', 'created')
99
+ .all()
100
+ }),
101
+ ])
95
102
 
96
103
  // "Not found" only when path is malformed OR there's no page AND no children to list.
97
104
  // Pure section paths (`/builds`, `/`) are valid even without `_index.md` if children exist.
@@ -366,7 +373,7 @@ type CopyState = 'idle' | 'copied' | 'error'
366
373
  const copyState = ref<CopyState>('idle')
367
374
  let copyResetTimer: ReturnType<typeof setTimeout> | null = null
368
375
 
369
- function buildMarkdown(): string {
376
+ async function buildMarkdown(): Promise<string> {
370
377
  const raw = (page.value as any)?.rawbody as string | undefined
371
378
  if (typeof raw === 'string' && raw.trim().length > 0) {
372
379
  const trimmed = raw.trimStart()
@@ -390,6 +397,10 @@ function buildMarkdown(): string {
390
397
  const value = skipFirst ? body.value.slice(1) : body.value
391
398
  if (value.length > 0) {
392
399
  try {
400
+ // Lazy import — keeps minimark/stringify out of the initial route
401
+ // bundle. Vite code-splits this into a chunk fetched only on first
402
+ // copy-as-markdown click.
403
+ const { stringify: stringifyMinimark } = await import('minimark/stringify')
393
404
  const md = stringifyMinimark({ type: 'minimark', value }).trim()
394
405
  if (md) lines.push(md, '')
395
406
  }
@@ -429,7 +440,7 @@ function buildMarkdown(): string {
429
440
 
430
441
  async function copyMarkdown() {
431
442
  if (!import.meta.client) return
432
- const markdown = buildMarkdown()
443
+ const markdown = await buildMarkdown()
433
444
 
434
445
  try {
435
446
  if (navigator.clipboard?.writeText) {
@@ -174,7 +174,7 @@ function reset(): void {
174
174
  background: #2e2f2c;
175
175
  box-shadow: 4px 4px 0 0 #474541;
176
176
  margin: 1.5rem 0;
177
- font-family: 'Space Grotesk', -apple-system, sans-serif;
177
+ font-family: 'Space Grotesk', 'Space Grotesk Fallback', -apple-system, sans-serif;
178
178
  color: #d5cfc5;
179
179
  }
180
180
 
@@ -191,7 +191,7 @@ function reset(): void {
191
191
  }
192
192
 
193
193
  .lsc-title {
194
- font-family: 'Space Grotesk', sans-serif;
194
+ font-family: 'Space Grotesk', 'Space Grotesk Fallback', sans-serif;
195
195
  font-size: 0.95rem;
196
196
  font-weight: 700;
197
197
  text-transform: uppercase;
@@ -329,7 +329,7 @@ function reset(): void {
329
329
  }
330
330
 
331
331
  .lsc-label {
332
- font-family: 'Space Grotesk', sans-serif;
332
+ font-family: 'Space Grotesk', 'Space Grotesk Fallback', sans-serif;
333
333
  font-size: 0.9375rem;
334
334
  font-weight: 500;
335
335
  color: #d5cfc5;
@@ -0,0 +1,37 @@
1
+ <script setup lang="ts">
2
+ // Override `<img>` rendered from markdown via @nuxt/content's Prose components.
3
+ // Default ProseImg emits a plain `<img>`; we swap to `<NuxtImg>` so consumers
4
+ // get @nuxt/image's lazy loading, responsive `srcset`, format negotiation, and
5
+ // (for SSG builds) pre-rendered optimized variants under .output/public.
6
+ //
7
+ // Surface kept identical to the default ProseImg so existing markdown — both
8
+ // in this layer's `content/` and in consumer projects' content — continues to
9
+ // work without changes. Authors write `![alt](/path.png)` and get an
10
+ // optimized, lazy-loaded image at the cost of nothing.
11
+ //
12
+ // External URLs (http*) pass through @nuxt/image's `remote` provider path;
13
+ // relative paths under `/public` get rewritten to the IPX-served route during
14
+ // dev and pre-rendered into static variants during `nuxt generate`.
15
+
16
+ defineProps<{
17
+ src?: string
18
+ alt?: string
19
+ width?: string | number
20
+ height?: string | number
21
+ title?: string
22
+ }>()
23
+ </script>
24
+
25
+ <template>
26
+ <NuxtImg
27
+ :src="src"
28
+ :alt="alt"
29
+ :width="width"
30
+ :height="height"
31
+ :title="title"
32
+ loading="lazy"
33
+ decoding="async"
34
+ format="webp"
35
+ densities="x1 x2"
36
+ />
37
+ </template>
package/nuxt.config.ts CHANGED
@@ -33,6 +33,7 @@ export default defineNuxtConfig({
33
33
  modules: [
34
34
  'vite-plugin-ai-annotator/nuxt',
35
35
  '@nuxt/content',
36
+ '@nuxt/image',
36
37
  '@nuxtjs/tailwindcss',
37
38
  // Toast notifications. Auto-registers `<Toaster />` (client-only) and a
38
39
  // plugin exposing `$toast` / the imported `toast()` helper from `vue-sonner`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "andy-note-nuxt",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Brutalist-terminal Nuxt Content theme for personal notes, guides, and second-brain knowledge bases. Use as a Nuxt layer.",
5
5
  "type": "module",
6
6
  "main": "./nuxt.config.ts",
@@ -48,6 +48,7 @@
48
48
  "@fontsource/literata": "^5.2.8",
49
49
  "@fontsource/space-grotesk": "^5.2.10",
50
50
  "@nuxt/content": "^3.12.0",
51
+ "@nuxt/image": "^2.0.0",
51
52
  "@nuxtjs/tailwindcss": "^6.14.0",
52
53
  "nuxt": "^4.4.6",
53
54
  "rehype-raw": "^7.0.0",
@@ -56,6 +57,9 @@
56
57
  "vue-sonner": "^2.0.9"
57
58
  },
58
59
  "devDependencies": {
60
+ "@capsizecss/core": "^4.1.3",
61
+ "@capsizecss/metrics": "^4.0.0",
62
+ "@capsizecss/unpack": "^4.0.0",
59
63
  "oxlint": "^1.65.0",
60
64
  "tailwindcss": "^3",
61
65
  "vite-plugin-ai-annotator": "^1.14.13"
@@ -38,9 +38,14 @@ export default {
38
38
  },
39
39
  },
40
40
  },
41
+ // The "* Fallback" entries are synthetic families declared via
42
+ // @font-face in app/assets/css/main.css. They re-map the system font's
43
+ // metrics to match the real webfont so `font-display: swap` doesn't
44
+ // shift the layout when the WOFF2 finally arrives. Order matters:
45
+ // real webfont → metric-matched fallback → ultimate system stack.
41
46
  fontFamily: {
42
- display: ['Space Grotesk', '-apple-system', 'BlinkMacSystemFont', 'sans-serif'],
43
- prose: ['Literata', 'Georgia', 'serif'],
47
+ display: ['Space Grotesk', 'Space Grotesk Fallback', '-apple-system', 'BlinkMacSystemFont', 'sans-serif'],
48
+ prose: ['Literata', 'Literata Fallback', 'Georgia', 'serif'],
44
49
  mono: ['SF Mono', 'Monaco', 'Consolas', 'ui-monospace', 'monospace'],
45
50
  },
46
51
  // Stamp shadows — flat 0-blur offsets are the brutalist signature.