create-smoodly-app 0.0.16 → 0.0.18

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 (75) hide show
  1. package/package.json +1 -1
  2. package/templates/blank/AGENTS.md +2 -2
  3. package/templates/blank/README.md +6 -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/LocaleNav.tsx +19 -0
  7. package/templates/blank/components/pages/Home.tsx +9 -3
  8. package/templates/blank/components/pages/Page.tsx +3 -1
  9. package/templates/blank/components/pages/Post.tsx +10 -6
  10. package/templates/blank/components/sections/Text.tsx +8 -4
  11. package/templates/blank/package.json +1 -1
  12. package/templates/blank/scripts/seed.ts +14 -2
  13. package/templates/blank/smoodly/config.ts +4 -11
  14. package/templates/blank/smoodly/{shared.ts → globals.ts} +4 -4
  15. package/templates/blank/smoodly/server.ts +9 -6
  16. package/templates/blank/supabase/migrations/00000000000000_smoodly.sql +1 -1
  17. package/templates/blank-intl/AGENTS.md +2 -2
  18. package/templates/blank-intl/README.md +7 -6
  19. package/templates/blank-intl/app/[locale]/layout.tsx +9 -14
  20. package/templates/blank-intl/app/globals.css +19 -9
  21. package/templates/blank-intl/components/LocaleNav.tsx +19 -0
  22. package/templates/blank-intl/components/pages/Home.tsx +9 -3
  23. package/templates/blank-intl/components/pages/Page.tsx +3 -1
  24. package/templates/blank-intl/components/pages/Post.tsx +10 -6
  25. package/templates/blank-intl/components/sections/Text.tsx +8 -4
  26. package/templates/blank-intl/package.json +1 -1
  27. package/templates/blank-intl/scripts/seed.ts +14 -2
  28. package/templates/blank-intl/smoodly/config.ts +4 -11
  29. package/templates/blank-intl/smoodly/{shared.ts → globals.ts} +4 -4
  30. package/templates/blank-intl/smoodly/server.ts +9 -6
  31. package/templates/blank-intl/supabase/migrations/00000000000000_smoodly.sql +1 -1
  32. package/templates/lorem/AGENTS.md +2 -2
  33. package/templates/lorem/README.md +5 -4
  34. package/templates/lorem/app/(site)/layout.tsx +9 -10
  35. package/templates/lorem/app/globals.css +53 -44
  36. package/templates/lorem/components/PageFrame.tsx +30 -0
  37. package/templates/lorem/components/elements/Callout.tsx +3 -3
  38. package/templates/lorem/components/elements/Cta.tsx +4 -4
  39. package/templates/lorem/components/pages/Article.tsx +7 -5
  40. package/templates/lorem/components/pages/Articles.tsx +4 -3
  41. package/templates/lorem/components/pages/Home.tsx +8 -7
  42. package/templates/lorem/components/pages/Page.tsx +4 -3
  43. package/templates/lorem/components/sections/Footer.tsx +4 -2
  44. package/templates/lorem/components/sections/Header.tsx +4 -2
  45. package/templates/lorem/components/sections/ImageText.tsx +5 -5
  46. package/templates/lorem/components/sections/Quote.tsx +9 -6
  47. package/templates/lorem/package.json +1 -1
  48. package/templates/lorem/scripts/seed.ts +28 -9
  49. package/templates/lorem/smoodly/config.ts +18 -11
  50. package/templates/lorem/smoodly/{shared.ts → globals.ts} +7 -7
  51. package/templates/lorem/smoodly/sections.ts +3 -3
  52. package/templates/lorem/smoodly/server.ts +9 -6
  53. package/templates/lorem/supabase/migrations/00000000000000_smoodly.sql +1 -1
  54. package/templates/lorem-intl/AGENTS.md +2 -2
  55. package/templates/lorem-intl/README.md +5 -4
  56. package/templates/lorem-intl/app/[locale]/layout.tsx +11 -13
  57. package/templates/lorem-intl/app/globals.css +53 -44
  58. package/templates/lorem-intl/components/PageFrame.tsx +30 -0
  59. package/templates/lorem-intl/components/elements/Callout.tsx +3 -3
  60. package/templates/lorem-intl/components/elements/Cta.tsx +4 -4
  61. package/templates/lorem-intl/components/pages/Article.tsx +7 -5
  62. package/templates/lorem-intl/components/pages/Articles.tsx +4 -3
  63. package/templates/lorem-intl/components/pages/Home.tsx +8 -7
  64. package/templates/lorem-intl/components/pages/Page.tsx +4 -3
  65. package/templates/lorem-intl/components/sections/Footer.tsx +4 -2
  66. package/templates/lorem-intl/components/sections/Header.tsx +4 -2
  67. package/templates/lorem-intl/components/sections/ImageText.tsx +5 -5
  68. package/templates/lorem-intl/components/sections/Quote.tsx +9 -6
  69. package/templates/lorem-intl/package.json +1 -1
  70. package/templates/lorem-intl/scripts/seed.ts +28 -9
  71. package/templates/lorem-intl/smoodly/config.ts +18 -11
  72. package/templates/lorem-intl/smoodly/{shared.ts → globals.ts} +7 -7
  73. package/templates/lorem-intl/smoodly/sections.ts +3 -3
  74. package/templates/lorem-intl/smoodly/server.ts +9 -6
  75. package/templates/lorem-intl/supabase/migrations/00000000000000_smoodly.sql +1 -1
