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.
- package/package.json +1 -1
- package/templates/blank/AGENTS.md +2 -2
- package/templates/blank/README.md +5 -5
- package/templates/blank/app/(site)/layout.tsx +3 -3
- package/templates/blank/app/globals.css +19 -9
- package/templates/blank/components/pages/Home.tsx +1 -1
- package/templates/blank/components/pages/Page.tsx +1 -1
- package/templates/blank/components/sections/Text.tsx +8 -4
- package/templates/blank/package.json +1 -1
- package/templates/blank/scripts/seed.ts +14 -2
- package/templates/blank/smoodly/config.ts +4 -11
- package/templates/blank/smoodly/{shared.ts → globals.ts} +4 -4
- package/templates/blank/smoodly/server.ts +9 -6
- package/templates/blank/supabase/migrations/00000000000000_smoodly.sql +1 -1
- package/templates/blank-intl/AGENTS.md +2 -2
- package/templates/blank-intl/README.md +5 -5
- package/templates/blank-intl/app/[locale]/layout.tsx +3 -3
- package/templates/blank-intl/app/globals.css +19 -9
- package/templates/blank-intl/components/pages/Home.tsx +1 -1
- package/templates/blank-intl/components/pages/Page.tsx +1 -1
- package/templates/blank-intl/components/sections/Text.tsx +8 -4
- package/templates/blank-intl/package.json +1 -1
- package/templates/blank-intl/scripts/seed.ts +14 -2
- package/templates/blank-intl/smoodly/config.ts +4 -11
- package/templates/blank-intl/smoodly/{shared.ts → globals.ts} +4 -4
- package/templates/blank-intl/smoodly/server.ts +9 -6
- package/templates/blank-intl/supabase/migrations/00000000000000_smoodly.sql +1 -1
- package/templates/lorem/AGENTS.md +2 -2
- package/templates/lorem/README.md +4 -4
- package/templates/lorem/app/(site)/layout.tsx +4 -4
- package/templates/lorem/app/globals.css +53 -44
- package/templates/lorem/components/elements/Callout.tsx +3 -3
- package/templates/lorem/components/elements/Cta.tsx +4 -4
- package/templates/lorem/components/pages/Home.tsx +4 -4
- package/templates/lorem/components/pages/Page.tsx +1 -1
- package/templates/lorem/components/sections/Footer.tsx +4 -2
- package/templates/lorem/components/sections/Header.tsx +4 -2
- package/templates/lorem/components/sections/ImageText.tsx +5 -5
- package/templates/lorem/components/sections/Quote.tsx +9 -6
- package/templates/lorem/package.json +1 -1
- package/templates/lorem/scripts/seed.ts +28 -9
- package/templates/lorem/smoodly/config.ts +18 -11
- package/templates/lorem/smoodly/{shared.ts → globals.ts} +7 -7
- package/templates/lorem/smoodly/sections.ts +3 -3
- package/templates/lorem/smoodly/server.ts +9 -6
- package/templates/lorem/supabase/migrations/00000000000000_smoodly.sql +1 -1
- package/templates/lorem-intl/AGENTS.md +2 -2
- package/templates/lorem-intl/README.md +4 -4
- package/templates/lorem-intl/app/[locale]/layout.tsx +4 -4
- package/templates/lorem-intl/app/globals.css +53 -44
- package/templates/lorem-intl/components/elements/Callout.tsx +3 -3
- package/templates/lorem-intl/components/elements/Cta.tsx +4 -4
- package/templates/lorem-intl/components/pages/Home.tsx +4 -4
- package/templates/lorem-intl/components/pages/Page.tsx +1 -1
- package/templates/lorem-intl/components/sections/Footer.tsx +4 -2
- package/templates/lorem-intl/components/sections/Header.tsx +4 -2
- package/templates/lorem-intl/components/sections/ImageText.tsx +5 -5
- package/templates/lorem-intl/components/sections/Quote.tsx +9 -6
- package/templates/lorem-intl/package.json +1 -1
- package/templates/lorem-intl/scripts/seed.ts +28 -9
- package/templates/lorem-intl/smoodly/config.ts +18 -11
- package/templates/lorem-intl/smoodly/{shared.ts → globals.ts} +7 -7
- package/templates/lorem-intl/smoodly/sections.ts +3 -3
- package/templates/lorem-intl/smoodly/server.ts +9 -6
- 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,
|
|
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
|
|
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,
|
|
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,
|
|
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,
|
|
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().
|
|
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
|
|
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
|
|
12
|
-
`elements.ts`, `collections.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
|
|
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
|
|
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
|
|
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
|
-
- **
|
|
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
|
|
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().
|
|
23
|
-
q().
|
|
24
|
-
q().
|
|
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.
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
.prose
|
|
57
|
-
.prose
|
|
58
|
-
.prose h2 { font-
|
|
59
|
-
.prose
|
|
60
|
-
.prose
|
|
61
|
-
.prose
|
|
62
|
-
.prose
|
|
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
|
-
|
|
69
|
-
.prose
|
|
70
|
-
.prose .
|
|
71
|
-
.prose .callout
|
|
72
|
-
.prose .callout[data-tone="
|
|
73
|
-
.prose .
|
|
74
|
-
.prose .
|
|
75
|
-
.prose .
|
|
76
|
-
.prose .cta
|
|
77
|
-
.prose .
|
|
78
|
-
.prose .
|
|
79
|
-
.prose .
|
|
80
|
-
.prose .cta
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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,
|
|
26
|
+
function CalloutView({ title, body, settings }: Props<typeof calloutSchema>) {
|
|
27
27
|
return (
|
|
28
|
-
<aside className="callout" data-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
|
-
|
|
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,
|
|
23
|
+
function CtaView({ heading, text, label, href, settings }: Props<typeof ctaSchema>) {
|
|
24
24
|
return (
|
|
25
|
-
<div className="cta" data-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={
|
|
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
|
|
15
|
-
// editor may drop a one-off quote, or place the
|
|
16
|
-
// picker's
|
|
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
|
|
4
|
-
// edited under
|
|
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
|
|
4
|
-
// edited under
|
|
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
|
-
//
|
|
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
|
-
|
|
23
|
-
imageSide: f.
|
|
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
|
|
4
|
-
// edited under
|
|
5
|
-
//
|
|
6
|
-
// the picker offers a
|
|
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>
|
|
@@ -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)) },
|
|
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 },
|
|
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
|
|
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
|
-
// ---
|
|
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: "
|
|
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
|
-
|
|
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
|
-
|
|
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(
|