@morphemecms/cms 0.11.0 → 0.12.0

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 (89) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/astro.mjs +2 -1
  3. package/lib/appId.d.mts +1 -0
  4. package/lib/appId.mjs +6 -0
  5. package/lib/apps.mjs +13 -3
  6. package/lib/apps.test.mjs +10 -1
  7. package/lib/contracts.d.mts +3 -2
  8. package/lib/contracts.mjs +3 -2
  9. package/lib/folderChanges.d.mts +29 -0
  10. package/lib/folderChanges.mjs +34 -0
  11. package/lib/section-ranges.d.mts +1 -1
  12. package/lib/section-ranges.mjs +8 -14
  13. package/lib/section-ranges.test.mjs +26 -5
  14. package/lib/storeEntries.d.mts +3 -1
  15. package/lib/storeEntries.mjs +22 -8
  16. package/lib/storeEntries.test.mjs +56 -7
  17. package/lib/text-sections.d.mts +11 -1
  18. package/lib/text-sections.mjs +18 -8
  19. package/lib/text-sections.test.mjs +34 -1
  20. package/lib/video-id.d.mts +6 -2
  21. package/lib/video-id.mjs +5 -1
  22. package/lib/video.d.mts +6 -2
  23. package/lib/video.mjs +5 -1
  24. package/lib/without-h1.d.mts +1 -0
  25. package/lib/without-h1.mjs +19 -4
  26. package/lib/without-h1.test.mjs +30 -1
  27. package/package.json +3 -2
  28. package/scripts/app-screenshots.mjs +2 -1
  29. package/scripts/check-content.mjs +2 -1
  30. package/scripts/check-site-types.mjs +26 -0
  31. package/scripts/site-types.mjs +14 -0
  32. package/scripts/site-types.test.mjs +21 -0
  33. package/site/activeTheme.test.mjs +52 -0
  34. package/site/appSelf.d.mts +2 -0
  35. package/site/appSelf.mjs +23 -0
  36. package/site/appSelf.test.mjs +35 -0
  37. package/site/content.ts +5 -2
  38. package/site/siteModules.mjs +2 -1
  39. package/site/theme.mjs +19 -8
  40. package/src/App.tsx +1 -1
  41. package/src/AppList.tsx +24 -9
  42. package/src/BlockDialog.tsx +38 -17
  43. package/src/CreateFab.tsx +8 -0
  44. package/src/DataForm.tsx +7 -17
  45. package/src/EntryForm.tsx +1 -1
  46. package/src/FormFrame.tsx +4 -3
  47. package/src/Library.tsx +4 -11
  48. package/src/ListFab.tsx +13 -0
  49. package/src/MarkdownEditor.tsx +88 -47
  50. package/src/Media.tsx +4 -13
  51. package/src/Redirects.tsx +6 -14
  52. package/src/Screen.tsx +13 -3
  53. package/src/StartFab.tsx +5 -7
  54. package/src/ThemeStore.tsx +33 -12
  55. package/src/apps.ts +2 -3
  56. package/src/drawerYield.d.mts +3 -0
  57. package/src/drawerYield.mjs +10 -0
  58. package/src/drawerYield.test.mjs +22 -0
  59. package/src/editor.css +55 -22
  60. package/src/fabScreens.test.mjs +28 -0
  61. package/src/fabScreens.ts +16 -0
  62. package/src/legacyMove.test.mjs +137 -0
  63. package/src/legacyMove.ts +131 -0
  64. package/src/markdown.mjs +10 -7
  65. package/src/markdown.test.mjs +16 -2
  66. package/src/menuOpen.ts +20 -4
  67. package/src/movedRoutes.mjs +2 -2
  68. package/src/movedRoutes.test.mjs +5 -0
  69. package/src/pageH1Panel.d.mts +4 -0
  70. package/src/pageH1Panel.mjs +31 -0
  71. package/src/pageH1Panel.test.mjs +56 -0
  72. package/src/pagePreview.tsx +22 -12
  73. package/src/sectionFrame.ts +3 -3
  74. package/src/sectionState.d.mts +3 -3
  75. package/src/sectionState.mjs +6 -6
  76. package/src/sectionState.test.mjs +14 -2
  77. package/src/sheetPlacement.d.mts +6 -4
  78. package/src/sheetPlacement.mjs +15 -10
  79. package/src/sheetPlacement.test.mjs +16 -9
  80. package/src/sidePanel.ts +38 -0
  81. package/src/store.ts +32 -27
  82. package/src/topBarSlot.tsx +7 -15
  83. package/src/ui.tsx +26 -15
  84. package/src/virtual.d.ts +6 -0
  85. package/starter/package.json +1 -1
  86. package/theme/components/PageHeading.astro +22 -0
  87. package/theme/layouts/PageLayout.astro +37 -20
  88. package/theme/styles/global.css +2 -1
  89. package/theme/styles/ui.css +40 -10
