Skip to content

Latest commit

 

History

History

site

README.md

harbor

site/ is the website for crewship.si: a full-screen, first-person walk around the ship, dock, island, and house, drawn as text.

The home page keeps its product summary, About link, and Docs link in the HTML. The start menu also links to the documentation. The static About page, served at /about, explains the product and links to setup and safety guides. The static What's new page, served at /changelog, is built by build.py from the changelog.d/ fragments and the released versions in CHANGELOG.md. The /docs index and guides are readable without JavaScript and are built from the repository's Markdown sources.

The harbor is rendered natively: one plain JavaScript file, public/harbor.js, draws on a 2D canvas. There is no 3D engine, no WebGL and no model files. A ray caster sends one ray per character cell from your eye; objects are maths shapes (planes, cones, ellipsoids, tapered limbs, cloth, height fields), not meshes. The nearest hit is lit by the moon and lamps with shadow rays, then brightness picks a glyph and the material picks a colour. Web workers share the drawing work; see how.html for rendering costs and graphics detail.

Each object shows an ASCII sign; see Content from the repository for sign placement. The sign uses the scene's character grid, font and colors, with clickable links. Selecting a destination on the ASCII mini map walks you there around obstacles; with reduced motion, you jump there instead. See how.html for the renderer and controls. The off-screen HTML list and current sign keep links available to screen readers and keyboard users. An uncaught error or rejected promise in harbor.js, or a failure to load it, hides the scene and shows the plain page. Errors and rejected promises from other scripts or resources, such as browser add-ons, do not. The start menu shows a still Harbor view with Explore, GitHub, About, and Settings. Explore starts the existing scene and moves keyboard focus to the stage. The scene starts with a 1.2-second glyph-noise sweep. Reduced motion skips the sweep. Press a key, click, touch the screen, or use the mouse wheel to skip the sweep. Waves, the fountain, and boats keep moving during the intro. See how.html for the canvas limits and intro rendering details.

The island rises from the beaches into gentle hills behind the town; the ground stays level under buildings, the plaza, and the dock. Slopes that face the moon are brighter, and hill crests catch a light outline. Curving dirt roads follow the ground, with soft, irregular edges and two worn ruts, and map walks follow their curves. Grass, sand, and road blend over soft edges, without square patches. The island also has staggered plaza paving, scattered stones and grass tufts, trees and palms, and foam along the shore. Tufts of tall grass sway in the wind and grow densest along the path edges, on the hills, and on the dry upper beach slopes. Grass is sparse on the footpaths and never grows on the cart road, the dock, the plaza, wet sand, or in buildings. Grass does not block walking or map routes, and it stays still with reduced motion.

Oak, lime, and birch trees have tapered trunks that branch into clumps of ragged leaves, with gaps that show the sky. The moon lights the tops of the clumps; their undersides and the inside of each crown stay dark. Palms have curved, ringed trunks and drooping fronds of many leaflets. Trees and palms bend and flutter in the scene's wind and stay still with reduced motion. Broadleaf trunk feet block walking and map routes; their upper limbs, leaves, and palms do not.

Three layers of cloud move slowly across the night sky in the direction of the wind on the sails. Most of the cloud is thin streaks along the wind, with soft, broken edges and holes. A few cumulus clumps have lumpy tops and flat bases. The moon lights the cloud edges that face it. Thin cloud dims the moon and the stars, and thick cloud hides them. The layers are at different heights, so they move at different speeds when you walk. With reduced motion, cloud drift stops, but walking still changes your view of the clouds.

Walk into the framed house door to enter a warm room with a lamp, table, and bed. The house windows open into the room: from inside you see the harbor through them, and from outside you see the lit room. The view through the house windows changes when you move or look around. A fireplace with a fire, a bookcase, chairs, an armchair, crates, a sea chest, potted plants, and a rug furnish the room. Four public-domain paintings hang on the walls in moulded frames with brass name plaques. The painting above the mantel is by Turner. See CREDITS.md for the works, sources, and licences. See art.py for the texture conversion command and how.html for rendering details. The blue bed stands under a back window and faces the door; darker walls and floor keep the furniture, lamp, and windows clear. Walk back through the inside door to return just outside, facing the island. Keyboard and touch movement share the door transition; walls and furniture block walking, and the map stays hidden inside. Release the movement keys and touch pad after a door crossing to move again. See the frame loop for adaptive detail and display timing.

