Lane E, Sprint 11: client juice — job sheet, invoice, and the kid's bike

The letterhead is the point: one business, two documents, same masthead, so
the job sheet and the invoice read as a pair of papers off the same outfit's
printer rather than two game screens. DESIGN.md has opened with "you run a
small landscaping outfit" since day one and it has never been on the glass.

tools/jobsheet/ — paste-ready for Lane A between CUT markers, purely additive
(.card/.row/.ledger untouched), rendered to four PNGs before handover, each
state addressable as ?v=. The ledger already WAS an invoice; it just never
said so. The line worth keeping is .void: a bonus that was on the job sheet
and didn't survive the night, struck through in the same column as the money
that did — a bonus you never watch fail is one you never learn to protect.

Two design calls left as A's with the reasoning beside them, same as the
carport's $180: the business name (proposal: HARD YARDS) and the ABN
(sequential digits on purpose — a plausible one could be a real business's).

bike_kid_01.glb — 1.15 x 0.33 x 0.74 m, 700 tris, lean baked in because a
bike doesn't stand up on its own. Two bugs caught by LOOKING at the render,
neither catchable by the existing suite: a comment claiming a step-through
above a diamond frame, and a fork with negative rake that drew head tube and
fork as one pole through the front wheel. Both passed the Blender verify —
a bounding box can't answer "is this the right shape".

The lean is an export-time axis fact (+Y in Blender is -Z in three.js), so
e.test.js now pins it in browser coords. Proved the assert fails: rebuilt at
lean=0 and Blender still said PASS while the selftest went red.

selftest 299/0/0. 32/32 GLBs byte-identical across three full factory runs.
This commit is contained in:
type-two 2026-07-17 17:39:58 +10:00
parent b38311021c
commit 8ec01028f9
11 changed files with 691 additions and 0 deletions

View File

@ -3228,3 +3228,96 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
· D's cold playthrough of site_02 — unblocked at last;
· A decides the carport collateral number and the Anchor.type enum widening D flagged;
· gate 3 (John plays the week) — FOUR sprints standing.
[E] 2026-07-19 — 🧾 **LANE A — the job sheet and the invoice are drawn, rendered, and waiting for you
in `tools/jobsheet/`.** Same handover as the end cards: everything between the CUT markers pastes
into hud.js, and four PNGs sit beside it so you can judge it before you paste it
(`preview_sheet1`, `preview_sheet3`, `preview_inv_good`, `preview_inv_bad`; each state is also a
URL — `index.html?v=inv_bad`). It is **purely additive** — I did not touch `.card`, `.row` or
`.ledger`, every rule is a new class, so pasting it and wiring nothing changes nothing.
· **The letterhead is the trick.** One business, two documents, same masthead on both: the job
sheet and the invoice read as a pair of papers off the same outfit's printer instead of two
game screens. DESIGN.md opens with "you run a small landscaping outfit" and that has never once
been on the glass. The bill-to block is deliberately the SAME component on both — on the sheet
it's who you're working for, on the invoice it's who's paying, and it not moving is the joke.
· **Your `.ledger` already IS an invoice, it just never said so.** It gains a rule, an AMOUNT DUE,
and a terms line. That's it.
· **The one line I'd fight for: `.void`.** A bonus that was on the job sheet and did NOT survive
the night, struck through, still sitting in the same column as the money you did get. Four CSS
declarations. A bonus the player never watches fail is a bonus they never learn to protect —
see `preview_inv_bad`, where the $25 clean-site is dead and the carport is $180 under it.
· Pay schedule is a **list, not three named fields**. base/garden/no-collateral is what SPRINT11
specifies, but a list costs nothing today and means a site wanting a fourth condition doesn't
need a card change — which is the whole point of sites being data.
[E] 2026-07-19 — 💰 **LANE A — two design calls in that tool are YOURS, and both have reasoning in the
code beside them so you can argue rather than just accept.** Same deal as the carport's $180.
· **The business name.** A letterhead needs an outfit on it and we've never named the player's.
My proposal is **HARD YARDS** — a real idiom for the unglamorous grind nobody thanks you for,
literally about yards, one word at masthead size, and it's the joke the game already tells:
THREADS' own win-card line is "nobody thanks you for the storm that did nothing. That's the
job." That IS the hard yards. What I deliberately did NOT do is name it after the player — a
surname mark is the more authentic thing a one-person outfit would have, and it's the better
answer the moment the player has a name, but naming the player is a much bigger call than a
letterhead and it isn't mine to make in a juice pass. One constant changes if Sprint 12 names them.
· **The ABN.** An Australian invoice without one isn't an invoice — it's the detail that makes the
paper read as real. But any plausible 11-digit number is potentially a real business's tax
identity, so it's sequential digits, which reads as "example" the way 555 numbers do. Deliberate,
not lazy — please don't "fix" it into something realistic.
[E] 2026-07-19 — 🚲 **`bike_kid_01.glb`** — the per-client prop. 1.15 × 0.33 × 0.74 m, 700 tris, and the
Hendersons now have a kid. It exists so the brief on the job sheet ("the seedlings have to be alive
when we get back") lands on a yard that visibly belongs to somebody.
**THE LEAN IS BAKED IN AND IT LEANS TOWARD Z. Read this before you place it.** A bike doesn't stand
up on its own, so an upright GLB would read as a bug the moment it's placed; the tilt is applied to
every point at author time, about the X axis through the origin, so the tyre contacts map to
themselves and it sits ON the ground. In the Blender source it leans +Y. **You don't work in
Blender.** The exporter maps (x,y,z) → (x,z,y), so in three.js it leans **Z**, and Z is where the
fence goes. Put the fence on +Z and it leans away into thin air — a bug that looks exactly like a
physics bug and isn't one. I wrote "+Y is the fence side" in the docstring first; it would have been
a lie by the time it reached you. Stand it ~0.10 m off the palings — bars touch a fence, not tyres.
**What it needs from you:** a loader line and a site-JSON key. There's no generic `props: []` — every
prop is a named top-level key (`gnome`, `shed`, `shedTable`), so the bike is dead weight until you
wire it, exactly like the carport was. Shipped with `mass_hint: 8.0` and `breakable: false`.
**Not priced, and that's a question for you, not a decision by me.** It's juice, not a trap. But it's
a bike in a 32 m/s funnelled southerly, and a bike that ignores that gust is a worse lie than no
bike. If you want it collateral my number is **$60** — a real kid's bike, well under the gnome-to-
carport span so it never competes with the site's actual trap, and the sting is that it's the one
piece of collateral the client's kid will notice is gone. Ignore that number happily; the prop
stands up without it.
[E] 2026-07-19 — 🐞 **two bugs, both caught by LOOKING at the render, neither catchable by the suite I
already had** — and that's the interesting part.
(1) I wrote a comment saying "it's a step-through: no top tube" and then built a diamond frame with
a top tube. The comment lied about the geometry directly under it. (2) The fork had NEGATIVE rake —
the axle sat behind the head tube's axis instead of ahead of it — so head tube and fork drew as one
straight pole down through the front wheel, which is a pogo stick with wheels.
**Both passed the Blender verify.** Dims, tri budget, node names, GLB round-trip: all green, both
times. A bounding box cannot answer "is this the right shape", and I'd have shipped a prop whose
silhouette was wrong while every number said fine. The step-through diagonal is the ONLY reason it
reads as a kid's bike from across a yard — at 20 m you can't judge absolute size, so the silhouette
has to carry it and scale can't.
[E] 2026-07-19 — 🔒 **the lean now has an assert, and I proved it fails.** `e.test.js` pins the bars'
centre to z < 0.05 in browser coords plus the wheels to the ground. Nothing else can catch a flip:
the Blender verify re-imports through the same mapping and round-trips green, and the bounding box
is the identical size whichever way the thing tips. I rebuilt the bike at `lean = 0°` as a negative
control — **Blender said `[PASS] 1.15 × 0.31 × 0.71`, the selftest went red, 1 failed / 298 passed.**
That's the axis trap this file's header has warned about since Sprint 3, and it caught me writing a
docstring rather than writing geometry.
status: selftest **299/0/0** (296 + 3), Lane E is 67 asserts, **32/32 GLBs byte-identical across
three full factory runs** (`210c47b2…`), and the un-tilt/restore round-tripped to the same hash.
[E] 2026-07-19 — standing by, not blocked:
· **the tree-move** — still yours to trigger, B. C's gain drop goes first (their standing offer),
and if the funnel still kills every $80 line after it, I move the tree, not the ratings. The
ratings are the site.
· **A — I built the cards before yours exist, on purpose.** SPRINT11 gate 2 specifies the shape
(client + brief + pay schedule) well enough to design against, and this is the same
design-ahead-and-hand-over move as the end cards, so the tool is a proposal you paste, edit, or
bin — I have not touched your files and won't. If your night-entry shape lands different, the
markup doesn't care: it lays out label/value pairs and a quote.
· **integrator, small one:** `.claude/launch.json` is tracked and hardcodes port 8815, so every
lane clone's dev server collides with every other and with the integration checkout. I worked
around it locally rather than committing a port change into lane/e. Worth a per-lane port or
`autoPort` at integration, before five lanes try to run a server at once.

View File

@ -380,6 +380,24 @@
"status": "PASS",
"problems": []
},
{
"name": "bike_kid_01",
"dims": [
1.148,
0.328,
0.7269
],
"tris": 700,
"nodes": [
"bars",
"bike_kid_01",
"frame",
"wheel_front",
"wheel_rear"
],
"status": "PASS",
"problems": []
},
{
"name": "garden_gnome_01_broken",
"dims": [

View File

@ -121,6 +121,8 @@ PAL = {
"bristle": "#C9A659", # broom straw
"hail_ice": "#DCEAF2", # hailstone
"window_warm": "#FFC98A", # someone is home
"bike_kid": "#D8483C", # the Henderson kid's bike — bought bright on purpose
"bike_grip": "#3B4048", # grips and saddle
"ref_pink": "#E85C8A", # the reference capsule — deliberately loud
}
@ -1446,6 +1448,137 @@ def build_garden_gnome_01_broken(name):
return root
def build_bike_kid_01(name):
"""A kid's 16-inch bike, dropped against the fence (SPRINT11 §Lane E — the
per-client prop). The Hendersons have a kid; the kid has a bike; the bike is
against the fence because that is where bikes live. It exists to make the
job sheet's brief — "the seedlings have to be alive when we get back" — land
on a yard that visibly belongs to somebody.
LEAN IS BAKED IN, and that's deliberate. A bike does not stand up on its own,
so an upright GLB would be a prop that reads as a bug the moment it's placed.
The whole thing is built through `_tilt()`, a rotation about X applied to
every point at author time.
The origin is the ground line, and the tilt is about the X axis THROUGH that
origin so the tyre contact points (z = 0) map to themselves and the bike
sits on the ground, not through it or above it. Drop it at ground level like
the gnome and rotate about the up axis to aim it.
** WHICH WAY IT LEANS read this before you place it, Lane A. **
In THIS file the bike leans toward +Y, because Blender is Z-up. You do not
work in Blender. The exporter maps Blender (x, y, z) -> glTF (x, z, -y), so
in three.js the bike leans toward **-Z**, and -Z is the side the fence goes.
Put the fence on +Z and the bike will lean away from it into thin air, which
is a bug that looks exactly like a physics bug and isn't one.
I wrote "+Y is the fence side" here first and it would have been a lie by the
time it reached you this is the axis trap the header of e.test.js exists to
catch, and it caught me writing the docstring, not the geometry. There's now
an assert in that file pinning the lean to -Z in browser coords, so if anyone
ever un-tilts this or flips the export, the suite says so instead of you
finding out by eye. Stand it ~0.10 m off the palings the bars, not the
tyres, are what touch a fence.
Not breakable and not priced: it's juice, not a trap. See my THREADS note if
you want it to be collateral that's your call and it has a number in it.
"""
root = add_empty(name)
frame_m = get_material("Mat_BikeFrame", PAL["bike_kid"], 0.55)
tyre_m = get_material("Mat_BikeTyre", PAL["mat_black"], 0.9)
steel = get_material("Mat_SteelDark", PAL["steel_dark"], 0.5, metallic=0.6)
grip_m = get_material("Mat_BikeGrip", PAL["bike_grip"], 0.85)
# The lean. 11° about X tips the bike toward +Y. Shallow on purpose: past
# about 15° it reads as "knocked over" rather than "parked", and this bike
# is meant to be resting, not already a casualty of the storm.
lean = math.radians(-11.0)
cl, sl = math.cos(lean), math.sin(lean)
def _tilt(p):
x, y, z = p
return (x, y * cl - z * sl, y * sl + z * cl)
R = 0.203 # 16" wheel: 406 mm diameter, the real kid-bike size
AX = 0.355 # axle from centre — 0.71 m wheelbase
# --- wheels. arc_points gives me the rim in the upright XZ plane; every
# point then goes through _tilt, which is the same thing add_arc_tube does
# internally minus the lean. Ten segments reads round at yard distance and
# keeps the pair under ~450 tris.
for side, cx in (("rear", -AX), ("front", AX)):
pts = arc_points(R, 0, math.tau, 10, center=(cx, 0, R), plane='XZ')
segs = [add_tube_between(f"{name}_{side}_tyre_s{i}", _tilt(pts[i]),
_tilt(pts[(i + 1) % len(pts)]), 0.019, tyre_m, verts=6)
for i in range(len(pts))]
segs.append(add_tube_between(f"{name}_{side}_hub", _tilt((cx, -0.028, R)),
_tilt((cx, 0.028, R)), 0.016, steel, verts=6))
join_group(segs, f"wheel_{side}", root)
# --- frame. Upright coordinates, tilted on the way in.
#
# It's a STEP-THROUGH: the tube from the head drops to the MIDDLE of the seat
# tube instead of running level to the top of it. That dropped diagonal is
# the entire reason this reads as a kid's bike from across the yard rather
# than as a small adult one — at 20 m you cannot judge absolute size, so the
# silhouette has to carry it. Scale alone would not.
#
# First pass built a diamond frame while the comment above it claimed a
# step-through, and I only caught it by looking at the render — the dims
# passed either way, because "is it the right shape" is not something a
# bounding box can answer. Same lesson as the Sprint 10 phantom post.
bb = (-0.05, 0, 0.175) # bottom bracket
seat_top = (-0.165, 0, 0.600)
head_top = (0.245, 0, 0.615)
head_bot = (0.285, 0, 0.430)
# 45% up the seat tube — where the step-through's diagonal lands.
step_join = (-0.102, 0, 0.366)
tubes = [
("seat_tube", bb, seat_top, 0.017),
("down_tube", bb, head_bot, 0.018),
("step_tube", head_top, step_join, 0.015), # the step-through drop
("chain_stay", bb, (-AX, 0, R), 0.012),
("seat_stay", seat_top, (-AX, 0, R), 0.011),
# The fork is RAKED — the axle sits ~21 mm FORWARD of the head tube's
# axis extended to axle height. Sign matters and I got it backwards
# first: with the axle behind that line, head tube and fork drew as one
# straight pole down through the front wheel, which is a pogo stick with
# wheels. Rake is what puts the bend in the silhouette.
("fork", head_bot, (AX, 0, R), 0.013),
("head_tube", head_bot, head_top, 0.016),
]
parts = [add_tube_between(f"{name}_{n}", _tilt(a), _tilt(b), r, frame_m, verts=6)
for (n, a, b, r) in tubes]
join_group(parts, "frame", root)
# --- the bits a kid actually touches. Bars sit across Y; the grips are the
# only two things on this bike that are worn, and they're the reason it
# reads as USED rather than as a showroom asset dropped in a yard.
bars = [
add_tube_between(f"{name}_stem", _tilt(head_top), _tilt((0.225, 0, 0.685)),
0.013, steel, verts=6),
add_tube_between(f"{name}_bar", _tilt((0.225, -0.155, 0.685)),
_tilt((0.225, 0.155, 0.685)), 0.012, steel, verts=6),
add_tube_between(f"{name}_grip_l", _tilt((0.225, -0.155, 0.685)),
_tilt((0.225, -0.095, 0.685)), 0.016, grip_m, verts=6),
add_tube_between(f"{name}_grip_r", _tilt((0.225, 0.095, 0.685)),
_tilt((0.225, 0.155, 0.685)), 0.016, grip_m, verts=6),
add_tube_between(f"{name}_saddle", _tilt((-0.200, 0, 0.615)),
_tilt((-0.130, 0, 0.622)), 0.030, grip_m, verts=6),
add_tube_between(f"{name}_crank", _tilt((-0.05, -0.075, 0.175)),
_tilt((-0.05, 0.075, 0.175)), 0.010, steel, verts=6),
]
join_group(bars, "bars", root)
stamp(root, name, "prop")
# A 16" kid's bike is ~8 kg. Lane A/C: this is here so the wind CAN have it
# later — a bike that ignores a 32 m/s gust is a worse lie than no bike.
root["mass_hint"] = 8.0
root["breakable"] = False
return root
def build_hail_stone_01(name):
"""One hailstone, ~22 mm (SPRINT5 §Lane E-1, "stone mesh if C wants geometry
over sprites").
@ -2193,6 +2326,12 @@ ASSETS = [
dict(name="garden_gnome_01", fn=build_garden_gnome_01,
dims=((0.10, 0.20), (0.10, 0.20), (0.33, 0.42)),
nodes=["gnome"]),
# The per-client prop. Leaning, so the Y span is wider and the Z shorter than
# an upright bike's — that asymmetry IS the lean, and if it ever measures
# square again someone has quietly un-tilted it.
dict(name="bike_kid_01", fn=build_bike_kid_01,
dims=((1.00, 1.25), (0.24, 0.52), (0.60, 0.84)),
nodes=["wheel_rear", "wheel_front", "frame", "bars"]),
# Aftermath wreckage (SPRINT3 §Lane E-2). Each keeps its intact twin's origin
# and footprint so Lane A swaps in place.
dict(name="garden_gnome_01_broken", fn=build_garden_gnome_01_broken,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 MiB

After

Width:  |  Height:  |  Size: 3.3 MiB

408
tools/jobsheet/index.html Normal file
View File

@ -0,0 +1,408 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>SHADES — job sheet + invoice preview (Lane E → Lane A)</title>
<style>
/* ==========================================================================
LANE A — SPRINT 11 GATE 2. Everything between the CUT markers is yours to
paste into hud.js. Same deal as the end cards last sprint: rendered and
screenshotted before it was handed over, so it's a paste rather than a
design job. Take it, edit it, or bin it.
It is deliberately ADDITIVE. I have not touched .card / .row / .ledger —
every rule below is a new class, so the cards keep working exactly as they
do today if you paste this and wire nothing. The job sheet is the forecast
card wearing a letterhead; the invoice is your .ledger wearing one too.
Your existing markup slots in underneath both.
THE TWO DESIGN CALLS IN HERE ARE YOURS, NOT MINE — see BUSINESS and ABN
at the top of the script block. My proposals are there with the reasoning
beside them so you can argue with them, same as the carport's $180.
====================================================================== */
/* ---- CUT HERE ---------------------------------------------------------- */
/* --- the letterhead ----------------------------------------------------
One business, two documents. This is the whole trick of the sprint: the
job sheet and the invoice carry the SAME letterhead, so they read as a
pair of papers off the same outfit's printer rather than as two game
screens. The player's business is the constant; the client changes every
night. That's the framing DESIGN.md opens with — "you run a small
landscaping outfit" — finally showing up on the glass.
Sits above the existing .card content, inside the same .card box. */
#hud-card .letterhead {
display: flex; align-items: baseline; justify-content: space-between;
gap: 16px; margin: -4px 0 14px; padding-bottom: 10px;
border-bottom: 2px solid #2f3f4a;
}
#hud-card .letterhead .mark {
font-weight: 800; font-size: 15px; letter-spacing: .22em; color: #fff;
white-space: nowrap;
}
/* The trade line does the work a logo would. Lowercase and spaced out so it
reads as printed rule, not as a heading competing with the mark. */
#hud-card .letterhead .trade {
font-size: 10px; letter-spacing: .13em; color: #6d818e; margin-top: 3px;
}
#hud-card .letterhead .docket { text-align: right; white-space: nowrap; }
#hud-card .letterhead .docket .kind {
font-weight: 700; font-size: 11px; letter-spacing: .18em; color: #8ba0ad;
}
#hud-card .letterhead .docket .no { font-size: 10px; color: #55677a; letter-spacing: .06em; }
/* --- bill-to / job-for block -------------------------------------------
Deliberately the same component on both documents. On the job sheet it's
who you're working for; on the invoice it's who's paying. Same box, and
the fact that it doesn't move between the two is the joke landing. */
#hud-card .billto { margin: 0 0 12px; }
#hud-card .billto .who { font-weight: 700; color: #fff; letter-spacing: .04em; }
#hud-card .billto .addr { font-size: 11px; color: #6d818e; }
/* --- the brief ---------------------------------------------------------
The client's own words. Italic and rule-marked because it is the one bit
of text on the card that isn't the game talking to the player — it's a
person who wants their seedlings alive. It should feel quoted, and it
should be the thing the player's eye lands on after the letterhead,
because it is the only place the stakes are ever said out loud. */
#hud-card .brief {
margin: 0 0 14px; padding: 9px 0 9px 12px;
border-left: 2px solid #3f5561;
color: #c3d0d9; font-style: italic; line-height: 1.6;
}
#hud-card .brief .attrib {
display: block; margin-top: 5px; font-style: normal;
font-size: 10px; letter-spacing: .1em; color: #55677a;
}
/* --- section rules ------------------------------------------------------ */
#hud-card .sect {
margin: 16px 0 7px; font-size: 10px; font-weight: 700; letter-spacing: .18em;
color: #55677a; border-bottom: 1px solid #1c262d; padding-bottom: 5px;
}
/* --- the pay schedule (job sheet) --------------------------------------
This is a QUOTE, not a receipt: it's what the night is worth if you do it
right. It reuses your .row shape so it lines up with everything else, but
the money is dimmed — these are amounts you have NOT earned yet, and they
should not read with the same confidence as the bank line. The conditions
are the point; the numbers are just what the conditions are worth. */
#hud-card .sched .row b { color: #9fb3bf; font-weight: 700; }
#hud-card .sched .cond {
font-size: 10px; color: #55677a; letter-spacing: .02em;
padding: 0 0 5px; border-bottom: 1px solid #1c262d; margin-top: -2px;
}
#hud-card .sched .row.headline b { color: #fff; }
#hud-card .sched .row.max { border-bottom: 0; margin-top: 6px; padding-top: 7px;
border-top: 1px solid #24343d; }
#hud-card .sched .row.max span { color: #8ba0ad; }
#hud-card .sched .row.max b { color: #ffd9a3; }
/* --- the invoice -------------------------------------------------------
Your .ledger already IS an invoice; it just never said so. These rules sit
ON TOP of it — paste them and .ledger keeps every behaviour it has, it
just gains a rule above the total and a due line under it.
.earned/.bad you already colour. The one thing I've added is .void: a
bonus that was on the job sheet and did NOT survive the night. Struck
through, not hidden. The player must see the money they didn't get sitting
in the same column as the money they did — a bonus you never see fail is a
bonus you never learn to protect. This is the single highest-value line in
the whole handover, and it's four CSS declarations. */
#hud-card .ledger .row.void span { color: #55677a; }
#hud-card .ledger .row.void b {
color: #55677a; font-weight: 400; text-decoration: line-through;
text-decoration-color: #7d2b2b; text-decoration-thickness: 1px;
}
/* The amount due. An invoice's whole rhetorical shape is that everything
above is argument and this is the conclusion. Give it the weight. */
#hud-card .ledger .row.due {
margin-top: 8px; padding-top: 9px; border-top: 2px solid #2f3f4a; border-bottom: 0;
align-items: baseline;
}
#hud-card .ledger .row.due span { color: #8ba0ad; letter-spacing: .12em; font-weight: 700;
font-size: 11px; }
#hud-card .ledger .row.due b { color: #fff; font-size: 20px; letter-spacing: .02em; }
#hud-card .ledger .row.due.neg b { color: #ff8f86; }
/* The terms line. Pure flavour, and the cheapest joke on the card: a real
invoice's most boring sentence, printed under a night that nearly killed
you. Left in as its own class so you can delete it in one line if it
doesn't land. */
#hud-card .terms {
margin-top: 10px; font-size: 10px; color: #45566a; letter-spacing: .04em;
}
/* ---- CUT HERE ---------------------------------------------------------- */
/* ---- preview chrome below; NOT part of the snippet --------------------- */
body { margin: 0; background: #060a0d; }
/* padding-top clears the toggle bar so a headless screenshot of any state is
a clean handover artifact rather than one with buttons through the
letterhead. Learned that the hard way on the first render. */
#hud-card { position: static !important; display: grid !important; place-items: start center;
padding: 74px 0 26px; }
.bar { position: fixed; z-index: 60; left: 50%; transform: translateX(-50%); top: 14px;
display: flex; gap: 8px; }
.bar button { font: 12px ui-monospace, Menlo, monospace; padding: 8px 12px; cursor: pointer; }
.lbl { position: fixed; left: 14px; bottom: 12px; z-index: 60;
font: 11px ui-monospace, Menlo, monospace; color: #55677a; letter-spacing: .1em; }
</style>
<style id="hudcss">
/* Lifted verbatim from hud.js so the preview renders in the real card's
skin. Do NOT paste this block — you already have it. It's here so that
what I screenshotted is what you'll actually see. */
#hud-card { inset: 0; z-index: 30; place-items: center;
font: 13px/1.7 ui-monospace, SFMono-Regular, Menlo, monospace; color: #dde5ea; }
#hud-card .card { background: #0d1418; border: 1px solid #2f3f4a; border-radius: 9px;
padding: 22px 26px; min-width: 440px; max-width: 620px; pointer-events: auto; }
#hud-card h1 { margin: 0 0 2px; font-size: 19px; letter-spacing: .16em; color: #fff; }
#hud-card h2 { margin: 0 0 16px; font-size: 12px; font-weight: 400; color: #8ba0ad;
letter-spacing: .04em; }
#hud-card .row { display: flex; justify-content: space-between; gap: 18px; padding: 3px 0;
border-bottom: 1px solid #1c262d; }
#hud-card .row b { font-weight: 700; color: #fff; }
#hud-card .stat { color: #8ba0ad; }
#hud-card .go { margin-top: 16px; padding: 9px 18px; background: #1d3d2a; border: 1px solid #3f7a52;
border-radius: 6px; color: #a8f0b8; cursor: pointer; font: inherit; font-weight: 700;
letter-spacing: .1em; }
#hud-card .verdict { margin: 14px 0 0; padding: 10px 12px; border-radius: 6px; font-weight: 700;
letter-spacing: .05em; }
#hud-card .verdict.win { background: #12321c; border: 1px solid #2c6b3c; color: #7fce6a; }
#hud-card .verdict.lose { background: #3a1618; border: 1px solid #7d2b2b; color: #ff8f86; }
#hud-card .pips { letter-spacing: .5em; font-size: 15px; margin: -6px 0 10px; color: #3f5561; }
#hud-card .pip.now { color: #7ee0ff; }
#hud-card .pip.held { color: #7fce6a; }
#hud-card .pip.lost { color: #ff8f86; }
#hud-card .ledger { margin-top: 14px; padding-top: 10px; border-top: 1px solid #24343d; }
#hud-card .ledger .row b { color: #a8f0b8; }
#hud-card .ledger .row.bad b { color: #ff8f86; }
#hud-card .ledger .row.total { margin-top: 6px; padding-top: 8px; border-top: 1px solid #24343d; }
#hud-card .ledger .row.total b { color: #fff; }
</style>
</head>
<body>
<div class="lbl" id="lbl">JOB SHEET · night 1</div>
<div class="bar">
<button onclick="show('sheet1')">job sheet n1</button>
<button onclick="show('sheet3')">job sheet n3 (corner block)</button>
<button onclick="show('inv_good')">invoice · clean</button>
<button onclick="show('inv_bad')">invoice · carport</button>
</div>
<div id="hud-card" class="on"><div id="slot"></div></div>
<script>
/* ==========================================================================
LANE A — THE TWO DESIGN CALLS. Both are yours. Reasoning is here so you can
overrule it from an informed position rather than just accepting my taste.
====================================================================== */
/* CALL 1 — THE BUSINESS NAME. A letterhead needs an outfit on it, and we've
never named the player's business. My proposal is HARD YARDS.
Reasoning: it's a real Australian idiom ("doing the hard yards" = the
unglamorous grind that nobody thanks you for), it is literally about yards,
and it's the joke the game is ALREADY telling — DESIGN.md says you never
build the right thing, you build the least wrong thing, and THREADS' own
line for the win card is "nobody thanks you for the storm that did nothing.
That's the job." That is the hard yards. It's one word at letterhead size,
which is what a mark needs to be, and it stays funny on an invoice for $180
of someone else's carport.
What I deliberately did NOT do: name it after the player. A surname mark
("<NAME> LANDSCAPING") is the most authentic thing a one-person outfit
would actually have, and it's the better answer the moment the player has a
name — but naming the player is a much bigger call than a letterhead, and
it's not mine to make in a juice pass. If Sprint 12 names them, this
constant is the only thing that changes. */
const BUSINESS = { mark: 'HARD YARDS', trade: 'landscaping · shade · drainage' };
/* CALL 2 — THE ABN. An Australian invoice without an ABN is not an invoice;
it's the single detail that makes the paper read as real to anyone who's
ever received one. But any plausible 11-digit number is potentially a REAL
business's ABN, and I'm not printing a stranger's tax identity on a game
card. So: sequential digits, which every Australian reader parses instantly
as "example" — the same convention as 555 phone numbers. Deliberate, not
lazy. Please don't "fix" it into something realistic. */
const ABN = 'ABN 12 345 678 901';
/* ==========================================================================
Lane A: swap this for your real night entries. The markup doesn't care what
the rows are — it lays out label/value pairs and a brief. Shape below is
what SPRINT11 gate 2 asks for: client + brief + pay schedule, as data.
Note the pay schedule is a LIST, not three named fields. base/garden/
no-collateral is what Sprint 11 specifies, but a list costs nothing now and
means a site that wants a fourth condition ("nothing tied to the carport")
doesn't need a card change — which, per SPRINT10, is the whole point of
sites being data.
====================================================================== */
const NIGHTS = {
sheet1: {
kind: 'sheet', night: 1, nights: 5, docket: 'JOB 1041',
client: 'R. & M. Henderson', addr: '14 Kurrajong St — backyard',
brief: 'The seedlings have to be alive when we get back. We fly out Friday and the beds are three weeks in.',
attrib: '— R. Henderson, on the phone, Monday',
storm: 'SOUTHERLY BUSTER', night_flag: false,
lines: ['gusting to 21 m/s, building after 9', 'rain: steady, 12 mm', null],
note: 'Nothing tonight can hurt the garden. Learn the anchors.',
bank: 95,
sched: [
['callout fee', '$80', 'the night, rigged and stood up', true],
['garden bonus', '$40', 'bed above 80% at dawn'],
['clean site', '$25', 'nothing of theirs broken'],
],
max: 145,
},
sheet3: {
kind: 'sheet', night: 3, nights: 5, docket: 'JOB 1043',
client: 'D. Okafor', addr: '2 Bight Rd — corner block',
site: 'the corner block — two streets, no shelter, and a carport that wants to be an anchor',
brief: "Whatever you tie to, don't tie to the carport. It's older than it looks and I'm not paying to put it back up.",
attrib: '— D. Okafor, written on the job docket',
storm: 'EARLY BUSTER', night_flag: true,
lines: ['gusting to 32 m/s, funnelled', 'rain: heavy · hail forecast', 'confidence: ±4 m/s (lead 0)'],
note: 'Hail is what kills a garden, and cloth stops hail. Get the sail over the bed.',
bank: 210,
sched: [
['callout fee', '$80', 'the night, rigged and stood up', true],
['garden bonus', '$40', 'bed above 80% at dawn'],
['clean site', '$25', 'nothing of theirs broken'],
],
max: 145,
},
inv_good: {
kind: 'invoice', night: 1, docket: 'INV 1041',
client: 'R. & M. Henderson', addr: '14 Kurrajong St — backyard',
subtitle: 'The bed came through it. So did the rig.',
rows: [['garden', '94%'], ['corners intact', '4/4'], ['what got through', 'nothing'],
['hardware lost', 'none'], ['collateral', 'none']],
ledger: [
['callout fee', '+$80'],
['garden bonus', '+$40'],
['clean site', '+$25'],
['gear recovered', '+$18'],
['spent on the rig', '$62', 'bad'],
],
due: '+$101', bank: '$95 → $196',
verdict: "Nobody thanks you for the storm that did nothing. That's the job.",
win: true,
},
inv_bad: {
kind: 'invoice', night: 3, docket: 'INV 1043',
client: 'D. Okafor', addr: '2 Bight Rd — corner block',
subtitle: 'The garden made it. The carport went to the neighbours.',
rows: [['garden', '86%'], ['corners intact', '3/4'], ['what got through', 'some hail'],
['hardware lost', '$34'], ['collateral', 'the carport ($180)']],
ledger: [
['callout fee', '+$80'],
['garden bonus', '+$40'],
['clean site', '$25', 'void'],
['gear recovered', '+$6'],
['the carport', '$180', 'bad'],
['spent on the rig', '$71', 'bad'],
],
due: '$125', neg: true, bank: '$210 → $85',
verdict: 'You took the carport with you.',
win: false,
terms_extra: "They'll want that back up by Thursday.",
},
};
/* --- renderers. Lane A: these are throwaway — your hud.js already builds the
rows; all you need from here is the MARKUP SHAPE and the class names. ---- */
function letterhead(n) {
return `<div class="letterhead">
<div>
<div class="mark">${BUSINESS.mark}</div>
<div class="trade">${BUSINESS.trade}</div>
</div>
<div class="docket">
<div class="kind">${n.kind === 'sheet' ? 'JOB SHEET' : 'TAX INVOICE'}</div>
<div class="no">${n.docket} · ${ABN}</div>
</div>
</div>`;
}
function billto(n) {
return `<div class="billto">
<div class="who">${n.client}</div>
<div class="addr">${n.addr}</div>
</div>`;
}
function brief(n) {
return `<div class="brief">${n.brief}<span class="attrib">${n.attrib}</span></div>`;
}
function pips(night, nights) {
return Array.from({ length: nights }, (_, i) => {
const done = i < night - 1, now = i === night - 1;
return `<span class="pip ${now ? 'now' : done ? 'held' : ''}">${now ? '◆' : done ? '●' : '·'}</span>`;
}).join('');
}
function renderSheet(n) {
const site = n.site ? `<div class="stat" style="color:#7ee0ff">${n.site}</div>` : '';
const sched = n.sched.map(([k, v, cond, head]) =>
`<div class="row ${head ? 'headline' : ''}"><span>${k}</span><b>${v}</b></div>
<div class="cond">${cond}</div>`).join('');
return `<div class="card">
${letterhead(n)}
${billto(n)}
${brief(n)}
<h1>NIGHT ${n.night} OF ${n.nights}</h1>
<div class="pips">${pips(n.night, n.nights)}</div>
<h2>${n.storm}${n.night_flag ? ' · NIGHT' : ''}</h2>
${site}
${n.lines.filter(Boolean).map((l) => `<div class="stat">${l}</div>`).join('')}
<div class="sect">WHAT IT PAYS</div>
<div class="sched">
${sched}
<div class="row max"><span>the night, done right</span><b>$${n.max}</b></div>
</div>
<div class="row" style="margin-top:14px"><span>in the bank</span><b>$${n.bank}</b></div>
<div class="stat" style="color:#8ba0ad;margin-top:10px">${n.note}</div>
<button class="go">RIG IT</button>
<div class="terms">Quoted on the forecast at time of callout. Weather is not a variation.</div>
</div>`;
}
function renderInvoice(n) {
const rows = n.rows.map(([k, v]) => `<div class="row"><span>${k}</span><b>${v}</b></div>`).join('');
const ledger = n.ledger.map(([k, v, cls]) =>
`<div class="row ${cls || ''}"><span>${k}</span><b>${v}</b></div>`).join('');
return `<div class="card">
${letterhead(n)}
${billto(n)}
<h1>MORNING · NIGHT ${n.night} OF 5</h1>
<h2>${n.subtitle}</h2>
${rows}
<div class="ledger">
${ledger}
<div class="row due ${n.neg ? 'neg' : ''}"><span>AMOUNT DUE</span><b>${n.due}</b></div>
<div class="row total"><span>in the bank</span><b>${n.bank}</b></div>
</div>
<div class="verdict ${n.win ? 'win' : 'lose'}">${n.verdict}</div>
<button class="go">NIGHT ${n.night + 1} →</button>
<div class="terms">Payable 14 days. ${n.terms_extra || 'Thank you for your business.'}</div>
</div>`;
}
function show(which) {
const n = NIGHTS[which];
document.getElementById('slot').innerHTML =
n.kind === 'sheet' ? renderSheet(n) : renderInvoice(n);
document.getElementById('lbl').textContent =
(n.kind === 'sheet' ? 'JOB SHEET · night ' : 'INVOICE · night ') + n.night;
}
/* ?v=sheet3 etc. — so a state is a URL. That's how the screenshots in
tools/jobsheet/*.png were taken (headless), and it means you can link me a
broken one rather than describe it. */
show(new URLSearchParams(location.search).get('v') || 'sheet1');
</script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

View File

@ -85,6 +85,8 @@ const ASSETS = [
'beam_anchor_01', 'beam_anchor_02', 'post_anchor_01', 'post_anchor_02'] },
{ name: 'carport_01_wrecked', h: [1.9, 2.45],
nodes: ['footings', 'posts', 'beams', 'roof_down'] },
{ name: 'bike_kid_01', h: [0.60, 0.84],
nodes: ['wheel_rear', 'wheel_front', 'frame', 'bars'] },
];
function sizeOf(gltf) {
@ -185,6 +187,37 @@ export default async function run(t) {
assert(Math.abs(b.min.y) < 0.05, `wreck sits at y=${b.min.y.toFixed(2)} — roof sheet through the ground?`);
});
// A bike does not stand up on its own, so the lean is baked into the GLB
// rather than left as a rotation for Lane A to remember. That makes the lean
// DIRECTION an export-time fact, and export-time facts about axes are exactly
// what this file exists to catch (see the header): Blender is Z-up and the
// bike leans +Y there, but the exporter maps (x,y,z) -> (x,z,-y), so by the
// time A places it in three.js it leans -Z.
//
// Nothing else can catch this. The Blender-side verify re-imports through the
// same mapping and round-trips green, and the bounding box is the identical
// size whichever way the thing tips. If someone flips the export convention or
// quietly un-tilts the bike, the yard still loads, the dims still pass, and the
// only symptom is a bike leaning into open air next to a fence — which reads as
// a physics bug and would get chased in the wrong lane entirely.
t.test('the kid\'s bike leans, and it leans toward -Z (fence side)', () => {
const g = loaded.get('bike_kid_01');
assert(g, 'bike_kid_01 did not load');
const bars = g.scene.getObjectByName('bars');
assert(bars, 'bike_kid_01 has no bars group to measure the lean by');
// The bars are the top of the bike, so they travel furthest when it tips.
const b = new THREE.Box3().setFromObject(bars);
const z = (b.max.z + b.min.z) / 2;
assert(z < -0.05, `bars sit at z=${z.toFixed(3)} — the bike is upright or leaning the wrong way`);
// ...and it must still be ON the ground: the tyre contact points are the
// pivot, so an un-pivoted lean would push the wheels through or above it.
const all = new THREE.Box3().setFromObject(g.scene);
assert(Math.abs(all.min.y) < 0.03,
`bike sits at y=${all.min.y.toFixed(3)} — wheels through the ground or floating`);
});
t.test('carport anchors stay a trap — the corner block has no good tie-off', () => {
const g = loaded.get('carport_01');
assert(g, 'carport_01 did not load');

Binary file not shown.