create-smoodly-app 0.0.15 → 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 (69) 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/collections.ts +1 -2
  12. package/templates/blank/smoodly/config.ts +4 -11
  13. package/templates/blank/smoodly/{shared.ts → globals.ts} +4 -4
  14. package/templates/blank/smoodly/server.ts +9 -6
  15. package/templates/blank/supabase/migrations/00000000000000_smoodly.sql +15 -12
  16. package/templates/blank-intl/AGENTS.md +2 -2
  17. package/templates/blank-intl/README.md +5 -5
  18. package/templates/blank-intl/app/[locale]/layout.tsx +3 -3
  19. package/templates/blank-intl/app/globals.css +19 -9
  20. package/templates/blank-intl/components/pages/Home.tsx +1 -1
  21. package/templates/blank-intl/components/pages/Page.tsx +1 -1
  22. package/templates/blank-intl/components/sections/Text.tsx +8 -4
  23. package/templates/blank-intl/package.json +1 -1
  24. package/templates/blank-intl/scripts/seed.ts +14 -2
  25. package/templates/blank-intl/smoodly/collections.ts +1 -2
  26. package/templates/blank-intl/smoodly/config.ts +4 -11
  27. package/templates/blank-intl/smoodly/{shared.ts → globals.ts} +4 -4
  28. package/templates/blank-intl/smoodly/server.ts +9 -6
  29. package/templates/blank-intl/supabase/migrations/00000000000000_smoodly.sql +15 -12
  30. package/templates/lorem/AGENTS.md +2 -2
  31. package/templates/lorem/README.md +4 -4
  32. package/templates/lorem/app/(site)/layout.tsx +4 -4
  33. package/templates/lorem/app/globals.css +53 -44
  34. package/templates/lorem/components/elements/Callout.tsx +3 -3
  35. package/templates/lorem/components/elements/Cta.tsx +4 -4
  36. package/templates/lorem/components/pages/Home.tsx +4 -4
  37. package/templates/lorem/components/pages/Page.tsx +1 -1
  38. package/templates/lorem/components/sections/Footer.tsx +4 -2
  39. package/templates/lorem/components/sections/Header.tsx +4 -2
  40. package/templates/lorem/components/sections/ImageText.tsx +5 -5
  41. package/templates/lorem/components/sections/Quote.tsx +9 -6
  42. package/templates/lorem/package.json +1 -1
  43. package/templates/lorem/scripts/seed.ts +29 -10
  44. package/templates/lorem/smoodly/collections.ts +2 -6
  45. package/templates/lorem/smoodly/config.ts +18 -11
  46. package/templates/lorem/smoodly/{shared.ts → globals.ts} +7 -7
  47. package/templates/lorem/smoodly/sections.ts +3 -3
  48. package/templates/lorem/smoodly/server.ts +9 -6
  49. package/templates/lorem/supabase/migrations/00000000000000_smoodly.sql +22 -18
  50. package/templates/lorem-intl/AGENTS.md +2 -2
  51. package/templates/lorem-intl/README.md +4 -4
  52. package/templates/lorem-intl/app/[locale]/layout.tsx +4 -4
  53. package/templates/lorem-intl/app/globals.css +53 -44
  54. package/templates/lorem-intl/components/elements/Callout.tsx +3 -3
  55. package/templates/lorem-intl/components/elements/Cta.tsx +4 -4
  56. package/templates/lorem-intl/components/pages/Home.tsx +4 -4
  57. package/templates/lorem-intl/components/pages/Page.tsx +1 -1
  58. package/templates/lorem-intl/components/sections/Footer.tsx +4 -2
  59. package/templates/lorem-intl/components/sections/Header.tsx +4 -2
  60. package/templates/lorem-intl/components/sections/ImageText.tsx +5 -5
  61. package/templates/lorem-intl/components/sections/Quote.tsx +9 -6
  62. package/templates/lorem-intl/package.json +1 -1
  63. package/templates/lorem-intl/scripts/seed.ts +29 -10
  64. package/templates/lorem-intl/smoodly/collections.ts +2 -6
  65. package/templates/lorem-intl/smoodly/config.ts +18 -11
  66. package/templates/lorem-intl/smoodly/{shared.ts → globals.ts} +7 -7
  67. package/templates/lorem-intl/smoodly/sections.ts +3 -3
  68. package/templates/lorem-intl/smoodly/server.ts +9 -6
  69. package/templates/lorem-intl/supabase/migrations/00000000000000_smoodly.sql +22 -18
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-smoodly-app",
3
- "version": "0.0.15",
3
+ "version": "0.0.17",
4
4
  "description": "Create a Smoodly site: a fresh Next.js app with the admin mounted, a Supabase schema, an env file and a first editor.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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