The pirate ship has a 28 m tapered hull, a raised stern castle, and two square-rigged masts. Its gun ports, railings, figurehead, and warm lanterns follow the hull and deck. Walking bounds and the map use the hull's tapered stations; the gangway joins the deck to the dock. The stern castle is the cabin, with planked walls, framed windows, a dark strap-hinged door, a glowing wall lantern, and pale roof trim. Walk through the cabin door to enter a small furnished room. It has a chart table under a hanging lantern, a chair, a bunk, a sea chest, and the cabin's windows, which open both ways like the house windows. The cabin uses the house's door transition, collisions, and hidden map; the inside door returns you to the deck, facing the bow. A steep companionway ladder connects the main deck to the cabin roof: ten open pale treads between sloped stringers, with a thick pale handrail on posts on each side. The ship's wheel stands on a pedestal on the cabin roof, behind the top of the ladder. Its eight spokes extend past the rim as handles around a brass hub, with the handles and hub cap pointing aft. The upright wheel runs across the ship, with its axle along the keel and the helmsman aft looking forward. The wheel and its pedestal block walking, so you walk around them on the roof. Map walks connect the dock through the gangway and reach the stern roof up the ladder. Ship approaches avoid deck obstacles and reject unsupported floors or height changes larger than the manual walking limit. Keyboard and touch movement use short segments so slow frames do not skip the ladder treads. One scene wind fills the sails into curved bellies that slowly fill and ease. Each foot sags in an arc between its corners, and the sides bow out. The sheets stay taut to the moving sail corners. The black flag shows a pale skull and crossbones and has a wavy fly edge; it streams with the masthead pennants in the same wind. With reduced motion, the sails stay full but do not move. The moored ship sways gently with the wind, under 2 degrees of roll, combining smooth cycles of about 8 and 11 seconds. The cabin and gun deck share the hull's roll and a pitch under 1 degree; their hanging lanterns swing slightly. With reduced motion, the hull, both ship rooms, and their lanterns stay still. The sails keep a pale canvas tone at night. Outboard lanterns make the gun ports visible.

A square hatch forward of the hold has a raised wooden coaming and a lantern on a post at its aft corner. Its ladder goes down a dark well to the gun deck. Walk onto the hatch to climb down to the foot of the ladder, facing aft. Each gun port has a cannon on a wooden carriage with four round wheels. The dark, tapered barrel runs from a rounded breech out through the port to a ring at the muzzle. A rack of stacked round shot stands beside each gun. Dark cross-beams carry the deck planks overhead, and the floor planks show their seams. Lanterns hang between the beams beside the walkway and cast pools of dim warm light. The open ports show the harbor outside, and from outside they show the lit gun deck. The far-shore town has no rooms, so its windows stay lit patterns. Walk into the ladder to climb back up beside the hatch, facing aft. Keyboard and touch movement share the house door's transition: release the movement keys and touch pad after a crossing to move again. The guns, shot racks, and main mast block walking, and the map stays hidden below deck. Map walks go around the open hatch.

The plaza fountain has an octagonal stone rim around a pool, and a pedestal with an upper bowl. Six streams rise from the central nozzle and fall into the upper bowl. Six spouts on the pedestal arc out into the pool. The droplets follow ballistic arcs and land well inside the bowl or the pool. The streams break up into droplets as they fall, and splashes, spreading rings, and mist show where they land. With reduced motion, the water stays still. The basin blocks walking, and a blue O marks the fountain on the mini map. The fountain marker leaves map labels and selected point markers clear. Map walks check each road and approach segment against the walking collision bounds at the local floor height and require continuous ground coverage. Detour corners come from every walking obstacle, including trees, hedges, the basin, the house, dock cargo, and ship fixtures. The page builds the corner graph only on the first animated map walk, not while loading the start menu or entering Explore. It uses the same level-ship geometry as startup and reuses the graph for later walks; reduced-motion jumps never need it. Ship bob and roll do not rebuild the graph. If no free viewing spot exists, the map selection stops without moving the player. Without reduced motion, a missing clear route also stops the map walk without moving the player.

The harbor plays ambient sound that fades with distance: sea and surf at the nearest waterline or ship side, water at the fountain, wind in the nearest tree, creaking timbers along the ship, and gulls circling far over the bay. Inside the house and the ship's rooms, a fire or lantern crackles nearby and the outdoor sounds are muffled; the ship's rooms keep the creaking. Sound starts only after Explore, and the speaker button turns it off or on. The page stores the sound choice under harbor-sound. Sound stops while the tab is hidden or the menu is open; reduced motion does not change it. The loops are CC0 recordings in public/audio/, as AAC with an Opus copy for browsers without AAC; see CREDITS.md for the sources. See how.html for the audio graph.

