@pramen/cms-editor 0.0.60 → 0.0.63

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cover.tsx ADDED
@@ -0,0 +1,163 @@
1
+ // The page header's generated cover art.
2
+ //
3
+ // A Notion page opens with a cover, and a CMS screen has the same problem a Notion page has:
4
+ // six list screens whose only difference is a word at the top read as one screen you keep
5
+ // landing on. The cover is what makes "Media" and "Menus" recognisable before the type is.
6
+ //
7
+ // So it is DERIVED, not chosen: a hash of the screen's name seeds a PRNG, and the PRNG lays
8
+ // out a Truchet field — a grid of quarter-circle arcs whose per-tile orientation is the only
9
+ // random thing about it. Same name, same picture, forever; a new collection gets its own
10
+ // without anyone drawing one. That is the whole appeal over a stock image: nothing to author,
11
+ // nothing to upload, and no screen that looks like another.
12
+ //
13
+ // Two rules govern every choice below, and both come from what this art sits UNDER:
14
+ //
15
+ // LEGIBILITY. The header carries 56px type and the primary action. Line work is
16
+ // `currentColor` at low alpha — a tint of the foreground, so it is dark on the light theme
17
+ // and light on the dark one BY CONSTRUCTION. That matters here specifically: podoba does
18
+ // not redefine its accent tokens per theme, so a fixed accent stroke would be near-white
19
+ // mint on a white ground in light, or near-black blue on a dark ground in dark. Colour
20
+ // therefore arrives as a WASH at ~0.14 alpha, which survives either ground, and the left
21
+ // third fades to the panel colour so the title never sits on pattern.
22
+ //
23
+ // NO DEPENDENCY, NO CANVAS, NO NETWORK. It is one inline SVG built during render: ~50
24
+ // paths, no measuring, no effects, no images to load, and it prints and scales.
25
+
26
+ import type { ReactElement } from "react";
27
+
28
+ /** Tiles across and down. The banner is wide and short and the SVG is `slice`-fitted, so
29
+ * these are a RATIO more than a count: the field fits by WIDTH and is cropped vertically,
30
+ * which is what makes it bleed to the panel edges at any header height.
31
+ *
32
+ * Tuned by looking at it. A 14×3 field puts ~80px tiles in a 1140px panel, and at that size a
33
+ * Truchet arc stops being a woven line and becomes a big soft loop — organic, which is the
34
+ * wrong register next to Swiss type. Finer tiles read as the precise, drawn-by-a-machine
35
+ * pattern this is meant to be. */
36
+ const COLS = 20;
37
+ const ROWS = 5;
38
+
39
+ /**
40
+ * FNV-1a, 32-bit.
41
+ *
42
+ * Not for security — for a stable, well-mixed number from a short string. Stability is the
43
+ * requirement that rules out `Math.random` and anything else stateful: the same screen has to
44
+ * draw the same picture on every render, in every browser, after every deploy.
45
+ *
46
+ * `>>> 0` after the multiply keeps it in unsigned 32-bit; JS bitwise ops work on int32, so
47
+ * without it the accumulator goes negative and the mixing degrades.
48
+ */
49
+ export function hashSeed(input: string): number {
50
+ let h = 0x811c9dc5;
51
+ for (let i = 0; i < input.length; i++) {
52
+ h ^= input.charCodeAt(i);
53
+ h = Math.imul(h, 0x01000193) >>> 0;
54
+ }
55
+ return h >>> 0;
56
+ }
57
+
58
+ /** mulberry32 — a tiny, well-distributed PRNG. Seeded once per cover, so the sequence (and
59
+ * therefore the picture) is a pure function of the name. */
60
+ export function seededRandom(seed: number): () => number {
61
+ let a = seed >>> 0;
62
+ return () => {
63
+ a = (a + 0x6d2b79f5) >>> 0;
64
+ let t = a;
65
+ t = Math.imul(t ^ (t >>> 15), t | 1);
66
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
67
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
68
+ };
69
+ }
70
+
71
+ /**
72
+ * The accents a wash may use.
73
+ *
74
+ * A CLOSED list of podoba tokens, not a hue rotation: the point is a page that looks like it
75
+ * belongs to this product, and a free hue would put colours in the chrome that exist nowhere
76
+ * else in it. Every entry is mid-toned, which is what lets one alpha work on both the white
77
+ * and the near-black ground — the very light tokens (`accent-mint`) vanish on white and the
78
+ * very dark one (`accent-blue`) vanishes on the dark theme, so neither is in here.
79
+ */
80
+ export const COVER_ACCENTS: readonly string[] = [
81
+ "var(--color-accent-strong)",
82
+ "var(--color-brand-green)",
83
+ "var(--color-accent-green-lighter)",
84
+ "var(--color-accent-yellow)",
85
+ "var(--color-accent-pink)",
86
+ ];
87
+
88
+ /** Which accent a given name gets. Exported because it is the one part of the picture worth
89
+ * asserting on directly: it must be stable, and it must be one of the five. */
90
+ export function accentFor(seed: string): string {
91
+ return COVER_ACCENTS[hashSeed(`${seed}:accent`) % COVER_ACCENTS.length] as string;
92
+ }
93
+
94
+ /** One Truchet tile: two quarter arcs joining opposite edge midpoints, in one of two
95
+ * orientations. Both are drawn at unit scale and translated into place, so the whole field is
96
+ * resolution-independent and the stroke scales with the banner. */
97
+ function tilePath(col: number, row: number, flipped: boolean): string {
98
+ const x = col;
99
+ const y = row;
100
+ return flipped
101
+ ? `M${x + 0.5} ${y} A0.5 0.5 0 0 0 ${x + 1} ${y + 0.5} M${x} ${y + 0.5} A0.5 0.5 0 0 0 ${x + 0.5} ${y + 1}`
102
+ : `M${x} ${y + 0.5} A0.5 0.5 0 0 0 ${x + 0.5} ${y} M${x + 1} ${y + 0.5} A0.5 0.5 0 0 0 ${x + 0.5} ${y + 1}`;
103
+ }
104
+
105
+ /**
106
+ * The cover for one screen, seeded by its name.
107
+ *
108
+ * Absolutely positioned to fill its (relative, `overflow-hidden`) parent — it is a
109
+ * background, and giving it its own box would make the header's height depend on the art
110
+ * rather than on the type in it.
111
+ *
112
+ * `aria-hidden`: it carries no information a reader could act on. The screen's name is in the
113
+ * `<h1>` right next to it, so announcing "image" here would add a stop on the way to the
114
+ * thing that is actually being labelled.
115
+ */
116
+ export function CoverArt({ seed }: { seed: string }): ReactElement {
117
+ const rand = seededRandom(hashSeed(seed));
118
+ const accent = accentFor(seed);
119
+ // The wash's origin, kept to the RIGHT half: the title is set left, and colour pooling
120
+ // under it is exactly the contrast the header cannot afford.
121
+ const washX = 55 + rand() * 40;
122
+ const washY = 20 + rand() * 60;
123
+
124
+ const tiles: string[] = [];
125
+ for (let row = 0; row < ROWS; row++) {
126
+ for (let col = 0; col < COLS; col++) tiles.push(tilePath(col, row, rand() > 0.5));
127
+ }
128
+ // A few filled nodes where arcs meet — the one place the accent appears at full strength,
129
+ // and what stops a page's identity from being carried by geometry alone (two names can land
130
+ // on similar-looking fields; they will not also land on the same colour).
131
+ const dots = Array.from({ length: 5 }, () => ({
132
+ cx: 1 + rand() * (COLS - 2),
133
+ cy: 0.5 + rand() * (ROWS - 1),
134
+ r: 0.09 + rand() * 0.11,
135
+ }));
136
+
137
+ return (
138
+ <svg
139
+ aria-hidden="true"
140
+ className="pointer-events-none absolute inset-0 h-full w-full text-fg"
141
+ viewBox={`0 0 ${COLS} ${ROWS}`}
142
+ preserveAspectRatio="xMidYMid slice"
143
+ >
144
+ <defs>
145
+ <radialGradient id={`wash-${hashSeed(seed)}`} cx={`${washX}%`} cy={`${washY}%`} r="70%">
146
+ <stop offset="0%" stopColor={accent} stopOpacity="0.34" />
147
+ <stop offset="100%" stopColor={accent} stopOpacity="0" />
148
+ </radialGradient>
149
+ </defs>
150
+ <rect width={COLS} height={ROWS} fill={`url(#wash-${hashSeed(seed)})`} />
151
+ <g fill="none" stroke="currentColor" strokeWidth="0.035" strokeLinecap="round" opacity="0.28">
152
+ {tiles.map((d) => (
153
+ <path key={d} d={d} />
154
+ ))}
155
+ </g>
156
+ <g fill={accent} opacity="0.5">
157
+ {dots.map((c) => (
158
+ <circle key={`${c.cx}-${c.cy}`} cx={c.cx} cy={c.cy} r={c.r} />
159
+ ))}
160
+ </g>
161
+ </svg>
162
+ );
163
+ }
package/src/furniture.tsx CHANGED
@@ -11,29 +11,25 @@
11
11
 
