smoodly 0.0.61 → 0.0.63

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 (76) hide show
  1. package/dist/admin/client-ops.js +1 -1
  2. package/dist/admin/drift.js +3 -1
  3. package/dist/admin/editor/CanvasOverlay.d.ts +4 -1
  4. package/dist/admin/editor/CanvasOverlay.js +3 -3
  5. package/dist/admin/editor/EditorView.js +65 -9
  6. package/dist/admin/editor/GlobalCanvas.d.ts +27 -0
  7. package/dist/admin/editor/GlobalCanvas.js +159 -0
  8. package/dist/admin/editor/InsertPicker.d.ts +8 -27
  9. package/dist/admin/editor/InsertPicker.js +56 -47
  10. package/dist/admin/editor/SamplePreview.d.ts +7 -0
  11. package/dist/admin/editor/SamplePreview.js +86 -0
  12. package/dist/admin/editor/SectionIcon.d.ts +4 -0
  13. package/dist/admin/editor/SectionIcon.js +8 -0
  14. package/dist/admin/editor/StructureView.d.ts +2 -2
  15. package/dist/admin/editor/StructureView.js +2 -1
  16. package/dist/admin/editor/canvas-address.d.ts +2 -0
  17. package/dist/admin/editor/canvas-address.js +3 -1
  18. package/dist/admin/editor/picker-rows.d.ts +33 -0
  19. package/dist/admin/editor/picker-rows.js +38 -0
  20. package/dist/admin/editor/sample-frame.d.ts +36 -0
  21. package/dist/admin/editor/sample-frame.js +58 -0
  22. package/dist/admin/editor/sample-picks.d.ts +34 -0
  23. package/dist/admin/editor/sample-picks.js +49 -0
  24. package/dist/admin/editor/view-memory.d.ts +5 -0
  25. package/dist/admin/editor/view-memory.js +19 -0
  26. package/dist/admin/files.generated.js +1 -1
  27. package/dist/admin/forms/MediaWidget.d.ts +1 -1
  28. package/dist/admin/forms/MediaWidget.js +10 -2
  29. package/dist/admin/ops.d.ts +5 -0
  30. package/dist/admin/serialize.d.ts +2 -0
  31. package/dist/admin/serialize.js +1 -0
  32. package/dist/admin/shell/AdminApp.js +5 -5
  33. package/dist/admin/shell/GlobalForm.js +80 -7
  34. package/dist/admin/shell/GlobalList.js +1 -1
  35. package/dist/admin/shell/{IconRail.d.ts → NavBar.d.ts} +3 -3
  36. package/dist/admin/shell/NavBar.js +120 -0
  37. package/dist/admin/shell/PagesList.js +4 -4
  38. package/dist/admin/shell/console-links.js +1 -1
  39. package/dist/admin/shell/global-canvas.d.ts +40 -0
  40. package/dist/admin/shell/global-canvas.js +60 -0
  41. package/dist/admin/shell/health-list.js +1 -1
  42. package/dist/admin/tree-ops.d.ts +11 -4
  43. package/dist/admin/tree-ops.js +28 -17
  44. package/dist/admin/ui/primitives.js +1 -1
  45. package/dist/admin/ui/theme.js +128 -101
  46. package/dist/admin/validate.js +3 -2
  47. package/dist/api-client.js +12 -2
  48. package/dist/astro/middleware.js +2 -0
  49. package/dist/astro/shell.d.ts +3 -0
  50. package/dist/astro/shell.js +8 -1
  51. package/dist/canvas-marker.d.ts +5 -0
  52. package/dist/canvas-marker.js +18 -6
  53. package/dist/conditions.js +5 -2
  54. package/dist/media.d.ts +15 -2
  55. package/dist/media.js +68 -10
  56. package/dist/next/page-renderer.js +12 -5
  57. package/dist/resolve.d.ts +6 -1
  58. package/dist/resolve.js +7 -7
  59. package/dist/sample-fill.d.ts +40 -0
  60. package/dist/sample-fill.js +145 -0
  61. package/dist/sample-tree.d.ts +30 -0
  62. package/dist/sample-tree.js +76 -0
  63. package/dist/site.d.ts +13 -0
  64. package/dist/site.js +10 -1
  65. package/dist/version.d.ts +1 -1
  66. package/dist/version.js +1 -1
  67. package/docs/collections/defining.md +2 -2
  68. package/docs/content-modeling/sections.md +19 -3
  69. package/docs/content-modeling/zones.md +3 -3
  70. package/docs/getting-started/deploying.md +1 -1
  71. package/docs/getting-started/working-together.md +1 -1
  72. package/docs/globals/defining.md +1 -1
  73. package/docs/globals/header-and-footer.md +2 -2
  74. package/docs/media/library.md +1 -1
  75. package/package.json +1 -1
  76. package/dist/admin/shell/IconRail.js +0 -109