package/src/Redirects.tsx CHANGED
@@ -2,7 +2,7 @@
2
2
  // each has, each opening a screen of its own.
3
3
  // "Alle videresendelser" (#seo/videresendelser/alle): every address of a page the site sends on elsewhere
4
4
  // (redirectData.ts), by address, with where it sends to, and search. Tapping one opens a sheet where
5
- // where it sends to can be changed, or the redirect deleted; "+" adds a new one (in the editor's own,
5
+ // where it sends to can be changed, or the redirect deleted; the FAB adds a new one (in the editor's own,
6
6
  // src/data/redirects.json). Each save is one commit, and none sends on through another afterwards
7
7
  // (flatten): a redirect to an address that is sent on goes straight to where that ends, and those
8
8
  // that went on through a deleted one go on to where it sent them.
@@ -13,16 +13,16 @@
13
13
  // letter) and, over the redirects, the funnel that shows only those to one page or post, picked as a
14
14
  // chip. The Save and Delete buttons show the loading indicator while their save runs (Forget does not).
15
15
  import { useEffect, useMemo, useRef, useState } from 'react';
16
- import { ArrowRight, ArrowsDownUp, Funnel, LinkBreak, Plus, Signpost, Trash, X } from '@phosphor-icons/react';
16
+ import { ArrowRight, ArrowsDownUp, Funnel, LinkBreak, Signpost, Trash, X } from '@phosphor-icons/react';
17
17
  import { addressOf, escape, REDIRECTS } from './addresses';
18
18
  import { ask } from './ask';
19
+ import { CreateFab } from './CreateFab';
19
20
  import Screen from './Screen';
20
21
  import {
21
22
  Button,
22
23
  InputChip,
23
24
  Choice,
24
25
  Group,
25
- IconButton,
26
26
  listClass,
27
27
  SupportingText,
28
28
  SearchRow,
@@ -279,17 +279,6 @@ export default function Redirects({ env, tab }: { env: Env; tab?: Tab }) {
279
279
  ? t.redirects.count(entries.length)
280
280
  : undefined
281
281
  }
282
- actions={
283
- tab === 'redirects' && (
284
- <IconButton
285
- icon={Plus}
286
- label={t.redirects.new}
287
-
288
- disabled={!files}
289
- onClick={() => open({ from: '', to: '', isNew: true })}
290
- />
291
- )
292
- }
293
282
  >
294
283
  <p className="mb-4 text-muted">{tab === 'redirects' ? t.redirects.intro : t.redirects.missing.intro}</p>
295
284
  <SearchRow
@@ -449,6 +438,9 @@ export default function Redirects({ env, tab }: { env: Env; tab?: Tab }) {
449
438
  </SheetBody>
450
439
  )}
451
440
  </BottomSheet>
441
+ {tab === 'redirects' && (
442
+ <CreateFab label={t.redirects.new} disabled={!files} onClick={() => open({ from: '', to: '', isNew: true })} />
443
+ )}
452
444
  </Screen>
453
445
  );
454
446
  }
package/src/Screen.tsx CHANGED
@@ -2,9 +2,10 @@
2
2
  // topBarSlot.tsx's place, at the top of the content), above the content. Below medium, where there is no rail, the panel
3
3
  // button at its start opens and closes the main drawer (MainMenu.tsx, menuOpen.ts; from medium it is the rail's; not on a screen with a back arrow); at expanded and wider the logo and the site's name after it (one link to the start screen,
4
4
  // through `back.guard`), below expanded the back arrow, the title and the status as in an app (the logo only on the start
5
- // screen); at the end the actions and the notifications, on every screen, and on the start screen (`home`) the link to the
5
+ // screen); at the end the page actions (below expanded), the actions and the notifications, on every screen, and on the start screen (`home`) the link to the
6
6
  // website. At expanded and wider the way here as breadcrumbs (the screen above, when that is not the start