12
12
  import { Button, Heading, Input } from "@podoba/react";
13
13
  import { useCallback, useEffect, useState } from "react";
14
- import { useUnsavedGuard } from "./app-context";
14
+ import { useApp, useUnsavedGuard } from "./app-context";
15
15
  import type { Api } from "./api";
16
16
  import { CONTROL, RichText, slugify } from "./fields";
17
17
  import { ROW, WRAP } from "./chrome";
18
+ import { useCrumb } from "./breadcrumb";
19
+ import { PageHeader } from "./page-header";
18
20
  import type { CollectionMeta, Menu, MenuItem, MenuItemKind, Page, Redirect, RichTextDoc, Taxonomy, Term, Widget, WidgetArea } from "./types";
19
- import { MAX_MENU_DEPTH, REDIRECT_STATUSES } from "./types";
21
+ import { MAX_MENU_DEPTH, REDIRECT_STATUSES, TAXONOMY_TARGET_LABELS, TAXONOMY_TARGETS } from "./types";
22
+ import type { TaxonomyTarget } from "./types";
20
23
 
21
24
 
22
25
  export function errText(e: unknown): string {
23
26
  return String((e as Error)?.message ?? e);
24
27
  }
25
28
 
26
- function Head({ lead, em, children }: { lead: string; em: string; children?: React.ReactNode }) {
27
- return (
28
- <div className="mb-6 mt-6 flex items-end justify-between gap-6 max-[820px]:flex-col max-[820px]:items-start">
29
- <h1 className="m-0 text-[40px] font-normal leading-[1.1] tracking-[-0.01em]">
30
- <span className="block text-fg-subtle">{lead}</span>
31
- <span className="block text-fg">{em}</span>
32
- </h1>
33
- {children}
34
- </div>
35
- );
36
- }
29
+ /** The site-furniture screens' header. `Head` stays as the local name the four call sites
30
+ * below already use; it is the same component, at the same scale, as every other screen —
31
+ * see the note in `page-header.tsx` about why the smaller variant went away. */
32
+ const Head = PageHeader;
37
33
 
