Foundations · 15
The Line of Sight
Two materials in this system already answer to where the reader is — the foil turns its rake as type travels up the screen, and the field slides its pool of light down a section as the reader passes it. Both were built on a line-of-sight model, and both read only one of that model's two axes. This chapter is the other axis, the rule that governs what may be allowed to move with it, and the one file in the system that measures a reader rather than an element.
The model, and the half of it that was missing
Holographic stock is not a picture of a spectrum. It is a surface that hands you a
different part of one from every angle, which is why a foil catalogue is photographed
tilting and a printed one looks dead. The system's answer, stated in
Colour and built in base.css, is that
you see the material as drawn when you are square to it — 116.57° is
the head-on rake, and the rake flattens towards 90° as the line of sight goes oblique.
A view() timeline supplies that obliquity for free on one axis: type at
the middle of the viewport is level with the reader's eye, type at either edge is not.
But a view timeline measures the element, and the model is about the
reader. Vertically the two coincide, because an eye at the middle of the
screen is the only assumption available to a stylesheet. Horizontally they come apart
the moment a viewport is wider than a headline: on a 1920 frame, a title at the
vertical centre and hard against the left margin was being drawn perfectly head-on
while the reader sat a foot to its right.
Live
Move the pointer left and right across the panel. The type is not moving and no colour is changing: the angle the light crosses the letterforms at opens as you come square to them and flattens as you go oblique. Scroll instead of moving sideways and the other axis does the same thing. The readout is the two numbers the foil is currently being drawn with.
Jetzt Projekt starten!
- ‑‑sight‑v
- 1.000
- ‑‑sight‑h
- 1.000
- rake
- 116.57°
- state
- resting
resting and stays there, this browser
or this reader is on one of the fallback paths — a coarse pointer, reduced motion, or
no support for scroll-driven animations. That is not a failure state. It is the
designed still, and it is what the two shipping pages render for everyone the script
declines to run for.
The field, on the same source
The pool of light on the ground takes the same measurement as
a position rather than as an angle. Vertically it is already scrubbed off the section's
view() timeline; horizontally it now stands where the reader does.
the floor lights where you stand
The composition
Two squarenesses, each a number in [0, 1], and they multiply.
--foil-angle: calc(var(--angle-square) + var(--angle-b) * var(--sight-v) * var(--sight-h));
Summing two swings could leave the band; a product of two numbers in
[0, 1] cannot, so the rake stays inside 90°–116.57° by
construction. Every contrast figure the foil has ever been measured at therefore
holds unchanged and unre-measured — turning a linear gradient changes where each stop
lands, never which stops are present, and the whole ramp is still inside the clip box
at every frame.
The product is also the physically true composition. A foil is head-on only when you are square to it on both axes; being level with a sheet you happen to be standing beside does not let you see it face on. Two independent swings added together would have said the opposite — that obliquity on one axis can be paid off by squareness on the other.
base.css has always said the 26.57° between the endpoints is the isometric
angle itself. The keyframes said 90deg and 116.57deg and left
that a coincidence a later edit could quietly break. It is now written as
--angle-square + --angle-b: move the isometric angle and the foil follows
it.
What may answer to sight
This is the part that keeps the idea from becoming a toy. A pointer-reactive surface is the easiest thing in the system to overspend, and the constraint is not taste — it is that the reader's position must never be able to change how legible anything is.
| May move with sight | Why it is safe |
|---|---|
| The angle a gradient is drawn at | Turning a ramp moves its stops; it adds and removes none of them. The worst sample is unchanged. |
| The position of a mask or a light | A mask multiplies by an alpha at or below 1, so every frame is at or under the unmasked floor already measured. |
| May not | Why not |
|---|---|
| Colour, ink strength, opacity of text | Contrast becomes a function of where a mouse is. There is no frame to measure, and a keyboard reader has no way to reach the legible one. |
| A composited specular or bloom over type | Already rejected for the vertical axis, for the same reason: it lifts contrast on the lit half and drops it on the ink half. |
| Position, size or layout of anything | Content that moves away from the pointer cannot be clicked, and content that moves towards it is a trap. Sight moves light, never geometry. |
| Anything a reader must perceive to use the page | Half of all readers have no fine pointer at all. Sight is the last layer, never the carrier. |
Anatomy
| Property | Written by | Range | Rest | Moves |
|---|---|---|---|---|
--sight-v | view() keyframes | 0 → 1 → 0 | 1 | the foil's rake, vertically |
--sight-h | cf-sight.js | 0 … 1 | 1 | the foil's rake, horizontally |
--field-light-y | view() keyframes | −20% … 120% | 50% | the pool, down the section |
--field-light-x | cf-sight.js | 0% … 100% | 50% | the pool, across the section |
--field-light-x is the only one of the four declared
inherits: true, and the asymmetry is forced rather than chosen: the pool is
painted by .cf-ground::before, a script cannot set a property on a
pseudo-element, so the only way in is to declare it on the originating element and let
the pseudo inherit it.
Markup
None. Nothing opts in with a class or an attribute — the script finds the two materials that already exist. All a page does is load the file, last, after the stylesheets:
<script src="/design-system/assets/js/cf-sight.js" defer></script>
cf-stream.js established.
Delete the tag and every page renders exactly as it did before the file existed — not
approximately, but to the digit, because --sight-h and
--field-light-x rest at the identity values of the expressions that consume
them.
Every path
| Condition | Vertical | Horizontal | What is rendered |
|---|---|---|---|
| Fine pointer, motion allowed | runs | runs | both axes |
| Coarse pointer (phone, tablet) | runs | rests | the vertical swing, as before |
| Pointer has left the window | runs | eases back to rest | the vertical swing, as before |
prefers-reduced-motion | rests | rests | the designer's 116.57° still |
| No scroll-driven animations | rests | rests | the designer's 116.57° still |
| Script blocked, deleted or failed | runs | rests | the vertical swing, as before |
No @property | rests | rests | solid type, no clip — the pre-foil fallback |
| neither | solid Schwarz type; the field is not printed | ||
| Forced colours | neither | CanvasText; the field is removed | |
The script gates itself on CSS.supports('animation-timeline', 'view()') —
the same test base.css puts on the swing — so the stylesheet and the file
cannot disagree about whether the vertical half is running. It re-checks on every
media-query change and puts both materials back the moment a condition stops holding.
What it costs
- One passive
pointermovelistener, coalesced into a singlerequestAnimationFrame. A pointer emits far more moves than a frame can use. - Only what is on screen is tracked. One
IntersectionObserverholds the live set; a page with four foil moments measures the one the reader can see. - Both values are quantised, so most frames of a gesture write nothing and a still pointer writes nothing at all. The rake takes 1/64 of its swing — 0.42°, under the width of the antialias on a stop boundary. The pool takes whole percent, which is roughly 14 px of a section at 1920 and well inside the falloff of a 1152 px ellipse.
- The pool's quantisation is the coarser of the two on purpose. Its property is inherited, so each write invalidates a subtree; the foil's is not, so each write invalidates one element.
- Both are damped by a 120 ms transition (
--duration-fast), which is what turns a quantised value into a material with weight rather than a value that teleports between stops.
Rules
- Sight is the last layer. Nothing may be legible only because of where a pointer is, and nothing may become illegible because of it. The measured contrast floors in this system are floors under every frame of this, by construction rather than by inspection.
- Angle and position only. The two-row table above is the whole permitted vocabulary. A third row needs a contrast argument of the same shape before it is written.
- It never touches an interactive surface. A control whose light
shifts while the reader is aiming at it is a different thing from a headline catching
it.
.cf-btn--solidreads the static foil from:rootand is deliberately excluded, the same way it is excluded from the vertical swing. - Never a substitute for hover or focus. Sight is atmosphere. State is state, and it is drawn by the contour and the ring, as it always was.
- Do not put the source on
:root. An animated inherited custom property on the document element restyles the whole document once per frame. The measurement is taken per element, on the handful that are on screen.
Three ways to get it wrong
- Making it bigger so it reads. The correct amount of this is the amount that is only noticed on the second look. If it cannot be seen at 26.57° of rake, the answer is that the reader is looking at the type rather than at the effect, which is the right outcome.
- Adding a tilt. A card that leans towards the pointer is the default in every holographic demo on the web, and it is skeuomorphism: this brand does not draw objects in perspective, it constructs them in 2:1 isometry, and an element that rotates has left the lattice.
- Reading a touch as a position. A finger has no position between
taps. Driving light from touch coordinates makes the page flinch every time it is
scrolled, which is why
(pointer: fine)is a gate and not a preference.