@phis/ui 0.4.0 → 0.4.1

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 (51) hide show
  1. package/components/controls/phi-table-control.d.ts +6 -1
  2. package/components/controls/phi-table-control.js +16 -4
  3. package/components/layouts/layout-definitions.d.ts +2 -2
  4. package/components/layouts/layout-definitions.js +6 -1
  5. package/components/shell/use-phi-shell-sticky-top.d.ts +5 -0
  6. package/components/shell/use-phi-shell-sticky-top.js +78 -0
  7. package/components/widgets/helpers/semantic-html-translation.d.ts +3 -1
  8. package/components/widgets/helpers/semantic-html-translation.js +8 -3
  9. package/components/widgets/navigation-descriptor-resolver.server.js +6 -1
  10. package/components/widgets/shared/markdown-render.js +15 -5
  11. package/helpers/text-source.d.ts +19 -0
  12. package/helpers/text-source.js +20 -0
  13. package/next/config.mjs +1 -1
  14. package/package.json +1 -1
  15. package/plugins/runtime-modules/auth/trees/phi-default-pub-confirm-page-tree.js +3 -0
  16. package/plugins/runtime-modules/auth/trees/phi-default-pub-login-page-tree.js +2 -0
  17. package/plugins/runtime-modules/auth/trees/phi-default-pub-registration-page-tree.js +3 -0
  18. package/plugins/runtime-modules/auth/trees/phi-default-pub-reset-password-page-tree.js +3 -0
  19. package/plugins/runtime-modules/builder/clients/widget-inspector.js +9 -0
  20. package/plugins/runtime-modules/builder/trees/phi-default-builder-area-preset-tree.js +4 -0
  21. package/plugins/runtime-modules/core/widgets/card/server.js +4 -2
  22. package/plugins/runtime-modules/core/widgets/description/config.d.ts +3 -0
  23. package/plugins/runtime-modules/core/widgets/description/config.js +2 -0
  24. package/plugins/runtime-modules/core/widgets/description/server.js +10 -12
  25. package/plugins/runtime-modules/core/widgets/entry-list/server.js +2 -2
  26. package/plugins/runtime-modules/core/widgets/html/server.js +1 -1
  27. package/plugins/runtime-modules/core/widgets/markdown-toc/server.js +7 -2
  28. package/plugins/runtime-modules/core/widgets/quick-links/config.d.ts +3 -0
  29. package/plugins/runtime-modules/core/widgets/quick-links/config.js +2 -0
  30. package/plugins/runtime-modules/core/widgets/quick-links/plugin.js +4 -2
  31. package/plugins/runtime-modules/core/widgets/quick-links/server.d.ts +5 -1
  32. package/plugins/runtime-modules/core/widgets/quick-links/server.js +4 -2
  33. package/plugins/runtime-modules/core/widgets/simple-text/config.d.ts +3 -0
  34. package/plugins/runtime-modules/core/widgets/simple-text/config.js +2 -0
  35. package/plugins/runtime-modules/core/widgets/simple-text/plugin.js +1 -1
  36. package/plugins/runtime-modules/core/widgets/simple-text/server.d.ts +4 -1
  37. package/plugins/runtime-modules/core/widgets/simple-text/server.js +3 -3
  38. package/plugins/runtime-modules/core/widgets/table/client.js +1 -1
  39. package/plugins/runtime-modules/core/widgets/table/config.d.ts +1 -0
  40. package/plugins/runtime-modules/core/widgets/table/config.js +3 -0
  41. package/plugins/runtime-modules/phi-default-site-area-preset-tree.js +12 -4
  42. package/plugins/runtime-modules/public/trees/phi-default-pub-contact-page-tree.js +2 -0
  43. package/plugins/runtime-modules/public/trees/phi-default-pub-unsubscribe-page-tree.js +3 -0
  44. package/plugins/runtime-modules/public/trees/phi-default-pub-welcome-page-tree.js +2 -1
  45. package/plugins/runtime-modules/revisions/trees/phi-default-builder-revisions-page-tree.js +4 -0
  46. package/server-helpers/translate.d.ts +9 -0
  47. package/server-helpers/translate.js +14 -0
  48. package/styles/controls.css +15 -1
  49. package/styles/root.css +20 -19
  50. package/types/core-widget-placements.d.ts +5 -0
  51. package/types/table-widget.d.ts +2 -0
@@ -122,6 +122,11 @@ export type PhiTableControlProps<TRow extends Record<string, unknown>> = {
122
122
  bordered?: boolean;
123
123
  striped?: boolean;
124
124
  showHeader?: boolean;
125
+ /**
126
+ * The header row stays at the top while the rows scroll under it: under the Shell's Header on a page,
127
+ * at the top edge of a Modal's or Drawer's body. `styles/controls.css` places it.
128
+ */
129
+ stickyHeader?: boolean;
125
130
  footer?: PhiTableControlFooter;
126
131
  summary?: PhiTableControlSummary;
127
132
  size?: PhiControlSize;
@@ -141,4 +146,4 @@ export type PhiTableControlProps<TRow extends Record<string, unknown>> = {
141
146
  onExternalDrop?: (event: DragEvent<HTMLDivElement>) => void;
142
147
  };
143
148
  export declare function readPhiTableControlValue(row: Record<string, unknown>, path: string): unknown;
144
- export declare function PhiTableControl<TRow extends Record<string, unknown>>({ rows, rowIdentityPath, columns, columnOrder, sortingMode, sorts, onSortsChange, columnReordering, onColumnOrderChange, rowSelection, pagination, tree, rowReordering, editing, loading, bordered, striped, showHeader, footer, summary, size, emptyText, layout, rowStyle, onRowActivate, onExternalRowDragOver, onExternalRowDrop, onExternalDragOver, onExternalDrop, }: PhiTableControlProps<TRow>): import("react").JSX.Element;
149
+ export declare function PhiTableControl<TRow extends Record<string, unknown>>({ rows, rowIdentityPath, columns, columnOrder, sortingMode, sorts, onSortsChange, columnReordering, onColumnOrderChange, rowSelection, pagination, tree, rowReordering, editing, loading, bordered, striped, stickyHeader, showHeader, footer, summary, size, emptyText, layout, rowStyle, onRowActivate, onExternalRowDragOver, onExternalRowDrop, onExternalDragOver, onExternalDrop, }: PhiTableControlProps<TRow>): import("react").JSX.Element;
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { usePhiShellStickyTop } from "../shell/use-phi-shell-sticky-top";
3
4
  import { Button, Flex, Table } from "antd";
4
5
  import { DndContext, KeyboardSensor, PointerSensor, closestCenter, useSensor, useSensors, } from "@dnd-kit/core";
5
6
  import { SortableContext, sortableKeyboardCoordinates, useSortable, } from "@dnd-kit/sortable";
@@ -354,7 +355,7 @@ function buildRowMove(rows, rowIdentityPath, parentRowIdentityPath, movedRowIden
354
355
  afterRowIdentity: placement === "after" ? targetRowIdentity : null,
355
356
  };
356
357
  }
