From a236e4866460dfe30bc2c19e6d8c8da8549a0cc8 Mon Sep 17 00:00:00 2001 From: mochi Date: Fri, 5 Jun 2026 14:50:01 -0400 Subject: [PATCH] refactor!: Control/Field taxonomy + Panel/Section chrome rename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a two-tier widget model and clearer chrome names. No external consumers yet, so the breaking renames land now. Controls vs Fields (controls/, was fields/): - Extract a bare `*Control` for every value input (Text/Number/Textarea/ Slider/Select/Color/Boolean) — pure widget: value/onChange, widget props, readonly, a `level` prop (→ data-stage-level) + forwarded validation handlers. No caption, no Field, no validation. - Refactor each `*Field` to compose its control as the single source of truth (BooleanField excepted — its caption must live inside the Kobalte root for label association, so it keeps bespoke wiring; BooleanControl is a parallel bare widget). - Share `ControlValidationProps` (level + focus/hover handlers). Chrome rename (layout/, was panes/): - SidePane → Panel, Module → Section, EditorPanes → EditorPanels. - EditorPanels slot props leftSide/rightSide → leftPanel/rightPanel. - localStorage prefixes stage-pane:/stage-module: → stage-panel:/stage-section:. - CSS: .stage-pane*/.stage-module* → .stage-panel*/.stage-section*; matching token renames (panel-header-height, section-header-pad/body-pad, color-section-header-*). Other: - LabelField → Label (bare display, no suffix). - Add seven `*Control` exports + types; bare-control demo in the playground. - Update architecture.md (Control/Field taxonomy), README, CLAUDE.md, NEXT-UP. typecheck + 248 tests + build all green. --- CLAUDE.md | 9 +- NEXT-UP.md | 10 +- README.md | 91 +++--- docs/architecture.md | 208 +++++++------ .../ActionButton.test.tsx | 0 .../src/{fields => controls}/ActionButton.tsx | 0 .../src/{fields => controls}/BaseButton.tsx | 0 .../src/controls/BooleanControl.test.tsx | 72 +++++ .../src/controls/BooleanControl.tsx | 88 ++++++ .../BooleanField.test.tsx | 0 .../src/{fields => controls}/BooleanField.tsx | 0 .../{fields => controls}/ButtonGroup.test.tsx | 0 .../src/{fields => controls}/ButtonGroup.tsx | 0 .../ButtonGroupField.test.tsx | 0 .../{fields => controls}/ButtonGroupField.tsx | 0 .../src/controls/ColorControl.test.tsx | 54 ++++ .../solid-stage/src/controls/ColorControl.tsx | 256 ++++++++++++++++ .../{fields => controls}/ColorField.test.tsx | 0 .../solid-stage/src/controls/ColorField.tsx | 63 ++++ .../src/{fields => controls}/Field.test.tsx | 0 .../src/{fields => controls}/Field.tsx | 0 .../{fields => controls}/FieldBar.test.tsx | 0 .../src/{fields => controls}/FieldBar.tsx | 0 .../{fields => controls}/FieldGroup.test.tsx | 0 .../src/{fields => controls}/FieldGroup.tsx | 0 .../{fields => controls}/IconButton.test.tsx | 0 .../src/{fields => controls}/IconButton.tsx | 0 .../IconButtonField.test.tsx | 0 .../{fields => controls}/IconButtonField.tsx | 0 .../{fields => controls}/IconToggle.test.tsx | 0 .../src/{fields => controls}/IconToggle.tsx | 0 .../IconToggleField.test.tsx | 0 .../{fields => controls}/IconToggleField.tsx | 0 .../{fields => controls}/InfoIcon.test.tsx | 0 .../src/{fields => controls}/InfoIcon.tsx | 0 .../Label.test.tsx} | 24 +- .../LabelField.tsx => controls/Label.tsx} | 10 +- .../src/controls/NumberControl.test.tsx | 52 ++++ .../src/controls/NumberControl.tsx | 69 +++++ .../{fields => controls}/NumberField.test.tsx | 0 .../solid-stage/src/controls/NumberField.tsx | 66 +++++ .../src/controls/SelectControl.test.tsx | 86 ++++++ .../SelectControl.tsx} | 107 +++---- .../{fields => controls}/SelectField.test.tsx | 0 .../solid-stage/src/controls/SelectField.tsx | 78 +++++ .../src/controls/SliderControl.test.tsx | 51 ++++ .../src/controls/SliderControl.tsx | 80 +++++ .../{fields => controls}/SliderField.test.tsx | 0 .../solid-stage/src/controls/SliderField.tsx | 62 ++++ .../src/controls/TextControl.test.tsx | 49 ++++ .../solid-stage/src/controls/TextControl.tsx | 44 +++ .../{fields => controls}/TextField.test.tsx | 0 .../src/{fields => controls}/TextField.tsx | 33 +-- .../src/controls/TextareaControl.test.tsx | 51 ++++ .../src/controls/TextareaControl.tsx | 74 +++++ .../TextareaField.test.tsx | 0 .../src/controls/TextareaField.tsx | 65 +++++ .../ToggleButton.test.tsx | 0 .../src/{fields => controls}/ToggleButton.tsx | 0 .../createFieldValidation.ts | 15 + .../solid-stage/src/fields/ColorField.tsx | 273 ------------------ .../solid-stage/src/fields/NumberField.tsx | 91 ------ .../solid-stage/src/fields/SliderField.tsx | 96 ------ .../solid-stage/src/fields/TextareaField.tsx | 102 ------- packages/solid-stage/src/index.ts | 74 +++-- .../EditorPanels.test.tsx} | 144 ++++----- .../EditorPanels.tsx} | 88 +++--- .../Panel.test.tsx} | 150 +++++----- .../{panes/SidePane.tsx => layout/Panel.tsx} | 98 +++---- .../Section.test.tsx} | 194 ++++++------- .../{panes/Module.tsx => layout/Section.tsx} | 68 ++--- .../src/{panes => layout}/TabMenu.test.tsx | 0 .../src/{panes => layout}/TabMenu.tsx | 0 .../src/{panes => layout}/Toolbar.test.tsx | 0 .../src/{panes => layout}/Toolbar.tsx | 0 .../solid-stage/src/{panes => layout}/size.ts | 0 packages/solid-stage/src/layout/types.ts | 1 + packages/solid-stage/src/overlay/modal.tsx | 10 +- packages/solid-stage/src/panes/types.ts | 1 - .../src/state/createEditorStore.test.tsx | 2 +- packages/solid-stage/src/state/persist.ts | 4 +- packages/solid-stage/src/theme/base.css | 166 +++++------ packages/solid-stage/src/theme/fields.css | 24 +- packages/solid-stage/src/theme/modal.css | 12 +- packages/solid-stage/src/theme/tokens.css | 24 +- playground/src/App.tsx | 159 +++++----- playground/src/styles.css | 6 +- 87 files changed, 2271 insertions(+), 1383 deletions(-) rename packages/solid-stage/src/{fields => controls}/ActionButton.test.tsx (100%) rename packages/solid-stage/src/{fields => controls}/ActionButton.tsx (100%) rename packages/solid-stage/src/{fields => controls}/BaseButton.tsx (100%) create mode 100644 packages/solid-stage/src/controls/BooleanControl.test.tsx create mode 100644 packages/solid-stage/src/controls/BooleanControl.tsx rename packages/solid-stage/src/{fields => controls}/BooleanField.test.tsx (100%) rename packages/solid-stage/src/{fields => controls}/BooleanField.tsx (100%) rename packages/solid-stage/src/{fields => controls}/ButtonGroup.test.tsx (100%) rename packages/solid-stage/src/{fields => controls}/ButtonGroup.tsx (100%) rename packages/solid-stage/src/{fields => controls}/ButtonGroupField.test.tsx (100%) rename packages/solid-stage/src/{fields => controls}/ButtonGroupField.tsx (100%) create mode 100644 packages/solid-stage/src/controls/ColorControl.test.tsx create mode 100644 packages/solid-stage/src/controls/ColorControl.tsx rename packages/solid-stage/src/{fields => controls}/ColorField.test.tsx (100%) create mode 100644 packages/solid-stage/src/controls/ColorField.tsx rename packages/solid-stage/src/{fields => controls}/Field.test.tsx (100%) rename packages/solid-stage/src/{fields => controls}/Field.tsx (100%) rename packages/solid-stage/src/{fields => controls}/FieldBar.test.tsx (100%) rename packages/solid-stage/src/{fields => controls}/FieldBar.tsx (100%) rename packages/solid-stage/src/{fields => controls}/FieldGroup.test.tsx (100%) rename packages/solid-stage/src/{fields => controls}/FieldGroup.tsx (100%) rename packages/solid-stage/src/{fields => controls}/IconButton.test.tsx (100%) rename packages/solid-stage/src/{fields => controls}/IconButton.tsx (100%) rename packages/solid-stage/src/{fields => controls}/IconButtonField.test.tsx (100%) rename packages/solid-stage/src/{fields => controls}/IconButtonField.tsx (100%) rename packages/solid-stage/src/{fields => controls}/IconToggle.test.tsx (100%) rename packages/solid-stage/src/{fields => controls}/IconToggle.tsx (100%) rename packages/solid-stage/src/{fields => controls}/IconToggleField.test.tsx (100%) rename packages/solid-stage/src/{fields => controls}/IconToggleField.tsx (100%) rename packages/solid-stage/src/{fields => controls}/InfoIcon.test.tsx (100%) rename packages/solid-stage/src/{fields => controls}/InfoIcon.tsx (100%) rename packages/solid-stage/src/{fields/LabelField.test.tsx => controls/Label.test.tsx} (76%) rename packages/solid-stage/src/{fields/LabelField.tsx => controls/Label.tsx} (88%) create mode 100644 packages/solid-stage/src/controls/NumberControl.test.tsx create mode 100644 packages/solid-stage/src/controls/NumberControl.tsx rename packages/solid-stage/src/{fields => controls}/NumberField.test.tsx (100%) create mode 100644 packages/solid-stage/src/controls/NumberField.tsx create mode 100644 packages/solid-stage/src/controls/SelectControl.test.tsx rename packages/solid-stage/src/{fields/SelectField.tsx => controls/SelectControl.tsx} (55%) rename packages/solid-stage/src/{fields => controls}/SelectField.test.tsx (100%) create mode 100644 packages/solid-stage/src/controls/SelectField.tsx create mode 100644 packages/solid-stage/src/controls/SliderControl.test.tsx create mode 100644 packages/solid-stage/src/controls/SliderControl.tsx rename packages/solid-stage/src/{fields => controls}/SliderField.test.tsx (100%) create mode 100644 packages/solid-stage/src/controls/SliderField.tsx create mode 100644 packages/solid-stage/src/controls/TextControl.test.tsx create mode 100644 packages/solid-stage/src/controls/TextControl.tsx rename packages/solid-stage/src/{fields => controls}/TextField.test.tsx (100%) rename packages/solid-stage/src/{fields => controls}/TextField.tsx (62%) create mode 100644 packages/solid-stage/src/controls/TextareaControl.test.tsx create mode 100644 packages/solid-stage/src/controls/TextareaControl.tsx rename packages/solid-stage/src/{fields => controls}/TextareaField.test.tsx (100%) create mode 100644 packages/solid-stage/src/controls/TextareaField.tsx rename packages/solid-stage/src/{fields => controls}/ToggleButton.test.tsx (100%) rename packages/solid-stage/src/{fields => controls}/ToggleButton.tsx (100%) rename packages/solid-stage/src/{fields => controls}/createFieldValidation.ts (88%) delete mode 100644 packages/solid-stage/src/fields/ColorField.tsx delete mode 100644 packages/solid-stage/src/fields/NumberField.tsx delete mode 100644 packages/solid-stage/src/fields/SliderField.tsx delete mode 100644 packages/solid-stage/src/fields/TextareaField.tsx rename packages/solid-stage/src/{panes/EditorPanes.test.tsx => layout/EditorPanels.test.tsx} (63%) rename packages/solid-stage/src/{panes/EditorPanes.tsx => layout/EditorPanels.tsx} (73%) rename packages/solid-stage/src/{panes/SidePane.test.tsx => layout/Panel.test.tsx} (63%) rename packages/solid-stage/src/{panes/SidePane.tsx => layout/Panel.tsx} (68%) rename packages/solid-stage/src/{panes/Module.test.tsx => layout/Section.test.tsx} (56%) rename packages/solid-stage/src/{panes/Module.tsx => layout/Section.tsx} (70%) rename packages/solid-stage/src/{panes => layout}/TabMenu.test.tsx (100%) rename packages/solid-stage/src/{panes => layout}/TabMenu.tsx (100%) rename packages/solid-stage/src/{panes => layout}/Toolbar.test.tsx (100%) rename packages/solid-stage/src/{panes => layout}/Toolbar.tsx (100%) rename packages/solid-stage/src/{panes => layout}/size.ts (100%) create mode 100644 packages/solid-stage/src/layout/types.ts delete mode 100644 packages/solid-stage/src/panes/types.ts diff --git a/CLAUDE.md b/CLAUDE.md index 19781ce..dbff073 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,9 +35,10 @@ packages/solid-stage/ # the published artefact src/ index.ts # public exports only — keep narrow test-utils.ts # mount() helper — internal, NOT in index.ts - panes/ # EditorPanes, SidePane, Module, size.ts + layout/ # EditorPanels, Panel, Section, Toolbar, TabMenu, size.ts canvas/ # CanvasStage, layers.ts - fields/ # Field + FieldGroup, value fields, button family, + controls/ # Field + FieldGroup + FieldBar, *Control widgets, + # *Field wrappers, button family, Label, # createFieldValidation, InfoIcon overlay/ # stageTooltip, modal state/ # createEditorStore, createPersistedToggle, persist.ts @@ -67,10 +68,10 @@ that's the global default (explicit > implicit). happy-dom 15.x doesn't ship a `ResizeObserver` or `IntersectionObserver`. `CanvasStage` source guards with `typeof ResizeObserver !== 'undefined'`; -`CanvasStage.test.tsx` stubs it via `vi.stubGlobal`. Likewise `SidePane` tracks +`CanvasStage.test.tsx` stubs it via `vi.stubGlobal`. Likewise `Panel` tracks overflow with an `IntersectionObserver` over top/bottom sentinels (guarded the same way, NOT a scroll handler — an `onScroll` that positions elements is a -scroll-linked effect Firefox warns about); `SidePane.test.tsx` stubs +scroll-linked effect Firefox warns about); `Panel.test.tsx` stubs `IntersectionObserver` and drives the callback by hand. Kobalte fields work under happy-dom, but the event that drives a value change diff --git a/NEXT-UP.md b/NEXT-UP.md index e7e5730..7d09175 100644 --- a/NEXT-UP.md +++ b/NEXT-UP.md @@ -18,14 +18,14 @@ design driven by the next real consumer. `PageSelector` exists only in `osrs-engine-study`. Greenfield in the library. -## Module strip-icon bar +## Section strip-icon bar -Collapsed `SidePane` currently shows only the chevron. Later: a vertical -column of module icons (consumer-provided list). Requires deciding on an +Collapsed `Panel` currently shows only the chevron. Later: a vertical +column of section icons (consumer-provided list). Requires deciding on an icon-pack approach without bloating the dep surface. -NB: this is **not** resolved by `Module`'s `icon` prop (a header-chevron -override on an *expanded* module) or `SidePane`'s `toggleVariant="edge"` (an +NB: this is **not** resolved by `Section`'s `icon` prop (a header-chevron +override on an *expanded* section) or `Panel`'s `toggleVariant="edge"` (an edge collapse toggle). Those are separate, shipped features; this item is still the collapsed-strip vertical icon column. diff --git a/README.md b/README.md index 7d9d4d6..62a9ddd 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # @nuffle/solid-stage -Small library of Solid + Three editor chrome primitives — collapsible side panes, -sectioned modules, and a multi-layer canvas stage. Extracted from two consumer -apps that independently grew the same shape. +Small library of Solid + Three editor chrome primitives — collapsible side +panels, collapsible sections, and a multi-layer canvas stage. Extracted from two +consumer apps that independently grew the same shape. ## Install @@ -17,6 +17,29 @@ Configure your registry to resolve the `@nuffle` scope: @nuffle:registry=https://gitea.homelab.nuffle.me/api/packages/nuffle/npm/ ``` +## Controls & Fields + +The library splits its interactive widgets into two tiers: + +- A **Control** is the bare widget — a value (`value` / `onChange`), its + widget-specific props (`min`/`max`, `options`, `alpha`, …), and nothing else. + No caption, no field row. Drop one straight into a toolbar or compose your own + layout. Seven ship: `TextControl`, `NumberControl`, `TextareaControl`, + `SliderControl`, `SelectControl`, `ColorControl`, `BooleanControl`. +- A **Field** is the captioned, value-bound row that *composes* a control — it + adds the label, the layout (`compact` / `expanded` / `bar`), the description, + the info bubble, and validation, then renders the matching control inside. + Each `*Field` is the `*Control` plus that field chrome: `TextField`, + `NumberField`, `TextareaField`, `SliderField`, `SelectField`, `ColorField`, + `BooleanField`. + +The control is the single source of truth; the field never re-implements the +widget. (`BooleanField` is the one exception — its caption must live inside the +Kobalte root for click-to-toggle, so it keeps a bespoke label rather than +wrapping `BooleanControl`.) `Field` / `FieldGroup` / `FieldBar` / +`createFieldValidation` are the shared field-row machinery; `Label` is bare +display text (neither field nor control). + ## Type scale & row height The library is opinionated about layout: **it owns the type scale (font-size + @@ -36,19 +59,19 @@ the 640px breakpoint (iOS Safari zoom guard). **The tiers cascade.** The tokens are CSS custom properties, so a tier set on any level flows down to everything inside it, and a nested level can re-tier to -override its ancestor — stage → pane → module → field. Set it with a `size` prop -(`'small' | 'normal' | 'large'`) at each level instead of the class: +override its ancestor — stage → panel → section → field. Set it with a `size` +prop (`'small' | 'normal' | 'large'`) at each level instead of the class: -- `EditorPanes` — `size` (whole stage), `leftSize` / `rightSize` (one pane). -- `SidePane` — `size` (the pane: header + modules + fields). -- `Module` — `size` (the module's **fields only**; its header keeps the pane - size, so an info-dense module can shrink its rows without its header falling out +- `EditorPanels` — `size` (whole stage), `leftSize` / `rightSize` (one panel). +- `Panel` — `size` (the panel: header + sections + fields). +- `Section` — `size` (the section's **fields only**; its header keeps the panel + size, so an info-dense section can shrink its rows without its header falling out of line). Use the explicit `.stage-size-normal` class (or `size="normal"`) to reset a subtree back to normal under a `small` / `large` ancestor. -The module header and the toolbars are part of the grid too: a header is one +The section header and the toolbars are part of the grid too: a header is one control-height, and a bar's height tracks the control-height of the controls it hosts. @@ -108,25 +131,25 @@ stageTooltip.hide(el); ``` Positioning is handled by `@floating-ui/dom` with `strategy: 'fixed'`, so the -bubble escapes pane `overflow` clipping. The single host element re-parents into +bubble escapes panel `overflow` clipping. The single host element re-parents into the triggering field's nearest `.stage-root` so that root's token overrides cascade. ## Modal A single app-level modal for settings-style flows: open it, edit, close it. It -dims the page, centres one rounded container hosting a `Module`, traps focus, and +dims the page, centres one rounded container hosting a `Section`, traps focus, and restores it to the opener on close. Clicks on the dim neither close the modal nor -pass through to content beneath; closing is the module's `×`, the Escape key, or +pass through to content beneath; closing is the section's `×`, the Escape key, or `stageModal.close()`. Render `StageModalHost` **once**, inside your `.stage-root`. Then open it from anywhere with a thunk (a function returning the content — not pre-built JSX, so it -mounts under the host's reactive owner). The hosted `Module` takes an `onClose` +mounts under the host's reactive owner). The hosted `Section` takes an `onClose` prop, which renders its `×` and makes the header static (no collapse): ```tsx -import { StageModalHost, stageModal, Module, FieldGroup, TextField } from '@nuffle/solid-stage'; +import { StageModalHost, stageModal, Section, FieldGroup, TextField } from '@nuffle/solid-stage'; // once, near the app root: @@ -134,11 +157,11 @@ import { StageModalHost, stageModal, Module, FieldGroup, TextField } from '@nuff // from any handler: stageModal.open( () => ( - stageModal.close()}> +
stageModal.close()}> - +
), { label: 'Application Settings' }, ); @@ -150,15 +173,15 @@ validation tooltips opened inside the modal portal into it and stack correctly above the dim. It is a plain descendant of your `.stage-root`, so it inherits all your token overrides cleanly — nothing to re-declare. -## Module header actions +## Section header actions -Put icon buttons on a module's title bar (copy / lock / popout, …) with +Put icon buttons on a section's title bar (copy / lock / popout, …) with `headerActions` — a list of `{ icon, title, onClick, active?, disabled?, tone? }`. Each renders as a borderless `IconButton` in the **subtle** variant (a muted glyph whose shape lights up on hover, no accent fill when toggled on); `title` is the accessible name and the native hover tooltip. As secondary meta-actions they are **mouse-only** (not in the tab order, no focus ring). Clicking an action never -toggles the module, and in a dismissable (modal) module they sit left of the `×`. +toggles the section, and in a dismissable (modal) section they sit left of the `×`. The `subtle` and `tabbable` props are also available on `IconButton` directly, for secondary icon buttons elsewhere. @@ -166,7 +189,7 @@ secondary icon buttons elsewhere. ```tsx import { Copy, Lock } from 'lucide-solid'; - {/* … */} - + ``` -## Module icon +## Section icon -Override a module's `▾`/`▸` collapse chevron with a custom glyph via `icon` +Override a section's `▾`/`▸` collapse chevron with a custom glyph via `icon` (an `IconComponent`, like `headerActions`). One icon serves both open and closed states; swap it per-state from the consumer side via `open()`/`onToggle` if a state-dependent glyph is wanted. The glyph tracks the size tier like an icon @@ -188,25 +211,25 @@ button. Collapse still works exactly as before. ```tsx import { Layers } from 'lucide-solid'; -{/* … */} +
{/* … */}
``` -## SidePane edge collapse +## Panel edge collapse -By default a `SidePane` (and an `EditorPanes` left/right pane) collapses via a -toggle in a **top header strip**. Pass `toggleVariant="edge"` (on `SidePane`, or -`leftToggleVariant` / `rightToggleVariant` on `EditorPanes`) to move the toggle to -a **full-height vertical bar on the pane's inside edge** instead — reclaiming the -header strip's vertical space for module content. Collapsed, the toggle fills the -full pane height. +By default a `Panel` (and an `EditorPanels` left/right panel) collapses via a +toggle in a **top header strip**. Pass `toggleVariant="edge"` (on `Panel`, or +`leftToggleVariant` / `rightToggleVariant` on `EditorPanels`) to move the toggle to +a **full-height vertical bar on the panel's inside edge** instead — reclaiming the +header strip's vertical space for section content. Collapsed, the toggle fills the +full panel height. ```tsx -} /> +} /> ``` ## TabMenu -A horizontal tab strip for a bar slot (e.g. an `EditorPanes` `topSub`), modelled +A horizontal tab strip for a bar slot (e.g. an `EditorPanels` `topSub`), modelled on an object/document tab bar: each tab has an `×` close (middle-click also closes), an optional `+` new-tab button rendered as a tab inline at the end of the strip, and overflow surfaces `‹`/`›` scroll arrows. Library chrome glyphs are diff --git a/docs/architecture.md b/docs/architecture.md index 3cbbbc4..0694b45 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -19,11 +19,11 @@ both independently grew the same shape. The load-bearing principles. Everything else is downstream of these; a new feature that violates one is almost certainly wrong. -1. **Pre-composed children, not registries.** `EditorPanes` takes - `leftSide?: JSX.Element`, not `leftModules: ModuleId[]`; `Module` takes +1. **Pre-composed children, not registries.** `EditorPanels` takes + `leftPanel?: JSX.Element`, not `leftSections: SectionId[]`; `Section` takes `children`, not a field schema; every bar is a pre-composed `Toolbar` (or plain JSX), not a bar config object. Naming the collapsible container - `Module` does **not** imply a module registry / id-keyed lookup — that + `Section` does **not** imply a section registry / id-keyed lookup — that mapping is app-specific. Composition stays child-based throughout. 2. **Controlled + uncontrolled, mirrored everywhere.** Anything with state @@ -31,7 +31,7 @@ feature that violates one is almost certainly wrong. with an `internalOpen()` signal fallback. Canonical accessor: `isOpen = () => props.open?.() ?? internalOpen()`. In uncontrolled mode the internal signal seeds from / writes back to localStorage where a primitive - persists (`Module` collapse, pane collapse); controlled mode leaves + persists (`Section` collapse, panel collapse); controlled mode leaves persistence to the parent (never caches). 3. **CSS classes + variables, no inline styles.** Every classname is `stage-*`. @@ -39,7 +39,7 @@ feature that violates one is almost certainly wrong. Consumers override by setting variables on any container. The library never writes `!important` — `:where()` (specificity 0) lets consumer overrides win without it. The library ships no inline-`style` escape hatch; the style hook - is a `class` prop (e.g. `LabelField`). + is a `class` prop (e.g. `Label`). 4. **Icons are consumer-supplied; no bundled icon pack.** Library chrome uses unicode glyphs (`▸`/`▾`, `«`/`»`, `×`/`+`/`‹`/`›`). Icon-button components @@ -52,10 +52,10 @@ feature that violates one is almost certainly wrong. SSR-safe. Documented in `README.md`. Do not add SSR shims or `typeof window` dances beyond the one `typeof ResizeObserver !== 'undefined'` guard. -6. **The chrome is centre-agnostic; the *world* is a canvas.** `EditorPanes` +6. **The chrome is centre-agnostic; the *world* is a canvas.** `EditorPanels` takes an arbitrary `center: JSX.Element` — the chrome could host any middle (DOM page-builder preview, code buffer, node graph). Do **not** narrow - `EditorPanes` to canvases. But `CanvasStage` and the focus model that treats + `EditorPanels` to canvases. But `CanvasStage` and the focus model that treats "the world" as a single non-tabbable boundary *are* canvas-specific: the multi-layer world/HUD/pointer z-stack + viewport ResizeObserver describe a viewport onto a continuous spatial scene. The tell: the moment a centre is @@ -80,7 +80,7 @@ the deferred list. Resist scope creep. live inside a `.stage-root` subtree. - **Portalled content mounts into the nearest `.stage-root`**, not `document.body` — otherwise tokens don't resolve and consumer overrides don't - cascade. It also escapes pane `overflow` clipping and sits above chrome via + cascade. It also escapes panel `overflow` clipping and sits above chrome via `--stage-z-popover`. The modal extends this: lookups are `.closest('.stage-root, .stage-modal-container')` so fields opened *inside* a modal portal into the container (see §7). @@ -105,7 +105,7 @@ the deferred list. Resist scope creep. carries the whole type-colour scheme. `--stage-color-on-accent` is *not* part of this group — it's the glyph colour on an accent fill and is pinned to contrast the accent, not the text. No hover variant by design: the *header background* - hover (`--stage-color-module-header-hover-bg`) is the hover affordance, not a + hover (`--stage-color-section-header-hover-bg`) is the hover affordance, not a glyph colour shift — don't add a `--stage-color-text-hover` nothing consumes. - **`--stage-colorpicker-*` is dimensions, not colour.** The ColorField popover's width/area/track/thumb sizes use the `colorpicker` stem (no hyphen after @@ -171,10 +171,10 @@ font-size forfeits the row-height guarantee. `sm`/`md` width split is gone — classes kept for API compat, now equivalent). The glyph is sized by `--stage-icon-size` (`calc(control-height − 7px)`) applied to the `` via `.stage-button svg` (CSS overrides lucide's attrs). -- **Module header is one control-height** (`min-height`, horizontal-only +- **Section header is one control-height** (`min-height`, horizontal-only padding) so it lines up with its field rows. Styled as *a field label rendered bold* — `--stage-color-text-muted`, `--stage-font-size` (tier), `font-weight: 700`. - Header height + font read the *inherited* (pane) tier, so a module re-tiering + Header height + font read the *inherited* (panel) tier, so a section re-tiering its own body never resizes its header. - **Bar height derives from control-height:** `--stage-toolbar-height: calc(control-height + 8px)`; every top/bottom/sub/stage bar flows through `.stage-toolbar`. @@ -189,21 +189,21 @@ font-size forfeits the row-height guarantee. ### The cascade & the `size` prop The three tier tokens inherit, so setting a tier (a `.stage-size-*` class or the raw token) on any element resizes everything inside it; a nested element can -re-tier to override its ancestor. This is the whole cascade — stage → pane → -module → field, plus bars — for free. +re-tier to override its ancestor. This is the whole cascade — stage → panel → +section → field, plus bars — for free. `size?: ControlSize` (`'small' | 'normal' | 'large'`) exposes it at three levels: -- `EditorPanes.size` (whole stage, the `.stage-panes` root); `leftSize` / - `rightSize` forward into the internal `SidePane`s (consumer composes panes via - `leftSide`/`rightSide`, not a `SidePane` directly). -- `SidePane.size` (header + modules + fields). -- `Module.size` — **applied to `.stage-module-body`, not the module root** — so - it resizes the module's *fields* while the header keeps the inherited *pane* - size (an info-dense module can go `small` without its header falling out of +- `EditorPanels.size` (whole stage, the `.stage-panels` root); `leftSize` / + `rightSize` forward into the internal `Panel`s (consumer composes panels via + `leftPanel`/`rightPanel`, not a `Panel` directly). +- `Panel.size` (header + sections + fields). +- `Section.size` — **applied to `.stage-section-body`, not the section root** — so + it resizes the section's *fields* while the header keeps the inherited *panel* + size (an info-dense section can go `small` without its header falling out of line). This body-scoping is why no structural change was needed. -Helper `panes/size.ts` `sizeClassList(size)` returns a 3-key classList fragment +Helper `layout/size.ts` `sizeClassList(size)` returns a 3-key classList fragment (all keys present so Solid's diff swaps tiers reactively); spread into a `classList`. `.stage-size-normal` exists as a reset because the default is *unclassed*, so a subtree under a small/large ancestor needs an explicit class @@ -263,44 +263,44 @@ unaffected. touched". (macOS Safari/Firefox don't focus `
` row, not one `
} /> + x} /> )); const cell = host.querySelector('.stage-center-cell') as HTMLDivElement; expect(cell).not.toBeNull(); @@ -25,8 +25,8 @@ describe('EditorPanes', () => { }); it('omitting top/toolbars/bottom collapses those rows to 0px', () => { - const { host, dispose } = mount(() => } />); - const grid = host.querySelector('.stage-panes') as HTMLDivElement; + const { host, dispose } = mount(() => } />); + const grid = host.querySelector('.stage-panels') as HTMLDivElement; // rows: top / topToolbars / capped band / bottomToolbars / bottom. The // band is minmax(0, 1fr) so tall pane content scrolls instead of growing // the row past the viewport (which would push the footer off-screen). @@ -40,7 +40,7 @@ describe('EditorPanes', () => { it('top bar renders into grid-row 1, full column span', () => { const { host, dispose } = mount(() => ( - t} center={
} /> + t
} center={
} /> )); const top = host.querySelector('.stage-top-bar') as HTMLDivElement; expect(top).not.toBeNull(); @@ -50,30 +50,30 @@ describe('EditorPanes', () => { dispose(); }); - it('leftSide + leftCollapsed=false: expanded pane renders, no strip', () => { + it('leftPanel + leftCollapsed=false: expanded panel renders, no strip', () => { const { host, dispose } = mount(() => ( - } - leftSide={
body
} + leftPanel={
body
} leftCollapsed={() => false} /> )); - expect(host.querySelector('.stage-pane-expanded')).not.toBeNull(); - expect(host.querySelector('.stage-pane-strip')).toBeNull(); + expect(host.querySelector('.stage-panel-expanded')).not.toBeNull(); + expect(host.querySelector('.stage-panel-strip')).toBeNull(); expect(host.querySelector('[data-testid="left"]')?.textContent).toBe('body'); dispose(); }); - it('leftSide + leftCollapsed=true: strip renders, no expanded pane', () => { + it('leftPanel + leftCollapsed=true: strip renders, no expanded panell', () => { const { host, dispose } = mount(() => ( - } - leftSide={
body
} + leftPanel={
body
} leftCollapsed={() => true} /> )); - expect(host.querySelector('.stage-pane-strip')).not.toBeNull(); - expect(host.querySelector('.stage-pane-expanded')).toBeNull(); + expect(host.querySelector('.stage-panel-strip')).not.toBeNull(); + expect(host.querySelector('.stage-panel-expanded')).toBeNull(); expect(host.querySelector('[data-testid="left"]')).toBeNull(); dispose(); }); @@ -82,77 +82,77 @@ describe('EditorPanes', () => { const [collapsed, setCollapsed] = createSignal(false); const onToggle = vi.fn((next: boolean) => setCollapsed(next)); const { host, dispose } = mount(() => ( - } - leftSide={
} + leftPanel={
} leftCollapsed={collapsed} onLeftToggle={onToggle} /> )); - expect(host.querySelector('.stage-pane-expanded')).not.toBeNull(); - const btn = host.querySelector('.stage-pane-header-button') as HTMLButtonElement; + expect(host.querySelector('.stage-panel-expanded')).not.toBeNull(); + const btn = host.querySelector('.stage-panel-header-button') as HTMLButtonElement; btn.click(); expect(onToggle).toHaveBeenCalledWith(true); - expect(host.querySelector('.stage-pane-strip')).not.toBeNull(); + expect(host.querySelector('.stage-panel-strip')).not.toBeNull(); dispose(); }); it('uncontrolled mode: defaultLeftCollapsed=true starts as strip, click toggles', () => { const { host, dispose } = mount(() => ( - } - leftSide={
body
} + leftPanel={
body
} defaultLeftCollapsed={true} /> )); - expect(host.querySelector('.stage-pane-strip')).not.toBeNull(); - const btn = host.querySelector('.stage-pane-strip-button') as HTMLButtonElement; + expect(host.querySelector('.stage-panel-strip')).not.toBeNull(); + const btn = host.querySelector('.stage-panel-strip-button') as HTMLButtonElement; btn.click(); - expect(host.querySelector('.stage-pane-expanded')).not.toBeNull(); + expect(host.querySelector('.stage-panel-expanded')).not.toBeNull(); expect(host.querySelector('[data-testid="left"]')).not.toBeNull(); dispose(); }); - it('no leftSide: column 1 track is 0px', () => { - const { host, dispose } = mount(() => } />); - const grid = host.querySelector('.stage-panes') as HTMLDivElement; + it('no leftPanel: column 1 track is 0px', () => { + const { host, dispose } = mount(() => } />); + const grid = host.querySelector('.stage-panels') as HTMLDivElement; expect(grid.style.gridTemplateColumns).toContain('0px'); expect(grid.style.gridTemplateColumns.startsWith('0px')).toBe(true); dispose(); }); - it('rightSide with rightCollapsed=false renders expanded pane in column 3', () => { + it('rightPanel with rightCollapsed=false renders expanded panel in column 3', () => { const { host, dispose } = mount(() => ( - } - rightSide={
r
} + rightPanel={
r
} rightCollapsed={() => false} /> )); expect(host.querySelector('[data-testid="right"]')?.textContent).toBe('r'); // strip-aware glyph: right + expanded => » - const btn = host.querySelector('.stage-pane-header-button') as HTMLButtonElement; + const btn = host.querySelector('.stage-panel-header-button') as HTMLButtonElement; expect(btn.textContent).toBe('»'); dispose(); }); - it('rightToggleVariant="edge" forwards into the right SidePane', () => { + it('rightToggleVariant="edge" forwards into the right Panel', () => { const { host, dispose } = mount(() => ( - } - rightSide={
r
} + rightPanel={
r
} rightCollapsed={() => false} rightToggleVariant="edge" /> )); - expect(host.querySelector('.stage-pane-edge-button')).not.toBeNull(); - expect(host.querySelector('.stage-pane-header')).toBeNull(); + expect(host.querySelector('.stage-panel-edge-button')).not.toBeNull(); + expect(host.querySelector('.stage-panel-header')).toBeNull(); dispose(); }); it('topSub/bottomSub render inside the centre cell (canvas-aligned), bracketing the content', () => { const { host, dispose } = mount(() => ( - c
} topSub={
ts
} bottomSub={
bs
} @@ -171,78 +171,78 @@ describe('EditorPanes', () => { dispose(); }); - it('embedded adds .stage-panes-embedded; default (root) does not', () => { - const root = mount(() => } />); - expect(root.host.querySelector('.stage-panes-embedded')).toBeNull(); + it('embedded adds .stage-panels-embedded; default (root) does not', () => { + const root = mount(() => } />); + expect(root.host.querySelector('.stage-panels-embedded')).toBeNull(); root.dispose(); - const emb = mount(() => } />); - expect(emb.host.querySelector('.stage-panes-embedded')).not.toBeNull(); + const emb = mount(() => } />); + expect(emb.host.querySelector('.stage-panels-embedded')).not.toBeNull(); emb.dispose(); }); it('persistKey: uncontrolled collapse seeds from cache', () => { - localStorage.setItem('stage-pane:k:left', '1'); + localStorage.setItem('stage-panel:k:left', '1'); const { host, dispose } = mount(() => ( - } leftSide={
} persistKey="k" /> + } leftPanel={
} persistKey="k" /> )); // cached '1' wins over the expanded default - expect(host.querySelector('.stage-pane-strip')).not.toBeNull(); - expect(host.querySelector('.stage-pane-expanded')).toBeNull(); + expect(host.querySelector('.stage-panel-strip')).not.toBeNull(); + expect(host.querySelector('.stage-panel-expanded')).toBeNull(); dispose(); }); it('persistKey: toggling an uncontrolled pane writes the new state', () => { const { host, dispose } = mount(() => ( - } leftSide={
} persistKey="k" /> + } leftPanel={
} persistKey="k" /> )); - expect(localStorage.getItem('stage-pane:k:left')).toBeNull(); - const btn = host.querySelector('.stage-pane-header-button') as HTMLButtonElement; + expect(localStorage.getItem('stage-panel:k:left')).toBeNull(); + const btn = host.querySelector('.stage-panel-header-button') as HTMLButtonElement; btn.click(); - expect(localStorage.getItem('stage-pane:k:left')).toBe('1'); + expect(localStorage.getItem('stage-panel:k:left')).toBe('1'); // header button is gone now; expand via the collapsed strip button - const strip = host.querySelector('.stage-pane-strip-button') as HTMLButtonElement; + const strip = host.querySelector('.stage-panel-strip-button') as HTMLButtonElement; strip.click(); - expect(localStorage.getItem('stage-pane:k:left')).toBe('0'); + expect(localStorage.getItem('stage-panel:k:left')).toBe('0'); dispose(); }); it('persistKey: left and right cache under distinct keys', () => { const { host, dispose } = mount(() => ( - } - leftSide={
} - rightSide={
} + leftPanel={
} + rightPanel={
} persistKey="k" /> )); - const buttons = host.querySelectorAll('.stage-pane-header-button'); + const buttons = host.querySelectorAll('.stage-panel-header-button'); (buttons[0] as HTMLButtonElement).click(); // left (buttons[1] as HTMLButtonElement).click(); // right - expect(localStorage.getItem('stage-pane:k:left')).toBe('1'); - expect(localStorage.getItem('stage-pane:k:right')).toBe('1'); + expect(localStorage.getItem('stage-panel:k:left')).toBe('1'); + expect(localStorage.getItem('stage-panel:k:right')).toBe('1'); dispose(); }); it('persistKey is ignored for a controlled pane (parent owns persistence)', () => { - localStorage.setItem('stage-pane:k:left', '1'); + localStorage.setItem('stage-panel:k:left', '1'); const { host, dispose } = mount(() => ( - } - leftSide={
} + leftPanel={
} persistKey="k" leftCollapsed={() => false} /> )); // controlled accessor wins; the cache is not read - expect(host.querySelector('.stage-pane-expanded')).not.toBeNull(); - expect(host.querySelector('.stage-pane-strip')).toBeNull(); + expect(host.querySelector('.stage-panel-expanded')).not.toBeNull(); + expect(host.querySelector('.stage-panel-strip')).toBeNull(); dispose(); }); it('topToolbars/bottomToolbars render full-width stacks, and stack multiple', () => { const { host, dispose } = mount(() => ( - } topToolbars={ <> @@ -266,17 +266,17 @@ describe('EditorPanes', () => { it('size tiers the stage root; leftSize/rightSize tier each pane', () => { const { host, dispose } = mount(() => ( - L
} - rightSide={
R
} + leftPanel={
L
} + rightPanel={
R
} leftSize="small" center={
} /> )); - expect(host.querySelector('.stage-panes')?.classList.contains('stage-size-large')).toBe(true); - const left = host.querySelector('.stage-pane-left') as HTMLElement; - const right = host.querySelector('.stage-pane-right') as HTMLElement; + expect(host.querySelector('.stage-panels')?.classList.contains('stage-size-large')).toBe(true); + const left = host.querySelector('.stage-panel-left') as HTMLElement; + const right = host.querySelector('.stage-panel-right') as HTMLElement; expect(left.classList.contains('stage-size-small')).toBe(true); // rightSize omitted → the right pane inherits the stage tier (no own class) expect(right.classList.contains('stage-size-small')).toBe(false); diff --git a/packages/solid-stage/src/panes/EditorPanes.tsx b/packages/solid-stage/src/layout/EditorPanels.tsx similarity index 73% rename from packages/solid-stage/src/panes/EditorPanes.tsx rename to packages/solid-stage/src/layout/EditorPanels.tsx index 34ff9d7..02bba86 100644 --- a/packages/solid-stage/src/panes/EditorPanes.tsx +++ b/packages/solid-stage/src/layout/EditorPanels.tsx @@ -1,26 +1,26 @@ import { Show, createSignal, type JSX } from 'solid-js'; -import { SidePane } from './SidePane'; +import { Panel } from './Panel'; import { sizeClassList, type ControlSize } from './size'; import { readFlag, writeFlag } from '../state/persist'; -export interface EditorPanesProps { +export interface EditorPanelsProps { /** Full-width chrome bar at the very top (single). */ readonly top?: JSX.Element; - /** Full-width panel-bg toolbar stack below `top`, above the pane band. */ + /** Full-width panel-bg toolbar stack below `top`, above the panel band. */ readonly topToolbars?: JSX.Element; /** Canvas-aligned (column 2) panel-bg toolbar stack above the centre. */ readonly topSub?: JSX.Element; /** Full-width chrome bar at the very bottom (single). */ readonly bottom?: JSX.Element; - /** Full-width panel-bg toolbar stack above `bottom`, below the pane band. */ + /** Full-width panel-bg toolbar stack above `bottom`, below the panel band. */ readonly bottomToolbars?: JSX.Element; /** Canvas-aligned (column 2) panel-bg toolbar stack below the centre. */ readonly bottomSub?: JSX.Element; - /** Consumer-composed left pane body. Omit to drop the left column. */ - readonly leftSide?: JSX.Element; - /** Consumer-composed right pane body. Omit to drop the right column. */ - readonly rightSide?: JSX.Element; - /** Centre cell — typically a ``, or another ``. */ + /** Consumer-composed left panel body. Omit to drop the left column. */ + readonly leftPanel?: JSX.Element; + /** Consumer-composed right panel body. Omit to drop the right column. */ + readonly rightPanel?: JSX.Element; + /** Centre cell — typically a ``, or another ``. */ readonly center: JSX.Element; /** @@ -33,11 +33,11 @@ export interface EditorPanesProps { readonly embedded?: boolean | undefined; /** - * Persist each pane's collapse state to localStorage (uncontrolled panes - * only), keyed `stage-pane::left` / `:right`. Opt-in: omit and - * panes don't cache. The key is also the per-stage namespace — give each - * stage in a multi-stage layout a distinct `persistKey` so their panes don't - * collide. A controlled pane (`leftCollapsed`/`rightCollapsed` supplied) + * Persist each panel's collapse state to localStorage (uncontrolled panels + * only), keyed `stage-panel::left` / `:right`. Opt-in: omit and + * panels don't cache. The key is also the per-stage namespace — give each + * stage in a multi-stage layout a distinct `persistKey` so their panels don't + * collide. A controlled panel (`leftCollapsed`/`rightCollapsed` supplied) * never caches; the parent owns persistence. */ readonly persistKey?: string | undefined; @@ -51,20 +51,20 @@ export interface EditorPanesProps { readonly defaultRightCollapsed?: boolean | undefined; /** - * Type-scale tier for the whole stage (bars, both panes, modules, fields). + * Type-scale tier for the whole stage (bars, both panels, sections, fields). * Omit to inherit the surrounding `.stage-root`. The control-height/font-size * tokens cascade, so this is the top of the per-stage size cascade; `leftSize` - * / `rightSize` override a single pane and `Module`'s `size` overrides a single - * module's fields underneath it. + * / `rightSize` override a single panel and `Section`'s `size` overrides a single + * section's fields underneath it. */ readonly size?: ControlSize | undefined; - /** Tier override for the left pane only (forwarded to its `SidePane`). */ + /** Tier override for the left panel only (forwarded to its `Panel`). */ readonly leftSize?: ControlSize | undefined; - /** Tier override for the right pane only (forwarded to its `SidePane`). */ + /** Tier override for the right panel only (forwarded to its `Panel`). */ readonly rightSize?: ControlSize | undefined; - /** Collapse-toggle placement for the left pane (`'header'` default | `'edge'`). */ + /** Collapse-toggle placement for the left panel (`'header'` default | `'edge'`). */ readonly leftToggleVariant?: 'header' | 'edge' | undefined; - /** Collapse-toggle placement for the right pane (`'header'` default | `'edge'`). */ + /** Collapse-toggle placement for the right panel (`'header'` default | `'edge'`). */ readonly rightToggleVariant?: 'header' | 'edge' | undefined; } @@ -80,29 +80,29 @@ function trackWidth(present: boolean, collapsed: boolean): string { * * - `top` / `bottom` are full-width single chrome bars. * - `topToolbars` / `bottomToolbars` are full-width panel-bg toolbar stacks - * that span the whole width above/below the pane band. - * - The side panes occupy the middle band (`grid-row: 3`) only. + * that span the whole width above/below the panel band. + * - The side panels occupy the middle band (`grid-row: 3`) only. * - `topSub` / `bottomSub` are canvas-aligned toolbar stacks: they live *inside* * the centre cell (column 2), so their left/right boundaries match the canvas. * - * Each pane is a grid cell — toggling collapse swaps the column track between + * Each panel is a grid cell — toggling collapse swaps the column track between * strip and expanded widths so the centre cell grows/shrinks naturally. Rows * collapse to `0px` when their slot is undefined; pages that don't opt in * render unchanged. Stack toolbars by listing several `Toolbar`s in a slot. * - * Collapse state is controlled or uncontrolled per side, mirroring `Module`. + * Collapse state is controlled or uncontrolled per side, mirroring `Section`. */ -export function EditorPanes(props: EditorPanesProps): JSX.Element { +export function EditorPanels(props: EditorPanelsProps): JSX.Element { // Persistence is opt-in (`persistKey`) and uncontrolled-only — a controlled - // pane leaves caching to the parent. When set, the key is also the seed + // panel leaves caching to the parent. When set, the key is also the seed // source: cached value wins over `defaultCollapsed`, then expanded. const leftKey = props.persistKey !== undefined && props.leftCollapsed === undefined - ? `stage-pane:${props.persistKey}:left` + ? `stage-panel:${props.persistKey}:left` : undefined; const rightKey = props.persistKey !== undefined && props.rightCollapsed === undefined - ? `stage-pane:${props.persistKey}:right` + ? `stage-panel:${props.persistKey}:right` : undefined; const [internalLeft, setInternalLeft] = createSignal( @@ -141,7 +141,7 @@ export function EditorPanes(props: EditorPanesProps): JSX.Element { }; const colTemplate = (): string => - `${trackWidth(props.leftSide !== undefined, leftCollapsed())} 1fr ${trackWidth(props.rightSide !== undefined, rightCollapsed())}`; + `${trackWidth(props.leftPanel !== undefined, leftCollapsed())} 1fr ${trackWidth(props.rightPanel !== undefined, rightCollapsed())}`; const rowTemplate = (): string => { const topRow = props.top === undefined ? '0px' : 'auto'; @@ -150,17 +150,17 @@ export function EditorPanes(props: EditorPanesProps): JSX.Element { props.bottomToolbars === undefined ? '0px' : 'auto'; const bottomRow = props.bottom === undefined ? '0px' : 'auto'; // `minmax(0, 1fr)` not `1fr`: a bare `1fr` track is `minmax(auto, 1fr)`, - // whose `auto` minimum grows the band to fit tall pane content — pushing + // whose `auto` minimum grows the band to fit tall panel content — pushing // the bottom bars/footer off-screen. Capping the minimum at 0 holds the - // band to the available height so each pane's `overflow: auto` body scrolls. + // band to the available height so each panel's `overflow: auto` body scrolls. return `${topRow} ${topToolbarsRow} minmax(0, 1fr) ${bottomToolbarsRow} ${bottomRow}`; }; return (
)} - - {(leftSide) => ( + + {(leftPanel) => (
- - {leftSide} - + {leftPanel} +
)}
@@ -218,18 +218,18 @@ export function EditorPanes(props: EditorPanesProps): JSX.Element { )}
- - {(rightSide) => ( + + {(rightPanel) => (
- - {rightSide} - + {rightPanel} +
)}
diff --git a/packages/solid-stage/src/panes/SidePane.test.tsx b/packages/solid-stage/src/layout/Panel.test.tsx similarity index 63% rename from packages/solid-stage/src/panes/SidePane.test.tsx rename to packages/solid-stage/src/layout/Panel.test.tsx index aec5f55..665bfd2 100644 --- a/packages/solid-stage/src/panes/SidePane.test.tsx +++ b/packages/solid-stage/src/layout/Panel.test.tsx @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { createSignal } from 'solid-js'; import { mount } from '../test-utils'; -import { SidePane } from './SidePane'; +import { Panel } from './Panel'; afterEach(() => { document.body.innerHTML = ''; @@ -11,48 +11,48 @@ afterEach(() => { type IOEntry = { target: Element; isIntersecting: boolean }; -describe('SidePane', () => { +describe('Panel', () => { it('collapsed renders strip + chevron only (no body)', () => { const { host, dispose } = mount(() => ( - true} onToggle={() => {}}> + true} onToggle={() => {}}>
inner
-
+ )); - expect(host.querySelector('.stage-pane-strip')).not.toBeNull(); - expect(host.querySelector('.stage-pane-expanded')).toBeNull(); - expect(host.querySelector('.stage-pane-body')).toBeNull(); + expect(host.querySelector('.stage-panel-strip')).not.toBeNull(); + expect(host.querySelector('.stage-panel-expanded')).toBeNull(); + expect(host.querySelector('.stage-panel-body')).toBeNull(); expect(host.querySelector('[data-testid="body"]')).toBeNull(); dispose(); }); it('expanded renders chevron + children', () => { const { host, dispose } = mount(() => ( - false} onToggle={() => {}}> + false} onToggle={() => {}}>
inner
-
+ )); - expect(host.querySelector('.stage-pane-expanded')).not.toBeNull(); - expect(host.querySelector('.stage-pane-strip')).toBeNull(); + expect(host.querySelector('.stage-panel-expanded')).not.toBeNull(); + expect(host.querySelector('.stage-panel-strip')).toBeNull(); expect(host.querySelector('[data-testid="body"]')?.textContent).toBe('inner'); dispose(); }); it('chevron glyph flips with side prop (left=« when expanded, right=»)', () => { const left = mount(() => ( - false} onToggle={() => {}}> + false} onToggle={() => {}}>
- + )); const right = mount(() => ( - false} onToggle={() => {}}> + false} onToggle={() => {}}>
- + )); const leftBtn = left.host.querySelector( - '.stage-pane-header-button', + '.stage-panel-header-button', ) as HTMLButtonElement; const rightBtn = right.host.querySelector( - '.stage-pane-header-button', + '.stage-panel-header-button', ) as HTMLButtonElement; expect(leftBtn.textContent).toBe('«'); expect(rightBtn.textContent).toBe('»'); @@ -62,20 +62,20 @@ describe('SidePane', () => { it('strip glyph flips with side prop (left=» when collapsed, right=«)', () => { const left = mount(() => ( - true} onToggle={() => {}}> + true} onToggle={() => {}}>
- + )); const right = mount(() => ( - true} onToggle={() => {}}> + true} onToggle={() => {}}>
- + )); const leftBtn = left.host.querySelector( - '.stage-pane-strip-button', + '.stage-panel-strip-button', ) as HTMLButtonElement; const rightBtn = right.host.querySelector( - '.stage-pane-strip-button', + '.stage-panel-strip-button', ) as HTMLButtonElement; expect(leftBtn.textContent).toBe('»'); expect(rightBtn.textContent).toBe('«'); @@ -86,11 +86,11 @@ describe('SidePane', () => { it('chevron click fires onToggle', () => { const onToggle = vi.fn(); const { host, dispose } = mount(() => ( - false} onToggle={onToggle}> + false} onToggle={onToggle}>
- + )); - const btn = host.querySelector('.stage-pane-header-button') as HTMLButtonElement; + const btn = host.querySelector('.stage-panel-header-button') as HTMLButtonElement; btn.click(); expect(onToggle).toHaveBeenCalledTimes(1); dispose(); @@ -115,29 +115,29 @@ describe('SidePane', () => { vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver); const { host, dispose } = mount(() => ( - false} onToggle={() => {}}> + false} onToggle={() => {}}>
tall content
-
+ )); - const body = host.querySelector('.stage-pane-body') as HTMLDivElement; - const top = host.querySelector('.stage-pane-scroll-sentinel-top') as Element; - const bottom = host.querySelector('.stage-pane-scroll-sentinel-bottom') as Element; + const body = host.querySelector('.stage-panel-body') as HTMLDivElement; + const top = host.querySelector('.stage-panel-scroll-sentinel-top') as Element; + const bottom = host.querySelector('.stage-panel-scroll-sentinel-bottom') as Element; const scrollTo = vi.fn(); body.scrollTo = scrollTo as unknown as typeof body.scrollTo; Object.defineProperty(body, 'scrollHeight', { value: 1000, configurable: true }); // Nothing observed yet → neither hint. - expect(host.querySelector('.stage-pane-scroll-hint')).toBeNull(); + expect(host.querySelector('.stage-panel-scroll-hint')).toBeNull(); // At the top of overflowing content (top in view, bottom out) → only bottom. fire?.([ { target: top, isIntersecting: true }, { target: bottom, isIntersecting: false }, ]); - expect(host.querySelector('.stage-pane-scroll-hint-top')).toBeNull(); - expect(host.querySelector('.stage-pane-scroll-hint-bottom')).not.toBeNull(); + expect(host.querySelector('.stage-panel-scroll-hint-top')).toBeNull(); + expect(host.querySelector('.stage-panel-scroll-hint-bottom')).not.toBeNull(); - (host.querySelector('.stage-pane-scroll-hint-bottom') as HTMLButtonElement).click(); + (host.querySelector('.stage-panel-scroll-hint-bottom') as HTMLButtonElement).click(); expect(scrollTo).toHaveBeenCalledWith({ top: 1000, behavior: 'smooth' }); // Middle (both out of view) → both hints. @@ -145,10 +145,10 @@ describe('SidePane', () => { { target: top, isIntersecting: false }, { target: bottom, isIntersecting: false }, ]); - expect(host.querySelector('.stage-pane-scroll-hint-top')).not.toBeNull(); - expect(host.querySelector('.stage-pane-scroll-hint-bottom')).not.toBeNull(); + expect(host.querySelector('.stage-panel-scroll-hint-top')).not.toBeNull(); + expect(host.querySelector('.stage-panel-scroll-hint-bottom')).not.toBeNull(); - (host.querySelector('.stage-pane-scroll-hint-top') as HTMLButtonElement).click(); + (host.querySelector('.stage-panel-scroll-hint-top') as HTMLButtonElement).click(); expect(scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' }); // At the bottom (top out, bottom in view) → only top. @@ -156,18 +156,18 @@ describe('SidePane', () => { { target: top, isIntersecting: false }, { target: bottom, isIntersecting: true }, ]); - expect(host.querySelector('.stage-pane-scroll-hint-top')).not.toBeNull(); - expect(host.querySelector('.stage-pane-scroll-hint-bottom')).toBeNull(); + expect(host.querySelector('.stage-panel-scroll-hint-top')).not.toBeNull(); + expect(host.querySelector('.stage-panel-scroll-hint-bottom')).toBeNull(); dispose(); }); it('excludes the expanded chevron from the keyboard tab order', () => { const { host, dispose } = mount(() => ( - false} onToggle={() => {}}> + false} onToggle={() => {}}>
- + )); - const btn = host.querySelector('.stage-pane-header-button') as HTMLButtonElement; + const btn = host.querySelector('.stage-panel-header-button') as HTMLButtonElement; expect(btn.getAttribute('tabindex')).toBe('-1'); dispose(); }); @@ -188,24 +188,24 @@ describe('SidePane', () => { vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver); const { host, dispose } = mount(() => ( - false} onToggle={() => {}}> + false} onToggle={() => {}}>
tall content
-
+ )); - const top = host.querySelector('.stage-pane-scroll-sentinel-top') as Element; - const bottom = host.querySelector('.stage-pane-scroll-sentinel-bottom') as Element; + const top = host.querySelector('.stage-panel-scroll-sentinel-top') as Element; + const bottom = host.querySelector('.stage-panel-scroll-sentinel-bottom') as Element; // Both edges out of view → both hints render. fire?.([ { target: top, isIntersecting: false }, { target: bottom, isIntersecting: false }, ]); expect( - (host.querySelector('.stage-pane-scroll-hint-top') as HTMLButtonElement).getAttribute( + (host.querySelector('.stage-panel-scroll-hint-top') as HTMLButtonElement).getAttribute( 'tabindex', ), ).toBe('-1'); expect( - (host.querySelector('.stage-pane-scroll-hint-bottom') as HTMLButtonElement).getAttribute( + (host.querySelector('.stage-panel-scroll-hint-bottom') as HTMLButtonElement).getAttribute( 'tabindex', ), ).toBe('-1'); @@ -214,9 +214,9 @@ describe('SidePane', () => { it('Escape inside the scroll content blurs the active element', () => { const { host, dispose } = mount(() => ( - false} onToggle={() => {}}> + false} onToggle={() => {}}> - + )); const input = host.querySelector('[data-testid="field"]') as HTMLInputElement; input.focus(); @@ -228,11 +228,11 @@ describe('SidePane', () => { it('Tab past either end holds focus (cancels nav); mid-list Tab is native', () => { const { host, dispose } = mount(() => ( - false} onToggle={() => {}}> + false} onToggle={() => {}}> - + )); const first = host.querySelector('[data-testid="first"]') as HTMLButtonElement; const last = host.querySelector('[data-testid="last"]') as HTMLButtonElement; @@ -261,13 +261,13 @@ describe('SidePane', () => { describe('toggleVariant="edge"', () => { it('expanded renders an edge button and no top header', () => { const { host, dispose } = mount(() => ( - false} onToggle={() => {}} toggleVariant="edge"> + false} onToggle={() => {}} toggleVariant="edge">
inner
-
+ )); - expect(host.querySelector('.stage-pane-expanded-edge')).not.toBeNull(); - expect(host.querySelector('.stage-pane-edge-button')).not.toBeNull(); - expect(host.querySelector('.stage-pane-header')).toBeNull(); + expect(host.querySelector('.stage-panel-expanded-edge')).not.toBeNull(); + expect(host.querySelector('.stage-panel-edge-button')).not.toBeNull(); + expect(host.querySelector('.stage-panel-header')).toBeNull(); expect(host.querySelector('[data-testid="body"]')?.textContent).toBe('inner'); dispose(); }); @@ -275,11 +275,11 @@ describe('SidePane', () => { it('edge button is out of the tab order and fires onToggle', () => { const onToggle = vi.fn(); const { host, dispose } = mount(() => ( - false} onToggle={onToggle} toggleVariant="edge"> + false} onToggle={onToggle} toggleVariant="edge">
- + )); - const btn = host.querySelector('.stage-pane-edge-button') as HTMLButtonElement; + const btn = host.querySelector('.stage-panel-edge-button') as HTMLButtonElement; expect(btn.getAttribute('tabindex')).toBe('-1'); btn.click(); expect(onToggle).toHaveBeenCalledTimes(1); @@ -288,24 +288,24 @@ describe('SidePane', () => { it('collapsed renders the full-height edge strip', () => { const { host, dispose } = mount(() => ( - true} onToggle={() => {}} toggleVariant="edge"> + true} onToggle={() => {}} toggleVariant="edge">
- + )); - expect(host.querySelector('.stage-pane-strip-edge')).not.toBeNull(); - expect(host.querySelector('.stage-pane-strip-button')).not.toBeNull(); + expect(host.querySelector('.stage-panel-strip-edge')).not.toBeNull(); + expect(host.querySelector('.stage-panel-strip-button')).not.toBeNull(); dispose(); }); it('header variant (default) is unchanged', () => { const { host, dispose } = mount(() => ( - false} onToggle={() => {}}> + false} onToggle={() => {}}>
- + )); - expect(host.querySelector('.stage-pane-header')).not.toBeNull(); - expect(host.querySelector('.stage-pane-edge-button')).toBeNull(); - expect(host.querySelector('.stage-pane-expanded-edge')).toBeNull(); + expect(host.querySelector('.stage-panel-header')).not.toBeNull(); + expect(host.querySelector('.stage-panel-edge-button')).toBeNull(); + expect(host.querySelector('.stage-panel-expanded-edge')).toBeNull(); dispose(); }); }); @@ -313,14 +313,14 @@ describe('SidePane', () => { it('reactivity: toggling external collapsed signal swaps DOM', () => { const [collapsed, setCollapsed] = createSignal(false); const { host, dispose } = mount(() => ( - {}}> + {}}>
x
-
+ )); - expect(host.querySelector('.stage-pane-expanded')).not.toBeNull(); + expect(host.querySelector('.stage-panel-expanded')).not.toBeNull(); setCollapsed(true); - expect(host.querySelector('.stage-pane-strip')).not.toBeNull(); - expect(host.querySelector('.stage-pane-expanded')).toBeNull(); + expect(host.querySelector('.stage-panel-strip')).not.toBeNull(); + expect(host.querySelector('.stage-panel-expanded')).toBeNull(); dispose(); }); }); diff --git a/packages/solid-stage/src/panes/SidePane.tsx b/packages/solid-stage/src/layout/Panel.tsx similarity index 68% rename from packages/solid-stage/src/panes/SidePane.tsx rename to packages/solid-stage/src/layout/Panel.tsx index d876bd8..d767ad8 100644 --- a/packages/solid-stage/src/panes/SidePane.tsx +++ b/packages/solid-stage/src/layout/Panel.tsx @@ -6,32 +6,32 @@ import { untrack, type JSX, } from 'solid-js'; -import type { PaneSide } from './types'; +import type { PanelSide } from './types'; import { sizeClassList, type ControlSize } from './size'; -export interface SidePaneProps { - readonly side: PaneSide; +export interface PanelProps { + readonly side: PanelSide; readonly collapsed: () => boolean; readonly onToggle: () => void; readonly children: JSX.Element; /** - * Type-scale tier for the whole pane (header + modules + fields). Omit to - * inherit the stage tier. Overrides the stage for this pane's subtree; a - * module inside can re-tier its own fields again. + * Type-scale tier for the whole panel (header + sections + fields). Omit to + * inherit the stage tier. Overrides the stage for this panel's subtree; a + * section inside can re-tier its own fields again. */ readonly size?: ControlSize | undefined; /** * Where the collapse toggle lives. `'header'` (default) puts it in a top header - * strip; `'edge'` puts it as a full-height vertical bar on the pane's *inside* - * edge (right edge for a left pane, left edge for a right pane), reclaiming the - * header strip's vertical space for module content. Fixed for the instance, like - * `side`. Collapsed, the `'edge'` variant's strip toggle fills the full pane + * strip; `'edge'` puts it as a full-height vertical bar on the panel's *inside* + * edge (right edge for a left panel, left edge for a right panel), reclaiming the + * header strip's vertical space for section content. Fixed for the instance, like + * `side`. Collapsed, the `'edge'` variant's strip toggle fills the full panel * height instead of a top button. */ readonly toggleVariant?: 'header' | 'edge' | undefined; } -function chevronGlyph(side: PaneSide, collapsed: boolean): string { +function chevronGlyph(side: PanelSide, collapsed: boolean): string { if (side === 'left') return collapsed ? '»' : '«'; return collapsed ? '«' : '»'; } @@ -46,20 +46,20 @@ const FOCUSABLE_SELECTOR = 'a[href], button, input, select, textarea, [tabindex] * *attribute*, not the computed `tabIndex` property — the property reports `-1` * for natively-focusable elements (`
- {props.children} + {props.children}
} > {/* Edge variant: a horizontal row — the toggle bar on the inside edge + - the scroll body. Inside edge = right for a left pane, left for a right - pane, hence the per-side ordering. No top header eats vertical space. */} + the scroll body. Inside edge = right for a left panel, left for a right + panel, hence the per-side ordering. No top header eats vertical space. */}
{edgeButton} - {props.children} + {props.children} {edgeButton}
diff --git a/packages/solid-stage/src/panes/Module.test.tsx b/packages/solid-stage/src/layout/Section.test.tsx similarity index 56% rename from packages/solid-stage/src/panes/Module.test.tsx rename to packages/solid-stage/src/layout/Section.test.tsx index c51b729..1a59520 100644 --- a/packages/solid-stage/src/panes/Module.test.tsx +++ b/packages/solid-stage/src/layout/Section.test.tsx @@ -2,17 +2,17 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { type JSX } from 'solid-js'; import { mount } from '../test-utils'; -import { Module } from './Module'; +import { Section } from './Section'; afterEach(() => { document.body.innerHTML = ''; localStorage.clear(); }); -describe('Module', () => { +describe('Section', () => { it('renders the title text', () => { const { host, dispose } = mount(() => ( - body +
body
)); expect(host.textContent).toContain('Animation'); dispose(); @@ -20,51 +20,51 @@ describe('Module', () => { it('defaults open when nothing is cached (down glyph, body present)', () => { const { host, dispose } = mount(() => ( - body content +
body content
)); - const chevron = host.querySelector('.stage-module-chevron') as HTMLSpanElement; + const chevron = host.querySelector('.stage-section-chevron') as HTMLSpanElement; expect(chevron.textContent).toBe('▾'); - expect(host.querySelector('.stage-module-body')?.textContent).toBe('body content'); + expect(host.querySelector('.stage-section-body')?.textContent).toBe('body content'); dispose(); }); it('honours defaultOpen={false}: right glyph + body hidden', () => { const { host, dispose } = mount(() => ( - +
body content - +
)); - const chevron = host.querySelector('.stage-module-chevron') as HTMLSpanElement; + const chevron = host.querySelector('.stage-section-chevron') as HTMLSpanElement; expect(chevron.textContent).toBe('▸'); - expect(host.querySelector('.stage-module-body')).toBeNull(); + expect(host.querySelector('.stage-section-body')).toBeNull(); dispose(); }); it('uncontrolled mode: click toggles glyph + body visibility', () => { const { host, dispose } = mount(() => ( - +
body content - +
)); const button = host.querySelector('button') as HTMLButtonElement; const chevron = (): string => - (host.querySelector('.stage-module-chevron') as HTMLSpanElement).textContent ?? ''; + (host.querySelector('.stage-section-chevron') as HTMLSpanElement).textContent ?? ''; expect(chevron()).toBe('▸'); button.click(); expect(chevron()).toBe('▾'); - expect(host.querySelector('.stage-module-body')).not.toBeNull(); + expect(host.querySelector('.stage-section-body')).not.toBeNull(); button.click(); expect(chevron()).toBe('▸'); - expect(host.querySelector('.stage-module-body')).toBeNull(); + expect(host.querySelector('.stage-section-body')).toBeNull(); dispose(); }); it('onToggle fires with the next value', () => { const onToggle = vi.fn(); const { host, dispose } = mount(() => ( - +
body - +
)); const button = host.querySelector('button') as HTMLButtonElement; button.click(); @@ -76,74 +76,74 @@ describe('Module', () => { }); it('persists collapse state to localStorage (keyed by title) and reseeds from it', () => { - const first = mount(() => body); + const first = mount(() =>
body
); // Defaults open; collapse it. (first.host.querySelector('button') as HTMLButtonElement).click(); - expect(localStorage.getItem('stage-module:Persisted')).toBe('0'); + expect(localStorage.getItem('stage-section:Persisted')).toBe('0'); first.dispose(); first.host.remove(); // A fresh instance with the same title seeds from the cached (collapsed) state. - const second = mount(() => body); - const chevron = second.host.querySelector('.stage-module-chevron') as HTMLSpanElement; + const second = mount(() =>
body
); + const chevron = second.host.querySelector('.stage-section-chevron') as HTMLSpanElement; expect(chevron.textContent).toBe('▸'); - expect(second.host.querySelector('.stage-module-body')).toBeNull(); + expect(second.host.querySelector('.stage-section-body')).toBeNull(); second.dispose(); }); it('cached value wins over defaultOpen', () => { - localStorage.setItem('stage-module:K', '0'); + localStorage.setItem('stage-section:K', '0'); const { host, dispose } = mount(() => ( - +
body - +
)); - expect((host.querySelector('.stage-module-chevron') as HTMLSpanElement).textContent).toBe('▸'); + expect((host.querySelector('.stage-section-chevron') as HTMLSpanElement).textContent).toBe('▸'); dispose(); }); it('persistKey overrides the title as the cache key', () => { const { host, dispose } = mount(() => ( - +
body - +
)); (host.querySelector('button') as HTMLButtonElement).click(); - expect(localStorage.getItem('stage-module:stable-id')).toBe('0'); - expect(localStorage.getItem('stage-module:Display Title')).toBeNull(); + expect(localStorage.getItem('stage-section:stable-id')).toBe('0'); + expect(localStorage.getItem('stage-section:Display Title')).toBeNull(); dispose(); }); it('persist={false} disables caching', () => { const { host, dispose } = mount(() => ( - +
body - +
)); (host.querySelector('button') as HTMLButtonElement).click(); - expect(localStorage.getItem('stage-module:NoCache')).toBeNull(); + expect(localStorage.getItem('stage-section:NoCache')).toBeNull(); dispose(); }); it('controlled mode never caches (parent owns persistence)', () => { const { host, dispose } = mount(() => ( - true} onToggle={() => {}}> +
true} onToggle={() => {}}> body - +
)); (host.querySelector('button') as HTMLButtonElement).click(); - expect(localStorage.getItem('stage-module:Ctl')).toBeNull(); + expect(localStorage.getItem('stage-section:Ctl')).toBeNull(); dispose(); }); it('controlled mode: internal signal is bypassed, glyph reflects parent open()', () => { const onToggle = vi.fn(); const { host, dispose } = mount(() => ( - true} onToggle={onToggle}> +
true} onToggle={onToggle}> body - +
)); - const chevron = host.querySelector('.stage-module-chevron') as HTMLSpanElement; + const chevron = host.querySelector('.stage-section-chevron') as HTMLSpanElement; expect(chevron.textContent).toBe('▾'); const button = host.querySelector('button') as HTMLButtonElement; button.click(); @@ -155,23 +155,23 @@ describe('Module', () => { it('size applies the tier class to the body (not the header)', () => { const { host, dispose } = mount(() => ( - +
body - +
)); - expect(host.querySelector('.stage-module-body')?.classList.contains('stage-size-small')).toBe( + expect(host.querySelector('.stage-section-body')?.classList.contains('stage-size-small')).toBe( true, ); // Header stays untiered so it follows the inherited (pane) size. expect( - host.querySelector('.stage-module-header')?.classList.contains('stage-size-small'), + host.querySelector('.stage-section-header')?.classList.contains('stage-size-small'), ).toBe(false); dispose(); }); it('no size leaves the body untiered (inherits the cascade)', () => { - const { host, dispose } = mount(() => body); - const body = host.querySelector('.stage-module-body') as HTMLElement; + const { host, dispose } = mount(() =>
body
); + const body = host.querySelector('.stage-section-body') as HTMLElement; expect(body.classList.contains('stage-size-small')).toBe(false); expect(body.classList.contains('stage-size-normal')).toBe(false); expect(body.classList.contains('stage-size-large')).toBe(false); @@ -183,42 +183,42 @@ describe('Module', () => { it('renders the custom icon in place of the chevron', () => { const { host, dispose } = mount(() => ( - +
body - +
)); - expect(host.querySelector('.stage-module-icon')).not.toBeNull(); - expect(host.querySelector('.stage-module-icon svg')).not.toBeNull(); - expect(host.querySelector('.stage-module-chevron')).toBeNull(); + expect(host.querySelector('.stage-section-icon')).not.toBeNull(); + expect(host.querySelector('.stage-section-icon svg')).not.toBeNull(); + expect(host.querySelector('.stage-section-chevron')).toBeNull(); dispose(); }); it('without icon the chevron still renders', () => { - const { host, dispose } = mount(() => body); - expect(host.querySelector('.stage-module-chevron')).not.toBeNull(); - expect(host.querySelector('.stage-module-icon')).toBeNull(); + const { host, dispose } = mount(() =>
body
); + expect(host.querySelector('.stage-section-chevron')).not.toBeNull(); + expect(host.querySelector('.stage-section-icon')).toBeNull(); dispose(); }); it('toggle still works with a custom icon', () => { const { host, dispose } = mount(() => ( - +
body - +
)); - expect(host.querySelector('.stage-module-body')).toBeNull(); - (host.querySelector('.stage-module-header-toggle') as HTMLButtonElement).click(); - expect(host.querySelector('.stage-module-body')).not.toBeNull(); + expect(host.querySelector('.stage-section-body')).toBeNull(); + (host.querySelector('.stage-section-header-toggle') as HTMLButtonElement).click(); + expect(host.querySelector('.stage-section-body')).not.toBeNull(); dispose(); }); - it('renders before the static title in a dismissable module', () => { + it('renders before the static title in a dismissable section', () => { const { host, dispose } = mount(() => ( - {}}> +
{}}> body - +
)); - expect(host.querySelector('.stage-module-header-static .stage-module-icon')).not.toBeNull(); + expect(host.querySelector('.stage-section-header-static .stage-section-icon')).not.toBeNull(); dispose(); }); }); @@ -226,47 +226,47 @@ describe('Module', () => { describe('onClose (dismissable mode)', () => { it('renders a static header with an X button, no chevron, body always open', () => { const { host, dispose } = mount(() => ( - {}}> +
{}}> body content - +
)); - expect(host.querySelector('.stage-module-header-static')).not.toBeNull(); - expect(host.querySelector('.stage-module-chevron')).toBeNull(); - expect(host.querySelector('.stage-module-close')).not.toBeNull(); - expect(host.querySelector('.stage-module-body')?.textContent).toBe('body content'); + expect(host.querySelector('.stage-section-header-static')).not.toBeNull(); + expect(host.querySelector('.stage-section-chevron')).toBeNull(); + expect(host.querySelector('.stage-section-close')).not.toBeNull(); + expect(host.querySelector('.stage-section-body')?.textContent).toBe('body content'); dispose(); }); it('clicking the X button fires onClose', () => { const onClose = vi.fn(); const { host, dispose } = mount(() => ( - +
body - +
)); - (host.querySelector('.stage-module-close') as HTMLButtonElement).click(); + (host.querySelector('.stage-section-close') as HTMLButtonElement).click(); expect(onClose).toHaveBeenCalledTimes(1); dispose(); }); it('does not persist collapse state (no toggle to cache)', () => { const { dispose } = mount(() => ( - {}}> +
{}}> body - +
)); - expect(localStorage.getItem('stage-module:Settings')).toBeNull(); + expect(localStorage.getItem('stage-section:Settings')).toBeNull(); dispose(); }); it('ignores a cached collapsed state — stays open', () => { - localStorage.setItem('stage-module:Settings', '0'); + localStorage.setItem('stage-section:Settings', '0'); const { host, dispose } = mount(() => ( - {}}> +
{}}> body - +
)); - expect(host.querySelector('.stage-module-body')?.textContent).toBe('body'); + expect(host.querySelector('.stage-section-body')?.textContent).toBe('body'); dispose(); }); }); @@ -276,7 +276,7 @@ describe('Module', () => { it('renders one borderless icon button per action', () => { const { host, dispose } = mount(() => ( - {} }, @@ -284,9 +284,9 @@ describe('Module', () => { ]} > body - + )); - const buttons = host.querySelectorAll('.stage-module-header-actions .stage-icon-button'); + const buttons = host.querySelectorAll('.stage-section-header-actions .stage-icon-button'); expect(buttons.length).toBe(2); buttons.forEach((b) => { // Borderless + subtle: the header always uses the bare secondary skin. @@ -301,46 +301,46 @@ describe('Module', () => { dispose(); }); - it('clicking an action fires its onClick and does NOT toggle the module', () => { + it('clicking an action fires its onClick and does NOT toggle the section', () => { const onClick = vi.fn(); const { host, dispose } = mount(() => ( - +
body - +
)); - // Module defaults open. - expect(host.querySelector('.stage-module-body')).not.toBeNull(); - (host.querySelector('.stage-module-header-actions .stage-icon-button') as HTMLButtonElement).click(); + // Section defaults open. + expect(host.querySelector('.stage-section-body')).not.toBeNull(); + (host.querySelector('.stage-section-header-actions .stage-icon-button') as HTMLButtonElement).click(); expect(onClick).toHaveBeenCalledTimes(1); // Still open — the action is a sibling of the toggle, so it never toggles. - expect(host.querySelector('.stage-module-body')).not.toBeNull(); + expect(host.querySelector('.stage-section-body')).not.toBeNull(); dispose(); }); it('the toggle still collapses the body when actions are present', () => { const { host, dispose } = mount(() => ( - {} }]}> +
{} }]}> body - +
)); - (host.querySelector('.stage-module-header-toggle') as HTMLButtonElement).click(); - expect(host.querySelector('.stage-module-body')).toBeNull(); + (host.querySelector('.stage-section-header-toggle') as HTMLButtonElement).click(); + expect(host.querySelector('.stage-section-body')).toBeNull(); dispose(); }); - it('a dismissable module shows actions left of the × close', () => { + it('a dismissable section shows actions left of the × close', () => { const { host, dispose } = mount(() => ( - {}} headerActions={[{ icon: Icon, title: 'Copy', onClick: () => {} }]} > body - + )); - const actions = host.querySelector('.stage-module-header-actions') as HTMLElement; + const actions = host.querySelector('.stage-section-header-actions') as HTMLElement; expect(actions.querySelector('.stage-icon-button')).not.toBeNull(); - const close = actions.querySelector('.stage-module-close'); + const close = actions.querySelector('.stage-section-close'); expect(close).not.toBeNull(); // Mouse-only — Escape is the keyboard close. expect(close?.getAttribute('tabindex')).toBe('-1'); diff --git a/packages/solid-stage/src/panes/Module.tsx b/packages/solid-stage/src/layout/Section.tsx similarity index 70% rename from packages/solid-stage/src/panes/Module.tsx rename to packages/solid-stage/src/layout/Section.tsx index 63321ad..b13ca3c 100644 --- a/packages/solid-stage/src/panes/Module.tsx +++ b/packages/solid-stage/src/layout/Section.tsx @@ -1,20 +1,20 @@ import { For, Show, createSignal, type JSX } from 'solid-js'; import { Dynamic } from 'solid-js/web'; import { readFlag, writeFlag } from '../state/persist'; -import { IconButton, type IconButtonTone, type IconComponent } from '../fields/IconButton'; +import { IconButton, type IconButtonTone, type IconComponent } from '../controls/IconButton'; import { sizeClassList, type ControlSize } from './size'; -/** Namespace for the per-module collapse-state keys in localStorage. */ -const STORAGE_PREFIX = 'stage-module:'; +/** Namespace for the per-section collapse-state keys in localStorage. */ +const STORAGE_PREFIX = 'stage-section:'; /** - * One icon button on a module's title bar (e.g. copy / lock / popout). Rendered + * One icon button on a section's title bar (e.g. copy / lock / popout). Rendered * as a **borderless** `IconButton` — the header always uses the borderless skin, * so `border` is not exposed. `title` is required: it's the button's accessible * name and its native hover tooltip ("what the button does"), since the button * shows only an icon. */ -export interface ModuleHeaderAction { +export interface SectionHeaderAction { readonly icon: IconComponent; readonly title: string; readonly onClick: () => void; @@ -25,7 +25,7 @@ export interface ModuleHeaderAction { readonly tone?: IconButtonTone | undefined; } -export interface ModuleProps { +export interface SectionProps { readonly title: string; readonly children: JSX.Element; readonly defaultOpen?: boolean | undefined; @@ -33,40 +33,40 @@ export interface ModuleProps { readonly onToggle?: ((next: boolean) => void) | undefined; /** * localStorage key the collapse state is cached under. Defaults to `title`; - * set an explicit key if two modules could collide on the same title. + * set an explicit key if two sections could collide on the same title. */ readonly persistKey?: string | undefined; /** * Cache the collapse state across reloads (uncontrolled mode only). On by - * default — pass `false` to opt a module out. + * default — pass `false` to opt a section out. */ readonly persist?: boolean | undefined; /** - * Make the module *dismissable* instead of collapsible. When set, the header + * Make the section *dismissable* instead of collapsible. When set, the header * becomes static (no chevron, no toggle), the body is always open, collapse * persistence is skipped, and a `×` button anchored top-right calls this. Used - * for a module hosted in the global modal (`stageModal`), where the section is + * for a section hosted in the global modal (`stageModal`), where the section is * opened, edited, then closed rather than collapsed. */ readonly onClose?: (() => void) | undefined; /** * Icon buttons on the right of the title bar (copy / lock / popout, …). Each is - * rendered as a borderless `IconButton`; in a dismissable module they sit left - * of the `×`. Clicking one fires its `onClick` without toggling the module. + * rendered as a borderless `IconButton`; in a dismissable section they sit left + * of the `×`. Clicking one fires its `onClick` without toggling the section. */ - readonly headerActions?: ReadonlyArray | undefined; + readonly headerActions?: ReadonlyArray | undefined; /** * Override the `▾`/`▸` collapse chevron with a custom icon. One icon serves * both states (open and closed) — swap it per-state from the consumer side via * `open()`/`onToggle` if a state-dependent glyph is wanted. In a dismissable - * (modal) module the icon renders before the static title. + * (modal) section the icon renders before the static title. */ readonly icon?: IconComponent | undefined; /** - * Type-scale tier for this module's *fields* (applied to the body, not the - * header). Omit to inherit the pane/stage tier. A module sizing its own fields - * keeps the pane's header height, since the override never touches the header — - * so an info-dense module can go `small` without shrinking its header out of + * Type-scale tier for this section's *fields* (applied to the body, not the + * header). Omit to inherit the panel/stage tier. A section sizing its own fields + * keeps the panel's header height, since the override never touches the header — + * so an info-dense section can go `small` without shrinking its header out of * line with its neighbours. */ readonly size?: ControlSize | undefined; @@ -75,20 +75,20 @@ export interface ModuleProps { /** * Collapsible container — a header with a chevron over a body. The body * holds whatever the consumer composes (a `FieldGroup`, readonly text, an - * arbitrary panel); the module is the unit a consumer builds editor UI + * arbitrary panel); the section is the unit a consumer builds editor UI * from. * * Controlled or uncontrolled, mirrored like the rest of the library: pass * `open()` + `onToggle` to drive it from the parent, otherwise the internal * signal is the source of truth. * - * Uncontrolled modules persist their collapse state to localStorage out of the + * Uncontrolled sections persist their collapse state to localStorage out of the * box (keyed by `title`, or `persistKey`). The initial state is the cached - * value if present, else `defaultOpen`, else open — so a module with nothing - * cached shows expanded. Controlled modules (`open()` supplied) never cache; + * value if present, else `defaultOpen`, else open — so a section with nothing + * cached shows expanded. Controlled sections (`open()` supplied) never cache; * the parent owns persistence. */ -export function Module(props: ModuleProps): JSX.Element { +export function Section(props: SectionProps): JSX.Element { // Dismissable (modal) mode: header is static, body always open, no persistence. const dismissable = (): boolean => props.onClose !== undefined; const persist = (): boolean => props.persist !== false && !dismissable(); @@ -114,41 +114,41 @@ export function Module(props: ModuleProps): JSX.Element { }; const hasActions = (): boolean => dismissable() || (props.headerActions?.length ?? 0) > 0; return ( -
+
{/* Header is a flex row: a left label area (a toggle button when collapsible, a static title when dismissable) + an optional right-side actions cluster. The actions are siblings of the toggle, not nested inside it, so a click on one never reaches the toggle (no nested } > {(icon) => ( - + )} - {props.title} + {props.title} -
+
{(action) => ( props.onClose?.()} @@ -181,7 +181,7 @@ export function Module(props: ModuleProps): JSX.Element {
-
+
{props.children}
diff --git a/packages/solid-stage/src/panes/TabMenu.test.tsx b/packages/solid-stage/src/layout/TabMenu.test.tsx similarity index 100% rename from packages/solid-stage/src/panes/TabMenu.test.tsx rename to packages/solid-stage/src/layout/TabMenu.test.tsx diff --git a/packages/solid-stage/src/panes/TabMenu.tsx b/packages/solid-stage/src/layout/TabMenu.tsx similarity index 100% rename from packages/solid-stage/src/panes/TabMenu.tsx rename to packages/solid-stage/src/layout/TabMenu.tsx diff --git a/packages/solid-stage/src/panes/Toolbar.test.tsx b/packages/solid-stage/src/layout/Toolbar.test.tsx similarity index 100% rename from packages/solid-stage/src/panes/Toolbar.test.tsx rename to packages/solid-stage/src/layout/Toolbar.test.tsx diff --git a/packages/solid-stage/src/panes/Toolbar.tsx b/packages/solid-stage/src/layout/Toolbar.tsx similarity index 100% rename from packages/solid-stage/src/panes/Toolbar.tsx rename to packages/solid-stage/src/layout/Toolbar.tsx diff --git a/packages/solid-stage/src/panes/size.ts b/packages/solid-stage/src/layout/size.ts similarity index 100% rename from packages/solid-stage/src/panes/size.ts rename to packages/solid-stage/src/layout/size.ts diff --git a/packages/solid-stage/src/layout/types.ts b/packages/solid-stage/src/layout/types.ts new file mode 100644 index 0000000..1d6b896 --- /dev/null +++ b/packages/solid-stage/src/layout/types.ts @@ -0,0 +1 @@ +export type PanelSide = 'left' | 'right'; diff --git a/packages/solid-stage/src/overlay/modal.tsx b/packages/solid-stage/src/overlay/modal.tsx index 2e86399..9803404 100644 --- a/packages/solid-stage/src/overlay/modal.tsx +++ b/packages/solid-stage/src/overlay/modal.tsx @@ -3,7 +3,7 @@ import { Dynamic } from 'solid-js/web'; /** * What a consumer hands to `stageModal.open`: a *thunk* returning the modal's - * content (a single `Module`, by convention). It is deliberately a function, not + * content (a single `Section`, by convention). It is deliberately a function, not * pre-built JSX — `open` is typically called from an event handler, which runs * with no Solid owner, so eagerly-created JSX would orphan its effects/cleanups. * The host instantiates the thunk under its own owner instead (via ``), @@ -12,7 +12,7 @@ import { Dynamic } from 'solid-js/web'; export type ModalContent = () => JSX.Element; export interface ModalOpenOptions { - /** Accessible name for the dialog (typically the hosted module's title). */ + /** Accessible name for the dialog (typically the hosted section's title). */ readonly label?: string | undefined; } @@ -36,8 +36,8 @@ const [label, setLabel] = createSignal(undefined); // keyboard user lands back where they were. let opener: HTMLElement | null = null; -// Focusable descendants of the container, in DOM order. Mirrors SidePane's -// `paneTabbables`: focusable, not disabled, not opted out via a `tabindex="-1"` +// Focusable descendants of the container, in DOM order. Mirrors Panel's +// `panelTabbables`: focusable, not disabled, not opted out via a `tabindex="-1"` // *attribute* (the computed `tabIndex` property misreports natively-focusable // elements, so we read the attribute). const FOCUSABLE_SELECTOR = @@ -80,7 +80,7 @@ export const stageModal: StageModalController = { open, close, isOpen }; * descendant of the consumer's root it inherits every token cleanly. * * The backdrop dims the page and swallows clicks (they neither close the modal - * nor pass through to content beneath). Closing is the `×` button (a `Module`'s + * nor pass through to content beneath). Closing is the `×` button (a `Section`'s * `onClose`), the Escape key, or an imperative `stageModal.close()`. Focus is * trapped within the dialog and restored to the opener on close. */ diff --git a/packages/solid-stage/src/panes/types.ts b/packages/solid-stage/src/panes/types.ts deleted file mode 100644 index 1eccf43..0000000 --- a/packages/solid-stage/src/panes/types.ts +++ /dev/null @@ -1 +0,0 @@ -export type PaneSide = 'left' | 'right'; diff --git a/packages/solid-stage/src/state/createEditorStore.test.tsx b/packages/solid-stage/src/state/createEditorStore.test.tsx index 85b4c0f..5d8ca9f 100644 --- a/packages/solid-stage/src/state/createEditorStore.test.tsx +++ b/packages/solid-stage/src/state/createEditorStore.test.tsx @@ -2,7 +2,7 @@ import { createRoot } from 'solid-js'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { mount } from '../test-utils'; -import { NumberField } from '../fields/NumberField'; +import { NumberField } from '../controls/NumberField'; import { createEditorStore, type EditorStore } from './createEditorStore'; interface Settings { diff --git a/packages/solid-stage/src/state/persist.ts b/packages/solid-stage/src/state/persist.ts index f00073d..8c7a4ce 100644 --- a/packages/solid-stage/src/state/persist.ts +++ b/packages/solid-stage/src/state/persist.ts @@ -76,9 +76,9 @@ export function savePersisted( /* Boolean flags --------------------------------------------------------- */ /* The shared kernel behind every persisted collapse/visibility toggle in the - library (`Module`, `EditorPanes` panes, and `createPersistedToggle`). Stores + library (`Section`, `EditorPanels` panels, and `createPersistedToggle`). Stores a bare `'1'`/`'0'` under the full key the caller supplies — the caller owns - the namespace (`stage-module:` / `stage-pane:` / consumer-chosen). */ + the namespace (`stage-section:` / `stage-panel:` / consumer-chosen). */ /** * Read a persisted boolean flag. `undefined` means nothing stored — the caller diff --git a/packages/solid-stage/src/theme/base.css b/packages/solid-stage/src/theme/base.css index 8b1c40d..c7f8789 100644 --- a/packages/solid-stage/src/theme/base.css +++ b/packages/solid-stage/src/theme/base.css @@ -1,5 +1,5 @@ /* Outer chrome -------------------------------------------------------- */ -.stage-panes { +.stage-panels { position: fixed; inset: 0; display: grid; @@ -8,7 +8,7 @@ /* Embedded stage: fills its parent slot instead of the window, so a stage can be nested inside another stage's centre cell (split / multi-stage layouts). The parent slot must establish a height. */ -.stage-panes.stage-panes-embedded { +.stage-panels.stage-panels-embedded { position: relative; inset: auto; width: 100%; @@ -100,8 +100,8 @@ overflow: hidden; } -/* SidePane ------------------------------------------------------------- */ -.stage-pane { +/* Panel ------------------------------------------------------------- */ +.stage-panel { z-index: var(--stage-z-pane-track); height: 100%; /* border-box so the inner border draws inside the pane's own width rather @@ -115,19 +115,19 @@ user-select: none; } -.stage-pane-left { +.stage-panel-left { border-right: 1px solid var(--stage-color-border); } -.stage-pane-right { +.stage-panel-right { border-left: 1px solid var(--stage-color-border); } -.stage-pane-strip { +.stage-panel-strip { width: var(--stage-strip-width); } -.stage-pane-expanded { +.stage-panel-expanded { width: var(--stage-expanded-width); } @@ -137,12 +137,12 @@ (header variant) it stays anchored to the top in a control-height band — the same vertical spot it held in the expanded header strip, so it doesn't jump on collapse. The edge variant re-centres it (its expanded chevron is centred too). */ -.stage-pane-strip-button { +.stage-panel-strip-button { display: flex; flex: 1; align-items: flex-start; justify-content: center; - background: var(--stage-color-module-header-bg); + background: var(--stage-color-section-header-bg); border: none; color: var(--stage-color-text-label); cursor: pointer; @@ -152,45 +152,45 @@ line-height: var(--stage-control-height); } -.stage-pane-strip-button:hover { +.stage-panel-strip-button:hover { color: var(--stage-color-text); - background: var(--stage-color-module-header-hover-bg); + background: var(--stage-color-section-header-hover-bg); } -.stage-pane-strip-edge .stage-pane-strip-button { +.stage-panel-strip-edge .stage-panel-strip-button { align-items: center; } -.stage-pane-header { +.stage-panel-header { display: flex; align-items: center; /* No padding: the toggle button fills the bar edge-to-edge so the whole header is the click target (the chevron's inset lives on the button). */ padding: 0; - height: var(--stage-pane-header-height); + height: var(--stage-panel-header-height); border-bottom: 1px solid var(--stage-color-border); flex-shrink: 0; } /* Edge variant expanded: a horizontal row (toggle bar + body) instead of the header-over-body column. The body must be allowed to shrink in the row flexbox. */ -.stage-pane-expanded-edge { +.stage-panel-expanded-edge { flex-direction: row; } -.stage-pane-expanded-edge .stage-pane-scroll { +.stage-panel-expanded-edge .stage-panel-scroll { min-width: 0; } /* Full-height vertical toggle bar on the pane's inside edge. Strip-width wide, transparent (the pane bg shows through), with a divider on the body side. */ -.stage-pane-edge-button { +.stage-panel-edge-button { flex: none; width: var(--stage-strip-width); display: flex; align-items: center; justify-content: center; - background: var(--stage-color-module-header-bg); + background: var(--stage-color-section-header-bg); border: none; color: var(--stage-color-text-label); padding: 0; @@ -200,39 +200,39 @@ font-size: var(--stage-font-size); } -.stage-pane-edge-button:hover { +.stage-panel-edge-button:hover { color: var(--stage-color-text); - background: var(--stage-color-module-header-hover-bg); + background: var(--stage-color-section-header-hover-bg); } -.stage-pane-left .stage-pane-edge-button { +.stage-panel-left .stage-panel-edge-button { border-left: 1px solid var(--stage-color-border); } -.stage-pane-right .stage-pane-edge-button { +.stage-panel-right .stage-panel-edge-button { border-right: 1px solid var(--stage-color-border); } /* The chevron sits on the pane's inner edge: right for a left pane, left for a right pane. The button fills the bar, so the alignment lives on it. */ -.stage-pane-header-left .stage-pane-header-button { +.stage-panel-header-left .stage-panel-header-button { justify-content: flex-end; } -.stage-pane-header-right .stage-pane-header-button { +.stage-panel-header-right .stage-panel-header-button { justify-content: flex-start; } /* Fills the whole header strip so the entire bar is the click target (consistent - with the edge bar and the module header toggle), not just the chevron glyph. + with the edge bar and the section header toggle), not just the chevron glyph. The chevron sizes off the font tier so it scales with control size. */ -.stage-pane-header-button { +.stage-panel-header-button { display: flex; flex: 1; align-self: stretch; align-items: center; justify-content: center; - background: var(--stage-color-module-header-bg); + background: var(--stage-color-section-header-bg); border: none; color: var(--stage-color-text-label); padding: 0 6px; @@ -242,17 +242,17 @@ font-size: var(--stage-font-size); } -.stage-pane-header-button:hover { +.stage-panel-header-button:hover { color: var(--stage-color-text); - background: var(--stage-color-module-header-hover-bg); + background: var(--stage-color-section-header-hover-bg); } /* Wraps the scroll viewport + the overflow-affordance bars. Relative so the bars can pin to its top/bottom edge; min-height:0 lets it (and the body) shrink below content height so the band row's cap (minmax(0,1fr) in - EditorPanes) takes effect and scrolling engages instead of the content + EditorPanels) takes effect and scrolling engages instead of the content forcing the pane taller. */ -.stage-pane-scroll { +.stage-panel-scroll { position: relative; flex: 1; min-height: 0; @@ -260,7 +260,7 @@ flex-direction: column; } -.stage-pane-body { +.stage-panel-body { flex: 1; min-height: 0; overflow-y: auto; @@ -269,15 +269,15 @@ scrollbar-width: none; } -.stage-pane-body::-webkit-scrollbar { +.stage-panel-body::-webkit-scrollbar { display: none; } /* Zero-content markers at the very top/bottom of the scroll area. An IntersectionObserver watches whether each is in view to decide if content - overflows that direction (see SidePane). 1px (not 0) so intersection is + overflows that direction (see Panel). 1px (not 0) so intersection is reliable across browsers; inert so they never catch pointer/selection. */ -.stage-pane-scroll-sentinel { +.stage-panel-scroll-sentinel { height: 1px; pointer-events: none; } @@ -285,7 +285,7 @@ /* Thin arrowed bar pinned to the pane's top/bottom edge, shown only when content overflows that way. Overlays the body so toggling it never reflows content; clicking scrolls fully to that end. */ -.stage-pane-scroll-hint { +.stage-panel-scroll-hint { position: absolute; left: 0; right: 0; @@ -303,35 +303,35 @@ cursor: pointer; } -.stage-pane-scroll-hint:hover { +.stage-panel-scroll-hint:hover { color: var(--stage-color-text); } -.stage-pane-scroll-hint-top { +.stage-panel-scroll-hint-top { top: 0; border-bottom: 1px solid var(--stage-color-border); } -.stage-pane-scroll-hint-bottom { +.stage-panel-scroll-hint-bottom { bottom: 0; border-top: 1px solid var(--stage-color-border); } -/* Module (collapsible container) -------------------------------------- */ -/* Every module closes with a bottom border — including the last in a stack — - so a pane's module list reads as closed off. Borders are bottom-only (no - module has a top border), so adjacent modules share a single divider; the +/* Section (collapsible container) -------------------------------------- */ +/* Every section closes with a bottom border — including the last in a stack — + so a pane's section list reads as closed off. Borders are bottom-only (no + section has a top border), so adjacent sections share a single divider; the last one's border just caps the list with nothing to double against. */ -.stage-module { +.stage-section { display: flex; flex-direction: column; border-bottom: 1px solid var(--stage-color-border); } -/* Exception: a module nested as the last child inside another module's body - would draw its rule a body-padding gap above the outer module's own bottom +/* Exception: a section nested as the last child inside another section's body + would draw its rule a body-padding gap above the outer section's own bottom border — a visual near-double. Drop it; the outer border caps the group. */ -.stage-module-body .stage-module:last-child { +.stage-section-body .stage-section:last-child { border-bottom: none; } @@ -341,7 +341,7 @@ reading the *inherited* control-height so it follows the pane's size. The horizontal padding lives on the children (toggle / title / actions), not here, so the toggle's hover highlight can reach the header's left edge. */ -.stage-module-header { +.stage-section-header { display: flex; align-items: center; width: 100%; @@ -351,18 +351,18 @@ constant control-height − 1px whether or not the divider is shown. Without this the open state's visible bottom-border (added below) eats into the border-box height, shrinking the content box by 1px versus the collapsed - state (whose divider lives on .stage-module instead) — which nudges the + state (whose divider lives on .stage-section instead) — which nudges the vertically-centred title. The open rule just recolours this transparent - border; collapsed leaves it invisible while .stage-module caps the list. */ + border; collapsed leaves it invisible while .stage-section caps the list. */ border-bottom: 1px solid transparent; - background: var(--stage-color-module-header-bg); + background: var(--stage-color-section-header-bg); } /* Collapsible toggle — chevron + title; clicking it toggles the body. `flex: 1` so it fills the width up to the actions, `align-self: stretch` so its hover highlight fills the full header height. Styled as the bold field label (muted, tier font-size); hover/focus brighten it. */ -.stage-module-header-toggle { +.stage-section-header-toggle { display: flex; align-items: center; gap: 4px; @@ -370,7 +370,7 @@ min-width: 0; align-self: stretch; box-sizing: border-box; - padding: var(--stage-module-header-pad); + padding: var(--stage-section-header-pad); background: transparent; border: none; color: var(--stage-color-text-muted); @@ -388,12 +388,12 @@ is false while an action icon is hovered: the icons read as a layer above the bar and hovering them never lights it. The toggle itself stays transparent and only brightens its text. */ -.stage-module-header:has(.stage-module-header-toggle:hover), -.stage-module-header:has(.stage-module-header-toggle:focus-visible) { - background: var(--stage-color-module-header-hover-bg); +.stage-section-header:has(.stage-section-header-toggle:hover), +.stage-section-header:has(.stage-section-header-toggle:focus-visible) { + background: var(--stage-color-section-header-hover-bg); } -.stage-module-header-toggle:hover { +.stage-section-header-toggle:hover { color: var(--stage-color-text); } @@ -402,7 +402,7 @@ clearly selected without shifting layout or fighting the open/closed bottom-border divider. Tab only (`:focus-visible`) — a mouse click leaves no border. */ -.stage-module-header-toggle:focus-visible { +.stage-section-header-toggle:focus-visible { color: var(--stage-color-text); outline: 1px solid var(--stage-color-focus); outline-offset: -1px; @@ -411,7 +411,7 @@ /* The title text — bold label that truncates with an ellipsis when squeezed (by header actions, or a long title). It has no own colour, so it inherits from the toggle (hover brightens it too) or, when dismissable, from the static header. */ -.stage-module-title { +.stage-section-title { flex: 1; min-width: 0; overflow: hidden; @@ -421,28 +421,28 @@ font-size: var(--stage-font-size); } -/* Divider between header and body only when the module is open; a closed - module leans on .stage-module's own bottom border, so this avoids a double +/* Divider between header and body only when the section is open; a closed + section leans on .stage-section's own bottom border, so this avoids a double rule. */ -.stage-module-open > .stage-module-header { +.stage-section-open > .stage-section-header { border-bottom: 1px solid var(--stage-color-border); } /* Dismissable (modal) header — static, no toggle. The title sits left as the bold label (its own padding, since there's no toggle to inherit it from); actions (incl. the × close) anchor right. */ -.stage-module-header-static { +.stage-section-header-static { color: var(--stage-color-text-muted); } -.stage-module-header-static > .stage-module-title { - padding: var(--stage-module-header-pad); +.stage-section-header-static > .stage-section-title { + padding: var(--stage-section-header-pad); } /* Right-side actions cluster — borderless icon buttons (and the × close in a - dismissable module), square at control-height so they line up with the header. + dismissable section), square at control-height so they line up with the header. The gap from the title is supplied by the toggle's / title's right padding. */ -.stage-module-header-actions { +.stage-section-header-actions { display: flex; align-items: center; gap: 2px; @@ -455,7 +455,7 @@ mouse-only (tabindex=-1; Escape closes a modal), so it shows no focus ring; the glyph brightens on hover and a press flashes a faint background, matching the subtle header-action icons. */ -.stage-module-close { +.stage-section-close { display: inline-flex; align-items: center; justify-content: center; @@ -472,15 +472,15 @@ cursor: pointer; } -.stage-module-close:hover { +.stage-section-close:hover { color: var(--stage-color-text); } -.stage-module-close:active { - background: var(--stage-color-module-header-hover-bg); +.stage-section-close:active { + background: var(--stage-color-section-header-hover-bg); } -.stage-module-chevron { +.stage-section-chevron { display: inline-block; width: 0.8rem; font-size: 0.7em; @@ -488,10 +488,10 @@ color: var(--stage-color-text-muted); } -/* Custom icon replacing the chevron (Module `icon` prop). Centred, muted to +/* Custom icon replacing the chevron (Section `icon` prop). Centred, muted to match the chevron, and the glyph tracks the size tier exactly like an icon button (`--stage-icon-size` applied to the rendered ). */ -.stage-module-icon { +.stage-section-icon { display: inline-flex; align-items: center; justify-content: center; @@ -499,22 +499,22 @@ color: var(--stage-color-text-muted); } -.stage-module-icon svg { +.stage-section-icon svg { width: var(--stage-icon-size); height: var(--stage-icon-size); } /* In a static (dismissable) header there's no toggle padding to inherit, so the leading icon supplies the left pad matching the header. */ -.stage-module-header-static > .stage-module-icon { - padding: var(--stage-module-header-pad); +.stage-section-header-static > .stage-section-icon { + padding: var(--stage-section-header-pad); padding-right: 0; } -.stage-module-body { +.stage-section-body { /* Consumer composes the body (a FieldGroup, text, ...); we provide a dense default frame. */ - padding: var(--stage-module-body-pad); + padding: var(--stage-section-body-pad); } /* TabMenu (bar control) ----------------------------------------------- */ @@ -565,14 +565,14 @@ /* Hover brightens a tab like a button — but not the active tab (already lifted). */ .stage-tab:not(.stage-tab-active):hover { - background: var(--stage-color-module-header-hover-bg); + background: var(--stage-color-section-header-hover-bg); } /* Active tab: a 1px top accent line + a lifted surface + brighter label, rather than a full-border highlight. */ .stage-tab-active { border-top-color: var(--stage-color-accent); - background: var(--stage-color-module-header-hover-bg); + background: var(--stage-color-section-header-hover-bg); } /* The tab's label/select target — fills the tab, label ellipsises when squeezed. */ @@ -638,7 +638,7 @@ .stage-tabmenu-add:hover, .stage-tabmenu-scroll:hover { color: var(--stage-color-text); - background: var(--stage-color-module-header-hover-bg); + background: var(--stage-color-section-header-hover-bg); } /* Close glyphs (VSCode-style reveal). The close button keeps its width always so diff --git a/packages/solid-stage/src/theme/fields.css b/packages/solid-stage/src/theme/fields.css index 7b207f1..a8366bf 100644 --- a/packages/solid-stage/src/theme/fields.css +++ b/packages/solid-stage/src/theme/fields.css @@ -50,7 +50,7 @@ align-items: center; /* The row snaps to one control-height; a short control (boolean / checkbox / slider / icon-button) centres in it instead of collapsing the row below the - tall-control rows, so a module's rows line up. Tall controls pin their own + tall-control rows, so a section's rows line up. Tall controls pin their own height (see .stage-input / .stage-select-trigger). */ min-height: var(--stage-control-height); } @@ -155,7 +155,7 @@ width: 100%; } -/* LabelField: static descriptive text ------------------------------------- +/* Label: static descriptive text ------------------------------------- * * Display-only text with no caption/value split — a single full-width text * element filling the row, not the label|value grid the other fields use. The @@ -430,7 +430,7 @@ .stage-number-step:hover { color: var(--stage-color-text-label); - background: var(--stage-color-module-header-hover-bg); + background: var(--stage-color-section-header-hover-bg); } .stage-number-step:active { @@ -543,7 +543,7 @@ .stage-select-item[data-highlighted] { outline: none; - background: var(--stage-color-module-header-hover-bg); + background: var(--stage-color-section-header-hover-bg); color: var(--stage-color-text); } @@ -847,6 +847,12 @@ /* Boolean — toggle switch (variant="toggle"); the checkbox variant uses the native box. ------------------------------------------------------ */ +/* Bare BooleanControl root (no caption): shrink-wrap so the switch/checkbox sits + inline when used directly. A BooleanField supplies its own field row instead. */ +.stage-boolean-control { + display: inline-flex; + align-items: center; +} /* Toggle variant: Kobalte's Switch.Control is the track, .Thumb the knob; state comes from data-checked / data-disabled rather than :checked / :disabled. */ .stage-switch { @@ -1037,7 +1043,7 @@ cursor: default; } -/* A non-tabbable button (`tabbable={false}` — e.g. a module's mouse-only header +/* A non-tabbable button (`tabbable={false}` — e.g. a section's mouse-only header actions) is outside focus navigation, so it shows no focus ring even on the click-focus other buttons use to mark themselves. Two classes + `:focus` out-specifies `.stage-button:focus`. */ @@ -1060,7 +1066,7 @@ } .stage-icon-button:hover:not(:disabled) { - background: var(--stage-color-module-header-hover-bg); + background: var(--stage-color-section-header-hover-bg); color: var(--stage-color-text); } @@ -1109,7 +1115,7 @@ opacity: 0.4; } -/* Subtle variant — secondary / meta actions (e.g. a module's header actions). +/* Subtle variant — secondary / meta actions (e.g. a section's header actions). Dimmer than the default icon button: the glyph sits MUTED, and only the *shape* brightens on hover — no background fill (the default lights its background). These rules come AFTER the default `.stage-icon-button*` rules so they win on @@ -1139,7 +1145,7 @@ over the hover rule (equal specificity) whenever the button is pressed, including a press on an already-active toggle. */ .stage-icon-button-subtle:active:not(:disabled) { - background: var(--stage-color-module-header-hover-bg); + background: var(--stage-color-section-header-hover-bg); } .stage-action-button { @@ -1206,7 +1212,7 @@ } .stage-toggle-button:hover { - background: var(--stage-color-module-header-hover-bg); + background: var(--stage-color-section-header-hover-bg); color: var(--stage-color-text); } diff --git a/packages/solid-stage/src/theme/modal.css b/packages/solid-stage/src/theme/modal.css index 30f26a0..9dec49a 100644 --- a/packages/solid-stage/src/theme/modal.css +++ b/packages/solid-stage/src/theme/modal.css @@ -2,10 +2,10 @@ * * One app-level modal, rendered by `StageModalHost` whenever `stageModal.open` * is called. The backdrop dims the page and centres a single rounded container - * that hosts one `Module`. It sits above all chrome + page-level popovers (its + * that hosts one `Section`. It sits above all chrome + page-level popovers (its * backdrop opens a stacking context, so the modal's own field popovers — which * portal into the container — layer correctly inside it). The backdrop captures - * clicks (they neither pass through nor close); closing is the module's `×`, + * clicks (they neither pass through nor close); closing is the section's `×`, * Escape, or `stageModal.close()`. */ .stage-modal-backdrop { position: fixed; @@ -24,18 +24,18 @@ flex-direction: column; width: 100%; max-width: var(--stage-modal-max-width); - /* Stay inside the padded viewport; the module scrolls if it overflows. */ + /* Stay inside the padded viewport; the section scrolls if it overflows. */ max-height: 100%; overflow: auto; background: var(--stage-color-panel-bg); border: 1px solid var(--stage-color-border); - /* Rounded edges; `overflow` clips the square-cornered module to match. */ + /* Rounded edges; `overflow` clips the square-cornered section to match. */ border-radius: var(--stage-modal-radius); box-shadow: 0 12px 48px rgba(0, 0, 0, 0.5); } -/* The container supplies the frame, so the hosted module drops its own bottom +/* The container supplies the frame, so the hosted section drops its own bottom rule (it would draw just inside the rounded border otherwise). */ -.stage-modal-container > .stage-module { +.stage-modal-container > .stage-section { border-bottom: none; } diff --git a/packages/solid-stage/src/theme/tokens.css b/packages/solid-stage/src/theme/tokens.css index c73e520..af42135 100644 --- a/packages/solid-stage/src/theme/tokens.css +++ b/packages/solid-stage/src/theme/tokens.css @@ -13,9 +13,9 @@ /* Floating popovers read as panels — and must stay opaque so content behind a select listbox can't bleed through and hurt readability. */ --stage-color-popover-bg: var(--stage-color-panel-bg); - /* Module header sits a step darker than the panel body; hover lifts back up. */ - --stage-color-module-header-bg: color-mix(in srgb, var(--stage-color-panel-bg) 85%, #000); - --stage-color-module-header-hover-bg: color-mix(in srgb, var(--stage-color-module-header-bg) 95%, #fff); + /* Section header sits a step darker than the panel body; hover lifts back up. */ + --stage-color-section-header-bg: color-mix(in srgb, var(--stage-color-panel-bg) 85%, #000); + --stage-color-section-header-hover-bg: color-mix(in srgb, var(--stage-color-section-header-bg) 95%, #fff); /* Border lifts off the panel; strong lifts further off the base border. */ --stage-color-border: color-mix(in srgb, var(--stage-color-panel-bg) 90%, #fff); --stage-color-border-strong: color-mix(in srgb, var(--stage-color-border) 90%, #fff); @@ -31,7 +31,7 @@ accent fill and must stay legible against it (dark glyph on a light accent). */ --stage-color-accent: #2196f3; /* Focus indicator colour — the border on focused text-likes and the 1px ring - on slider / switch / checkbox / buttons / module headers. Deliberately + on slider / switch / checkbox / buttons / section headers. Deliberately white (not the accent): a pink ring blends into accent-filled controls, so focus stays legible by being independent of the accent. Consumer-overridable. */ --stage-color-focus: #ffffff; @@ -108,8 +108,8 @@ /* Spacing — tuned for information density over padding. */ /* Horizontal only: the header's height is one control-height (set via min-height in base.css), so it lines up with the field rows below it. */ - --stage-module-header-pad: 0 8px; - --stage-module-body-pad: 4px 8px 6px; + --stage-section-header-pad: 0 8px; + --stage-section-body-pad: 4px 8px 6px; /* Modal overlay — the dim behind it, the rounded container, and its sizing. The container caps at `max-width` and shrinks on narrow viewports, never @@ -188,7 +188,7 @@ size) at once. Plain-specificity classes (specificity 1) so they beat the `:where(.stage-root)` 0-specificity defaults without `!important`; a consumer token override still wins (raw tokens stay overridable). The tokens cascade, - so applying a tier to any subtree (a pane, a module, a bar) resizes everything + so applying a tier to any subtree (a pane, a section, a bar) resizes everything inside it; a nested element can re-tier to override its ancestor. Provisional sizes — tune live in the playground. */ @@ -220,7 +220,7 @@ fields.css mirrors this). A custom property's `var()` is substituted at the element where the property is *declared*, then the resolved value inherits — so declaring these only on the root would freeze the inner `--stage-control-height` - at the root's value, and a subtree size override (Module / SidePane / EditorPanes + at the root's value, and a subtree size override (Section / Panel / EditorPanels `size`, all via a `.stage-size-*` class) would resize the row + base-token controls but NOT these derived ones (the field label, using the base token directly, would shrink while the switch/checkbox/slider/icon stayed put). Listing @@ -232,14 +232,14 @@ .stage-size-large { /* Bar row: one control-height + 8px breathing room. */ --stage-toolbar-height: calc(var(--stage-control-height) + 8px); - /* Expanded-pane top header strip: one control-height, so it matches the module + /* Expanded-pane top header strip: one control-height, so it matches the section headers stacked below it (which also key off control-height). In the per-tier block so it re-resolves at the pane's own size class. */ - --stage-pane-header-height: var(--stage-control-height); + --stage-panel-header-height: var(--stage-control-height); /* Collapsed strip + edge-toggle width = one control-height, so the strip reads - "as wide as a module header is tall" and scales with the tier. Here (not the + "as wide as a section header is tall" and scales with the tier. Here (not the root block) so it re-resolves per size class. The collapsed grid-column track - in EditorPanes reads this token at the panes-grid level: with no per-pane size + in EditorPanels reads this token at the panes-grid level: with no per-pane size override it resolves to the same control-height the strip inherits, so track and strip stay matched. */ --stage-strip-width: var(--stage-control-height); diff --git a/playground/src/App.tsx b/playground/src/App.tsx index c9fcd46..037a565 100644 --- a/playground/src/App.tsx +++ b/playground/src/App.tsx @@ -1,18 +1,20 @@ import { Show, createSignal, type JSX } from 'solid-js'; import { - EditorPanes, - Module, + EditorPanels, + Section, CanvasStage, FieldGroup, FieldBar, TextField, - LabelField, + Label, NumberField, SelectField, BooleanField, SliderField, ColorField, TextareaField, + SliderControl, + SelectControl, ButtonGroup, ActionButton, ToggleButton, @@ -100,7 +102,7 @@ export function App(): JSX.Element { // One reactive source of truth, persisted to localStorage. Both nested // stages bind into THIS store — editing a field in the primary stage's left - // pane live-updates anything reading the same slot in the secondary stage. + // panel live-updates anything reading the same slot in the secondary stage. // Stages are a structural division, not a state boundary. const [state, setState, bind] = createEditorStore({ defaults, @@ -130,9 +132,9 @@ export function App(): JSX.Element { // visibly doing something (mirrored into the primary stage's topSub bar). const [activeModel, setActiveModel] = createSignal('Goblin'); - // Global modal demo — app-level settings hosted in a single Module with an X. - // Its state lives in plain signals; opening it loads the module thunk into the - // shared `stageModal`, and the Module's onClose / a Done button close it. + // Global modal demo — app-level settings hosted in a single Section with an X. + // Its state lives in plain signals; opening it loads the section thunk into the + // shared `stageModal`, and the Section's onClose / a Done button close it. const [appName, setAppName] = createSignal('Untitled Project'); const [appTheme, setAppTheme] = createSignal('dark'); const [appAutosave, setAppAutosave] = createSignal(true); @@ -140,7 +142,7 @@ export function App(): JSX.Element { const openSettings = (): void => { stageModal.open( () => ( - stageModal.close()} headerActions={[ @@ -166,7 +168,7 @@ export function App(): JSX.Element {
stageModal.close()} />
- + ), { label: 'Application Settings' }, ); @@ -205,11 +207,11 @@ export function App(): JSX.Element { } // OUTER stage chrome — full-width bars that frame the whole split. The split - // itself lives in the outer stage's centre; the outer stage has no panes. + // itself lives in the outer stage's centre; the outer stage has no panels. const top = ( - } + left={
- + - +
- +
- +
- +
- {/* LabelField — static descriptive text, no value. The last entry binds no + {/* Label — static descriptive text, no value. The last entry binds no slot but reads `state.text.compact`, so it updates live as you type in - the TextField module in the left pane: dynamic labels need no setter, + the TextField section in the left panel: dynamic labels need no setter, just a reactive `text`. Compact truncates (incl. explicit newlines); expanded wraps and keeps newlines; italic comes from the class style-hook. */} - {} }, @@ -625,34 +652,34 @@ export function App(): JSX.Element { ]} > - - - - - - + - +
); - // PRIMARY nested stage: left pane only, no bars, viewport = world A. The pane + // PRIMARY nested stage: left panel only, no bars, viewport = world A. The panel // is uncontrolled — collapse it via its own chevron; the library persists - // that under `stage-pane:playground-a:left` (persistKey = the per-stage - // namespace, so the two stages' panes never collide). + // that under `stage-panel:playground-a:left` (persistKey = the per-stage + // namespace, so the two stages' panels never collide). const primaryStage = ( - true} newTabTitle="New model" /> - +
} center={ @@ -682,14 +709,14 @@ export function App(): JSX.Element { /> ); - // SECONDARY nested stage: right pane only, no bars, viewport = world B. + // SECONDARY nested stage: right panel only, no bars, viewport = world B. // Uncontrolled + persisted under its own namespace. const secondaryStage = ( - ); - // OUTER stage: chrome bars, no panes. Its centre is the split band holding + // OUTER stage: chrome bars, no panels. Its centre is the split band holding // the two nested stages. This is the recursion: a stage's viewport IS a // stage (here, two of them). return ( @@ -711,7 +738,7 @@ export function App(): JSX.Element { 'stage-size-large': tier() === 'large', }} > -