@@ -1,5 +1,6 @@
1
1
  import { smoodly, z, Zone, type Props } from "smoodly";
2
2
  import { Text } from "../sections/Text";
3
+ import { LocaleNav } from "../LocaleNav";
3
4
 
4
5
  // An open page shape: no `slug`, so editors create as many as they like,
5
6
  // anywhere in the tree the config allows. `page` is where this one sits
@@ -8,7 +9,7 @@ export const pageSchema = smoodly.schema.page({
8
9
  name: "page",
9
10
  title: "Page",
10
11
  zones: {
11
- body: z.freeform({ sections: [Text] }),
12
+ body: z.freeform({ sections: [Text], custom: true }),
12
13
  },
13
14
  meta: { seo: true },
14
15
  });
@@ -16,6 +17,7 @@ export const pageSchema = smoodly.schema.page({
16
17
  function PageView({ zones, page }: Props<typeof pageSchema>) {
17
18
  return (
18
19
  <>
20
+ <LocaleNav locale={page.locale} locales={page.locales} />
19
21
  <h1 className="container">{page.title}</h1>
20
22
  <Zone of={zones.body} />
21
23
  </>
@@ -1,20 +1,24 @@
1
1
  import { smoodly, type EntryPageProps } from "smoodly";
2
2
  import { RichText } from "@/smoodly/richtext";
3
3
  import { posts } from "@/smoodly/collections";
4
+ import { LocaleNav } from "../LocaleNav";
4
5
 
5
6
  // The entry page for `posts`, served at /posts/<slug>. It receives the
6
7
  // entry's fields; the body is richtext rendered by the site's bound
7
8
  // RichText, which knows the registered elements.
8
9
  type PostFields = { title: string; date?: string; body?: unknown };
9
10
 
10
- function PostView({ entry }: EntryPageProps) {
11
+ function PostView({ entry, locale, locales }: EntryPageProps) {
11
12
  const post = entry.fields as PostFields;
12
13
  return (
13
- <article className="container prose">
14
- <h1>{post.title}</h1>
15
- {post.date && <p><time dateTime={post.date}>{post.date.slice(0, 10)}</time></p>}
16
- <RichText doc={post.body} />
17
- </article>
14
+ <>
15
+ <LocaleNav locale={locale} locales={locales} />
16
+ <article className="container prose">
17
+ <h1>{post.title}</h1>
18
+ {post.date && <p><time dateTime={post.date}>{post.date.slice(0, 10)}</time></p>}
19
+ <RichText doc={post.body} />
20
+ </article>
21
+ </>
18
22
  );
19
23
  }
20
24
 
@@ -5,8 +5,9 @@ import { text } from "@/smoodly/toolsets";
5
5
  // A section: a title and a richtext body. Registered in
6
6
  // smoodly/sections.ts and allowed in the page shapes' zones
7
7
  // (components/pages/*.tsx). Copy this file to add another section.
8
- // The section wrapper renders the root element and emits the config's
9
- // section styles as data attributes (data-tone here) for app/globals.css.
8
+ // The section wrapper renders the root element, Smoodly's styles set
9
+ // inline (spacing, width, align) and the node id; a setting the CSS
10
+ // reads — here `background` — is the view's own data attribute.
10
11
  export const textSchema = smoodly.schema.section({
11
12
  name: "text",
12
13
  title: "Text",
@@ -15,6 +16,9 @@ export const textSchema = smoodly.schema.section({
15
16
  title: f.text(),
16
17
  body: f.richtext({ toolset: text }),
17
18
  },
19
+ settings: {
20
+ background: f.radio([{ value: "paper", label: "Paper" }, { value: "ink", label: "Ink" }]).default("paper"),
21
+ },
18
22
  sample: {
19
23
  title: "Hello",
20
24
  body: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "Write something here." }] }] },
@@ -25,9 +29,9 @@ export const textSchema = smoodly.schema.section({
25
29
  // select and inline-edit them on the canvas.
26
30
  const m = smoodly.marks(textSchema);
27
31
 
28
- function TextView({ title, body, styles }: Props<typeof textSchema>) {
32
+ function TextView({ title, body, settings, styles }: Props<typeof textSchema>) {
29
33
  return (
30
- <smoodly.Section styles={styles}>
34
+ <smoodly.Section styles={styles} className="text" data-background={settings.background}>
31
35
  <div className="container prose">
32
36
  <h2 {...m.field("title")}>{title}</h2>
33
37
  <div {...m.field("body")}><RichText doc={body} /></div>
@@ -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.18",
18
18
  "next-intl": "^4.14.2"
19
19
  },
20
20
  "devDependencies": {
@@ -26,7 +26,7 @@ if (await pages.resolvePath(DEFAULT, "/")) {
26
26
  // Richtext is TipTap JSON, the stored format; RichText renders it.
27
27
  type Node = Record<string, unknown>;
28
28
  const p = (s: string): Node => ({ type: "paragraph", content: [{ type: "text", text: s }] });
29
- const button = (label: string, href: string): Node => ({ type: "element", attrs: { name: "button", fields: { label, href }, styles: {} } });
29
+ const button = (label: string, href: string): Node => ({ type: "element", attrs: { name: "button", fields: { label, href }, settings: {} } });
30
30
  const doc = (...content: Node[]) => ({ type: "doc", content });
31
31
 
32
32
  // --- the site item: name and tagline, published in every locale ------
@@ -38,7 +38,7 @@ for (const locale of OTHERS) {
38
38
  await entries.setStatus("site", site.id, locale, "published");
39
39
  }
40
40
 
41
- // --- the home page: one Text section ---------------------------------
41
+ // --- the home page: one Text section and one custom section ----------
42
42
 
43
43
  const tree = {
44
44
  meta: { description: "A new site, built with Smoodly." },
@@ -47,6 +47,7 @@ const tree = {
47
47
  {
48
48
  id: "node-hello",
49
49
  type: "text",
50
+ settings: { background: "paper" },
50
51
  fields: {
51
52
  title: "Hello",
52
53
  body: doc(
@@ -56,6 +57,17 @@ const tree = {
56
57
  ),
57
58
  },
58
59
  },
60
+ // The custom section: the editor's own rows of columns, rendered by Smoodly.
61
+ {
62
+ id: "node-custom",
63
+ type: "custom",
64
+ fields: { rows: [
65
+ { spans: [6, 6], gap: "md", align: "top", reverse: false, columns: [
66
+ doc(p("A custom section holds rows of columns. Drag the width bar in the inspector to resize them.")),
67
+ doc(p("Each column is richtext. Columns stack on small screens; the developer's stylesheet needs nothing.")),
68
+ ] },
69
+ ] },
70
+ },
59
71
  ],
60
72
  },
61
73
  } as unknown as PageTree;
@@ -1,14 +1,14 @@
1
- import { defineConfig, f } from "smoodly";
1
+ import { defineConfig } from "smoodly";
2
2
  import { routing } from "@/i18n/routing";
3
3
  import { collections } from "./collections";
4
4
  import { sections } from "./sections";
5
- import { shared } from "./shared";
5
+ import { globals } from "./globals";
6
6
  import { elements } from "./elements";
7
7
  import { HomePage } from "@/components/pages/Home";
8
8
  import { StandardPage } from "@/components/pages/Page";
9
9
  import { PostPage } from "@/components/pages/Post";
10
10
 
11
- export { collections, sections, shared, elements };
11
+ export { collections, sections, globals, elements };
12
12
 
13
13
  // The registry and the plain settings, one file. Runtime-free: nothing
14
14
  // here imports a route file or Next, so a script and the CLI's template
@@ -20,7 +20,7 @@ export const config = defineConfig({
20
20
  sections,
21
21
  elements,
22
22
  collections,
23
- shared,
23
+ globals,
24
24
  pages: [HomePage, StandardPage, PostPage],
25
25
  },
26
26
  locales: { default: routing.defaultLocale, supported: [...routing.locales] },
@@ -28,11 +28,4 @@ export const config = defineConfig({
28
28
  homePageSlug: "home",
29
29
  // Editors can nest pages three deep.
30
30
  pages: { tree: { depth: 3 } },
31
- // Every section gets this select beside its own styles; the section
32
- // wrapper emits it as data-tone, and app/globals.css styles it.
33
- styles: {
34
- sections: {
35
- tone: f.select(["paper", "ink"]).default("paper"),
36
- },
37
- },
38
31
  });
@@ -1,10 +1,10 @@
1
- // Shared content: code-declared singletons, edited once under Globals
1
+ // Global content: code-declared singletons, edited once under Globals
2
2
  // and read anywhere. `site` is an object item — fields code consumes;
3
- // the layout reads it through q().getShared("site"). A visual item
3
+ // the layout reads it through q().getGlobal("site"). A visual item
4
4
  // (`section: SomeSection`) renders through a section instead.
5
5
  import { f, smoodly } from "smoodly";
6
6
 
7
- export const site = smoodly.shared({
7
+ export const site = smoodly.global({
8
8
  name: "site",
9
9
  title: "Site",
10
10
  fields: {
@@ -13,4 +13,4 @@ export const site = smoodly.shared({
13
13
  },
14
14
  });
15
15
 
16
- export const shared = [site];
16
+ export const globals = [site];
@@ -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,10 @@ 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 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/PageFrame.tsx` — the header and `<main>` every page view renders (a layout cannot know which page is on screen); with more than one locale, the language links from the page's `locales` map — the same page in the other language, or its home when there is no translation.
16
+ - `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
17
  - `components/strings.ts`, `messages/` — the site's own UI strings and the request locale, one seam.
17
18
  - `public/images/` — the starter's photos; image fields hold URLs like `/images/hero_image.jpg`.
18
19
  - `scripts/seed.ts` — the starter content, written through the stores. `npm run seed` is idempotent.
@@ -24,11 +25,11 @@ for an agent (or a person) building the site.
24
25
 
25
26
  The Lorem Co. site the installer seeded is a tour of the features:
26
27
 
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).
28
+ - **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
29
  - **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
30
  - **Company / About** — a folder and a page under it (the breadcrumb), the same body sections, plus people.
30
31
  - **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.
32
+ - **Global content** — the header every page renders through `components/PageFrame.tsx`, the footer the layout renders, the contact details the footer reads, the quote pages place.
32
33
 
33
34
  Reseed an empty database any time with `npm run seed`.
34
35
 
@@ -1,6 +1,5 @@
1
1
  import type { ReactNode } from "react";
2
2
  import { Instrument_Sans, Newsreader } from "next/font/google";
3
- import { HeaderView } from "@/components/sections/Header";
4
3
  import { FooterView } from "@/components/sections/Footer";
5
4
  import { q } from "@/smoodly/server";
6
5
  import "../globals.css";
@@ -14,20 +13,20 @@ export const metadata = { title: "Lorem Co." };
14
13
  // The site's root layout, in its own route group so the admin's layout
15
14
  // under app/(smoodly)/admin is a second root layout beside it rather than
16
15
  // a child of this one — two root layouts, a full page load between them,
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
19
- // tag, null until published.
16
+ // which is fine: they are separate apps in spirit. The footer and the
17
+ // contact block are global content: read through `q`, cached under their
18
+ // tag, null until published. The header is NOT here: every page view
19
+ // renders it through components/PageFrame.tsx, the same file that carries
20
+ // the language links in a multilingual site (spec 2026-09-15).
20
21
  export default async function RootLayout({ children }: { children: ReactNode }) {
21
- const [headerProps, footerProps, contactFields] = await Promise.all([
22
- q().getShared("header"),
23
- q().getShared("footer"),
24
- q().getShared("contact"),
22
+ const [footerProps, contactFields] = await Promise.all([
23
+ q().getGlobal("footer"),
24
+ q().getGlobal("contact"),
25
25
  ]);
26
26
  return (
27
27
  <html lang="en" className={`${serif.variable} ${sans.variable}`}>
28
28
  <body>
29
- {headerProps && <HeaderView {...headerProps} />}
30
- <main>{children}</main>
29
+ {children}
31
30
  {footerProps && <FooterView {...footerProps} contact={contactFields} />}
32
31
  </body>
33
32
  </html>
@@ -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; }
@@ -0,0 +1,30 @@
1
+ import type { ReactNode } from "react";
2
+ import type { LocaleAlternate } from "smoodly";
3
+ import { href, q } from "@/smoodly/server";
4
+ import { HeaderView, type LocaleLink } from "./sections/Header";
5
+
6
+ // The header and the <main> around a page's content, rendered by every
7
+ // page view rather than the layout: a layout cannot know which page is on
8
+ // screen (Next renders the two concurrently), and the language links must
9
+ // land on the same page in the other language. `locales` is the page's or
10
+ // entry's map (spec 2026-09-15): the other locale's path where the page
11
+ // exists and is published there, that locale's home otherwise. With one
12
+ // locale (the single-language scaffold) there are no links at all. The
13
+ // header itself is the global item, read through `q` per locale.
14
+ export async function PageFrame({ locale, locales, children }: {
15
+ locale: string;
16
+ locales: Record<string, LocaleAlternate | null>;
17
+ children: ReactNode;
18
+ }) {
19
+ const header = await q().getGlobal("header", { locale });
20
+ const codes = Object.keys(locales);
21
+ const links: LocaleLink[] | undefined = codes.length > 1
22
+ ? codes.map((code) => ({ code, url: href(locales[code]?.path ?? "/", code), current: code === locale }))
23
+ : undefined;
24
+ return (
25
+ <>
26
+ {header && <HeaderView {...header} locales={links} />}
27
+ <main>{children}</main>
28
+ </>
29
+ );
30
+ }
@@ -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
  }