# Room Art (Moonarc)

A room drawn flat, for a stay that has no photograph of it yet: a single, a double, a twin, a suite, a family room, a bunk room or a loft, in the colours of the block or tone it sits on, with a shade, a light and a pale tone mixed from them. A seed varies the drawing (which side the window is, the rug, the plant, the picture on the wall) and draws the same room on every build; time sets the sky in the window, day, dusk or night, and lights the lamp after dark. As a tile it fills its box with the block's ground as the wall. Hovering or focusing a link around it switches the lamp on. Nothing moves on its own. Zero JavaScript.

- Import: `import RoomArt from '@moonarc/core/RoomArt'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/room-art.json`
- Tier A · category background · trigger pointer
- Readout: `<RoomArt kind="double" seed="Room 4" time="dusk" tile block={2} />`
- Browser support: widely (every browser); elsewhere: nothing to fall back from
- Measured cost: 0 B JS (CSS 6.5 kB raw)
- Page: https://moonarc.dev/components/room-art/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `kind` | `'single' | 'double' | 'twin' | 'suite' | 'family' | 'bunk' | 'loft'` | none | The room. Unset, the seed picks one. |
| `seed` | `string` | `''` | A word the drawing is varied by: the same word, the same room. |
| `time` | `'day' | 'dusk' | 'night'` | `'day'` | The sky in the window; the lamp is on at dusk and at night. |
| `tile` | `boolean` | `false` | Fill the box: the block's ground as the wall, the floor along the bottom. |
| `block` | `1 | 2 | 3 | 4 | 5` | none | The block the tile paints; unset, the ground it sits on. |
| `colour` | `string` | none | The furniture's colour. Unset, the block's first motif colour. |
| `accent` | `string` | none | The bedding and the trim. Unset, the block's second motif colour. |
| `label` | `string` | none | What the picture shows, for a screen reader. Unset, it is decoration and hidden. |

## Usage

```astro
<a href="/rooms/garden-double/" class="room-card">
  <RoomArt kind="double" seed="Garden double" time="dusk" block={2} tile />
  <span>Garden double, from €140 a night</span>
</a>
```

## Reduced motion

The lamp switches on at once.

## With ClientRouter

No script: it works on any page however it was reached.

## Craft

- One SVG of rectangles and paths written at build from the seed, a few hundred bytes of markup a room.
- Every colour is a mix of two custom properties, so a palette or a tone repaints the furniture, the bedding, the wall and the floor together.
- The window goes on the side the bed is not, and the lamp beside the bed, so a seed never draws a bed under the window's sill.

## Replaces

- Stock room photographs as placeholders

## Source

