Skip to content
AstroCraft Docs
On this theme

The TV Set

Every page of TVfolio renders inside a television. Sections/Global/TvSet.astro is the single component that draws it, and understanding its unit system is the difference between customizing this theme confidently and fighting it.

The set is not an SVG, and that is the first decision worth knowing. The screen has to hold live, scrollable, focusable content, so the cabinet is built from real boxes and CSS. Only three genuinely photographic textures ship as files — brick, wood grain and cabinet grain. Everything else Figma rasterized (body shading, the specular top edge, the scanlines) turned out to be a pure vertical gradient with no horizontal variation, so it is a gradient here: same pixels, roughly 90 KB lighter.

The anatomy

.tv-stage            the room — wall texture, then the light falling off at the corners
└── .tv-scene        the set; declares the unit system, is the container query root
    ├── .tv-table    the surface it stands on (wood grain, the set's own shadow)
    └── .tv-chassis  the cabinet — grain, shading, specular edge
        ├── .tv-bezel
        │   └── .tv-screen        the tube
        │       ├── __chrome--top      slot="screen-top"
        │       ├── __viewport         the default slot — the picture
        │       ├── __chrome--bottom   slot="screen-bottom"
        │       └── __vignette / __scanlines / __reflection / __glass
        └── TvControlPanel    VOL knob, LIGHT/DARK switch, four social caps, power LED

The three units

This is the core of the design, and it is three custom properties declared on .tv-scene.

--tv-u: calc(100cqw / 1120);
--tv-c: max(0.583px, var(--tv-u));
--tv-t: max(1px, var(--tv-u));

--tv-u is one Figma pixel, in container-query units. The desktop frame draws a 1120px-wide cabinet, so 100cqw / 1120 makes every N * var(--tv-u) dimension scale as one physical object. A 32u border radius stays proportionally a 32u border radius at any cabinet width.

It is never used on .tv-scene itself, because a container cannot query itself.

--tv-c is the controls unit. Identical to --tv-u until the cabinet shrinks past phone size, then it stops — because a knob scales with the cabinet but a thumb does not. The 0.583 is not a fitted constant: it is the median of the ratios between the two Figma frames’ twenty control dimensions, which is the designer’s own answer to “how much does a control shrink”, measured. Mean error against the drawn mobile values is 0.6px.

--tv-t is the type unit. Screen text does not scale the way cabinet parts do. Comparing the two frames, every size in the 9–15u band is drawn at the same pixel value on the 374 cabinet as on the 1120 one — 9→9, 11→11, 12→12, 14→14, 15→15, and the 32u channel number too. Five independent confirmations that the answer is 1.0, which is why the floor is 1px: the unit holds at the drawn pixel below the design cabinet and grows with the set above it.

Anything reading --tv-t needs no floor of its own. Typography covers the scale built on it.

The floor idiom

You will see this expression everywhere in the theme:

border-radius: max(24px, calc(32 * var(--tv-u)));

Read it as: the desktop frame’s value, scaled — but never below the mobile frame’s own measurement. One expression per dimension instead of a second value set behind a breakpoint, and it holds at every width in between rather than only at the two sizes the designer drew.

The floors are measured, not guessed, and the source is emphatic about why. The viewport insets were originally eyeballed at 52 and 48px and both were too small: the channel bar is 59px tall at a 374 cabinet, because its type floors while its box shrinks, so a 52px inset let the bar sit on the first line of the picture. The home page hid it (its first block is a centred figure); the About page’s full-width section head is what exposed it. The lesson the comment draws — read the mobile frame’s real nodes, never infer from a screenshot — is the one to carry into your own edits.

Shadows carry the unit too, which is less obvious than it sounds. An absolute-pixel shadow reaches proportionally further in as the set gets smaller; measured against the light-mode export, that alone made the tube’s edges about ten levels too dark.

The two chrome slots

The tube takes content in three places:

<TvSet>
  <TvScreenBar slot="screen-top" channel={channelFor(page)} label={page} />
  <FastextBar slot="screen-bottom" current={page} />

  <!-- the default slot: the picture -->
</TvSet>

The chrome is pinned to the tube, not placed in the scrolling viewport, because on a real set the channel bar and the fastext keys do not scroll with the page — they belong to the receiver, not the transmission.

The viewport is inset, not padded. Padding would let the picture scroll on under the channel bar; the design clips it at the bar’s baseline instead. And the room the insets reserve is driven by :has(), so a route that supplies no chrome gets a plain tube with no dead space:

.tv-screen:has(.tv-screen__chrome--top > *) .tv-screen__viewport {
  top: max(90px, calc(100 * var(--tv-u)));
}

The two chrome layers sit on opposite sides of the glass overlays. The bottom bar takes z-index: 2 so the vignette does not dim the fastext keys — they are the receiver’s own UI, not part of the transmitted picture. The top bar takes the opposite call and stays under the glass.

The glass stack

Four absolutely-positioned, pointer-events: none layers sit over the picture:

  • __vignette — a radial darkening, at --crt-vignette-opacity
  • __scanlines — 2px on, 2px off, at a fixed pitch rather than a scaled one, so it never moirés against the display’s own pixel grid
  • __reflection — a 149.2° linear sheen
  • __glass — the curve of the glass, read as a rim highlight plus a deep inner falloff, entirely in box-shadow

Both dimmer knobs — --crt-scanline-opacity and --crt-vignette-opacity — ease off on a lit screen, because black scanlines and a black vignette read far louder over white than over unlit glass. They are theme variables; see Colors & Theming.

If the CRT effect is too strong for your taste, those two variables in global.css are the first place to look. Turning both to 0 leaves a clean panel inside the cabinet without touching a component.

The one breakpoint

There is exactly one media query in the file, and everything above it is mobile-first:

@media (width >= 64rem) {
  .tv-scene {
    width: min(100%, 1440px, calc((100lvh - 5rem) * 1120 / 821));
    height: auto;
    aspect-ratio: 1120 / 821;
  }
}

Below lg, the set simply fills the space it is given and the tube takes whatever height is left over. That is not a scaled-down desktop layout — the two Figma frames are genuinely different objects: the desktop cabinet is 1120×821 landscape, the mobile one 374×812 portrait. Filling the space works at any phone or tablet size rather than only at the one width someone drew.

At lg the designed aspect ratio returns, aspect-ratio fixes the height, the flexible tube resolves to exactly the designed 604u, and every max() floor falls away — at that size the proportional value is always the larger one.

Two numbers in that min() are worth explaining:

The ceiling is 1440, not the drawn 1120. The frame is drawn at 1440×900 where the cabinet fills 1120 — but horizontal padding stops growing at 10rem, so on a 1920 screen there is 1600px of room and a cabinet pinned to 1120 leaves the set marooned in wall. Because everything inside is N * var(--tv-u), the cabinet’s width is the type scale: at 1120 the smallest legends render at 11px, which is genuinely hard to read at arm’s length. Letting it reach 1440 puts them at 14.1px. This changes nothing at the drawn size — at 1440×900 the height term binds at 1119, exactly as before.

lg rather than md. At 768px the proportional legends land near 7px, so the fill-the-viewport layout is genuinely the better one right up to 1024.

The stage padding

padding: clamp(1rem, 2.8vw, 2.5rem) clamp(0.5rem, calc(13.14vw - 29.2px), 10rem);

That horizontal term is a line fitted through the two frames’ own measurements: the mobile export is 428 wide with a 374 cabinet (27 each side), the desktop one 1440 with a 1120 cabinet (160 each side). 13.14vw - 29.2px gives 27.0 at 428 and exactly 160 at 1440, so the cabinet lands on its drawn width at both sizes and interpolates between them.

One global rule you would not expect

global.css reserves scrollbar space site-wide to prevent layout shift — except on a page that cannot scroll:

html {
  scrollbar-gutter: stable;
  &:has(.tv-stage) { scrollbar-gutter: auto; }
}

.tv-stage is exactly 100lvh and clips, so the reserved ~15px is pure loss — and it comes off the width of the set, which is how a 428px phone rendered a 374-wide cabinet as 359. The rule lives beside the declaration it answers rather than being reached up to from the component that noticed.

The control panel

TvControlPanel.astro draws the VOL knob, the LIGHT/DARK switch, four social push caps and the power LED. Two of those are real controls rather than decoration.

The LIGHT/DARK cap is a genuine <button>. It flips the document theme through the same theme.ts contract the ui/theme-toggle primitive uses, with aria-pressed kept in sync. Note that it imports the module, not the component — the primitive speaks the document’s visual language, and the cap speaks the cabinet’s, so it shares the logic and draws itself.

The four social caps are live only when configured. A platform present in siteData.socials renders an <a>; one left out stays an inert moulding rather than a dead link. That is the same call navData makes for keys and channels, and the production gate in siteData.json.ts enforces the third case — a cap pointing at a platform homepage fails a production build.

The knob’s turned-aluminium ramp is six palette tokens rather than six inline hex values, because the theme’s Tailwind rule forbids raw colour in markup and the brand exception covers a third party’s mark, not this knob.

Customizing the cabinet

The materials are all tokens in tailwind-theme.css--color-chassis-shell, --color-table-top, --color-chassis-metal-0 through -5, and so on. They are fixed across both themes: comparing the design’s two modes token by token, not one of them moves, because a walnut cabinet has no dark variant. Recolouring the set is editing that block.

The tube is the opposite case — a CRT is a display, so every crt/* token flips. Those live with the semantic variables in global.css.

If you want to change the proportions, change 1120 in --tv-u and the aspect-ratio together, and expect to re-measure the floors. The floors are the part that does not survive a proportion change, because each one is a real measurement from a real frame rather than a formula.

NEXT STEPColors & Theming