docs: Browser Platform APIs design doc [Stage 1]#225
Conversation
7d24ac7 to
e08aecb
Compare
There was a problem hiding this comment.
DX Review -- Approve
Reviewer: josh (advocate)
Verdict: Approved
API Ergonomics
CSS Layer System (Section 1.1) -- Excellent. The developer-facing API is unchanged. css() calls remain identical; @layer wrapping is a compile-time concern invisible to the developer. The resolveLayer() function uses file path and API function as inputs, which means the developer never has to think about which layer their styles belong to. The layer assignment table (globalCss with resets -> vertz.reset, css() -> vertz.user, variants() -> vertz.components) is intuitive and matches the mental model of "framework styles < component library styles < my styles."
The LayerConfig type with parentLayer and layerOrder is well-structured for the escape hatch case -- if a developer needs to customize the parent layer name, the option is there, but the default (vertz) is correct for 99% of use cases.
Native CSS Nesting (Section 1.1) -- Clean. Output-only change. The developer writes the same css() input and gets smaller, more readable CSS output. The CSSExtractionOptions.nesting: boolean default of true is correct -- nesting is Baseline since December 2023. The only developers who would set this to false are those debugging CSS output, and having the option is appropriate.
Container Queries (Section 1.1) -- This is the highlight of the CSS section from a DX perspective. Using the existing object-form pattern ({ '@container (min-width: 400px)': ['p:8'] }) for container queries is an excellent decision. It follows the same pattern as { '&::after': ['content:empty'] }. One pattern for all nested CSS constructs. Developers who already know the object-form syntax get container queries for free.
The new container-type and container-name shorthands are good additions to the property map. container-type:inline-size reads naturally and is consistent with the existing shorthand vocabulary. The orphaned container query diagnostic is developer-friendly -- warning instead of error is the right call since the containment context may be in a parent component.
Router: Navigation API (Section 1.2) -- The NavigationBackend abstraction is the right architectural choice. The key DX observation: the public Router interface is completely unchanged. createRouter(routes, url) returns the same Router shape with current, loaderData, loaderError, searchParams, navigate, revalidate, dispose. This is a textbook "invisible improvement" -- better browser integration with zero learning curve.
The createRouter signature change to accept an optional third argument options?: RouterOptions is additive and non-breaking. Good.
Router: View Transitions (Section 1.2) -- The opt-in design via viewTransition: boolean | ViewTransitionConfig on both RouteConfig and RouterOptions is exactly right. The simplest case is viewTransition: true (a single boolean on a route). The advanced case allows a CSS class name for custom transition styles. This is progressive disclosure done well.
The prefers-reduced-motion automatic check in withViewTransition() is important and correctly placed -- developers do not need to remember to handle this themselves.
The vt-name shorthand for view-transition-name is practical. The full CSS property name (view-transition-name) is verbose for something developers will use frequently when animating shared elements between routes. vt-name:hero-image is concise and discoverable.
Primitives: Popover API (Section 1.3) -- The migration is entirely internal. PopoverOptions gains placement?: AnchorPlacement, which is additive. The existing defaultOpen and onOpenChange are preserved. The return type (PopoverElements & { state: PopoverState }) is unchanged. Developers using Popover.Root() today will continue to work without changes.
The AnchorPlacement type with 12 values (top/bottom/left/right + start/end variants) is comprehensive and uses the same naming convention developers know from Floating UI, Popper.js, and Radix. This is deliberate and good -- developers migrating from those libraries will find familiar placement names.
Primitives: Dialog modal vs non-modal (Section 1.3) -- Using native <dialog> with showModal() for modal and Popover API for non-modal is the correct technical decision, and it has a DX benefit: the modal path gets native ::backdrop, native focus trapping, and native Escape handling. The developer does not need to configure any of these -- they come from the platform.
SSR: Brotli Compression (Section 1.4) -- Zero API surface change for the common case. The negotiateEncoding() and compressStream() functions are exported for developers who need manual control, but the default path is automatic. The CompressionOptions with brotliQuality and minChunkSize are advanced knobs with sensible defaults (quality 4, 1 KB minimum). Most developers will never touch these.
Progressive Disclosure
The design doc demonstrates strong progressive disclosure across all features:
Layer 0 -- Zero configuration, invisible improvements:
@layerwrapping: automatic, correct ordering, no API change- Native CSS nesting: output-only change, smaller CSS
- Navigation API: transparent fallback, same
Routerinterface - Brotli compression: automatic content-encoding negotiation
Layer 1 -- Simple opt-in:
- View Transitions:
viewTransition: trueon a route or globally - Container queries: use existing object-form pattern with
@containerkey - Popover placement:
placement: 'bottom'(familiar name)
Layer 2 -- Full control:
- View Transitions:
ViewTransitionConfigwith CSS class for custom transitions +vt-nameshorthand for shared elements - Container queries: named containers via
container-nameshorthand - Layer configuration:
LayerConfigto customize parent layer name and ordering - Compression:
CompressionOptionsfor quality/chunk tuning
This is the correct structure. The simple case is genuinely simple. The complex case is possible without changing the simple case's API.
Developer Journey Alignment
The PRD's Developer Journey (written by josh during Discovery) described seven features through the lens of "what stays the same and what changes." The design doc faithfully translates this:
- "Same API, better output" for CSS features -- confirmed.
css(),globalCss(), andvariants()inputs are unchanged. - "Same
router.navigate(), better backend" for Navigation API -- confirmed.Routerinterface is identical. - "
placement: 'bottom'as before" for primitives -- confirmed.PopoverOptionsis additive only. - "Fully invisible" for Brotli -- confirmed. No new imports or configuration.
- "Opt-in via
viewTransition: true" for View Transitions -- confirmed. Single boolean on route config.
The PRD's mental model ("vertz gets out of the way -- the compiler optimizes toward native browser APIs") is reflected in every design decision. The design doc does not introduce any new concepts or mental overhead for developers who do not need the advanced features.
Concerns
Non-blocking -- vt-name shorthand discoverability:
The vt-name shorthand maps to view-transition-name, but unlike other shorthands (p -> padding, bg -> background-color), the vt- prefix is not immediately obvious to a developer encountering it for the first time. Consider whether the full property name view-transition-name should also work as a passthrough (via the raw value type or the object-form CSS property passthrough). The shorthand is good for frequent use, but the full name should also be recognized for discoverability. This is minor -- most developers will find vt-name via docs or autocomplete.
Non-blocking -- Popover placement default communication:
The PopoverOptions.placement defaults to 'bottom'. This is the right default (it is what Floating UI and Radix default to). But the design doc does not specify what happens when the developer does NOT pass placement and the popover is at the bottom of the viewport. With CSS Anchor Positioning, the browser handles overflow/flip automatically via position-try-fallbacks (or position-try-options in older spec drafts). The design doc should clarify whether automatic flip behavior is included in the anchor positioning setup, or whether it only applies the static position-area value. Without auto-flip, popovers at viewport edges will be clipped. This is an implementation detail that should be noted in the design doc for the implementing engineer.
Non-blocking -- compressStream unreachable code path:
In Section 1.4, the compressStream function has an unreachable code path: the encoding === 'br' branch first checks for CompressionStream API support and constructs a compressed variable using ChunkBufferTransform + CompressionStream('deflate'), but then ignores that variable and calls createBrotliStream() regardless. The comment acknowledges this ("CompressionStream does not support Brotli in all runtimes") but the code as written constructs an unused stream. This should be cleaned up during implementation -- the CompressionStream branch should only be used for gzip, and Brotli should always use createBrotliStream(). This is a design doc artifact, not a blocking concern.
Non-blocking -- Fallback positioning timing:
In the migrated Popover primitive (Section 1.3), the fallback positioning uses a dynamic import: void import('../utils/fallback-positioning').then(...). This means there is a brief moment between when the popover opens and when it gets positioned. During this window, the popover is visible but unpositioned (it has content.hidden = false or showPopover() has been called). For the native Popover API path (non-anchored), this would show the popover at its default position (top-left of viewport or wherever the element naturally renders) before the fallback positioning kicks in. Consider deferring the popover visibility until after the fallback position is calculated, or pre-loading the fallback module when anchor positioning is not detected (so it is available synchronously when the popover opens). This is a polish concern for implementation.
Non-blocking -- ViewTransitionConfig type ergonomics:
The ViewTransitionConfig.enabled field accepts boolean | string. When true, it is a cross-fade. When a string, it is a CSS class. This is concise but slightly surprising -- usually enabled is a boolean. Consider whether viewTransition: string at the route level (shorthand for "enabled with this class") would be cleaner than nesting inside ViewTransitionConfig.enabled. Looking more closely, RouteConfig.viewTransition already accepts boolean | ViewTransitionConfig, so viewTransition: 'slide-in' does not work -- you would need viewTransition: { enabled: 'slide-in' }. Since the most common custom case is providing a class name, it might be worth adding string to the union at the route level: viewTransition?: boolean | string | ViewTransitionConfig. This collapses the most common custom case from { enabled: 'slide-in' } to just 'slide-in'. Low priority, but worth considering during implementation.
Verdict
Approved. This is a well-designed feature set that maintains API stability while adopting modern browser capabilities. The core principle -- "same developer API, better browser integration" -- is consistent throughout every feature. Progressive disclosure is strong: the simplest cases (no new API to learn), the opt-in cases (single boolean), and the advanced cases (typed configuration objects) are all well-separated.
The non-blocking concerns above are implementation-level polish items, not design-level issues. None of them require a redesign. The architecture decisions (NavigationBackend abstraction, nested @layer strategy, Popover API for non-modal + native for modal, opt-in View Transitions, container queries via existing object-form) are all sound and well-justified.
The PRD's developer journey is faithfully reflected in the design. The prior art analysis (Radix, TanStack, Tailwind, Astro, SvelteKit) is thorough and the design makes defensible choices that either match or improve on prior art.
Ship it.
There was a problem hiding this comment.
Technical Feasibility Review -- Approve
Reviewer: nora (frontend engineer, packages/ui/ owner)
Verdict: Approved with non-blocking observations
Codebase Alignment
File paths are correct. I verified every new and modified file path against the current codebase:
packages/ui-compiler/src/css-extraction/layers.ts-- new file, sits alongside the existingextractor.ts,code-splitting.ts,dead-css.ts,hmr.ts,route-css-manifest.ts. Clean fit. The barrel export atcss-extraction/index.tsis a simple addition.packages/ui-compiler/src/build/brotli-precompress.ts-- newbuild/directory underui-compiler/src/. This directory does not exist yet. This is fine; it is a logical grouping for build-time-only tools. The design should note that this is a new directory.packages/ui/src/router/navigation-backend.tsandview-transitions.ts-- new files in the existingrouter/directory, alongsidenavigate.ts,define-routes.ts,link.ts, etc. The barrel export atrouter/index.tscurrently exports from all router modules. Clean additions.packages/primitives/src/utils/popover.ts,anchor.ts,fallback-positioning.ts-- new files in the existingutils/directory alongsidearia.ts,focus.ts,id.ts,keyboard.ts. The existingutils.tsbarrel re-exports from allutils/modules. Straightforward.packages/ui-server/src/compression.ts-- new file alongsidecritical-css.ts,render-to-stream.ts, etc. The barrel atui-server/src/index.tswould export the new compression utilities. Clean fit.
Module boundaries are respected. The design correctly identifies:
- Compiler-only code stays in
@vertz/ui-compiler - Runtime code stays in
@vertz/ui - Primitives in
@vertz/primitives - SSR in
@vertz/ui-server
One minor path correction: The design references packages/ui-compiler/src/css-extraction/__tests__/layers.test.ts for the layer tests, which follows the existing test co-location pattern (__tests__/css-extraction.test.ts already exists there). Confirmed correct.
Type System Integration
CSSExtractionResult extension is backward-compatible. The design adds a layer: CSSLayer field to the existing CSSExtractionResult interface (currently only css: string and blockNames: string[]). This is an additive change. However, every consumer of CSSExtractionResult needs updating:
CSSCodeSplitter.split()receivesMap<string, CSSExtractionResult>-- it currently reads.cssonly, so the new.layerfield is ignored. But the code-splitting logic will need to wrap each chunk in its@layerblock. The design does not explicitly show this integration. Non-blocking concern #1: The code-splitting module needs to be updated to wrap per-route CSS chunks in their respective@layerblocks. The common chunk and per-route chunks must each include the correct layer wrapping. The design showswrapInLayer()but does not show where in the code-splitting pipeline it is called.
RouteConfig generic parameters are preserved. The design adds viewTransition?: boolean | ViewTransitionConfig to RouteConfig<TPath, TLoaderData, TSearch>. This is a new optional field on an existing generic interface. No generic parameter changes. The type test in router.test-d.ts should verify both the positive case (viewTransition: true is accepted) and the negative case (@ts-expect-error viewTransition: 42). The design includes both. Correct.
RouterOptions is a new non-generic type. The createRouter function signature changes from (routes, url) to (routes, url, options?). The third parameter is optional, so existing calls are unaffected. This is a clean backward-compatible extension.
NavigationBackend is fully internal. Not exported from @vertz/ui. The Router interface is unchanged. The NavigateOptions type is unchanged. No public type surface changes for the router beyond adding ViewTransitionConfig, RouterOptions, and the viewTransition field on RouteConfig. All three are additive.
ViewTransitionConfig type is concrete (not generic). No type flow complexity. The union boolean | ViewTransitionConfig in RouteConfig.viewTransition and RouterOptions.viewTransition is straightforward.
Popover API types are internal to primitives. PopoverOptions gains placement?: AnchorPlacement. The AnchorPlacement union type is a string literal union -- no generics. The existing PopoverElements and PopoverState interfaces are unchanged. The PopoverOptions extension is additive (optional field).
CompressionEncoding is a string literal union. negotiateEncoding() returns CompressionEncoding. compressStream() accepts it. No generics, no complex type flows.
The container-type value type needs adding to the token-resolver's PropertyMapping.valueType union. Currently valueType is typed as a string literal union in the runtime token-resolver.ts:
valueType: 'spacing' | 'color' | 'radius' | 'shadow' | 'size' | 'display' | 'alignment' | 'font-size' | 'font-weight' | 'line-height' | 'ring' | 'content' | 'raw';The design proposes 'container-type' as a new value type. This needs adding to this union. The compiler-side PROPERTY_MAP in css-transformer.ts uses valueType: string (not a union), so it is less constrained. Confirmed: the design accounts for this with the CONTAINER_TYPE_MAP resolution, but the runtime token-resolver.ts needs the union updated. Feasible.
Implementation Feasibility
Sub-phase 1: CSS Compiler (1.5 weeks) -- Feasible, estimate is accurate.
The existing CSSExtractor.extract() currently produces flat CSS rules via buildCSSRules(). Converting this to nested output is a targeted refactor of the buildCSSRules function -- the data (base declarations, pseudo declarations, nested rules) is already separated in the function body. The design's buildNestedCSSRule function correctly mirrors the existing data structure. The layer system is entirely new code in a new file with no existing code to modify (only the extraction pipeline integration point). 1.5 weeks is reasonable for 7 tasks.
The @container key handling in css.ts is notable. Currently, the runtime css() function processes object-form entries by replacing & in the selector with .${className}. For @container keys, there is no & to replace -- the container query wraps the class-based selector. The design does not explicitly show the runtime css() modification, only the compiler-side. Non-blocking concern #2: The runtime css() function in packages/ui/src/css/css.ts (line 98: const resolvedSelector = selector.replace('&', '.${className}')) needs special-casing for @container keys. When the key starts with @container, the output should be @container (...) { .className { ... } } rather than trying to replace &. The design shows the compiler output correctly but should specify the runtime behavior too, since css() has a runtime path used in dev/test/SSR.
The compiler-side css-transformer.ts has the same issue at line 228: const resolvedSelector = entry.selector.replace('&', '.${className}'). Both the runtime and compiler paths need the @container special case.
Sub-phase 2: Router (1.5 weeks) -- Feasible, but the POC is a real blocker.
The NavigationBackend abstraction is a straightforward extract-and-abstract refactor. The current createRouter in navigate.ts does exactly three things with the History API: pushState, replaceState, and addEventListener('popstate'). These map directly to the backend's push, replace, and onNavigate methods.
The signal reactivity + View Transitions POC (Unknown 4.1) is correctly identified as a P0 blocker. Looking at the scheduler (runtime/scheduler.ts), signal updates for effects use queueMicrotask only when not batching. Outside a batch, scheduleNotify calls subscriber._notify() synchronously. Inside batch(), effects queue and flush synchronously when the outermost batch() completes. The applyNavigation function sets current.value = match which triggers synchronous computed propagation and (if no batch) immediate effect execution. This means the DOM update triggered by current.value assignment should complete synchronously within the startViewTransition callback -- provided no intermediate queueMicrotask is used in the rendering path.
My assessment: The POC will likely show that the signal update works correctly in the common case (no batching). However, if any part of the rendering pipeline uses queueMicrotask or requestAnimationFrame (e.g., the focusFirst call in some components), those deferred operations would escape the view transition callback. The POC should specifically test with components that have effects using queueMicrotask. 1.5 weeks is reasonable assuming the POC resolves favorably (no flush() mechanism needed).
Sub-phase 3: Primitives + SSR (2-2.5 weeks) -- Feasible but tight.
Six component migrations plus the anchor positioning fallback plus Brotli compression in 2-2.5 weeks. The primitives migrations follow a consistent pattern (add popover attribute, wire up toggle event, conditional anchor positioning), so after the first one (Popover) is done, the remaining five should go faster. The design correctly identifies that Brotli work is isolated to @vertz/ui-server and can run in parallel.
The Dialog migration is the most complex of the six because it has a branching path (modal via <dialog> vs non-modal via Popover API). The design correctly identifies this.
The Tooltip migration has a subtle issue: the design says Tooltip uses popover with manual type. This is correct (tooltips should not light-dismiss on click outside), but the popover="manual" elements do not participate in the popover stack nesting. If a tooltip is inside a popover (e.g., a help icon in a dropdown menu), the tooltip opening will not affect the parent popover's dismiss behavior. This is the correct behavior but should be tested.
2-2.5 weeks is tight for this scope. I would budget closer to 2.5 weeks.
Concerns
All non-blocking:
-
Code-splitting +
@layerintegration gap. TheCSSCodeSplitterproduces per-route CSS chunks by concatenatingCSSExtractionResult.cssstrings. With the new layer system, each result has a.layerfield, but the code-splitter does not know about layers yet. During implementation, the code-splitter needs to wrap each chunk's CSS in the appropriate@layerblock before concatenation. This is not shown in the design. Should be addressed in the implementation plan, not the design doc. -
Runtime
css()needs@containerspecial-casing. The design focuses on the compiler-side@containerhandling but the runtimecss()function (used in dev, test, and SSR fallback) also processes object-form entries and currently assumes all keys are CSS selectors containing&. A@containerkey does not contain&-- it is an at-rule that wraps the class selector. The runtime path needs: if key starts with@, emit@container (...) { .className { declarations } }. Straightforward to implement but should be noted in the design. -
container-typeshorthand parsing ambiguity. The shorthandcontainer-type:inline-sizeparses asproperty=container-type,value=inline-sizewith the current shorthand parser (split by:). However, the shorthand parser inshorthand-parser.tssplits on:and the first segmentcontainer-typecontains a-, which is fine -- the parser does not restrict property names to alphanumeric. But the shorthandcontainer-type:inline-sizehas a total of two:segments (container-typeandinline-size), which correctly parses asproperty:value. No issue. I verified the parser handles hyphenated property names. -
Duplicated property maps. The design adds
container-type,container-name, andvt-nameto the property map. Currently there are THREE copies of the property map:token-resolver.ts(runtime),extractor.ts(compiler extraction), andcss-transformer.ts(compiler transformation). All three must be updated in sync. The design mentions the runtime and compiler maps but does not explicitly call out thatextractor.tshas its own copy. This is an existing tech debt issue, not introduced by this design, but worth noting. During implementation, I would add all three shorthands to all three maps. -
compressStreamBrotli implementation. The design showsCompressionStreambeing used for gzip but notes thatCompressionStreamdoes not support Brotli in all runtimes. The Brotli path falls through tocreateBrotliStream()which uses Node.jszlib. This is correct for a server-side module (@vertz/ui-server). However, the dead code incompressStreamwhere it creates aCompressionStream('deflate')on line 1174 and then immediately falls through tocreateBrotliStreamon line 1178 is confusing. During implementation, this should be cleaned up -- theCompressionStreamcheck for Brotli should be removed since there is no standard'br'format string forCompressionStream. -
@layerorder in SSR with code-split CSS. The design mentions (Unknown 4.3) that the layer order declaration should appear exactly once in the first<style>block. The currentinlineCriticalCss()function incritical-css.tsis minimal -- it wraps CSS in<style>tags. Prepending the layer order is straightforward (generateLayerOrder() + '\n' + css). But with code-split CSS, subsequent<link>stylesheets also need their rules inside@layer vertz.X { ... }blocks. This means the code-splitting pipeline must emit layered CSS (concern #1 above). As long as both are addressed, this works correctly.
Missing from File Structure
packages/ui-compiler/src/build/directory does not exist yet. Needs creating. (Already noted.)- No
packages/ui/src/css/__tests__/token-resolver.test.tsexists today. The design notes this needs adding. Confirmed -- this file is missing in the codebase. The token resolver has been tested indirectly throughcss.test.tsbut a dedicated test file is needed for the new shorthands. - The design references
packages/ui-compiler/src/css-extraction/hmr.tsas unchanged. Confirmed -- HMR does not need modification for layers or nesting since it delegates toCSSExtractor.
Verdict
Approved. The design is thorough, the file paths align with the codebase, the type system extensions are backward-compatible, and the architecture decisions are sound. The NavigationBackend abstraction is the right level of indirection -- it matches the existing data-driven pattern in the router and enables clean testing. The layer system with nested @layer vertz { ... } is the correct approach to avoid conflicts with third-party CSS frameworks.
The View Transitions + signal reactivity POC (Unknown 4.1) is correctly identified as a P0 blocker for Sub-phase 2. Based on my reading of the scheduler, I expect the POC to succeed without needing a flush() mechanism, but I support running the POC before committing to the design.
The six non-blocking observations above should be addressed during implementation planning (Stage 2), not in the design doc itself. They are implementation details, not design issues.
Estimates: Sub-phases 1 (1.5 weeks) and 2 (1.5 weeks) are accurate. Sub-phase 3 should be budgeted at 2.5 weeks rather than 2 to account for the six-component migration surface area plus the Dialog branching complexity.
Scope Review — ApproveReviewer: pm (product manager) — posted via personal account (GITHUB_PM not yet configured in Doppler) PRD Goal Coverage
Scope Alignment
Non-Blocking Concerns
VerdictApproved. Faithful translation of PRD into technical specification. Ready for implementation after nora's technical feasibility sign-off. |
e08aecb to
bf68128
Compare
7d24ac7
bf68128 to
7d24ac7
Compare
ff72e56 to
ebb85da
Compare
Design doc for the Browser Platform APIs feature covering: - CSS compiler: @layer, native nesting, container queries - Router: Navigation API, View Transitions - Primitives: Popover API + CSS Anchor Positioning for 6 components - SSR: Brotli streaming compression PRD: plans/prds/browser-platform-apis.md (backstage PR #14) Includes: - Concrete TypeScript types and function signatures - Architecture decisions table (10 decisions) - 3 sub-phase breakdown with integration tests - E2E acceptance test - Unknowns: View Transitions spike (blocks Sub-phase 2)
ebb85da to
5d02fae
Compare
Design doc for the Browser Platform APIs feature covering: - CSS compiler: @layer, native nesting, container queries - Router: Navigation API, View Transitions - Primitives: Popover API + CSS Anchor Positioning for 6 components - SSR: Brotli streaming compression PRD: plans/prds/browser-platform-apis.md (backstage PR #14) Includes: - Concrete TypeScript types and function signatures - Architecture decisions table (10 decisions) - 3 sub-phase breakdown with integration tests - E2E acceptance test - Unknowns: View Transitions spike (blocks Sub-phase 2) Co-authored-by: vertz-tech-lead[bot] <2828099+vertz-tech-lead[bot]@users.noreply.github.com>
Design Doc: Browser Platform APIs
Stage 1 design doc for the Browser Platform APIs feature. Implements modern browser APIs in vertz's compiler-driven UI framework.
PRD:
plans/prds/browser-platform-apis.md(approved, backstage PR #14)Features Covered
@layercascade control, native CSS nesting output,@containerqueries incss()Key Design Decisions
NavigationBackendabstraction -- Clean separation between router logic and browser API. Testable, swappable.@layer vertz { ... }-- Isolates vertz cascade from Tailwind/third-party layers.@supports+ dynamic import for anchor positioning fallback -- Zero JS in supporting browsers.<dialog>for modal, Popover API for non-modal -- Right API for each use case.Phase Breakdown
Unknowns
startViewTransition()callback.Reviewers Needed