Relationships
Branches 3
Context
The v0.20.0 surface model (SPEC-086–090) gave media surfaces a frame-aspect knob — aspect-ratio: var(--frame-aspect) on the media zone. It works well for images, but two limitations surfaced in real use:
Aspect is a single, fixed ratio. Authors routinely want a different shape at different sizes — a hero that's
21/9on a wide layout but4/3on a phone. A lone ratio can't express that.Aspect is the wrong tool for content guests.
aspect-ratiois width-driven: it derives height from width. That's right for media you crop to a shape, but wrong for a guest with an intrinsic content extent — acodegroup,datatable, or chart. The motivating case: acodegroupin a card should show a generous number of rows on a wide screen and only a few (scrolling the rest) on mobile. Forcing an aspect-ratio onto it produces a tall near-empty box on wide cards and a cramped sliver on narrow ones — neither tracks "show ~N rows."
What already exists in the codebase, and shapes this decision:
- A
framespreset registry exists at the theme layer —ThemeConfig.frames: Record<string, FramePresetDefinition>(packages/transform/src/types.ts), engine-resolved withextendsinheritance (resolveFrameChrome,engine.ts), and merged base-over-overrides (merge.ts).FramePresetDefinitionalready carriesaspect,shadow,displace,offset,oversize,place,anchor,extends. tintsandbackgroundsare already project-definable inrefrakt.config.jsonand merge project-over-theme, last wins (packages/types/src/theme.ts).framesis not exposed at the project level — the only gap in an otherwise-symmetric trio.- Named scales (
sm/md/lg/xl) are an established vocabulary but are currently fixed rems (e.g..rf-card[data-height="md"] { min-height: 18rem }), not responsive. bentoalready has an extent knob —content-height/row-height(named scale → fixed rem, with--cell-content-overflow: hidden). It is bento-only and lives at the wrong layer to helpcard.- Guests already self-declare capabilities —
interactive: truein rune config; guests opt out of media-zone clipping by self-declaration. The knowledge sits with the guest (the composability contract's governing asymmetry). - The media zone already establishes a container context (
container-typeis set on it), so container-query units (cqi) and@containerrules are available without new plumbing.
Decision
Adopt a coherent model with the following parts:
Two named sizing intents, not one knob.
- Proportional —
frame-aspect(width→height). For media cropped to a shape. - Extent — a media-zone height knob (working name
media-height), bounded with overflow handling (scroll for code, fade/clip otherwise). For content guests.frame-aspectandmedia-heightboth size the media zone, so they are mutually exclusive — setting both warns, extent wins.media-heightis lifted into the shared media-layout vocabulary (alongsidemedia-position/media-ratio, shared bycard/bento-cell/recipe). It generalises the extent pattern bento pioneered withcontent-height(a named height + overflow), but as a distinct, zone-named sibling, not a replacement:media-heightsizes the media zone,content-heightcaps the body zone — one height knob per zone. How the two zones (plusframe-aspectand the outer track) interact is the Sizing precedence model below.
- Proportional —
Responsiveness lives in the named token/preset layer, resolved against the container — never as per-breakpoint values in content. Named scales become responsive by construction via
clamp()in container units (cqi), soheight="md"/media-height="md"means "generous on a wide card, compact on a narrow one" with zero breakpoint authoring. The theme owns the curve.A small, fixed set of container thresholds, owned by the theme. The responsive vocabulary reuses the existing named scale (
sm/md/lg), expressed as a bounded set of container-width thresholds defined as theme tokens — not arbitrary per-preset thresholds in project config. This keeps@containercodegen tractable and the size vocabulary consistent across every knob.Reuse the
framesregistry for named aspects; do not add anaspectsregistry. A named aspect is a thin frame preset. The application grammar mirrorsbgexactly: a raw value inline (frame-aspect="16/9", likebg-gradient="to-b") versus a named preset (frame="hero", likebg="brand-fade"). One registry, one named-application verb.Expose the
framesregistry at the project level inrefrakt.config.json, merged project-over-theme (last wins) — exactly astints/backgroundsalready are. Frames stay escape-hatch-free (a bounded facet vocabulary, no raw-CSSstylelikebackgroundscarry), so config-defined frames remain portable and theme-swappable.Guests self-declare their default sizing intent (a capability, e.g.
mediaFit: 'aspect' | 'extent'), read name-agnostically by the container — the same pattern asinteractive: true. Image/figure/gallerydefault to aspect;codegroup/datatable/chart/text default to extent. So a codegroup-in-card "just works" (extent + scroll, responsive) with no authoring, andframe-aspect/media-heightremain explicit overrides.Ship in two phases (each independently useful):
- Phase 1 (small): expose project-level
frameswith the existingaspect: string. Static named aspects/frames in config — pure data, consistent withtints/backgrounds, tiny diff. - Phase 2: the responsive layer — a container-keyed structured form for
aspect(over the fixed threshold set) +@containercodegen, responsive namedheight/media-heightclamps, the generalisedmedia-heightextent knob, and guestmediaFitdefaults.
- Phase 1 (small): expose project-level
Sizing precedence
Multiple height knobs can apply to one surface — frame-aspect and media-height on the media zone, content-height on the body zone, and an outer track in bento. Rather than treat them as rival peers, the model is an explicit precedence ladder with one authoritative height knob per zone and a declared fill priority, so they compose instead of fight.
The ladder (outer wins):
The outer envelope is the hard clamp. A bento
row-height/row-span, or a card's ownheight/aspectin cover mode, bounds the cell. Nothing inside may force it taller — overflow is clipped/scrolled, never spilled (the same host-owned-clip principle the frame model uses). Where there is no envelope (a card in prose), the cell height is intrinsic: media + body + footer stack.One height intent per zone.
- Media zone:
frame-aspectXORmedia-height(extent wins, warn). - Body zone:
content-heightis a max-height cap, not a size claim — it bounds the text and scrolls/clips the rest; it never reserves a fixed slice thatmedia-heightmust subtract from. This is the key move that stops the two zones reading as rivals.
- Media zone:
Fill priority — the explicit zone is fixed, the other flexes.
media-heightonly → media fixed, body flexes (capped bycontent-height).content-heightonly → body fixed (capped), media flexes to fill.- Both set → both fixed; if they exceed the track, the body scrolls (
content-heightalready implies overflow) and the engine warns; if there is slack, the media zone absorbs it (media is the visual hero, and this keeps the body pinned at its requested cap).
Disjoint authority (tracked vs untracked). This makes media-height and content-height primary in different contexts, which is what dissolves the apparent bento conflict:
- Tracked (bento cell): the track-native tools are primary —
row-height/span (envelope),content-height(body cap),frame-aspect+ size-derived placement (media shape).media-heighthere is a clamped request (min(media-height, available)); if it cannot be honored it warns and the body absorbs the difference. - Untracked (a card in prose): no envelope, so
media-heightis the authoritative media sizer — the motivating codegroup-in-card case.
Enforcement. The transform engine surfaces the conflicting/over-constrained cases as build warnings — frame-aspect + media-height together, and media-height that cannot be honored within a track — the same way it already warns for an interactive guest in a linked tile. Conflicts appear at build, not as silent mis-renders.
Rationale
- Container, not viewport, is the only correct axis for a composable surface. This is the load-bearing insight: it's why per-breakpoint content values are a trap rather than merely verbose, and why the named scales should resolve in
cqi/@container. - Reuse over invention. Frames already exist with
extendsand engine resolution;tints/backgroundsalready prove the project-level registry pattern; bento already proves the extent-with-overflow concept; guests already self-declare capabilities. Each decision closes a gap in an existing pattern rather than adding a parallel one — minimal new surface area, maximal consistency. - A small fixed threshold set keeps the responsive story bounded: codegen is finite, the
sm/md/lgvocabulary stays uniform across height/aspect/extent, and projects can't drift into bespoke breakpoint systems. Flexibility lost is the long tail of arbitrary thresholds, which the named-preset +extendsmechanism can usually approximate anyway. - Phasing lets the cheap, high-value win (config-definable static frames) land immediately without waiting on the
@containercodegen. - One height knob per zone + a fill priority dissolves the apparent conflict. The risk was a three-way fight between
media-height,content-height, andframe-aspectin a bento cell. Naming the precedence — outer envelope clamps, one height intent per zone,content-heightis a cap (not a size claim), the explicit zone is fixed and the other flexes — means the only true collision is two knobs on the same zone (frame-aspect/media-height), which is already exclusive. The rest compose.
Consequences
- Two specs follow (to be drafted): Spec A — project-level
framesregistry + named static aspects (Phase 1); Spec B — responsive named scales (container-keyedaspectcurves over the fixed threshold set,@containercodegen, responsiveheightclamps, themedia-heightextent knob, and guestmediaFitdefaults) (Phase 2). Likely targeted at v0.21. - New mechanism in Phase 2: a responsive aspect cannot be a single custom- property value, so the build must emit
@containerrules per responsive preset — analogous to existing tint/background CSS generation, but new for frames. - A new guest capability (
mediaFit) must be threaded through core and plugin rune configs; guests that set neither inherit a sensible default by kind. content-heightis reframed (not redefined) as a body-zone max-height cap rather than a fixed size contributor. This matches its current implementation (--cell-content-height+overflow: hidden), and the precedence ladder must be implemented in the shared media-layout CSS (split/bento) and validated by the engine — the body zone flexes to the remainder, media absorbs slack, the outer track clamps, and over-constrained cells warn.FramePresetDefinition.aspectgrows fromstringtostring | <container- keyed map>; the scalar form stays valid (Phase 1 compatible).- Docs updates:
runes/surfaces.md,runes/bg.md, theme-authoringconfig-api/surfaces, and the new compositions catalogue (the codegroup-in-card pattern is the canonical extent example). - Escape-hatch-free frames is now an explicit invariant to preserve: unlike
backgrounds, frames must not gain a raw-CSSstylefield.