@ahrowe/ui 0.25.1 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/dist/esm/common/floorPlan/floorPlan.faces.mjs +2 -0
  2. package/dist/esm/common/floorPlan/floorPlan.faces.mjs.map +1 -0
  3. package/dist/esm/common/floorPlan/floorPlan.geometry.mjs +2 -0
  4. package/dist/esm/common/floorPlan/floorPlan.geometry.mjs.map +1 -0
  5. package/dist/esm/common/floorPlan/floorPlan.graph.mjs +2 -0
  6. package/dist/esm/common/floorPlan/floorPlan.graph.mjs.map +1 -0
  7. package/dist/esm/common/floorPlan/floorPlan.hit.mjs +2 -0
  8. package/dist/esm/common/floorPlan/floorPlan.hit.mjs.map +1 -0
  9. package/dist/esm/common/floorPlan/floorPlan.json.mjs +2 -0
  10. package/dist/esm/common/floorPlan/floorPlan.json.mjs.map +1 -0
  11. package/dist/esm/common/floorPlan/floorPlan.path.mjs +2 -0
  12. package/dist/esm/common/floorPlan/floorPlan.path.mjs.map +1 -0
  13. package/dist/esm/common/floorPlan/floorPlan.plan.mjs +2 -0
  14. package/dist/esm/common/floorPlan/floorPlan.plan.mjs.map +1 -0
  15. package/dist/esm/common/floorPlan/floorPlan.rooms.mjs +2 -0
  16. package/dist/esm/common/floorPlan/floorPlan.rooms.mjs.map +1 -0
  17. package/dist/esm/common/floorPlan/floorPlan.snap.mjs +2 -0
  18. package/dist/esm/common/floorPlan/floorPlan.snap.mjs.map +1 -0
  19. package/dist/esm/common/floorPlan/floorPlan.types.mjs +2 -0
  20. package/dist/esm/common/floorPlan/floorPlan.types.mjs.map +1 -0
  21. package/dist/esm/common/floorPlan/legacySvg.mjs +2 -0
  22. package/dist/esm/common/floorPlan/legacySvg.mjs.map +1 -0
  23. package/dist/esm/common/hooks/useSheet.mjs +1 -1
  24. package/dist/esm/common/hooks/useSheet.mjs.map +1 -1
  25. package/dist/esm/common/planCanvas/planCanvas.mjs +2 -0
  26. package/dist/esm/common/planCanvas/planCanvas.mjs.map +1 -0
  27. package/dist/esm/common/planCanvas/planCanvas.module.mjs +2 -0
  28. package/dist/esm/common/planCanvas/planCanvas.module.mjs.map +1 -0
  29. package/dist/esm/common/roomDrawer/roomDrawer.mjs +1 -1
  30. package/dist/esm/common/roomDrawer/roomDrawer.mjs.map +1 -1
  31. package/dist/esm/common/roomDrawer/roomDrawer.module.mjs +1 -1
  32. package/dist/esm/common/roomDrawer/roomDrawer.module.mjs.map +1 -1
  33. package/dist/esm/common/roomDrawer/usePlanHistory.mjs +2 -0
  34. package/dist/esm/common/roomDrawer/usePlanHistory.mjs.map +1 -0
  35. package/dist/esm/common/roomViewer/roomViewer.mjs +1 -1
  36. package/dist/esm/common/roomViewer/roomViewer.mjs.map +1 -1
  37. package/dist/esm/common/roomViewer/roomViewer.module.mjs +1 -1
  38. package/dist/esm/common/roomViewer/roomViewer.module.mjs.map +1 -1
  39. package/dist/esm/common/styles/sheet.module.mjs.map +1 -1
  40. package/dist/esm/index.mjs +1 -1
  41. package/dist/index.cjs +3 -7
  42. package/dist/index.cjs.map +1 -1
  43. package/dist/style.css +1 -1
  44. package/dist/types/package/common/configProvider/configProvider.types.d.ts +4 -0
  45. package/dist/types/package/common/floorPlan/floorPlan.faces.d.ts +33 -0
  46. package/dist/types/package/common/floorPlan/floorPlan.fixtures.d.ts +35 -0
  47. package/dist/types/package/common/floorPlan/floorPlan.geometry.d.ts +111 -0
  48. package/dist/types/package/common/floorPlan/floorPlan.graph.d.ts +139 -0
  49. package/dist/types/package/common/floorPlan/floorPlan.hit.d.ts +26 -0
  50. package/dist/types/package/common/floorPlan/floorPlan.json.d.ts +23 -0
  51. package/dist/types/package/common/floorPlan/floorPlan.path.d.ts +59 -0
  52. package/dist/types/package/common/floorPlan/floorPlan.plan.d.ts +98 -0
  53. package/dist/types/package/common/floorPlan/floorPlan.rooms.d.ts +47 -0
  54. package/dist/types/package/common/floorPlan/floorPlan.snap.d.ts +101 -0
  55. package/dist/types/package/common/floorPlan/floorPlan.types.d.ts +276 -0
  56. package/dist/types/package/common/floorPlan/index.d.ts +28 -0
  57. package/dist/types/package/common/floorPlan/legacySvg.d.ts +27 -0
  58. package/dist/types/package/common/hooks/useSheet.d.ts +4 -1
  59. package/dist/types/package/common/planCanvas/index.d.ts +2 -0
  60. package/dist/types/package/common/planCanvas/planCanvas.d.ts +7 -0
  61. package/dist/types/package/common/planCanvas/planCanvas.types.d.ts +62 -0
  62. package/dist/types/package/common/roomDrawer/roomDrawer.d.ts +3 -3
  63. package/dist/types/package/common/roomDrawer/roomDrawer.types.d.ts +295 -15
  64. package/dist/types/package/common/roomDrawer/usePlanHistory.d.ts +24 -0
  65. package/dist/types/package/common/roomViewer/index.d.ts +1 -0
  66. package/dist/types/package/common/roomViewer/roomViewer.d.ts +9 -2
  67. package/dist/types/package/common/roomViewer/roomViewer.types.d.ts +88 -9
  68. package/dist/types/package/index.d.ts +1 -0
  69. package/docs/CLAUDE.md +1 -0
  70. package/docs/ConfigProvider.md +1 -0
  71. package/docs/Dropdown.md +1 -1
  72. package/docs/FloatingMenu.md +1 -1
  73. package/docs/FloorPlan.md +214 -0
  74. package/docs/Modal.md +2 -2
  75. package/docs/Popover.md +1 -1
  76. package/docs/RoomDrawer.md +330 -30
  77. package/docs/RoomViewer.md +80 -27
  78. package/package.json +1 -1
  79. package/dist/esm/common/roomDrawer/roomDrawer.utils.mjs +0 -6
  80. package/dist/esm/common/roomDrawer/roomDrawer.utils.mjs.map +0 -1
  81. package/dist/types/package/common/roomDrawer/roomDrawer.utils.d.ts +0 -32
  82. package/docs/room-drawing-analysis.md +0 -337