38
34
  function Saved() {
39
35
  return <div className="rounded-lg border border-brand-green bg-brand-green/20 px-3.5 py-2.5 text-small text-fg">saved</div>;
@@ -84,9 +80,10 @@ export function MenusView({ api, onOpen, onError, canEdit }: { api: Api; onOpen:
84
80
  };
85
81
 
86
82
  return (
87
- <div className={WRAP}>
83
+ <>
88
84
  <Head lead="Navigation" em={menus === null ? "Menus" : menus.length === 1 ? "1 menu" : `${menus.length} menus`} />
89
- <div className="flex flex-col gap-2">
85
+ <div className={WRAP}>
86
+ <div className="flex flex-col gap-2">
90
87
  {menus === null ? <p className="text-fg-subtle">Loading…</p> : null}
91
88
  {menus?.length === 0 ? <p className="text-fg-subtle">No menus yet. A menu is read by name — <code>getMenu(&quot;primary&quot;)</code> — from your layout.</p> : null}
92
89
  {(menus ?? []).map((m) => (
@@ -110,7 +107,8 @@ export function MenusView({ api, onOpen, onError, canEdit }: { api: Api; onOpen:
110
107
  <Button className="mt-3" onPress={create} isDisabled={busy || !label.trim() || !name.trim()}>Create menu</Button>
111
108
  </div>
112
109
  ) : null}
113
- </div>
110
+ </div>
111
+ </>
114
112
  );
115
113
  }
116
114
 
@@ -166,6 +164,9 @@ export function MenuEditor({ api, name, collections, onBack, onDeleted, onError,
166
164
  }) {
167
165
  const [menu, setMenu] = useState<Menu | null>(null);
168
166
  const [items, setItems] = useState<MenuItem[]>([]);
167
+ // The app bar's trailing crumb — the menu's LABEL once it has loaded, not the `name` key in
168
+ // the URL, which is what a layout gets to publish and is not what an editor calls it.
169
+ useCrumb(menu?.label);
169
170
  const [label, setLabel] = useState("");
170
171
  // The whole tree is edited locally and written only by "Save menu", so leaving the screen
171
172
  // discards it. Nothing prompted before this — `PageEditor` was the only screen that ever
@@ -440,9 +441,10 @@ export function RedirectsView({ api, onError, canEdit }: { api: Api; onError: (s
440
441
  };
441
442
 
442
443
  return (
443
- <div className={WRAP}>
444
+ <>
444
445
  <Head lead="Old URLs, kept alive" em={rows === null ? "Redirects" : rows.length === 1 ? "1 redirect" : `${rows.length} redirects`} />
445
- <p className="mb-4 max-w-[62ch] text-sm text-fg-muted">
446
+ <div className={WRAP}>
447
+ <p className="mb-4 max-w-[62ch] text-sm text-fg-muted">
446
448
  Changing a page&apos;s slug changes a live URL and breaks every link to it. A redirect is how the old one keeps working.
447
449
  Disabling one keeps the record of what the old URL was, which deleting it does not.
448
450
  </p>
@@ -486,21 +488,76 @@ export function RedirectsView({ api, onError, canEdit }: { api: Api; onError: (s
486
488
  <Button className="mt-3" onPress={create} isDisabled={busy || !from.trim() || !to.trim()}>Add redirect</Button>
487
489
  </div>
488
490
  ) : null}
489
- </div>
491
+ </div>
492
+ </>
490
493
  );
491
494
  }
492
495
 
493
496
  // --- taxonomies -----------------------------------------------------------------------
494
497
 
498
+ /** What a vocabulary classifies. `null` is EVERYTHING, and it is a real state rather than a
499
+ * shorthand for "all boxes ticked": a vocabulary that was never narrowed keeps applying to
500
+ * whatever the CMS grows next, where an explicit `["page","media"]` freezes it at today's two.
501
+ * So "Everything" is its own option, not the all-checked case. */
502
+ function AppliesToField({ value, onChange, disabled }: { value: TaxonomyTarget[] | null; onChange: (v: TaxonomyTarget[] | null) => void; disabled?: boolean }) {
503
+ const toggle = (t: TaxonomyTarget) => {
504
+ const next = (value ?? []).includes(t) ? (value ?? []).filter((x) => x !== t) : [...(value ?? []), t];
505
+ // Unchecking the last one would mean a vocabulary nothing can use, which the server
506
+ // refuses — so it lands back on "Everything", the nearest thing the person meant.
507
+ onChange(next.length === 0 ? null : next);
508
+ };
509
+ return (
510
+ <div className="mt-3">
511
+ <span className="text-caption text-fg-subtle">Applies to</span>
512
+ {/* "Everything" gets its own line rather than sitting in the row as a third peer: a radio
513
+ beside two checkboxes reads as one group with mismatched controls, when it is actually
514
+ the choice ABOVE them — pick everything, or pick which. */}
515
+ <div className="mt-1 flex flex-col gap-1">
516
+ <label className="flex items-center gap-2">
517
+ <input type="radio" checked={value === null} disabled={disabled} onChange={() => onChange(null)} />
518
+ <span className="text-sm text-fg">Everything</span>
519
+ </label>
520
+ <div className="flex flex-wrap items-center gap-x-4 gap-y-1 pl-5">
521
+ {TAXONOMY_TARGETS.map((t) => (
522
+ <label key={t} className="flex items-center gap-2">
523
+ <input type="checkbox" checked={(value ?? []).includes(t)} disabled={disabled} onChange={() => toggle(t)} />
524
+ <span className="text-sm text-fg">{TAXONOMY_TARGET_LABELS[t]}</span>
525
+ </label>
526
+ ))}
527
+ </div>
528
+ </div>
529
+ </div>
530
+ );
531
+ }
532
+
533
+ /** How a vocabulary's scope reads in a list row. */
534
+ function appliesToText(t: Taxonomy): string {
535
+ const list = t.appliesTo;
536
+ if (!Array.isArray(list) || list.length === 0) return "everything";
537
+ return list.map((x) => TAXONOMY_TARGET_LABELS[x] ?? x).join(" + ").toLowerCase();
538
+ }
539
+
540
+
495
541
  export function TaxonomiesView({ api, onOpen, onError, canEdit }: { api: Api; onOpen: (slug: string) => void; onError: (s: string) => void; canEdit: boolean }) {
496
542
  const [taxa, setTaxa] = useState<Taxonomy[] | null>(null);
497
543
  const [label, setLabel] = useState("");
498
544
  const [slug, setSlug] = useState("");
499
545
  const [slugTouched, setSlugTouched] = useState(false);
500
546
  const [hierarchical, setHierarchical] = useState(false);
547
+ // `null` is "everything", which is also what an un-narrowed vocabulary stores — so the
548
+ // default here is the same value the server would have written anyway.
549
+ const [appliesTo, setAppliesTo] = useState<TaxonomyTarget[] | null>(null);
501
550
  const [busy, setBusy] = useState(false);
551
+ // Scoping a vocabulary and media tagging shipped together, and the flag is the same one for
552
+ // that reason: a server either has both or neither, and a second capability for the same
553
+ // release would be one that can never be false on its own. On an older server the field is
554
+ // hidden rather than offered and silently dropped.
555
+ const { cms: { mediaTerms: scopable } } = useApp();
502
556
 
503
557
  const refresh = useCallback(() => {
558
+ // No target: this is the screen that EDITS the scope, so it has to show a vocabulary it
559
+ // has narrowed away — otherwise narrowing one to Media would remove it from the only
560
+ // place that could widen it again.
504
561
  api.listTaxonomies().then(setTaxa).catch((e) => { setTaxa([]); onError(errText(e)); });
505
562
  }, [api, onError]);
506
563
  useEffect(refresh, [refresh]);
@@ -508,16 +565,19 @@ export function TaxonomiesView({ api, onOpen, onError, canEdit }: { api: Api; on
508
565
  const create = async () => {
509
566
  setBusy(true);
510
567
  try {
511
- const created = await api.createTaxonomy({ slug, label, hierarchical });
512
- setLabel(""); setSlug(""); setSlugTouched(false); setHierarchical(false);
568
+ // On a server that cannot scope a vocabulary, `null` is what it would store anyway —
569
+ // so this sends the same value the field's default means rather than a conditional key.
570
+ const created = await api.createTaxonomy({ slug, label, hierarchical, appliesTo: scopable ? appliesTo : null });
571
+ setLabel(""); setSlug(""); setSlugTouched(false); setHierarchical(false); setAppliesTo(null);
513
572
  onOpen(created.slug);
514
573
  } catch (e) { onError(errText(e)); } finally { setBusy(false); }
515
574
  };
516
575
 
517
576
  return (
518
- <div className={WRAP}>
577
+ <>
519
578
  <Head lead="How this site" em="is classified" />
520
- <p className="mb-4 max-w-[62ch] text-sm text-fg-muted">
579
+ <div className={WRAP}>
580
+ <p className="mb-4 max-w-[62ch] text-sm text-fg-muted">
521
581
  A vocabulary is a way of grouping pages — categories, tags, regions. There are no built-in ones:
522
582
  a deployment declares what it sorts by, the same way it declares its content types.
523
583
  </p>
@@ -529,6 +589,7 @@ export function TaxonomiesView({ api, onOpen, onError, canEdit }: { api: Api; on
529
589
  <span className="min-w-0 flex-1 truncate font-medium">{t.label}</span>
530
590
  <span className="shrink-0 truncate text-fg-subtle">{t.slug}</span>
531
591
  <span className="shrink-0 text-caption text-fg-subtle">{t.hierarchical ? "nested" : "flat"}</span>
592
+ {scopable ? <span className="shrink-0 text-caption text-fg-subtle">{appliesToText(t)}</span> : null}
532
593
  </div>
533
594
  ))}
534
595
  </div>
@@ -546,10 +607,12 @@ export function TaxonomiesView({ api, onOpen, onError, canEdit }: { api: Api; on
546
607
  <input type="checkbox" checked={hierarchical} onChange={(e) => setHierarchical(e.target.checked)} />
547
608
  <span className="text-sm text-fg">Terms can nest (categories rather than tags)</span>
548
609
  </label>
610
+ {scopable ? <AppliesToField value={appliesTo} onChange={setAppliesTo} /> : null}
549
611
  <Button className="mt-3" onPress={create} isDisabled={busy || !label.trim() || !slug.trim()}>Create vocabulary</Button>
550
612
  </div>
551
613
  ) : null}
552
- </div>
614
+ </div>
615
+ </>
553
616
  );
554
617
  }
555
618
 
@@ -569,6 +632,7 @@ export function TaxonomyEditor({ api, slug, onBack, onDeleted, onError, canEdit
569
632
  const [termSlugTouched, setTermSlugTouched] = useState(false);
570
633
  const [parentId, setParentId] = useState("");
571
634
  const [busy, setBusy] = useState(false);
635
+ const { cms: { mediaTerms: scopable } } = useApp();
572
636
 
573
637
  const refreshTerms = useCallback(() => {
574
638
  api.getTermTree(slug).then(setTree).catch((e) => { setTree([]); onError(errText(e)); });
@@ -606,6 +670,23 @@ export function TaxonomyEditor({ api, slug, onBack, onDeleted, onError, canEdit
606
670
  if (label === t.label) return;
607
671
  try { await api.updateTerm(t.id, { label }); refreshTerms(); } catch (e) { onError(errText(e)); }
608
672
  };
673
+ /** Saved on change rather than behind a Save button, because a narrowing can be REFUSED —
674
+ * the server rejects one that would strand existing assignments — and a refusal has to be
675
+ * visible while the choice that caused it is still on screen. On refusal the field goes back
676
+ * to what is stored, so it never shows a scope the server did not accept. */
677
+ const setAppliesTo = async (next: TaxonomyTarget[] | null) => {
678
+ if (!tax) return;
679
+ const previous = tax.appliesTo ?? null;
680
+ setTax({ ...tax, appliesTo: next });
681
+ try {
682
+ onError("");
683
+ await api.updateTaxonomy(tax.id, { appliesTo: next });
684
+ } catch (e) {
685
+ setTax({ ...tax, appliesTo: previous });
686
+ onError(errText(e));
687
+ }
688
+ };
689
+
609
690
  const delTaxonomy = async () => {
610
691
  if (!tax || !confirm(`Delete the vocabulary “${tax.label}”? Every term in it goes too, along with every page's assignments.`)) return;
611
692
  try { await api.deleteTaxonomy(tax.id); onDeleted(); } catch (e) { onError(errText(e)); }
@@ -665,6 +746,17 @@ export function TaxonomyEditor({ api, slug, onBack, onDeleted, onError, canEdit
665
746
  </div>
666
747
  ) : null}
667
748
 
749
+ {canEdit && scopable ? (
750
+ <div className="rounded-lg border border-border bg-surface-muted p-4">
751
+ <Heading level="2" className="mb-1 font-normal">Where this vocabulary is offered</Heading>
752
+ <p className="max-w-[62ch] text-caption text-fg-subtle">
753
+ Narrowing is refused while the vocabulary is still assigned to something it would stop applying to —
754
+ remove those assignments first, so nothing is left tagged with a vocabulary you can no longer see.
755
+ </p>
756
+ <AppliesToField value={tax.appliesTo ?? null} onChange={setAppliesTo} />
757
+ </div>
758
+ ) : null}
759
+
668
760
  {canEdit ? <Button variant="ghost" className="self-start text-danger" onPress={delTaxonomy}>Delete this vocabulary</Button> : null}
669
761
  </div>
670
762
  </div>
@@ -706,9 +798,10 @@ export function WidgetAreasView({ api, onOpen, onError, canEdit }: { api: Api; o
706
798
  };
707
799
 
708
800
  return (
709
- <div className={WRAP}>
801
+ <>
710
802
  <Head lead="Parts of the layout" em="you can fill in" />
711
- <p className="mb-4 max-w-[62ch] text-sm text-fg-muted">
803
+ <div className={WRAP}>
804
+ <p className="mb-4 max-w-[62ch] text-sm text-fg-muted">
712
805
  A widget area is a named slot in your layout — a sidebar, a footer column — that an editor fills without touching code.
713
806
  Your layout reads one by name: <code>getWidgetArea(&quot;sidebar&quot;)</code>.
714
807
  </p>
@@ -736,7 +829,8 @@ export function WidgetAreasView({ api, onOpen, onError, canEdit }: { api: Api; o
736
829
  <Button className="mt-3" onPress={create} isDisabled={busy || !label.trim() || !name.trim()}>Create widget area</Button>
737
830
  </div>
738
831
  ) : null}
739
- </div>
832
+ </div>
833
+ </>
740
834
  );
741
835
  }
742
836
 
package/src/icons.tsx ADDED
@@ -0,0 +1,97 @@
1
+ // The editor's icon family: Phosphor, regular weight.
2
+ //
3
+ // ONE family, chosen for its plainness — at 15px in a nav rail an icon has to survive as a
4
+ // silhouette, and Phosphor's regular weight is the simplest set that still reads at that
5
+ // size. It replaces two half-families: podoba's own line set (which does not cover a CMS's
6
+ // nouns — no taxonomy, no widget area, no redirect) and, next to it, the glyphs this file
7
+ // used to draw by hand to fill those gaps. Two sets of hand-fitted curves side by side is
8
+ // the one outcome worse than either.
9
+ //
10
+ // Everything the editor draws comes from HERE, aliased to a name that says what it means in
11
+ // this app rather than what it depicts. That is not ceremony: it is what makes the family a
12
+ // decision recorded in one file. Swapping it (or moving it into @podoba/react, where it
13
+ // belongs once the design system adopts a set) is then this module's imports and nothing
14
+ // else — no call site names a vendor.
15
+ //
16
+ // A HOST's own icon is a separate thing and stays a separate thing: a collection or a Block
17
+ // Kit page declares `icon: "🎓"` and that string goes in the icon column verbatim (see
18
+ // `NavIcon` in nav.ts). Resolving such a string against Phosphor BY NAME is deliberately not
19
+ // offered — a by-name lookup needs the whole 3000-icon registry in the bundle, which is
20
+ // megabytes to let a deployment name one glyph it can already supply directly.
21
+
22
+ import {
23
+ ArrowBendUpRightIcon,
24
+ ArrowSquareOutIcon,
25
+ CaretDownIcon,
26
+ CaretRightIcon,
27
+ FileTextIcon,
28
+ FolderIcon,
29
+ GearIcon,
30
+ type Icon,
31
+ ImageIcon,
32
+ LayoutIcon,
33
+ ListDashesIcon,
34
+ ListIcon,
35
+ MoonIcon,
36
+ SidebarSimpleIcon,
37
+ SignOutIcon,
38
+ SquaresFourIcon,
39
+ StackIcon,
40
+ SunIcon,
41
+ TagIcon,
42
+ UsersIcon,
43
+ } from "@phosphor-icons/react";
44
+ import type { NavGlyph } from "./nav";
45
+
46
+ /** A drawable glyph. Phosphor's own component type, re-exported so a call site can hold one
47
+ * without importing the vendor. */
48
+ export type { Icon };
49
+
50
+ // --- the chrome's own controls -----------------------------------------------------------
51
+ //
52
+ // Named for the JOB, not the picture. `MenuToggleIcon` is the hamburger that opens the rail
53
+ // below `md`; calling it `ListIcon` at the call site would put it one careless edit away from
54
+ // standing in for the Menus SECTION, which is a destination and a different glyph.
55
+
56
+ /** Opens the collapsed rail below `md`. */
57
+ export const MenuToggleIcon = ListIcon;
58
+ /** Collapses the rail to icons, and brings it back. ONE glyph for both directions: the
59
+ * button is a toggle, and swapping the picture per state makes the reader decide what the
60
+ * new picture means before they can decide whether to press it. `aria-expanded` carries the
61
+ * state, which is where a state belongs. */
62
+ export const RailToggleIcon = SidebarSimpleIcon;
63
+ /** A group that is open — points down, as every file tree has agreed. */
64
+ export const GroupOpenIcon = CaretDownIcon;
65
+ /** …and one that is folded. */
66
+ export const GroupFoldedIcon = CaretRightIcon;
67
+ /** Switch to the light theme (shown while dark is active). */
68
+ export const LightThemeIcon = SunIcon;
69
+ /** …and the reverse. */
70
+ export const DarkThemeIcon = MoonIcon;
71
+ /** Drop the session. */
72
+ export { SignOutIcon };
73
+ /** The account menu's settings entry — the same glyph the nav's Settings row uses. */
74
+ export { GearIcon as SettingsIcon };
75
+
76
+ // --- the nav's destinations ---------------------------------------------------------------
77
+
78
+ /** Glyph name -> component. The layout renders `NAV_GLYPHS[entry.icon.name]`; `satisfies`
79
+ * rather than an annotation, so the mapping stays exhaustive over `NavGlyph` (a glyph with no
80
+ * drawing is a compile error, not a blank square in the rail) without widening every value to
81
+ * the shared signature. */
82
+ export const NAV_GLYPHS = {
83
+ pages: FileTextIcon,
84
+ collection: FolderIcon,
85
+ media: ImageIcon,
86
+ // NOT the hamburger: that is the control that opens this rail, and one glyph cannot mean
87
+ // both "open the nav" and "the Menus section".
88
+ menus: ListDashesIcon,
89
+ taxonomies: TagIcon,
90
+ widgets: LayoutIcon,
91
+ redirects: ArrowBendUpRightIcon,
92
+ app: SquaresFourIcon,
93
+ types: StackIcon,
94
+ users: UsersIcon,
95
+ settings: GearIcon,
96
+ link: ArrowSquareOutIcon,
97
+ } satisfies Record<NavGlyph, Icon>;