@@ -14,7 +14,7 @@ for an agent (or a person) building the site.
14
14
  - `components/pages/` — the page shapes and the entry page: `Home` (fixed at /, one freeform zone), `Page` (open, editors create many), `Post` (the entry page for posts).
15
15
  - `components/sections/Text.tsx` — the one section: a title and a richtext body. Listed in `smoodly/sections.ts`.
16
16
  - `components/elements/Button.tsx` — the one element: a link styled as a button, embedded in richtext through `smoodly/toolsets.ts`.
17
- - `smoodly/` — the wiring: `config.ts` (the registry and settings), `collections.ts` (posts), `shared.ts` (the site item), `sections.ts`, `elements.ts`, `toolsets.ts`, `server.ts` (stores, site layer, renderer, the read API `q`), `ops.ts` (the admin's one server action), `admin.tsx` (the admin mount), `richtext.tsx` (the bound renderer).
17
+ - `smoodly/` — the wiring: `config.ts` (the registry and settings), `collections.ts` (posts), `globals.ts` (the site item), `sections.ts`, `elements.ts`, `toolsets.ts`, `server.ts` (stores, site layer, renderer, the read API `q`), `ops.ts` (the admin's one server action), `admin.tsx` (the admin mount), `richtext.tsx` (the bound renderer).
18
18
  - `scripts/seed.ts` — the home page and the site item, written through the stores. `npm run seed` is idempotent.
19
19
  - `app/globals.css` — a reset, a container, the two tones. Replace it.
20
20
  - `.env.local` — `SMOODLY_URL`, `SMOODLY_SECRET_KEY`, `SMOODLY_PUBLISHABLE_KEY`.
@@ -48,12 +48,12 @@ Then create a migration with its view:
48
48
  Save the output as `supabase/migrations/<timestamp>_people.sql` and apply
49
49
  it with `supabase db push` (hosted) or `supabase db reset` (local).
50
50
 
51
- ## Add a shared item
51
+ ## Add a global item
52
52
 
53
- Copy the `site` block in `smoodly/shared.ts` and add it to `shared`. An
54
- object item (`fields`) is read in code with `q().getShared("name")`; a
53
+ Copy the `site` block in `smoodly/globals.ts` and add it to `globals`. An
54
+ object item (`fields`) is read in code with `q().getGlobal("name")`; a
55
55
  visual item (`section: SomeSection`) renders through its section and can
56
- be placed on pages from the picker's Shared group. Edit both under
56
+ be placed on pages from the picker's Global group. Edit both under
57
57
  Globals in the admin.
58
58
 
59
59
  Reseed an empty database any time with `npm run seed`.
@@ -4,16 +4,16 @@ import "../globals.css";
4
4
 
5
5
  // The site's root layout, in its own route group so the admin's layout
6
6
  // under app/(smoodly)/admin is a second root layout beside it rather than
7
- // a child of this one. The `site` shared item (smoodly/shared.ts) gives
7
+ // a child of this one. The `site` global item (smoodly/globals.ts) gives
8
8
  // the document title and the footer line: read through `q`, cached under
9
9
  // its tag, null until published.
10
10
  export async function generateMetadata() {
11
- const site = await q().getShared("site");
11
+ const site = await q().getGlobal("site");
12
12
  return { title: site?.name ?? "Smoodly site" };
13
13
  }
14
14
 
15
15
  export default async function RootLayout({ children }: { children: ReactNode }) {
16
- const site = await q().getShared("site");
16
+ const site = await q().getGlobal("site");
17
17
  return (
18
18
  <html lang="en">
19
19
  <body>
@@ -1,13 +1,22 @@
1
1
  /* The blank starter's one stylesheet: a reset, a system font, a
2
- container, prose defaults, the button element, and the two tones the
3
- config's section style emits as data-tone. Replace freely. */
2
+ container, prose defaults, the button element, and the two backgrounds
3
+ the Text section's setting selects. The editor's Spacing & layout
4
+ steps (Smoodly's styles set, smoodly/config.ts) render inline through
5
+ the section wrapper and read the --space-* / --width-* variables.
6
+ Replace freely. */
4
7
 
5
8
  :root {
9
+ /* Smoodly's styles set reads these (smoodly/config.ts): editor steps become padding, width, alignment. */
10
+ --space-sm: 1.5rem; --space-md: 3rem; --space-lg: 4.5rem;
11
+ --width-narrow: 40rem; --width-wide: 90rem;
12
+
6
13
  --paper: #ffffff;
7
14
  --ink: #1a1a1a;
8
15
  --muted: #666666;
9
16
  --container: 720px;
17
+ --smd-gutter: 1rem; /* the custom section's rows share the container's side gutter */
10
18
  }
19
+ @media (min-width: 768px) { :root { --space-sm: 2rem; --space-md: 4rem; --space-lg: 6rem; } }
11
20
 
12
21
  * { box-sizing: border-box; }
13
22
  html, body { margin: 0; padding: 0; }
@@ -23,15 +32,16 @@ body {
23
32
  main { flex: 1; }
24
33
  a { color: inherit; }
25
34
 
26
- .container { width: min(var(--container), 100% - 2rem); margin-inline: auto; }
35
+ /* The container reads the editor's width step; without one, the design's width. */
36
+ .container { width: min(var(--smd-width, var(--container)), 100% - 2rem); margin-inline: auto; }
27
37
 
28
- /* Sections carry data-tone from the config's section styles. */
29
- [data-tone="paper"] { background: var(--paper); color: var(--ink); }
30
- [data-tone="ink"] { background: var(--ink); color: var(--paper); }
31
- section[data-tone] { padding-block: 3rem; }
38
+ /* The Text section: its own padding, and the background its setting selects. */
39
+ .text { padding-block: 3rem; }
40
+ [data-background="paper"] { background: var(--paper); color: var(--ink); }
41
+ [data-background="ink"] { background: var(--ink); color: var(--paper); }
32
42
 
33
- .prose > * + * { margin-top: 1em; }
34
- .prose h1, .prose h2, .prose h3 { line-height: 1.2; }
43
+ .prose > * + *, .smd-col > * + * { margin-top: 1em; } /* the custom section's columns read like prose */
44
+ .prose h1, .prose h2, .prose h3, .smd-col h1, .smd-col h2, .smd-col h3 { line-height: 1.2; }
35
45
 
36
46
  /* The Button element, inside richtext. */
37
47
  .button {
@@ -10,7 +10,7 @@ export const homeSchema = smoodly.schema.page({
10
10
  title: "Home",
11
11
  slug: "home",
12
12
  zones: {
13
- body: z.freeform({ sections: [Text] }),
13
+ body: z.freeform({ sections: [Text], custom: true }),
14
14
  },
15
15
  meta: { seo: true },
16
16
  });
@@ -8,7 +8,7 @@ export const pageSchema = smoodly.schema.page({
8
8
  name: "page",
9
9
  title: "Page",
10
10
  zones: {
11
- body: z.freeform({ sections: [Text] }),
11
+ body: z.freeform({ sections: [Text], custom: true }),
12
12
  },
13
13
  meta: { seo: true },
14
14
  });
@@ -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.15"
17
+ "smoodly": "0.0.17"
18
18
  },
19
19
  "devDependencies": {
20
20
  "@next/env": "^16.3.4",
@@ -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;
@@ -4,7 +4,7 @@ import { text } from "./toolsets";
4
4
  // A collection: structured content with its own admin list, served at
5
5
  // `path` by its entry page (components/pages/Post.tsx). Fields marked
6
6
  // .localized() are stored per locale; the rest are shared across
7
- // locales. `versions: true` keeps a history per locale. To add a
7
+ // locales. To add a
8
8
  // collection: copy this block, add it to `collections`, write its view
9
9
  // into a new migration with `collectionSQL` from "smoodly", and apply
10
10
  // it (supabase db push, or supabase db reset locally).
@@ -14,7 +14,6 @@ export const posts = smoodly.collection({
14
14
  titleField: "title",
15
15
  path: "posts",
16
16
  order: { by: "date", direction: "desc" },
17
- versions: true,
18
17
  fields: {
19
18
  title: f.text().localized(),
20
19
  slug: f.slug({ from: "title" }),
@@ -1,13 +1,13 @@
1
- import { defineConfig, f } from "smoodly";
1
+ import { defineConfig } from "smoodly";
2
2
  import { collections } from "./collections";
3
3
  import { sections } from "./sections";
4
- import { shared } from "./shared";
4
+ import { globals } from "./globals";
5
5
  import { elements } from "./elements";
6
6
  import { HomePage } from "@/components/pages/Home";
7
7
  import { StandardPage } from "@/components/pages/Page";
8
8
  import { PostPage } from "@/components/pages/Post";
9
9
 
10
- export { collections, sections, shared, elements };
10
+ export { collections, sections, globals, elements };
11
11
 
12
12
  // The registry and the plain settings, one file. Runtime-free: nothing
13
13
  // here imports a route file or Next, so a script and the CLI's template
@@ -17,7 +17,7 @@ export const config = defineConfig({
17
17
  sections,
18
18
  elements,
19
19
  collections,
20
- shared,
20
+ globals,
21
21
  pages: [HomePage, StandardPage, PostPage],
22
22
  },
23
23
  locales: { default: "en", supported: ["en"] },
@@ -25,11 +25,4 @@ export const config = defineConfig({
25
25
  homePageSlug: "home",
26
26
  // Editors can nest pages three deep.
27
27
  pages: { tree: { depth: 3 } },
28
- // Every section gets this select beside its own styles; the section
29
- // wrapper emits it as data-tone, and app/globals.css styles it.
30
- styles: {
31
- sections: {
32
- tone: f.select(["paper", "ink"]).default("paper"),
33
- },
34
- },
35
28
  });
@@ -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];
@@ -14,10 +14,10 @@ import {
14
14
  import { supabaseAdminAuth, type AdminAuth, type AuthConfig } from "smoodly/admin/next";
15
15
  import { createPageRenderer, createSmoodlyQueries, type PageRegistration } from "smoodly/next";
16
16
  import type { SupabaseClient } from "@supabase/supabase-js";
17
- import { collections, config, elements, sections, shared } from "./config";
17
+ import { collections, config, elements, sections, globals } from "./config";
18
18
 
19
19
  type Renderer = ReturnType<typeof createPageRenderer>;
20
- type Queries = ReturnType<typeof createSmoodlyQueries<typeof collections, typeof shared>>;
20
+ type Queries = ReturnType<typeof createSmoodlyQueries<typeof collections, typeof globals>>;
21
21
  type Bound = {
22
22
  db: SupabaseClient; pages: SupabasePageStore; entries: SupabaseEntryStore; assets: SupabaseAssetStore; auth: AdminAuth; site: SmoodlySite; renderer: Renderer; q: Queries;
23
23
  };
@@ -31,9 +31,11 @@ function bound(): Bound {
31
31
  // ONE path index for both stores: pages and entries share the URL space.
32
32
  const paths = new SupabasePathIndex(db);
33
33
  const locales = config.locales;
34
- const entries = new SupabaseEntryStore(db, collections, { paths, locales, shared, elements });
34
+ const entries = new SupabaseEntryStore(db, collections, { paths, locales, globals, elements });
35
35
  const pages = new SupabasePageStore(db, {
36
36
  sections, elements, entries, paths, locales,
37
+ // The custom section's columns toolset, so a column's images count as usage (spec 2026-09-14 §6).
38
+ custom: config.custom,
37
39
  homePageSlug: config.homePageSlug,
38
40
  tree: config.pages?.tree,
39
41
  });
@@ -46,15 +48,16 @@ function bound(): Bound {
46
48
  // The site layer: (locale, path) → page or entry, and the content API
47
49
  // `q` wraps below. Not exported — the app reads through `q`, which adds
48
50
  // draft awareness and the cache.
49
- const site = createSmoodlySite({ stores, collections, shared, sections, elements, locales, homePageSlug: config.homePageSlug, pages: config.registry.pages ?? [] });
51
+ const site = createSmoodlySite({ stores, collections, globals, sections, elements, locales, homePageSlug: config.homePageSlug, styles: config.styles, custom: config.custom, pages: config.registry.pages ?? [] });
50
52
  const renderer = createPageRenderer({
51
53
  site,
52
54
  sections,
55
+ elements,
53
56
  pages: (config.registry.pages ?? []) as PageRegistration[],
54
57
  href,
55
58
  });
56
59
  // The read API (spec 2026-09-11): typed by the registry, draft-aware, cached under tags the admin expires.
57
- const q = createSmoodlyQueries({ site, collections, shared });
60
+ const q = createSmoodlyQueries({ site, collections, globals });
58
61
  cached = { db, pages, entries, assets, auth, site, renderer, q };
59
62
  return cached;
60
63
  }
@@ -83,7 +86,7 @@ export function publicAuth(): AuthConfig {
83
86
  export const href = (path: string, _locale: string) => path;
84
87
 
85
88
  /** The read API (spec 2026-09-11) for layouts and page views that fetch:
86
- * `q().getEntries("articles", { locale, limit: 3 })`, `q().getShared("header",
89
+ * `q().getEntries("articles", { locale, limit: 3 })`, `q().getGlobal("header",
87
90
  * { locale })`, `q().getPageTree({ locale })`. Typed by the registry, the
88
91
  * live draft in the editor preview, cached under tags the admin expires.
89
92
  * Rename it if `q` is not to your taste — this file is yours. */
@@ -37,6 +37,7 @@ create table if not exists "page_locales" (
37
37
  "draft_version_id" uuid,
38
38
  "published_version_id" uuid,
39
39
  "updated_at" timestamptz not null default now(),
40
+ "updated_by" text,
40
41
  primary key ("page_id", "locale")
41
42
  );
42
43
 
@@ -49,19 +50,20 @@ create or replace function "smoodly_reorder_pages"(p_ids uuid[]) returns void la
49
50
  $$;
50
51
 
51
52
  -- A version belongs to ONE locale of one page: each locale has its own
52
- -- draft, its own publish and its own history.
53
+ -- draft, its own publish and its own history. One row per publish plus
54
+ -- the live draft (spec 2026-09-13 history §1): the unpublished draft row
55
+ -- is rewritten in place and saved_at is when its content last changed.
53
56
  create table if not exists "page_versions" (
54
57
  "id" uuid primary key default gen_random_uuid(),
55
58
  "space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
56
59
  "page_id" uuid not null references "pages" ("id") on delete cascade,
57
60
  "locale" text not null,
58
61
  "tree" jsonb not null,
59
- "created_by" text,
60
- "created_at" timestamptz not null default now()
62
+ "saved_at" timestamptz not null default now()
61
63
  );
62
64
 
63
65
  create index if not exists "page_versions_page_locale_idx"
64
- on "page_versions" ("page_id", "locale", "created_at");
66
+ on "page_versions" ("page_id", "locale", "saved_at");
65
67
 
66
68
  create table if not exists "entries" (
67
69
  "id" uuid primary key default gen_random_uuid(),
@@ -98,6 +100,7 @@ create table if not exists "entry_locales" (
98
100
  "draft_version_id" uuid,
99
101
  "published_version_id" uuid,
100
102
  "updated_at" timestamptz not null default now(),
103
+ "updated_by" text,
101
104
  primary key ("entry_id", "locale")
102
105
  );
103
106
 
@@ -107,19 +110,19 @@ create index if not exists "entry_locales_status_idx"
107
110
  -- A version belongs to ONE locale of one entry and stores the MERGED
108
111
  -- fields (shared + localized + slug) as they were at that save, so
109
112
  -- restoring an old Finnish version restores the shared fields too.
110
- -- Rows exist only for collections registered with `versions: true`.
113
+ -- Rows for every collection and every global item (spec 2026-09-14 §1).
114
+ -- One row per publish plus the live draft (spec 2026-09-13 history §1).
111
115
  create table if not exists "entry_versions" (
112
116
  "id" uuid primary key default gen_random_uuid(),
113
117
  "space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
114
118
  "entry_id" uuid not null references "entries" ("id") on delete cascade,
115
119
  "locale" text not null,
116
120
  "fields" jsonb not null,
117
- "created_by" text,
118
- "created_at" timestamptz not null default now()
121
+ "saved_at" timestamptz not null default now()
119
122
  );
120
123
 
121
124
  create index if not exists "entry_versions_entry_locale_idx"
122
- on "entry_versions" ("entry_id", "locale", "created_at");
125
+ on "entry_versions" ("entry_id", "locale", "saved_at");
123
126
 
124
127
  -- v1 reorder (DESIGN.md §3 Manual ordering): rewrite sort 1..N in ONE
125
128
  -- statement. Deliberately does not touch updated_at — a drag must not
@@ -506,12 +509,12 @@ select
506
509
  e."sort",
507
510
  e."created_at",
508
511
  l."updated_at",
509
- v."fields"->>'title' as "title",
510
- (v."fields"->>'date')::timestamptz as "date",
511
- v."fields"->'body' as "body"
512
+ coalesce(v."fields", e."fields" || l."fields")->>'title' as "title",
513
+ (coalesce(v."fields", e."fields" || l."fields")->>'date')::timestamptz as "date",
514
+ coalesce(v."fields", e."fields" || l."fields")->'body' as "body"
512
515
  from "entries" e
513
516
  join "entry_locales" l on l."entry_id" = e."id"
514
- join "entry_versions" v on v."id" = l."published_version_id"
517
+ left join "entry_versions" v on v."id" = l."published_version_id"
515
518
  where e."collection" = 'posts' and l."status" = 'published';
516
519
 
517
520
  create index if not exists "entries_posts_date_idx"
@@ -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
@@ -15,7 +15,7 @@ for an agent (or a person) building the site.
15
15
  - `components/pages/` — the page shapes and the entry page: `Home` (fixed at /, one freeform zone), `Page` (open, editors create many), `Post` (the entry page for posts).
16
16
  - `components/sections/Text.tsx` — the one section: a title and a richtext body. Listed in `smoodly/sections.ts`.
17
17
  - `components/elements/Button.tsx` — the one element: a link styled as a button, embedded in richtext through `smoodly/toolsets.ts`.
18
- - `smoodly/` — the wiring: `config.ts` (the registry and settings), `collections.ts` (posts), `shared.ts` (the site item), `sections.ts`, `elements.ts`, `toolsets.ts`, `server.ts` (stores, site layer, renderer, the read API `q`), `ops.ts` (the admin's one server action), `admin.tsx` (the admin mount), `richtext.tsx` (the bound renderer).
18
+ - `smoodly/` — the wiring: `config.ts` (the registry and settings), `collections.ts` (posts), `globals.ts` (the site item), `sections.ts`, `elements.ts`, `toolsets.ts`, `server.ts` (stores, site layer, renderer, the read API `q`), `ops.ts` (the admin's one server action), `admin.tsx` (the admin mount), `richtext.tsx` (the bound renderer).
19
19
  - `scripts/seed.ts` — the home page and the site item, written through the stores. `npm run seed` is idempotent.
20
20
  - `app/globals.css` — a reset, a container, the two tones. Replace it.
21
21
  - `.env.local` — `SMOODLY_URL`, `SMOODLY_SECRET_KEY`, `SMOODLY_PUBLISHABLE_KEY`.
@@ -49,12 +49,12 @@ Then create a migration with its view:
49
49
  Save the output as `supabase/migrations/<timestamp>_people.sql` and apply
50
50
  it with `supabase db push` (hosted) or `supabase db reset` (local).
51
51
 
52
- ## Add a shared item
52
+ ## Add a global item
53
53
 
54
- Copy the `site` block in `smoodly/shared.ts` and add it to `shared`. An
55
- object item (`fields`) is read in code with `q().getShared("name")`; a
54
+ Copy the `site` block in `smoodly/globals.ts` and add it to `globals`. An
55
+ object item (`fields`) is read in code with `q().getGlobal("name")`; a
56
56
  visual item (`section: SomeSection`) renders through its section and can
57
- be placed on pages from the picker's Shared group. Edit both under
57
+ be placed on pages from the picker's Global group. Edit both under
58
58
  Globals in the admin.
59
59
 
60
60
  Reseed an empty database any time with `npm run seed`.
@@ -7,20 +7,20 @@ import "../globals.css";
7
7
 
8
8
  // The site's root layout under the locale segment; the admin has its own
9
9
  // under app/(smoodly)/admin, outside [locale], as a second root layout.
10
- // The `site` shared item gives the document title and the footer line,
10
+ // The `site` global item gives the document title and the footer line,
11
11
  // read per locale through `q`, null until published in this locale. The
12
12
  // locale links are the visitor's only language switch, since locale
13
13
  // detection is off (see i18n/routing.ts).
14
14
  export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
15
15
  const { locale } = await params;
16
- const site = await q().getShared("site", { locale });
16
+ const site = await q().getGlobal("site", { locale });
17
17
  return { title: site?.name ?? "Smoodly site" };
18
18
  }
19
19
 
20
20
  export default async function LocaleLayout({ children, params }: { children: ReactNode; params: Promise<{ locale: string }> }) {
21
21
  const { locale } = await params;
22
22
  if (!hasLocale(routing.locales, locale)) notFound();
23
- const site = await q().getShared("site", { locale });
23
+ const site = await q().getGlobal("site", { locale });
24
24
  const locales = routing.locales.map((code) => ({ code, url: href("/", code), current: code === locale }));
25
25
  return (
26
26
  <html lang={locale}>
@@ -1,13 +1,22 @@
1
1
  /* The blank starter's one stylesheet: a reset, a system font, a
2
- container, prose defaults, the button element, and the two tones the
3
- config's section style emits as data-tone. Replace freely. */
2
+ container, prose defaults, the button element, and the two backgrounds
3
+ the Text section's setting selects. The editor's Spacing & layout
4
+ steps (Smoodly's styles set, smoodly/config.ts) render inline through
5
+ the section wrapper and read the --space-* / --width-* variables.
6
+ Replace freely. */
4
7
 
5
8
  :root {
9
+ /* Smoodly's styles set reads these (smoodly/config.ts): editor steps become padding, width, alignment. */
10
+ --space-sm: 1.5rem; --space-md: 3rem; --space-lg: 4.5rem;
11
+ --width-narrow: 40rem; --width-wide: 90rem;
12
+
6
13
  --paper: #ffffff;
7
14
  --ink: #1a1a1a;
8
15
  --muted: #666666;
9
16
  --container: 720px;
17
+ --smd-gutter: 1rem; /* the custom section's rows share the container's side gutter */
10
18
  }
19
+ @media (min-width: 768px) { :root { --space-sm: 2rem; --space-md: 4rem; --space-lg: 6rem; } }
11
20
 
12
21
  * { box-sizing: border-box; }
13
22
  html, body { margin: 0; padding: 0; }
@@ -23,15 +32,16 @@ body {
23
32
  main { flex: 1; }
24
33
  a { color: inherit; }
25
34
 
26
- .container { width: min(var(--container), 100% - 2rem); margin-inline: auto; }
35
+ /* The container reads the editor's width step; without one, the design's width. */
36
+ .container { width: min(var(--smd-width, var(--container)), 100% - 2rem); margin-inline: auto; }
27
37
 
28
- /* Sections carry data-tone from the config's section styles. */
29
- [data-tone="paper"] { background: var(--paper); color: var(--ink); }
30
- [data-tone="ink"] { background: var(--ink); color: var(--paper); }
31
- section[data-tone] { padding-block: 3rem; }
38
+ /* The Text section: its own padding, and the background its setting selects. */
39
+ .text { padding-block: 3rem; }
40
+ [data-background="paper"] { background: var(--paper); color: var(--ink); }
41
+ [data-background="ink"] { background: var(--ink); color: var(--paper); }
32
42
 
33
- .prose > * + * { margin-top: 1em; }
34
- .prose h1, .prose h2, .prose h3 { line-height: 1.2; }
43
+ .prose > * + *, .smd-col > * + * { margin-top: 1em; } /* the custom section's columns read like prose */
44
+ .prose h1, .prose h2, .prose h3, .smd-col h1, .smd-col h2, .smd-col h3 { line-height: 1.2; }
35
45
 
36
46
  /* The Button element, inside richtext. */
37
47
  .button {
@@ -10,7 +10,7 @@ export const homeSchema = smoodly.schema.page({
10
10
  title: "Home",
11
11
  slug: "home",
12
12
  zones: {
13
- body: z.freeform({ sections: [Text] }),
13
+ body: z.freeform({ sections: [Text], custom: true }),
14
14
  },
15
15
  meta: { seo: true },
16
16
  });
@@ -8,7 +8,7 @@ export const pageSchema = smoodly.schema.page({
8
8
  name: "page",
9
9
  title: "Page",
10
10
  zones: {
11
- body: z.freeform({ sections: [Text] }),
11
+ body: z.freeform({ sections: [Text], custom: true }),
12
12
  },
13
13
  meta: { seo: true },
14
14
  });
@@ -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.15",
17
+ "smoodly": "0.0.17",
18
18
  "next-intl": "^4.14.2"
19
19
  },
20
20
  "devDependencies": {