357
- export function PhiTableControl({ rows, rowIdentityPath, columns, columnOrder, sortingMode, sorts, onSortsChange, columnReordering = false, onColumnOrderChange, rowSelection, pagination = false, tree, rowReordering, editing, loading = false, bordered, striped = false, showHeader, footer, summary, size, emptyText, layout, rowStyle, onRowActivate, onExternalRowDragOver, onExternalRowDrop, onExternalDragOver, onExternalDrop, }) {
358
+ export function PhiTableControl({ rows, rowIdentityPath, columns, columnOrder, sortingMode, sorts, onSortsChange, columnReordering = false, onColumnOrderChange, rowSelection, pagination = false, tree, rowReordering, editing, loading = false, bordered, striped = false, stickyHeader = false, showHeader, footer, summary, size, emptyText, layout, rowStyle, onRowActivate, onExternalRowDragOver, onExternalRowDrop, onExternalDragOver, onExternalDrop, }) {
358
359
  const { token } = usePhiConfig();
359
360
  const [editingRowIdentityState, setEditingRowIdentity] = useState(null);
360
361
  const [rowEditPatch, setRowEditPatch] = useState({});
@@ -988,6 +989,8 @@ export function PhiTableControl({ rows, rowIdentityPath, columns, columnOrder, s
988
989
  const hasClosingBlock = !loading &&
989
990
  (Boolean(footer) || (summary != null && summary.placement !== "sticky-top"));
990
991
  const frameRef = useRef(null);
992
+ // Where the Shell's Header ends, which is where a sticky header row stops (`styles/controls.css`).
993
+ usePhiShellStickyTop({ active: stickyHeader && showHeader !== false, elementRef: frameRef });
991
994
  const [headerBox, setHeaderBox] = useState(null);
992
995
  useEffect(() => {
993
996
  const frame = frameRef.current;
@@ -1007,11 +1010,15 @@ export function PhiTableControl({ rows, rowIdentityPath, columns, columnOrder, s
1007
1010
  observer.observe(frame);
1008
1011
  return () => observer.disconnect();
1009
1012
  }, [showHeader, loading]);
1010
- const table = (_jsx(Table, { className: ["phi-table-control", hasClosingBlock ? "phi-table-control--closing-block" : null]
1013
+ const table = (_jsx(Table, { className: [
1014
+ "phi-table-control",
1015
+ hasClosingBlock ? "phi-table-control--closing-block" : null,
1016
+ stickyHeader && showHeader !== false ? "phi-table-control--sticky-header" : null,
1017
+ ]
1011
1018
  .filter(Boolean)
1012
1019
  .join(" "), style: { "--phi-table-striped-row-background": token.colorFillAlter }, rowKey: (row) => loading
1013
1020
  ? `__phi_table_loading_${String(row[PHI_TABLE_LOADING_ROW])}`
1014
- : String(readIdentity(row, rowIdentityPath)), columns: renderedColumns, dataSource: loading ? loadingRows : dataSource, bordered: bordered, showHeader: showHeader, tableLayout: layout.mode, size: size, rowClassName: (_row, index) => striped && index % 2 === 1 ? "phi-table-control__row--striped" : "", rowSelection: loading ? undefined : antdRowSelection, components: rowReordering?.enabled && !loading ? { body: { row: PhiTableSortableRow } } : undefined, scroll: layout.overflowX === "auto" ? { x: true } : undefined, pagination: pagination === false ? false : {
1021
+ : String(readIdentity(row, rowIdentityPath)), columns: renderedColumns, dataSource: loading ? loadingRows : dataSource, bordered: bordered, showHeader: showHeader, tableLayout: layout.mode, size: size, rowClassName: (_row, index) => striped && index % 2 === 1 ? "phi-table-control__row--striped" : "", rowSelection: loading ? undefined : antdRowSelection, components: rowReordering?.enabled && !loading ? { body: { row: PhiTableSortableRow } } : undefined, scroll: layout.overflowX === "auto" ? { x: true } : undefined, sticky: stickyHeader && showHeader !== false ? true : undefined, pagination: pagination === false ? false : {
1015
1022
  ...{ [PHI_VISUAL_ANCHOR_PART_ATTRIBUTE]: "pagination" },
1016
1023
  current: pagination.page,
1017
1024
  pageSize: pagination.pageSize,
@@ -1097,7 +1104,12 @@ export function PhiTableControl({ rows, rowIdentityPath, columns, columnOrder, s
1097
1104
  insertionGapIndex,
1098
1105
  insertionGapHeight: activeDragRowHeight,
1099
1106
  }, children: table }) }) })) : table;
1100
- const constrainedTable = (_jsxs("div", { ref: frameRef, style: { position: "relative", width: "100%", maxWidth: "100%", minWidth: 0, overflow: "hidden" }, children: [renderedTable, headerBox ? (_jsx("div", { "aria-hidden": true, [PHI_VISUAL_ANCHOR_PART_ATTRIBUTE]: "header", style: { position: "absolute", left: 0, right: 0, top: headerBox.top, height: headerBox.height, pointerEvents: "none" } })) : null] }));
1107
+ /*
1108
+ * Clipped, not hidden: `hidden` makes the frame a scroll container, and a sticky header row then sticks
1109
+ * to the frame's own top -- pushed down inside the Table by the Shell's Header height -- instead of to
1110
+ * the page. `clip` cuts off the same overflow and leaves the page the one that scrolls.
1111
+ */
1112
+ const constrainedTable = (_jsxs("div", { ref: frameRef, style: { position: "relative", width: "100%", maxWidth: "100%", minWidth: 0, overflow: "clip" }, children: [renderedTable, headerBox ? (_jsx("div", { "aria-hidden": true, [PHI_VISUAL_ANCHOR_PART_ATTRIBUTE]: "header", style: { position: "absolute", left: 0, right: 0, top: headerBox.top, height: headerBox.height, pointerEvents: "none" } })) : null] }));
1101
1113
  return onExternalDragOver || onExternalDrop ? (_jsx("div", { onDragOver: (event) => {
1102
1114
  if (event.defaultPrevented) {
1103
1115
  setExternalAppendActive(false);
@@ -483,7 +483,7 @@ export declare const PHI_GRID_LAYOUT_DEFINITION: {
483
483
  kind: "layout";
484
484
  pluginKey: string;
485
485
  typeKey: string;
486
- slotSizePolicy: "fill";
486
+ slotSizePolicy: "fill-inline";
487
487
  title: string;
488
488
  description: string;
489
489
  category: "structure";
@@ -1176,7 +1176,7 @@ export declare const PHI_CORE_LAYOUT_DEFINITIONS: readonly [{
1176
1176
  kind: "layout";
1177
1177
  pluginKey: string;
1178
1178
  typeKey: string;
1179
- slotSizePolicy: "fill";
1179
+ slotSizePolicy: "fill-inline";
1180
1180
  title: string;
1181
1181
  description: string;
1182
1182
  category: "structure";
@@ -415,7 +415,12 @@ export const PHI_GRID_LAYOUT_DEFINITION = {
415
415
  kind: "layout",
416
416
  pluginKey: resolvePhiCmsLayoutPluginKey("grid"),
417
417
  typeKey: "grid",
418
- slotSizePolicy: "fill",
418
+ /*
419
+ * As wide as its slot, as tall as its rows. A Grid is content in flow: filling the block axis as well
420
+ * let a vertical stack share its height out among the Grids in it, and each one, held to its share,
421
+ * spilled its rows over the next section.
422
+ */
423
+ slotSizePolicy: "fill-inline",
419
424
  title: "Grid",
420
425
  description: "Slots in rows, as many to a row as the Grid's own width allows.",
421
426
  category: "structure",
@@ -0,0 +1,5 @@
1
+ import { type RefObject } from "react";
2
+ export declare function usePhiShellStickyTop({ active, elementRef, }: {
3
+ active: boolean;
4
+ elementRef: RefObject<HTMLElement | null>;
5
+ }): void;
@@ -0,0 +1,78 @@
1
+ "use client";
2
+ import { useEffect } from "react";
3
+ /**
4
+ * How far down the viewport the Shell's Header holds right now, published on the Shell.
5
+ *
6
+ * Something that sticks to the top of the page -- a Table's header row -- has to stick under the Header
7
+ * rather than behind it, and where the Header ends is not a constant: each band sticks or scrolls by its
8
+ * own config, and a revealing `header_top` slides away while the visitor reads down and comes back when
9
+ * they scroll up. So the edge is measured, not configured: the lowest bottom of every Header band that is
10
+ * stuck at this moment, written as `--phi-shell-sticky-top` on the Shell element. Unset, a reader falls
11
+ * back to `0px`, which is right for a Shell without a sticky Header.
12
+ *
13
+ * Run by whatever sticks under it -- every sticky Table does -- so a page without one loads nothing for
14
+ * it. Each runner measures all the bands and they all write the same value. Not inside an Overlay: a
15
+ * Modal's or Drawer's body scrolls on its own, and its top edge is the line.
16
+ */
17
+ const PHI_SHELL_STICKY_TOP_PROPERTY = "--phi-shell-sticky-top";
18
+ const HEADER_BANDS = '[data-phi-region-key^="header_"]:not([hidden])';
19
+ function measurePhiShellStickyTop(shell) {
20
+ let bottom = 0;
21
+ for (const band of shell.querySelectorAll(HEADER_BANDS)) {
22
+ const style = window.getComputedStyle(band);
23
+ if (style.position !== "sticky" && style.position !== "fixed") {
24
+ continue;
25
+ }
26
+ const rect = band.getBoundingClientRect();
27
+ const stuckAt = Number.parseFloat(style.top) || 0;
28
+ // Below its sticking line it is still in flow; above the viewport it has slid away.
29
+ if (rect.height === 0 || rect.top > stuckAt + 1 || rect.bottom <= 0) {
30
+ continue;
31
+ }
32
+ bottom = Math.max(bottom, rect.bottom);
33
+ }
34
+ return `${Math.round(bottom)}px`;
35
+ }
36
+ const OVERLAY_BODIES = "[data-phi-modal-body-content], [data-phi-drawer-body-content]";
37
+ export function usePhiShellStickyTop({ active, elementRef, }) {
38
+ useEffect(() => {
39
+ const element = elementRef.current;
40
+ const shell = active && element && !element.closest(OVERLAY_BODIES)
41
+ ? element.closest("[data-phi-shell-layout]")
42
+ : null;
43
+ if (!element || !shell) {
44
+ return undefined;
45
+ }
46
+ let frame = 0;
47
+ const publish = () => {
48
+ frame = 0;
49
+ const next = measurePhiShellStickyTop(shell);
50
+ if (shell.style.getPropertyValue(PHI_SHELL_STICKY_TOP_PROPERTY) !== next) {
51
+ shell.style.setProperty(PHI_SHELL_STICKY_TOP_PROPERTY, next);
52
+ }
53
+ };
54
+ const schedule = () => {
55
+ if (frame === 0) {
56
+ frame = window.requestAnimationFrame(publish);
57
+ }
58
+ };
59
+ publish();
60
+ window.addEventListener("scroll", schedule, { passive: true });
61
+ window.addEventListener("resize", schedule);
62
+ // A revealing band moves by a transition; its end is where it settled.
63
+ shell.addEventListener("transitionend", schedule);
64
+ const observer = new ResizeObserver(schedule);
65
+ for (const band of shell.querySelectorAll(HEADER_BANDS)) {
66
+ observer.observe(band);
67
+ }
68
+ return () => {
69
+ if (frame !== 0) {
70
+ window.cancelAnimationFrame(frame);
71
+ }
72
+ window.removeEventListener("scroll", schedule);
73
+ window.removeEventListener("resize", schedule);
74
+ shell.removeEventListener("transitionend", schedule);
75
+ observer.disconnect();
76
+ };
77
+ }, [active, elementRef]);
78
+ }
@@ -1,2 +1,4 @@
1
1
  import "server-only";
2
- export declare function translateSemanticHtml(sourceHtml: string, sourceLocale?: string): Promise<string>;
2
+ export declare function translateSemanticHtml(sourceHtml: string, sourceLocale?: string,
3
+ /** The render's locale. Without it the request's is asked, and a static render has no request. */
4
+ locale?: string): Promise<string>;
@@ -1,7 +1,7 @@
1
1
  import "server-only";
2
2
  import renderHtml from "dom-serializer";
3
3
  import { parseDocument } from "htmlparser2";
4
- import { trBulk } from "../../../server-helpers/translate";
4
+ import { trBulk, trBulkForLocale } from "../../../server-helpers/translate";
5
5
  import { sanitizePhiHtmlWidgetMarkup } from "./html-content";
6
6
  const SEMANTIC_HTML_BLOCKS = new Set([
7
7
  "p",
@@ -49,7 +49,9 @@ function renderNodes(nodes) {
49
49
  function hasVisibleText(html) {
50
50
  return html.replace(/<[^>]+>/g, "").replace(/&nbsp;/g, " ").trim().length > 0;
51
51
  }
52
- export async function translateSemanticHtml(sourceHtml, sourceLocale) {
52
+ export async function translateSemanticHtml(sourceHtml, sourceLocale,
53
+ /** The render's locale. Without it the request's is asked, and a static render has no request. */
54
+ locale) {
53
55
  const sanitized = sanitizePhiHtmlWidgetMarkup(sourceHtml);
54
56
  if (!sanitized) {
55
57
  return "";
@@ -64,7 +66,10 @@ export async function translateSemanticHtml(sourceHtml, sourceLocale) {
64
66
  if (units.length === 0) {
65
67
  return sanitized;
66
68
  }
67
- const translated = await trBulk(units.map((unit) => unit.html), 0, "html", sourceLocale);
69
+ const msgs = units.map((unit) => unit.html);
70
+ const translated = locale
71
+ ? await trBulkForLocale(locale, msgs, 0, "html", sourceLocale)
72
+ : await trBulk(msgs, 0, "html", sourceLocale);
68
73
  units.forEach((unit, index) => {
69
74
  const fragment = parseDocument(translated[index] ?? unit.html);
70
75
  unit.node.children = fragment.children ?? [];
@@ -127,6 +127,11 @@ export async function resolvePhiDescriptorNavigationItems(runtime, navKey, overl
127
127
  /*
128
128
  * One batch per source language. A Module's label names its language and is translated from it; an
129
129
  * operator's names none and is read in the Site's source language, which the server supplies.
130
+ *
131
+ * The batch also decides the store. A Module's label is the platform's text, the same on every Site,
132
+ * so it is translated once in the global store, which only the operator edits. A Site that wants
133
+ * other words renames the entry, the label loses its `sourceLocale`, and from then on it is the
134
+ * Site's own text in the Site's store. The Builder's surfaces carry Module labels only.
130
135
  */
131
136
  await Promise.all([...collectNavigationLabels(surface.items)].map(async ([sourceLocale, group]) => {
132
137
  const labels = [...group];
@@ -136,7 +141,7 @@ export async function resolvePhiDescriptorNavigationItems(runtime, navKey, overl
136
141
  locale: runtime.locale.current,
137
142
  ...(sourceLocale ? { sourceLocale } : {}),
138
143
  };
139
- const translator = runtime.area === "builder"
144
+ const translator = sourceLocale || runtime.area === "builder"
140
145
  ? createGlobalTranslator(options)
141
146
  : createSiteTranslator({ ...options, siteKey: rt.siteKey });
142
147
  /*
@@ -2,7 +2,7 @@ import { cache } from "react";
2
2
  import { unified } from "unified";
3
3
  import remarkParse from "remark-parse";
4
4
  import remarkGfm from "remark-gfm";
5
- import { trBulk } from "../../../server-helpers/translate";
5
+ import { trBulk, trBulkForLocale } from "../../../server-helpers/translate";
6
6
  import { resolvePhiCodeLanguage, tokenizePhiCode } from "./markdown-code-tokens";
7
7
  import { readPhiInternalReference } from "../../../types/references";
8
8
  import { resolvePhiWidgetInternalReferences } from "../helpers/internal-reference-resolver.server";
@@ -294,11 +294,21 @@ function parseTranslatedMarkdownInlines(translatedHtml, metadata) {
294
294
  appendText(translatedHtml.slice(cursor));
295
295
  return stack.length === 1 ? stack[0]?.children ?? [] : null;
296
296
  }
297
- async function buildMarkdownInlineMap(tree, translate, sourceLocale) {
297
+ /*
298
+ * The render's own locale where there is one. A static render has no request to ask: `trBulk` reads the
299
+ * locale from the request headers, finds none, and answers in the Site's default -- which left every
300
+ * static Public page's body in its source language under a translated header.
301
+ */
302
+ function translateUnits(locale, msgs, format, sourceLocale) {
303
+ return locale
304
+ ? trBulkForLocale(locale, msgs, 0, format, sourceLocale)
305
+ : trBulk(msgs, 0, format, sourceLocale);
306
+ }
307
+ async function buildMarkdownInlineMap(tree, translate, sourceLocale, locale) {
298
308
  const units = [];
299
309
  collectMarkdownTranslationUnits(tree, units);
300
310
  const translated = translate && units.length > 0
301
- ? await trBulk(units.map((unit) => unit.html), 0, "html", sourceLocale)
311
+ ? await translateUnits(locale, units.map((unit) => unit.html), "html", sourceLocale)
302
312
  : units.map((unit) => unit.html);
303
313
  /*
304
314
  * Image titles are prose and translate as plain text, each on its own: they are separate sentences
@@ -307,7 +317,7 @@ async function buildMarkdownInlineMap(tree, translate, sourceLocale) {
307
317
  */
308
318
  const titleSlots = units.flatMap((unit) => unit.metadata.titles.flatMap((title, index) => title ? [{ metadata: unit.metadata, index, title }] : []));
309
319
  if (translate && titleSlots.length > 0) {
310
- const translatedTitles = await trBulk(titleSlots.map((slot) => slot.title), 0, "text", sourceLocale);
320
+ const translatedTitles = await translateUnits(locale, titleSlots.map((slot) => slot.title), "text", sourceLocale);
311
321
  titleSlots.forEach((slot, index) => {
312
322
  slot.metadata.titles[slot.index] = translatedTitles[index] || slot.title;
313
323
  });
@@ -508,7 +518,7 @@ async function resolveMarkdownRenderDataFromSource(markdown, translate, headingI
508
518
  headingIdPrefix,
509
519
  headingOffset,
510
520
  };
511
- const inlineMap = await buildMarkdownInlineMap(tree, translate, sourceLocale);
521
+ const inlineMap = await buildMarkdownInlineMap(tree, translate, sourceLocale, runtime?.locale.current);
512
522
  const blocks = mapBlockNodes(tree.children, context, inlineMap);
513
523
  return { blocks, headings: context.headings };
514
524
  }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Whose words a text Widget holds, which decides the store that translates them.
3
+ *
4
+ * A Preset's text is the platform's: the same sentence on every installation, translated once in the
5
+ * global store, which only the operator edits. Text an author wrote on a Site is the Site's and goes to
6
+ * the Site's store. The Preset says so by writing `textSource: "preset"` beside its words; the Builder
7
+ * drops the marker the moment an author changes one of the Widget's texts, and from then on all of them
8
+ * are the Site's.
9
+ *
10
+ * A marker rather than a translation done by the Preset itself: adopting a Preset stores the tree it
11
+ * built, so a Preset that translated its words would freeze one locale into the Site's copy.
12
+ */
13
+ export declare const PHI_TEXT_SOURCE_PRESET: "preset";
14
+ export type PhiTextSource = typeof PHI_TEXT_SOURCE_PRESET;
15
+ export declare function readPhiTextSource(value: unknown): PhiTextSource | undefined;
16
+ /** Whether a Builder edit of this field ends the Preset's ownership of the Widget's words. */
17
+ export declare function isPhiAuthoredTextField(field: {
18
+ type?: string;
19
+ }): boolean;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Whose words a text Widget holds, which decides the store that translates them.
3
+ *
4
+ * A Preset's text is the platform's: the same sentence on every installation, translated once in the
5
+ * global store, which only the operator edits. Text an author wrote on a Site is the Site's and goes to
6
+ * the Site's store. The Preset says so by writing `textSource: "preset"` beside its words; the Builder
7
+ * drops the marker the moment an author changes one of the Widget's texts, and from then on all of them
8
+ * are the Site's.
9
+ *
10
+ * A marker rather than a translation done by the Preset itself: adopting a Preset stores the tree it
11
+ * built, so a Preset that translated its words would freeze one locale into the Site's copy.
12
+ */
13
+ export const PHI_TEXT_SOURCE_PRESET = "preset";
14
+ export function readPhiTextSource(value) {
15
+ return value === PHI_TEXT_SOURCE_PRESET ? value : undefined;
16
+ }
17
+ /** Whether a Builder edit of this field ends the Preset's ownership of the Widget's words. */
18
+ export function isPhiAuthoredTextField(field) {
19
+ return field.type === "string";
20
+ }
package/next/config.mjs CHANGED
@@ -57,7 +57,7 @@ function customRenders(antd, h, Fragment) {
57
57
  Form: (Form) => h(Form, null, h(Form.Item, { label: "Label" }, h(antd.Input))),
58
58
  Dropdown: (Dropdown) => h(Dropdown, { menu: { items: [] } }, h("div")),
59
59
  Menu: (Menu) => h(Menu, { items: [] }),
60
- QRCode: (QRCode) => h(QRCode, { value: "https://phisys.com" }),
60
+ QRCode: (QRCode) => h(QRCode, { value: "https://phis.dev" }),
61
61
  Tree: (Tree) => h(Tree, { treeData: [] }),
62
62
  Tag: (Tag) => h(Fragment, null, h(Tag, { color: "blue" }, "Tag"), h(Tag, { color: "success" }, "Tag")),
63
63
  Badge: (Badge) => h(Fragment, null, h(Badge), h(Badge.Ribbon)),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phis/ui",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "license": "Apache-2.0",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -1,3 +1,4 @@
1
+ import { PHI_TEXT_SOURCE_PRESET } from "../../../../helpers/text-source";
1
2
  import { createPhiPresetCmsInstanceIdMap } from "../../../../types/cms-instance-id";
2
3
  import { PHI_SPACE } from "../../../../theme/antd-css-var-contract";
3
4
  import { PHI_AUTH_RUNTIME_MODULE_ID } from "../ids";
@@ -97,6 +98,7 @@ export async function buildPhiDefaultPubConfirmPageTree({ page, presetKey, }) {
97
98
  sortOrder: 0,
98
99
  label: "pub confirmation description widget",
99
100
  config: {
101
+ textSource: PHI_TEXT_SOURCE_PRESET,
100
102
  eyebrow: "Confirm Email",
101
103
  title: "Activate your account",
102
104
  description: "Review your registration details and confirm your email address to activate access.",
@@ -160,6 +162,7 @@ export async function buildPhiDefaultPubConfirmPageTree({ page, presetKey, }) {
160
162
  slotIndex: 2,
161
163
  label: "pub confirmation missing token",
162
164
  config: {
165
+ textSource: PHI_TEXT_SOURCE_PRESET,
163
166
  text: "No confirmation token was provided. Open the confirmation link from your email.",
164
167
  tone: "secondary",
165
168
  visibleWhen: withoutToken,
@@ -1,3 +1,4 @@
1
+ import { PHI_TEXT_SOURCE_PRESET } from "../../../../helpers/text-source";
1
2
  import { createPhiPresetCmsInstanceIdMap } from "../../../../types/cms-instance-id";
2
3
  import { PHI_SPACE } from "../../../../theme/antd-css-var-contract";
3
4
  import { PHI_LAYOUT } from "../../../../theme/phi-tokens";
@@ -98,6 +99,7 @@ export async function buildPhiDefaultPubLoginPageTree({ page, presetKey, runtime
98
99
  sortOrder: 0,
99
100
  label: "pub login description widget",
100
101
  config: {
102
+ textSource: PHI_TEXT_SOURCE_PRESET,
101
103
  eyebrow: "Login",
102
104
  title: "Sign in to your account",
103
105
  description: "Access your customer workspace, orders, and account details.",
@@ -1,3 +1,4 @@
1
+ import { PHI_TEXT_SOURCE_PRESET } from "../../../../helpers/text-source";
1
2
  import { createPhiPresetCmsInstanceIdMap } from "../../../../types/cms-instance-id";
2
3
  import { PHI_SPACE } from "../../../../theme/antd-css-var-contract";
3
4
  import { PHI_AUTH_RUNTIME_MODULE_ID } from "../ids";
@@ -68,6 +69,7 @@ export async function buildPhiDefaultPubRegistrationPageTree({ page, presetKey,
68
69
  sortOrder: 0,
69
70
  label: "pub registration description widget",
70
71
  config: {
72
+ textSource: PHI_TEXT_SOURCE_PRESET,
71
73
  eyebrow: "Register",
72
74
  title: "Create account",
73
75
  description: "Create your account to access your personalized workspace.",
@@ -113,6 +115,7 @@ export async function buildPhiDefaultPubRegistrationPageTree({ page, presetKey,
113
115
  slotIndex: 1,
114
116
  label: "pub registration notice",
115
117
  config: {
118
+ textSource: PHI_TEXT_SOURCE_PRESET,
116
119
  text: "We only use your details to create and support your account.",
117
120
  tone: "secondary",
118
121
  },
@@ -1,3 +1,4 @@
1
+ import { PHI_TEXT_SOURCE_PRESET } from "../../../../helpers/text-source";
1
2
  import { createPhiPresetCmsInstanceIdMap } from "../../../../types/cms-instance-id";
2
3
  import { PHI_SPACE } from "../../../../theme/antd-css-var-contract";
3
4
  import { PHI_AUTH_RUNTIME_MODULE_ID } from "../ids";
@@ -89,6 +90,7 @@ export async function buildPhiDefaultPubResetPasswordPageTree({ page, presetKey,
89
90
  sortOrder: 0,
90
91
  label: "pub reset password description widget",
91
92
  config: {
93
+ textSource: PHI_TEXT_SOURCE_PRESET,
92
94
  eyebrow: "Reset password",
93
95
  title: "Choose a new password",
94
96
  description: "Use the reset link from your email to choose a new secure password.",
@@ -113,6 +115,7 @@ export async function buildPhiDefaultPubResetPasswordPageTree({ page, presetKey,
113
115
  slotIndex: 0,
114
116
  label: "pub reset password intro",
115
117
  config: {
118
+ textSource: PHI_TEXT_SOURCE_PRESET,
116
119
  text: "If the account exists, a reset email is on its way. Open the link in that email to choose a new password.",
117
120
  tone: "secondary",
118
121
  visibleWhen: withoutToken,
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { isPhiAuthoredTextField, readPhiTextSource } from "../../../../helpers/text-source";
3
4
  import { useEffect } from "react";
4
5
  import { PhiGeometryControl } from "../../../../components/controls/phi-geometry-control";
5
6
  import { PhiSurfaceControl } from "../../../../components/controls/phi-surface-control";
@@ -81,6 +82,14 @@ export function PhiDeveloperBuilderWidgetInspectorWidgetClient({ section, builde
81
82
  videoProviderDescriptors,
82
83
  onChange: (next) => {
83
84
  const patch = buildPhiInspectorConfigPathPatch(currentWidgetSettingsConfigRecord, next);
85
+ /*
86
+ * An author writing into a Preset's text makes the Widget's words the Site's: from here on the
87
+ * Site's store translates them, not the global one (`helpers/text-source.ts`).
88
+ */
89
+ if (readPhiTextSource(currentWidgetSettingsConfigRecord.textSource) != null
90
+ && isPhiAuthoredTextField(field)) {
91
+ patch.textSource = undefined;
92
+ }
84
93
  onConfigChange?.(patch);
85
94
  },
86
95
  });
@@ -1663,6 +1663,8 @@ async function buildPhiDefaultBuilderPagePresetTemplateTree({ page, runtime, pre
1663
1663
  }),
1664
1664
  nodes.widget({
1665
1665
  typeKey: "simple-text",
1666
+ /* Label set text, translated already; a second pass would hand the translator a non-source text. */
1667
+ flags: PhiCmsFlags.NoTranslate,
1666
1668
  id: PHI_BUILDER_MODULE_USAGE_WIDGET_IDS.moduleUsageIntro,
1667
1669
  parentLayoutNodeId: PHI_BUILDER_MODULE_USAGE_LAYOUT_IDS.moduleUsageBody,
1668
1670
  slotIndex: PHI_CMS_DEFAULT_SLOT_INDEX,
@@ -1737,6 +1739,8 @@ async function buildPhiDefaultBuilderPagePresetTemplateTree({ page, runtime, pre
1737
1739
  }),
1738
1740
  nodes.widget({
1739
1741
  typeKey: "simple-text",
1742
+ /* Label set text, translated already; a second pass would hand the translator a non-source text. */
1743
+ flags: PhiCmsFlags.NoTranslate,
1740
1744
  id: PHI_BUILDER_PUBLIC_ROUTES_WIDGET_IDS.publicRoutesIntro,
1741
1745
  parentLayoutNodeId: PHI_BUILDER_PUBLIC_ROUTES_LAYOUT_IDS.publicRoutesBody,
1742
1746
  slotIndex: PHI_CMS_DEFAULT_SLOT_INDEX,
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { trBulk } from "../../../../../server-helpers/translate";
2
+ import { trBulk, trBulkForLocale } from "../../../../../server-helpers/translate";
3
3
  import { isPhiMediaSvgContentType } from "../../../../../constants/media";
4
4
  import { resolvePhiImagePresentation } from "../../../../../components/media/image-presentation";
5
5
  import { resolvePhiPublicAssetReference } from "../../../../../components/widgets/helpers/internal-reference-resolver.server";
@@ -23,7 +23,9 @@ export async function PhiCardWidget({ labels, config, runtime, links, translate,
23
23
  ["actionLabel", actionEnabled ? ownActionLabel : undefined],
24
24
  ];
25
25
  const translatedTexts = translate
26
- ? await trBulk(textEntries.map(([, value]) => value ?? ""))
26
+ ? await (runtime?.locale.current
27
+ ? trBulkForLocale(runtime.locale.current, textEntries.map(([, value]) => value ?? ""))
28
+ : trBulk(textEntries.map(([, value]) => value ?? "")))
27
29
  : textEntries.map(([, value]) => value ?? "");
28
30
  const translatedByKey = new Map(textEntries.map(([key], index) => [key, translatedTexts[index] || undefined]));
29
31
  /*
@@ -1,3 +1,4 @@
1
+ import { type PhiTextSource } from "../../../../../helpers/text-source";
1
2
  import { type PhiCmsWidgetConfigBase } from "../../../../../components/widgets/config/parser-primitives";
2
3
  import { type PhiTextTone } from "../../../../../components/widgets/config/text-tone";
3
4
  import { type PhiBulletListEntry, type PhiBulletListStyle } from "../../../../../components/widgets/shared/bullet-list-vocabulary";
@@ -13,6 +14,8 @@ export type PhiCmsDescriptionWidgetConfig = PhiCmsWidgetConfigBase & {
13
14
  footer?: string;
14
15
  /** The tone of the description text; absent is the default secondary text. */
15
16
  tone?: PhiTextTone;
17
+ /** `"preset"` while the words are a Preset's and translated globally (`helpers/text-source.ts`). */
18
+ textSource?: PhiTextSource;
16
19
  };
17
20
  declare function parsePhiCmsDescriptionWidgetConfig(config: Record<string, unknown>): PhiCmsDescriptionWidgetConfig;
18
21
  export declare const PHI_DESCRIPTION_WIDGET_DEFINITION: {
@@ -1,4 +1,5 @@
1
1
  import { resolvePhiCmsWidgetPluginKey } from "../../../../../constants/cms-widget-types";
2
+ import { readPhiTextSource } from "../../../../../helpers/text-source";
2
3
  import { PhiCmsWidgetType } from "../../../../../constants/cms-widget-types";
3
4
  import { readRenderableBlockConfig, readString } from "../../../../../components/widgets/config/parser-primitives";
4
5
  import { PHI_TEXT_TONE_FIELD_OPTIONS, readPhiTextTone, } from "../../../../../components/widgets/config/text-tone";
@@ -15,6 +16,7 @@ function parsePhiCmsDescriptionWidgetConfig(config) {
15
16
  asideList: readPhiBulletListStyle(config.asideList),
16
17
  footer: readString(config.footer),
17
18
  tone: readPhiTextTone(config.tone),
19
+ textSource: readPhiTextSource(config.textSource),
18
20
  };
19
21
  }
20
22
  export const PHI_DESCRIPTION_WIDGET_DEFINITION = {
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { trGlobal, trGlobalForLocale, } from "../../../../../server-helpers/translate";
2
+ import { trWidgetText } from "../../../../../server-helpers/translate";
3
3
  import { maskPhiTextPlaceholders, resolvePhiTextPlaceholders } from "../../../../../helpers/text-placeholders";
4
4
  import { PhiBulletListControl } from "../../../../../components/controls/phi-bullet-list-control";
5
5
  import { buildPhiBulletListTree, resolvePhiBulletListStyle, } from "../../../../../components/widgets/shared/bullet-list-vocabulary";
@@ -12,17 +12,15 @@ import { PHI_COLOR, PHI_FONT_SIZE, PHI_LINE_HEIGHT, PHI_SPACE, } from "../../../
12
12
  * `{year}` out of the translator's hands, which would otherwise hand back `{Jahr}`.
13
13
  */
14
14
  async function translateLabel(locale, value, runtime, translate,
15
+ /** Whose words these are, which decides the store (`helpers/text-source.ts`). */
16
+ textSource,
15
17
  /** The text's own values, which stand in it as `%1` onwards and are never translated. */
16
18
  values = []) {
17
19
  if (!value) {
18
20
  return undefined;
19
21
  }
20
22
  const masked = maskPhiTextPlaceholders(value, values.length);
21
- const translated = !translate
22
- ? masked.text
23
- : locale
24
- ? await trGlobalForLocale(locale, masked.text)
25
- : await trGlobal(masked.text);
23
+ const translated = translate ? await trWidgetText(locale, masked.text, textSource) : masked.text;
26
24
  return resolvePhiTextPlaceholders(translated, masked.names, runtime, values);
27
25
  }
28
26
  function resolveDescriptionToneColor(tone) {
@@ -39,15 +37,15 @@ function resolveDescriptionToneColor(tone) {
39
37
  }
40
38
  export async function PhiDescriptionWidget({ labels, config, runtime, translate, }) {
41
39
  const locale = runtime?.locale.current;
42
- const eyebrow = await translateLabel(locale, labels.eyebrow ?? config?.eyebrow, runtime, translate);
43
- const title = await translateLabel(locale, labels.title ?? config?.title, runtime, translate);
44
- const description = await translateLabel(locale, labels.description ?? config?.description, runtime, translate);
45
- const asideTitle = await translateLabel(locale, labels.asideTitle ?? config?.asideTitle, runtime, translate);
40
+ const eyebrow = await translateLabel(locale, labels.eyebrow ?? config?.eyebrow, runtime, translate, config?.textSource);
41
+ const title = await translateLabel(locale, labels.title ?? config?.title, runtime, translate, config?.textSource);
42
+ const description = await translateLabel(locale, labels.description ?? config?.description, runtime, translate, config?.textSource);
43
+ const asideTitle = await translateLabel(locale, labels.asideTitle ?? config?.asideTitle, runtime, translate, config?.textSource);
46
44
  const asideEntries = await Promise.all((labels.asideItems ?? config?.asideItems ?? []).map(async (entry) => ({
47
45
  ...entry,
48
- text: await translateLabel(locale, entry.text, runtime, translate, entry.values),
46
+ text: await translateLabel(locale, entry.text, runtime, translate, config?.textSource, entry.values),
49
47
  })));
50
- const footer = await translateLabel(locale, labels.footer ?? config?.footer, runtime, translate);
48
+ const footer = await translateLabel(locale, labels.footer ?? config?.footer, runtime, translate, config?.textSource);
51
49
  const resolvedAsideItems = buildPhiBulletListTree(asideEntries.filter((entry) => Boolean(entry.text)), (entry) => entry.text);
52
50
  const stackStyle = {
53
51
  display: "grid",
@@ -5,7 +5,7 @@ import { getPhiContentEntryReaderLabels } from "../../../../../components/widget
5
5
  import { PhiCmsWidgetType } from "../../../../../constants/cms-widget-types";
6
6
  import { fetchPhiSiteContentEntries } from "../../../../../gateway/content-entries";
7
7
  import { phiRuntime } from "../../../../../server-helpers/phi-runtime";
8
- import { trBulk } from "../../../../../server-helpers/translate";
8
+ import { trBulkForLocale } from "../../../../../server-helpers/translate";
9
9
  import { readPhiBoundContentEntry } from "../../content-entries/record-source.server";
10
10
  import { buildPhiEntryViews } from "./view.server";
11
11
  import { normalizePhiEntryListWidgetConfig } from "./config";
@@ -74,7 +74,7 @@ export async function PhiEntryListWidget({ config, runtime, registry, links, tra
74
74
  // A stacked entry's title is an `h2`; its text's headings start under it.
75
75
  headingOffset: 2,
76
76
  });
77
- const [title] = settings.title && translate ? await trBulk([settings.title]) : [settings.title];
77
+ const [title] = settings.title && translate ? await trBulkForLocale(locale, [settings.title]) : [settings.title];
78
78
  return (_jsx(PhiRuntimeModuleRenderClientHost, { type: PhiCmsWidgetType.EntryList, componentProps: {
79
79
  entries: views,
80
80
  labels,
@@ -52,7 +52,7 @@ export async function PhiHtmlWidget({ blockId, config, runtime, }) {
52
52
  }
53
53
  const translatedHtml = (sourceMode !== "url" && config?.resolvedContent != null) || config?.preferSource === true
54
54
  ? sourceHtml
55
- : await translateSemanticHtml(sourceHtml, sourceMode === "url" ? config?.sourceLocale?.trim() : undefined);
55
+ : await translateSemanticHtml(sourceHtml, sourceMode === "url" ? config?.sourceLocale?.trim() : undefined, runtime.locale.current);
56
56
  if (!translatedHtml) {
57
57
  return null;
58
58
  }
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { PhiCmsFlags } from "../../../../../constants/phi-cms";
3
3
  import { hasPhiFlag } from "../../../../../helpers/flags";
4
- import { tr } from "../../../../../server-helpers/translate";
4
+ import { trForLocale } from "../../../../../server-helpers/translate";
5
5
  import { PhiCmsWidgetType } from "../../../../../constants/cms-widget-types";
6
6
  import { parsePhiCmsMarkdownWidgetConfig } from "../markdown/config";
7
7
  import { PhiRuntimeModuleRenderClientHost } from "../../../../../components/runtime/runtime-module-render-client-manifest";
@@ -25,7 +25,12 @@ function filterHeadings(headings, config) {
25
25
  return headings.filter((heading) => heading.level >= minLevel && heading.level <= maxLevel);
26
26
  }
27
27
  export async function PhiMarkdownTocWidget({ config, defaultLabels, tree, runtime, }) {
28
- const title = await tr(config?.title?.trim() || defaultLabels.title);
28
+ /*
29
+ * The author's title in the render's locale; the default is a label set's and arrives translated, so
30
+ * asking for it again would hand a translator text that is no longer in the source language.
31
+ */
32
+ const ownTitle = config?.title?.trim();
33
+ const title = ownTitle ? await trForLocale(runtime.locale.current, ownTitle) : defaultLabels.title;
29
34
  const targetWidget = resolveTargetMarkdownWidget(tree, config);
30
35
  if (!targetWidget) {
31
36
  return (_jsx(PhiRuntimeModuleRenderClientHost, { type: PhiCmsWidgetType.MarkdownToc, componentProps: { config: { ...config, title, headings: [] } } }));
@@ -1,9 +1,12 @@
1
+ import { type PhiTextSource } from "../../../../../helpers/text-source";
1
2
  import { type PhiCmsWidgetConfigBase } from "../../../../../components/widgets/config/parser-primitives";
2
3
  export type PhiCmsQuickLinksWidgetConfig = PhiCmsWidgetConfigBase & {
3
4
  navKey?: string;
4
5
  title?: string;
5
6
  columns?: 1 | 2 | 3;
6
7
  separator?: boolean;
8
+ /** `"preset"` while the words are a Preset's and translated globally (`helpers/text-source.ts`). */
9
+ textSource?: PhiTextSource;
7
10
  };
8
11
  declare function parsePhiCmsQuickLinksWidgetConfig(config: Record<string, unknown>): PhiCmsQuickLinksWidgetConfig;
9
12
  export declare const PHI_QUICK_LINKS_WIDGET_DEFINITION: {
@@ -1,4 +1,5 @@
1
1
  import { resolvePhiCmsWidgetPluginKey } from "../../../../../constants/cms-widget-types";
2
+ import { readPhiTextSource } from "../../../../../helpers/text-source";
2
3
  import { PhiCmsWidgetType } from "../../../../../constants/cms-widget-types";
3
4
  import { PHI_AUTHORING_OPTION_PROVIDER_KEYS } from "../../../../../constants/authoring-option-provider-keys";
4
5
  import { readBoolean, readNumber, readRenderableBlockConfig, readString, } from "../../../../../components/widgets/config/parser-primitives";
@@ -11,6 +12,7 @@ function parsePhiCmsQuickLinksWidgetConfig(config) {
11
12
  title: readString(config.title),
12
13
  columns: columns === 1 || columns === 3 ? columns : 2,
13
14
  separator: readBoolean(config.separator) ?? true,
15
+ textSource: readPhiTextSource(config.textSource),
14
16
  };
15
17
  }
16
18
  export const PHI_QUICK_LINKS_WIDGET_DEFINITION = {
@@ -1,9 +1,11 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { PhiCmsFlags } from "../../../../../constants/phi-cms";
3
+ import { hasPhiFlag } from "../../../../../helpers/flags";
2
4
  import { PhiQuickLinksWidget } from "./server";
3
5
  import { PHI_QUICK_LINKS_WIDGET_DEFINITION, PHI_QUICK_LINKS_WIDGET_PLUGIN_TYPE, } from "./config";
4
6
  export const PHI_QUICK_LINKS_WIDGET_PLUGIN = {
5
7
  ...PHI_QUICK_LINKS_WIDGET_DEFINITION,
6
- render: ({ widget, runtime, config }) => (_jsx(PhiQuickLinksWidget, { runtime: runtime, navKey: config.navKey, title: config.title, columns: config.columns, separator: config.separator }, `widget-${widget.id}`)),
7
- renderPreview: ({ widget, runtime, config }) => (_jsx(PhiQuickLinksWidget, { runtime: runtime, navKey: config.navKey, title: config.title, columns: config.columns, separator: config.separator, interactive: false }, `widget-${widget.id}`)),
8
+ render: ({ widget, runtime, config }) => (_jsx(PhiQuickLinksWidget, { runtime: runtime, navKey: config.navKey, title: config.title, textSource: config.textSource, translate: !hasPhiFlag(widget.flags, PhiCmsFlags.NoTranslate), columns: config.columns, separator: config.separator }, `widget-${widget.id}`)),
9
+ renderPreview: ({ widget, runtime, config }) => (_jsx(PhiQuickLinksWidget, { runtime: runtime, navKey: config.navKey, title: config.title, textSource: config.textSource, translate: !hasPhiFlag(widget.flags, PhiCmsFlags.NoTranslate), columns: config.columns, separator: config.separator, interactive: false }, `widget-${widget.id}`)),
8
10
  };
9
11
  export { PHI_QUICK_LINKS_WIDGET_PLUGIN_TYPE };
@@ -1,10 +1,14 @@
1
1
  import type { PhiBlockRuntime } from "../../../../../types";
2
+ import type { PhiTextSource } from "../../../../../helpers/text-source";
2
3
  export type PhiQuickLinksWidgetProps = {
3
4
  runtime: Pick<PhiBlockRuntime, "site" | "locale" | "area" | "viewer" | "request">;
4
5
  navKey?: string;
5
6
  title?: string;
7
+ /** Whose words the title is, which decides the store that translates it. */
8
+ textSource?: PhiTextSource;
9
+ translate?: boolean;
6
10
  columns?: 1 | 2 | 3;
7
11
  separator?: boolean;
8
12
  interactive?: boolean;
9
13
  };
10
- export declare function PhiQuickLinksWidget({ runtime, navKey, title, columns, separator, interactive, }: PhiQuickLinksWidgetProps): Promise<import("react").JSX.Element | null>;
14
+ export declare function PhiQuickLinksWidget({ runtime, navKey, title, textSource, translate, columns, separator, interactive, }: PhiQuickLinksWidgetProps): Promise<import("react").JSX.Element | null>;
@@ -3,15 +3,17 @@ import { PhiCmsWidgetType } from "../../../../../constants/cms-widget-types";
3
3
  import { PhiRuntimeModuleRenderClientHost } from "../../../../../components/runtime/runtime-module-render-client-manifest";
4
4
  import { resolvePhiNavigationItems } from "../../../../../components/widgets/server/navigation-request";
5
5
  import { mapPhiNavItemsToQuickLinksItems } from "./items";
6
- export async function PhiQuickLinksWidget({ runtime, navKey, title, columns = 2, separator = true, interactive = true, }) {
6
+ import { trWidgetText } from "../../../../../server-helpers/translate";
7
+ export async function PhiQuickLinksWidget({ runtime, navKey, title, textSource, translate = true, columns = 2, separator = true, interactive = true, }) {
7
8
  const resolvedNavKey = navKey?.trim() ||
8
9
  `${runtime.area}:footer`;
9
10
  const resolvedNavigationItems = (await resolvePhiNavigationItems(runtime, resolvedNavKey)) ?? [];
10
11
  if (resolvedNavigationItems.length === 0) {
11
12
  return null;
12
13
  }
14
+ const ownTitle = title?.trim();
13
15
  const labels = {
14
- title,
16
+ title: ownTitle && translate ? await trWidgetText(runtime.locale.current, ownTitle, textSource) : title,
15
17
  };
16
18
  const config = {
17
19
  columns,
@@ -1,3 +1,4 @@
1
+ import { type PhiTextSource } from "../../../../../helpers/text-source";
1
2
  import { type PhiSimpleTextMark } from "../../../../../types/core-widget-placements";
2
3
  import type { PhiCmsResolvedContent } from "../../../../../types";
3
4
  import type { PhiWidgetFontFamilyKey, PhiWidgetFontSizeKey } from "../../../../../types/site-theme";
@@ -15,6 +16,8 @@ export type PhiCmsSimpleTextWidgetConfig = PhiCmsWidgetConfigBase & {
15
16
  tone?: PhiTextTone;
16
17
  marks?: PhiSimpleTextMark[];
17
18
  disabled?: boolean;
19
+ /** `"preset"` while the words are a Preset's and translated globally (`helpers/text-source.ts`). */
20
+ textSource?: PhiTextSource;
18
21
  };
19
22
  declare function parsePhiCmsSimpleTextWidgetConfig(config: Record<string, unknown>): PhiCmsSimpleTextWidgetConfig;
20
23
  export type PhiSimpleTextWidgetRenderableConfig = PhiCmsSimpleTextWidgetConfig & {
@@ -1,4 +1,5 @@
1
1
  import { resolvePhiCmsWidgetPluginKey } from "../../../../../constants/cms-widget-types";
2
+ import { readPhiTextSource } from "../../../../../helpers/text-source";
2
3
  import { PHI_SIMPLE_TEXT_MARKS } from "../../../../../types/core-widget-placements";
3
4
  import { PhiCmsWidgetType } from "../../../../../constants/cms-widget-types";
4
5
  import { PHI_RENDERABLE_BLOCK_GEOMETRY_FIELDS } from "../../../../../helpers/renderable-block-plugin-fields";
@@ -27,6 +28,7 @@ function parsePhiCmsSimpleTextWidgetConfig(config) {
27
28
  tone: readPhiTextTone(config.tone),
28
29
  marks: readSimpleTextMarks(config.marks),
29
30
  disabled: readBoolean(config.disabled),
31
+ textSource: readPhiTextSource(config.textSource),
30
32
  };
31
33
  }
32
34
  export const PHI_SIMPLE_TEXT_WIDGET_DEFINITION = {
@@ -9,7 +9,7 @@ export const PHI_SIMPLE_TEXT_WIDGET_PLUGIN = {
9
9
  const noTranslate = hasPhiFlag(widget.flags, PhiCmsFlags.NoTranslate);
10
10
  // A record's text is already translated; only the Widget's own still has to be.
11
11
  const hasResolvedText = Boolean(widget.resolvedContent?.textFields.text);
12
- return (_jsx(PhiSimpleTextWidget, { blockId: widget.id, translate: !noTranslate && !hasResolvedText, labels: {
12
+ return (_jsx(PhiSimpleTextWidget, { blockId: widget.id, translate: !noTranslate && !hasResolvedText, textSource: config.textSource, labels: {
13
13
  text: resolvePhiSimpleTextWidgetText({
14
14
  ...config,
15
15
  label: widget.label ?? undefined,
@@ -1,3 +1,4 @@
1
+ import type { PhiTextSource } from "../../../../../helpers/text-source";
1
2
  import type { PhiCmsInstanceId, PhiRenderableBlockBase, PhiServerBlockBaseProps } from "../../../../../types";
2
3
  import type { PhiSimpleTextWidgetClientConfig } from "./client";
3
4
  export type PhiSimpleTextWidgetLabels = {
@@ -6,6 +7,8 @@ export type PhiSimpleTextWidgetLabels = {
6
7
  export type PhiSimpleTextWidgetConfig = PhiSimpleTextWidgetClientConfig & PhiRenderableBlockBase;
7
8
  export type PhiSimpleTextWidgetProps = PhiServerBlockBaseProps<PhiSimpleTextWidgetLabels, PhiSimpleTextWidgetConfig> & {
8
9
  blockId: PhiCmsInstanceId;
10
+ /** Whose words the text is, which decides the store that translates it (`helpers/text-source.ts`). */
11
+ textSource?: PhiTextSource;
9
12
  /**
10
13
  * Whether the text still has to be translated, which is true only where it is the Widget's own.
11
14
  *
@@ -16,4 +19,4 @@ export type PhiSimpleTextWidgetProps = PhiServerBlockBaseProps<PhiSimpleTextWidg
16
19
  */
17
20
  translate?: boolean;
18
21
  };
19
- export declare function PhiSimpleTextWidget({ blockId, labels, config, translate, runtime, }: PhiSimpleTextWidgetProps): Promise<import("react").JSX.Element>;
22
+ export declare function PhiSimpleTextWidget({ blockId, labels, config, translate, textSource, runtime, }: PhiSimpleTextWidgetProps): Promise<import("react").JSX.Element>;
@@ -1,9 +1,9 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { trGlobal, trGlobalForLocale, } from "../../../../../server-helpers/translate";
2
+ import { trWidgetText } from "../../../../../server-helpers/translate";
3
3
  import { maskPhiTextPlaceholders, resolvePhiTextPlaceholders } from "../../../../../helpers/text-placeholders";
4
4
  import { PhiCmsWidgetType } from "../../../../../constants/cms-widget-types";
5
5
  import { PhiRuntimeModuleRenderClientHost } from "../../../../../components/runtime/runtime-module-render-client-manifest";
6
- export async function PhiSimpleTextWidget({ blockId, labels, config, translate, runtime, }) {
6
+ export async function PhiSimpleTextWidget({ blockId, labels, config, translate, textSource, runtime, }) {
7
7
  const locale = runtime?.locale.current;
8
8
  /*
9
9
  * Masked before the translation and filled after it.
@@ -14,7 +14,7 @@ export async function PhiSimpleTextWidget({ blockId, labels, config, translate,
14
14
  */
15
15
  const masked = maskPhiTextPlaceholders(labels.text);
16
16
  const translated = translate && masked.text
17
- ? locale ? await trGlobalForLocale(locale, masked.text) : await trGlobal(masked.text)
17
+ ? await trWidgetText(locale, masked.text, textSource)
18
18
  : masked.text;
19
19
  const text = resolvePhiTextPlaceholders(translated, masked.names, runtime);
20
20
  return (_jsx(PhiRuntimeModuleRenderClientHost, { type: PhiCmsWidgetType.SimpleText, componentProps: {
@@ -913,7 +913,7 @@ export function PhiTableWidgetClient({ config: inputConfig, externalSearch, exte
913
913
  afterRowIdentity: null,
914
914
  }).catch(() => undefined).finally(clearPhiDataDragPayload);
915
915
  }
916
- : undefined, loading: effectiveLoading, bordered: presentation.borders, striped: presentation.row?.striped, showHeader: presentation.header, size: resolvedControlSize, emptyText: (_jsxs(PhiFlexControl, { vertical: true, gap: "small", children: [_jsx(PhiTypographyControl, { strong: true, children: presentation.emptyState?.title ?? labels.emptyTitle }), _jsx(PhiTypographyControl, { type: "secondary", children: presentation.emptyState?.description ?? labels.emptyDescription })] })), layout: presentation.layout, footer: footer, summary: tableSummary })] }));
916
+ : undefined, loading: effectiveLoading, bordered: presentation.borders, striped: presentation.row?.striped, showHeader: presentation.header, stickyHeader: presentation.stickyHeader !== false, size: resolvedControlSize, emptyText: (_jsxs(PhiFlexControl, { vertical: true, gap: "small", children: [_jsx(PhiTypographyControl, { strong: true, children: presentation.emptyState?.title ?? labels.emptyTitle }), _jsx(PhiTypographyControl, { type: "secondary", children: presentation.emptyState?.description ?? labels.emptyDescription })] })), layout: presentation.layout, footer: footer, summary: tableSummary })] }));
917
917
  }
918
918
  function TableAction({ action, disabled = false, disabledReason, templateValue, size, onActivate }) {
919
919
  const icon = action.icon ? resolvePhiButtonIcon(action.icon) : null;
@@ -1217,6 +1217,7 @@ export declare const PHI_TABLE_WIDGET_DEFINITION: {
1217
1217
  };
1218
1218
  borders: false;
1219
1219
  header: true;
1220
+ stickyHeader: true;
1220
1221
  row: {
1221
1222
  striped: false;
1222
1223
  };
@@ -537,6 +537,7 @@ export function parsePhiTableWidgetConfig(config) {
537
537
  },
538
538
  borders: readBoolean(presentation.borders),
539
539
  header: readBoolean(presentation.header),
540
+ stickyHeader: readBoolean(presentation.stickyHeader),
540
541
  footer: readTableFooter(presentation.footer),
541
542
  summary: readTableSummary(presentation.summary),
542
543
  row: {
@@ -1153,6 +1154,7 @@ export const PHI_TABLE_WIDGET_DEFINITION = {
1153
1154
  { ...PHI_CONTROL_SIZE_FIELD, key: "presentation.controlSize" },
1154
1155
  { key: "presentation.borders", type: "boolean", label: "Borders" },
1155
1156
  { key: "presentation.header", type: "boolean", label: "Header" },
1157
+ { key: "presentation.stickyHeader", type: "boolean", label: "Sticky Header" },
1156
1158
  { key: "presentation.row.striped", type: "boolean", label: "Striped Rows" },
1157
1159
  ...buildPhiTableConditionExpressionConfigFields("presentation.row.mutedWhen", "Muted Row Conditions", "any", "No muted rows"),
1158
1160
  ],
@@ -1162,6 +1164,7 @@ export const PHI_TABLE_WIDGET_DEFINITION = {
1162
1164
  layout: { mode: "auto", overflowX: "auto" },
1163
1165
  borders: false,
1164
1166
  header: true,
1167
+ stickyHeader: true,
1165
1168
  row: { striped: false },
1166
1169
  },
1167
1170
  features: {
@@ -4,8 +4,7 @@ import { PhiCmsFlags, PhiCmsRegionType } from "../../constants/phi-cms";
4
4
  import { resolvePhiCmsWidgetPluginKey } from "../../constants/cms-widget-types";
5
5
  import { buildPhiCmsLayoutNamespacedTypeKey } from "../../constants/cms-layout-types";
6
6
  import { resolvePhiCmsLayoutPluginKey } from "../../constants/cms-layout-type-keys";
7
- import { trGlobal } from "../../server-helpers/translate";
8
- import { PHI_TR_CTX_WEB_UI_LABEL } from "../../gateway/tr";
7
+ import { PHI_TEXT_SOURCE_PRESET } from "../../helpers/text-source";
9
8
  import { PHI_SPACE } from "../../theme/antd-css-var-contract";
10
9
  import { buildPhiCmsWidgetNamespacedTypeKey } from "../../constants/cms-widget-types";
11
10
  import { createPhiCmsPresetNodes } from "../../helpers/cms-preset-nodes";
@@ -44,7 +43,12 @@ export async function buildPhiDefaultSiteAreaPresetTree({ page, runtime, presetK
44
43
  const footerTopRegion = PHI_DEFAULT_PUB_AREA_PRESET.regions.find((region) => region.key === "footer_top");
45
44
  const footerBottomRegion = PHI_DEFAULT_PUB_AREA_PRESET.regions.find((region) => region.key === "footer_bottom");
46
45
  const { label: contactLabel, href: contactHref, icon: contactIcon, } = resolvePhiBrandContact(runtime);
47
- const defaultQuickLinksTitle = await trGlobal("Quick Links", 0, PHI_TR_CTX_WEB_UI_LABEL);
46
+ /*
47
+ * The Preset's words stay in the source language and say they are the Preset's; the Widgets translate
48
+ * them through the global store (`helpers/text-source.ts`). The contact label is the Preset's only
49
+ * while the Site's theme names none.
50
+ */
51
+ const contactLabelIsPreset = !runtime.site.theme?.contact?.label?.trim();
48
52
  /*
49
53
  * A template rather than a finished sentence, and left untranslated here.
50
54
  *
@@ -477,6 +481,7 @@ export async function buildPhiDefaultSiteAreaPresetTree({ page, runtime, presetK
477
481
  label: "pub header top right",
478
482
  config: {
479
483
  text: contactLabel,
484
+ ...(contactLabelIsPreset ? { textSource: PHI_TEXT_SOURCE_PRESET } : {}),
480
485
  href: contactHref,
481
486
  icon: contactIcon,
482
487
  },
@@ -581,7 +586,8 @@ export async function buildPhiDefaultSiteAreaPresetTree({ page, runtime, presetK
581
586
  visibilityMask: page.visibilityMask,
582
587
  label: "pub footer main quick links",
583
588
  config: {
584
- title: defaultQuickLinksTitle,
589
+ title: "Quick Links",
590
+ textSource: PHI_TEXT_SOURCE_PRESET,
585
591
  navKey: `${navigationArea}:footer`,
586
592
  columns: 2,
587
593
  separator: true,
@@ -601,6 +607,7 @@ export async function buildPhiDefaultSiteAreaPresetTree({ page, runtime, presetK
601
607
  label: "pub footer main right",
602
608
  config: {
603
609
  text: contactLabel,
610
+ ...(contactLabelIsPreset ? { textSource: PHI_TEXT_SOURCE_PRESET } : {}),
604
611
  href: contactHref,
605
612
  icon: contactIcon,
606
613
  },
@@ -623,6 +630,7 @@ export async function buildPhiDefaultSiteAreaPresetTree({ page, runtime, presetK
623
630
  label: "pub footer bottom text",
624
631
  config: {
625
632
  text: footerBottomText,
633
+ textSource: PHI_TEXT_SOURCE_PRESET,
626
634
  tone: "secondary",
627
635
  },
628
636
  contentVersionId: null,
@@ -1,3 +1,4 @@
1
+ import { PHI_TEXT_SOURCE_PRESET } from "../../../../helpers/text-source";
1
2
  import { createPhiPresetCmsInstanceIdMap } from "../../../../types/cms-instance-id";
2
3
  import { PHI_SPACE } from "../../../../theme/antd-css-var-contract";
3
4
  import { PHI_PUBLIC_RUNTIME_MODULE_ID } from "../ids";
@@ -73,6 +74,7 @@ export async function buildPhiDefaultPubContactPageTree({ page, }) {
73
74
  sortOrder: 0,
74
75
  label: "pub contact description widget",
75
76
  config: {
77
+ textSource: PHI_TEXT_SOURCE_PRESET,
76
78
  eyebrow: "Contact",
77
79
  title: "Get in touch",
78
80
  description: "Send us your question, project request, or product inquiry and we will get back to you.",
@@ -1,3 +1,4 @@
1
+ import { PHI_TEXT_SOURCE_PRESET } from "../../../../helpers/text-source";
1
2
  import { createPhiPresetCmsInstanceIdMap } from "../../../../types/cms-instance-id";
2
3
  import { PHI_PUBLIC_RUNTIME_MODULE_ID } from "../ids";
3
4
  import { PhiCmsPageType } from "../../../../constants/phi-cms";
@@ -58,6 +59,7 @@ export async function buildPhiDefaultPubUnsubscribePageTree({ page, presetKey, }
58
59
  sortOrder: 0,
59
60
  label: "pub unsubscribe intro widget",
60
61
  config: {
62
+ textSource: PHI_TEXT_SOURCE_PRESET,
61
63
  title: "Stop receiving updates",
62
64
  description: "Press the button below and this site will send you no further updates.",
63
65
  visibleWhen: withToken,
@@ -75,6 +77,7 @@ export async function buildPhiDefaultPubUnsubscribePageTree({ page, presetKey, }
75
77
  sortOrder: 0,
76
78
  label: "pub unsubscribe incomplete widget",
77
79
  config: {
80
+ textSource: PHI_TEXT_SOURCE_PRESET,
78
81
  title: "This link is incomplete",
79
82
  description: "Open the unsubscribe link from the message itself, or use the most recent one you received.",
80
83
  tone: "secondary",
@@ -1,3 +1,4 @@
1
+ import { PHI_TEXT_SOURCE_PRESET } from "../../../../helpers/text-source";
1
2
  import { createPhiPresetCmsInstanceIdMap } from "../../../../types/cms-instance-id";
2
3
  import { PHI_PUBLIC_RUNTIME_MODULE_ID } from "../ids";
3
4
  import { PHI_PUBLIC_BASE_PAGE_REFERENCES } from "../../public-base-page-references";
@@ -125,7 +126,7 @@ export async function buildPhiDefaultPubWelcomePageTree({ page, runtime, }) {
125
126
  sortOrder: 0,
126
127
  label: "pub welcome greeting",
127
128
  // English source, like every Preset sentence: the Site translator makes it "Herzlich willkommen".
128
- config: { text: "A warm welcome" },
129
+ config: { textSource: PHI_TEXT_SOURCE_PRESET, text: "A warm welcome" },
129
130
  }),
130
131
  nodes.widget({
131
132
  typeKey: "markdown",
@@ -265,6 +265,8 @@ export async function buildPhiDefaultBuilderRevisionsPageTree({ page, runtime, }
265
265
  */
266
266
  nodes.widget({
267
267
  typeKey: "simple-text",
268
+ /* Label set text, translated already; a second pass would hand the translator a non-source text. */
269
+ flags: PhiCmsFlags.NoTranslate,
268
270
  id: PHI_REVISIONS_DELETE_AREA_WIDGET_IDS.deleteAreaWarning,
269
271
  parentLayoutNodeId: PHI_REVISIONS_DELETE_AREA_LAYOUT_IDS.deleteAreaBody,
270
272
  slotIndex: PHI_CMS_DEFAULT_SLOT_INDEX,
@@ -279,6 +281,8 @@ export async function buildPhiDefaultBuilderRevisionsPageTree({ page, runtime, }
279
281
  }),
280
282
  nodes.widget({
281
283
  typeKey: "simple-text",
284
+ /* Label set text, translated already; a second pass would hand the translator a non-source text. */
285
+ flags: PhiCmsFlags.NoTranslate,
282
286
  id: PHI_REVISIONS_DELETE_AREA_WIDGET_IDS.deleteAreaSurvives,
283
287
  parentLayoutNodeId: PHI_REVISIONS_DELETE_AREA_LAYOUT_IDS.deleteAreaBody,
284
288
  slotIndex: PHI_CMS_SEQUENTIAL_LAYOUT_SLOTS[1].slotIndex,
@@ -1,10 +1,19 @@
1
1
  import "server-only";
2
2
  import { type PhiTranslationParams } from "../helpers/translation-format";
3
3
  import { PHI_TR_CTX_WEB_UI_LABEL, type PhiTranslationFormat } from "../gateway/tr";
4
+ import { type PhiTextSource } from "../helpers/text-source";
4
5
  export { PHI_TR_CTX_WEB_UI_LABEL };
5
6
  export type { PhiTranslationParams } from "../helpers/translation-format";
6
7
  export declare function tr(msg: string, params?: PhiTranslationParams, ctx?: string | number, format?: PhiTranslationFormat): Promise<string>;
7
8
  export declare function trGlobal(msg: string, params?: PhiTranslationParams, ctx?: string | number, format?: PhiTranslationFormat): Promise<string>;
9
+ /**
10
+ * A text Widget's own words in the render's locale, from the store their source names.
11
+ *
12
+ * `textSource: "preset"` is a Preset's sentence and the platform's, so the global store answers it;
13
+ * anything else an author wrote and the Site's store does (`helpers/text-source.ts`). The locale is the
14
+ * render's where there is one: a static render has no request to read it from.
15
+ */
16
+ export declare function trWidgetText(locale: string | undefined, msg: string, textSource: PhiTextSource | undefined): Promise<string>;
8
17
  export declare function trForLocale(localeInput: string, msg: string, params?: PhiTranslationParams, ctx?: string | number, format?: PhiTranslationFormat,
9
18
  /** The language `msg` is written in, when it is not the Site's own -- a Module's text. */
10
19
  sourceLocale?: string): Promise<string>;
@@ -6,6 +6,7 @@ import { PHI_TR_CTX_WEB_UI_LABEL, trBulk as requestInternalTranslationBulk, tr a
6
6
  import { getPhiRequestRuntime } from "./request-runtime";
7
7
  import { resolvePhiRequestLocale } from "./request-locale";
8
8
  import { readPhiServerApiCredentials } from "../helpers/phis-server-credentials";
9
+ import { PHI_TEXT_SOURCE_PRESET } from "../helpers/text-source";
9
10
  export { PHI_TR_CTX_WEB_UI_LABEL };
10
11
  function buildTranslatorOptions(locale, sourceLocale) {
11
12
  const requestRuntime = getPhiRequestRuntime();
@@ -46,6 +47,19 @@ export async function trGlobal(msg, params = 0, ctx = 0, format = "text") {
46
47
  const locale = await resolvePhiRequestLocale();
47
48
  return trGlobalForLocale(locale, msg, params, ctx, format);
48
49
  }
50
+ /**
51
+ * A text Widget's own words in the render's locale, from the store their source names.
52
+ *
53
+ * `textSource: "preset"` is a Preset's sentence and the platform's, so the global store answers it;
54
+ * anything else an author wrote and the Site's store does (`helpers/text-source.ts`). The locale is the
55
+ * render's where there is one: a static render has no request to read it from.
56
+ */
57
+ export async function trWidgetText(locale, msg, textSource) {
58
+ if (textSource === PHI_TEXT_SOURCE_PRESET) {
59
+ return locale ? trGlobalForLocale(locale, msg) : trGlobal(msg);
60
+ }
61
+ return locale ? trForLocale(locale, msg) : tr(msg);
62
+ }
49
63
  /**
50
64
  * Trimmed, one per message and in order: an empty message stays in its place, and `trBulk` answers it
51
65
  * with an empty text, so every answer lines up with the message it belongs to.
@@ -492,10 +492,24 @@ h5.phi-typography, .phi-typography h5 {
492
492
  * as a Table closed with one instead of with a footer. Clipping the Table is what both cases want, which
493
493
  * is why the rule names neither.
494
494
  */
495
+ /*
496
+ * A sticky header row sticks under the Shell's Header, which publishes where it ends right now
497
+ * (`components/shell/use-phi-shell-sticky-top.ts`), and at the top edge of an Overlay's body,
498
+ * which scrolls on its own. Ant Design writes the offset inline, so the position is stated over it.
499
+ */
500
+ .phi-table-control.phi-table-control--sticky-header [class~="ant-table-sticky-holder"] {
501
+ top: var(--phi-table-sticky-top, var(--phi-shell-sticky-top, 0px)) !important;
502
+ }
503
+
504
+ :is([data-phi-modal-body-content], [data-phi-drawer-body-content]) {
505
+ --phi-table-sticky-top: 0px;
506
+ }
507
+
495
508
  .phi-table-control.phi-table-control--closing-block [class~="ant-table"] {
496
509
  border-end-start-radius: var(--phi-surface-radius, var(--ant-border-radius-lg));
497
510
  border-end-end-radius: var(--phi-surface-radius, var(--ant-border-radius-lg));
498
- overflow: hidden;
511
+ /* `clip`, not `hidden`, so the corners are cut without making this the sticky header's scroller. */
512
+ overflow: clip;
499
513
  }
500
514
 
501
515
  /*
package/styles/root.css CHANGED
@@ -114,29 +114,30 @@ samp {
114
114
  animation-timing-function: var(--phi-effects-animation-easing, ease-out);
115
115
  }
116
116
 
117
- :where([data-phi-renderable-block="true"][data-phi-effects-transition="composite"][data-phi-effects-trigger="on_visible"]) {
118
- animation-composition: var(--phi-effects-animation-composition, replace);
119
- animation-delay: var(--phi-effects-animation-delay, 0ms);
120
- animation-duration: var(--phi-effects-animation-duration, 1000ms);
121
- animation-name: var(--phi-effects-animation-name);
122
- animation-play-state: paused;
123
- animation-timing-function: var(--phi-effects-animation-easing, ease-out);
124
- }
125
-
126
- :where([data-phi-renderable-block="true"][data-phi-effects-transition="composite"][data-phi-effects-trigger="on_visible"][data-phi-effects-state="running"]) {
127
- animation-play-state: running;
128
- }
129
-
130
117
  /*
131
- * On ready, the whole rule inside the query that asks whether anything can release it.
118
+ * On visible and on ready, the whole rule inside the query that asks whether anything can release it.
132
119
  *
133
- * The animation is held at its first frame until the page has finished arriving, so a Widget whose
134
- * content the server streams in a segment of its own can land, and everything below it can settle,
135
- * behind an entrance that has not started. Only scripting sets the state that releases it; where there
136
- * is none the Block would stand at that first frame for good, which for a fade in means invisible. So
137
- * without scripting there is no animation at all and the Block is simply there.
120
+ * The animation is held at its first frame -- until the Block is scrolled into view, or until the page
121
+ * has finished arriving, so a Widget whose content the server streams in a segment of its own can land,
122
+ * and everything below it can settle, behind an entrance that has not started. Only scripting sets the
123
+ * state that releases it; where there is none the Block would stand at that first frame for good, which
124
+ * for a fade in means invisible. So without scripting there is no animation at all and the Block is
125
+ * simply there.
138
126
  */
139
127
  @media (scripting: enabled) {
128
+ :where([data-phi-renderable-block="true"][data-phi-effects-transition="composite"][data-phi-effects-trigger="on_visible"]) {
129
+ animation-composition: var(--phi-effects-animation-composition, replace);
130
+ animation-delay: var(--phi-effects-animation-delay, 0ms);
131
+ animation-duration: var(--phi-effects-animation-duration, 1000ms);
132
+ animation-name: var(--phi-effects-animation-name);
133
+ animation-play-state: paused;
134
+ animation-timing-function: var(--phi-effects-animation-easing, ease-out);
135
+ }
136
+
137
+ :where([data-phi-renderable-block="true"][data-phi-effects-transition="composite"][data-phi-effects-trigger="on_visible"][data-phi-effects-state="running"]) {
138
+ animation-play-state: running;
139
+ }
140
+
140
141
  :where([data-phi-renderable-block="true"][data-phi-effects-transition="composite"][data-phi-effects-trigger="on_ready"]) {
141
142
  animation-composition: var(--phi-effects-animation-composition, replace);
142
143
  animation-delay: var(--phi-effects-animation-delay, 0ms);
@@ -1,5 +1,6 @@
1
1
  import type { PhiBulletListEntry, PhiBulletListStyle } from "../components/widgets/shared/bullet-list-vocabulary";
2
2
  import type { PhiRenderableBlockBase } from "./renderable-block";
3
+ import type { PhiTextSource } from "../helpers/text-source";
3
4
  import type { PhiRuntimeConditionExpression } from "./runtime-condition";
4
5
  import type { PhiSignalRoute, PhiSignalValue } from "./signals";
5
6
  import type { PhiCssLength } from "./length";
@@ -71,6 +72,8 @@ export type PhiSimpleTextWidgetPlacement = PhiWidgetPlacementBase & {
71
72
  tone?: PhiTextTone;
72
73
  marks?: PhiSimpleTextMark[];
73
74
  disabled?: boolean;
75
+ /** `"preset"` while the words are a Preset's (`helpers/text-source.ts`). */
76
+ textSource?: PhiTextSource;
74
77
  };
75
78
  export type PhiDescriptionWidgetPlacement = PhiWidgetPlacementBase & {
76
79
  eyebrow?: string;
@@ -81,6 +84,8 @@ export type PhiDescriptionWidgetPlacement = PhiWidgetPlacementBase & {
81
84
  asideList?: PhiBulletListStyle;
82
85
  footer?: string;
83
86
  tone?: PhiTextTone;
87
+ /** `"preset"` while the words are a Preset's (`helpers/text-source.ts`). */
88
+ textSource?: PhiTextSource;
84
89
  };
85
90
  export type PhiButtonWidgetPlacement = PhiWidgetPlacementBase & PhiControlPlacement & PhiControlBadgeConfig & {
86
91
  action?: PhiCommonControlActionKey;
@@ -306,6 +306,8 @@ export type PhiTableWidgetPresentation = PhiControlPresentationConfig & {
306
306
  borders?: boolean;
307
307
  /** The header row; shown unless this says `false`. */
308
308
  header?: boolean;
309
+ /** Whether the header row stays at the top while the rows scroll under it; on unless this says `false`. */
310
+ stickyHeader?: boolean;
309
311
  footer?: PhiTableFooterConfig;
310
312
  summary?: PhiTableSummaryConfig;
311
313
  row?: {