```astro
---
/**
 * RoomArt — a room drawn flat, for a stay that has no photograph of it yet: a single, a double, a twin, a suite, a family room, a
 * bunk room or a loft, in the colours of the block or tone it sits on, with the window's sky by the time of day. `seed`
 * varies the drawing (where the window is, the rug, the plant, the picture on the wall) and draws the same room on
 * every build; `time` sets the sky and lights the lamp at dusk and at night. With `tile` it fills its box, the block's
 * ground as the wall.
 *
 * Hovering or focusing a link around it switches the lamp on. Nothing moves on its own. Zero JavaScript.
 */
import type { HTMLAttributes } from 'astro/types';

const kinds = ['single', 'double', 'twin', 'suite', 'family', 'bunk', 'loft'] as const;
type Kind = (typeof kinds)[number];

interface Props extends HTMLAttributes<'div'> {
  /** The room. Unset, `seed` picks one. */
  kind?: Kind;
  /** A word the drawing is varied by: the same word, the same room. */
  seed?: string;
  /** The sky in the window: daylight, dusk (the lamp on) or night (the lamp on, the moon out). */
  time?: 'day' | 'dusk' | 'night';
  /** Fill the box: the block's ground as the wall, the floor along the bottom. */
  tile?: boolean;
  /** The block the tile paints (1–5, `blocks.css`); unset, the tile takes the ground it sits on. */
  block?: 1 | 2 | 3 | 4 | 5;
  /** The furniture's colour, any CSS colour. Unset, the block's first motif colour. */
  colour?: string;
  /** The bedding and the trim. Unset, the block's second motif colour. */
  accent?: string;
  /** What the picture shows, for a screen reader. Unset, the drawing is decoration and hidden from it. */
  label?: string;
}

const { kind, seed = '', time = 'day', tile = false, block, colour, accent, label, class: className, style, ...rest } = Astro.props;
let h = 2166136261;
for (const c of `${kind ?? ''}:${seed}`) h = Math.imul(h ^ c.charCodeAt(0), 16777619);
/** A seeded number in [0, 1): the same seed, the same room. */
const r = () => ((h = Math.imul(h ^ (h >>> 15), 2246822507) + 0x6d2b79f5) >>> 0) / 4294967296;
const pick = <T,>(a: readonly T[]): T => a[Math.floor(r() * a.length)]!;
const art: Kind = kind ?? pick(kinds);

const F = 86; // the floor line, in a 160 by 120 box
const out: string[] = [];
const add = (s: string) => out.push(s);
const n = (v: number) => +v.toFixed(1);
const rect = (cls: string, x: number, y: number, w: number, hh: number, rx = 0) => add(`<rect class="${cls}" x="${n(x)}" y="${n(y)}" width="${n(w)}" height="${n(hh)}"${rx ? ` rx="${rx}"` : ''}/>`);
const path = (cls: string, d: string) => add(`<path class="${cls}" d="${d}"/>`);

// the window, on the side the bed is not: its sky by the time of day
const right = r() < 0.5;
const wx = right ? 104 : 18;
const wide = art === 'suite' ? 44 : 36;
const sky = time === 'night' ? 'ma-room__night' : time === 'dusk' ? 'ma-room__dusk' : 'ma-room__day';
const windowAt = (x: number, w: number) => {
  rect('ma-room__frame', x - 2, 14, w + 4, 42, 1.5);
  rect(sky, x, 16, w, 38);
  if (time === 'night') add(`<circle class="ma-room__moon" cx="${n(x + w * 0.7)}" cy="26" r="4"/>`);
  else if (time === 'day') add(`<circle class="ma-room__sun" cx="${n(x + w * 0.3)}" cy="27" r="5"/>`);
  else add(`<rect class="ma-room__glow" x="${n(x)}" y="40" width="${n(w)}" height="14"/>`);
  path('ma-room__bars', `M${n(x + w / 2)} 16V54M${n(x)} 35H${n(x + w)}`);
  rect('ma-room__sill', x - 4, 56, w + 8, 3, 1);
};
// the room's furniture: a bed of its width at x, its pillows and its cover
const bed = (x: number, w: number, top = F - 26) => {
  rect('ma-room__b', x, top - 14, 4, F - top + 14);
  rect('ma-room__b', x, top - 14, w * 0.18, 16, 2);
  rect('ma-room__l', x + 3, top, w - 3, 10, 2);
  for (let p = 0; p < (w > 40 ? 2 : 1); p++) rect('ma-room__p', x + 6 + p * (w / 2 - 4), top - 4, w / 2 - 8 > 14 ? 14 : w / 2 - 8, 6, 3);
  rect('ma-room__a', x + w * 0.38, top + 2, w * 0.62, 12, 2);
  rect('ma-room__s', x + 4, F - 6, 3, 6);
  rect('ma-room__s', x + w - 4, F - 6, 3, 6);
};
const lampAt = (x: number) => {
  rect('ma-room__s', x - 7, F - 20, 14, 20, 1.5);
  rect('ma-room__d', x - 0.6, F - 34, 1.2, 14);
  path('ma-room__shade', `M${n(x - 6)} ${F - 34}L${n(x - 3.5)} ${F - 42}H${n(x + 3.5)}L${n(x + 6)} ${F - 34}Z`);
  add(`<circle class="ma-room__lamp" data-on="${time === 'day' ? 'off' : ''}" cx="${n(x)}" cy="${F - 36}" r="13"/>`);
};
const plant = (x: number) => {
  path('ma-room__d', `M${n(x - 5)} ${F}L${n(x - 6)} ${F - 9}H${n(x + 6)}L${n(x + 5)} ${F}Z`);
  add(`<ellipse class="ma-room__g" cx="${n(x - 4)}" cy="${F - 16}" rx="4" ry="8" transform="rotate(-20 ${n(x - 4)} ${F - 16})"/><ellipse class="ma-room__gl" cx="${n(x + 4)}" cy="${F - 17}" rx="4" ry="8" transform="rotate(22 ${n(x + 4)} ${F - 17})"/>`);
};

if (art === 'loft') path('ma-room__roof', `M-10 -10H170V2L${right ? 40 : 120} 30L${right ? -10 : 170} 2Z`);
windowAt(wx, wide);
if (r() < 0.7) {
  const px = right ? 30 : 118;
  rect('ma-room__frame', px, 20, 18, 14, 1);
  rect(r() < 0.5 ? 'ma-room__a' : 'ma-room__l', px + 2, 22, 14, 10);
}
// the rug, under everything on the floor
add(`<ellipse class="ma-room__rug" cx="${right ? 56 : 104}" cy="${F + 12}" rx="${n(32 + r() * 10)}" ry="7"/>`);
if (art === 'twin') {
  bed(right ? 8 : 72, 30);
  lampAt(right ? 50 : 114);
  bed(right ? 60 : 124, 30);
} else if (art === 'single') {
  // one narrow bed against the wall, a desk and its chair where a second bed would be
  bed(right ? 10 : 118, 32);
  const dx = right ? 62 : 64;
  rect('ma-room__b', dx, F - 26, 34, 3, 1);
  rect('ma-room__s', dx + 2, F - 23, 2.5, 23);
  rect('ma-room__s', dx + 29.5, F - 23, 2.5, 23);
  rect('ma-room__a', dx + 10, F - 18, 12, 3, 1);
  rect('ma-room__s', dx + 11, F - 15, 2, 15);
  rect('ma-room__s', dx + 19, F - 15, 2, 15);
  rect('ma-room__s', dx + 19, F - 30, 2, 15);
  lampAt(right ? 50 : 110);
} else if (art === 'family') {
  bed(right ? 8 : 102, 50);
  lampAt(right ? 66 : 92);
  bed(right ? 76 : 58, 26);
} else if (art === 'bunk') {
  const x = right ? 16 : 84;
  rect('ma-room__b', x, F - 56, 4, 56);
  rect('ma-room__b', x + 52, F - 56, 4, 56);
  for (const top of [F - 52, F - 22]) {
    rect('ma-room__l', x + 4, top, 48, 8, 1.5);
    rect('ma-room__p', x + 7, top - 4, 13, 5, 2.5);
    rect('ma-room__a', x + 22, top + 1, 30, 9, 1.5);
  }
  path('ma-room__ladder', `M${x + 44} ${F}V${F - 52}M${x + 50} ${F}V${F - 52}M${x + 44} ${F - 10}H${x + 50}M${x + 44} ${F - 20}H${x + 50}M${x + 44} ${F - 30}H${x + 50}M${x + 44} ${F - 40}H${x + 50}`);
  lampAt(right ? 84 : 66);
} else {
  const w = art === 'loft' ? 66 : 60;
  const x = right ? 14 : 146 - w;
  bed(x, w, art === 'loft' ? F - 18 : F - 26);
  lampAt(right ? x + w + 12 : x - 12);
  if (art === 'suite') {
    const ax = right ? 112 : 18;
    rect('ma-room__b', ax, F - 22, 28, 22, 4);
    rect('ma-room__a', ax + 3, F - 30, 22, 14, 3);
    rect('ma-room__s', ax - 2, F - 26, 5, 18, 2);
    rect('ma-room__s', ax + 25, F - 26, 5, 18, 2);
  }
}
if (r() < 0.6 && art !== 'suite') plant(right ? 150 : 10);

const a11y = label ? { role: 'img' as const, 'aria-label': label } : { 'aria-hidden': 'true' as const };
const vars = [colour && `--ma-room-body:${colour}`, accent && `--ma-room-accent:${accent}`, typeof style === 'string' && style].filter(Boolean).join(';') || undefined;
---

<div class:list={['ma-room', tile && 'ma-room--tile', className]} data-ma-block={block} data-kind={art} data-time={time} style={vars} {...rest}>
  <svg viewBox="0 0 160 120" class="ma-room__svg" preserveAspectRatio={tile ? 'xMidYMax slice' : undefined} focusable="false" {...a11y}>
    <rect class="ma-room__wall" x="-200" y="-200" width="560" height={200 + F} />
    <rect class="ma-room__floor" x="-200" y={F} width="560" height="200" />
    <rect class="ma-room__skirt" x="-200" y={F - 2} width="560" height="2" />
    <g set:html={out.join('')} />
  </svg>
</div>

<style is:global>
  @layer components {
    /* the furniture's colour, and the shade, light, pale and deep tones mixed from it, so a colourway is one value */
    :where(.ma-room) {
      --_body: var(--ma-room-body, var(--ma-motif-1, var(--primary, currentColor)));
      --_accent: var(--ma-room-accent, var(--ma-motif-2, var(--accent, currentColor)));
      --_shade: color-mix(in oklab, var(--_body) 76%, #000);
      --_light: color-mix(in oklab, var(--_body) 22%, #fff);
      --_pale: color-mix(in oklab, var(--_body) 8%, #fff);
      --_deep: color-mix(in oklab, var(--_body) 32%, #17120e);
      --_wall: var(--ma-block-ground, color-mix(in oklab, var(--_body) 10%, #fff));
      --_lit: oklch(0.9 0.12 85);
      display: grid;
      place-items: end center;
    }
    :where(.ma-room__svg) { width: 100%; height: 100%; overflow: visible; }
    :where(.ma-room--tile) { position: relative; overflow: hidden; width: 100%; height: 100%; background: var(--_wall); }
    :where(.ma-room--tile .ma-room__svg) { position: absolute; inset: 0; overflow: hidden; }
    :where(.ma-room__wall) { fill: var(--_wall); }
    :where(.ma-room:not(.ma-room--tile) .ma-room__wall) { fill: none; }
    :where(.ma-room__floor) { fill: color-mix(in oklab, var(--_wall) 74%, var(--_deep)); }
    :where(.ma-room__skirt) { fill: color-mix(in oklab, var(--_wall) 60%, var(--_deep)); }
    :where(.ma-room__roof) { fill: color-mix(in oklab, var(--_wall) 86%, var(--_deep)); }
    :where(.ma-room__rug) { fill: color-mix(in oklab, var(--_accent) 55%, var(--_wall)); }
    :where(.ma-room__frame) { fill: var(--_pale); stroke: var(--_shade); stroke-width: 0.8; }
    :where(.ma-room__sill) { fill: var(--_light); }
    :where(.ma-room__bars) { fill: none; stroke: var(--_pale); stroke-width: 1.6; }
    :where(.ma-room__day) { fill: oklch(0.86 0.06 230); }
    :where(.ma-room__dusk) { fill: oklch(0.76 0.11 50); }
    :where(.ma-room__night) { fill: oklch(0.26 0.06 265); }
    :where(.ma-room__sun) { fill: oklch(0.95 0.09 95); }
    :where(.ma-room__glow) { fill: oklch(0.68 0.15 30 / 0.55); }
    :where(.ma-room__moon) { fill: oklch(0.96 0.03 95); }
    :where(.ma-room__b) { fill: var(--_body); }
    :where(.ma-room__s) { fill: var(--_shade); }
    :where(.ma-room__l) { fill: var(--_light); }
    :where(.ma-room__p) { fill: var(--_pale); }
    :where(.ma-room__a) { fill: var(--_accent); }
    :where(.ma-room__d) { fill: var(--_deep); }
    :where(.ma-room__shade) { fill: var(--_light); }
    :where(.ma-room__ladder) { fill: none; stroke: var(--_shade); stroke-width: 1.6; stroke-linecap: round; }
    :where(.ma-room__g) { fill: color-mix(in oklab, var(--_accent) 40%, oklch(0.55 0.12 150)); }
    :where(.ma-room__gl) { fill: color-mix(in oklab, var(--_accent) 30%, oklch(0.7 0.13 140)); }
    /* the lamp's light: off by day, on at dusk and at night, and on when a link around the drawing is pointed at */
    :where(.ma-room__lamp) { fill: var(--_lit); opacity: 0; transition: opacity var(--ma-duration-slow, 400ms) var(--ma-ease-out, ease-out); }
    :where(.ma-room__lamp[data-on='']) { opacity: 0.45; }
    :where(a:is(:hover, :focus-visible) .ma-room__lamp) { opacity: 0.45; }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-room__lamp) { transition: none; }
    }
  }
</style>

```
