uniweb 0.8.5 → 0.8.7

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "0.8.5",
3
+ "version": "0.8.7",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,9 +41,9 @@
41
41
  "js-yaml": "^4.1.0",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
- "@uniweb/core": "0.5.4",
45
- "@uniweb/kit": "0.7.3",
46
- "@uniweb/runtime": "0.6.4",
47
- "@uniweb/build": "0.8.4"
44
+ "@uniweb/build": "0.8.6",
45
+ "@uniweb/runtime": "0.6.5",
46
+ "@uniweb/kit": "0.7.4",
47
+ "@uniweb/core": "0.5.5"
48
48
  }
49
49
  }
@@ -2,6 +2,27 @@
2
2
 
3
3
  Uniweb is a Component Content Architecture (CCA). Content lives in markdown, code lives in React components, and a runtime connects them. The runtime handles section wrapping, background rendering, context theming, and token resolution — components receive pre-parsed content and render it with semantic tokens. Understanding what the runtime does (and therefore what components should *not* do) is the key to working effectively in this architecture.
4
4
 
5
+ ## Documentation
6
+
7
+ This project was created with [Uniweb](https://github.com/uniweb/cli). Full documentation (markdown, fetchable): https://github.com/uniweb/docs
8
+
9
+ **To read a specific page:** `https://raw.githubusercontent.com/uniweb/docs/main/{section}/{page}.md`
10
+
11
+ **By task:**
12
+
13
+ | Task | Doc page |
14
+ |------|----------|
15
+ | Writing page content | `authoring/writing-content.md` |
16
+ | Theming and styling | `authoring/theming.md` |
17
+ | Building components | `development/creating-components.md` |
18
+ | Kit API (hooks, components) | `reference/kit-reference.md` |
19
+ | Site configuration | `reference/site-configuration.md` |
20
+ | Content shape reference | `reference/content-structure.md` |
21
+ | Component metadata (meta.js) | `reference/component-metadata.md` |
22
+ | Migrating existing designs | `development/converting-existing.md` |
23
+
24
+ > **npm registry:** Use `https://registry.npmjs.org/uniweb` for package metadata — the npmjs.com website blocks automated requests.
25
+
5
26
  ## Project Structure
6
27
 
7
28
  ```
@@ -49,6 +70,15 @@ pnpm uniweb add site blog # Named → ./blog/
49
70
 
50
71
  The name is both the directory name and the package name. Use `--project <name>` to co-locate under a project directory (e.g., `--project docs` → `docs/foundation/`).
51
72
 
73
+ ### Adding section types
74
+
75
+ ```bash
76
+ pnpm uniweb add section Hero
77
+ pnpm uniweb add section Hero --foundation ui # When multiple foundations exist
78
+ ```
79
+
80
+ Creates `src/sections/Hero/index.jsx` and `meta.js` with a minimal CCA-proper starter. The dev server picks it up automatically — no build or install needed.
81
+
52
82
  ### What the CLI generates
53
83
 
54
84
  **Foundation** (`vite.config.js`, `package.json`, `src/foundation.js`, `src/styles.css`):
@@ -67,8 +97,11 @@ The name is both the directory name and the package name. Use `--project <name>`
67
97
  pnpm install # Install dependencies
68
98
  pnpm dev # Start dev server
69
99
  pnpm build # Build for production
100
+ pnpm preview # Preview production build (SSG + SPA)
70
101
  ```
71
102
 
103
+ > **npm works too.** Projects include both `pnpm-workspace.yaml` and npm workspaces. Replace `pnpm` with `npm` in any command above.
104
+
72
105
  ## Content Authoring
73
106
 
74
107
  ### Section Format
@@ -81,11 +114,11 @@ type: Hero
81
114
  theme: dark
82
115
  ---
83
116
 
84
- ### Eyebrow Text ← pretitle (heading before a more important one)
117
+ ### V1.0.0 IS OUT ← pretitle (small label above the title)
85
118
 
86
- # Main Headline ← title
119
+ # Build the system. ← title (the big headline)
87
120
 
88
- ## Subtitle ← subtitle
121
+ ## Not every page. ← subtitle
89
122
 
90
123
  Description paragraph.
91
124
 
@@ -94,6 +127,8 @@ Description paragraph.
94
127
  ![Image](./image.jpg)
95
128
  ```
96
129
 
130
+ Content authors don't need to understand *why* `###` means pretitle — just that putting a smaller heading before the main heading creates a small label above it. Heading levels set *structure* (pretitle, title, subtitle), not font size — the component controls visual sizing.
131
+
97
132
  ### Content Shape
98
133
 
99
134
  The semantic parser extracts markdown into a flat, guaranteed structure. No null checks needed — empty strings/arrays if content is absent:
@@ -112,7 +147,7 @@ content = {
112
147
  insets: [], // Inline @Component references — { refId }
113
148
  lists: [], // [[{ paragraphs, links, lists, ... }]] — each list item is an object, not a string
114
149
  quotes: [], // Blockquotes
115
- data: {}, // From tagged code blocks (```yaml:tagname)
150
+ data: {}, // From tagged code blocks (```yaml:tagname) and (```js:tagname)
116
151
  headings: [], // Overflow headings after subtitle2
117
152
  items: [], // Each has the same flat structure — from headings after body content
118
153
  sequence: [], // All elements in document order
@@ -127,12 +162,38 @@ content = {
127
162
  We built this for you. ← paragraph
128
163
 
129
164
  ### Fast ← items[0].title
165
+ ![](lu-zap) ← items[0].icons[0]
130
166
  Lightning quick. ← items[0].paragraphs[0]
131
167
 
132
168
  ### Secure ← items[1].title
169
+ ![](lu-shield) ← items[1].icons[0]
133
170
  Enterprise-grade. ← items[1].paragraphs[0]
134
171
  ```
135
172
 
173
+ Each item has the same content shape as the top level — `title`, `paragraphs`, `icons`, `links`, `lists`, etc. are all available per item.
174
+
175
+ **Complete example — markdown and resulting content shape side by side:**
176
+
177
+ ```markdown
178
+ ### Eyebrow │ content.pretitle = "Eyebrow"
179
+ # Our Features │ content.title = "Our Features"
180
+ ## Build better products │ content.subtitle = "Build better products"
181
+
182
+ We help teams ship faster. │ content.paragraphs[0] = "We help teams..."
183
+
184
+ [Get Started](/start) │ content.links[0] = { href: "/start", label: "Get Started" }
185
+
186
+ ### Fast │ content.items[0].title = "Fast"
187
+ ![](lu-zap) │ content.items[0].icons[0] = { library: "lu", name: "zap" }
188
+ Lightning quick. │ content.items[0].paragraphs[0] = "Lightning quick."
189
+
190
+ ### Secure │ content.items[1].title = "Secure"
191
+ ![](lu-shield) │ content.items[1].icons[0] = { library: "lu", name: "shield" }
192
+ Enterprise-grade security. │ content.items[1].paragraphs[0] = "Enterprise-grade..."
193
+ ```
194
+
195
+ Headings before the main title become `pretitle`. Headings after the main title at a lower importance become `subtitle`. Headings that appear after body content (paragraphs, links, images) start the `items` array.
196
+
136
197
  **Lists** contain bullet or ordered list items. Each list item is an object with the same content shape — not a plain string:
137
198
 
138
199
  ```markdown
@@ -186,6 +247,7 @@ The three parts carry distinct information:
186
247
  ![Architecture diagram](@NetworkDiagram){variant=compact}
187
248
  ![Cache metrics](@PerformanceChart){period=30d}
188
249
  ![](@GradientBlob){position=top-right}
250
+ ![npm create uniweb](@CommandBlock){note="Vite + React + Routing — ready to go"}
189
251
  ```
190
252
 
191
253
  Inset components must declare `inset: true` in their `meta.js`. They render at the exact position in the content flow where the author placed them. See meta.js section below for details.
@@ -198,8 +260,26 @@ Inset components must declare `inset: true` in their `meta.js`. They render at t
198
260
  ![alt](./img.jpg){role=banner} <!-- Role determines array: imgs, icons, or videos -->
199
261
  ```
200
262
 
263
+ **Quote values that contain spaces:** `{note="Ready to go"}` not `{note=Ready to go}`. Unquoted values end at the first space.
264
+
201
265
  Standalone links (alone on a line) become buttons. Inline links stay as text links.
202
266
 
267
+ **Standalone links** — paragraphs that contain *only* links (no other text) are promoted to `content.links[]`. This works for single links and for multiple links sharing a paragraph:
268
+
269
+ ```markdown
270
+ [Primary](/start) ← standalone → content.links[0]
271
+
272
+ [Secondary](/learn) ← standalone → content.links[1]
273
+
274
+ [One](/a) [Two](/b) ← links-only paragraph → content.links[0], content.links[1]
275
+ ```
276
+
277
+ Links mixed with non-link text stay as inline `<a>` tags within `content.paragraphs[]`:
278
+
279
+ ```markdown
280
+ Check out [this](/a) and [that](/b). ← inline links in paragraph text, NOT in content.links[]
281
+ ```
282
+
203
283
  ### Structured Data
204
284
 
205
285
  Tagged code blocks pass structured data via `content.data`:
@@ -215,19 +295,73 @@ submitLabel: Send
215
295
 
216
296
  Access: `content.data?.form` → `{ fields: [...], submitLabel: "Send" }`
217
297
 
298
+ **Code blocks need tags too.** Untagged code blocks (plain ```js) are only visible to sequential-rendering components like Article or DocSection. If a component needs to access code blocks by name, tag them:
299
+
300
+ ````markdown
301
+ ```jsx:before
302
+ const old = fetch('/api')
303
+ ```
304
+
305
+ ```jsx:after
306
+ const data = useData()
307
+ ```
308
+ ````
309
+
310
+ Access: `content.data?.before`, `content.data?.after` → raw code strings.
311
+
312
+ ### Lists as Navigation Menus
313
+
314
+ Markdown lists are ideal for navigation, menus, and grouped link structures. Each list item is a full content object with `paragraphs`, `links`, `icons`, and nested `lists`.
315
+
316
+ **Header nav — flat list with icons and links:**
317
+
318
+ ```markdown
319
+ - ![](lu-home) [Home](/)
320
+ - ![](lu-book) [Docs](/docs)
321
+ - ![](lu-mail) [Contact](/contact)
322
+ ```
323
+
324
+ Access: `content.lists[0]` — each item has `item.links[0]` (href + label) and `item.icons[0]` (icon).
325
+
326
+ **Footer — nested list for grouped links:**
327
+
328
+ ```markdown
329
+ - Product
330
+ - [Features](/features)
331
+ - [Pricing](/pricing)
332
+ - Company
333
+ - [About](/about)
334
+ - [Careers](/careers)
335
+ ```
336
+
337
+ Access: `content.lists[0]` — each top-level item has `item.paragraphs[0]` (group label) and `item.lists[0]` (array of sub-items, each with `subItem.links[0]`).
338
+
339
+ ```jsx
340
+ content.lists[0]?.map((group, i) => (
341
+ <div key={i}>
342
+ <Span text={group.paragraphs[0]} className="font-semibold text-heading" />
343
+ <ul>
344
+ {group.lists[0]?.map((subItem, j) => (
345
+ <li key={j}><Link to={subItem.links[0]?.href}>{subItem.links[0]?.label}</Link></li>
346
+ ))}
347
+ </ul>
348
+ </div>
349
+ ))
350
+ ```
351
+
218
352
  ### Section Backgrounds
219
353
 
220
- Set `background` in frontmatter — the runtime renders it automatically:
354
+ Set `background` in frontmatter — the runtime renders it automatically. The string form auto-detects the type:
221
355
 
222
356
  ```yaml
223
- ---
224
- type: Hero
225
- theme: dark
226
- background: /images/hero.jpg # Simple: URL (image or video auto-detected)
227
- ---
357
+ background: /images/hero.jpg # Image (by extension)
358
+ background: /videos/hero.mp4 # Video (by extension)
359
+ background: linear-gradient(135deg, #667eea, #764ba2) # CSS gradient
360
+ background: '#1a1a2e' # Color (hex quote in YAML)
361
+ background: var(--primary-900) # Color (CSS variable)
228
362
  ```
229
363
 
230
- Full syntax supports `image`, `video`, `gradient`, `color` modes plus overlays:
364
+ The object form gives more control:
231
365
 
232
366
  ```yaml
233
367
  background:
@@ -235,6 +369,8 @@ background:
235
369
  overlay: { enabled: true, type: dark, opacity: 0.5 }
236
370
  ```
237
371
 
372
+ Overlay shorthand — `overlay: 0.5` is equivalent to `{ enabled: true, type: dark, opacity: 0.5 }`.
373
+
238
374
  Components that render their own background declare `background: 'self'` in `meta.js`.
239
375
 
240
376
  ### Page Organization
@@ -385,7 +521,12 @@ CCA separates theme from code. Components use **semantic CSS tokens** instead of
385
521
  | `text-link` | Link color |
386
522
  | `bg-primary` | Primary action background |
387
523
  | `text-primary-foreground` | Text on primary background |
524
+ | `hover:bg-primary-hover` | Primary hover state |
525
+ | `border-primary-border` | Primary border (transparent by default) |
388
526
  | `bg-secondary` | Secondary action background |
527
+ | `text-secondary-foreground` | Text on secondary background |
528
+ | `hover:bg-secondary-hover` | Secondary hover state |
529
+ | `border-secondary-border` | Secondary border |
389
530
  | `text-success` / `bg-success-subtle` | Status: success |
390
531
  | `text-error` / `bg-error-subtle` | Status: error |
391
532
  | `text-warning` / `bg-warning-subtle` | Status: warning |
@@ -417,7 +558,7 @@ The runtime does significant work that other frameworks push onto components. Un
417
558
  | `isDark ? 'text-white' : 'text-gray-900'` | Just write `text-heading` — it adapts |
418
559
  | Background rendering code | Declare `background:` in frontmatter instead |
419
560
  | Color constants / tokens files | Colors come from `theme.yml` |
420
- | Custom CSS variables for colors (`--ink`, `--paper`, `--accent`) in `styles.css` | Map source colors to `theme.yml` colors/neutral. The build generates `--primary-50` through `--primary-950`, `--neutral-50` through `--neutral-950`, etc. Components use semantic tokens (`text-heading`, `bg-section`) that resolve from these palettes per context. A parallel color system bypasses all of this. |
561
+ | Parallel color system (`--ink`, `--paper`) that duplicates what tokens already provide | Map source color roles to `theme.yml` colors/neutral. The build generates `--primary-50` through `--primary-950`, `--neutral-50` through `--neutral-950`, etc. Use palette shades directly (`var(--primary-300)`) for specific tones. Additive design classes that BUILD ON tokens are fine a parallel system that REPLACES them bypasses context adaptation. |
421
562
 
422
563
  **What to hardcode** (not semantic — same in every context): layout (`grid`, `flex`, `max-w-6xl`), spacing (`p-6`, `gap-8`), typography scale (`text-3xl`, `font-bold`), animations, border-radius.
423
564
 
@@ -430,6 +571,33 @@ theme: dark ← sets context-dark, all tokens resolve to dark values
430
571
  ---
431
572
  ```
432
573
 
574
+ Alternate between `light` (default), `medium`, and `dark` across sections for visual rhythm — no CSS needed. A typical marketing page:
575
+
576
+ ```markdown
577
+ <!-- 1-hero.md -->
578
+ theme: dark
579
+
580
+ <!-- 2-features.md -->
581
+ (no theme — defaults to light)
582
+
583
+ <!-- 3-testimonials.md -->
584
+ theme: medium
585
+
586
+ <!-- 4-cta.md -->
587
+ theme: dark
588
+ ```
589
+
590
+ **Per-section token overrides** — the object form lets authors fine-tune individual tokens for a specific section:
591
+
592
+ ```yaml
593
+ theme:
594
+ mode: light
595
+ primary: neutral-900 # Dark buttons in a light section
596
+ primary-hover: neutral-800
597
+ ```
598
+
599
+ Any semantic token (`section`, `heading`, `body`, `primary`, `link`, etc.) can be overridden this way. The overrides are applied as inline CSS custom properties on the section wrapper — components don't need to know about them.
600
+
433
601
  **Site controls the palette** in `theme.yml`. The same foundation looks different across sites because tokens resolve from the site's color configuration, not from component code.
434
602
 
435
603
  ### theme.yml
@@ -437,18 +605,14 @@ theme: dark ← sets context-dark, all tokens resolve to dark values
437
605
  ```yaml
438
606
  # site/theme.yml
439
607
  colors:
440
- primary:
441
- base: '#3b82f6'
442
- exactMatch: true # Use this exact hex at the 500 shade
608
+ primary: '#3b82f6' # Your exact hex appears at shade 500
443
609
  secondary: '#64748b'
444
610
  accent: '#8b5cf6'
445
- neutral: stone # Named preset: stone, zinc, gray, slate, neutral
611
+ neutral: stone # Named preset: stone, zinc, gray, slate, neutral
446
612
 
447
613
  contexts:
448
614
  light:
449
- section: '#fafaf9' # Override individual tokens per context
450
- primary: var(--primary-500)
451
- primary-hover: var(--primary-600)
615
+ section: '#fafaf9' # Override individual tokens per context
452
616
 
453
617
  fonts:
454
618
  import:
@@ -467,23 +631,86 @@ vars: # Override foundation-declared variables
467
631
 
468
632
  Each color generates 11 OKLCH shades (50–950). The `neutral` palette is special — use a named preset (`stone` for warm) rather than a hex value. Three contexts are built-in: `light` (default), `medium`, `dark`. Context override keys match token names — `section:` not `bg:`, `primary:` not `btn-primary-bg:`.
469
633
 
634
+ ### How colors reach components
635
+
636
+ Your hex color → 11 shades (50–950) → semantic tokens → components.
637
+
638
+ **Shade 500 = your exact input color.** The build generates lighter shades (50–400) above it and darker shades (600–950) below it, redistributing lightness proportionally to maintain a smooth scale. Set `exactMatch: false` on a color to opt out and use fixed lightness values instead.
639
+
640
+ Semantic tokens map shades to roles. The defaults for light/medium contexts:
641
+
642
+ | Token | Shade | Purpose |
643
+ |-------|-------|---------|
644
+ | `--primary` | 600 | Button background |
645
+ | `--primary-hover` | 700 | Button hover |
646
+ | `--link` | 600 | Link color |
647
+ | `--ring` | 500 | Focus ring |
648
+
649
+ In dark contexts, `--primary` uses shade 500 and `--link` uses shade 400.
650
+
651
+ **Buttons and links use shade 600 — darker than your input.** This is an accessibility choice: shade 600 provides better contrast with white button text. For medium-bright brand colors like orange, buttons will be noticeably darker than the brand color.
652
+
653
+ **Recipe — brand-exact buttons:**
654
+
655
+ ```yaml
656
+ colors:
657
+ primary: "#E35D25"
658
+
659
+ contexts:
660
+ light:
661
+ primary: primary-500 # Your exact color on buttons
662
+ primary-hover: primary-600 # Darker on hover
663
+ ```
664
+
665
+ > **Contrast warning:** Bright brand colors (orange, yellow, light green) at shade 500 may not meet WCAG contrast (4.5:1) with white foreground text. Test buttons for readability — if contrast is insufficient, keep the default shade 600 mapping or darken your base color.
666
+
470
667
  ### Foundation variables
471
668
 
472
- Foundations declare customizable layout/spacing values in `foundation.js`:
669
+ Foundations declare customizable layout/spacing values in `foundation.js`. The starter includes:
473
670
 
474
671
  ```js
475
- export default {
476
- vars: {
477
- 'header-height': { default: '4rem' },
478
- 'sidebar-width': { default: '280px' },
479
- },
672
+ export const vars = {
673
+ 'header-height': { default: '4rem', description: 'Fixed header height' },
674
+ 'max-content-width': { default: '80rem', description: 'Maximum content width' },
675
+ 'section-padding-y': { default: 'clamp(4rem, 6vw, 7rem)', description: 'Vertical padding for sections' },
480
676
  }
481
677
  ```
482
678
 
483
- Sites override them in `theme.yml` under `vars:`. Components use them as `var(--header-height)`.
679
+ Sites override them in `theme.yml` under `vars:`. Components use them via Tailwind arbitrary values or CSS: `py-[var(--section-padding-y)]`, `h-[var(--header-height)]`, etc.
680
+
681
+ The `section-padding-y` default uses `clamp()` for fluid spacing — tighter on mobile, more breathing room on large screens. Use this variable for consistent section spacing instead of hardcoding padding in each component. Sites can override to a fixed value (`section-padding-y: 3rem`) or a different clamp in `theme.yml`.
484
682
 
485
683
  **When to break the rules:** Header/footer components that float over content may need direct color logic (reading the first section's theme). Decorative elements with fixed branding (logos) use literal colors.
486
684
 
685
+ ### Design richness beyond tokens
686
+
687
+ Semantic tokens handle context adaptation — the hard problem of making colors work in light, medium, and dark sections. **They are a floor, not a ceiling.** A great foundation adds its own design vocabulary on top.
688
+
689
+ The token set is deliberately small (24 tokens). It covers the dimensions that change per context. Everything that stays constant across contexts — border weights, shadow depth, radius scales, gradient angles, accent borders, glassmorphism, elevation layers — belongs in your foundation's `styles.css` or component code.
690
+
691
+ **Don't flatten a rich design to fit the token set.** If a source design has 4 border tones, create them:
692
+
693
+ ```css
694
+ /* foundation/src/styles.css */
695
+ .border-subtle { border-color: color-mix(in oklch, var(--border), transparent 50%); }
696
+ .border-strong { border-color: color-mix(in oklch, var(--border), var(--heading) 30%); }
697
+ .border-accent { border-color: var(--primary-300); }
698
+ ```
699
+
700
+ These compose with semantic tokens — they adapt per context because they reference `--border`, `--heading`, or palette shades. But they add design nuance the token set alone doesn't provide.
701
+
702
+ **The priority:** Design quality > portability > configurability. It's better to ship a foundation with beautiful, detailed design that's less configurable than to ship a generic one that looks flat. A foundation that looks great for one site is more valuable than one that looks mediocre for any site.
703
+
704
+ **Text tones beyond the 3-token set.** Source designs often have 4+ text tones (primary, secondary, tertiary, disabled). Uniweb provides 3 (`text-heading`, `text-body`, `text-subtle`). Don't collapse the extras — create them with `color-mix()` so they still adapt per context:
705
+
706
+ ```css
707
+ /* foundation/src/styles.css */
708
+ .text-tertiary { color: color-mix(in oklch, var(--body), var(--subtle) 50%); }
709
+ .text-disabled { color: color-mix(in oklch, var(--subtle), transparent 40%); }
710
+ ```
711
+
712
+ **When migrating from an existing design**, map every visual detail — not just the ones that have a semantic token. Shadow systems, border hierarchies, custom hover effects, accent tints: create CSS classes or Tailwind utilities in `styles.css` for anything the original has that tokens don't cover. Use palette shades directly (`var(--primary-300)`, `bg-neutral-200`) for fine-grained color control beyond the semantic tokens.
713
+
487
714
  ## Component Development
488
715
 
489
716
  ### Props Interface
@@ -509,15 +736,97 @@ function Hero({ content, params }) {
509
736
  )
510
737
  }
511
738
 
512
- Hero.className = 'pt-32 md:pt-48' // Classes on the <section> wrapper
739
+ Hero.className = 'pt-32 md:pt-48' // Override spacing for hero (more top padding)
513
740
  Hero.as = 'div' // Change wrapper element (default: 'section')
514
741
 
515
742
  export default Hero
516
743
  ```
517
744
 
518
- - `Component.className` — adds classes to the runtime's wrapper. Use for section-level padding, borders, overflow. The component's own JSX handles inner layout only (`max-w-7xl mx-auto px-6`).
745
+ - `Component.className` — adds classes to the runtime's wrapper. Use for section-level spacing, borders, overflow. Set `py-[var(--section-padding-y)]` for consistent spacing from the theme variable, or override for specific sections (e.g., hero needs extra top padding). The component's own JSX handles inner layout only (`max-w-7xl mx-auto px-6`).
519
746
  - `Component.as` — changes the wrapper element. Use `'nav'` for headers, `'footer'` for footers, `'div'` when `<section>` isn't semantically appropriate.
520
747
 
748
+ **Layout components** (Header, Footer) typically need `Component.className = 'p-0'` to suppress the runtime's default section padding, since they control their own padding. Also set `Component.as = 'header'` or `'footer'` for semantic HTML:
749
+
750
+ ```jsx
751
+ function Header({ content, block }) { /* ... */ }
752
+ Header.className = 'p-0'
753
+ Header.as = 'header'
754
+ export default Header
755
+ ```
756
+
757
+ ### Content Patterns for Header and Footer
758
+
759
+ Header and Footer are the hardest components to content-model because they combine several content categories. Use different parts of the content shape for each role:
760
+
761
+ **Header** — title for logo, list for nav links, standalone link for CTA, tagged YAML for metadata:
762
+
763
+ ````markdown
764
+ ---
765
+ type: Header
766
+ ---
767
+
768
+ # Acme Inc
769
+
770
+ - ![](lu-search) [How It Works](/how-it-works)
771
+ - ![](lu-users) [For Teams](/for-teams)
772
+ - ![](lu-book) [Docs](/docs)
773
+
774
+ [Get Started](/docs/quickstart)
775
+
776
+ ```yaml:config
777
+ github: https://github.com/acme
778
+ version: v2.1.0
779
+ ```
780
+ ````
781
+
782
+ ```jsx
783
+ function Header({ content, block }) {
784
+ const logo = content.title // "Acme Inc"
785
+ const navItems = content.lists[0] || [] // [{icons, links}, ...]
786
+ const cta = content.links[0] // {href, label}
787
+ const config = content.data?.config // {github, version}
788
+ // ...
789
+ }
790
+ ```
791
+
792
+ **Footer** — paragraph for tagline, nested list for grouped columns, tagged YAML for legal:
793
+
794
+ ````markdown
795
+ ---
796
+ type: Footer
797
+ ---
798
+
799
+ Build something great.
800
+
801
+ - Product
802
+ - [Features](/features)
803
+ - [Pricing](/pricing)
804
+ - Developers
805
+ - [Docs](/docs)
806
+ - [GitHub](https://github.com/acme){target=_blank}
807
+ - Community
808
+ - [Discord](#)
809
+ - [Blog](/blog)
810
+
811
+ ```yaml:legal
812
+ copyright: © 2025 Acme Inc
813
+ ```
814
+ ````
815
+
816
+ ```jsx
817
+ function Footer({ content, block }) {
818
+ const tagline = content.paragraphs[0] // "Build something great."
819
+ const columns = content.lists[0] || [] // [{paragraphs, lists}, ...]
820
+ const legal = content.data?.legal // {copyright}
821
+
822
+ // Each column: group.paragraphs[0] = label, group.lists[0] = links
823
+ columns.map(group => ({
824
+ label: group.paragraphs[0],
825
+ links: group.lists[0]?.map(item => item.links[0])
826
+ }))
827
+ }
828
+ ```
829
+
521
830
  ### meta.js Structure
522
831
 
523
832
  ```javascript
@@ -577,6 +886,8 @@ import { H2, P, Span } from '@uniweb/kit'
577
886
  <Text text={content.title} as="h2" className="..." /> // explicit tag
578
887
  ```
579
888
 
889
+ These components render their own HTML tag — don't wrap them in a matching tag. `<h2><H2 text={...} /></h2>` creates a nested `<h2><h2>...</h2></h2>`, which is invalid HTML. Just use `<H2 text={...} />` directly.
890
+
580
891
  Don't render content strings with `{content.paragraphs[0]}` in JSX — that shows HTML tags as visible text. Use `<P>`, `<H2>`, `<Span>`, etc. for content strings.
581
892
 
582
893
  **Rendering full content** (`@uniweb/kit`):
@@ -596,15 +907,29 @@ import { Section, Render } from '@uniweb/kit'
596
907
 
597
908
  **Other primitives** (`@uniweb/kit`): `Link`, `Image`, `Icon`, `Media`, `Asset`, `SafeHtml`, `SocialIcon`, `FileLogo`, `cn()`
598
909
 
910
+ `Link` props: `to` (or `href`), `target`, `reload`, `download`, `className`, `children`:
911
+
912
+ ```jsx
913
+ <Link to="/about">About</Link> // SPA navigation via React Router
914
+ <Link to="page:about">About</Link> // Resolves page ID to route
915
+ <Link reload href={localeUrl}>ES</Link> // Full page reload, prepends basePath
916
+ // External URLs auto-get target="_blank" and rel="noopener noreferrer"
917
+ ```
918
+
599
919
  **Other styled** (`@uniweb/kit`): `SidebarLayout`, `Prose`, `Article`, `Code`, `Alert`, `Table`, `Details`, `Divider`, `Disclaimer`
600
920
 
601
921
  **Hooks:**
602
922
  - `useScrolled(threshold)` → boolean for scroll-based header styling
603
923
  - `useMobileMenu()` → `{ isOpen, toggle, close }` with auto-close on navigation
604
924
  - `useAccordion({ multiple, defaultOpen })` → `{ isOpen, toggle }` for expand/collapse
605
- - `useActiveRoute()` → `{ route, isActiveOrAncestor(page) }` for nav highlighting (SSG-safe)
925
+ - `useActiveRoute()` → `{ route, rootSegment, isActive(page), isActiveOrAncestor(page) }` for nav highlighting (SSG-safe)
606
926
  - `useGridLayout(columns, { gap })` → responsive grid class string
607
927
  - `useTheme(name)` → standardized theme classes
928
+ - `useAppearance()` → `{ scheme, toggle, canToggle, setScheme, schemes }` — light/dark mode control with localStorage persistence
929
+ - `useRouting()` → `{ useLocation, useParams, useNavigate, Link, isRoutingAvailable }` — SSG-safe routing access (returns no-op fallbacks during prerender)
930
+ - `useWebsite()` → `{ website, localize, makeHref, getLanguage, getLanguages, getRoutingComponents }` — primary runtime hook
931
+ - `useThemeData()` → Theme instance for programmatic color access (`getColor(name, shade)`, `getPalette(name)`)
932
+ - `useColorContext(block)` → `'light' | 'medium' | 'dark'` — current section's color context
608
933
 
609
934
  **Utilities:** `cn()` (Tailwind class merge), `filterSocialLinks(links)`, `getSocialPlatform(url)`
610
935
 
@@ -642,6 +967,28 @@ website.hasMultipleLocales()
642
967
  website.getLocales() // [{ code, label, isDefault }]
643
968
  website.getActiveLocale() // 'en'
644
969
  website.getLocaleUrl('es')
970
+
971
+ // Core properties
972
+ website.name // Site name from site.yml
973
+ website.basePath // Deployment base path (e.g., '/docs/')
974
+
975
+ // Route detection
976
+ const { isActive, isActiveOrAncestor } = useActiveRoute()
977
+ isActive(page) // Exact match
978
+ isActiveOrAncestor(page) // Ancestor match (for parent highlighting in nav)
979
+
980
+ // Appearance (light/dark mode)
981
+ const { scheme, toggle, canToggle } = useAppearance()
982
+
983
+ // Page properties
984
+ page.title // Page title
985
+ page.label // Short label for nav (falls back to title)
986
+ page.route // Route path
987
+ page.isHidden() // Hidden from navigation
988
+ page.showInHeader() // Visible in header nav
989
+ page.showInFooter() // Visible in footer nav
990
+ page.hasChildren() // Has child pages
991
+ page.children // Array of child Page objects
645
992
  ```
646
993
 
647
994
  ### Insets and the Visual Component
@@ -670,6 +1017,8 @@ function SplitContent({ content, block }) {
670
1017
  - `block.getInset(refId)` — lookup by refId (used by sequential renderers)
671
1018
  - `content.insets` — flat array of `{ refId }` entries (parallel to `content.imgs`)
672
1019
 
1020
+ **SSG and hooks:** Inset components that use React hooks (useState, useEffect) will trigger prerender warnings during `pnpm build`. This is expected — the SSG pipeline cannot render hooks due to dual React instances in the build. The warnings are informational; the page renders correctly client-side. If you see `"Skipped SSG for /..."` or `"Invalid hook call"`, this is the cause.
1021
+
673
1022
  Inset components declare `inset: true` in meta.js. Use `hidden: true` for inset-only components:
674
1023
 
675
1024
  ```js
@@ -853,7 +1202,7 @@ Foundation styles in `foundation/src/styles.css`:
853
1202
 
854
1203
  Semantic color tokens (`text-heading`, `bg-section`, `bg-primary`, etc.) come from `theme-tokens.css` — which the runtime populates from the site's `theme.yml`. Don't redefine colors here that belong in `theme.yml`. Use `@theme` only for values the token system doesn't cover (custom breakpoints, animations, shadows).
855
1204
 
856
- **Custom CSS is fine alongside Tailwind.** Animations, keyframes, gradients with masks, always-dark code blocks, and other effects that aren't expressible as utility classes can go directly in `styles.css`. Tailwind handles layout and spacing; custom CSS handles visual effects.
1205
+ **Custom CSS is expected alongside Tailwind.** Your foundation's `styles.css` is the design layer — shadow systems, border hierarchies, gradient effects, accent treatments, elevation scales, glassmorphism. If the source design has a visual detail, create a class for it. Tailwind handles layout and spacing; semantic tokens handle context adaptation; `styles.css` handles everything else that makes the design rich and distinctive.
857
1206
 
858
1207
  ## Troubleshooting
859
1208
 
@@ -863,6 +1212,16 @@ Semantic color tokens (`text-heading`, `bg-section`, `bg-primary`, etc.) come fr
863
1212
 
864
1213
  **Styles not applying** — Verify `@source` in `styles.css` includes your component paths. Check custom colors match `@theme` definitions.
865
1214
 
1215
+ **Prerender warnings about hooks/useState** — Components with React hooks (useState/useEffect) — especially insets — will show SSG warnings during `pnpm build`. This is expected and harmless; see the note in the Insets section above.
1216
+
1217
+ **Content not appearing as expected?** In dev mode, open the browser console and inspect the parsed content shape your component receives:
1218
+
1219
+ ```js
1220
+ globalThis.uniweb.activeWebsite.activePage.bodyBlocks[0].parsedContent
1221
+ ```
1222
+
1223
+ Compare with the Content Shape table above to identify mapping issues (e.g., headings becoming items instead of title, links inline in paragraphs instead of in `links[]`).
1224
+
866
1225
  ## Further Documentation
867
1226
 
868
1227
  Full Uniweb documentation is available at **https://github.com/uniweb/docs** — raw markdown files you can fetch directly.
@@ -1,17 +1,18 @@
1
1
  /**
2
2
  * Add Command
3
3
  *
4
- * Adds foundations, sites, extensions, or co-located projects to an existing workspace.
4
+ * Adds foundations, sites, extensions, section types, or co-located projects to an existing workspace.
5
5
  *
6
6
  * Usage:
7
7
  * uniweb add project [name] [--from <template>]
8
8
  * uniweb add foundation [name] [--from <template>] [--path <dir>] [--project <name>]
9
9
  * uniweb add site [name] [--from <template>] [--foundation <name>] [--path <dir>] [--project <name>]
10
10
  * uniweb add extension [name] [--from <template>] [--site <name>] [--path <dir>]
11
+ * uniweb add section <name> [--foundation <name>]
11
12
  */
12
13
 
13
14
  import { existsSync } from 'node:fs'
14
- import { readFile, writeFile } from 'node:fs/promises'
15
+ import { readFile, writeFile, mkdir } from 'node:fs/promises'
15
16
  import { join, relative } from 'node:path'
16
17
  import prompts from 'prompts'
17
18
  import yaml from 'js-yaml'
@@ -123,9 +124,10 @@ export async function add(rawArgs) {
123
124
  { label: 'foundation', description: 'Component library' },
124
125
  { label: 'site', description: 'Content site' },
125
126
  { label: 'extension', description: 'Additional component package' },
127
+ { label: 'section', description: 'Section type in a foundation' },
126
128
  ]))
127
129
  log('')
128
- log(`Usage: ${prefix} add <project|foundation|site|extension> [name]`)
130
+ log(`Usage: ${prefix} add <project|foundation|site|extension|section> [name]`)
129
131
  process.exit(1)
130
132
  }
131
133
 
@@ -138,6 +140,7 @@ export async function add(rawArgs) {
138
140
  { title: 'Foundation', value: 'foundation', description: 'Component library' },
139
141
  { title: 'Site', value: 'site', description: 'Content site' },
140
142
  { title: 'Extension', value: 'extension', description: 'Additional component package' },
143
+ { title: 'Section', value: 'section', description: 'Section type in a foundation' },
141
144
  ],
142
145
  }, {
143
146
  onCancel: () => {
@@ -169,9 +172,12 @@ export async function add(rawArgs) {
169
172
  case 'extension':
170
173
  await addExtension(rootDir, projectName, parsed, pm)
171
174
  break
175
+ case 'section':
176
+ await addSection(rootDir, parsed)
177
+ break
172
178
  default:
173
179
  error(`Unknown subcommand: ${parsed.subcommand}`)
174
- log(`Valid subcommands: project, foundation, site, extension`)
180
+ log(`Valid subcommands: project, foundation, site, extension, section`)
175
181
  process.exit(1)
176
182
  }
177
183
  }
@@ -875,6 +881,138 @@ async function wireExtensionToSite(rootDir, siteName, extensionName, extensionPa
875
881
  }
876
882
  }
877
883
 
884
+ /**
885
+ * Add a section type to a foundation
886
+ */
887
+ async function addSection(rootDir, opts) {
888
+ let name = opts.name
889
+
890
+ // Interactive name prompt when not provided
891
+ if (!name) {
892
+ if (isNonInteractive(process.argv)) {
893
+ error(`Missing section name.\n`)
894
+ log(`Usage: ${getCliPrefix()} add section <Name>`)
895
+ log(`\nSection names use PascalCase: Hero, FeatureGrid, CallToAction`)
896
+ process.exit(1)
897
+ }
898
+
899
+ const response = await prompts({
900
+ type: 'text',
901
+ name: 'name',
902
+ message: 'Section name (PascalCase):',
903
+ validate: (value) => /^[A-Z][a-zA-Z0-9]*$/.test(value) || 'Use PascalCase: Hero, FeatureGrid, CallToAction',
904
+ }, {
905
+ onCancel: () => {
906
+ log('\nCancelled.')
907
+ process.exit(0)
908
+ },
909
+ })
910
+ name = response.name
911
+ }
912
+
913
+ // Validate PascalCase
914
+ if (!/^[A-Z][a-zA-Z0-9]*$/.test(name)) {
915
+ error(`Section name must be PascalCase (e.g., Hero, FeatureGrid, CallToAction).`)
916
+ process.exit(1)
917
+ }
918
+
919
+ // Find the foundation
920
+ const foundations = await discoverFoundations(rootDir)
921
+ let foundation
922
+
923
+ if (foundations.length === 0) {
924
+ error('No foundation found in this workspace.')
925
+ log(`Create one first: ${getCliPrefix()} add foundation`)
926
+ process.exit(1)
927
+ } else if (foundations.length === 1) {
928
+ foundation = foundations[0]
929
+ } else if (opts.foundation) {
930
+ foundation = foundations.find(f => f.name === opts.foundation)
931
+ if (!foundation) {
932
+ error(`Foundation '${opts.foundation}' not found.`)
933
+ log(`Available: ${foundations.map(f => f.name).join(', ')}`)
934
+ process.exit(1)
935
+ }
936
+ } else if (isNonInteractive(process.argv)) {
937
+ error(`Multiple foundations found. Specify which to use:\n`)
938
+ log(formatOptions(foundations.map(f => ({ label: f.name, description: f.path }))))
939
+ log('')
940
+ log(`Usage: ${getCliPrefix()} add section ${name} --foundation <name>`)
941
+ process.exit(1)
942
+ } else {
943
+ const response = await prompts({
944
+ type: 'select',
945
+ name: 'foundation',
946
+ message: 'Which foundation?',
947
+ choices: foundations.map(f => ({ title: f.name, description: f.path, value: f })),
948
+ }, {
949
+ onCancel: () => {
950
+ log('\nCancelled.')
951
+ process.exit(0)
952
+ },
953
+ })
954
+ foundation = response.foundation
955
+ }
956
+
957
+ // Resolve sections directory
958
+ const sectionsDir = join(rootDir, foundation.path, 'src', 'sections')
959
+ const sectionDir = join(sectionsDir, name)
960
+
961
+ if (existsSync(sectionDir)) {
962
+ error(`Section '${name}' already exists at ${foundation.path}/src/sections/${name}/`)
963
+ process.exit(1)
964
+ }
965
+
966
+ // Create section directory and files
967
+ await mkdir(sectionDir, { recursive: true })
968
+
969
+ const componentContent = `import { H2, P, Link, cn } from '@uniweb/kit'
970
+
971
+ export default function ${name}({ content, params }) {
972
+ const { title, paragraphs = [], links = [] } = content || {}
973
+
974
+ return (
975
+ <div className="max-w-4xl mx-auto px-6">
976
+ {title && <H2 text={title} className="text-heading text-3xl font-bold" />}
977
+ <P text={paragraphs} className="text-body mt-4" />
978
+ {links.length > 0 && (
979
+ <div className="mt-6 flex gap-3 flex-wrap">
980
+ {links.map((link, i) => (
981
+ <Link key={i} to={link.href} className={cn(
982
+ 'px-5 py-2.5 rounded-lg font-medium transition-colors',
983
+ i === 0
984
+ ? 'bg-primary text-primary-foreground hover:bg-primary-hover'
985
+ : 'bg-secondary text-secondary-foreground hover:bg-secondary-hover'
986
+ )}>
987
+ {link.label}
988
+ </Link>
989
+ ))}
990
+ </div>
991
+ )}
992
+ </div>
993
+ )
994
+ }
995
+ `
996
+
997
+ const metaContent = `export default {
998
+ title: '${name}',
999
+ description: '',
1000
+ params: {},
1001
+ }
1002
+ `
1003
+
1004
+ await writeFile(join(sectionDir, 'index.jsx'), componentContent)
1005
+ await writeFile(join(sectionDir, 'meta.js'), metaContent)
1006
+
1007
+ success(`Created section '${name}' at ${foundation.path}/src/sections/${name}/`)
1008
+ log(` ${colors.dim}index.jsx${colors.reset} — component (customize the JSX)`)
1009
+ log(` ${colors.dim}meta.js${colors.reset} — metadata (add content expectations, params, presets)`)
1010
+ if (foundations.length === 1) {
1011
+ log('')
1012
+ log(`${colors.dim}The dev server will pick it up automatically.${colors.reset}`)
1013
+ }
1014
+ }
1015
+
878
1016
  /**
879
1017
  * Show help for the add command
880
1018
  */
@@ -882,13 +1020,14 @@ function showAddHelp() {
882
1020
  log(`
883
1021
  ${colors.cyan}${colors.bright}Uniweb Add${colors.reset}
884
1022
 
885
- Add projects, foundations, sites, or extensions to your workspace.
1023
+ Add projects, foundations, sites, extensions, or section types to your workspace.
886
1024
 
887
1025
  ${colors.bright}Usage:${colors.reset}
888
1026
  uniweb add project [name] [options]
889
1027
  uniweb add foundation [name] [options]
890
1028
  uniweb add site [name] [options]
891
1029
  uniweb add extension <name> [options]
1030
+ uniweb add section <name> [options]
892
1031
 
893
1032
  ${colors.bright}Common Options:${colors.reset}
894
1033
  --from <template> Apply content from a template after scaffolding
@@ -904,6 +1043,9 @@ ${colors.bright}Site Options:${colors.reset}
904
1043
  ${colors.bright}Extension Options:${colors.reset}
905
1044
  --site <name> Site to wire extension URL into
906
1045
 
1046
+ ${colors.bright}Section Options:${colors.reset}
1047
+ --foundation <n> Foundation to add section to (prompted if multiple exist)
1048
+
907
1049
  ${colors.bright}Examples:${colors.reset}
908
1050
  uniweb add project docs # Create docs/foundation/ + docs/site/
909
1051
  uniweb add project docs --from academic # Co-located pair + academic content
@@ -912,6 +1054,8 @@ ${colors.bright}Examples:${colors.reset}
912
1054
  uniweb add site # Create ./site/ at root
913
1055
  uniweb add site blog --foundation marketing # Create ./blog/ wired to marketing
914
1056
  uniweb add extension effects --site site # Create ./extensions/effects/
1057
+ uniweb add section Hero # Create Hero section type
1058
+ uniweb add section Hero --foundation ui # Target specific foundation
915
1059
  uniweb add foundation --project docs # Create ./docs/foundation/ (co-located)
916
1060
  uniweb add site --project docs # Create ./docs/site/ (co-located)
917
1061
  `)
package/src/index.js CHANGED
@@ -583,6 +583,7 @@ ${colors.bright}Add Subcommands:${colors.reset}
583
583
  add foundation [name] Add a foundation (--from, --path, --project)
584
584
  add site [name] Add a site (--from, --foundation, --path, --project)
585
585
  add extension <name> Add an extension (--from, --site, --path)
586
+ add section <name> Add a section type to a foundation (--foundation)
586
587
 
587
588
  ${colors.bright}Global Options:${colors.reset}
588
589
  --non-interactive Fail with usage info instead of prompting
@@ -24,8 +24,8 @@ export const vars = {
24
24
  description: 'Maximum content width (1280px)',
25
25
  },
26
26
  'section-padding-y': {
27
- default: '5rem',
28
- description: 'Vertical padding for sections',
27
+ default: 'clamp(4rem, 6vw, 7rem)',
28
+ description: 'Vertical padding for sections (fluid: adapts to viewport)',
29
29
  },
30
30
  }
31
31
 
@@ -51,15 +51,15 @@ colors:
51
51
  # Text: body, heading, subtle
52
52
  # Border: border, ring
53
53
  # Links: link, link-hover
54
- # Actions: primary, primary-foreground, primary-hover
55
- # secondary, secondary-foreground, secondary-hover
54
+ # Actions: primary, primary-foreground, primary-hover, primary-border
55
+ # secondary, secondary-foreground, secondary-hover, secondary-border
56
56
  # Status: success, warning, error, info (+ -subtle variants)
57
57
 
58
58
  # contexts:
59
59
  # light:
60
60
  # section: white
61
61
  # medium:
62
- # section: 'var(--neutral-100)'
62
+ # section: neutral-100
63
63
  # dark:
64
64
  # heading: white
65
65