7
- // screen), the title and the status stand in a row under the bar, at the top of the content. The bar stays at the top while
7
+ // screen), the title and the status stand in a row under the bar, at the top of the content, and the page actions
8
+ // (`pageActions`) at the end of that row. The bar stays at the top while
8
9
  // scrolling and keeps clear of a phone's notch. A screen's tabs (`tabs`) are the bottom of the bar, under the title, as wide
9
10
  // as the content, and stay with it. The back arrow goes to the screen above, back in the history when that is the screen
10
11
  // before (nav.ts up). The content is as wide as a form reads well, or wider for lists and the grid of files (`wide`) and for
@@ -38,6 +39,9 @@ type Props<T extends string> = {
38
39
  back?: Back;
39
40
  status?: ReactNode;
40
41
  actions?: ReactNode;
42
+ /** The screen's own actions (a form's Preview and View on site): from expanded at the end of the row under the bar,
43
+ * below expanded in the top bar, before `actions`. */
44
+ pageActions?: ReactNode;
41
45
  drawer?: ReactNode;
42
46
  tabs?: TabChoice<T>;
43
47
  home?: boolean;
@@ -74,6 +78,7 @@ export default function Screen<T extends string>({
74
78
  back,
75
79
  status,
76
80
  actions,
81
+ pageActions,
77
82
  drawer,
78
83
  tabs,
79
84
  home = false,
@@ -135,7 +140,10 @@ export default function Screen<T extends string>({
135
140
  )}
136
141
  </div>
137
142
  <div className="hidden min-w-0 flex-1 expanded:block" />
138
- <div className="flex shrink-0 items-center justify-end gap-1">{actions}</div>
143
+ <div className="flex shrink-0 items-center justify-end gap-1">
144
+ {pageActions && <div className="flex items-center gap-1 expanded:hidden">{pageActions}</div>}
145
+ {actions}
146
+ </div>
139
147
  <NotificationsLink />
140
148
  {home && <WebsiteLink />}
141
149
  {drawer}
@@ -169,6 +177,8 @@ export default function Screen<T extends string>({
169
177
  )}
170
178
  <h1 className="truncate font-medium">{title}</h1>
171
179
  {status && <p className="shrink-0 text-sm text-muted">{status}</p>}
180
+ {/* The icons are taller than the text: the negative margin keeps the row's height. */}
181
+ {pageActions && <div className="-my-3 ml-auto flex shrink-0 items-center gap-1">{pageActions}</div>}
172
182
  </div>
173
183
  {tabs ? (
174
184
  <TabPanel id={id} value={tabs.value}>
package/src/StartFab.tsx CHANGED
@@ -1,17 +1,15 @@
1
1
  // The start screen's FAB menu (Start in Library.tsx): a new page and a new blog post, each opening its empty form
2
- // (#ny/<list>), as the + on its list does. Exposes newOnStart (the actions) and StartFab. Uses FabMenu (ui.tsx) and the
3
- // collections and icons of site.ts.
2
+ // (#ny/<list>), as the FAB on its list does. Exposes newOnStart (the actions) and StartFab. Uses FabMenu (ui.tsx),
3
+ // START_FAB (fabScreens.ts) and the collections and icons of site.ts.
4
4
  import { Plus } from '@phosphor-icons/react';
5
5
  import { t } from './i18n';
6
+ import { START_FAB } from './fabScreens';
6
7
  import { collections, icons } from './site';
7
8
  import { FabMenu, type FabAction } from './ui';
8
9
 
9
- /** The lists a new entry can be made in from the start screen, in this order. */
10
- const ON_START = ['pages', 'blog'];
11
-
12
- /** The start screen's actions: the lists of ON_START the site has and can make a new entry in (`create`). */
10
+ /** The start screen's actions: the lists of START_FAB the site has and can make a new entry in (`create`). */
13
11
  export function newOnStart(): FabAction[] {
14
- return ON_START.flatMap((id) => {
12
+ return START_FAB.flatMap((id) => {
15
13
  const c = collections.find((x) => x.id === id);
16
14
  return c?.create ? [{ icon: icons[id] ?? Plus, label: c.create, href: `#ny/${id}` }] : [];
17
15
  });
@@ -21,6 +21,8 @@ import { iconPacks, packOfTheme } from './iconPacks';
21
21
  import { theme, themes, type ThemeInfo } from './themes';
22
22
  import {
23
23
  changedFiles,
24
+ legacyOf,
25
+ moveChanges,
24
26
  newer,
25
27
  notesSince,
26
28
  STANDARD_THEME,
@@ -63,8 +65,12 @@ export function useThemeCatalog(env: Env): ShownTheme[] | null | 'failed' {
63
65
  // version updated to, until the site's build has it (and the editor reloads with it, deploy.ts keepCurrent).
64
66
  const changed = new Map<string, string>();
65
67
 
66
- /** Whether a theme is in the site now: installed and not uninstalled since, or installed since. */
67
- export const themeInstalled = (id: string) => (changed.has(id) ? changed.get(id) !== 'uninstalled' : themes.some((x) => x.id === id));
68
+ const themeHere = (id: string) => (changed.has(id) ? changed.get(id) !== 'uninstalled' : themes.some((x) => x.id === id));
69
+
70
+ /** Whether a theme is in the site now: installed and not uninstalled since, or installed since; or, with its `legacy` id,
71
+ * a copy under that old id from the store (legacyOf). */
72
+ export const themeInstalled = (id: string, legacy?: string) =>
73
+ themeHere(id) || (!!legacy && legacyOf({ legacy }, themes) !== null && themeHere(legacy));
68
74
 
69
75
  /** A theme installed from the store that the site's build does not have yet. */
70
76
  export const themePending = (id: string) => changed.get(id) === 'installed' && !themes.some((x) => x.id === id);
@@ -74,7 +80,7 @@ const versionOf = (x: ThemeInfo) => (changed.has(x.id) && changed.get(x.id) !==
74
80
 
75
81
  /** The newer version of a theme from the store, when the store has one. */
76
82
  export const themeUpdateOf = (x: ThemeInfo, catalog: ShownTheme[] | null | 'failed') => {
77
- const found = x.store && Array.isArray(catalog) ? catalog.find((c) => c.id === x.id) : undefined;
83
+ const found = x.store && Array.isArray(catalog) ? catalog.find((c) => c.id === x.id || c.legacy === x.id) : undefined;
78
84
  return found && newer(found.version, versionOf(x)) ? found : undefined;
79
85
  };
80
86
 
@@ -124,11 +130,13 @@ function useThemeActions(env: Env) {
124
130
  text: { busy: string; done: string; commit: string; change: string },
125
131
  next: () => Promise<Parameters<typeof themeChanges>[2]>,
126
132
  go: (current: RepoFile[]) => Promise<boolean> = async () => true,
133
+ // The id the theme is installed under, when it is a legacy copy that moves to `id` (moveChanges).
134
+ legacy: string | null = null,
127
135
  ): Promise<boolean> {
128
136
  setRunning({ id, action, asking: false });
129
137
  env.notify({ text: text.busy });
130
138
  try {
131
- const [current, wanted] = await Promise.all([env.folder(themeFolder(id)), next()]);
139
+ const [current, wanted] = await Promise.all([env.folder(themeFolder(legacy ?? id)), next()]);
132
140
  if (!(await go(current))) {
133
141
  env.notify(null);
134
142
  return false;
@@ -136,7 +144,11 @@ function useThemeActions(env: Env) {
136
144
  // `go` cleared the message (and its dialog stopped the button being busy): saving goes on.
137
145
  setRunning({ id, action, asking: false });
138
146
  env.notify({ text: text.busy });
139
- await env.commit(text.commit, themeChanges(id, current, wanted), text.change);
147
+ await env.commit(
148
+ text.commit,
149
+ legacy && wanted ? moveChanges({ id }, legacy, wanted, current, env.files) : themeChanges(id, current, wanted),
150
+ text.change,
151
+ );
140
152
  env.notify({ text: text.done, kind: 'saved' });
141
153
  return true;
142
154
  } catch (e) {
@@ -165,7 +177,7 @@ function useThemeActions(env: Env) {
165
177
  if (done) changed.set(x.id, 'installed');
166
178
  return done;
167
179
  },
168
- async update(x: ShownTheme, record: StoreRecord) {
180
+ async update(x: ShownTheme, record: StoreRecord, legacy: string | null = null) {
169
181
  const done = await change(
170
182
  x.id,
171
183
  'update',
@@ -190,11 +202,15 @@ function useThemeActions(env: Env) {
190
202
  record.version,
191
203
  x.version,
192
204
  notesSince(x.releaseNotes, record.version, x.version),
193
- changedFiles(x.id, record, current, themeFolder(x.id)),
205
+ changedFiles(x.id, record, current, themeFolder(legacy ?? x.id)),
194
206
  );
195
207
  },
208
+ legacy,
196
209
  );
197
- if (done) changed.set(x.id, x.version);
210
+ if (done && legacy) {
211
+ changed.set(x.id, 'installed');
212
+ changed.set(legacy, 'uninstalled');
213
+ } else if (done) changed.set(x.id, x.version);
198
214
  return done;
199
215
  },
200
216
  async uninstall(id: string, name: string) {
@@ -221,11 +237,12 @@ function useThemeActions(env: Env) {
221
237
  export function allThemes(catalog: ShownTheme[] | null | 'failed') {
222
238
  const store = Array.isArray(catalog) ? catalog : [];
223
239
  return [
224
- ...store.map((x) => ({ id: x.id, name: x.name, author: x.author, version: x.version, icon: x.icon, words: x.words })),
240
+ ...store.map((x) => ({ id: x.id, legacy: x.legacy, name: x.name, author: x.author, version: x.version, icon: x.icon, words: x.words })),
225
241
  ...themes
226
- .filter((x) => !store.some((s) => s.id === x.id))
242
+ .filter((x) => !store.some((s) => s.id === x.id || s.legacy === x.id))
227
243
  .map((x) => ({
228
244
  id: x.id,
245
+ legacy: undefined as string | undefined,
229
246
  name: x.name,
230
247
  author: x.author,
231
248
  version: x.version,
@@ -247,7 +264,7 @@ export function ThemeRow({ of }: { of: ReturnType<typeof allThemes>[number] }) {
247
264
  headline={of.name}
248
265
  supporting={t.library.themeByline(of.version, of.author)}
249
266
  truncate
250
- count={themeInstalled(of.id) && (themePending(of.id) ? t.apps.pending : t.apps.installed)}
267
+ count={themeInstalled(of.id, of.legacy) && (themePending(of.id) ? t.apps.pending : t.apps.installed)}
251
268
  arrow
252
269
  />
253
270
  );
@@ -363,7 +380,11 @@ export function ThemeStoreButtons({ of, env, files, top }: { of: ThemeInfo; env:
363
380
  if (top)
364
381
  return update ? (
365
382
  <div className="mb-8">
366
- <Button disabled={actions.saving} busy={actions.busy(of.id, 'update')} onClick={() => actions.update(update, of.store!)}>
383
+ <Button
384
+ disabled={actions.saving}
385
+ busy={actions.busy(update.id, 'update')}
386
+ onClick={() => actions.update(update, of.store!, legacyOf(update, themes))}
387
+ >
367
388
  {t.apps.update(update.version)}
368
389
  </Button>
369
390
  </div>
package/src/apps.ts CHANGED
@@ -251,9 +251,8 @@ export function kindsOffered(
251
251
  export const tabsOf = (list: string, apps: SiteApp[], installed: string[]) =>
252
252
  apps.flatMap((app) => (installed.includes(app.id) && app.tabs?.[list] ? [{ app, tab: app.tabs[list] }] : []));
253
253
 
254
- /** The id of an app from the path of a file in its folder ("/src/apps/mailchimp/index.ts": "mailchimp",
255
- * also ".../apps/mailchimp/popups/View.tsx"). */
256
- export const appIdOf = (path: string) => path.split('/apps/')[1].split('/')[0];
254
+ // The id of an app from the path of a file in its folder (lib/appId.mjs, also read by the @app/self plugin).
255
+ export { appIdOf } from '../lib/appId.mjs';
257
256
 
258
257
  /** The file of an app's settings (config.apps.settings/<id>.json). */
259
258
  export const settingsFile = (id: string) => `${config.apps.settings}/${id}.json`;
@@ -0,0 +1,3 @@
1
+ /** Whether the standard drawer shows open: remembered (sidebar.ts), panelOpen (the standard side panel is open), large (window
2
+ * at least `large`), reopened (the menu button opened it while the panel is open). */
3
+ export function drawerShown(remembered: any, panelOpen: any, large: any, reopened: any): boolean;
@@ -0,0 +1,10 @@
1
+ // Whether the standard navigation drawer shows open while the standard side panel may be open beside the text (840-1199px:
2
+ // both do not fit). Exposes drawerShown(remembered, panelOpen, large, reopened); used by menuOpen.ts; no dependencies.
3
+
4
+ /** Whether the standard drawer shows open: remembered (sidebar.ts), panelOpen (the standard side panel is open), large (window
5
+ * at least `large`), reopened (the menu button opened it while the panel is open). */
6
+ export function drawerShown(remembered, panelOpen, large, reopened) {
7
+ if (!remembered && !reopened) return false;
8
+ if (panelOpen && !large && !reopened) return false;
9
+ return true;
10
+ }
@@ -0,0 +1,22 @@
1
+ // Tests for drawerYield.mjs: the standard drawer yields to the side panel below large, unless the menu button reopened it.
2
+ import test from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import { drawerShown } from './drawerYield.mjs';
5
+
6
+ test('the drawer shows as remembered while no panel is open', () => {
7
+ assert.equal(drawerShown(true, false, false, false), true);
8
+ assert.equal(drawerShown(false, false, true, false), false);
9
+ });
10
+
11
+ test('the drawer yields while the panel is open below large', () => {
12
+ assert.equal(drawerShown(true, true, false, false), false);
13
+ assert.equal(drawerShown(false, true, false, false), false);
14
+ });
15
+
16
+ test('the drawer shows again when the menu button reopened it', () => {
17
+ assert.equal(drawerShown(true, true, false, true), true);
18
+ });
19
+
20
+ test('the drawer never yields from large', () => {
21
+ assert.equal(drawerShown(true, true, true, false), true);
22
+ });
package/src/editor.css CHANGED
@@ -4,6 +4,10 @@
4
4
  @reference '../theme/styles/base.css';
5
5
 
6
6
  :where(:root) {
7
+ /* The menus and the toolbar of the text: Material 3's menu surface and shadow (elevation level 2). */
8
+ --editor-menu-surface: var(--surface-container-lowest);
9
+ --editor-menu-shadow: var(--elevation-2);
10
+
7
11
  /* How a check went (CheckList: the SEO rating), as PageSpeed shows it: the mark before a check that is fine and one
8
12
  that can be done better, and the score's ring (from 90 good, from 50 average, else bad) with its number and the
9
13
  light circle in it; the same in every theme. */
@@ -33,13 +37,14 @@
33
37
  --editor-section-rhythm: 0.625em;
34
38
 
35
39
  /* A form's "on this page" area (SectionPane in ui.tsx) against the window's end edge: its width (the card is as wide as the navigation drawer), the card in it
36
- stands --section-pane-gap in from the end edge and from under the top bar. */
40
+ stands --section-pane-gap in from the end edge and from the window's top edge. */
37
41
  --section-pane-width: calc(var(--navigation-drawer-width, 22.5rem) + var(--section-pane-gap));
38
42
  --section-pane-gap: 2rem;
39
43
 
40
- /* The side panel of a block's fields and the section settings (BottomSheet `panel` in ui.tsx) on large and wider: half the
41
- space beside the start panel, the content column taking the other half (see below). */
42
- --side-panel-width: calc((100vw - var(--column-start)) / 2);
44
+ /* The side panel of a block's fields and the section settings (BlockDialog's standard side sheet from expanded, BottomSheet
45
+ `panel` in ui.tsx from large): M3's side sheet is 400dp (25rem) wide at most; where the window is narrower it is half
46
+ the space beside the start panel, the content column taking the other half (see below). */
47
+ --side-panel-width: min(25rem, calc((100vw - var(--column-start)) / 2));
43
48
  }
44
49
 
45
50
  /* In dark mode the score's number in the light colours, which are read on the dark, and Google's dark colours. */
@@ -403,10 +408,25 @@
403
408
  z-index: 30;
404
409
  }
405
410
 
411
+ /* A FAB whose action runs or cannot be used now (FabAction.busy, .disabled): dimmed, and nothing to press. */
412
+ .editor-fab :is(.extended-fab, .fab):disabled {
413
+ opacity: 0.38;
414
+ pointer-events: none;
415
+ }
416
+
406
417
  :root:has(.editor-fab) .snackbar-stack {
407
418
  bottom: calc(6rem + env(safe-area-inset-bottom));
408
419
  }
409
420
 
421
+ /* While a form's save bar is up (FormFrame.tsx) the FAB stands above it, so it does not cover Save; the messages above both. */
422
+ :root:has(.editor-save-bar) .editor-fab {
423
+ bottom: calc(6.5rem + env(safe-area-inset-bottom));
424
+ }
425
+
426
+ :root:has(.editor-save-bar):has(.editor-fab) .snackbar-stack {
427
+ bottom: calc(11.5rem + env(safe-area-inset-bottom));
428
+ }
429
+
410
430
  /* From medium the navigation rail stands at the start edge of the window (MainMenu.tsx), so the messages are in the middle of
411
431
  the content next to it and the save bar starts where it ends; at expanded and wider, while the standard drawer is open
412
432
  in the rail's place, they keep clear of the drawer instead. The widths are the theme's. The top bar and its tabs (Screen.tsx)
@@ -461,8 +481,8 @@
461
481
  not less than nothing and not more than the space left between the panels less the column. Only when that space is
462
482
  smaller than the column does it get narrower. (Measured against the window's width, 100vw, scrollbar and all.) Below
463
483
  medium nothing stands beside the content and the column is centred by auto margins. While the side panel is open (see
464
- the end of this block) the panel takes the end half of the space beside the start panel and the column the other half:
465
- --column-width stays the column's most, so on a very wide window it is narrower than its half. */
484
+ the end of this block) the column is centred in the space between the start panel and the side panel by auto margins
485
+ (the content's end padding keeps it clear of the panel), still no wider than --column-width. */
466
486
  :root {
467
487
  --column-start: 0px;
468
488
  --column-end: 0px;
@@ -504,25 +524,26 @@
504
524
  right: var(--section-pane-width);
505
525
  }
506
526
 
507
- /* BottomSheet's `panel` (.editor-side-panel): the theme's modal side sheet at the window's end edge under the top bar,
508
- the full remaining height; its backdrop is clear. */
527
+ /* BottomSheet's `panel` (.editor-side-panel): the theme's modal side sheet at the window's end edge, the full window
528
+ height like the standard one; its backdrop is clear. */
509
529
  .editor-side-panel {
510
- inset: var(--top-bar-height) 0 0 auto;
530
+ inset: 0 0 0 auto;
511
531
  width: var(--side-panel-width);
512
- height: calc(100dvh - var(--top-bar-height));
532
+ height: 100dvh;
513
533
  }
514
534
  .editor-side-panel:dir(rtl) {
515
- inset: var(--top-bar-height) auto 0 0;
535
+ inset: 0 auto 0 0;
516
536
  }
517
537
  .editor-side-panel::backdrop {
518
538
  background: transparent;
519
539
  }
520
540
  }
521
541
 
522
- /* The block dialog's standard side sheet (.editor-side-sheet: BlockDialog.tsx, not modal, from medium) stands at the window's
523
- end edge under the top bar, the full remaining height. BlockDialog.tsx sets [data-side-panel] on the root while it is
524
- open: it takes the "on this page" area's place (that is hidden), and the content, which stays in use, keeps clear of it. */
525
- @media (width >= theme(--breakpoint-medium)) {
542
+ /* The block dialog's standard side sheet (.editor-side-sheet: BlockDialog.tsx, not modal, from expanded) stands at the window's
543
+ end edge from the top of the window to its bottom (M3), with its divider the whole height. BlockDialog.tsx sets
544
+ [data-side-panel] on the root while it is open: the top bar (.editor-top-bar-slot, topBarSlot.tsx) ends at the sheet's
545
+ start edge, it takes the "on this page" area's place (that is hidden), and the content, which stays in use, keeps clear of it. */
546
+ @media (width >= theme(--breakpoint-expanded)) {
526
547
  :root[data-side-panel] {
527
548
  --column-end: var(--side-panel-width);
528
549
  }
@@ -532,19 +553,25 @@
532
553
  :root[data-side-panel] .editor-content {
533
554
  padding-inline-end: var(--side-panel-width);
534
555
  }
556
+ :root[data-side-panel] .editor-column {
557
+ margin-inline: auto;
558
+ }
535
559
  :root[data-side-panel] .editor-save-bar {
536
560
  right: var(--side-panel-width);
537
561
  }
538
562
  .editor-side-sheet {
539
563
  position: fixed;
540
- inset: var(--top-bar-height) 0 0 auto;
564
+ inset: 0 0 0 auto;
541
565
  z-index: 20;
542
566
  width: var(--side-panel-width);
543
567
  max-width: none;
544
- height: calc(100dvh - var(--top-bar-height));
568
+ height: 100dvh;
545
569
  }
546
570
  .editor-side-sheet:dir(rtl) {
547
- inset: var(--top-bar-height) auto 0 0;
571
+ inset: 0 auto 0 0;
572
+ }
573
+ :root[data-side-panel] .editor-top-bar-slot {
574
+ padding-inline-end: var(--side-panel-width);
548
575
  }
549
576
  }
550
577
 
@@ -632,9 +659,9 @@ html {
632
659
  gap: 0.125rem;
633
660
  border: 1px solid var(--line);
634
661
  border-radius: 9999px;
635
- background: var(--surface);
662
+ background: var(--editor-menu-surface);
636
663
  padding: 0.25rem;
637
- box-shadow: var(--shadow);
664
+ box-shadow: var(--editor-menu-shadow);
638
665
  }
639
666
 
640
667
  .editor-text .site-block-menu button {
@@ -656,6 +683,12 @@ html {
656
683
  color: var(--accent);
657
684
  }
658
685
 
686
+ /* The text's toolbar (MarkdownEditor.tsx, Toolbar in ui.tsx): the menu surface and shadow. */
687
+ .editor-tools {
688
+ --toolbar-standard-surface: var(--editor-menu-surface);
689
+ box-shadow: var(--editor-menu-shadow);
690
+ }
691
+
659
692
  /* The icons over the selected text, on a phone at the bottom over the keyboard (selectionMenu in
660
693
  MarkdownEditor.tsx). */
661
694
  .editor-selection-menu {
@@ -665,9 +698,9 @@ html {
665
698
  gap: 0.125rem;
666
699
  border: 1px solid var(--line);
667
700
  border-radius: 9999px;
668
- background: var(--surface);
701
+ background: var(--editor-menu-surface);
669
702
  padding: 0.25rem;
670
- box-shadow: var(--shadow);
703
+ box-shadow: var(--editor-menu-shadow);
671
704
  }
672
705
 
673
706
  .editor-selection-menu[hidden],
@@ -0,0 +1,28 @@
1
+ // Tests for src/fabScreens.ts: the start screen's FAB menu and the create actions of a list. Run: node --test src/fabScreens.test.mjs
2
+ import assert from 'node:assert/strict';
3
+ import { test } from 'node:test';
4
+ import { START_FAB, listFab } from './fabScreens.ts';
5
+
6
+ const standard = { value: 'standard', label: 'Standard' };
7
+ const mailchimp = { value: 'mailchimp', label: 'Mailchimp' };
8
+
9
+ test('the start screen makes a page or a blog post', () => {
10
+ assert.deepEqual(START_FAB, ['pages', 'blog']);
11
+ });
12
+
13
+ test('a list without kinds, or with one, has one action named after its create label', () => {
14
+ assert.deepEqual(listFab({ id: 'pages', create: 'New page' }, []), [{ label: 'New page', href: '#ny/pages' }]);
15
+ assert.deepEqual(listFab({ id: 'popups', create: 'New promotion' }, [standard]), [{ label: 'New promotion', href: '#ny/popups' }]);
16
+ });
17
+
18
+ test('a list of several kinds has one action per kind', () => {
19
+ assert.deepEqual(listFab({ id: 'popups', create: 'New promotion' }, [standard, mailchimp]), [
20
+ { label: 'Standard', href: '#ny/popups/standard' },
21
+ { label: 'Mailchimp', href: '#ny/popups/mailchimp' },
22
+ ]);
23
+ });
24
+
25
+ test('a list that cannot make entries has no action', () => {
26
+ assert.deepEqual(listFab({ id: 'orders' }, []), []);
27
+ assert.deepEqual(listFab({ id: 'orders' }, [standard, mailchimp]), []);
28
+ });
@@ -0,0 +1,16 @@
1
+ // What the create FAB offers: the start screen's menu (START_FAB, the collection ids) and a list's own (listFab: one
2
+ // action, or one per kind). Pure; the icons and the FAB itself are added by StartFab.tsx and ListFab.tsx.
3
+
4
+ /** The collections a new entry can be made in from the start screen, in this order: pages and blog posts. */
5
+ export const START_FAB = ['pages', 'blog'];
6
+
7
+ /** A list's create actions, as a label and where it goes: with several kinds one per kind (`#ny/<id>/<kind>`), else one
8
+ * named `create` (`#ny/<id>`); none for a list that cannot make entries (no `create`). */
9
+ export function listFab(
10
+ collection: { id: string; create?: string },
11
+ kinds: { value: string; label: string }[],
12
+ ): { label: string; href: string }[] {
13
+ if (!collection.create) return [];
14
+ if (kinds.length > 1) return kinds.map((k) => ({ label: k.label, href: `#ny/${collection.id}/${k.value}` }));
15
+ return [{ label: collection.create, href: `#ny/${collection.id}` }];
16
+ }