Settings provides Auto, Low, and High graphics, plus System, Reduce, and Full motion. Auto keeps adaptive phone detail, and High keeps full detail. Low uses larger glyphs and disables shadows. Low stays active through house doors, cabin doors, and the ship's hatch. System follows the operating system's motion setting. Reduce stops idle scene animation; Full allows it. Close Settings with Escape or Back to return focus to the Settings button. The small harbor-settings record stores only graphics and motion values. If storage fails, changes apply for the visit and Settings shows a notice. The notice clears after a successful save. Reset restores Auto graphics, System motion, and sound on without clearing other storage. The Controls section lists the existing keyboard and touch controls. The scene Menu button pauses the view and releases movement input. Opening Menu renders the current view and discards older worker frames, including their bitmaps. Escape first releases pointer lock or closes the map; a later press opens the menu. The start menu remains available after Escape, including before the first user action. The scene Menu button stays clear of map controls, sign links, and the zoom hint. If the scene script fails, the plain page retains GitHub and About links without inactive scene buttons.

Run the menu check through chrome-devtools-axi run with the preview server URL:

const { checkMenu } = await import(process.cwd() + "/site/menu-check.mjs");
console.log(await checkMenu(page, "http://127.0.0.1:8000/"));

The page uses plain HTML, CSS and JavaScript in public/, with no runtime dependencies. Nothing here reaches a Crewship host or worker image: .dockerignore excludes site/, and the playbook never copies it. tests/test_harbor_isolation.py checks both.

Content from the repository

build.py (Python standard library) copies public/ to dist/ and fills the points of interest from the README's Features list and More docs row and from docs/features.md. Every object in the scene needs a SCENE entry whose docs link appears in one of them; a test checks this. It also adds the quick start, the repository, the latest release from CHANGELOG.md, and /how. A link with an object in the scene (mapped in build.py's SCENE) opens on that object. A link with no mapping prints a warning but never blocks the build. The docs board shows on the mini map like the other points. Add an object and a SCENE entry to give the link its own place. The build limits each sign to a title, a short description and one link.

Unmapped pages share a short summary with a count and one link to the README feature index. The welcome sign shows the home page's project links, including About. Posted signs first place their bottom frame row on the lowest projected support top. Paired posts keep a screen-aligned rectangle wide enough to cover both supports; the higher support top passes behind the board. Projection uses the same cell centers as the ray-cast scene, not the aimed surface or the object's center. Signs without posts keep their existing anchor placement. If the post top is off screen or the frame cannot fit, signs reflow below or beside the map. Signs keep clear of the move pad and safe-area edges. If space is limited, signs remove spacing and shorten the visible copy. If no clear rectangle fits, compact signs can cover the map but keep their links clear of the move pad. Signs remain within the safe area. Touch links share one row when they fit; otherwise, each link has its own row. Inline touch links keep hit regions at least 44 CSS pixels tall without increasing the frame height. Stacked links have one blank link row between them and separate hit regions at least 24 CSS pixels tall. Each stacked link row is at least 12 CSS pixels tall. The invisible hit padding can extend beyond the sign frame, but stays clear of the move pad. Touch frames fit the text, with one blank row before the links. Click or tap a sign link to open it. Focus a feature-list link to show its sign. The sign highlights the focused link without scrolling the scene. Link focus closes the map and cancels pending automatic walking. See how.html for the movement and Enter controls.

Published documentation

