create-smoodly-app 0.0.16 → 0.0.17

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 (65) hide show
  1. package/package.json +1 -1
  2. package/templates/blank/AGENTS.md +2 -2
  3. package/templates/blank/README.md +5 -5
  4. package/templates/blank/app/(site)/layout.tsx +3 -3
  5. package/templates/blank/app/globals.css +19 -9
  6. package/templates/blank/components/pages/Home.tsx +1 -1
  7. package/templates/blank/components/pages/Page.tsx +1 -1
  8. package/templates/blank/components/sections/Text.tsx +8 -4
  9. package/templates/blank/package.json +1 -1
  10. package/templates/blank/scripts/seed.ts +14 -2
  11. package/templates/blank/smoodly/config.ts +4 -11
  12. package/templates/blank/smoodly/{shared.ts → globals.ts} +4 -4
  13. package/templates/blank/smoodly/server.ts +9 -6
  14. package/templates/blank/supabase/migrations/00000000000000_smoodly.sql +1 -1
  15. package/templates/blank-intl/AGENTS.md +2 -2
  16. package/templates/blank-intl/README.md +5 -5
  17. package/templates/blank-intl/app/[locale]/layout.tsx +3 -3
  18. package/templates/blank-intl/app/globals.css +19 -9
  19. package/templates/blank-intl/components/pages/Home.tsx +1 -1
  20. package/templates/blank-intl/components/pages/Page.tsx +1 -1
  21. package/templates/blank-intl/components/sections/Text.tsx +8 -4
  22. package/templates/blank-intl/package.json +1 -1
  23. package/templates/blank-intl/scripts/seed.ts +14 -2
  24. package/templates/blank-intl/smoodly/config.ts +4 -11
  25. package/templates/blank-intl/smoodly/{shared.ts → globals.ts} +4 -4
  26. package/templates/blank-intl/smoodly/server.ts +9 -6
  27. package/templates/blank-intl/supabase/migrations/00000000000000_smoodly.sql +1 -1
  28. package/templates/lorem/AGENTS.md +2 -2
  29. package/templates/lorem/README.md +4 -4
  30. package/templates/lorem/app/(site)/layout.tsx +4 -4
  31. package/templates/lorem/app/globals.css +53 -44
  32. package/templates/lorem/components/elements/Callout.tsx +3 -3
  33. package/templates/lorem/components/elements/Cta.tsx +4 -4
  34. package/templates/lorem/components/pages/Home.tsx +4 -4
  35. package/templates/lorem/components/pages/Page.tsx +1 -1
  36. package/templates/lorem/components/sections/Footer.tsx +4 -2
  37. package/templates/lorem/components/sections/Header.tsx +4 -2
  38. package/templates/lorem/components/sections/ImageText.tsx +5 -5
  39. package/templates/lorem/components/sections/Quote.tsx +9 -6
  40. package/templates/lorem/package.json +1 -1
  41. package/templates/lorem/scripts/seed.ts +28 -9
  42. package/templates/lorem/smoodly/config.ts +18 -11
  43. package/templates/lorem/smoodly/{shared.ts → globals.ts} +7 -7
  44. package/templates/lorem/smoodly/sections.ts +3 -3
  45. package/templates/lorem/smoodly/server.ts +9 -6
  46. package/templates/lorem/supabase/migrations/00000000000000_smoodly.sql +1 -1
  47. package/templates/lorem-intl/AGENTS.md +2 -2
  48. package/templates/lorem-intl/README.md +4 -4
  49. package/templates/lorem-intl/app/[locale]/layout.tsx +4 -4
  50. package/templates/lorem-intl/app/globals.css +53 -44
  51. package/templates/lorem-intl/components/elements/Callout.tsx +3 -3
  52. package/templates/lorem-intl/components/elements/Cta.tsx +4 -4
  53. package/templates/lorem-intl/components/pages/Home.tsx +4 -4
  54. package/templates/lorem-intl/components/pages/Page.tsx +1 -1
  55. package/templates/lorem-intl/components/sections/Footer.tsx +4 -2
  56. package/templates/lorem-intl/components/sections/Header.tsx +4 -2
  57. package/templates/lorem-intl/components/sections/ImageText.tsx +5 -5
  58. package/templates/lorem-intl/components/sections/Quote.tsx +9 -6
  59. package/templates/lorem-intl/package.json +1 -1
  60. package/templates/lorem-intl/scripts/seed.ts +28 -9
  61. package/templates/lorem-intl/smoodly/config.ts +18 -11
  62. package/templates/lorem-intl/smoodly/{shared.ts → globals.ts} +7 -7
  63. package/templates/lorem-intl/smoodly/sections.ts +3 -3
  64. package/templates/lorem-intl/smoodly/server.ts +9 -6
  65. package/templates/lorem-intl/supabase/migrations/00000000000000_smoodly.sql +1 -1
@@ -15,10 +15,10 @@ import { supabaseAdminAuth, type AdminAuth, type AuthConfig } from "smoodly/admi
15
15
  import { createPageRenderer, createSmoodlyQueries, type PageRegistration } from "smoodly/next";
16
16
  import type { SupabaseClient } from "@supabase/supabase-js";
17
17
  import { getPathname } from "@/i18n/navigation";
18
- import { collections, config, elements, sections, shared } from "./config";
18
+ import { collections, config, elements, sections, globals } from "./config";
19
19
 
20
20
  type Renderer = ReturnType<typeof createPageRenderer>;
21
- type Queries = ReturnType<typeof createSmoodlyQueries<typeof collections, typeof shared>>;
21
+ type Queries = ReturnType<typeof createSmoodlyQueries<typeof collections, typeof globals>>;
22
22
  type Bound = {
23
23
  db: SupabaseClient; pages: SupabasePageStore; entries: SupabaseEntryStore; assets: SupabaseAssetStore; auth: AdminAuth; site: SmoodlySite; renderer: Renderer; q: Queries;
24
24
  };
