This commit is contained in:
Andras Schmelczer 2026-09-11 22:14:24 +01:00
commit 0c0f08b350
45 changed files with 1565 additions and 1579 deletions

View file

@ -11,6 +11,7 @@ import {
uncutSkirting,
} from '../lib/cut-list';
import { boardSearchLabel, findBoardMatches } from '../lib/board-tracking';
import { PERSISTENCE_LIMITS } from '../lib/limits';
import { FlooringPreview, SkirtingPreview } from './CutPreview';
function BoardHeader({
@ -120,7 +121,7 @@ export function CutList({
id="board-search"
type="search"
value={boardSearchLabel(project, flooring, skirting, search)}
maxLength={200}
maxLength={PERSISTENCE_LIMITS.searchLength}
onChange={(event) => setSearch(event.target.value)}
placeholder="F12, A2, or a room name"
autoComplete="off"

View file

@ -2,14 +2,7 @@ import { Fragment } from 'react';
import type { FlooringPiece, SkirtingPiece } from '../lib/types';
import { cutMm, degree } from '../lib/format';
import { skirtingEndLabel } from '../lib/cut-list';
import {
dimensionedOutline,
flooringTemplate,
fullPreviewBox,
skirtingEndBoxes,
skirtingTemplate,
viewBox,
} from '../lib/cut-preview';
import { dimensionedOutline, fullPreviewBox, skirtingEndBoxes, viewBox } from '../lib/cut-preview';
import type { EdgeDimension } from '../lib/cut-preview';
function Dimensions({ dimensions, font }: { dimensions: EdgeDimension[]; font: number }) {
@ -112,7 +105,7 @@ export function FlooringPreview({
piece: FlooringPiece;
compact?: boolean;
}) {
const template = flooringTemplate(piece);
const template = piece.template;
const dimensions = compact
? undefined
: dimensionedOutline(template, piece.shaped ? undefined : [0, 1], {
@ -159,11 +152,10 @@ export function SkirtingPreview({
thickness: number;
compact?: boolean;
}) {
const template = skirtingTemplate(piece, thickness);
const template = piece.template;
const Wrapper = compact ? 'span' : 'div';
const EndWrapper = compact ? 'span' : 'figure';
const Grid = compact ? 'span' : 'div';
if (template.length !== 4) return <span className="helper">Shape template unavailable</span>;
const boxes = skirtingEndBoxes(template, thickness, piece.installedTemplate);
const dimensions = compact
? undefined
@ -186,8 +178,8 @@ export function SkirtingPreview({
front: template[3],
box: boxes.left,
type: piece.leftType,
installedBack: piece.installedTemplate?.[0],
installedFront: piece.installedTemplate?.[3],
installedBack: piece.installedTemplate[0],
installedFront: piece.installedTemplate[3],
},
{
side: 'right',
@ -195,8 +187,8 @@ export function SkirtingPreview({
front: template[2],
box: boxes.right,
type: piece.rightType,
installedBack: piece.installedTemplate?.[1],
installedFront: piece.installedTemplate?.[2],
installedBack: piece.installedTemplate[1],
installedFront: piece.installedTemplate[2],
},
] as const;
return (

View file

@ -1,11 +1,20 @@
import { useId } from 'react';
import type { FlooringPlan, Geometry, Point, Room, SkirtingPlan, WallEdge } from '../lib/types';
import type {
FlooringPlan,
Geometry,
PlanMaterial,
Point,
Room,
SkirtingPlan,
WallEdge,
} from '../lib/types';
import { area, cutMm, mm } from '../lib/format';
import { signedArea } from '../lib/geometry';
import { isSkirtingCut } from '../lib/cut-list';
import { skirtingPolygon } from '../lib/skirting-polygon';
import { dimensionLabels, wallDimensionBox } from '../lib/dimension-labels';
import { floorPieceLabels } from '../lib/piece-labels';
import { PRECISION } from '../lib/limits';
import type { DimensionLabel, LabelBox } from '../lib/dimension-labels';
const points = (path: Point[]) => path.map((p) => `${p.x},${p.y}`).join(' ');
@ -61,7 +70,10 @@ function Wall({ edge, span, label }: { edge: WallEdge; span: number; label: Dime
</text>
</g>
{edge.doors
.filter((d) => d.offset >= 0 && d.width > 0 && d.offset + d.width <= edge.length + 0.001)
.filter(
(d) =>
d.offset >= 0 && d.width > 0 && d.offset + d.width <= edge.length + PRECISION.clipping,
)
.map((door) => {
const a = {
x: edge.start.x + (dx / length) * door.offset,
@ -116,6 +128,7 @@ interface Props {
completedBoards?: string[];
highlightedPieces?: string[];
searchActive?: boolean;
material?: PlanMaterial;
}
export function FloorPlan({
@ -129,6 +142,7 @@ export function FloorPlan({
completedBoards = [],
highlightedPieces = [],
searchActive = false,
material,
}: Props) {
const hatch = useId().replaceAll(':', '');
const allPoints = geometry.edges.flatMap((edge) => [edge.start, edge.end]);
@ -139,8 +153,11 @@ export function FloorPlan({
const span = Math.max(maxX - minX, maxY - minY, 1000),
font = span * 0.024;
const orientation = Math.sign(signedArea(geometry.vertices)) || 1;
const floorPieces = flooring.pieces.filter((p) => p.roomId === room.id);
const skirtPieces = skirting.pieces.filter((p) => p.roomId === room.id);
// Printing includes both materials; the interactive drawing shows the chosen view.
const floorPieces =
material === 'skirting' ? [] : flooring.pieces.filter((p) => p.roomId === room.id);
const skirtPieces =
material === 'flooring' ? [] : skirting.pieces.filter((p) => p.roomId === room.id);
const skirtLabels = new Map<string, Point & { width: number; text: string }>();
const reserved: LabelBox[] = [];
for (const piece of skirtPieces) {
@ -197,20 +214,25 @@ export function FloorPlan({
className="drawing-card"
aria-label="Floor plan preview"
data-search-active={searchActive}
data-view={material ?? 'both'}
>
<div className="drawing-header">
<h2>
{room.name} <small>{area(geometry.area)} m²</small>
</h2>
<div className="drawing-legend">
<span>
<i className="floor-key" />
Flooring cuts
</span>
<span>
<i className="skirt-key" />
Skirting cuts
</span>
{material !== 'skirting' && (
<span>
<i className="floor-key" />
Flooring cuts
</span>
)}
{material !== 'flooring' && (
<span>
<i className="skirt-key" />
Skirting cuts
</span>
)}
<span>
<i className="uncut-key" />
Uncut
@ -331,8 +353,7 @@ export function FloorPlan({
const done = completedBoards.includes(`skirting:${piece.stockId}`);
const highlighted = highlightedPieces.includes(piece.id);
// Each template uses its true wall-facing and room-facing edges, including mitres.
const thickness = piece.template ? Math.max(...piece.template.map((p) => p.y)) : 0;
const polygon = skirtingPolygon(piece, thickness, orientation);
const polygon = skirtingPolygon(piece, orientation);
return (
<g
key={piece.id}

View file

@ -24,12 +24,7 @@ function PlannerLink({ compact = false }: { compact?: boolean }) {
function PlanIllustration() {
return (
<figure className="landing-plan">
<figcaption className="landing-plan-heading">
<span>
<span className="landing-status-dot" /> A little planning goes a long way.
</span>
<span className="landing-plan-index">FIG. 01</span>
</figcaption>
<figcaption className="landing-plan-heading">Example floor layout</figcaption>
<div className="landing-plan-paper">
<div className="landing-plan-room">
<span>Living room</span>
@ -55,7 +50,7 @@ function PlanIllustration() {
<path d="M80 48V23M460 48V23M80 32H460M75 37L85 27M455 37L465 27" />
<path d="M62 57H37M62 343H37M46 57V343M41 62L51 52M41 348L51 338" />
</g>
<g fill="#55685d" fontSize="12" fontFamily="monospace" textAnchor="middle">
<g fill="#55685d" textAnchor="middle">
<rect x="224" y="21" width="92" height="22" fill="#f6f7f2" />
<text x="270" y="36">
4,800 mm
@ -104,7 +99,7 @@ function PlanIllustration() {
<rect x="91" y="112" width="72" height="22" fill="#c66935" stroke="#a74617" />
<rect x="403" y="178" width="46" height="22" fill="#c66935" stroke="#a74617" />
</g>
<g fontSize="11" fontFamily="monospace" fill="#fff" textAnchor="middle">
<g fill="#fff" textAnchor="middle">
<text x="127" y="127">
F1
</text>
@ -113,11 +108,11 @@ function PlanIllustration() {
</text>
</g>
<rect x="226" y="177" width="114" height="48" rx="2" fill="#fffdf7" stroke="#b49a72" />
<text x="283" y="198" fontSize="13" fontWeight="600" fill="#17263f" textAnchor="middle">
<text x="283" y="198" fill="#17263f" textAnchor="middle">
Living room
</text>
<text x="283" y="214" fontSize="10" letterSpacing="1" fill="#58655e" textAnchor="middle">
EXAMPLE LAYOUT
<text x="283" y="214" fill="#58655e" textAnchor="middle">
Example layout
</text>
<path d="M79 257V317" stroke="#f6f7f2" strokeWidth="15" />
<path
@ -127,8 +122,8 @@ function PlanIllustration() {
strokeWidth="1.2"
/>
<path d="M471 321H496M490 315L496 321L490 327" fill="none" stroke="#748279" />
<text x="470" y="307" fontSize="10" fontFamily="monospace" fill="#55685d">
LAY
<text x="470" y="307" fill="#55685d">
Lay
</text>
</svg>
</div>
@ -179,11 +174,11 @@ function TrimIllustration({ scotia }: { scotia: boolean }) {
strokeWidth="1.5"
/>
<path d="M142 143L173 113H225" fill="none" stroke="#a4471c" />
<text x="233" y="117" fill="#a4471c" fontSize="13" fontWeight="600">
<text x="233" y="117" fill="#a4471c">
Scotia bead
</text>
<path d="M102 85H225" stroke="#7f8e84" />
<text x="233" y="89" fill="#526156" fontSize="12">
<text x="233" y="89" fill="#526156">
Existing skirting
</text>
</>
@ -196,11 +191,11 @@ function TrimIllustration({ scotia }: { scotia: boolean }) {
strokeWidth="1.5"
/>
<path d="M113 85H225" stroke="#a4471c" />
<text x="233" y="89" fill="#a4471c" fontSize="13" fontWeight="600">
<text x="233" y="89" fill="#a4471c">
New skirting
</text>
<path d="M108 154L175 127H225" fill="none" stroke="#7f8e84" />
<text x="233" y="131" fill="#526156" fontSize="12">
<text x="233" y="131" fill="#526156">
Floor tucks under
</text>
</>
@ -211,14 +206,14 @@ function TrimIllustration({ scotia }: { scotia: boolean }) {
stroke="#526156"
strokeDasharray="3 3"
/>
<text x="197" y="210" fill="#526156" fontSize="11">
<text x="197" y="210" fill="#526156">
Expansion gap
</text>
<text x="48" y="15" fill="#526156" fontSize="10" letterSpacing="1">
WALL
<text x="48" y="15" fill="#526156">
Wall
</text>
<text x="278" y="151" fill="#526156" fontSize="10" letterSpacing="1">
FLOORING
<text x="278" y="151" fill="#526156">
Flooring
</text>
</svg>
);
@ -267,38 +262,24 @@ export function LandingPage() {
<a className="landing-skip" href="#main">
Skip to content
</a>
<header className="landing-header landing-container">
<a
className="landing-brand"
href={window.location.pathname}
aria-label="Floor planner home"
>
<span className="square-mark" aria-hidden="true" />
<span>
Floor planner<span className="landing-brand-dot">.</span>
</span>
</a>
<nav className="landing-nav" aria-label="Main navigation">
<a href="#why">Why plan?</a>
<a href="#guide">DIY guide</a>
<a href="#finishing">The finishing touches</a>
</nav>
<PlannerLink compact />
<header className="landing-header">
<div className="landing-header-content landing-container">
<a
className="landing-brand"
href={window.location.pathname}
aria-label="Floor planner home"
>
<span className="square-mark" aria-hidden="true" />
<span>Floor planner</span>
</a>
<PlannerLink compact />
</div>
</header>
<main id="main">
<section className="landing-hero landing-container" aria-labelledby="landing-title">
<div className="landing-hero-copy">
<p className="landing-eyebrow">
<span /> MEASURE. PLAN. LAY.
</p>
<h1 id="landing-title">
A great floor
<br />
starts with
<br />
<em>a little planning.</em>
</h1>
<h1 id="landing-title">A great floor starts with a little planning.</h1>
<p className="landing-intro">
From room measurements to the last cut. Plan your floorboards and skirting, make the
most of your materials, and start your DIY project with confidence.
@ -309,10 +290,6 @@ export function LandingPage() {
First time fitting a floor? <ArrowDown size={16} aria-hidden="true" />
</a>
</div>
<p className="landing-reassurance">
<Check size={14} aria-hidden="true" /> Free to use <span>·</span> No sign-up{' '}
<span>·</span> Right in your browser
</p>
</div>
<PlanIllustration />
</section>
@ -322,16 +299,12 @@ export function LandingPage() {
id="why"
aria-labelledby="benefits-title"
>
<div className="landing-section-kicker">
<span className="landing-eyebrow">LESS GUESSWORK. MORE GETTING IT DONE.</span>
<h2 id="benefits-title">Your tape measure’s new best friend.</h2>
</div>
<h2 id="benefits-title">Why plan your floor?</h2>
<div className="landing-benefit-grid">
{benefits.map(({ icon: Icon, title, text }, index) => (
{benefits.map(({ icon: Icon, title, text }) => (
<article className="landing-benefit" key={title}>
<div className="landing-benefit-top">
<Icon size={24} strokeWidth={1.5} aria-hidden="true" />
<span>0{index + 1}</span>
</div>
<h3>{title}</h3>
<p>{text}</p>
@ -343,12 +316,7 @@ export function LandingPage() {
<section className="landing-guide" id="guide" aria-labelledby="guide-title">
<div className="landing-container landing-guide-layout">
<div className="landing-guide-intro">
<p className="landing-eyebrow">THE DIY STARTING POINT</p>
<h2 id="guide-title">
Your first floor?
<br />
You’ve got this.
</h2>
<h2 id="guide-title">Your first floor? You’ve got this.</h2>
<p>
A little patience, a good plan, and one row at a time. Here’s the journey for a
floating click-fit laminate floor.
@ -374,7 +342,7 @@ export function LandingPage() {
<ol className="landing-steps">
{steps.map(({ title, text }, index) => (
<li key={title}>
<span className="landing-step-number">0{index + 1}</span>
<span className="landing-step-number">{index + 1}.</span>
<div>
<h3>{title}</h3>
<p>{text}</p>
@ -397,10 +365,7 @@ export function LandingPage() {
aria-labelledby="finishing-title"
>
<div className="landing-finishing-heading">
<div>
<p className="landing-eyebrow">SMALL DETAILS. A PROPER FINISH.</p>
<h2 id="finishing-title">Scotia or skirting board?</h2>
</div>
<h2 id="finishing-title">Scotia or skirting board?</h2>
<p>
Both can hide the expansion gap around a floating floor. The difference is what you’re
keeping, and the finish you want.
@ -408,15 +373,10 @@ export function LandingPage() {
</div>
<div className="landing-trim-grid">
<article className="landing-trim-card">
<div className="landing-trim-label">
<span>KEEP YOUR EXISTING SKIRTING</span>
<span>01</span>
</div>
<p className="landing-trim-label">Keep your existing skirting</p>
<TrimIllustration scotia />
<div className="landing-trim-copy">
<h3>
Scotia <span>the small finishing bead</span>
</h3>
<h3>Scotia</h3>
<p>
A narrow, curved moulding fitted at the foot of your existing skirting. It covers
the flooring’s expansion gap without removing the boards already on your wall.
@ -428,18 +388,14 @@ export function LandingPage() {
</div>
</article>
<article className="landing-trim-card">
<div className="landing-trim-label">
<span>REPLACE OR REFIT YOUR SKIRTING</span>
<span>02</span>
</div>
<p className="landing-trim-label">Replace or refit your skirting</p>
<TrimIllustration scotia={false} />
<div className="landing-trim-copy">
<h3>
Skirting board <span>also called a baseboard</span>
</h3>
<h3>Skirting board</h3>
<p>
The taller board along the base of the wall. When fitted after the flooring, it
can cover the expansion gap itself, giving you a clean edge without an extra bead.
Also called a baseboard, this is the taller board along the base of the wall. When
fitted after the flooring, it can cover the expansion gap itself, giving you a
clean edge without an extra bead.
</p>
<p className="landing-trim-choice">
<Check size={16} aria-hidden="true" /> A neat finish when you’re renovating the
@ -460,30 +416,10 @@ export function LandingPage() {
</a>
</div>
</section>
<section className="landing-final-cta landing-container" aria-labelledby="ready-title">
<div>
<p className="landing-eyebrow">YOUR NEXT PROJECT STARTS HERE</p>
<h2 id="ready-title">
Measure twice.
<br />
<em>Start with a plan.</em>
</h2>
<p>Bring your room measurements. We’ll help with the layout.</p>
</div>
<div className="landing-final-actions">
<PlannerLink />
<span>Open the planner. Make it your room.</span>
</div>
</section>
</main>
<footer className="landing-footer landing-container">
<a className="landing-brand" href={window.location.pathname}>
<span className="square-mark" aria-hidden="true" />
<span>Floor planner.</span>
</a>
<p>A little preparation for a floor you’ll be proud of.</p>
<span>Floor planner</span>
<a href="#main">Back to top ↑</a>
</footer>
</div>

View file

@ -39,6 +39,9 @@ export function MaterialsEditor({
<h3 id="flooring-materials" className="material-heading">
Floorboards
</h3>
<p className="helper material-help">
Use the length and width printed on your flooring pack.
</p>
<div className="field-grid">
<NumberField
label="Board length"
@ -56,13 +59,6 @@ export function MaterialsEditor({
<div className="field">
<span>Laying pattern</span>
<div className="segmented" role="group" aria-label="Laying pattern">
<button
className={f.pattern === 'brick' ? 'active' : ''}
aria-pressed={f.pattern === 'brick'}
onClick={() => floor('pattern', 'brick')}
>
Brick
</button>
<button
className={f.pattern === 'random' ? 'active' : ''}
aria-pressed={f.pattern === 'random'}
@ -70,16 +66,28 @@ export function MaterialsEditor({
>
Random
</button>
<button
className={f.pattern === 'brick' ? 'active' : ''}
aria-pressed={f.pattern === 'brick'}
onClick={() => floor('pattern', 'brick')}
>
Brick
</button>
</div>
<p className="helper">
{f.pattern === 'random'
? 'Varied joints for a natural look. Choose fewer cuts or a more varied arrangement below.'
: 'A regular pattern with each row staggered by half a board.'}
</p>
</div>
{f.pattern === 'random' && (
<div className="field">
<span>Optimise random flooring · all rooms</span>
<span>What matters most? · all rooms</span>
<div className="optimisation-options" role="group" aria-label="Flooring optimisation">
{(['cuts', 'random'] as const).map((priority) => {
const plan = flooringOptions[priority];
const valid = !plan.issues.some((issue) => issue.severity === 'error');
const selected = (f.optimisation ?? 'cuts') === priority;
const selected = f.optimisation === priority;
return (
<button
key={priority}
@ -119,42 +127,56 @@ export function MaterialsEditor({
</button>
</div>
</div>
<div className="field-grid">
<NumberField label="Wall spacer" value={f.gap} onChange={(n) => floor('gap', n)} />
<NumberField
label="Saw kerf"
value={f.kerf}
onChange={(n) => floor('kerf', n)}
tooltip={kerfHelp}
/>
<NumberField
label="Minimum joint offset"
value={f.minOffset}
onChange={(n) => floor('minOffset', n)}
/>
<NumberField
label="Maximum joint offset"
value={f.maxOffset}
onChange={(n) => floor('maxOffset', n)}
/>
<NumberField
label="Shortest end piece"
value={f.minLength}
min={1}
onChange={(n) => floor('minLength', n)}
/>
<NumberField
label="Narrowest edge row"
value={f.minWidth}
min={1}
onChange={(n) => floor('minWidth', n)}
/>
</div>
<NumberField
label="Wall spacer"
value={f.gap}
onChange={(n) => floor('gap', n)}
hint="Expansion gap between the floorboards and walls. Check your flooring instructions."
/>
<details className="advanced-settings">
<summary>Advanced flooring settings</summary>
<p className="helper material-help">
Adjust the saw blade width, joint spacing and smallest pieces to suit your flooring.
</p>
<div className="field-grid">
<NumberField
label="Saw kerf"
value={f.kerf}
onChange={(n) => floor('kerf', n)}
tooltip={kerfHelp}
/>
<NumberField
label="Minimum joint offset"
value={f.minOffset}
onChange={(n) => floor('minOffset', n)}
/>
<NumberField
label="Maximum joint offset"
value={f.maxOffset}
onChange={(n) => floor('maxOffset', n)}
/>
<NumberField
label="Shortest end piece"
value={f.minLength}
min={1}
onChange={(n) => floor('minLength', n)}
/>
<NumberField
label="Narrowest edge row"
value={f.minWidth}
min={1}
onChange={(n) => floor('minWidth', n)}
/>
</div>
</details>
</section>
<section className="material-section skirting" aria-labelledby="skirting-materials">
<h3 id="skirting-materials" className="material-heading">
Skirting boards
</h3>
<p className="helper material-help">
Enter one purchased board length. Door openings are left clear automatically.
</p>
<div className="field-grid">
<NumberField
label="Skirting board length"
@ -167,19 +189,24 @@ export function MaterialsEditor({
value={s.thickness}
onChange={(n) => skirt('thickness', n)}
/>
<NumberField
label="Minimum piece"
value={s.minLength}
min={1}
onChange={(n) => skirt('minLength', n)}
/>
<NumberField
label="Skirting saw kerf"
value={s.kerf}
onChange={(n) => skirt('kerf', n)}
tooltip={kerfHelp}
/>
</div>
<details className="advanced-settings">
<summary>Advanced skirting settings</summary>
<div className="field-grid">
<NumberField
label="Minimum piece"
value={s.minLength}
min={1}
onChange={(n) => skirt('minLength', n)}
/>
<NumberField
label="Skirting saw kerf"
value={s.kerf}
onChange={(n) => skirt('kerf', n)}
tooltip={kerfHelp}
/>
</div>
</details>
</section>
</>
);

View file

@ -16,6 +16,7 @@ import { mm } from '../lib/format';
import { NumberField, NumericInput, Issues } from './Fields';
import { DoorPosition } from './DoorPosition';
import { insertWallAfter, moveWall } from '../lib/wall-editing';
import { PRECISION, PROJECT_LIMITS } from '../lib/limits';
interface Props {
room: Room;
geometry: Geometry;
@ -36,6 +37,7 @@ export function OutlineEditor({
}: Props) {
const draggedWall = useRef<string | null>(null);
const [dropTarget, setDropTarget] = useState<string | null>(null);
const [customSelected, setCustomSelected] = useState(false);
const changeWall = (id: string, fn: (wall: Wall) => Wall) =>
changeRoom((r) => ({ ...r, walls: r.walls.map((w) => (w.id === id ? fn(w) : w)) }));
const isRectangle =
@ -43,6 +45,10 @@ export function OutlineEditor({
room.walls.every((w) => w.turn === 90) &&
room.walls[0].length === room.walls[2].length &&
room.walls[1].length === room.walls[3].length;
const isFireplace =
room.walls.length === 8 &&
room.walls.every((wall, index) => wall.turn === [90, 90, 90, -90, -90, 90, 90, 90][index]);
const isCustom = customSelected || (!isRectangle && !isFireplace);
const lastEdge = geometry.edges.at(-1);
const finalExpectedLength = lastEdge ? Math.hypot(lastEdge.start.x, lastEdge.start.y) : 0;
const addWall = () =>
@ -106,41 +112,72 @@ export function OutlineEditor({
<span>Room name</span>
<input
value={room.name}
maxLength={80}
maxLength={PROJECT_LIMITS.nameLength}
onChange={(e) => changeRoom((r) => ({ ...r, name: e.target.value }))}
/>
</label>
<div className="template-options">
<div className="subheading shape-heading">
<h3>Room shape</h3>
</div>
<div
className="template-options"
role="group"
aria-label="Room shape"
aria-describedby="custom-shape-help"
>
<button
className={isRectangle ? 'template active' : 'template'}
aria-pressed={isRectangle}
onClick={() => changeRoom((r) => rectangle(r.id, r.name))}
className={isRectangle && !isCustom ? 'template active' : 'template'}
aria-pressed={isRectangle && !isCustom}
onClick={() => {
setCustomSelected(false);
changeRoom((r) => rectangle(r.id, r.name));
}}
>
<span className="rectangle-icon" />
Rectangle
</button>
<button
className={room.walls.length === 8 ? 'template active' : 'template'}
aria-pressed={room.walls.length === 8}
onClick={() => changeRoom((r) => fireplace(r.id, r.name))}
className={isFireplace && !isCustom ? 'template active' : 'template'}
aria-pressed={isFireplace && !isCustom}
onClick={() => {
setCustomSelected(false);
changeRoom((r) => fireplace(r.id, r.name));
}}
>
<span className="fireplace-icon" />
With fireplace
</button>
<button
className="template"
onClick={() =>
className={isCustom ? 'template active' : 'template'}
aria-pressed={isCustom}
onClick={() => {
setCustomSelected(true);
changeRoom((r) => ({
...r,
walls: [{ id: `${r.id}-w1`, length: 4000, turn: 90, doors: [] }],
}))
}
}));
}}
>
<MoveUpRight size={19} />
Custom
</button>
</div>
{isRectangle && (
<p className="shape-help" id="custom-shape-help">
{isCustom && geometry.closed ? (
<>Your outline is complete. Adjust individual walls, corners or doorways below.</>
) : isCustom ? (
<>
Add your walls in order below, then use <strong>Add closing wall</strong> to complete
the outline.
</>
) : (
<>
Have an irregular room? Click <strong>Custom</strong> to start a new outline and enter
each wall and corner. Choosing a shape replaces the current outline and doorways.
</>
)}
</p>
{isRectangle && !isCustom && (
<div className="field-grid quick-dimensions">
<NumberField
label="Room width"
@ -166,7 +203,7 @@ export function OutlineEditor({
/>
</div>
)}
<details className="wall-details" open={!isRectangle}>
<details className="wall-details" open={isCustom || !isRectangle}>
<summary>
Walls & corners <span>{room.walls.length} walls</span>
</summary>
@ -270,7 +307,7 @@ export function OutlineEditor({
className="icon-button"
aria-label={`Insert wall after W${i + 1}`}
title={`Insert wall after W${i + 1}`}
disabled={room.walls.length >= 100}
disabled={room.walls.length >= PROJECT_LIMITS.wallsPerRoom}
onClick={() => changeRoom((r) => insertWallAfter(r, wall.id))}
>
<Plus size={16} />
@ -289,7 +326,11 @@ export function OutlineEditor({
</div>
))}
</div>
<button className="text-button" onClick={addWall} disabled={room.walls.length >= 100}>
<button
className="text-button"
onClick={addWall}
disabled={room.walls.length >= PROJECT_LIMITS.wallsPerRoom}
>
<Plus size={15} /> Add a wall
</button>
</details>
@ -317,7 +358,10 @@ export function OutlineEditor({
<button
className="text-button"
onClick={closeOutline}
disabled={room.walls.length >= 100 || Math.abs(geometry.closingTurn) >= 179.99}
disabled={
room.walls.length >= PROJECT_LIMITS.wallsPerRoom ||
Math.abs(geometry.closingTurn) >= PROJECT_LIMITS.maxTurn - PRECISION.millimetres
}
>
Add closing wall <Plus size={14} />
</button>
@ -419,7 +463,9 @@ export function OutlineEditor({
<button
className="text-button"
onClick={addDoor}
disabled={!room.walls.length || room.walls.at(-1)!.doors.length >= 30}
disabled={
!room.walls.length || room.walls.at(-1)!.doors.length >= PROJECT_LIMITS.doorsPerWall
}
>
<Plus size={15} /> Add a doorway
</button>
@ -427,7 +473,7 @@ export function OutlineEditor({
<NumberField
label="Measurement tolerance"
value={tolerance}
max={50}
max={PROJECT_LIMITS.maxTolerance}
onChange={(n) => changeProject((p) => ({ ...p, tolerance: n }))}
hint="Largest closing gap accepted for the cut plan."
/>

View file

@ -0,0 +1,66 @@
.planner-loading {
display: grid;
place-items: center;
min-height: 100vh;
min-height: 100dvh;
padding: 32px;
background: #fdfcf7;
}
.planner-loading-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
color: var(--ink);
font-size: 16px;
font-weight: 600;
text-align: center;
}
.planner-loading-floor {
display: block;
width: 112px;
height: 112px;
}
.planner-loading-outline {
fill: none;
stroke: #d9ddd3;
stroke-width: 2px;
}
.planner-loading-row {
fill: #d7b68a;
opacity: 0.25;
animation: planner-lay-boards 2.4s ease-in-out infinite;
}
.planner-loading-row rect:last-child {
fill: #ead9bc;
}
.planner-loading-row:nth-of-type(2) {
animation-delay: 180ms;
}
.planner-loading-row:nth-of-type(3) {
animation-delay: 360ms;
}
.planner-loading-row:nth-of-type(4) {
animation-delay: 540ms;
}
.planner-loading-row:first-of-type rect:first-child {
fill: #ac451d;
}
@keyframes planner-lay-boards {
0%,
100% {
opacity: 0.25;
transform: translateX(-4px);
}
25%,
65% {
opacity: 1;
transform: translateX(0);
}
}
@media (prefers-reduced-motion: reduce) {
.planner-loading-row {
animation: none;
opacity: 1;
}
}

View file

@ -0,0 +1,35 @@
import './PlannerLoading.css';
export function PlannerLoading() {
return (
<main className="planner-loading">
<div className="planner-loading-content" role="status" aria-live="polite" aria-atomic="true">
<svg
className="planner-loading-floor"
viewBox="0 0 112 112"
aria-hidden="true"
focusable="false"
>
<rect className="planner-loading-outline" x="4" y="4" width="104" height="104" rx="4" />
<g className="planner-loading-row">
<rect x="14" y="14" width="30" height="18" rx="1" />
<rect x="48" y="14" width="50" height="18" rx="1" />
</g>
<g className="planner-loading-row">
<rect x="14" y="36" width="50" height="18" rx="1" />
<rect x="68" y="36" width="30" height="18" rx="1" />
</g>
<g className="planner-loading-row">
<rect x="14" y="58" width="30" height="18" rx="1" />
<rect x="48" y="58" width="50" height="18" rx="1" />
</g>
<g className="planner-loading-row">
<rect x="14" y="80" width="50" height="18" rx="1" />
<rect x="68" y="80" width="30" height="18" rx="1" />
</g>
</svg>
<p>Opening your planner…</p>
</div>
</main>
);
}