The same build publishes README.md, SETUP.md, and every top-level docs/*.md file. Their URLs are /docs/readme, /docs/setup, and /docs/<lowercase-file-stem>. dist/docs.html is the /docs index; each guide is a dist/docs/<slug>.html file. The Markdown renderer has no third-party dependencies. It supports headings with GitHub-style IDs, paragraphs, nested lists, fenced code, inline code, emphasis, links, images, tables, and blockquotes. It retains the repository's picture markup through a small HTML allowlist, drops event attributes, and escapes other HTML. Relative links to published guides use their /docs URLs and keep query strings and fragments. Other repository links stay on GitHub; relative images use raw GitHub URLs so they render as images. Each guide has its own title, a description from its first paragraph (at most 160 characters), an absolute canonical URL, and breadcrumb structured data. public/llms.txt links to the published guides for AI readers.

Preview

python3 site/build.py
python3 site/serve.py

Then open http://localhost:8000. The preview serves /how, /about, /docs, and /docs/<slug> without redirects and returns 404 for unknown routes. It matches Cloudflare's default auto-trailing-slash handling for these HTML files: .html and trailing-slash aliases redirect to the extensionless canonical URL. Pass a port to site/serve.py to use a port other than 8000.

Link preview

index.html sets the Open Graph and Twitter card tags, with absolute https://crewship.si URLs. The home, How, and About pages define their metadata in public/index.html, public/how.html, and public/about.html; the build generates each documentation page's metadata from its source. The preview image public/og.png (1200x630) is committed, so the build and the deploy need no browser. og-image.mjs draws it with the real renderer from a fixed camera, without the sign or the mini map, and sets the title over the scene. It also draws public/apple-touch-icon.png (180x180) from public/favicon.svg. After a scene, preview layout, or favicon change, regenerate both PNGs in the Playwright container and commit them:

python3 site/build.py
npm install --no-save --no-package-lock --prefix site playwright@1.64.0
docker run --rm --ipc=host --user "$(id -u):$(id -g)" -e HOME=/tmp -v "$PWD/site:/site" -w /site \
  mcr.microsoft.com/playwright:v1.64.0-noble node og-image.mjs

The Playwright package version must match the container version. Rebuild dist/ after regeneration to preview or deploy the new images. tests/test_harbor_build.py checks the tags, the URLs, and the image sizes.

Browser checks

The check loads the built page in Playwright WebKit at desktop and iPhone portrait and landscape sizes. It checks that /how, /docs, and /docs/setup serve HTML with their canonical URLs, including requests with a query string. Missing routes and directory requests return 404 rather than crashing the fixture server. Same-tab sign links check the destination's heading before returning to the harbor. The check fails on a crash, an uncaught error, a console error, a fallback to the plain page, or a multi-glyph fillText call on touch. The check walks through the house door and back, and down the ship's hatch and back up its ladder, with the touch pad. The check enters through Explore before it exercises the scene. It checks the first rendered frame after Explore for black pixels and checks that scene time advances during the intro. It checks Menu clearance and the map toggle in both map sizes. Inside the house, it checks that manifest focus keeps the player in place and draws a sign with hit-testable links. It checks every sign against safe-area edges and checks map and move pad clearance before the final overlay placement. It checks the how, docsboard, and mailbox signs against the placement rules above. It uses three camera views and an additional oblique docsboard view. It checks each support independently for vertical gaps and horizontal coverage on desktop and iPhone portrait and landscape. It includes 320×568 phones in both orientations and landscape heights of 256 and 192 pixels. The geometry fixtures use fixed normal and coarse grids, independent of host rendering speed. The Python checks exercise adaptive detail with controlled render costs and painting delays in the exterior, house, cabin, and gun deck. It checks measured safe insets, visible signs, link hit regions, and keyboard focus without stage scrolling. On iPhone 15 Pro, it checks that the canvas draws at the display pixel ratio. It also taps six pixels above and below each stacked link center to check the separate hit regions. It also checks feature-link focus with the full map open and an arrival selection pending. It fails if rendering stalls. It taps or clicks every link, checks same-tab and new-tab navigation, and returns to the scene. After Back, it waits for page startup and selects Explore when the start menu is open. Before link navigation, it starts sound through the speaker button and waits for every audio loop to start. This keeps repeated About visits from cancelling the restored scene's audio loads. It also opens the docs index with the keyboard. See how.html for the rendering limits on touch devices.

CI also loads the page in Playwright Chromium in a 3651x2160 window at DPR 2, with chromium-check.mjs. It adds a failing image and a rejected promise, as a browser add-on can. It fails on the plain-page fallback, a black canvas 3 s after load, or a console error. It also fails if the backing-store area exceeds the canvas limit. CI pins the Playwright package and official browser image to the same release. The image includes the browsers and system libraries, so slow Ubuntu package downloads cannot consume the browser checks' time. The job keeps its 10-minute limit. To run the checks locally, use the release in the harbor-webkit job in ci.yml:

python3 site/build.py
cd site
npm install --no-save --no-package-lock --prefix . playwright@1.64.1
npx playwright install --with-deps webkit chromium
node webkit-check.mjs
node chromium-check.mjs

Deploy

Cloudflare Workers serves dist/ as static assets; wrangler.jsonc is the configuration. The harbor workflow builds and deploys on every push to main, on each published stable release (not prereleases), and on a manual run. Each run is a GitHub deployment in the crewship.si environment, which accepts only main and v* tags and holds the deploy token as its CLOUDFLARE_API_TOKEN secret and the account id as its CLOUDFLARE_ACCOUNT_ID variable.

To deploy by hand with a token that can edit the crewship Worker:

python3 site/build.py
cd site
CLOUDFLARE_API_TOKEN=... CLOUDFLARE_ACCOUNT_ID=... npx wrangler@latest deploy

The site serves on https://crewship.si, a custom domain attached to the Worker outside wrangler.jsonc, and each deploy records that URL under Deployments.

The build copies public/robots.txt unchanged and generates dist/sitemap.xml. The crawler policy allows all crawlers, including GPTBot and OAI-SearchBot. The sitemap lists the canonical home, How, About, documentation index, and every published guide URL without modification dates. Origin redirects and Cloudflare-managed crawler settings live outside this repository. Check that the deployed /robots.txt matches the file before changing those settings. public/e3c5c1952d33a0d51288552a3907210a.txt is the IndexNow key file. After a deploy that changes page URLs or content, submit the changed URLs:

curl -fsS -X POST https://api.indexnow.org/indexnow -H 'Content-Type: application/json' \
  -d '{"host":"crewship.si","key":"e3c5c1952d33a0d51288552a3907210a","urlList":["https://crewship.si/"]}'