@@ -0,0 +1,214 @@
1
+ # FloorPlan
2
+
3
+ **When to use:** The shared data model behind `RoomDrawer` and `RoomViewer`. Reach for it directly when you need to build, inspect, validate or persist a floor plan in code rather than by drawing one — seeding a plan from a database, computing room areas, or generating a layout programmatically.
4
+
5
+ **Keywords:** blueprint, cad, grundriss, apartment, building, storey, partition, area calculation, planar graph, half-edge, extrusion
6
+
7
+ **Import:** `import { emptyFloorPlan, addWall, derivePlan } from '@ahrowe/ui'`
8
+ **Types:** `import type { FloorPlan, PlanFloor, PlanNode, PlanWall, PlanOpening, PlanRoom, DerivedRoom, PlanPoint, PlanView, PlanSelection } from '@ahrowe/ui'`
9
+
10
+ `PlanPoint`, `PlanView` and `PlanSelection` are the ones that also appear in `RoomDrawer` and `RoomViewer` props. Every exported function's own option and result types come with it — `WallAttrs`, `SplitWallResult`, `WallPathResult` and the rest.
11
+
12
+ ## The model in one paragraph
13
+
14
+ A plan is a **planar wall graph**. `PlanNode`s are junctions, `PlanWall`s are the edges between them, and a wall is **shared**: where two rooms meet there is one wall, not one per room. Rooms are not stored as shapes at all — they are the enclosed faces the walls happen to form, recomputed by `derivePlan`, with a `PlanRoom` carrying only the name and colour. A `PlanOpening` (door, window, archway, gap) belongs to a **wall**, so a door between two rooms is cut once and both rooms open through it.
15
+
16
+ **Every length is in millimetres.** Coordinates, wall thickness, storey height, opening offsets and widths. There is deliberately no per-plan scale factor: it would have to be threaded through every function, and one missed call site is a silent 1000x error. `unitSystem` affects display formatting only and is never read by geometry.
17
+
18
+ ```tsx
19
+ import { emptyFloorPlan, addRect, addOpening, derivePlan, OpeningKind } from '@ahrowe/ui';
20
+
21
+ const plan = emptyFloorPlan({ floorLabel: 'Ground floor' });
22
+ let floor = plan.floors[0];
23
+
24
+ // Two rooms flush against each other. The shared edge becomes ONE wall.
25
+ floor = addRect(floor, { x: 0, y: 0 }, { x: 3000, y: 3000 }).floor;
26
+ floor = addRect(floor, { x: 3000, y: 0 }, { x: 7000, y: 3000 }).floor;
27
+ Object.keys(floor.walls).length; // 7, not 8
28
+
29
+ // Rooms are derived, not drawn.
30
+ const { rooms } = derivePlan(floor);
31
+ rooms.length; // 2
32
+ rooms[0].area; // mm², holes already subtracted
33
+ rooms[0].outer; // the polygon, for rendering
34
+ ```
35
+
36
+ ## Enums
37
+
38
+ - `WallType`: `Exterior` | `Interior` | `Partition` | `Virtual` (a room divider with no physical wall: it still closes a face, but extrudes to nothing in 3D)
39
+ - `OpeningKind`: `Door` | `Window` | `Archway` | `Gap`
40
+ - `DoorSwing`: `None` | `StartLeft` | `StartRight` | `EndLeft` | `EndRight` | `Double` | `Sliding`
41
+ - `PlanUnitSystem`: `Metric` | `Imperial` (display only)
42
+ - `PlanIssueKind`: what `validatePlan` reports
43
+
44
+ ## Building a plan
45
+
46
+ Every operation is pure and returns `PlanEdit<T>` = `{ floor, result, issues }`, so results drop straight into React state.
47
+
48
+ | Function | Purpose |
49
+ |----------|---------|
50
+ | `addWall(floor, a, b, attrs?, tol?)` | Adds a wall, **splitting anything it crosses** so the graph stays planar |
51
+ | `addRect(floor, corner, opposite, attrs?, tol?)` | Four walls at once, welding onto anything already there |
52
+ | `splitWall(floor, wallId, at)` | Cuts a wall at a point or 0..1 parameter; redistributes its openings |
53
+ | `mergeNodes(floor, keepId, removeId)` | Welds two nodes, deduplicating the walls that collapse together |
54
+ | `dissolveNode(floor, nodeId)` | Rejoins two collinear same-construction walls; the inverse of `splitWall` |
55
+ | `removeWall(floor, wallId, opts?)` | Removes a wall, its openings, and any node left orphaned |
56
+ | `moveNode` / `transformPlan` / `scalePlan` | Move one node, translate everything, or rescale everything |
57
+ | `addOpening` / `updateOpening` / `removeOpening` | Openings, clamped to fit their wall |
58
+ | `validatePlan(floor)` | Every way the graph currently breaks the planar invariant |
59
+ | `cleanupPlan(floor)` | Removes what can safely be removed. Idempotent |
60
+
61
+ **`addWall` is the one that matters.** It resolves each endpoint (reusing a nearby node, splitting a wall it lands on, or creating one), then splits every wall it crosses and cuts itself at every node it passes through. That is what makes a second rectangle drawn flush against the first share the existing edge instead of laying a duplicate on top of it.
62
+
63
+ ```tsx
64
+ // Drawing across an existing wall splits both, leaving four walls and a new junction.
65
+ floor = addWall(floor, { x: 0, y: 0 }, { x: 4000, y: 0 }).floor;
66
+ floor = addWall(floor, { x: 2000, y: -2000 }, { x: 2000, y: 2000 }).floor;
67
+ Object.keys(floor.walls).length; // 4
68
+ validatePlan(floor); // []
69
+ ```
70
+
71
+ **Tolerances.** `addWall`'s `tol` is the welding distance in millimetres. Pass the value a user's pointer tolerance works out to in world units (see `resolveSnap` below); the default is exact-match only.
72
+
73
+ ## Deriving rooms
74
+
75
+ ```tsx
76
+ const { floor: refreshed, rooms, issues } = derivePlan(floor);
77
+ ```
78
+
79
+ `derivePlan` extracts the faces and attaches stored metadata. It is pure and **never creates or deletes a `PlanRoom`**, so it is safe to call on every render. Memoise it on `plan.revision`, which every mutation bumps.
80
+
81
+ `commitDerivation(floor)` mints a `PlanRoom` for each face that matched nothing — call it once per committed edit, not per render.
82
+
83
+ Each `DerivedRoom` carries the face (`outer`, `holes`, `area`, `perimeter`, `wallIds`), the matched `meta`, and an `anchor` guaranteed to be **inside** the polygon (an area centroid can fall outside a concave room, so it is computed, not averaged).
84
+
85
+ ### How a room keeps its name
86
+
87
+ Identity is primarily the **set of boundary wall ids**, with the anchor point as a secondary signal and area as a tie-break. That ordering is what makes it robust:
88
+
89
+ - **Reshape or drag the whole plan** — the wall set is unchanged, so it matches regardless of where the anchors ended up.
90
+ - **Split a room** — both children share half the old boundary, so the **anchor decides**: the child containing it keeps the name, the other becomes a new room. Predictable beats plausible.
91
+ - **Merge two rooms** — one wins on area (the larger), and **the loser is kept, not deleted**. It is reported as `PlanIssueKind.UnmatchedRoom`, and re-adding the wall restores it to the right child by itself. Call `pruneUnmatchedRooms` only when you actually want them gone.
92
+
93
+ Set `anchorPinned` on a `PlanRoom` to stop `derivePlan` moving its label.
94
+
95
+ ### Free-standing walls
96
+
97
+ A wall with a loose end does not create a room and does not break the one it sits in. The face walk goes out along it and straight back, so the room's fill, area and hit-testing are unaffected. Two consequences: the face polygon contains a zero-width slit (so it is not a *simple* polygon — run `stripSpurs` before triangulating a floor slab), and that wall's id appears **twice** in `wallIds`.
98
+
99
+ ## Rendering walls
100
+
101
+ `buildWallPaths(floor)` returns `{ groups, caps }` for a two-pass stroke. Both passes stroke the **same** path data: the outline pass at `thickness`, the fill pass at `thickness - 2 * outlineWidth`. The visible wall outline is not drawn at all, it is what is left of the first pass after the second covers its middle.
102
+
103
+ ```tsx
104
+ const { groups, caps } = buildWallPaths(floor);
105
+ const ow = 1 * pxToWorld; // outline width, screen-constant
106
+
107
+ <g fill="none" strokeLinecap="butt" strokeLinejoin="miter" strokeMiterlimit={4}>
108
+ {groups.map((g) => <path key={g.thickness} d={g.d} strokeWidth={g.thickness} stroke="var(--border-color)" />)}
109
+ </g>
110
+ <g fill="none" strokeLinecap="butt" strokeLinejoin="miter" strokeMiterlimit={4}>
111
+ {groups.map((g) => g.thickness - 2 * ow > 0 && (
112
+ <path key={g.thickness} d={g.d} strokeWidth={g.thickness - 2 * ow} stroke="var(--background-accent)" />
113
+ ))}
114
+ </g>
115
+ <g stroke="var(--border-color)" strokeWidth={ow}>
116
+ {caps.map((c, i) => <line key={i} x1={c.a.x} y1={c.a.y} x2={c.b.x} y2={c.b.y} />)}
117
+ </g>
118
+ ```
119
+
120
+ Rules that make this work:
121
+
122
+ - **Every outline path before every fill path.** One `<g>` each, never interleaved pairs.
123
+ - **`caps` must be drawn.** Nothing is ever drawn across a butt end, because both passes stop at the same coordinate, so opening jambs, free wall ends and thickness changes need explicit lines. Each cap says which (`reason: 'jamb' | 'freeEnd' | 'thicknessStep'`).
124
+ - **`outlineWidth` is screen-constant**, so recompute `thickness - 2 * ow` on zoom. The path data itself never changes. Skip the fill pass entirely when it would go non-positive (zoomed far out, walls become solid lines, which is correct at that scale).
125
+
126
+ - **`strokeLinejoin` and `strokeMiterlimit` must be set**, and set the same on both passes. Corners are filled by the join, not by extra geometry.
127
+
128
+ Walls are emitted as chains through corners so `stroke-linejoin: miter` fills them. Where a chain has to stop, at a junction or a thickness change, most corners still need nothing: both walls' bands start at the node, so across a gap of up to 180 degrees they already reach each other. The exception is a gap **wider** than 180 degrees, which happens when every wall at a node points into the same half-plane; there the two faces diverge and leave a wedge bitten out of the node, and a small hinged subpath is emitted to mitre it. At most one gap at a node can be reflex, so there is never more than one. Different thicknesses become separate groups, since one path can carry only one stroke width.
129
+
130
+ `buildWallPaths(floor, { wallIds })` builds the same paths for a subset of walls, taking node degrees from the whole floor so chains break and corners fill exactly where they do in the full drawing. That is what lets a room's boundary be highlighted by painting over the walls it is bounded by, openings and all.
131
+
132
+ `wallCentrelineSpans(floor, wall)` gives the stretches that are actually wall, in millimetres from node `a`, with openings removed and overlaps merged. `openingGeometry(floor, opening)` gives the centre, direction, normal and endpoints for drawing a door leaf, swing arc or window symbol.
133
+
134
+ ## Addressing a plan
135
+
136
+ `getFloor(plan, floorId?)` reads one floor, defaulting to the lowest. `updateFloor(plan, floorId, edit)` applies a floor-level edit and bumps `revision` for you; `setFloor` replaces one outright. `floorBounds(floor)` gives the bounding box, padded by half the thickest wall so the outer face of every wall is inside it, which is what a fit-to-view needs. `renameFloor`, `wallLength`, `floorIndex` and `DEFAULT_WALL_ATTRS` round out the small helpers.
137
+
138
+ ## Floors
139
+
140
+ `FloorPlan.floors` is a stack of storeys, **bottom first**: index 0 is the lowest, and `elevation` rises with the index.
141
+
142
+ ```ts
143
+ import { addFloor, duplicateFloor, removeFloor, moveFloor, setFloorHeight, floorBelow } from '@ahrowe/ui';
144
+
145
+ const { plan: two, floor } = addFloor(plan); // empty storey on top
146
+ const { plan: three } = duplicateFloor(two, floor.id); // copy of it, above it
147
+ const below = floorBelow(three, floor.id); // what an underlay traces
148
+ ```
149
+
150
+ Every one of these re-stacks the elevations behind you, so each storey sits on the one below: insert a floor and the ones above it rise, remove one and they drop, raise a storey's height with `setFloorHeight` and everything over it lifts, and the building's own ground level stays where it was. This is also why a storey's height is not set with a plain `setFloor`, which would leave the floors above buried in it or floating over it — neither of which a 2D plan would show. Without that, a gap or an overlap only turns up in 3D, long after the edit that caused it.
151
+
152
+ `duplicateFloor` regenerates **every** id and rewires every reference — walls to the copy's nodes, openings to its walls, rooms to its wall ids — so the two storeys share nothing and an edit to one cannot reach into the other. A room's `wallIds` in particular must point at the copy's own walls, or the first `derivePlan` would strand every room in it.
153
+
154
+ `setFloorHeight` clamps to `MIN_FLOOR_HEIGHT` (500mm), low enough for a crawl space and high enough to stop a zero-height storey. `removeFloor` refuses to remove the last storey: a plan with nowhere to draw would need a special case in every consumer.
155
+
156
+ ## Saving and loading
157
+
158
+ JSON is the interchange format. `planToJson` writes it; `planFromJson` reads it back, and does so defensively, because a file off a disk is untrusted and whatever comes out is rendered immediately.
159
+
160
+ ```ts
161
+ import { planToJson, planFromJson } from '@ahrowe/ui';
162
+
163
+ const text = planToJson(plan); // indented; pass { pretty: false } for one line
164
+ const { plan: loaded, issues } = planFromJson(text);
165
+ if (!loaded) console.error(issues); // unreadable
166
+ else if (issues.length) console.warn(issues); // loaded, but repaired
167
+ ```
168
+
169
+ `planFromJson` rebuilds the plan field by field rather than casting the parsed object, so no unchecked field reaches a renderer. It repairs wherever the repair is unambiguous — a numeric string becomes a number, an unknown enum value falls back, a wall with missing corners or an opening with no wall is dropped — and returns a `null` plan only when the text is not JSON, is not an object, has no `floors`, or carries a `version` other than `PLAN_FORMAT_VERSION`. A room whose walls have gone is kept rather than dropped: re-adding the wall re-matches it by name.
170
+
171
+ `revision` is bumped on load, since `derivePlan` memoises on it and the repaired plan is not the one the file described.
172
+
173
+ ## Snapping and pointer input
174
+
175
+ ```tsx
176
+ const world = clientToWorld(e.clientX, e.clientY, svg.getBoundingClientRect(), view);
177
+ const snap = resolveSnap(world, { floor, pxToWorld: 1 / view.zoom, origin, options });
178
+ ```
179
+
180
+ `clientToWorld` **only converts** — snapping is `resolveSnap`'s job alone, which is what makes "hold Alt to disable snapping" possible at all.
181
+
182
+ Every tolerance in `SnapOptions` is in **screen pixels**, converted through `pxToWorld` at the point of use. A fixed world-unit tolerance covers less and less of the screen as you zoom in until it stops working.
183
+
184
+ `resolveSnap` runs in two stages, because half of these are constraints rather than points. A hard snap (`Node`, `Intersection`, `Wall`) fixes both degrees of freedom, so the first match wins. A constraint (`Angle`, `Extension`, `Alignment`) removes only one: two of them intersect, one is projected onto and then slid **along** to the nearest grid position. That last detail is what lets angle lock and grid snap coexist — you get exactly 45° at a round length, rather than a point that is neither.
185
+
186
+ Pass `options.enabled: []` to disable snapping entirely.
187
+
188
+ ## Validation
189
+
190
+ ```tsx
191
+ const issues = validatePlan(floor); // O(walls²) — development and tests, not per keystroke
192
+ ```
193
+
194
+ The invariant that matters: **no two walls may cross without a node at the intersection, and no node may sit on a wall's interior without that wall being split there.** Break either and the face walk silently merges two rooms into one figure-eight face with no error anywhere, which is why `UnsplitCrossing` and `UnsplitNodeOnWall` exist as explicit issue kinds. Everything built through `addWall` maintains this automatically.
195
+
196
+ ## Importing older plans
197
+
198
+ ```tsx
199
+ import { planFromLegacySvg } from '@ahrowe/ui';
200
+
201
+ // The old canvas was unitless; pick the factor that makes it millimetres.
202
+ // A 400-unit-wide export meant to be a 12m house wants scale: 30.
203
+ const plan = planFromLegacySvg(svgText, { scale: 30 });
204
+ ```
205
+
206
+ Feeding every legacy polygon edge through `addWall` is what collapses the old duplicated walls: the second room's copy of a shared edge resolves to the same two nodes and is recognised as already walled. Room names carry across, and doors are recovered where the old swing-arc path is recognisable; anything that is not is skipped rather than guessed at.
207
+
208
+ ## Ready for 3D
209
+
210
+ The model carries everything an extrusion needs and nothing it would have to invent: `PlanWall.thickness` / `height` / `baseHeight`, `PlanOpening.sill` / `head`, `PlanFloor.elevation` / `height`, and derived floor polygons. No 3D renderer ships today.
211
+
212
+ These are the fields that do nothing in 2D, and that is expected rather than a gap: a plan is a horizontal cut, so a window 900mm up the wall and a door at floor level are the same hole seen from above. `sill` is the height of an opening's bottom above the floor and `head` the height of its top; the drawer edits `sill` in its selection bar so the value is captured while the plan is being drawn, rather than having to be filled in for every opening afterwards.
213
+
214
+ One thing to know when you build one: `PlanFace.outer` is positively wound under the shoelace formula, which in SVG's y-down space reads as **visually clockwise**. Anything assuming counter-clockwise (slab normals, most triangulators) must flip it, and should run `stripSpurs` first so a free-standing wall's zero-width slit does not produce degenerate triangles.
package/docs/Modal.md CHANGED
@@ -52,7 +52,7 @@ const [isOpen, setIsOpen] = useState(false);
52
52
  | `closeOnEscape` | `boolean` | Close on Escape while this is the topmost modal. Default `true` |
