@morphemecms/cms 0.14.2-next-20261107121812 → 0.14.2

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/CHANGELOG.md CHANGED
@@ -1,10 +1,12 @@
1
1
  # @morphemecms/cms
2
2
 
3
- ## 0.14.2-next-20261107121812
3
+ ## 0.14.2
4
4
 
5
5
  ### Patch Changes
6
6
 
7
+ - fc6864a: On a phone, the editor's footer starts at the bottom of a short screen, and you can scroll it up above the add button.
7
8
  - 13dbbca: Lists follow Material 3's list spec: the label text in body large (16/24, weight 400), also beside a switch, and the editor's rows on Surface over a page in Surface Container.
9
+ - 5c21889: The editor of a test version (npm tag `next`, e.g. 0.14.2-next.0) loads again: the release notes it carries no longer compare a test version, which threw and left the page blank.
8
10
  - 3fbb65f: The start screen's update card shows the new version and its first news, and the Morpheme page shows the release notes as an article.
9
11
 
10
12
  ## 0.14.1
@@ -54,6 +54,8 @@ export function notesSince(entries: {
54
54
  * The notes of every released version in the running minor line (x.y.0 up to `version`), from the files the package carries
55
55
  * (release-notes/i18n/<locale>/<version>.json), newest first. A card gets the address of its screenshot
56
56
  * (<locale>/<version>/<id>.webp) when that file exists. Versions of another minor, newer versions and versions without notes are left out.
57
+ * A test version (0.14.2-next.0, the npm tag next) has not had the notes of its own version yet: the line up to the one before.
58
+ * It runs as the editor loads (bundledNotes.ts), so it never throws: nothing when `version` is not a version.
57
59
  * @param {Record<string, ReleasedNotes>} files path -> parsed notes file, e.g. '../release-notes/i18n/en-US/0.14.0.json'
58
60
  * @param {Record<string, string>} images path -> address, e.g. '../release-notes/i18n/en-US/0.14.0/<id>.webp'
59
61
  * @param {string} version the running version
@@ -180,18 +180,22 @@ const sameMinor = (a, b) => a.split('.').slice(0, 2).join('.') === b.split('.').
180
180
  * The notes of every released version in the running minor line (x.y.0 up to `version`), from the files the package carries
181
181
  * (release-notes/i18n/<locale>/<version>.json), newest first. A card gets the address of its screenshot
182
182
  * (<locale>/<version>/<id>.webp) when that file exists. Versions of another minor, newer versions and versions without notes are left out.
183
+ * A test version (0.14.2-next.0, the npm tag next) has not had the notes of its own version yet: the line up to the one before.
184
+ * It runs as the editor loads (bundledNotes.ts), so it never throws: nothing when `version` is not a version.
183
185
  * @param {Record<string, ReleasedNotes>} files path -> parsed notes file, e.g. '../release-notes/i18n/en-US/0.14.0.json'
184
186
  * @param {Record<string, string>} images path -> address, e.g. '../release-notes/i18n/en-US/0.14.0/<id>.webp'
185
187
  * @param {string} version the running version
186
188
  * @returns {{ version: string, text: string, notes: Record<string, ReleaseNote[]> }[]}
187
189
  */
188
190
  export function bundledNotes(files, images, version) {
191
+ const [base, pre] = String(version ?? '').split('-');
192
+ if (!PLAIN.test(base)) return [];
189
193
  const byVersion = {};
190
194
  for (const [path, file] of Object.entries(files)) {
191
195
  const match = /\/i18n\/([^/]+)\/([^/]+)\.json$/.exec(path);
192
196
  if (!match || !Array.isArray(file?.notes)) continue;
193
197
  const [, locale, v] = match;
194
- if (!PLAIN.test(v) || !sameMinor(v, version) || compareVersions(v, version) === 1) continue;
198
+ if (!PLAIN.test(v) || !sameMinor(v, base) || compareVersions(v, base) >= (pre === undefined ? 1 : 0)) continue;
195
199
  byVersion[v] ??= {};
196
200
  byVersion[v][locale] = file.notes.map((note) => {
197
201
  if (note?.kind !== 'card') return note;
@@ -224,6 +224,17 @@ test('bundledNotes ignores a newer version', () => {
224
224
  ['0.14.0'],
225
225
  );
226
226
  });
227
+ test('bundledNotes takes a test version (npm tag next) without throwing: the line before its own version', () => {
228
+ const files = { ...en('0.14.0', [line('a')]), ...en('0.14.1', [line('b')]), ...en('0.14.2', [line('c')]) };
229
+ assert.deepEqual(
230
+ bundledNotes(files, {}, '0.14.2-next.0').map((e) => e.version),
231
+ ['0.14.1', '0.14.0'],
232
+ );
233
+ assert.deepEqual(bundledNotes(files, {}, '0.15.0-next.3'), []);
234
+ });
235
+ test('bundledNotes gives nothing for something that is not a version', () => {
236
+ assert.deepEqual(bundledNotes(en('0.14.0', [line('a')]), {}, 'latest'), []);
237
+ });
227
238
  test('bundledNotes gives an empty list without a file for the line', () => {
228
239
  assert.deepEqual(bundledNotes(en('0.13.0', [line('a')]), {}, '0.14.0'), []);
229
240
  assert.deepEqual(bundledNotes({}, {}, '0.14.0'), []);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morphemecms/cms",
3
- "version": "0.14.2-next-20261107121812",
3
+ "version": "0.14.2",
4
4
  "description": "Morpheme CMS: the editor for a site built with Astro, its screens, its blocks and the rules a site gives it. See README.md.",
5
5
  "license": "MIT",
6
6
  "author": "Lars Adolfsen",
@@ -0,0 +1,21 @@
1
+ {
2
+ "version": "0.14.2",
3
+ "notes": [
4
+ {
5
+ "id": "footer-scrolls-above-fab",
6
+ "kind": "line",
7
+ "text": "På en telefon kan du nu rulle editorens bundlinje fri af plusknappen, også på korte skærme."
8
+ },
9
+ {
10
+ "id": "lists-follow-md3",
11
+ "kind": "line",
12
+ "text": "Lister i editoren viser nu lyse rækker på en tonet side, som i Material 3."
13
+ },
14
+ {
15
+ "id": "update-card-whats-new",
16
+ "kind": "card",
17
+ "title": "Nyhederne møder dig på startskærmen",
18
+ "text": "Efter en opdatering skal du ikke selv lede efter, hvad der er nyt. Øverst på startskærmen ser du nu den nye version og de vigtigste nyheder, med et billede af den største. Tryk på Se, hvad der er nyt for at se det hele."
19
+ }
20
+ ]
21
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "version": "0.14.2",
3
+ "notes": [
4
+ {
5
+ "id": "footer-scrolls-above-fab",
6
+ "kind": "line",
7
+ "text": "On a phone, you can now scroll the editor's footer clear of the add button, also on short screens."
8
+ },
9
+ {
10
+ "id": "lists-follow-md3",
11
+ "kind": "line",
12
+ "text": "Lists in the editor now show light rows on a tinted page, as in Material 3."
13
+ },
14
+ {
15
+ "id": "update-card-whats-new",
16
+ "kind": "card",
17
+ "title": "What's new meets you on the start screen",
18
+ "text": "After an update, you shouldn't have to look for what's new yourself. The top of the start screen now shows the new version and the main news, with a picture of the biggest one. Tap See what's new to see it all."
19
+ }
20
+ ]
21
+ }
package/src/Screen.tsx CHANGED
@@ -12,16 +12,17 @@
12
12
  // a form with its fields beside the text (`panel`). The column (editor.css .editor-column) stands centred on the window,
13
13
  // off-centre only as far as the panels beside it require.
14
14
  // Under the content of every screen is the editor's footer (EditorFooter.tsx), at the bottom of the window when the content
15
- // is short (the screen fills the column under the bar, TopBarLayout); under the footer is room for the floating action button
16
- // only while the screen has one (fabPresence.tsx) and the content pushes the footer below the window (useFooterClearance.ts).
17
- import { useId, useRef, type MouseEvent, type ReactNode } from 'react';
15
+ // is short (the screen fills the column under the bar, TopBarLayout). Below medium, while the screen has a floating action
16
+ // button (fabPresence.tsx), there is room for it under the footer (data-clearance): the page is that much taller than the
17
+ // window (--editor-fab-clearance, editor.css), so a short screen still has its footer at the bottom of the window and can
18
+ // be scrolled up to show it above the FAB.
19
+ import { useId, type MouseEvent, type ReactNode } from 'react';
18
20
  import { createPortal } from 'react-dom';
19
21
  import { ArrowLeft, Bell, CaretRight, ArrowSquareOut } from '@phosphor-icons/react';
20
22
  import { t } from './i18n';
21
23
  import { IconButton, IconLink, MenuButton, TabPanel, TabRow, TopAppBar, useMenuLayout, type TabChoice } from './ui';
22
24
  import { up } from './nav';
23
25
  import EditorFooter from './EditorFooter';
24
- import useFooterClearance from './useFooterClearance';
25
26
  import { FabPresenceContext, useCountFabs } from './fabPresence';
26
27
  import { useMenuOpen } from './menuOpen';
27
28
  import { unseen, useNotifications } from './notificationData';
@@ -120,9 +121,7 @@ export default function Screen<T extends string>({
120
121
  else way();
121
122
  };
122
123
  const slot = useTopBarSlot();
123
- const main = useRef<HTMLElement>(null);
124
124
  const fabs = useCountFabs();
125
- useFooterClearance(main, fabs.count > 0);
126
125
  // The way here, without the start screen: the logo in the bar is the way there.
127
126
  const crumbs = back && back.href !== '#' ? [back] : [];
128
127
  const bar = (
@@ -175,12 +174,12 @@ export default function Screen<T extends string>({
175
174
  </TopAppBar>
176
175
  );
177
176
  return (
178
- <div className={`flex flex-col ${slot ? 'flex-1' : 'min-h-dvh'}`}>
177
+ <div className={`flex flex-col ${slot ? 'flex-1' : 'min-h-[calc(100dvh+var(--editor-fab-clearance,0px))]'}`}>
179
178
  {slot ? createPortal(bar, slot) : bar}
180
179
  {/* A row, so the column keeps its margins (editor.css .editor-column) and is as high as the rest of the window. */}
181
180
  <div className="flex flex-1">
182
181
  <main
183
- ref={main}
182
+ data-clearance={fabs.count > 0}
184
183
  className={`editor-screen flex min-w-0 flex-1 flex-col px-4 pt-6 pb-[env(safe-area-inset-bottom)] data-[clearance=true]:pb-[calc(4.5rem+env(safe-area-inset-bottom))] medium:px-6 medium:pt-8 medium:pb-4 medium:data-[clearance=true]:pb-4 ${width}`}
185
184
  >
186
185
  {/* At expanded and wider: the way here, the title and the status, under the bar. */}
package/src/editor.css CHANGED
@@ -419,6 +419,15 @@
419
419
  z-index: 30;
420
420
  }
421
421
 
422
+ /* Below medium, while a screen leaves room for its FAB under the footer (Screen.tsx, data-clearance), the page is that room
423
+ taller than the window (the screen's padding less the bare one), so a short screen still has its footer at the bottom of
424
+ the window and can be scrolled up to show it above the FAB. */
425
+ @media (width < theme(--breakpoint-medium)) {
426
+ :root:has(.editor-screen[data-clearance='true']) {
427
+ --editor-fab-clearance: 4.5rem;
428
+ }
429
+ }
430
+
422
431
  /* A FAB whose action runs or cannot be used now (FabAction.busy, .disabled): dimmed, and nothing to press. */
423
432
  .editor-fab :is(.extended-fab, .fab):disabled {
424
433
  opacity: 0.38;
@@ -1,6 +1,6 @@
1
1
  // Lets a screen know it shows a FAB: Screen (Screen.tsx) provides the context, FabMenu (ui.tsx) calls useRegisterFab while
2
2
  // it is drawn. Exposes FabPresenceContext, useCountFabs and useRegisterFab. Used to leave room under the footer for the FAB
3
- // only (useFooterClearance.ts).
3
+ // only (Screen.tsx).
4
4
  import { createContext, useCallback, useContext, useEffect, useState } from 'react';
5
5
 
6
6
  /** Called with +1 when a FAB appears on the screen and -1 when it goes. Absent outside a Screen. */
@@ -19,8 +19,9 @@ export function TopBarLayout({ above, aside, children }: { above?: ReactNode; as
19
19
  {above}
20
20
  <div className="flex">
21
21
  {aside}
22
- {/* At least as high as the window, so a short screen's footer is at its bottom (Screen.tsx). */}
23
- <div className="flex min-h-dvh min-w-0 flex-1 flex-col">
22
+ {/* At least as high as the window, so a short screen's footer is at its bottom (Screen.tsx), and the room for a FAB
23
+ under the footer higher (--editor-fab-clearance, editor.css). */}
24
+ <div className="flex min-h-[calc(100dvh+var(--editor-fab-clearance,0px))] min-w-0 flex-1 flex-col">
24
25
  <div ref={setSlot} className="editor-top-bar-slot" />
25
26
  <Slot.Provider value={slot}>{slot && children}</Slot.Provider>
26
27
  </div>
@@ -32,7 +32,7 @@
32
32
  "@material-symbols/svg-400": "0.48.2",
33
33
  "@material/material-color-utilities": "^0.3.0",
34
34
  "@milkdown/kit": "^7.22.3",
35
- "@morphemecms/cms": "0.14.1",
35
+ "@morphemecms/cms": "0.14.2",
36
36
  "@phosphor-icons/core": "^2.1.1",
37
37
  "@phosphor-icons/react": "^2.1.10",
38
38
  "@tailwindcss/typography": "^0.5.20",
@@ -1 +0,0 @@
1
- export function needsClearance(pageHeight: number, windowHeight: number, hasFab: boolean): boolean;
@@ -1,9 +0,0 @@
1
- // Whether a screen needs clearance under its footer for the floating action button: only when it has one and its content
2
- // pushes the footer below the window, so a short screen has its footer at the very bottom. Exposes needsClearance. No imports.
3
-
4
- /**
5
- * @param {number} pageHeight The page's height with no clearance added.
6
- * @param {number} windowHeight The window's height.
7
- * @param {boolean} hasFab Whether the screen shows a FAB.
8
- */
9
- export const needsClearance = (pageHeight, windowHeight, hasFab) => hasFab && pageHeight > windowHeight;
@@ -1,24 +0,0 @@
1
- // Tests for lib/footerClearance.mjs: clearance under the footer only for a FAB, and only when the page is taller than the window.
2
- import test from 'node:test';
3
- import assert from 'node:assert/strict';
4
- import { needsClearance } from './footerClearance.mjs';
5
-
6
- test('a page that fits the window needs no clearance', () => {
7
- assert.equal(needsClearance(600, 800, true), false);
8
- });
9
-
10
- test('a page exactly as tall as the window needs none', () => {
11
- assert.equal(needsClearance(800, 800, true), false);
12
- });
13
-
14
- test('a tall page with a FAB needs clearance', () => {
15
- assert.equal(needsClearance(801, 800, true), true);
16
- });
17
-
18
- test('a tall page without a FAB needs none', () => {
19
- assert.equal(needsClearance(2000, 800, false), false);
20
- });
21
-
22
- test('a short page without a FAB needs none', () => {
23
- assert.equal(needsClearance(600, 800, false), false);
24
- });
@@ -1,5 +0,0 @@
1
- {
2
- "id": "lists-follow-md3",
3
- "kind": "line",
4
- "text": "Lister i editoren viser nu lyse rækker på en tonet side, som i Material 3."
5
- }
@@ -1,6 +0,0 @@
1
- {
2
- "id": "update-card-whats-new",
3
- "kind": "card",
4
- "title": "Nyhederne møder dig på startskærmen",
5
- "text": "Efter en opdatering skal du ikke selv lede efter, hvad der er nyt. Øverst på startskærmen ser du nu den nye version og de vigtigste nyheder, med et billede af den største. Tryk på Se, hvad der er nyt for at se det hele."
6
- }
@@ -1,5 +0,0 @@
1
- {
2
- "id": "lists-follow-md3",
3
- "kind": "line",
4
- "text": "Lists in the editor now show light rows on a tinted page, as in Material 3."
5
- }
@@ -1,6 +0,0 @@
1
- {
2
- "id": "update-card-whats-new",
3
- "kind": "card",
4
- "title": "What's new meets you on the start screen",
5
- "text": "After an update, you shouldn't have to look for what's new yourself. The top of the start screen now shows the new version and the main news, with a picture of the biggest one. Tap See what's new to see it all."
6
- }
@@ -1,30 +0,0 @@
1
- // Gives a screen's <main> data-clearance="true" while its content pushes the footer below the window (so the floating action
2
- // button, if the screen has one, has room under it) and "false" while the footer sits at the bottom of the window. Exposes useFooterClearance. It
3
- // measures the page with the clearance off, so the answer cannot flip back and forth; the decision is needsClearance in
4
- // lib/footerClearance.mjs. The attribute is set on the element, with no re-render.
5
- import { useLayoutEffect, type RefObject } from 'react';
6
- import { needsClearance } from '../lib/footerClearance.mjs';
7
-
8
- export default function useFooterClearance(ref: RefObject<HTMLElement | null>, hasFab: boolean) {
9
- useLayoutEffect(() => {
10
- const main = ref.current;
11
- if (!main) return;
12
- // The page's layout height (offsetHeight), not scrollHeight: that counts a screen's entrance animation, which moves it a few px.
13
- const measure = () => {
14
- main.dataset.clearance = 'false';
15
- const root = document.documentElement;
16
- main.dataset.clearance = String(needsClearance(document.body.offsetHeight, root.clientHeight, hasFab));
17
- };
18
- measure();
19
- const observer = new ResizeObserver(measure);
20
- // Watch the parts inside <main> and what lies beside it and its ancestors (the top bar), not <main> itself: their
21
- // size does not depend on the clearance, so a change in them is a real change of the page.
22
- for (let el: Element | null = main; el && el !== document.documentElement; el = el.parentElement)
23
- for (const part of el === main ? el.children : (el.parentElement?.children ?? [])) observer.observe(part);
24
- window.addEventListener('resize', measure);
25
- return () => {
26
- observer.disconnect();
27
- window.removeEventListener('resize', measure);
28
- };
29
- }, [ref, hasFab]);
30
- }