Skip to content

Commit 2239813

Browse files
authored
feat(editor): unify Core Framework into one tabbed panel with declarative management (CoreBunch#123)
Consolidates Colors/Type/Space into one tabbed Framework panel (Overview/Colors/Type/Space), replaces the multi-verb manage modal with a declarative Full/Variables only/None FrameworkManagerDialog routed through the shared confirm + global toast, adds a reusable SplitButton primitive (Publish + Add-font), and fixes a batch of framework bugs (onboarding class reconcile, colors-less merge reset, Add-missing-tokens lockout, colors category grouping). Docs + CLAUDE.md error-toast convention updated.
1 parent 1a5625c commit 2239813

76 files changed

Lines changed: 2582 additions & 1005 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CLAUDE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,8 @@ Every untyped boundary uses TypeBox. Inside the boundary, code trusts the parsed
204204
### UI error handling
205205

206206
- Async UI handlers wrap in `try/catch`. Logged errors use the prefix `console.error('[<component>] <description>:', err)`.
207-
- User-visible errors go through component state + `role="alert"` (or `role="status"` for non-blocking). Never `alert()` / `confirm()` / `prompt()` — gated by `no-native-browser-dialogs.test.ts`.
207+
- **Operation failures surface through the global toast bus**`pushToast({ kind: 'error', title, body })` from `@ui/components/Toast` — for anything a user triggered that then failed (save / import / delete / publish / apply / network call). This is the default for user-visible errors. The single mounted `<ToastProvider />` renders them with `role="alert"`, so you don't hand-roll the a11y. Use `getErrorMessage(err, …)` for the `body`.
208+
- The **only** exception is **field-local, non-blocking validation** that belongs next to a specific control inside a form (e.g. an invalid token name in a dialog) — that may stay inline with `role="alert"` / `role="status"`. Operation results (a request that failed) are NOT field-local; toast them. Never `alert()` / `confirm()` / `prompt()` — gated by `no-native-browser-dialogs.test.ts`.
208209
- Error message extraction: `getErrorMessage(err, 'Unknown <thing> error')` from `src/core/utils/errorMessage.ts` — handles the `instanceof Error` check and the empty-message fallback in one place.
209210
- Soft fallbacks (corrupted localStorage, missing optional config): `parseJsonWithFallback` + continue with defaults.
210211
- Hard fallbacks (corrupted required document, broken HTTP envelope): let the error bubble to the nearest error boundary. Do not silently mask.

docs/editor.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -522,9 +522,9 @@ Opens the rail-selected panel:
522522
- `DomPanel` — layer tree of the current page
523523
- `SiteExplorerPanel` — pages and components roster
524524
- `MediaExplorerPanel` — quick media insert
525-
- `ColorsPanel`, `TypographyPanel`, `SpacingPanel` — site-level design tokens
526-
- `DependenciesPanel` — site package.json / `bun install`
525+
- `FrameworkPanel` — site-level design tokens (the Core Framework) in one panel with **Overview / Colors / Type / Space** tabs. Its "Manage framework" button opens `FrameworkManagerDialog`, a declarative state picker (Full framework / Variables only / None) that reconciles the framework to the chosen target. Sits **above** Selectors in the rail.
527526
- `SelectorsPanel` — CSS class library
527+
- `DependenciesPanel` — site package.json / `bun install`
528528
- `PluginEditorPanel` — plugin-provided editor panels
529529
- `AgentPanel` — AI assistant
530530

@@ -541,7 +541,7 @@ Property controls are driven by the selected node's module schema (`src/core/mod
541541

542542
At the top of the Properties Panel, the selector picker is the single entry point for CSS rules that affect the selected element. Assigned class rules render as removable `TagPill` chips and are stored on `node.classIds`; matching ambient rules render as non-removable `TagPill` chips because they apply by selector matching, not assignment. Ambient rules that match only through a universal subject (`*`, `body.x *`, `*::before`) never render as pills — they style every element in their scope and stay reachable through the dropdown and the Selectors panel instead. The dropdown searches both class rules and ambient selectors, is capped to the picker width budget, and ellipsizes long selector labels instead of expanding across the editor. Ambient rows that do not match the selected canvas element stay visible but disabled with the mismatch reason, and selector-shaped input such as `.hero .title`, `h1`, or `a:hover` creates an ambient rule instead of a class.
543543

544-
The Typography panel stores Google/custom font assets and editable font tokens together under `site.settings.fonts`. Installed font assets own the self-hosted `@font-face` files; font tokens own the builder-facing variables such as `--font-primary`, the assigned font asset, and the fallback stack. The property-panel `font-family` control is a rich picker: token rows write `var(--font-primary)` so the selected node or class keeps following future token swaps, direct font rows write a concrete family stack as an escape hatch, and the text input still accepts manual values.
544+
The Framework panel's **Type** tab stores Google/custom font assets and editable font tokens together under `site.settings.fonts`. Installed font assets own the self-hosted `@font-face` files; font tokens own the builder-facing variables such as `--font-primary`, the assigned font asset, and the fallback stack. The property-panel `font-family` control is a rich picker: token rows write `var(--font-primary)` so the selected node or class keeps following future token swaps, direct font rows write a concrete family stack as an escape hatch, and the text input still accepts manual values.
545545

546546
When the user clicks a rule in the Selectors Panel, the Properties Panel switches to **selector-editing mode** — the body shows style controls for that rule directly, and the header renders `SelectorHeader` with the rule's CSS selector, an inline rename input, and a delete button. The delete and rename actions are only shown for non-generated rules and require `site.style.edit`.
547547

docs/features/site-shell.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -453,7 +453,7 @@ The panel rail's per-viewport canvas iframe shows up automatically when `preview
453453

454454
### Add a color token
455455

456-
The Site → Colors panel calls `createFrameworkColorToken(input)` on the editor store's `siteSlice`. The action writes to `settings.framework.colors.tokens`, then calls `reconcileFrameworkClasses` to sync generated utility classes. Saving updates `framework.css` via `buildSiteFrameworkCss(site)` and republishes affected pages.
456+
The Framework panel's Colors tab calls `createFrameworkColorToken(input)` on the editor store's `siteSlice`. The action writes to `settings.framework.colors.tokens`, then calls `reconcileFrameworkClasses` to sync generated utility classes. Saving updates `framework.css` via `buildSiteFrameworkCss(site)` and republishes affected pages.
457457

458458
### Add a site file
459459

docs/reference/ui-primitives.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ Every interactive control in `src/admin/` goes through one of these. Bare `<butt
2323
| Primitive | When to use | Key props |
2424
|--------------------|----------------------------------------------------------------------|------------------------------------------------------------|
2525
| `Button` | **Every** action button | `variant: 'ghost' \| 'secondary' \| 'primary' \| 'destructive'`, `size: 'micro' \| 'xs' \| 'sm' \| 'md' \| 'lg'`, `iconOnly`, `pressed`, `tooltip` |
26+
| `SplitButton` | A primary action fused to a chevron that opens a dropdown of related actions (Publish ▾, Add Google font ▾). Composes `Button` + `ContextMenu`. | `label`, `onClick`, `menuItems`, `icon?`, `variant?`, `size?`, `busy?`, `disabled?` |
2627
| `Switch` | Boolean toggle (on / off) | `checked`, `onChange`, `disabled` |
2728
| `Checkbox` | Boolean inside a list / form | `checked`, `onChange`, `indeterminate` |
2829
| `SegmentedControl` | A few mutually exclusive options shown inline; `value` can be `undefined` for an unset state where no segment appears pressed | `options`, `value`, `onChange`, `onClear?` (deselectable — clicking the active segment fires `onClear` and shows a hover close-icon overlay) |

src/__tests__/admin/accountPage.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ function setupEditorState() {
108108
focusedPanel: 'canvas',
109109
siteExplorerPanelOpen: false,
110110
selectorsPanelOpen: false,
111-
colorsPanelOpen: false,
111+
frameworkPanelOpen: false,
112112
mediaExplorerPanelOpen: false,
113113
codeEditorPanelOpen: false,
114114
activeEditorFileId: null,

src/__tests__/architecture/button-primitive-usage.test.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -197,13 +197,20 @@ const ALLOWLIST = new Set([
197197
// actions, not the token-driven Button sizes (same as §8.12).
198198
'admin/pages/data/components/ExportDialog/ExportDialog.tsx',
199199

200-
// ── §8.14 Framework import mode cards ────────────────────────────────────
201-
// FrameworkImportModal's two import modes ("Full framework" / "Variables
202-
// only") render as role="radio" cards inside a role="radiogroup" — each is a
203-
// stacked custom layout (icon + title + tick + description + bullet list)
204-
// that Button's inline-flex size tokens cannot represent. Same pattern class
205-
// as §8.7's role="option" card grids (custom ARIA role + multi-line card).
206-
'admin/pages/dashboard/components/FrameworkImportModal.tsx',
200+
// ── §8.14 Framework state cards ──────────────────────────────────────────
201+
// FrameworkManagerDialog's state options ("Full framework" / "Variables
202+
// only" / "None") render as role="radio" cards inside a role="radiogroup" —
203+
// each is a stacked custom layout (icon + title + tick + description + bullet
204+
// list) that Button's inline-flex size tokens cannot represent. Same pattern
205+
// class as §8.7's role="option" card grids (custom ARIA role + multi-line card).
206+
'admin/shared/dialogs/FrameworkManagerDialog/FrameworkManagerDialog.tsx',
207+
208+
// ── §8.15 Framework Home activation cards ────────────────────────────────
209+
// FrameworkHome's Colors/Typography/Space cards are stacked multi-line tiles
210+
// (icon + title + status + count + swatch grid) that switch the panel tab on
211+
// click — the same multi-line custom-layout card class as §8.7 / §8.14 that
212+
// Button's inline-flex size tokens cannot represent.
213+
'admin/pages/site/panels/FrameworkPanel/FrameworkHome.tsx',
207214
])
208215

209216
// ---------------------------------------------------------------------------

src/__tests__/architecture/framework-typography-spacing.test.ts

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77
* and any future server-side code can import it.
88
* - The editor panels MUST live under src/admin/pages/site/components/<Family>Panel/
99
* and consume only the instatic design tokens (no raw hex, no Tailwind).
10-
* - The new icons (`type`, `ruler-dimension`) MUST be on the panel rail and
11-
* reachable through the catalog import path the icon gate already enforces.
10+
* - Colors / Typography / Spacing are consolidated into one Framework rail
11+
* entry; the per-family tabs live inside the FrameworkPanel.
1212
*
1313
* Each gate failure produces a precise, actionable error so a future refactor
1414
* never silently drops a constraint.
@@ -117,22 +117,24 @@ describe('architecture — typography / spacing panels', () => {
117117
describe('architecture — panel rail', () => {
118118
const railSource = readSource('admin/pages/site/sidebars/PanelRail/PanelRail.tsx')
119119

120-
it('rail wires both new panels via the catalog icon imports', () => {
121-
expect(railSource).toContain("from 'pixel-art-icons/icons/text-start-t'")
122-
expect(railSource).toContain("from 'pixel-art-icons/icons/ruler-dimension-solid'")
120+
// Colors / Typography / Spacing were consolidated into one Framework rail
121+
// entry (tabs live inside the FrameworkPanel). The rail now exposes a single
122+
// `framework` item via the colors-swatch catalog icon.
123+
it('rail wires the Framework entry via the catalog icon import', () => {
124+
expect(railSource).toContain("from 'pixel-art-icons/icons/colors-swatch-solid'")
123125
})
124126

125-
it('rail exposes typography + spacing entries with stable ids', () => {
126-
expect(railSource).toMatch(/id:\s*'typography'/)
127-
expect(railSource).toMatch(/id:\s*'spacing'/)
127+
it('rail exposes a single framework entry with a stable id', () => {
128+
expect(railSource).toMatch(/id:\s*'framework'/)
129+
expect(railSource).not.toMatch(/id:\s*'typography'/)
130+
expect(railSource).not.toMatch(/id:\s*'spacing'/)
128131
})
129132
})
130133

131134
describe('architecture — left sidebar', () => {
132135
const layoutSource = readSource('admin/pages/site/sidebars/LeftSidebar/LeftSidebar.tsx')
133136

134-
it('mounts TypographyPanel and SpacingPanel', () => {
135-
expect(layoutSource).toContain('<TypographyPanel />')
136-
expect(layoutSource).toContain('<SpacingPanel />')
137+
it('mounts the consolidated FrameworkPanel', () => {
138+
expect(layoutSource).toContain('<FrameworkPanel />')
137139
})
138140
})
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { beforeEach, describe, expect, it } from 'bun:test'
2+
import { useEditorStore } from '@site/store/store'
3+
import { buildCoreFrameworkSettings } from '@core/framework'
4+
import { makeSite } from '../fixtures'
5+
6+
function resetStore() {
7+
useEditorStore.setState({
8+
site: makeSite(),
9+
activePageId: 'page-1',
10+
selectedNodeId: null,
11+
selectedNodeIds: [],
12+
activeClassId: null,
13+
selectedSelectorClassId: null,
14+
_historyPast: [],
15+
_historyFuture: [],
16+
canUndo: false,
17+
canRedo: false,
18+
hasUnsavedChanges: false,
19+
} as Parameters<typeof useEditorStore.setState>[0])
20+
}
21+
22+
beforeEach(resetStore)
23+
24+
function frameworkRuleCount(): number {
25+
return Object.keys(useEditorStore.getState().site!.styleRules).filter((id) =>
26+
id.startsWith('framework:'),
27+
).length
28+
}
29+
30+
describe('framework manager store actions', () => {
31+
it('setFrameworkPreset("full") seeds the framework and generates locked classes', () => {
32+
useEditorStore.getState().setFrameworkPreset('full')
33+
const state = useEditorStore.getState()
34+
expect(state.site!.settings.framework!.colors.tokens.length).toBe(13)
35+
// Reconcile produced framework-prefixed locked classes in the registry.
36+
expect(frameworkRuleCount()).toBeGreaterThan(0)
37+
})
38+
39+
it('setFrameworkPreset merges (adds missing) without duplicating existing tokens', () => {
40+
useEditorStore.getState().setFrameworkPreset('variables')
41+
const before = useEditorStore.getState().site!.settings.framework!.colors.tokens.length
42+
// Switch to the other state — already-present slugs are not duplicated.
43+
useEditorStore.getState().setFrameworkPreset('full')
44+
const after = useEditorStore.getState().site!.settings.framework!.colors.tokens.length
45+
expect(after).toBe(before)
46+
})
47+
48+
it('switching full → variables strips utility classes; variables → full restores them', () => {
49+
useEditorStore.getState().setFrameworkPreset('full')
50+
expect(frameworkRuleCount()).toBeGreaterThan(0)
51+
52+
// Variables-only: every :root variable stays but no utility class survives.
53+
useEditorStore.getState().setFrameworkPreset('variables')
54+
expect(frameworkRuleCount()).toBe(0)
55+
expect(useEditorStore.getState().site!.settings.framework).toBeDefined()
56+
57+
// Back to full: the canonical preset utilities come back.
58+
useEditorStore.getState().setFrameworkPreset('full')
59+
expect(frameworkRuleCount()).toBeGreaterThan(0)
60+
})
61+
62+
it('setFrameworkPreset("none") clears the framework and is undoable', () => {
63+
useEditorStore.setState({
64+
site: {
65+
...makeSite(),
66+
settings: { framework: buildCoreFrameworkSettings({ includeUtilities: true }) },
67+
},
68+
} as Parameters<typeof useEditorStore.setState>[0])
69+
70+
useEditorStore.getState().setFrameworkPreset('none')
71+
expect(useEditorStore.getState().site!.settings.framework).toBeUndefined()
72+
// No framework-prefixed classes survive in the registry.
73+
expect(frameworkRuleCount()).toBe(0)
74+
75+
useEditorStore.getState().undo()
76+
expect(useEditorStore.getState().site!.settings.framework).toBeDefined()
77+
})
78+
})
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/**
2+
* computeFluidScale must mirror Core Framework's `getTypeScale` exactly.
3+
*
4+
* The key invariant: Core Framework does NOT swap min/max when the min-screen
5+
* size exceeds the max-screen size (its swap is commented out). `min` is the
6+
* size at the min screen width, `max` at the max screen width — an ordering by
7+
* breakpoint, not by value. At steps below the base, the larger max-screen
8+
* ratio compresses the size below the min-screen one, so min > max, and the
9+
* emitted `clamp(min, …, max)` correctly pins to the min-screen size.
10+
*
11+
* Source of truth (verbatim formula):
12+
* core-framework/packages/core/src/components/modules/typography/functions/getTypeScale.ts
13+
*/
14+
import { test, expect } from 'bun:test'
15+
import { computeFluidScale } from '@core/framework'
16+
17+
test('computeFluidScale matches Core Framework getTypeScale — no min/max swap', () => {
18+
// Default typography scale (16→18px, Major Second→Perfect Fourth, 320–1400px).
19+
const scale = computeFluidScale({
20+
minBaseSize: 16,
21+
maxBaseSize: 18,
22+
minScaleRatio: 1.125,
23+
maxScaleRatio: 1.333,
24+
steps: 8, // xs,s,m,l,xl,2xl,3xl,4xl
25+
baseScaleIndex: 2, // base = "m"
26+
minScreenWidth: 320,
27+
maxScreenWidth: 1400,
28+
})
29+
30+
// Step 0 ("xs"), i = -2: the max-screen size (10.13) drops BELOW the
31+
// min-screen size (12.64). Core Framework keeps them unswapped.
32+
expect(scale[0].min).toBe('12.64') // 16 * 1.125^-2
33+
expect(scale[0].max).toBe('10.13') // 18 * 1.333^-2
34+
35+
// Base step ("m", i = 0) is exactly the base sizes at each breakpoint.
36+
expect(scale[2].min).toBe('16')
37+
expect(scale[2].max).toBe('18')
38+
39+
// Above the base the ordering is the intuitive one (min-screen < max-screen).
40+
expect(Number(scale[7].min)).toBeLessThan(Number(scale[7].max)) // "4xl"
41+
})
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { describe, expect, it } from 'bun:test'
2+
import { collectUsedFrameworkClassIds } from '@core/framework'
3+
import type { SiteDocument } from '@core/page-tree'
4+
5+
describe('collectUsedFrameworkClassIds', () => {
6+
it('returns only assigned framework-prefixed ids across pages + VCs', () => {
7+
const site = {
8+
pages: [
9+
{
10+
id: 'p',
11+
nodes: { a: { id: 'a', classIds: ['framework:color:primary:text', 'custom'] } },
12+
},
13+
],
14+
visualComponents: [
15+
{
16+
id: 'vc',
17+
classIds: ['framework:color:dark:bg'],
18+
tree: { nodes: { b: { id: 'b', classIds: ['framework:typography:g:l'] } } },
19+
},
20+
],
21+
} as unknown as SiteDocument
22+
const used = collectUsedFrameworkClassIds(site)
23+
expect(used.has('framework:color:primary:text')).toBe(true)
24+
expect(used.has('framework:color:dark:bg')).toBe(true)
25+
expect(used.has('framework:typography:g:l')).toBe(true)
26+
expect(used.has('custom')).toBe(false)
27+
})
28+
29+
it('tolerates nodes without classIds', () => {
30+
const site = {
31+
pages: [{ id: 'p', nodes: { a: { id: 'a' } } }],
32+
visualComponents: [],
33+
} as unknown as SiteDocument
34+
expect(collectUsedFrameworkClassIds(site).size).toBe(0)
35+
})
36+
})

0 commit comments

Comments
 (0)