@@ -32,9 +32,11 @@ function bound(): Bound {
32
32
  // ONE path index for both stores: pages and entries share the URL space.
33
33
  const paths = new SupabasePathIndex(db);
34
34
  const locales = config.locales;
35
- const entries = new SupabaseEntryStore(db, collections, { paths, locales, shared, elements });
35
+ const entries = new SupabaseEntryStore(db, collections, { paths, locales, globals, elements });
36
36
  const pages = new SupabasePageStore(db, {
37
37
  sections, elements, entries, paths, locales,
38
+ // The custom section's columns toolset, so a column's images count as usage (spec 2026-09-14 §6).
39
+ custom: config.custom,
38
40
  homePageSlug: config.homePageSlug,
39
41
  tree: config.pages?.tree,
40
42
  });
@@ -47,15 +49,16 @@ function bound(): Bound {
47
49
  // The site layer: (locale, path) → page or entry, and the content API
48
50
  // `q` wraps below. Not exported — the app reads through `q`, which adds
49
51
  // draft awareness and the cache.
50
- const site = createSmoodlySite({ stores, collections, shared, sections, elements, locales, homePageSlug: config.homePageSlug, pages: config.registry.pages ?? [] });
52
+ const site = createSmoodlySite({ stores, collections, globals, sections, elements, locales, homePageSlug: config.homePageSlug, styles: config.styles, custom: config.custom, pages: config.registry.pages ?? [] });
51
53
  const renderer = createPageRenderer({
52
54
  site,
53
55
  sections,
56
+ elements,
54
57
  pages: (config.registry.pages ?? []) as PageRegistration[],
55
58
  href,
56
59
  });
57
60
  // The read API (spec 2026-09-11): typed by the registry, draft-aware, cached under tags the admin expires.
58
- const q = createSmoodlyQueries({ site, collections, shared });
61
+ const q = createSmoodlyQueries({ site, collections, globals });
59
62
  cached = { db, pages, entries, assets, auth, site, renderer, q };
60
63
  return cached;
61
64
  }
@@ -84,7 +87,7 @@ export function publicAuth(): AuthConfig {
84
87
  export const href = (path: string, locale: string) => getPathname({ locale, href: path });
85
88
 
86
89
  /** The read API (spec 2026-09-11) for layouts and page views that fetch:
87
- * `q().getEntries("articles", { locale, limit: 3 })`, `q().getShared("header",
90
+ * `q().getEntries("articles", { locale, limit: 3 })`, `q().getGlobal("header",
88
91
  * { locale })`, `q().getPageTree({ locale })`. Typed by the registry, the
89
92
  * live draft in the editor preview, cached under tags the admin expires.
90
93
  * Rename it if `q` is not to your taste — this file is yours. */
@@ -110,7 +110,7 @@ create index if not exists "entry_locales_status_idx"
110
110
  -- A version belongs to ONE locale of one entry and stores the MERGED
111
111
  -- fields (shared + localized + slug) as they were at that save, so
112
112
  -- restoring an old Finnish version restores the shared fields too.
113
- -- Rows for every collection and every shared item (spec 2026-09-14 §1).
113
+ -- Rows for every collection and every global item (spec 2026-09-14 §1).
114
114
  -- One row per publish plus the live draft (spec 2026-09-13 history §1).
115
115
  create table if not exists "entry_versions" (
116
116
  "id" uuid primary key default gen_random_uuid(),
@@ -8,8 +8,8 @@ where things are.
8
8
  ## Rules
9
9
 
10
10
  - **Register everything in `smoodly/`.** Every section, element,
11
- collection and shared item is listed in its barrel (`sections.ts`,
12
- `elements.ts`, `collections.ts`, `shared.ts`) and `smoodly/config.ts`
11
+ collection and global item is listed in its barrel (`sections.ts`,
12
+ `elements.ts`, `collections.ts`, `globals.ts`) and `smoodly/config.ts`
13
13
  is the one registry. A component that is not registered does not exist
14
14
  for the admin.
15
15
  - **A new collection is a new migration.** Write its view with
@@ -10,9 +10,9 @@ for an agent (or a person) building the site.
10
10
 
11
11
  - `app/(site)/page.tsx` — the home page: one line calling renderSmoodlyPath([]).
12
12
  - `app/(site)/[...path]/page.tsx` — every other page and every collection entry, at any depth.
13
- - `app/(site)/layout.tsx` — the site's root layout: fonts, `app/globals.css`, the header and footer from shared content; the admin has its own under `app/(smoodly)/admin/`.
13
+ - `app/(site)/layout.tsx` — the site's root layout: fonts, `app/globals.css`, the header and footer from global content; the admin has its own under `app/(smoodly)/admin/`.
14
14
  - `components/pages/` — the page shapes and the entry page: `Home` (locked hero, freeform body), `Articles` (a fixed page at the collection's path — its index — with a code-fetched grid), `Page` (open; About lives here), `Article` (the entry page).
15
- - `components/sections/` — the building blocks editors compose with, and the header, footer and quote behind the shared items. Add one, list it in `smoodly/sections.ts`.
15
+ - `components/sections/` — the building blocks editors compose with, and the header, footer and quote behind the global items. Add one, list it in `smoodly/sections.ts`.
16
16
  - `components/strings.ts`, `messages/` — the site's own UI strings and the request locale, one seam.
17
17
  - `public/images/` — the starter's photos; image fields hold URLs like `/images/hero_image.jpg`.
18
18
  - `scripts/seed.ts` — the starter content, written through the stores. `npm run seed` is idempotent.
@@ -24,11 +24,11 @@ for an agent (or a person) building the site.
24
24
 
25
25
  The Lorem Co. site the installer seeded is a tour of the features:
26
26
 
27
- - **Home** — a locked hero (fields only; nobody adds, moves or removes it), then a freeform body: image and text (richtext), the shared **quote** placed from the picker's Shared group, points (a list with a minimum and a maximum, an object per item), favourite articles (an editor-picked list of refs).
27
+ - **Home** — a locked hero (fields only; nobody adds, moves or removes it), then a freeform body: image and text (richtext), the global **quote** placed from the picker's Global group, points (a list with a minimum and a maximum, an object per item), favourite articles (an editor-picked list of refs).
28
28
  - **Articles** — a fixed page at the collection's own URL segment, so it is the collection's index: the hero is the editor's, the grid is code fetching published articles.
29
29
  - **Company / About** — a folder and a page under it (the breadcrumb), the same body sections, plus people.
30
30
  - **An article** — a collection entry with a richtext body, tags (a localized list), a date and an author ref; versioned, so every save is a snapshot.
31
- - **Shared content** — the header and footer the layout renders, the contact details the footer reads, the quote pages place.
31
+ - **Global content** — the header and footer the layout renders, the contact details the footer reads, the quote pages place.
32
32
 
33
33
  Reseed an empty database any time with `npm run seed`.
34
34
 
@@ -15,13 +15,13 @@ export const metadata = { title: "Lorem Co." };
15
15
  // under app/(smoodly)/admin is a second root layout beside it rather than
16
16
  // a child of this one — two root layouts, a full page load between them,
17
17
  // which is fine: they are separate apps in spirit. Header, footer and the
18
- // contact block are shared content: read through `q`, cached under their
18
+ // contact block are global content: read through `q`, cached under their
19
19
  // tag, null until published.
20
20
  export default async function RootLayout({ children }: { children: ReactNode }) {
21
21
  const [headerProps, footerProps, contactFields] = await Promise.all([
22
- q().getShared("header"),
23
- q().getShared("footer"),
24
- q().getShared("contact"),
22
+ q().getGlobal("header"),
23
+ q().getGlobal("footer"),
24
+ q().getGlobal("contact"),
25
25
  ]);
26
26
  return (
27
27
  <html lang="en" className={`${serif.variable} ${sans.variable}`}>
@@ -1,11 +1,18 @@
1
1
  /* Lorem Co. — the starter's one stylesheet.
2
2
  Tokens from the design, base rules, then one block per section and
3
- page in page order. Sections carry data-spacing and data-tone from
4
- the config's section styles; the site header and footer are shared
5
- items rendered by the layout. Photos crop with object-fit: cover at
6
- the design's aspect ratios. */
3
+ page in page order. Each section carries its own padding; the editor's
4
+ Spacing & layout steps (Smoodly's styles set, smoodly/config.ts) render
5
+ inline through the section wrapper and read the --space-* / --width-*
6
+ variables below. A section's `background` setting is its own data
7
+ attribute. The site header and footer are global items rendered by
8
+ the layout. Photos crop with object-fit: cover at the design's aspect
9
+ ratios. */
7
10
 
8
11
  :root {
12
+ /* Smoodly's styles set reads these (smoodly/config.ts): editor steps become padding, width, alignment. */
13
+ --space-sm: 24px; --space-md: 48px; --space-lg: 72px;
14
+ --width-narrow: 640px; --width-wide: 1440px;
15
+
9
16
  --paper: #faf7f2;
10
17
  --ink: #1f1b17;
11
18
  --accent: #d9653a;
@@ -15,9 +22,12 @@
15
22
  --body: #3a342e;
16
23
  --radius: 6px;
17
24
  --container: 1200px;
25
+ /* The custom section's rows share the container's side padding (spec 2026-09-14 §6). */
26
+ --smd-gutter: 32px;
18
27
  --serif: var(--font-serif), Georgia, serif;
19
28
  --sans: var(--font-sans), Helvetica, Arial, sans-serif;
20
29
  }
30
+ @media (min-width: 768px) { :root { --space-sm: 32px; --space-md: 64px; --space-lg: 96px; } }
21
31
 
22
32
  /* ---------- base ---------- */
23
33
 
@@ -39,7 +49,8 @@ img { display: block; max-width: 100%; }
39
49
  h1, h2, h3, p, blockquote, address { margin: 0; }
40
50
  address { font-style: normal; }
41
51
 
42
- .container { max-width: var(--container); width: 100%; margin: 0 auto; padding-inline: 32px; }
52
+ /* The container reads the editor's width step; without one, the design's width. */
53
+ .container { max-width: var(--smd-width, var(--container)); width: 100%; margin: 0 auto; padding-inline: 32px; }
43
54
 
44
55
  .kicker { font-size: 13px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--accent); font-weight: 600; }
45
56
  .h2 { font-family: var(--serif); font-weight: 400; font-size: clamp(30px, 3.5vw, 44px); line-height: 1.1; letter-spacing: -0.02em; text-wrap: balance; }
@@ -52,48 +63,40 @@ address { font-style: normal; }
52
63
  .button--outline:hover { background: transparent; color: var(--ink); border-color: var(--ink); }
53
64
 
54
65
  /* Richtext (RichText renders bare elements) */
55
- .prose { display: flex; flex-direction: column; gap: 20px; font-size: 17px; line-height: 1.65; color: var(--muted); }
56
- .prose p { text-wrap: pretty; }
57
- .prose h2, .prose h3 { font-family: var(--serif); font-weight: 500; color: var(--ink); line-height: 1.2; letter-spacing: -0.01em; }
58
- .prose h2 { font-size: 32px; margin-top: 8px; }
59
- .prose h3 { font-size: 24px; }
60
- .prose ul, .prose ol { margin: 0; padding-left: 22px; display: flex; flex-direction: column; gap: 8px; }
61
- .prose blockquote { margin: 8px 0; padding-left: 24px; border-left: 2px solid var(--accent); font-family: var(--serif); font-style: italic; font-size: 24px; line-height: 1.4; color: var(--ink); }
62
- .prose a { color: var(--accent); text-decoration: underline; }
66
+ /* The custom section's columns (.smd-col) read like prose (spec 2026-09-14 §6). */
67
+ .prose, .smd-col{ display: flex; flex-direction: column; gap: 20px; font-size: 17px; line-height: 1.65; color: var(--muted); }
68
+ .prose p, .smd-col p{ text-wrap: pretty; }
69
+ .prose h2, .prose h3, .smd-col h2, .smd-col h3 { font-family: var(--serif); font-weight: 500; color: var(--ink); line-height: 1.2; letter-spacing: -0.01em; }
70
+ .prose h2, .smd-col h2{ font-size: 32px; margin-top: 8px; }
71
+ .prose h3, .smd-col h3{ font-size: 24px; }
72
+ .prose ul, .prose ol, .smd-col ul, .smd-col ol { margin: 0; padding-left: 22px; display: flex; flex-direction: column; gap: 8px; }
73
+ .prose blockquote, .smd-col blockquote{ margin: 8px 0; padding-left: 24px; border-left: 2px solid var(--accent); font-family: var(--serif); font-style: italic; font-size: 24px; line-height: 1.4; color: var(--ink); }
74
+ .prose a, .smd-col a{ color: var(--accent); text-decoration: underline; }
63
75
  .prose--lead { gap: 24px; font-size: 18px; line-height: 1.7; color: var(--body); }
64
76
  .prose--lead > p:first-child { font-family: var(--serif); font-size: 24px; line-height: 1.45; color: var(--ink); }
65
77
 
66
78
  /* ---------- richtext block nodes: images, callout, cta ---------- */
67
- .prose figure { margin: 8px 0; }
68
- .prose figure img { width: 100%; height: auto; border-radius: var(--radius); display: block; }
69
- .prose figcaption { margin-top: 8px; font-size: 14px; color: var(--muted); }
70
- .prose .callout { margin: 8px 0; padding: 20px 24px; border-radius: var(--radius); border-left: 3px solid var(--accent); background: #fff; display: flex; flex-direction: column; gap: 8px; font-size: 16px; line-height: 1.6; }
71
- .prose .callout[data-tone="success"] { border-left-color: #3a8f5c; }
72
- .prose .callout[data-tone="warning"] { border-left-color: #c9962b; background: #fff8e8; }
73
- .prose .callout__title { color: var(--ink); font-size: 15px; letter-spacing: 0.02em; }
74
- .prose .callout__body { display: flex; flex-direction: column; gap: 8px; }
75
- .prose .cta { margin: 16px 0; padding: 28px 32px; border-radius: var(--radius); background: var(--ink); color: var(--paper); display: flex; align-items: center; justify-content: space-between; gap: 24px; flex-wrap: wrap; }
76
- .prose .cta[data-variant="outline"] { background: transparent; color: var(--ink); border: 1px solid var(--line-strong); }
77
- .prose .cta__heading { font-family: var(--serif); font-size: 24px; font-weight: 500; line-height: 1.2; display: block; }
78
- .prose .cta__text { margin: 6px 0 0; font-size: 15px; color: inherit; opacity: 0.8; }
79
- .prose .cta .button { background: var(--paper); color: var(--ink); flex: none; }
80
- .prose .cta[data-variant="outline"] .button { background: var(--ink); color: var(--paper); }
81
-
82
- /* ---------- section styles: the config's spacing and tone ---------- */
83
-
84
- /* Every section in the page body gets the medium spacing by element,
85
- not by attribute: a node saved without an explicit style carries no
86
- data-spacing (the config's default is not filled at render), so the
87
- attribute selectors below only handle the two non-default values. */
88
- main > section { padding-block: 56px; }
89
- [data-spacing="sm"] { padding-block: 32px; }
90
- [data-spacing="lg"] { padding-block: 88px; }
91
- [data-tone="ink"] { background: var(--ink); color: var(--paper); }
92
- [data-tone="ink"] .prose, [data-tone="ink"] .hero__text, [data-tone="ink"] .page-hero__text,
93
- [data-tone="ink"] .point__text, [data-tone="ink"] .card__meta, [data-tone="ink"] .card__excerpt,
94
- [data-tone="ink"] .person__role, [data-tone="ink"] .quote__attribution { color: var(--line); }
95
- [data-tone="ink"] .button { background: var(--paper); color: var(--ink); }
96
- [data-tone="ink"] .button--outline { background: transparent; color: var(--paper); border-color: var(--muted); }
79
+ .prose figure, .smd-col figure{ margin: 8px 0; }
80
+ /* Inline, so the section's align step moves an image with the text around it. */
81
+ .prose figure img, .smd-col figure img{ display: inline-block; vertical-align: top; max-width: 100%; height: auto; border-radius: var(--radius); }
82
+ .prose figcaption, .smd-col figcaption{ margin-top: 8px; font-size: 14px; color: var(--muted); }
83
+ .prose .callout, .smd-col .callout{ margin: 8px 0; padding: 20px 24px; border-radius: var(--radius); border-left: 3px solid var(--accent); background: #fff; display: flex; flex-direction: column; gap: 8px; font-size: 16px; line-height: 1.6; }
84
+ .prose .callout[data-tone="success"], .smd-col .callout[data-tone="success"]{ border-left-color: #3a8f5c; }
85
+ .prose .callout[data-tone="warning"], .smd-col .callout[data-tone="warning"]{ border-left-color: #c9962b; background: #fff8e8; }
86
+ .prose .callout__title, .smd-col .callout__title{ color: var(--ink); font-size: 15px; letter-spacing: 0.02em; }
87
+ .prose .callout__body, .smd-col .callout__body{ display: flex; flex-direction: column; gap: 8px; }
88
+ .prose .cta, .smd-col .cta{ margin: 16px 0; padding: 28px 32px; border-radius: var(--radius); background: var(--ink); color: var(--paper); display: flex; align-items: center; justify-content: space-between; gap: 24px; flex-wrap: wrap; }
89
+ .prose .cta[data-variant="outline"], .smd-col .cta[data-variant="outline"]{ background: transparent; color: var(--ink); border: 1px solid var(--line-strong); }
90
+ .prose .cta__heading, .smd-col .cta__heading{ font-family: var(--serif); font-size: 24px; font-weight: 500; line-height: 1.2; display: block; }
91
+ .prose .cta__text, .smd-col .cta__text{ margin: 6px 0 0; font-size: 15px; color: inherit; opacity: 0.8; }
92
+ .prose .cta .button, .smd-col .cta .button{ background: var(--paper); color: var(--ink); flex: none; }
93
+ .prose .cta[data-variant="outline"] .button, .smd-col .cta[data-variant="outline"] .button{ background: var(--ink); color: var(--paper); }
94
+
95
+ /* ---------- section background: the quote's own `background` setting (Paper / Ink) ---------- */
96
+
97
+ [data-background="ink"] { background: var(--ink); color: var(--paper); }
98
+ [data-background="ink"] .quote__attribution { color: var(--line); }
99
+ [data-background="ink"].quote { border-color: transparent; }
97
100
 
98
101
  /* ---------- header ---------- */
99
102
 
@@ -105,6 +108,7 @@ main > section { padding-block: 56px; }
105
108
 
106
109
  /* ---------- hero (home) ---------- */
107
110
 
111
+ .hero { padding-block: 56px; }
108
112
  .hero__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 48px; align-items: center; }
109
113
  .hero__copy { display: flex; flex-direction: column; gap: 24px; }
110
114
  .hero__heading { font-family: var(--serif); font-weight: 400; font-size: clamp(40px, 5vw, 68px); line-height: 1.05; letter-spacing: -0.02em; text-wrap: balance; }
@@ -114,6 +118,7 @@ main > section { padding-block: 56px; }
114
118
 
115
119
  /* ---------- page hero ---------- */
116
120
 
121
+ .page-hero { padding-block: 56px; }
117
122
  .page-hero__inner { display: flex; flex-direction: column; gap: 40px; }
118
123
  .page-hero__copy { display: flex; flex-direction: column; gap: 20px; max-width: 760px; }
119
124
  .page-hero__heading { font-family: var(--serif); font-weight: 400; font-size: clamp(40px, 5vw, 64px); line-height: 1.05; letter-spacing: -0.02em; text-wrap: balance; }
@@ -122,6 +127,7 @@ main > section { padding-block: 56px; }
122
127
 
123
128
  /* ---------- image and text ---------- */
124
129
 
130
+ .image-text { padding-block: 56px; }
125
131
  .image-text__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 48px; align-items: center; }
126
132
  .image-text__image { width: 100%; border-radius: var(--radius); }
127
133
  [data-image-side="right"] .image-text__image { order: 2; }
@@ -130,13 +136,14 @@ main > section { padding-block: 56px; }
130
136
 
131
137
  /* ---------- quote ---------- */
132
138
 
133
- .quote { border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
139
+ .quote { padding-block: 56px; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
134
140
  .quote__inner { max-width: 880px; margin: 0 auto; padding-inline: 32px; text-align: center; display: flex; flex-direction: column; gap: 24px; align-items: center; }
135
141
  .quote__text { font-family: var(--serif); font-style: italic; font-weight: 300; font-size: clamp(26px, 3vw, 38px); line-height: 1.3; letter-spacing: -0.01em; text-wrap: balance; }
136
142
  .quote__attribution { font-size: 14px; color: var(--muted); letter-spacing: 0.04em; }
137
143
 
138
144
  /* ---------- points ---------- */
139
145
 
146
+ .points { padding-block: 56px; }
140
147
  .points .container { display: flex; flex-direction: column; gap: 48px; }
141
148
  .points__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 40px; }
142
149
  .point { display: flex; flex-direction: column; gap: 16px; }
@@ -146,6 +153,7 @@ main > section { padding-block: 56px; }
146
153
 
147
154
  /* ---------- article cards (favourites, index, related) ---------- */
148
155
 
156
+ .favourites { padding-block: 56px; }
149
157
  .favourites .container, .related .container { display: flex; flex-direction: column; gap: 40px; }
150
158
  .card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 40px 32px; }
151
159
  .card-grid--3 { grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
@@ -161,6 +169,7 @@ h3.card__title { font-size: 24px; }
161
169
 
162
170
  /* ---------- people ---------- */
163
171
 
172
+ .people { padding-block: 56px; }
164
173
  .people .container { display: flex; flex-direction: column; gap: 40px; }
165
174
  .people__grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 40px 32px; }
166
175
  .person { display: flex; flex-direction: column; gap: 12px; }
@@ -14,7 +14,7 @@ export const calloutSchema = smoodly.schema.element({
14
14
  title: f.text().max(80),
15
15
  body: f.richtext({ toolset: smoodly.toolset({ marks: ["bold", "italic", "link"] }) }),
16
16
  },
17
- styles: {
17
+ settings: {
18
18
  tone: f.select([{ value: "info", label: "Info" }, { value: "success", label: "Success" }, { value: "warning", label: "Warning" }]).default("info"),
19
19
  },
20
20
  sample: {
@@ -23,9 +23,9 @@ export const calloutSchema = smoodly.schema.element({
23
23
  },
24
24
  });
25
25
 
26
- function CalloutView({ title, body, styles }: Props<typeof calloutSchema>) {
26
+ function CalloutView({ title, body, settings }: Props<typeof calloutSchema>) {
27
27
  return (
28
- <aside className="callout" data-tone={styles.tone}>
28
+ <aside className="callout" data-tone={settings.tone}>
29
29
  <strong className="callout__title">{title}</strong>
30
30
  <div className="callout__body"><RichText doc={body} /></div>
31
31
  </aside>
@@ -14,20 +14,20 @@ export const ctaSchema = smoodly.schema.element({
14
14
  label: f.text().max(40),
15
15
  href: f.link(),
16
16
  },
17
- styles: {
17
+ settings: {
18
18
  variant: f.select([{ value: "primary", label: "Primary" }, { value: "outline", label: "Outline" }]).default("primary"),
19
19
  },
20
20
  sample: { heading: "Ready to start?", text: "Sed ut perspiciatis unde omnis iste natus.", label: "Get in touch", href: "/company/about-us" },
21
21
  });
22
22
 
23
- function CtaView({ heading, text, label, href, styles }: Props<typeof ctaSchema>) {
23
+ function CtaView({ heading, text, label, href, settings }: Props<typeof ctaSchema>) {
24
24
  return (
25
- <div className="cta" data-variant={styles.variant}>
25
+ <div className="cta" data-variant={settings.variant}>
26
26
  <div className="cta__copy">
27
27
  <strong className="cta__heading">{heading}</strong>
28
28
  {text && <p className="cta__text">{text}</p>}
29
29
  </div>
30
- <a className={styles.variant === "outline" ? "button button--outline" : "button"} href={href}>{label}</a>
30
+ <a className={settings.variant === "outline" ? "button button--outline" : "button"} href={href}>{label}</a>
31
31
  </div>
32
32
  );
33
33
  }
@@ -11,16 +11,16 @@ import { Quote } from "../sections/Quote";
11
11
  // duplicate — and the config's homePageSlug maps that record to "/".
12
12
  // The hero is locked: fields only, never added, moved or removed. The
13
13
  // body is freeform over five sections. Quote is among them because the
14
- // picker offers a shared item only where the zone allows its section: an
15
- // editor may drop a one-off quote, or place the shared one from the
16
- // picker's Shared group.
14
+ // picker offers a global item only where the zone allows its section: an
15
+ // editor may drop a one-off quote, or place the global one from the
16
+ // picker's Global group.
17
17
  export const homeSchema = smoodly.schema.page({
18
18
  name: "home",
19
19
  title: "Home",
20
20
  slug: "home",
21
21
  zones: {
22
22
  hero: z.locked(Hero),
23
- body: z.freeform({ sections: [ImageText, Points, FavouriteArticles, People, Quote] }),
23
+ body: z.freeform({ sections: [ImageText, Points, FavouriteArticles, People, Quote], custom: true }),
24
24
  },
25
25
  meta: { seo: true },
26
26
  });
@@ -16,7 +16,7 @@ export const pageSchema = smoodly.schema.page({
16
16
  title: "Page",
17
17
  zones: {
18
18
  hero: z.locked(PageHero),
19
- body: z.freeform({ sections: [ImageText, Points, FavouriteArticles, People, Quote] }),
19
+ body: z.freeform({ sections: [ImageText, Points, FavouriteArticles, People, Quote], custom: true }),
20
20
  },
21
21
  meta: { seo: true },
22
22
  // A page field (spec 2026-09-10 page fields §7): the parent's view lists
@@ -1,7 +1,7 @@
1
1
  import { f, smoodly, type Props, type Scope } from "smoodly";
2
2
 
3
- // The site footer — a VISUAL shared item (smoodly/shared.ts): one row,
4
- // edited under Shared content, rendered by the layout. Three link columns
3
+ // The site footer — a VISUAL global item (smoodly/globals.ts): one row,
4
+ // edited under Global content, rendered by the layout. Three link columns
5
5
  // as top-level objects, each localized as a whole (an object holding a
6
6
  // list — one level, which the list field allows; a list of columns each
7
7
  // holding a list would be a nested list, which it refuses). The layout
@@ -28,6 +28,8 @@ export const footerSchema = smoodly.schema.section({
28
28
  social: { title: "Social", links: [{ label: "Instagram", href: "#" }] },
29
29
  copyright: "© 2026 Lorem Co. Built with Smoodly.",
30
30
  },
31
+ // The layout's footer keeps the design's spacing: no Spacing & layout group.
32
+ styles: false,
31
33
  });
32
34
 
33
35
  export type ContactFields = { email: string; phone?: string; address: string };
@@ -1,7 +1,7 @@
1
1
  import { f, smoodly, type Props } from "smoodly";
2
2
 
3
- // The site header — a VISUAL shared item (smoodly/shared.ts): one row,
4
- // edited under Shared content, rendered by the layout, never placed in a
3
+ // The site header — a VISUAL global item (smoodly/globals.ts): one row,
4
+ // edited under Global content, rendered by the layout, never placed in a
5
5
  // page. The brand is the same in every language; the nav is a list of
6
6
  // links localized as a whole, so each language has its own labels and
7
7
  // hrefs. The layout renders HeaderView directly to hand it the locale
@@ -16,6 +16,8 @@ export const headerSchema = smoodly.schema.section({
16
16
  nav: f.list(f.object({ label: f.text(), href: f.link() })).localized(),
17
17
  },
18
18
  sample: { brand: "Lorem Co.", nav: [{ label: "Home", href: "/" }] },
19
+ // Nobody pads the header from the editor: no Spacing & layout group.
20
+ styles: false,
19
21
  });
20
22
 
21
23
  export type LocaleLink = { code: string; url: string; current: boolean };
@@ -3,7 +3,7 @@ import { RichText } from "@/smoodly/richtext";
3
3
  import { SmoodlyImage } from "smoodly/image";
4
4
 
5
5
  // A photo beside a heading and richtext, the photo on either side (a
6
- // style, not a field: styles are never translated). Without a photo the
6
+ // setting, not a field: settings are never translated). Without a photo the
7
7
  // heading takes the left column and the text the right — the About
8
8
  // page's text block. The richtext renders through the package's
9
9
  // RichText: paragraphs, headings, lists, quotes.
@@ -19,8 +19,8 @@ export const imageTextSchema = smoodly.schema.section({
19
19
  text: f.richtext({ toolset: smoodly.toolset({ marks: ["bold", "italic", "link"] }) }),
20
20
  link: f.object({ label: f.text(), href: f.link() }).optional(),
21
21
  },
22
- styles: {
23
- imageSide: f.select([{ value: "left", label: "Image left" }, { value: "right", label: "Image right" }]).default("left"),
22
+ settings: {
23
+ imageSide: f.radio([{ value: "left", label: "Image left" }, { value: "right", label: "Image right" }]).default("left"),
24
24
  },
25
25
  sample: {
26
26
  heading: "Duis aute irure dolor in reprehenderit",
@@ -33,7 +33,7 @@ export const imageTextSchema = smoodly.schema.section({
33
33
 
34
34
  const m = smoodly.marks(imageTextSchema);
35
35
 
36
- function ImageTextView({ image, heading, text, link, styles }: Props<typeof imageTextSchema>) {
36
+ function ImageTextView({ image, heading, text, link, settings, styles }: Props<typeof imageTextSchema>) {
37
37
  const linkMarks = m.object("link");
38
38
  const body = (
39
39
  <>
@@ -42,7 +42,7 @@ function ImageTextView({ image, heading, text, link, styles }: Props<typeof imag
42
42
  </>
43
43
  );
44
44
  return (
45
- <smoodly.Section styles={styles} className="image-text">
45
+ <smoodly.Section styles={styles} className="image-text" data-image-side={settings.imageSide}>
46
46
  <div className="container image-text__grid">
47
47
  {image ? (
48
48
  <>
@@ -1,9 +1,9 @@
1
1
  import { f, smoodly, type Props } from "smoodly";
2
2
 
3
- // A pull quote. Registered as a shared item (smoodly/shared.ts): one row
4
- // edited under Shared content, placed on pages from the "+" picker's
5
- // Shared group as a linked node. The body zones also list it, because
6
- // the picker offers a shared item only where the zone allows its
3
+ // A pull quote. Registered as a global item (smoodly/globals.ts): one row
4
+ // edited under Global content, placed on pages from the "+" picker's
5
+ // Global group as a linked node. The body zones also list it, because
6
+ // the picker offers a global item only where the zone allows its
7
7
  // section — so an editor may also drop a one-off quote.
8
8
  export const quoteSchema = smoodly.schema.section({
9
9
  name: "quote",
@@ -13,6 +13,9 @@ export const quoteSchema = smoodly.schema.section({
13
13
  text: f.text().localized(),
14
14
  attribution: f.text().localized(),
15
15
  },
16
+ settings: {
17
+ background: f.radio([{ value: "paper", label: "Paper" }, { value: "ink", label: "Ink" }]).default("paper"),
18
+ },
16
19
  sample: {
17
20
  text: "Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit.",
18
21
  attribution: "Jane Doe, Founder",
@@ -21,9 +24,9 @@ export const quoteSchema = smoodly.schema.section({
21
24
 
22
25
  const m = smoodly.marks(quoteSchema);
23
26
 
24
- function QuoteView({ text, attribution, styles }: Props<typeof quoteSchema>) {
27
+ function QuoteView({ text, attribution, settings, styles }: Props<typeof quoteSchema>) {
25
28
  return (
26
- <smoodly.Section styles={styles} className="quote">
29
+ <smoodly.Section styles={styles} className="quote" data-background={settings.background}>
27
30
  <div className="quote__inner">
28
31
  <blockquote className="quote__text">“<span {...m.field("text")}>{text}</span>”</blockquote>
29
32
  <p className="quote__attribution" {...m.field("attribution")}>{attribution}</p>
@@ -14,7 +14,7 @@
14
14
  "next": "^16.3.4",
15
15
  "react": "^19",
16
16
  "react-dom": "^19",
17
- "smoodly": "0.0.16"
17
+ "smoodly": "0.0.17"
18
18
  },
19
19
  "devDependencies": {
20
20
  "@next/env": "^16.3.4",
@@ -48,9 +48,9 @@ const bullets = (...items: string[]): Node => ({ type: "bulletList", content: it
48
48
  // Block nodes (spec 2026-09-08): a library image, and the starter's two elements.
49
49
  const imageBlock = (image: { asset: string }, caption: string): Node => ({ type: "image", attrs: { image, caption } });
50
50
  const callout = (title: string, text: string, tone: "info" | "success" | "warning" = "info"): Node =>
51
- ({ type: "element", attrs: { name: "callout", fields: { title, body: doc(p(text)) }, styles: { tone } } });
51
+ ({ type: "element", attrs: { name: "callout", fields: { title, body: doc(p(text)) }, settings: { tone } } });
52
52
  const cta = (heading: string, text: string, label: string, href: string): Node =>
53
- ({ type: "element", attrs: { name: "cta", fields: { heading, text, label, href }, styles: { variant: "primary" } } });
53
+ ({ type: "element", attrs: { name: "cta", fields: { heading, text, label, href }, settings: { variant: "primary" } } });
54
54
  const doc = (...content: Node[]) => ({ type: "doc", content });
55
55
 
56
56
  // The starter's photos become assets: each file is uploaded once through
@@ -86,7 +86,7 @@ const paths = (locale: string) => ({
86
86
  about: href(locale === FI ? "/yritys/tietoa-meista" : "/company/about-us", locale),
87
87
  });
88
88
 
89
- /** Create an entry (a collection's or a shared item's) in EN, add the FI
89
+ /** Create an entry (a collection's or a global item's) in EN, add the FI
90
90
  * row when there is one, and publish both — every entry is published in
91
91
  * every locale it has BEFORE any page, because publish integrity is per
92
92
  * locale and would (rightly) block a page whose tree references an entry
@@ -173,7 +173,7 @@ const article = await seedEntry("articles", {
173
173
  tags: ["Näkemyksiä", "Muotoilu"],
174
174
  });
175
175
 
176
- // --- shared items ---------------------------------------------------
176
+ // --- global items ---------------------------------------------------
177
177
 
178
178
  const nav = (locale: string, labels: [string, string, string]) => {
179
179
  const links = paths(locale);
@@ -206,12 +206,14 @@ await seedEntry("contact",
206
206
  { address: "Loremkatu 1, 00100 Helsinki" },
207
207
  );
208
208
 
209
+ // A visual global item's settings ride the row under the reserved
210
+ // `$settings` key (spec 2026-09-13 §1): the quote is the site's one dark band.
209
211
  const quoteRow = await seedEntry("quote",
210
- { text: "Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores.", attribution: "Jane Doe, Founder" },
212
+ { text: "Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores.", attribution: "Jane Doe, Founder", $settings: { background: "ink" } },
211
213
  { attribution: "Jane Doe, perustaja" },
212
214
  );
213
215
  // A placement: the quote as a linked node, the same row wherever it sits.
214
- const quote: Node = { id: "node-quote", type: "shared", fields: { item: "quote", ref: quoteRow.id } };
216
+ const quote: Node = { id: "node-quote", type: "global", fields: { item: "quote", ref: quoteRow.id } };
215
217
 
216
218
  // --- pages ----------------------------------------------------------
217
219
 
@@ -227,7 +229,7 @@ const imageText = (locale: string, linkLabel: string): Node => ({
227
229
  ),
228
230
  link: { label: linkLabel, href: paths(locale).about },
229
231
  },
230
- styles: { imageSide: "left" },
232
+ settings: { imageSide: "left" },
231
233
  });
232
234
 
233
235
  const points: Node = {
@@ -241,7 +243,8 @@ const points: Node = {
241
243
  { icon: "diamond", heading: "Pariatur deleniti", text: "Similique sunt in culpa qui officia deserunt mollitia animi, id est laborum et dolorum fuga." },
242
244
  ],
243
245
  },
244
- styles: { spacing: "lg" },
246
+ // One Spacing & layout step stored (spec 2026-09-14 §2); the rest are Default and store nothing.
247
+ styles: { spacingBottom: "lg" },
245
248
  };
246
249
 
247
250
  const favourites = (heading: string): Node => ({
@@ -250,6 +253,21 @@ const favourites = (heading: string): Node => ({
250
253
  fields: { heading, items: [article.id] },
251
254
  });
252
255
 
256
+ // The custom section (spec 2026-09-14 §6): the editor's own rows of
257
+ // richtext columns, widths in twelfths; Smoodly renders its layout CSS.
258
+ const customSection = (): Node => ({
259
+ id: "node-custom",
260
+ type: "custom",
261
+ fields: { rows: [
262
+ { spans: [4, 8], gap: "lg", align: "top", reverse: false, columns: [
263
+ doc(h2("Sed ut perspiciatis"), p("Unde omnis iste natus error sit voluptatem accusantium doloremque laudantium.")),
264
+ doc(p("Totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo."), callout("Nemo enim", "Ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit.", "info")),
265
+ ] },
266
+ { spans: [12], gap: "md", align: "top", reverse: false, columns: [doc(cta("Neque porro quisquam", "Qui dolorem ipsum quia dolor sit amet.", "Read the articles", "/articles"))] },
267
+ ] },
268
+ styles: { spacingBottom: "lg" },
269
+ });
270
+
253
271
  const homeTree = (locale: string, t: { kicker: string; buttons: [string, string]; link: string; favourites: string }): Tree => ({
254
272
  // The built-in SEO set rides the tree (spec 2026-09-10 page fields §2).
255
273
  meta: { description: "Lorem Co. — consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore." },
@@ -270,7 +288,7 @@ const homeTree = (locale: string, t: { kicker: string; buttons: [string, string]
270
288
  image: img.hero,
271
289
  },
272
290
  }],
273
- body: [imageText(locale, t.link), quote, points, favourites(t.favourites)],
291
+ body: [imageText(locale, t.link), quote, points, customSection(), favourites(t.favourites)],
274
292
  },
275
293
  });
276
294
 
@@ -319,6 +337,7 @@ const aboutTree = (t: { kicker: string; people: string; description: string }):
319
337
  {
320
338
  id: "node-about-text",
321
339
  type: "imageText",
340
+ settings: { imageSide: "left" },
322
341
  fields: {
323
342
  heading: "Duis aute irure dolor in reprehenderit",
324
343
  text: doc(