53
53
  | `presentation` | `Presentation` | Present as a bottom sheet, always (`Sheet`) or only on a small touch screen (`Auto`). `Default`, or omitting it, is the centred dialog |
54
54
 
55
- **Bottom sheet:** `presentation={Presentation.Sheet}` pins the dialog to the bottom of the screen instead of centring it — full width up to 560px, rounded on its top corners only, sliding up rather than scaling in, and lifted above an open on-screen keyboard. Nothing else changes: the header stays fixed and the body scrolls, which is what a sheet needs anyway, and the backdrop, focus trap, scroll lock and Escape all behave exactly as they do centred. `Presentation.Auto` applies it only when the pointer is coarse *and* the screen is under 768px wide.
55
+ **Bottom sheet:** `presentation={Presentation.Sheet}` pins the dialog to the bottom of the screen instead of centring it — full width up to 560px, rounded on its top corners only, sliding up rather than scaling in, and lifted above an open on-screen keyboard, shrinking to fit the space that leaves rather than running off the top of the screen. Nothing else changes: the header stays fixed and the body scrolls, which is what a sheet needs anyway, and the backdrop, focus trap, scroll lock and Escape all behave exactly as they do centred. `Presentation.Auto` applies it only when the pointer is coarse *and* the screen is under 768px wide.
56
56
 