@@ -0,0 +1,76 @@
1
+ // The picker's samples (spec 2026-11-14 add section previews): what a zone's "+" offers, as nodes the site can draw.
2
+ // The admin's modal shows each row as the site itself renders it; the site builds this tree from its registry (the
3
+ // service holds no samples), the service resolves it in place of the page's draft (ContentReads.loadSamples), and
4
+ // the admin finds each row's node in the frame by the ids below. Framework-free: both adapters use it.
5
+ import { allowsCustom } from "./admin/custom-section.js";
6
+ import { nodeFromSample } from "./admin/tree-ops.js";
7
+ import { CUSTOM_NODE_TYPE, defaultRow } from "./custom/index.js";
8
+ import { GLOBAL_NODE_TYPE } from "./global.js";
9
+ import { SAMPLE_PICK_LIMIT, sampleCollections } from "./sample-fill.js";
10
+ /** A sample node's id, fixed so the admin finds it without asking. The middle word keeps a section a developer
11
+ * named `custom` (or `global-…`) apart from the Custom section and from a global item. */
12
+ export function sampleNodeId(kind, name) {
13
+ return kind === "custom" ? "sample-custom" : `sample-${kind}-${name}`;
14
+ }
15
+ /** The sections a zone's picker offers: none for a locked zone or one the page type does not have. */
16
+ function offered(source, policy) {
17
+ if (!policy || policy.kind === "locked")
18
+ return [];
19
+ return source.sections.filter((s) => policy.allow === "*" || policy.allow.includes(s.name));
20
+ }
21
+ /** The collections a zone's offered sections reference (spec 2026-11-14 filled samples): what the picks are read for. */
22
+ export const zoneSampleCollections = (source, policy) => sampleCollections(offered(source, policy));
23
+ /** The zone's picker rows as nodes, in the picker's order: the sections its policy allows, the Custom section where
24
+ * the zone takes one, the visual global items whose section it allows. Each is what an insert would create; a
25
+ * placement names its item alone (the service finds the row, and drops an item that has none). No other zone is
26
+ * in the tree. `picks` fill the sections' reference fields (spec 2026-11-14 filled samples); the tree is always the
27
+ * filled one, whatever the picker's switch says. */
28
+ export function sampleTree(source, policy, zone, picks) {
29
+ const nodes = [];
30
+ if (policy && policy.kind !== "locked") {
31
+ const allows = (section) => policy.allow === "*" || policy.allow.includes(section);
32
+ for (const s of offered(source, policy))
33
+ nodes.push(nodeFromSample(s, sampleNodeId("section", s.name), { picks }));
34
+ // As the admin's customNode: the registry's custom sample is one default row.
35
+ if (allowsCustom(policy))
36
+ nodes.push({ id: sampleNodeId("custom"), type: CUSTOM_NODE_TYPE, fields: { rows: [defaultRow()] } });
37
+ for (const item of source.globals) {
38
+ if (item.section !== undefined && allows(item.section))
39
+ nodes.push({ id: sampleNodeId("global", item.name), type: GLOBAL_NODE_TYPE, fields: { item: item.name } });
40
+ }
41
+ }
42
+ return { zones: { [zone]: nodes } };
43
+ }
44
+ /** The reference picks of a zone's samples (spec 2026-11-14 filled samples): the first published entries of each
45
+ * collection its sections reference, one published read per collection in the page's locale: the read the admin's
46
+ * `entries.samplePicks` makes for the insert. A read that fails leaves that collection's references unfilled. */
47
+ async function samplePicks(site, template, zone, locale) {
48
+ const picks = {};
49
+ await Promise.all(site.sampleCollections(template, zone).map(async (collection) => {
50
+ try {
51
+ picks[collection] = (await site.getEntries(collection, { locale, limit: SAMPLE_PICK_LIMIT })).map((e) => e.id);
52
+ }
53
+ catch (e) {
54
+ console.error(`smoodly: the picker's sample entries of "${collection}" could not be read — ${e instanceof Error ? e.message : String(e)}`);
55
+ }
56
+ }));
57
+ return picks;
58
+ }
59
+ /** What a samples address draws for a hit: the page with the zone's samples resolved by the service. A page the
60
+ * service cannot answer for (no record yet, an older service, an outage) is drawn with its zones empty, so the
61
+ * admin finds no node and every row keeps its icon; an entry has no zones and is returned as it is. */
62
+ export async function samplesHit(site, hit, target, locale, path, zone) {
63
+ if (hit.kind !== "page")
64
+ return hit;
65
+ const emptied = { ...hit, tree: { ...hit.tree, zones: {} } };
66
+ if (!target)
67
+ return emptied;
68
+ try {
69
+ const resolved = await site.loadSamples(target, locale, path, site.sampleTree(hit.template, zone, await samplePicks(site, hit.template, zone, locale)));
70
+ return resolved?.kind === "page" ? resolved : emptied;
71
+ }
72
+ catch (e) {
73
+ console.error(`smoodly: the picker's samples could not be loaded — ${e instanceof Error ? e.message : String(e)}`);
74
+ return emptied;
75
+ }
76
+ }
package/dist/site.d.ts CHANGED
@@ -12,6 +12,7 @@ import type { StyleValues, StyleValuesInput } from "./styles.ts";
12
12
  import type { CustomConfigInput, ResolvedCustom } from "./custom/index.ts";
13
13
  import type { PageRecord, PageStore } from "./store.ts";
14
14
  import type { PathHit } from "./path-index.ts";
15
+ import type { SamplePicks } from "./sample-fill.ts";
15
16
  import type { EntryItem, PageItem, Seo } from "./read-shapes.ts";
16
17
  import type { SchemaOr } from "./registration.ts";
17
18
  import type { ResolvedConfig } from "./config.ts";