57
57
  `Presentation.Default` means "wherever the component normally puts it", which for a Modal is centred rather than anchored to anything — so an app-wide `<ConfigProvider presentation={Presentation.Default}>` switches sheets off everywhere, this included.
58
58
 
@@ -64,7 +64,7 @@ import { Modal, Presentation } from '@ahrowe/ui';
64
64
  </Modal>
65
65
  ```
66
66
 
67
- A sheet-presented Modal takes 90% of the screen height rather than the 70% a menu-sized sheet uses, since it carries forms and detail views. Override either with the `--sheet-max-height` and `--sheet-safe-padding` custom properties, per instance via `style` or theme-wide via `ThemeProvider`.
67
+ A sheet-presented Modal takes 90% of the screen height rather than the 70% a menu-sized sheet uses, since it carries forms and detail views. Override either with the `--sheet-max-height` and `--sheet-safe-padding` custom properties, per instance via `style` or theme-wide via `ThemeProvider`. `--sheet-max-height` is a ceiling, not a height: whatever it's set to, a sheet never grows past the area actually visible, which is measured live and so accounts for an open keyboard that no viewport unit can see.
68
68
 
69
69
  **Global defaults:** adopts `ConfigProvider`, e.g. `defaultProps={{ Modal: { presentation: Presentation.Auto } }}` — which also covers `DatePicker`, `TimeInput` and `KlipyPicker` when they open in a Modal via their own `isModal`. `presentation={Presentation.Auto}` on the provider itself sets every sheet-capable component at once. See [ConfigProvider.md](ConfigProvider.md).
70
70
 
package/docs/Popover.md CHANGED
@@ -61,7 +61,7 @@ import { Popover, PopoverPlacement, PopoverAlign, PopoverTrigger } from '@ahrowe
61
61
 
62
62
  **Slots:** `root` `trigger` `floating` `panel` `arrow` `backdrop` (`backdrop` only exists while presenting as a sheet)
63
63
 
64
- **Bottom sheet:** `presentation={Presentation.Sheet}` drops the anchoring and the arrow, and pins the panel to the bottom of the screen instead — full width up to 560px, behind a backdrop, scrolling internally past 70% of the viewport height, lifted above an open on-screen keyboard. Scrolling on the page behind it is locked while it's open, and tapping the backdrop closes it without pressing whatever sits behind it (`closeOnOutsideClick={false}` leaves the backdrop inert but still blocking). `Presentation.Auto` applies that only when the pointer is coarse *and* the screen is under 768px wide.
64
+ **Bottom sheet:** `presentation={Presentation.Sheet}` drops the anchoring and the arrow, and pins the panel to the bottom of the screen instead — full width up to 560px, behind a backdrop, scrolling internally past 70% of the viewport height, lifted above an open on-screen keyboard, and shrunk to fit the space that leaves rather than running off the top of the screen. Scrolling on the page behind it is locked while it's open, and tapping the backdrop closes it without pressing whatever sits behind it (`closeOnOutsideClick={false}` leaves the backdrop inert but still blocking). `Presentation.Auto` applies that only when the pointer is coarse *and* the screen is under 768px wide.
65
65
 
66
66
  Only a `Click` trigger presents as a sheet. `Hover` and `Focus` open something small next to whatever caused them, which is the opposite of a screen-owning sheet, so they stay anchored regardless of `presentation`.
67
67