@@ -144,6 +145,16 @@ export interface ContentReads {
144
145
  targetPaths(target: PathTarget): Promise<Record<string, string>>;
145
146
  /** Everything the renderer needs for one target: null when it is a folder, unpublished (outside draft), or gone. */
146
147
  load(target: PathTarget, locale: string, path: string, options?: LoadOptions): Promise<SiteHit | null>;
148
+ /** The picker's rows for a zone of a page type, as raw nodes built from the registry's samples (sample-tree.ts):
149
+ * what `loadSamples` is handed. A static: the service holds no samples. */
150
+ sampleTree(template: string, zone: string, picks?: SamplePicks): PageTree;
151
+ /** The collections the zone's offered sections reference (spec 2026-11-14 filled samples): the picks are their
152
+ * first published entries, read before the tree is built. A static. */
153
+ sampleCollections(template: string, zone: string): string[];
154
+ /** The picker's samples (spec 2026-11-14 add section previews): the page's draft with `tree`'s zones resolved in
155
+ * place of the stored ones, images, references and placed globals as a draft has them. Draft only; nothing is
156
+ * stored. A placement names its item alone. Null for an entry, a page that is gone or not in `locale`. */
157
+ loadSamples(target: PathTarget, locale: string, path: string, tree: PageTree): Promise<SiteHit | null>;
147
158
  /** The published read of an address: one locate that tells a record to assemble from a redirect row, and the
148
159
  * code-owned page where the index has no row. */
149
160
  lookup(locale: string, path: string): Promise<SiteHit | {
@@ -220,6 +231,8 @@ export declare function siteStatics(options: Omit<SiteOptions, "stores">): {
220
231
  custom: ResolvedCustom;
221
232
  cacheKey: string;
222
233
  codePage: (locale: string, path: string) => SitePage | null;
234
+ sampleTree: (template: string, zone: string, picks?: SamplePicks) => PageTree;
235
+ sampleCollections: (template: string, zone: string) => string[];
223
236
  };
224
237
  /** What createSmoodlySite takes, read off the config. */
225
238
  export declare function siteOptions(config: ResolvedConfig, stores?: SiteOptions["stores"]): SiteOptions;
package/dist/site.js CHANGED
@@ -2,6 +2,7 @@ import { lastPart } from "./addresses.js";
2
2
  import { withDefaults } from "./field-defaults.js";
3
3
  import { modelKey } from "./model-key.js";
4
4
  import { SMOODLY_VERSION } from "./version.js";
5
+ import { sampleTree as buildSampleTree, zoneSampleCollections } from "./sample-tree.js";
5
6
  import { pageSeo } from "./seo.js";
6
7
  import { pageFieldsOf, siteShape } from "./assemble.js";
7
8
  /** The `page` prop for a loaded page. `href` is the app's seam from a
@@ -60,7 +61,15 @@ export function siteStatics(options) {
60
61
  ...(withSeo ? { seo: pageSeo(undefined, spec.title) } : {}),
61
62
  };
62
63
  }
63
- return { shape, locales, homePageSlug, styles, custom, cacheKey, codePage };
64
+ const sampleSource = {
65
+ sections: options.sections.map((x) => ("schema" in x ? x.schema : x)),
66
+ globals: (options.globals ?? []).map((g) => ({ name: g.name, ...(g.section !== undefined ? { section: g.section } : {}) })),
67
+ };
68
+ // A page type's schema carries its zones (page.ts); the site's own page type names only what the reads use.
69
+ const policyOf = (template, zone) => pageSchemas.find((x) => x.kind === "page" && x.name === template)?.zones?.[zone];
70
+ const sampleTree = (template, zone, picks) => buildSampleTree(sampleSource, policyOf(template, zone), zone, picks);
71
+ const sampleCollections = (template, zone) => zoneSampleCollections(sampleSource, policyOf(template, zone));
72
+ return { shape, locales, homePageSlug, styles, custom, cacheKey, codePage, sampleTree, sampleCollections };
64
73
  }
65
74
  /** What createSmoodlySite takes, read off the config. */
66
75
  export function siteOptions(config, stores) {
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const SMOODLY_VERSION = "0.0.61";
1
+ export declare const SMOODLY_VERSION = "0.0.63";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // Generated by scripts/write-version.mjs from package.json — do not edit.
2
- export const SMOODLY_VERSION = "0.0.61";
2
+ export const SMOODLY_VERSION = "0.0.63";
@@ -26,7 +26,7 @@ export const people = smoodly.collection({
26
26
  | Name | Description |
27
27
  | --- | --- |
28
28
  | `name` | What references and queries use. Keep it stable once entries exist. global, asset and media are reserved. |
29
- | `title` | The label in the admin's rail and list. |
29
+ | `title` | The label in the admin's navigation and list. |
30
30
  | `titleField` | The field whose value names an entry in lists and pickers. Without one, or while it is empty, an entry reads Untitled. |
31
31
  | `fields` | Any builders. A field named slug gives entries a URL part; .localized() gives a field a value per language. |
32
32
  | `path` | Serves each entry at its own URL under this segment. See Entry pages. |
@@ -38,7 +38,7 @@ Register the export in the `collections` list of `smoodly/collections.ts`, which
38
38
 
39
39
  ## Entries in the admin
40
40
 
41
- The *Collections* item in the admin's rail opens a menu of every collection. A collection editors open all day can sit on the rail itself: add `admin: { showInNav: true }` to its schema and it appears after *Pages* under its title, and leaves the menu. A collection's list shows its entries with their title, status and when they changed, 20 to a page; *Previous* and *Next* under the rows move through a longer list, and *Per page* shows 100 or 500 instead, a choice the browser remembers. The box above the rows filters by the collection's `titleField`: it finds the entries whose title holds the text, in any language, and searches the whole collection, not the page on screen. A collection without a `titleField` has no filter. While a filter is on, a hand-ordered list does not drag. *+ New entry* creates an empty draft in the selected language and opens the form at once. The form autosaves, and carries the same *Publish* control as a page, so an entry is published per language like a page. *History* and *Discard changes* work the same way.
41
+ The *Collections* item in the admin's top bar opens a menu of every collection. A collection editors open all day can sit on the bar itself: add `admin: { showInNav: true }` to its schema and it appears after *Pages* under its title, and leaves the menu. A collection's list shows its entries with their title, status and when they changed, 20 to a page; *Previous* and *Next* under the rows move through a longer list, and *Per page* shows 100 or 500 instead, a choice the browser remembers. The box above the rows filters by the collection's `titleField`: it finds the entries whose title holds the text, in any language, and searches the whole collection, not the page on screen. A collection without a `titleField` has no filter. While a filter is on, a hand-ordered list does not drag. *+ New entry* creates an empty draft in the selected language and opens the form at once. The form autosaves, and carries the same *Publish* control as a page, so an entry is published per language like a page. *History* and *Discard changes* work the same way.
42
42
 
43
43
  An entry cannot be deleted while a page, a section or another entry references it. The message says how many, and the media library shows the same for assets.
44
44
 
@@ -32,7 +32,7 @@ export const heroSchema = smoodly.schema.section({
32
32
  | --- | --- |
33
33
  | `name` | What the content stores for every placed Hero, so keep it stable once pages exist. |
34
34
  | `title` | The label in Add section and in the inspector. |
35
- | `description` | One line under the title in Add section. Optional, like icon. |
35
+ | `description` | A line about the section, kept for later use: the admin does not show it today. Optional, like icon. |
36
36
  | `fields` | What editors fill. Field order is form order: editors see Kicker, Heading, Text, Buttons, Image, top to bottom. |
37
37
  | `sample` | What a new Hero starts with. See Sample content below. |
38
38
 
@@ -195,12 +195,28 @@ A registered section that no zone lists never appears in *Add section*. Lorem Co
195
195
 
196
196
  ## Sample content
197
197
 
198
- `sample` is what a section starts with, so a page never shows an empty one. Smoodly uses it twice:
198
+ `sample` is what a section starts with, so a page never shows an empty one. Smoodly uses it three times:
199
199
 
200
+ - *Add section* shows each section as your site draws it with its sample, so an editor sees what they are about to add.
200
201
  - When an editor picks the section in *Add section*, the new section carries the sample.
201
202
  - When a page opens with an empty locked zone, Smoodly fills the zone from the locked section's sample.
202
203
 
203
- Fields the sample leaves out start from their `.default()`, or empty. A missing image or number stays unset rather than showing a broken picture or a stray zero. Make the sample a complete value in plain copy: it is what editors see first, and what they overwrite.
204
+ What Smoodly shows is what it inserts: the picture and the new section are built by the same rules.
205
+
206
+ The sample always comes first. A field it sets is taken as it is, and nothing is added to it or inside it. A field it leaves out starts from its `.default()`. A field with neither is filled by Smoodly, so a section with a thin sample still looks like itself:
207
+
208
+ | Name | Description |
209
+ | --- | --- |
210
+ | `f.text()` | Required: a short Latin phrase where the key reads as a title (title, heading, label, name, button and the like), else one sentence, cut to .max(n). Optional: empty. |
211
+ | `f.richtext()` | Required: a heading, two paragraphs and a list where the toolset has them, else one paragraph. Optional: empty. |
212
+ | `f.image()` | A placeholder picture, required or optional. It shows in the editor and in previews only, never on the live page; publishing treats it as no image, so a required image still asks for a photo. |
213
+ | `f.ref(), f.refList()` | The collection's first published entries: one for a required f.ref, up to three for an f.refList, fewer if .max(n) is lower. |
214
+ | `f.list(item)` | Required: .min(n) items, or three, never more than .max(n). Each item is filled the same way. |
215
+ | `f.object({ … })` | Required: its fields, filled the same way. |
216
+
217
+ Numbers, dates, links, videos and files are not filled (a number, a video and a file stay unset; a date and a link start empty), a list nothing fills starts as an empty list, and settings are never filled: they keep their defaults. An empty list in a sample (`people: []`) counts as left out. To choose the content yourself, put it in the sample.
218
+
219
+ *Add section* has an *Insert filled* switch under its list. Turned off, a section arrives empty: without the sample and without anything generated. The picture still shows the filled section.
204
220
 
205
221
  The sample may set settings too, under their own keys.
206
222
 
@@ -52,13 +52,13 @@ function LandingView({ zones }: Props<typeof landingSchema>) {
52
52
  export const LandingPage = smoodly.page(landingSchema, LandingView);
53
53
  ```
54
54
 
55
- The hero zone offers a choice of two openers and takes one of them. The body takes anything. The closing zone always shows the site's one quote, edited under *Site*.
55
+ The hero zone offers a choice of two openers and takes one of them. The body takes anything. The closing zone always shows the site's one quote, edited under *Sections*.
56
56
 
57
57
  > **Note:** `sections: "*"` includes every registered section, the header and footer too. List the sections instead when some are only meant for the layout.
58
58
 
59
59
  ## What editors see
60
60
 
61
- In the canvas, *Add section* appears between the sections of a zone and at its end. It opens a picker with the sections the policy allows, then *Custom section* where `custom: true`, then a *Global* group with the globals whose section is allowed there. A zone capped with `.max(n)` loses its *Add section* once it is full.
61
+ In the canvas, *Add section* appears between the sections of a zone and at its end. It opens a picker with the sections the policy allows, then *Custom section* where `custom: true`, then a *Global* group with the globals whose section is allowed there. A zone capped with `.max(n)` loses its *Add section* once it is full. The picker shows each section as your site draws it, filled where its `sample` is silent; its *Insert filled* switch, when turned off, inserts the section empty instead ([Sample content](../content-modeling/sections.md#sample-content)).
62
62
 
63
63
  A section's toolbar moves it up or down, duplicates it or removes it. In a locked zone the section is tagged *locked* and the toolbar says *fields only*. Smoodly fills the zone from the section's `sample` the first time the page opens.
64
64
 
@@ -66,7 +66,7 @@ An empty zone shows as a box in the canvas so it can take a section. On the site
66
66
 
67
67
  ## Locking a zone to a global
68
68
 
69
- `z.locked()` also takes a global with a section, as `closing` does above. The zone then shows that one global on every page of the type. Editors change it once, under *Site* or right in any canvas that shows it, and every page follows. The starter's header and footer take a different route: the layout renders them directly, so they need no zone.
69
+ `z.locked()` also takes a global with a section, as `closing` does above. The zone then shows that one global on every page of the type. Editors change it once, under *Globals* or right in any canvas that shows it, and every page follows. The starter's header and footer take a different route: the layout renders them directly, so they need no zone.
70
70
 
71
71
  A global can also be placed by hand. The starter lists Quote in its body zones, so the picker's *Global* group offers the site quote there, and an editor may still add a one-off Quote from the sections list.
72
72
 
@@ -51,7 +51,7 @@ export default defineConfig({
51
51
  });
52
52
  ```
53
53
 
54
- Publishing does not change the live site. Editors press **Deploy** in the admin's rail when they are done, and the host builds. Give the site the host's deploy hook as `SMOODLY_DEPLOY_HOOK` where `astro build` runs (on Vercel: Settings › Git › Deploy Hooks): the build hands it to Smoodly's API with the site's address; without it the tile shows what is waiting and Deploy stays disabled. `SITE_URL` must be the site's own address, and the build refuses static output without it: the tile reads the build's marker, `/_smoodly/build.json`, from there. It says *Building…* until the new build answers, *N changes* while publishes wait (an unpublish or a delete counts as one too), and *The build has not landed* after ten minutes with no new build, in which case look at the host's log. A host that strips query strings from a cached file can hold the old marker for its max-age.
54
+ Publishing does not change the live site. Editors press **Deploy** in the admin's top bar, next to their avatar, when they are done, and the host builds. Give the site the host's deploy hook as `SMOODLY_DEPLOY_HOOK` where `astro build` runs (on Vercel: Settings › Git › Deploy Hooks): the build hands it to Smoodly's API with the site's address; without it the tile shows what is waiting and Deploy stays disabled. `SITE_URL` must be the site's own address, and the build refuses static output without it: the tile reads the build's marker, `/_smoodly/build.json`, from there. It says *Building…* until the new build answers, *N changes* while publishes wait (an unpublish or a delete counts as one too), and *The build has not landed* after ten minutes with no new build, in which case look at the host's log. A host that strips query strings from a cached file can hold the old marker for its max-age.
55
55
 
56
56
  The admin, its revalidate and preview routes and the preview address still run on demand, so the deployment is files plus one function; a host with no function at all cannot run the admin. A static page has no lists by query string and no draft at its real address: drafts are edited through the preview address, as in live mode. A moved page's old address becomes one of the host's redirects at build, so it is answered without a function too. The cache provider in `astro.config` is unused in static output and may stay.
57
57
 
@@ -20,7 +20,7 @@ Editors sign in at smoodly.io, never on your site: `/admin` sends them there and
20
20
 
21
21
  ## Who is editing what
22
22
 
23
- A lock after a title in Pages, in a collection or in Site means someone else has that item open in the language you are looking at. Hover it for their name. Opening the item anyway shows a line under the top bar: *aleksi is also editing this page.* Nothing is locked; it is a heads-up.
23
+ A lock after a title in Pages, in a collection or in Globals means someone else has that item open in the language you are looking at. Hover it for their name. Opening the item anyway shows a line under the top bar: *aleksi is also editing this page.* Nothing is locked; it is a heads-up.
24
24
 
25
25
  Presence runs on Smoodly's service, over one connection per open admin tab. Without it the admin works exactly the same, minus the locks and the line; a tab that loses its connection reconnects on its own.
26
26
 
@@ -45,7 +45,7 @@ Register the list in the config's `globals`. A visual global's section must be i
45
45
 
46
46
  ## In the admin
47
47
 
48
- *Site* in the rail lists every global with its kind, *Visual* or *Fields*, and its status in the selected language. Each opens a form like an entry's: the fields in schema order, and for a visual global the *Settings* tab's groups after them. The form autosaves, and carries the same *Publish* control, *History* and *Discard changes* as a page. A visual global's page also says on how many pages it is used.
48
+ *Globals*, under ⋮ in the admin's top bar, lists every global with its kind, *Visual* or *Fields*, and its status in the selected language. A *Fields* global opens a form like an entry's, the fields in schema order. A *Visual* global opens on a canvas: a page of the site that shows it, the home page for a `layout` global and otherwise the first page that places it, with the global selected and the rest of the page dimmed. Its fields sit beside the canvas under *Content* and *Settings*, text marked for inline editing can be typed in place, and when several pages place it, *Shown on* picks the page. A visual global no page shows yet opens as a form. Either way the page autosaves, and carries the same *Publish* control, *History* and *Discard changes* as a page.
49
49
 
50
50
  A global exists in every language from the start, so there is nothing to add or delete. Publishing is per language, as for pages. A field's value is shared across languages unless it chains `.localized()`: the starter's contact email is one value, its address is translated.
51
51
 
@@ -25,7 +25,7 @@ export const headerSchema = smoodly.schema.section({
25
25
 
26
26
  `styles: false` takes away the *Spacing & layout* group, because the header's spacing is the design's. The brand is shared across languages and the navigation is localized as a whole, so each language has its own labels and links. The footer follows the same pattern with a tagline, three link columns and a copyright line.
27
27
 
28
- Both are registered in `smoodly/sections.ts` and named with `smoodly.global()` in `smoodly/globals.ts`, which gives each a row under *Site* and its `sample` as starting content. No zone lists them, so the *Add section* picker never offers them. Both are `layout: true` ([Defining a global](../globals/defining.md#layout-globals)): on Astro they arrive with the page's own read, so rendering them costs no extra call.
28
+ Both are registered in `smoodly/sections.ts` and named with `smoodly.global()` in `smoodly/globals.ts`, which gives each a row under *Sections* and its `sample` as starting content. No zone lists them, so the *Add section* picker never offers them. Both are `layout: true` ([Defining a global](../globals/defining.md#layout-globals)): on Astro they arrive with the page's own read, so rendering them costs no extra call.
29
29
 
30
30
  ## Rendering them
31
31
 
@@ -112,6 +112,6 @@ On a single-language site there are no links and the header shows the brand and
112
112
 
113
113
  ## Editing them
114
114
 
115
- Editors change the header and footer under *Site*, or in any page's canvas: both are selectable there, tagged *global*, with their own form and their own *Publish* in the inspector. The header's navigation edits in place like any list with [marks](../content-modeling/marks.md). Publishing a page never publishes the footer, because a footer going live on every page is a deliberate click.
115
+ Editors change the header and footer under *Globals*, or in any page's canvas: both are selectable there, tagged *global*, with their own form and their own *Publish* in the inspector. The header's navigation edits in place like any list with [marks](../content-modeling/marks.md). Publishing a page never publishes the footer, because a footer going live on every page is a deliberate click.
116
116
 
117
117
  Next, [The media library](../media/library.md).
@@ -4,7 +4,7 @@ Every image, video and file editors upload lives in one library. A field stores
4
4
 
5
5
  ## Uploading
6
6
 
7
- Editors upload from two places. A media field takes a dropped file straight away, or opens the library to pick one. *Media* in the rail shows the whole library, with an upload button and a filter for images, video and files.
7
+ Editors upload from two places. A media field takes a dropped file straight away, or opens the library to pick one. *Media* in the top bar shows the whole library, with an upload button and a filter for images, video and files.
8
8
 
9
9
  The browser measures an image's dimensions on upload, and captures a frame of a video as its poster, stored as an image asset of its own. What the site can accept is config:
10
10
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smoodly",
3
- "version": "0.0.61",
3
+ "version": "0.0.63",
4
4
  "description": "Open-source, code-first CMS for Astro and Next.js: define a component once, it becomes a typed, editable building block.",
5
5
  "license": "MIT",
6
6
  "engines": {
@@ -1,109 +0,0 @@
1
- "use client";
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- // packages/smoodly/src/admin/shell/IconRail.tsx
4
- // The admin's primary navigation: the design handoff's 72px IconRail (the
5
- // cool-blue look's components/navigation/IconRail, in the tree until 2026-10-19;
6
- // the shipped tokens are design_handoff_smoodly_neutral/), which replaced the
7
- // 200px SideNav so the visual editor gets the width. Ported markup-for-markup;
8
- // the handoff's click handlers are the shell's router.
9
- //
10
- // Items are registry-derived, never separately configured: Pages, then a
11
- // rail item for each collection registered with `admin.showInNav`
12
- // (2026-11-13), then Collections — one rail item whose flyout lists every
13
- // other collection, absent when none is left — then
14
- // Media, Globals (the global content items, when the registry has any;
15
- // the route is /globals), and Settings (the Settings area, spec
16
- // 2026-09-25; the route is /settings; every member has a page there since
17
- // Redirects, 2026-09-26, so the shell passes `settings` once the role is
18
- // known). The user avatar is pinned to the bottom and opens the account
19
- // flyout: the email, the theme toggle, "My Account" (at smoodly.io) and Sign
20
- // out (spec 2026-10-16 sign-in and the dashboard §6).
21
- //
22
- // Flyouts: click toggles, Esc closes and returns focus to the trigger,
23
- // ↑/↓ move between entries, a pointer down outside closes. A child being
24
- // active highlights its parent tile and the entry inside the menu.
25
- import { useEffect, useId, useRef, useState } from "react";
26
- import { ExternalLink, Globe, Image, Layers, LibraryBig, PanelTop, RadioTower, Settings } from "lucide-react";
27
- import { tileCopy } from "./deploy-tile.js";
28
- import { ThemeToggle } from "../ui/primitives.js";
29
- import { useAdminHref } from "./base.js";
30
- import { consoleLinks } from "./console-links.js";
31
- import { SMOODLY_VERSION } from "../../version.js";
32
- import { RouteLink } from "./RouteLink.js";
33
- /** Lucide at the rail's size and stroke (the tile's CSS restates both). */
34
- const ICON = { size: 18, strokeWidth: 2, "aria-hidden": true };
35
- /** The app icon, inlined: a package can't ship a file into the host app's
36
- * /public. The wordmark's "s" on a rounded square (chosen 2026-10-23; the
37
- * same tile, green with a white s, is smoodly.io's favicon). The square takes
38
- * --logo-outer and the glyph --logo-inner, so a look decides its colours. */
39
- function AppIcon() {
40
- return (_jsxs("svg", { className: "sm-rail__logo", viewBox: "0 0 64 64", fill: "none", xmlns: "http://www.w3.org/2000/svg", role: "img", "aria-label": "Smoodly", children: [_jsx("rect", { width: "64", height: "64", rx: "14", style: { fill: "var(--logo-outer)" } }), _jsx("path", { style: { fill: "var(--logo-inner)" }, transform: "translate(14.382 -4.365) scale(0.7123)", d: "M30.5851 22.9763C36.7891 22.9763 45.6052 24.6681 49.3603 26.5237L46.2583 42.2415C41.6869 40.113 35.2653 38.4212 29.4422 38.4212C26.0137 38.4212 23.7825 39.1852 23.7825 40.9862C23.7825 42.1869 24.9797 42.8964 27.1021 43.333L31.5647 44.3153C40.8162 46.3346 45.5508 51.1373 45.5508 58.505C45.5508 73.4587 33.9592 79.1345 18.939 79.1345C11.4833 79.1345 4.24536 77.3335 0.109375 75.6963L3.42905 58.9416C8.97998 61.8341 16.4356 63.6351 21.2247 63.6351C24.6532 63.6351 26.9933 62.7619 26.9933 60.6334C26.9933 59.4327 26.1226 58.505 23.5648 58.0684L19.7553 57.3043C12.5173 55.9399 6.31336 50.9735 6.31336 43.0055C6.31336 30.6169 15.6737 23.0309 30.6939 23.0309L30.5851 22.9763Z" })] }));
41
- }
42
- /** A menu link that opens smoodly.io in a new tab, with the arrow that says so. */
43
- function ExtLink({ href, onClick, children }) {
44
- return (_jsxs("a", { role: "menuitem", className: "sm-rail__menu-item", href: href, target: "_blank", rel: "noreferrer", onClick: onClick, children: [_jsx("span", { children: children }), _jsx(ExternalLink, { className: "sm-rail__ext", size: 13, strokeWidth: 1.75, "aria-hidden": "true" })] }));
45
- }
46
- export function IconRail({ registry, active, email, settings = true, deploy, onSignOut }) {
47
- const adminHref = useAdminHref();
48
- const root = useRef(null);
49
- const uid = useId();
50
- const [open, setOpen] = useState(null);
51
- // Collections opens on hover as well (spec 2026-09-15 rail tweaks): a hover
52
- // open closes when the pointer leaves and never steals focus; a click open
53
- // is sticky and moves focus into the menu for the keyboard.
54
- const [hovered, setHovered] = useState(false);
55
- const menuId = (key) => `sm-rail-menu${uid}${key}`;
56
- const toggle = (key) => setOpen((cur) => (cur === key ? null : key));
57
- const close = () => setOpen(null);
58
- const hoverLeave = () => setHovered(false);
59
- // A click open moves focus into the menu; a pointer down outside closes it.
60
- // Outside means outside the menu and its trigger — the rest of the rail
61
- // counts too (2026-09-26); the trigger is left to its own toggle.
62
- useEffect(() => {
63
- if (!open || !root.current)
64
- return;
65
- if (!hovered)
66
- root.current.querySelector(`#${CSS.escape(menuId(open))} [role=menuitem]`)?.focus();
67
- const onPointerDown = (e) => {
68
- if (!(e.target instanceof Node))
69
- return;
70
- const menu = root.current?.querySelector(`#${CSS.escape(menuId(open))}`);
71
- const trigger = root.current?.querySelector(`[aria-controls="${menuId(open)}"]`);
72
- if (menu?.contains(e.target) || trigger?.contains(e.target))
73
- return;
74
- close();
75
- };
76
- document.addEventListener("pointerdown", onPointerDown);
77
- return () => document.removeEventListener("pointerdown", onPointerDown);
78
- // eslint-disable-next-line react-hooks/exhaustive-deps
79
- }, [open]);
80
- const onKeyDown = (e) => {
81
- if (!open || !root.current)
82
- return;
83
- if (e.key === "Escape") {
84
- close();
85
- root.current.querySelector(`[aria-controls="${menuId(open)}"]`)?.focus();
86
- }
87
- else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
88
- const items = Array.from(root.current.querySelectorAll(`#${CSS.escape(menuId(open))} [role=menuitem]`));
89
- if (!items.length)
90
- return;
91
- e.preventDefault();
92
- const i = items.indexOf(document.activeElement);
93
- items[(i + (e.key === "ArrowDown" ? 1 : -1) + items.length) % items.length].focus();
94
- }
95
- };
96
- const link = (key, label, icon, href) => (_jsx("li", { className: "sm-rail__item", children: _jsxs(RouteLink, { className: "sm-rail__btn", href: href, "aria-current": key === active ? "page" : undefined, onClick: close, children: [_jsx("span", { className: "sm-rail__tile", children: icon }), _jsx("span", { className: "sm-rail__label", title: label, children: label })] }) }, key));
97
- // A collection with `admin.showInNav` is a rail item of its own; the rest stay in the Collections menu.
98
- const pinned = registry.collections.filter((c) => c.inNav);
99
- const collections = registry.collections.filter((c) => !c.inNav);
100
- const collectionActive = collections.some((c) => c.name === active);
101
- const collectionsOpen = open === "collections" || (hovered && open === null);
102
- const name = email?.split("@")[0] ?? "Editor";
103
- const initials = name.slice(0, 2).toUpperCase();
104
- return (_jsxs("nav", { className: "sm-rail", "aria-label": "Primary", ref: root, onKeyDown: onKeyDown, children: [_jsxs("div", { className: "sm-rail__item", children: [_jsx("button", { type: "button", className: "sm-rail__brand", title: "Smoodly", "aria-haspopup": "menu", "aria-expanded": open === "brand", "aria-controls": menuId("brand"), onClick: () => toggle("brand"), children: _jsx(AppIcon, {}) }), _jsxs("ul", { className: "sm-rail__menu sm-rail__menu--brand", role: "menu", id: menuId("brand"), "data-open": open === "brand", children: [_jsx("li", { role: "none", children: _jsx(ExtLink, { href: consoleLinks().project, onClick: close, children: "This site at smoodly.io" }) }), _jsx("li", { role: "none", children: _jsx(ExtLink, { href: consoleLinks().docs, onClick: close, children: "Documentation" }) }), _jsxs("li", { className: "sm-rail__menu-version", role: "presentation", children: ["Smoodly ", SMOODLY_VERSION] })] })] }), _jsxs("ul", { className: "sm-rail__list", children: [link("pages", "Pages", _jsx(PanelTop, { ...ICON }), adminHref("/pages")), pinned.map((c) => link(c.name, c.title, _jsx(Layers, { ...ICON }), adminHref(`/${c.name}`))), collections.length > 0 && (_jsxs("li", { className: "sm-rail__item", onMouseEnter: () => setHovered(true), onMouseLeave: hoverLeave, children: [_jsxs("button", { type: "button", className: "sm-rail__btn", "aria-current": collectionActive ? "page" : undefined, "aria-haspopup": "menu", "aria-expanded": collectionsOpen, "aria-controls": menuId("collections"), onClick: () => toggle("collections"), children: [_jsx("span", { className: "sm-rail__tile", children: _jsx(LibraryBig, { ...ICON }) }), _jsx("span", { className: "sm-rail__label", children: "Collections" })] }), _jsx("ul", { className: "sm-rail__menu sm-rail__menu--collections", role: "menu", id: menuId("collections"), "data-open": collectionsOpen, children: collections.map((c) => (_jsx("li", { role: "none", children: _jsx(RouteLink, { role: "menuitem", className: "sm-rail__menu-item", href: adminHref(`/${c.name}`), "aria-current": c.name === active ? "page" : undefined, onClick: () => { close(); hoverLeave(); }, children: _jsx("span", { children: c.title }) }) }, c.name))) })] })), link("media", "Media", _jsx(Image, { ...ICON }), adminHref("/media")), registry.globals.length > 0 && link("globals", "Site", _jsx(Globe, { ...ICON }), adminHref("/globals")), settings && link("settings", "Settings", _jsx(Settings, { ...ICON }), adminHref("/settings"))] }), deploy && (() => {
105
- const copy = tileCopy(deploy.status, Date.now());
106
- const id = menuId("deploy");
107
- return (_jsxs("div", { className: "sm-rail__item sm-rail__deploy", "data-state": deploy.status.state, children: [_jsxs("button", { type: "button", className: "sm-rail__btn", "aria-haspopup": "menu", "aria-expanded": open === "deploy", "aria-controls": id, onClick: () => toggle("deploy"), title: copy.detail, children: [_jsxs("span", { className: `sm-rail__tile${copy.lit ? " sm-rail__tile--lit" : ""}${copy.warn ? " sm-rail__tile--warn" : ""}`, children: [_jsx(RadioTower, { ...ICON }), copy.badge && _jsx("span", { className: "sm-rail__badge", "aria-label": `${copy.badge} changes waiting`, children: copy.badge })] }), _jsx("span", { className: "sm-rail__label", children: copy.label })] }), _jsxs("ul", { className: "sm-rail__menu sm-rail__menu--up", role: "menu", id: id, "data-open": open === "deploy", children: [_jsx("li", { className: "sm-rail__menu-detail", role: "presentation", children: copy.detail }), _jsx("li", { role: "none", children: _jsx("button", { type: "button", role: "menuitem", className: "sm-rail__menu-item", disabled: copy.action === null || deploy.busy, title: copy.disabledReason ?? undefined, onClick: () => { close(); deploy.onDeploy(); }, children: copy.action === "retry" ? "Deploy again" : "Deploy now" }) }), copy.disabledReason && _jsx("li", { className: "sm-rail__menu-note", role: "presentation", children: copy.disabledReason })] })] }));
108
- })(), _jsxs("div", { className: "sm-rail__item sm-rail__foot", children: [_jsx("button", { type: "button", className: "sm-rail__user", title: name, "aria-haspopup": "menu", "aria-expanded": open === "user", "aria-controls": menuId("user"), onClick: () => toggle("user"), children: initials }), _jsxs("ul", { className: "sm-rail__menu sm-rail__menu--up", role: "menu", id: menuId("user"), "data-open": open === "user", children: [_jsx("li", { className: "sm-rail__menu-title", role: "presentation", children: email ?? name }), _jsx("li", { role: "none", children: _jsx(ThemeToggle, {}) }), _jsx("li", { role: "none", children: _jsx(ExtLink, { href: consoleLinks().account, onClick: close, children: "My Account" }) }), _jsx("li", { role: "none", children: _jsx("button", { type: "button", role: "menuitem", className: "sm-rail__menu-item", onClick: () => { close(); onSignOut(); }, children: "Sign out" }) })] })] })] }));
109
- }